diff --git a/src/agent_loop.py b/src/agent_loop.py index 592ebaec1..73bcbdec8 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -663,7 +663,11 @@ Generate an image. Line 1 = description, line 2 = model name, line 3 = WxH (e.g. "manage_session": "- ```manage_session``` — Rename, archive, delete, fork, switch, or `list` chats (the UI calls them 'chats'; 'session' is internal). Line 1 = action (list/switch/rename/archive/unarchive/delete/important/unimportant/truncate/fork), Line 2 = exact chat id from `list_sessions` (or `current` where supported). For delete/archive/truncate, always list first and reuse the exact id; never invent placeholder ids. `switch`/`open` returns a clickable anchor link the user can tap to open the chat — use for \"open my X chat\".", "manage_memory": "- ```manage_memory``` — Manage the user's persistent memory (facts about the USER themselves, their preferences, context that persists across chats). Line 1 = action (list/add/edit/delete/search), rest = content. Use when user says 'remember this' about themselves, states identity facts like 'my name is ' / 'call me ' / 'I live in ', or asks about stored memories. DO NOT use for info about another person (their address, phone, email, birthday) — that goes in `manage_contact`. If the user pastes an address/phone with a name and says 'save this for ', use `manage_contact add` with the address arg, NOT manage_memory.", "manage_skills": "- ```manage_skills``` — Skill registry (SKILL.md format). Args (JSON): {\"action\": \"list|view|view_ref|search|add|edit|patch|publish|delete\", ...}. `list` returns the index of available skills (published + teacher-escalation drafts); `view name=foo` fetches the full SKILL.md; `view_ref name=foo path=...` loads a reference file under the skill directory. For `add`, provide an explicit kebab-case `name` and only report the exact returned name, because storage may normalize or dedupe it. Use this BEFORE doing domain work — there may already be a procedure (published or draft) that prescribes the correct steps. Drafts written by the teacher loop are authoritative guidance even though they're not yet published.", - "manage_tasks": "- ```manage_tasks``` — Create and manage scheduled background tasks (recurring AI jobs). Args (JSON): {\"action\": \"list|create|edit|delete|pause|resume|run\", ...}", + "manage_tasks": """\ +```manage_tasks +{"action": "create", "name": "", "task_type": "llm", "prompt": "", "trigger_type": "schedule", "schedule": "daily", "scheduled_time": "09:00"} +``` +Create and manage scheduled background tasks (recurring AI jobs). action=create needs `name`, `task_type` (llm/research/action), `prompt`, `trigger_type` (schedule/event), and `schedule` (once/daily/weekly/monthly). action=list returns all tasks. action=edit/delete/pause/resume/run needs `task_id`.""", "manage_endpoints": "- ```manage_endpoints``` — Add, remove, or configure AI model API endpoints. Args (JSON): {\"action\": \"list|add|delete|enable|disable\", ...}. Use when user wants to add a new AI provider.", "manage_mcp": "- ```manage_mcp``` — Manage MCP (Model Context Protocol) tool servers — external tools that extend your capabilities. Args (JSON): {\"action\": \"list|add|delete|reconnect|list_tools\", ...}", "manage_webhooks": "- ```manage_webhooks``` — Configure outgoing webhooks (HTTP notifications on events like chat completion). Args (JSON): {\"action\": \"list|add|delete|enable|disable\", ...}", diff --git a/src/tools/system.py b/src/tools/system.py index 813d57df2..556a664b1 100644 --- a/src/tools/system.py +++ b/src/tools/system.py @@ -280,8 +280,21 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict: except ValueError: return {"error": "Invalid JSON arguments", "exit_code": 1} + # Normalize "add" -> "create": models that were not shown the enum sometimes + # emit {"action": "add", ...} (same failure class as manage_skills #4013). + if args.get("action") == "add": + args["action"] = "create" if not args.get("action") and any(args.get(k) is not None for k in ("task", "description", "schedule", "time", "day_of_week")): args["action"] = "create" + # Flatten a nested "task" object: {"action":"create","task":{"name":"foo",...}} + # is equivalent to {"action":"create","name":"foo",...} but some models emit + # the former. Hoist the inner fields so the rest of the handler sees them flat. + task_obj = args.get("task") + if isinstance(task_obj, dict): + for _k, _v in task_obj.items(): + if args.get(_k) is None: + args[_k] = _v + args.pop("task", None) if args.get("task") and not args.get("name"): args["name"] = args["task"] if args.get("task") and not args.get("prompt"): @@ -331,6 +344,11 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict: task_type = args.get("task_type", "llm") trigger_type = args.get("trigger_type", "schedule") + # When the model omits "prompt" but supplies "name", use the name + # as the prompt so a bare {"action":"create","name":"foo"} still + # produces a runnable task instead of an error. + if not args.get("prompt") and args.get("name"): + args["prompt"] = args["name"] if task_type in ("llm", "research") and not args.get("prompt"): return {"error": "Prompt is required for llm/research tasks", "exit_code": 1} if task_type == "action" and not args.get("action_name"): diff --git a/tests/test_manage_tasks_model_writes.py b/tests/test_manage_tasks_model_writes.py new file mode 100644 index 000000000..ee64352dc --- /dev/null +++ b/tests/test_manage_tasks_model_writes.py @@ -0,0 +1,120 @@ +"""Regression tests for manage_tasks model-write normalisation (#5757). + +Models that were not shown the full tool schema (e.g. when RAG selects a +different tool set or a local model is used without function-call schemas) +sometimes emit action="add" instead of action="create", or wrap task fields in +a nested "task" object rather than providing them at the top level. Before the +fix, both patterns fell through to the "unknown action" error branch and the +task was silently not created. + +This test module verifies that both calling patterns produce a successfully +created task, matching the same-class fix applied to manage_skills (#4013). +""" + +import json +import tempfile + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool + +from tests.helpers.import_state import clear_fake_database_modules + +clear_fake_database_modules() + +import core.database as cdb +from core.database import ScheduledTask +from src.tools.system import do_manage_tasks + +_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_ENGINE = create_engine( + f"sqlite:///{_TMPDB.name}", + connect_args={"check_same_thread": False}, + poolclass=NullPool, +) +cdb.Base.metadata.create_all(_ENGINE) +_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False) +cdb.SessionLocal = _TS + + +def _count_tasks_named(name): + db = _TS() + try: + return db.query(ScheduledTask).filter(ScheduledTask.name == name).count() + finally: + db.close() + + +@pytest.mark.asyncio +async def test_action_add_is_treated_as_create(): + """action='add' must be normalised to 'create' so the task is persisted.""" + out = await do_manage_tasks( + json.dumps({ + "action": "add", + "name": "test task add", + "prompt": "summarise the news", + "task_type": "llm", + "trigger_type": "schedule", + "schedule": "daily", + }), + owner="alice", + ) + assert out["exit_code"] == 0, out + assert "Created task" in out["response"] + assert _count_tasks_named("test task add") == 1 + + +@pytest.mark.asyncio +async def test_nested_task_object_is_flattened(): + """{'action':'add','task':{'name':'foo'}} must be treated as create with name=foo.""" + out = await do_manage_tasks( + json.dumps({ + "action": "add", + "task": { + "name": "test task 2", + }, + }), + owner="alice", + ) + assert out["exit_code"] == 0, out + assert "Created task" in out["response"] + assert _count_tasks_named("test task 2") == 1 + + +@pytest.mark.asyncio +async def test_nested_task_object_with_prompt(): + """Nested task object with explicit prompt field is handled correctly.""" + out = await do_manage_tasks( + json.dumps({ + "action": "create", + "task": { + "name": "daily summary", + "prompt": "summarise my emails", + }, + "trigger_type": "schedule", + "schedule": "daily", + }), + owner="alice", + ) + assert out["exit_code"] == 0, out + assert "Created task" in out["response"] + assert _count_tasks_named("daily summary") == 1 + + +@pytest.mark.asyncio +async def test_name_used_as_prompt_fallback_when_prompt_absent(): + """When prompt is absent but name is present, name is used as the prompt.""" + out = await do_manage_tasks( + json.dumps({ + "action": "create", + "name": "my simple task", + "task_type": "llm", + "trigger_type": "schedule", + "schedule": "daily", + }), + owner="alice", + ) + assert out["exit_code"] == 0, out + assert "Created task" in out["response"] + assert _count_tasks_named("my simple task") == 1