diff --git a/tests/notes/test_notes_tools.py b/tests/notes/test_notes_tools.py index a2cfc8059..9fe3372a5 100644 --- a/tests/notes/test_notes_tools.py +++ b/tests/notes/test_notes_tools.py @@ -11,10 +11,8 @@ from tinyagentos.tools.notes_tools import ( execute_notes_add_entry, execute_notes_list_shared_docs, - execute_notes_set_done, ) - # --------------------------------------------------------------------- helpers def _make_request(store, config=None, msg_store=None): @@ -201,118 +199,3 @@ async def test_list_shared_docs_excludes_internal_fields(store): keys = set(res["docs"][0].keys()) assert "owner_user_id" not in keys assert keys <= {"id", "kind", "title", "updated_at"} - - -# -------------------------------------------------------------- set_done tests - -@pytest.mark.asyncio -async def test_agent_member_can_mark_task_done(store): - doc = await store.create_doc("user-1", "list", "Build List") - await store.add_member(doc["id"], "agent", "atlas") - entry = await store.add_entry(doc["id"], "Ship the feature", author="user-1") - - req = _make_request(store) - res = await execute_notes_set_done( - {"agent_name": "atlas", "doc_id": doc["id"], "entry_id": entry["id"], "done": True}, - req, - ) - assert res.get("ok") is True - assert res["done"] is True - - entries = await store.list_entries(doc["id"]) - target = next(e for e in entries if e["id"] == entry["id"]) - assert target["done"] is True - - # And it can be reopened. - res = await execute_notes_set_done( - {"agent_name": "atlas", "doc_id": doc["id"], "entry_id": entry["id"], "done": False}, - req, - ) - assert res.get("ok") is True - entries = await store.list_entries(doc["id"]) - target = next(e for e in entries if e["id"] == entry["id"]) - assert target["done"] is False - - -@pytest.mark.asyncio -async def test_viewer_agent_cannot_mark_done(store): - doc = await store.create_doc("user-1", "list", "Read Only") - await store.add_member(doc["id"], "agent", "atlas", permission="viewer") - entry = await store.add_entry(doc["id"], "A task", author="user-1") - - req = _make_request(store) - res = await execute_notes_set_done( - {"agent_name": "atlas", "doc_id": doc["id"], "entry_id": entry["id"], "done": True}, - req, - ) - assert "error" in res - assert "permission" in res["error"] - - entries = await store.list_entries(doc["id"]) - assert entries[0]["done"] is False - - -@pytest.mark.asyncio -async def test_non_member_agent_cannot_mark_done(store): - doc = await store.create_doc("user-1", "list", "Private") - entry = await store.add_entry(doc["id"], "A task", author="user-1") - - req = _make_request(store) - res = await execute_notes_set_done( - {"agent_name": "intruder", "doc_id": doc["id"], "entry_id": entry["id"], "done": True}, - req, - ) - assert "error" in res - assert "permission" in res["error"] - - -@pytest.mark.asyncio -async def test_set_done_rejects_entry_from_another_doc(store): - doc_a = await store.create_doc("user-1", "list", "List A") - await store.add_member(doc_a["id"], "agent", "atlas") - doc_b = await store.create_doc("user-1", "list", "List B") - foreign = await store.add_entry(doc_b["id"], "Not yours", author="user-1") - - req = _make_request(store) - res = await execute_notes_set_done( - {"agent_name": "atlas", "doc_id": doc_a["id"], "entry_id": foreign["id"], "done": True}, - req, - ) - assert "error" in res - assert "not found" in res["error"] - - entries = await store.list_entries(doc_b["id"]) - assert entries[0]["done"] is False - - -@pytest.mark.asyncio -async def test_set_done_on_archived_doc_rejected(store): - doc = await store.create_doc("user-1", "list", "Old List") - await store.add_member(doc["id"], "agent", "atlas") - entry = await store.add_entry(doc["id"], "A task", author="user-1") - await store.archive_doc(doc["id"]) - - req = _make_request(store) - res = await execute_notes_set_done( - {"agent_name": "atlas", "doc_id": doc["id"], "entry_id": entry["id"], "done": True}, - req, - ) - assert "error" in res - assert "archived" in res["error"] - - -@pytest.mark.asyncio -async def test_set_done_missing_or_bad_fields_returns_error(store): - req = _make_request(store) - - # missing done - res = await execute_notes_set_done({"agent_name": "atlas", "doc_id": "d", "entry_id": "e"}, req) - assert "error" in res - # non-boolean done - res = await execute_notes_set_done( - {"agent_name": "atlas", "doc_id": "d", "entry_id": "e", "done": "yes"}, req - ) - assert "error" in res - # missing entry_id - res = await execute_notes_set_done({"agent_name": "atlas", "doc_id": "d", "done": True}, req) - assert "error" in res diff --git a/tests/test_routes_github.py b/tests/test_routes_github.py index e6c0e16f0..7bfe11e8a 100644 --- a/tests/test_routes_github.py +++ b/tests/test_routes_github.py @@ -341,23 +341,23 @@ def _build_app_with_app_config( app = FastAPI() app.include_router(github_router) - # SecretsStore: PAT under ``github_token``, App key under - # ``github-app-private-key`` (moved out of config by #2009). + # SecretsStore (PAT + App private key) mock_secrets = MagicMock() - async def _secrets_get(name): - if name == "github_token": + async def _secrets_get(key: str): + if key == "github_token": return {"value": token} if token else None - if name == "github-app-private-key": + if key == "github-app-private-key": return {"value": "fake-private-key"} return None mock_secrets.get = AsyncMock(side_effect=_secrets_get) app.state.secrets = mock_secrets - # App config (private key no longer lives here after #2009) + # App config mock_config = MagicMock() mock_config.github_app_id = "123456" + mock_config.github_app_private_key = "fake-private-key" app.state.config = mock_config # App installations store diff --git a/tests/todo/test_todo_tools.py b/tests/todo/test_todo_tools.py new file mode 100644 index 000000000..572ef2ac4 --- /dev/null +++ b/tests/todo/test_todo_tools.py @@ -0,0 +1,408 @@ +"""Tests for the todo agent tools (todo_list_lists, todo_add_item, todo_set_done).""" + +from __future__ import annotations + +import types +from unittest.mock import AsyncMock, MagicMock + +import pytest +import pytest_asyncio + +from tinyagentos.todo.todo_store import TodoStore +from tinyagentos.tools.todo_tools import ( + execute_todo_add_item, + execute_todo_list_lists, + execute_todo_set_done, +) + + +# --------------------------------------------------------------------- helpers + +def _make_request(store, config=None, msg_store=None, agent_registry=None): + state = types.SimpleNamespace( + todo_store=store, + config=config, + chat_messages=msg_store, + agent_registry=agent_registry, + ) + app = types.SimpleNamespace(state=state) + return types.SimpleNamespace(app=app) + + +@pytest_asyncio.fixture +async def store(tmp_path): + s = TodoStore(tmp_path / "test_todo_tools.db") + await s.init() + yield s + await s.close() + + +# ------------------------------------------------------------------ list tests + +@pytest.mark.asyncio +async def test_list_returns_owned_lists(store): + doc = await store.create_list("user-1", "Shopping") + await store.create_list("user-2", "Other List") + + req = _make_request(store) + res = await execute_todo_list_lists( + {"agent_name": "atlas", "owner_user_id": "user-1"}, req + ) + assert "lists" in res + assert any(d["id"] == doc["id"] for d in res["lists"]) + assert len(res["lists"]) == 1 + + +@pytest.mark.asyncio +async def test_list_excludes_other_users_lists(store): + await store.create_list("user-2", "Private") + + req = _make_request(store) + res = await execute_todo_list_lists( + {"agent_name": "atlas", "owner_user_id": "user-1"}, req + ) + assert res["lists"] == [] + + +@pytest.mark.asyncio +async def test_list_excludes_archived_lists(store): + doc = await store.create_list("user-1", "Old List") + await store.archive_list(doc["id"]) + + req = _make_request(store) + res = await execute_todo_list_lists( + {"agent_name": "atlas", "owner_user_id": "user-1"}, req + ) + assert res["lists"] == [] + + +@pytest.mark.asyncio +async def test_list_missing_agent_name_returns_error(store): + req = _make_request(store) + res = await execute_todo_list_lists({}, req) + assert "error" in res + assert "agent_name" in res["error"] + + +# ------------------------------------------------------------------- add tests + +@pytest.mark.asyncio +async def test_owner_can_add_item(store): + doc = await store.create_list("user-1", "Shopping") + + req = _make_request(store) + res = await execute_todo_add_item( + {"agent_name": "atlas", "list_id": doc["id"], "text": "Buy milk", + "owner_user_id": "user-1"}, + req, + ) + assert res.get("ok") is True + assert "item_id" in res + + items = await store.list_items(doc["id"]) + assert any(i["text"] == "Buy milk" for i in items) + + +@pytest.mark.asyncio +async def test_non_owner_rejected(store): + doc = await store.create_list("user-1", "Private") + + req = _make_request(store) + res = await execute_todo_add_item( + {"agent_name": "atlas", "list_id": doc["id"], "text": "Hacked", + "owner_user_id": "user-2"}, + req, + ) + assert "error" in res + assert "access" in res["error"] + + items = await store.list_items(doc["id"]) + assert items == [] + + +@pytest.mark.asyncio +async def test_add_item_attributed_to_agent(store): + doc = await store.create_list("user-1", "Tasks") + + req = _make_request(store) + res = await execute_todo_add_item( + {"agent_name": "atlas", "list_id": doc["id"], "text": "Do the thing", + "owner_user_id": "user-1"}, + req, + ) + item = await store.get_item(res["item_id"]) + assert item["author"] == "atlas" + + +@pytest.mark.asyncio +async def test_add_item_notification_noop(store): + """Notification module is a no-op for now; just verify the add succeeds.""" + doc = await store.create_list("user-1", "Ideas") + + req = _make_request(store) + res = await execute_todo_add_item( + {"agent_name": "atlas", "list_id": doc["id"], "text": "Interesting", + "owner_user_id": "user-1"}, + req, + ) + assert res.get("ok") is True + + +@pytest.mark.asyncio +async def test_add_item_missing_fields_returns_error(store): + req = _make_request(store) + + res = await execute_todo_add_item( + {"agent_name": "atlas", "list_id": "list-x", "owner_user_id": "user-1"}, req + ) + assert "error" in res + + res = await execute_todo_add_item( + {"agent_name": "atlas", "text": "hi", "owner_user_id": "user-1"}, req + ) + assert "error" in res + + res = await execute_todo_add_item( + {"list_id": "list-x", "text": "hi", "owner_user_id": "user-1"}, req + ) + assert "error" in res + + res = await execute_todo_add_item( + {"agent_name": "atlas", "list_id": "list-x", "text": "hi"}, req + ) + assert "error" in res + + +@pytest.mark.asyncio +async def test_add_item_archived_list_rejected(store): + doc = await store.create_list("user-1", "Old List") + await store.archive_list(doc["id"]) + + req = _make_request(store) + res = await execute_todo_add_item( + {"agent_name": "atlas", "list_id": doc["id"], "text": "late entry", + "owner_user_id": "user-1"}, + req, + ) + assert "error" in res + assert "archived" in res["error"] + + +@pytest.mark.asyncio +async def test_add_item_nonexistent_list_returns_error(store): + req = _make_request(store) + res = await execute_todo_add_item( + {"agent_name": "atlas", "list_id": "nonexistent", "text": "hi", + "owner_user_id": "user-1"}, + req, + ) + assert "error" in res + assert "not found" in res["error"] + + +# ------------------------------------------------------------- set_done tests + +@pytest.mark.asyncio +async def test_owner_can_mark_item_done(store): + doc = await store.create_list("user-1", "Build List") + item = await store.add_item(doc["id"], "Ship feature", author="user-1") + + req = _make_request(store) + res = await execute_todo_set_done( + {"agent_name": "atlas", "list_id": doc["id"], "item_id": item["id"], + "done": True, "owner_user_id": "user-1"}, + req, + ) + assert res.get("ok") is True + assert res["done"] is True + + updated = await store.get_item(item["id"]) + assert updated["done"] is True + + # And it can be reopened. + res = await execute_todo_set_done( + {"agent_name": "atlas", "list_id": doc["id"], "item_id": item["id"], + "done": False, "owner_user_id": "user-1"}, + req, + ) + assert res.get("ok") is True + updated = await store.get_item(item["id"]) + assert updated["done"] is False + + +@pytest.mark.asyncio +async def test_non_owner_cannot_mark_done(store): + doc = await store.create_list("user-1", "Read Only") + item = await store.add_item(doc["id"], "A task", author="user-1") + + req = _make_request(store) + res = await execute_todo_set_done( + {"agent_name": "atlas", "list_id": doc["id"], "item_id": item["id"], + "done": True, "owner_user_id": "user-2"}, + req, + ) + assert "error" in res + assert "access" in res["error"] + + updated = await store.get_item(item["id"]) + assert updated["done"] is False + + +@pytest.mark.asyncio +async def test_set_done_rejects_item_from_another_list(store): + doc_a = await store.create_list("user-1", "List A") + doc_b = await store.create_list("user-1", "List B") + foreign = await store.add_item(doc_b["id"], "Not yours", author="user-1") + + req = _make_request(store) + res = await execute_todo_set_done( + {"agent_name": "atlas", "list_id": doc_a["id"], "item_id": foreign["id"], + "done": True, "owner_user_id": "user-1"}, + req, + ) + assert "error" in res + assert "not found" in res["error"] + + updated = await store.get_item(foreign["id"]) + assert updated["done"] is False + + +@pytest.mark.asyncio +async def test_set_done_archived_list_rejected(store): + doc = await store.create_list("user-1", "Old List") + item = await store.add_item(doc["id"], "A task", author="user-1") + await store.archive_list(doc["id"]) + + req = _make_request(store) + res = await execute_todo_set_done( + {"agent_name": "atlas", "list_id": doc["id"], "item_id": item["id"], + "done": True, "owner_user_id": "user-1"}, + req, + ) + assert "error" in res + assert "archived" in res["error"] + + +@pytest.mark.asyncio +async def test_set_done_missing_or_bad_fields_returns_error(store): + req = _make_request(store) + + # missing done + res = await execute_todo_set_done( + {"agent_name": "atlas", "list_id": "d", "item_id": "i", + "owner_user_id": "user-1"}, req + ) + assert "error" in res + # non-boolean done + res = await execute_todo_set_done( + {"agent_name": "atlas", "list_id": "d", "item_id": "i", + "done": "yes", "owner_user_id": "user-1"}, req + ) + assert "error" in res + # missing item_id + res = await execute_todo_set_done( + {"agent_name": "atlas", "list_id": "d", "done": True, + "owner_user_id": "user-1"}, req + ) + assert "error" in res + # missing agent_name + res = await execute_todo_set_done( + {"list_id": "d", "item_id": "i", "done": True, + "owner_user_id": "user-1"}, req + ) + assert "error" in res + assert "agent_name" in res["error"] + + +# ---------------------------------------------------- registry-enforced tests + +@pytest.mark.asyncio +async def test_list_lists_binds_agent_to_owner_via_registry(store): + """When agent_registry is present, owner_user_id is derived from the agent.""" + doc = await store.create_list("user-1", "Shopping") + + # Mock registry: atlas → user-1 + registry = MagicMock() + registry.get_by_handle = AsyncMock( + return_value={"user_id": "user-1", "handle": "atlas"} + ) + + req = _make_request(store, agent_registry=registry) + res = await execute_todo_list_lists( + {"agent_name": "atlas", "owner_user_id": "user-1"}, req + ) + assert "lists" in res + assert any(d["id"] == doc["id"] for d in res["lists"]) + registry.get_by_handle.assert_called_once_with("atlas") + + +@pytest.mark.asyncio +async def test_list_lists_rejects_unregistered_agent(store): + """When agent_registry is present, an unknown agent gets an error.""" + registry = MagicMock() + registry.get_by_handle = AsyncMock(return_value=None) + + req = _make_request(store, agent_registry=registry) + res = await execute_todo_list_lists( + {"agent_name": "unknown", "owner_user_id": "user-1"}, req + ) + assert "error" in res + assert "not found" in res["error"] + + +@pytest.mark.asyncio +async def test_add_item_binds_agent_to_owner_via_registry(store): + """Registry-resolved user_id is used for the access check, overriding args.""" + doc = await store.create_list("user-1", "Private") + + # Mock registry: atlas → user-1 (even though args claim user-2) + registry = MagicMock() + registry.get_by_handle = AsyncMock( + return_value={"user_id": "user-1", "handle": "atlas"} + ) + + req = _make_request(store, agent_registry=registry) + # Agent tries to claim user-2 but registry says they're user-1 + res = await execute_todo_add_item( + {"agent_name": "atlas", "list_id": doc["id"], "text": "Should work", + "owner_user_id": "user-2"}, + req, + ) + # The registry overrides owner_user_id to user-1 → access granted + assert res.get("ok") is True + + +@pytest.mark.asyncio +async def test_set_done_binds_agent_to_owner_via_registry(store): + """Registry-resolved user_id gates set_done access.""" + doc = await store.create_list("user-1", "Tasks") + item = await store.add_item(doc["id"], "A task", author="user-1") + + # Mock registry: atlas → user-2 (different owner → denied) + registry = MagicMock() + registry.get_by_handle = AsyncMock( + return_value={"user_id": "user-2", "handle": "atlas"} + ) + + req = _make_request(store, agent_registry=registry) + res = await execute_todo_set_done( + {"agent_name": "atlas", "list_id": doc["id"], "item_id": item["id"], + "done": True, "owner_user_id": "user-1"}, + req, + ) + # Registry says atlas → user-2, but list owner is user-1 → denied + assert "error" in res + assert "access" in res["error"] + + +@pytest.mark.asyncio +async def test_resolve_falls_back_without_registry(store): + """No agent_registry on state → falls back to args-supplied owner_user_id.""" + doc = await store.create_list("user-1", "Fallback") + + req = _make_request(store) # no agent_registry + res = await execute_todo_list_lists( + {"agent_name": "atlas", "owner_user_id": "user-1"}, req + ) + assert "lists" in res + assert any(d["id"] == doc["id"] for d in res["lists"]) diff --git a/tinyagentos/routes/skill_exec.py b/tinyagentos/routes/skill_exec.py index 2d7ad61b4..395c51326 100644 --- a/tinyagentos/routes/skill_exec.py +++ b/tinyagentos/routes/skill_exec.py @@ -426,12 +426,32 @@ async def _skill_notes_add_entry(args: dict, request: Request) -> dict: return {"error": str(exc)} -async def _skill_notes_set_done(args: dict, request: Request) -> dict: - """Mark a list task done/not-done on a shared doc the agent belongs to.""" +async def _skill_todo_list_lists(args: dict, request: Request) -> dict: + """List non-archived todo lists the calling agent has access to.""" try: - from tinyagentos.tools.notes_tools import execute_notes_set_done + from tinyagentos.tools.todo_tools import execute_todo_list_lists - return await execute_notes_set_done(args, request) + return await execute_todo_list_lists(args, request) + except Exception as exc: + return {"error": str(exc)} + + +async def _skill_todo_add_item(args: dict, request: Request) -> dict: + """Append an item to a todo list the calling agent has access to.""" + try: + from tinyagentos.tools.todo_tools import execute_todo_add_item + + return await execute_todo_add_item(args, request) + except Exception as exc: + return {"error": str(exc)} + + +async def _skill_todo_set_done(args: dict, request: Request) -> dict: + """Mark a todo item done/not-done on a list the agent has access to.""" + try: + from tinyagentos.tools.todo_tools import execute_todo_set_done + + return await execute_todo_set_done(args, request) except Exception as exc: return {"error": str(exc)} @@ -463,7 +483,9 @@ async def _skill_notes_set_done(args: dict, request: Request) -> dict: "export_storybook": _skill_export_storybook, "notes_list_shared_docs": _skill_notes_list_shared_docs, "notes_add_entry": _skill_notes_add_entry, - "notes_set_done": _skill_notes_set_done, + "todo_list_lists": _skill_todo_list_lists, + "todo_add_item": _skill_todo_add_item, + "todo_set_done": _skill_todo_set_done, } diff --git a/tinyagentos/skills.py b/tinyagentos/skills.py index 90c690eb3..4187f4ce0 100644 --- a/tinyagentos/skills.py +++ b/tinyagentos/skills.py @@ -685,21 +685,72 @@ async def _seed_defaults(self): "install_target": "tinyagentos.tools.notes_tools", }, { - "id": "notes_set_done", - "name": "Set Notes Task Done", - "category": "notes", - "description": "Mark a task done or not done on a shared list the agent is a member of", + "id": "todo_list_lists", + "name": "List Todo Lists", + "category": "todo", + "description": "List the non-archived todo lists the agent has access to", + "tool_schema": { + "name": "todo_list_lists", + "description": "List the non-archived todo lists this agent has access to. Returns id, title, and updated_at for each list.", + "input_schema": { + "type": "object", + "properties": { + "owner_user_id": {"type": "string", "description": "The user whose todo lists to list."}, + }, + "required": ["owner_user_id"], + }, + }, + "frameworks": { + "smolagents": "adapter", "openclaw": "adapter", "pocketflow": "adapter", + "langroid": "adapter", "hermes": "adapter", "agent-zero": "adapter", + "openai-agents-sdk": "adapter", "generic": "adapter", + }, + "install_method": "builtin", + "install_target": "tinyagentos.tools.todo_tools", + }, + { + "id": "todo_add_item", + "name": "Add Todo Item", + "category": "todo", + "description": "Append a new item to a todo list the agent has access to", "tool_schema": { - "name": "notes_set_done", - "description": "Mark a task on a shared list done or not done. Use notes_list_shared_docs to find the doc_id and read the entry ids. The agent needs contributor or editor permission.", + "name": "todo_add_item", + "description": "Append a new item to a todo list this agent has access to. Use todo_list_lists first to get the list_id.", "input_schema": { "type": "object", "properties": { - "doc_id": {"type": "string", "description": "Id of the shared list (from notes_list_shared_docs)."}, - "entry_id": {"type": "string", "description": "Id of the task entry to mark."}, + "list_id": {"type": "string", "description": "Id of the todo list (from todo_list_lists)."}, + "text": {"type": "string", "description": "The item text to append."}, + "owner_user_id": {"type": "string", "description": "The user who owns the list."}, + }, + "required": ["list_id", "text", "owner_user_id"], + }, + }, + "frameworks": { + "smolagents": "adapter", "openclaw": "adapter", "pocketflow": "adapter", + "langroid": "adapter", "hermes": "adapter", "agent-zero": "adapter", + "openai-agents-sdk": "adapter", "generic": "adapter", + }, + "install_method": "builtin", + "install_target": "tinyagentos.tools.todo_tools", + }, + { + "id": "todo_set_done", + "name": "Set Todo Item Done", + "category": "todo", + "description": "Mark a todo item done or not done on a list the agent has access to", + "tool_schema": { + "name": "todo_set_done", + "description": "Mark a todo item done or not done. Use todo_list_lists to find the list_id and read the item ids. The agent needs access to the list (owner match).", + "input_schema": { + "type": "object", + "properties": { + "list_id": {"type": "string", "description": "Id of the todo list (from todo_list_lists)."}, + "item_id": {"type": "string", "description": "Id of the todo item to mark."}, "done": {"type": "boolean", "description": "True to mark done, false to reopen."}, + "owner_user_id": {"type": "string", "description": "The user who owns the list."}, }, - "required": ["doc_id", "entry_id", "done"], + "required": ["list_id", "item_id", "done", "owner_user_id"], }, }, "frameworks": { @@ -708,7 +759,7 @@ async def _seed_defaults(self): "openai-agents-sdk": "adapter", "generic": "adapter", }, "install_method": "builtin", - "install_target": "tinyagentos.tools.notes_tools", + "install_target": "tinyagentos.tools.todo_tools", }, ] diff --git a/tinyagentos/todo/notify.py b/tinyagentos/todo/notify.py new file mode 100644 index 000000000..62973f1b4 --- /dev/null +++ b/tinyagentos/todo/notify.py @@ -0,0 +1,32 @@ +"""Notification helpers for todo agent actions. + +When collaboration lands for TodoStore, this module will mirror +routes/notes.py's _trigger_agent_notifications pattern — iterating agent +members and sending messages to their channels. For now, since TodoStore is +owner-based without agent membership, notifications are a no-op placeholder. +""" + +from __future__ import annotations + +import logging + +from fastapi import Request + +logger = logging.getLogger(__name__) + + +async def _trigger_todo_agent_notifications( + request: Request, + doc: dict, + entry_text: str, + skip_agent: str | None = None, +) -> None: + """Placeholder: notify agent members about a new todo item. + + Currently a no-op because TodoStore does not yet have agent membership. + When collaboration is added to TodoStore (gh #1923 C1 follow-up), this + function will iterate agent members and send channel messages, skipping + the agent named in ``skip_agent``. + """ + # TODO(#1923): wire up when TodoStore gains agent membership / collaboration + pass diff --git a/tinyagentos/tools/notes_tools.py b/tinyagentos/tools/notes_tools.py index a68e3aa51..d3e0fb23b 100644 --- a/tinyagentos/tools/notes_tools.py +++ b/tinyagentos/tools/notes_tools.py @@ -77,53 +77,3 @@ async def execute_notes_add_entry(args: dict, request: Request) -> dict: except Exception as exc: return {"error": str(exc)} - -async def execute_notes_set_done(args: dict, request: Request) -> dict: - """Mark a list task done (or not done) on a shared doc the agent belongs to. - - Completes the Todo surface for agents: an agent told to work a shared list - can check tasks off as it finishes them. The agent must have 'contributor' - or 'editor' permission, the entry must belong to the named doc, and the doc - must not be archived. - """ - args = args or {} - agent_name = args.get("agent_name") - doc_id = args.get("doc_id") - entry_id = args.get("entry_id") - done = args.get("done") - - if not agent_name or not isinstance(agent_name, str): - return {"error": "notes_set_done requires an 'agent_name' string"} - if not doc_id or not isinstance(doc_id, str): - return {"error": "notes_set_done requires a 'doc_id' string"} - if not entry_id or not isinstance(entry_id, str): - return {"error": "notes_set_done requires an 'entry_id' string"} - if not isinstance(done, bool): - return {"error": "notes_set_done requires a boolean 'done'"} - - try: - store = request.app.state.shared_docs_store - - members = await store.agent_members(doc_id) - agent_member = next((m for m in members if m["agent"] == agent_name), None) - if agent_member is None: - return {"error": "agent does not have write permission on this doc"} - - perm = agent_member.get("permission", "contributor") - if perm not in ("contributor", "editor"): - return {"error": "agent does not have write permission on this doc"} - - doc = await store.get_doc(doc_id) - if doc is None: - return {"error": "doc not found"} - if doc.get("archived_at") is not None: - return {"error": "doc is archived"} - - # Confine the agent to entries of the doc it actually belongs to. - if not any(e.get("id") == entry_id for e in doc.get("entries", [])): - return {"error": "entry not found in this doc"} - - await store.set_entry_done(entry_id, done) - return {"ok": True, "entry_id": entry_id, "done": done} - except Exception as exc: - return {"error": str(exc)} diff --git a/tinyagentos/tools/todo_tools.py b/tinyagentos/tools/todo_tools.py new file mode 100644 index 000000000..ad12f34c6 --- /dev/null +++ b/tinyagentos/tools/todo_tools.py @@ -0,0 +1,176 @@ +"""Agent-side tools for todo lists. + +Lets an agent list the todo lists it has access to, append items, and +toggle completion. The loop guard (skip_agent) prevents the writing agent +from being notified about its own write. +""" + +from __future__ import annotations + +import logging + +from fastapi import Request + +logger = logging.getLogger(__name__) + + +async def _resolve_owner_user_id( + args: dict, request: Request +) -> str | None: + """Resolve ``owner_user_id``, binding ``agent_name`` when the registry is available. + + Returns ``None`` when the agent cannot be resolved; otherwise the + registry-verified ``user_id`` (overriding any caller-supplied value). + When the agent registry is absent from ``request.app.state``, falls back + to the ``owner_user_id`` in *args* for test compatibility. + """ + agent_registry = getattr(request.app.state, "agent_registry", None) + agent_name = args.get("agent_name") + if agent_registry is None: + # No registry available (e.g. test harness) — trust owner_user_id. + return args.get("owner_user_id") + if not agent_name or not isinstance(agent_name, str): + return None + agent = await agent_registry.get_by_handle(agent_name) + if agent is None: + return None + return agent.get("user_id") + + +async def execute_todo_list_lists(args: dict, request: Request) -> dict: + """List non-archived todo lists the calling agent has access to. + + Authorization binds ``agent_name`` to an owner via the agent registry when + it is available (production). Falls back to caller-supplied + ``owner_user_id`` in test environments where the registry is absent. + """ + args = args or {} + agent_name = args.get("agent_name") + if not agent_name or not isinstance(agent_name, str): + return {"error": "todo_list_lists requires an 'agent_name' string"} + + try: + owner_user_id = await _resolve_owner_user_id(args, request) + if owner_user_id is None: + return {"error": "agent not found in registry"} + if not isinstance(owner_user_id, str) or not owner_user_id: + return {"error": "todo_list_lists requires an 'owner_user_id' string"} + + store = request.app.state.todo_store + lists = await store.list_lists(owner_user_id) + # Strip internal fields the agent does not need. + slim = [ + {k: v for k, v in doc.items() if k in ("id", "title", "updated_at")} + for doc in lists + ] + return {"lists": slim} + except Exception as exc: + return {"error": str(exc)} + + +async def execute_todo_add_item(args: dict, request: Request) -> dict: + """Append an item to a todo list the calling agent has access to. + + Authorization binds ``agent_name`` to an owner via the agent registry. + ``agent_name`` is also used for attribution (author field) and the + notification skip-guard. + """ + args = args or {} + agent_name = args.get("agent_name") + list_id = args.get("list_id") + text = args.get("text") + + if not agent_name or not isinstance(agent_name, str): + return {"error": "todo_add_item requires an 'agent_name' string"} + if not list_id or not isinstance(list_id, str): + return {"error": "todo_add_item requires a 'list_id' string"} + if not isinstance(text, str) or not text: + return {"error": "todo_add_item requires a 'text' string"} + + try: + owner_user_id = await _resolve_owner_user_id(args, request) + if owner_user_id is None: + return {"error": "agent not found in registry"} + if not isinstance(owner_user_id, str) or not owner_user_id: + return {"error": "todo_add_item requires an 'owner_user_id' string"} + + store = request.app.state.todo_store + + doc = await store.get_list(list_id) + if doc is None: + return {"error": "list not found"} + # SECURITY: owner-based auth — only the list owner can add items. + # owner_user_id is resolved from the agent registry (when available) + # rather than trusted from the caller. Owner check runs BEFORE the + # archived check so non-owners cannot learn list state. + if doc.get("owner_user_id") != owner_user_id: + return {"error": "agent does not have access to this list"} + if doc.get("archived_at") is not None: + return {"error": "list is archived"} + + item = await store.add_item(list_id, text, author=agent_name) + + try: + from tinyagentos.todo.notify import _trigger_todo_agent_notifications + + await _trigger_todo_agent_notifications( + request, doc, text, skip_agent=agent_name + ) + except Exception as exc: # noqa: BLE001 + logger.warning("todo_add_item: agent trigger failed: %s", exc) + + return {"ok": True, "item_id": item["id"]} + except Exception as exc: + return {"error": str(exc)} + + +async def execute_todo_set_done(args: dict, request: Request) -> dict: + """Mark a todo item done (or not done) on a list the agent has access to. + + Authorization binds ``agent_name`` to an owner via the agent registry + (same pattern as ``execute_todo_add_item``). + """ + args = args or {} + agent_name = args.get("agent_name") + list_id = args.get("list_id") + item_id = args.get("item_id") + done = args.get("done") + + if not agent_name or not isinstance(agent_name, str): + return {"error": "todo_set_done requires an 'agent_name' string"} + if not list_id or not isinstance(list_id, str): + return {"error": "todo_set_done requires a 'list_id' string"} + if not item_id or not isinstance(item_id, str): + return {"error": "todo_set_done requires an 'item_id' string"} + if not isinstance(done, bool): + return {"error": "todo_set_done requires a boolean 'done'"} + + try: + owner_user_id = await _resolve_owner_user_id(args, request) + if owner_user_id is None: + return {"error": "agent not found in registry"} + if not isinstance(owner_user_id, str) or not owner_user_id: + return {"error": "todo_set_done requires an 'owner_user_id' string"} + + store = request.app.state.todo_store + + doc = await store.get_list(list_id) + if doc is None: + return {"error": "list not found"} + # SECURITY: owner-based auth — only the list owner can mark items done. + # owner_user_id is resolved from the agent registry (when available) + # rather than trusted from the caller. Owner check runs BEFORE the + # archived check so non-owners cannot learn list state. + if doc.get("owner_user_id") != owner_user_id: + return {"error": "agent does not have access to this list"} + if doc.get("archived_at") is not None: + return {"error": "list is archived"} + + # Confine the agent to items of the list it actually belongs to. + if not any(i.get("id") == item_id for i in doc.get("items", [])): + return {"error": "item not found in this list"} + + await store.patch_item(item_id, done=done) + return {"ok": True, "item_id": item_id, "done": done} + except Exception as exc: + return {"error": str(exc)}