diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index dd40e18..4042795 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response from sqlalchemy import Select, case, or_, select +from app.actor import Actor from app.dependencies import ActorDep, DbDep, UserIdDep from app.models import CoinEvent, Project, Tag, Task, utcnow from app.realtime import publish @@ -17,7 +18,7 @@ TaskOut, TaskUpdate, ) -from app.services import tasklog +from app.services import claims, tasklog from app.services.closing import handle_task_closed from app.services.detailing import apply_proposal, detail_task from app.services.options import pick_options @@ -61,6 +62,19 @@ return task +def _mandate(actor: Actor, task: Task, *, sets_eligible: bool = False) -> None: + """Рамка мандата (ТЗ 3.20): агент — 403, если вышел за неё. + + sets_eligible — запрос меняет флаг доступности: его ставит только владелец. + """ + try: + if sets_eligible: + claims.ensure_owner_sets_eligible(actor) + claims.ensure_agent_can_mutate(task, actor) + except claims.MandateError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from None + + def _archived_project_ids(db: Any, user_id: str) -> Select[tuple[int]]: """id проектов в архиве — их задачи в рабочих видах не показываются.""" return select(Project.id).where(Project.is_archived.is_(True), Project.user_id == user_id) @@ -144,6 +158,10 @@ None, ge=0, le=10, description="Нижняя граница приоритета (шкала 0-10)" ), parent_id: int | None = Query(None), + # Мандат агента (ТЗ 3.20): «доступно агенту», «создано мной / агентом», приёмка + ai_eligible: bool | None = Query(None), + created_by_kind: str | None = Query(None, pattern="^(user|agent)$"), + accept_state: str | None = Query(None, pattern="^(pending|accepted|rejected)$"), include_archived: bool = Query(False), ) -> list[Task]: # Завершённые задачи сортируются по дате закрытия, остальные — по добавлению @@ -169,6 +187,12 @@ ) if parent_id is not None: stmt = stmt.where(Task.parent_task_id == parent_id) + if ai_eligible is not None: + stmt = stmt.where(Task.ai_eligible.is_(ai_eligible)) + if created_by_kind: + stmt = stmt.where(Task.created_by_kind == created_by_kind) + if accept_state: + stmt = stmt.where(Task.accept_state == accept_state) if tag_id: stmt = stmt.where(Task.tags.any(Tag.id == tag_id)) # Поиск по подстроке имени тега (ТЗ 3.6, 0.70): ввод в списке задач не выбирает @@ -219,6 +243,9 @@ task = _get_task_for_update(db, task_id, user_id) data = schema.model_dump(exclude_unset=True) old_status, old_eligible = task.status, task.ai_eligible + # Мандат (ТЗ 3.20): без флага доступности агент задачу не меняет, а сам флаг + # ставит только владелец — иначе «пометил и закрыл» обходило бы правило + _mandate(actor, task, sets_eligible="ai_eligible" in data) if "title" in data and not str(data["title"]).strip(): raise HTTPException(status_code=422, detail="title cannot be empty") diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 0b85f5a..c3b715e 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -31,13 +31,20 @@ normalize_repository_url, normalize_site_url, ) -from app.services import tasklog +from app.services import claims, tasklog from app.services.closing import handle_task_closed from app.services.detailing import detail_task, summarize_project from app.services.xp import coins_for_create, grant_create_xp VALID_STATUSES = {"to_do", "in_progress", "done", "cancelled", "deferred"} +# Подсказка к отказу по мандату (ТЗ 3.20): маленькая модель должна понять, что +# делать дальше — попросить владельца пометить задачу или действовать за него +_MANDATE_HINT = ( + " — ask the owner to mark the task as available for AI agents " + "(ai_eligible), or pass is_user=true if you act for the owner" +) + def _mcp_user(ctx: Context[Any, Any, Any] | None) -> str: """user_id владельца токена (кладёт McpAuthMiddleware).""" @@ -161,6 +168,14 @@ "recur_interval_days": task.recur_interval_days, "recur_weekdays": task.recur_weekdays, "recur_day_of_month": task.recur_day_of_month, + # Мандат агента (ТЗ 3.20): по ai_eligible агент решает, можно ли трогать + # задачу, accept_state показывает, ждёт ли работа владельца, claimed_by — + # занята ли она другим агентом (протухшая аренда наружу не показывается) + "ai_eligible": task.ai_eligible, + "created_by_kind": task.created_by_kind, + "created_by_name": task.created_by_name, + "accept_state": task.accept_state, + "claimed_by": task.claimed_by, } @@ -427,6 +442,15 @@ tag_ids: Annotated[ list[int] | None, Field(description="Полный новый список id тегов (из list_tags)") ] = None, + ai_eligible: Annotated[ + bool | None, + Field( + description=( + "Доступность задачи ИИ-агенту: true — разрешить агентам брать её " + "в работу, false — запретить. Ставит только владелец задачи" + ) + ), + ] = None, is_user: Annotated[ bool, Field(description="true — действую от имени владельца, а не как агент") ] = False, @@ -467,6 +491,15 @@ f"Task {task_id} not found — find the right id with list_tasks(query=...)." ) old_status = task.status + old_eligible = task.ai_eligible + # Мандат (ТЗ 3.20): без флага доступности агент задачу не меняет, а сам флаг + # ставит только владелец; владельцу рамка не мешает + try: + if ai_eligible is not None: + claims.ensure_owner_sets_eligible(actor) + claims.ensure_agent_can_mutate(task, actor) + except claims.MandateError as exc: + raise ValueError(f"{exc}{_MANDATE_HINT}") from None if title is not None: if not title.strip(): raise ValueError("title cannot be empty") @@ -529,6 +562,8 @@ if len(tags) != len(set(tag_ids)): raise ValueError("Unknown tag id in tag_ids") task.tags = tags + if ai_eligible is not None: + task.ai_eligible = ai_eligible # Закрытие — переход в done (а не «уже закрытая»): тот же общий путь # наград, что и в HTTP-API — спавн регулярной (один раз, spawned_at), # XP/монеты/растение. Выход из done сбрасывает done_at. @@ -550,6 +585,10 @@ actor, tasklog.status_transition(old_status, task.status), ) + if ai_eligible is not None and task.ai_eligible != old_eligible: + tasklog.log_event( + session, task, tasklog.KIND_ELIGIBLE, actor, "on" if task.ai_eligible else "off" + ) session.commit() publish(user_id, "task.changed", {"id": task.id, "source": "mcp"}) if outcome is not None and outcome.event is not None: @@ -618,6 +657,16 @@ query: Annotated[ str | None, Field(description="Подстрока по заголовку и описанию задачи") ] = None, + ai_eligible: Annotated[ + bool | None, Field(description="Фильтр: true — задачи, доступные ИИ-агенту") + ] = None, + created_by_kind: Annotated[ + str | None, Field(description="Фильтр: user (создал владелец) | agent (создал агент)") + ] = None, + accept_state: Annotated[ + str | None, + Field(description="Фильтр приёмки: pending (ждёт владельца) | accepted | rejected"), + ] = None, limit: Annotated[ int, Field(description="Максимум задач в ответе (по умолчанию 50, до 200)") ] = 50, @@ -627,6 +676,7 @@ Каждая задача в ответе: id, title, status, project, tags и сроки. Работайте с найденной задачей по её id (update_task / get_task / complete_task). + Отдельный список задач, доступных агенту, даёт list_available_tasks. """ user_id = _mcp_user(ctx) session = get_session_factory()() @@ -647,6 +697,12 @@ stmt = stmt.where(Task.detail_state == detail_state) if tag_id: stmt = stmt.where(Task.tags.any(and_(Tag.id == tag_id, Tag.user_id == user_id))) + if ai_eligible is not None: + stmt = stmt.where(Task.ai_eligible.is_(ai_eligible)) + if created_by_kind: + stmt = stmt.where(Task.created_by_kind == created_by_kind) + if accept_state: + stmt = stmt.where(Task.accept_state == accept_state) if query: pattern = f"%{query}%" # Описание живёт в документе — ищем join'ом по полиморфной привязке diff --git a/backend/app/services/claims.py b/backend/app/services/claims.py new file mode 100644 index 0000000..2e4397d --- /dev/null +++ b/backend/app/services/claims.py @@ -0,0 +1,37 @@ +"""Мандат ИИ-агента: что агенту можно и как он берёт задачу в работу (ТЗ 3.20). + +Без флага `ai_eligible` агент задачу не меняет — только читает. Это не охрана: +держатель токена и так действует как владелец (3.20), а рамка нужна, чтобы агент не +трогал то, что владелец ему не поручал. + +Ошибки — доменным исключением: REST отдаёт по нему 403, MCP — текст с подсказкой +следующего шага (стиль 3.10), а правила остаются одними для обоих путей. +""" + +from datetime import timedelta + +from app.actor import Actor +from app.models import Task + +# Аренда (взятие в работу) живёт два часа: агент продлевает её повторным claim, +# пропал — задача сама вернулась в свободные (ленивое протухание, без крона) +CLAIM_TTL = timedelta(hours=2) + + +class MandateError(Exception): + """Действие агента вне мандата: владелец задачу ему не поручал.""" + + +def ensure_agent_can_mutate(task: Task, actor: Actor) -> None: + """Агент меняет только задачи с флагом доступности; владелец — любые.""" + if actor.is_agent and not task.ai_eligible: + raise MandateError(f"Task {task.id} is not available to an AI agent") + + +def ensure_owner_sets_eligible(actor: Actor) -> None: + """Флаг доступности ставит владелец: агент не выдаёт себе мандат задним числом. + + Своя задача — другое дело: при создании агент вправе попросить мандат (3.20). + """ + if actor.is_agent: + raise MandateError("ai_eligible is set by the owner") diff --git a/backend/tests/test_agent_mandate.py b/backend/tests/test_agent_mandate.py new file mode 100644 index 0000000..c33ad42 --- /dev/null +++ b/backend/tests/test_agent_mandate.py @@ -0,0 +1,174 @@ +"""Тесты мандата ИИ-агента: доступность задачи, гейт агента (ТЗ 3.20).""" + +from types import SimpleNamespace +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from app import mcp_server +from app.actor import VIA_API, agent_actor +from app.dependencies import actor_of, require_actor, require_user +from tests.conftest import agent_ctx, mcp_ctx # type: ignore[attr-defined] + + +def _create(client: TestClient, **payload: Any) -> dict[str, Any]: + resp = client.post("/api/tasks", json={"title": "задача", **payload}) + assert resp.status_code == 200, resp.text + got = client.get(f"/api/tasks/{resp.json()['id']}") + assert got.status_code == 200, got.text + return got.json() + + +def _as_agent(client: TestClient, name: str = "Нави") -> Any: + """Действие REST — агентское: подменяем актора, авторизация остаётся. + + Заголовок `X-Actor` разбирает actor_of() по пути; здесь подменён именно актор, + поэтому правило заголовка проверяется отдельным тестом. + """ + overrides = client.app.dependency_overrides # type: ignore[attr-defined] + saved = overrides.get(require_actor) + overrides[require_actor] = lambda: agent_actor(name, 7, "Владелец", VIA_API) + return saved + + +def _restore_agent(client: TestClient, saved: Any) -> None: + overrides = client.app.dependency_overrides # type: ignore[attr-defined] + if saved is None: + overrides.pop(require_actor, None) + else: + overrides[require_actor] = saved + + +def test_flag_defaults_off(client: TestClient) -> None: + task = _create(client) + assert task["ai_eligible"] is False + assert task["accept_state"] is None + assert task["claimed_by"] is None + + +def test_owner_sets_flag_by_patch(client: TestClient) -> None: + task = _create(client) + resp = client.patch(f"/api/tasks/{task['id']}", json={"ai_eligible": True}) + assert resp.status_code == 200, resp.text + assert resp.json()["ai_eligible"] is True + + +def test_agent_needs_flag(client: TestClient) -> None: + task = _create(client) + flagged = _create(client, ai_eligible=True) + saved = _as_agent(client) + try: + denied = client.patch(f"/api/tasks/{task['id']}", json={"priority": 5}) + assert denied.status_code == 403, denied.text + allowed = client.patch(f"/api/tasks/{flagged['id']}", json={"priority": 5}) + assert allowed.status_code == 200, allowed.text + finally: + _restore_agent(client, saved) + + +def test_owner_is_not_limited(client: TestClient) -> None: + """Владелец (сессия SSO) правит любую задачу — флаг для него не преграда.""" + task = _create(client) + assert client.patch(f"/api/tasks/{task['id']}", json={"priority": 5}).status_code == 200 + + +def test_agent_cannot_grant_itself_the_flag(client: TestClient) -> None: + flagged = _create(client, ai_eligible=True) + saved = _as_agent(client) + try: + # Ни на помеченной задаче, ни с объявлением «действую как владелец» + denied = client.patch(f"/api/tasks/{flagged['id']}", json={"ai_eligible": False}) + assert denied.status_code == 403, denied.text + denied2 = client.patch( + f"/api/tasks/{flagged['id']}", + json={"ai_eligible": False}, + headers={"X-Actor": "user"}, + ) + assert denied2.status_code == 403, denied2.text + finally: + _restore_agent(client, saved) + + +def test_agent_may_create_flagged_task(client: TestClient) -> None: + """Своя задача — другое дело: при создании агент вправе попросить мандат.""" + saved = _as_agent(client) + try: + task = _create(client, ai_eligible=True) + assert task["ai_eligible"] is True + assert task["created_by_kind"] == "agent" + assert task["created_by_name"] == "Нави" + finally: + _restore_agent(client, saved) + + +def test_list_filters_by_flag_and_author(client: TestClient) -> None: + flagged = _create(client, ai_eligible=True) + plain = _create(client) + assert [t["id"] for t in client.get("/api/tasks", params={"ai_eligible": True}).json()] == [ + flagged["id"] + ] + owners = [t["id"] for t in client.get("/api/tasks", params={"created_by_kind": "user"}).json()] + assert set(owners) == {flagged["id"], plain["id"]} + assert client.get("/api/tasks", params={"created_by_kind": "agent"}).json() == [] + + +def test_bearer_token_is_agent_until_declared(client: TestClient) -> None: + """REST с Bearer: по умолчанию агент, `X-Actor: user` объявляет действие владельца.""" + token = client.post("/api/mcp-tokens", json={"label": "Нави"}).json()["token"] + plain = _create(client) + saved = client.app.dependency_overrides.pop(require_user, None) # type: ignore[attr-defined] + auth = {"Authorization": f"Bearer {token}"} + try: + denied = client.patch(f"/api/tasks/{plain['id']}", json={"priority": 5}, headers=auth) + assert denied.status_code == 403, denied.text + allowed = client.patch( + f"/api/tasks/{plain['id']}", + json={"priority": 5}, + headers={**auth, "X-Actor": "user"}, + ) + assert allowed.status_code == 200, allowed.text + # Создано агентом (расширение объявило бы себя владельцем) — виден актор + mine = client.post("/api/tasks", json={"title": "от агента"}, headers=auth).json() + got = client.get(f"/api/tasks/{mine['id']}", headers=auth).json() + assert (got["created_by_kind"], got["created_by_name"]) == ("agent", "Нави") + finally: + if saved is not None: + client.app.dependency_overrides[require_user] = saved # type: ignore[attr-defined] + + +def test_x_actor_header_rule() -> None: + """Правило actor_of: агент за заголовком не читается, «user» делает его владельцем.""" + state = SimpleNamespace(gntodo_actor=agent_actor("Нави", 7, "Владелец", VIA_API)) + assert actor_of(SimpleNamespace(state=state, headers={})).is_agent + declared = actor_of(SimpleNamespace(state=state, headers={"x-actor": "user"})) + assert (declared.kind, declared.name, declared.via) == ("user", "Владелец", "api") + + +def test_mcp_tool_refuses_unflagged_task(client: TestClient) -> None: + ctx = agent_ctx(label="Нави") + plain = mcp_server.create_task("без флага", ctx=ctx) + with pytest.raises(ValueError, match="not available to an AI agent"): + mcp_server.update_task(plain["id"], priority=5, ctx=ctx) + # ... и то же самое с is_user=true проходит: так действует владелец + assert mcp_server.update_task(plain["id"], priority=5, is_user=True, ctx=ctx)["priority"] == 5 + + +def test_mcp_flag_is_owner_only(client: TestClient) -> None: + ctx = agent_ctx(label="Нави") + task = mcp_server.create_task("своя", ai_eligible=True, ctx=ctx) + with pytest.raises(ValueError, match="ai_eligible is set by the owner"): + mcp_server.update_task(task["id"], ai_eligible=False, ctx=ctx) + # Владелец (is_user=true) флаг снять вправе + closed = mcp_server.update_task(task["id"], ai_eligible=False, is_user=True, ctx=ctx) + assert closed["ai_eligible"] is False + + +def test_mcp_compacts_expose_mandate(client: TestClient) -> None: + ctx = mcp_ctx() + created = mcp_server.create_task("задача", ai_eligible=True, ctx=ctx) + full = mcp_server.get_task(created["id"], ctx=ctx) + assert full["ai_eligible"] is True + assert full["created_by_kind"] == "user" + assert full["accept_state"] is None + assert full["claimed_by"] is None diff --git a/backend/tests/test_task_events.py b/backend/tests/test_task_events.py index 75c7814..07fd0bc 100644 --- a/backend/tests/test_task_events.py +++ b/backend/tests/test_task_events.py @@ -98,7 +98,7 @@ def test_mcp_agent_actions_are_marked(client: TestClient) -> None: """Действия агента отличимы от действий владельца: актор и канал.""" ctx = agent_ctx(label="Нави") - created = mcp_server.create_task("от агента", ctx=ctx) + created = mcp_server.create_task("от агента", ai_eligible=True, ctx=ctx) mcp_server.update_task(created["id"], status="in_progress", ctx=ctx) rows = _events(client)["items"]