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 <christian@sentineltech.eu>
Signed-off-by: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com>
This commit is contained in:
Christian-Sidak 2026-07-27 20:35:45 -07:00
parent d8a2059df8
commit 17beb95d10
3 changed files with 143 additions and 1 deletions

View file

@ -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 <name>' / 'call me <name>' / 'I live in <place>', 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 <person>', 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 name>", "task_type": "llm", "prompt": "<what to do>", "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\", ...}",

View file

@ -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"):

View file

@ -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