From 17beb95d10e2392733b8e53c76be3336a2a72090 Mon Sep 17 00:00:00 2001 From: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:35:45 -0700 Subject: [PATCH] fix(tasks): normalise action=add and nested task object in manage_tasks Models that are not shown the full schema sometimes emit action="add" (instead of "create") or wrap task fields in a nested "task" object rather than providing them at the top level. Both patterns fell through to the "Unknown action" error branch, so no task was created even though the model believed it had succeeded. This is the same failure class as the manage_skills schema gap fixed in #4013: the model sees prose describing the tool but not the schema, so it guesses at the action verb and arg shape. Changes: - do_manage_tasks: normalise action="add" to "create" before dispatch - do_manage_tasks: flatten a nested "task" dict into top-level fields, mirroring the existing string-valued "task" alias - do_manage_tasks: use "name" as the "prompt" fallback when prompt is absent, so {"action":"create","name":"foo"} does not return an error - agent_loop.py: expand the local-model tool snippet for manage_tasks with a concrete JSON example and explicit arg descriptions so models without schema access emit the right shape on the first attempt Fixes #5757 Signed-off-by: Christian Sidak Signed-off-by: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com> --- src/agent_loop.py | 6 +- src/tools/system.py | 18 ++++ tests/test_manage_tasks_model_writes.py | 120 ++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 tests/test_manage_tasks_model_writes.py 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