Tasks: one-door delivery — send_message is the only path out of a task session#2988
Conversation
… a task session A task fire has no chat attached, so delivery gets exactly one door: the send_message tool with an explicit destination. Final-text <message to> blocks are inert in task sessions; the fire's final text is auto-appended to the series run log (tasks/<id>.md) instead — exactly once per run (skipped when the agent already wrote its own append-log line that fire). Inert blocks are stripped from auto-appended lines and logged as [undelivered → …] so a misaddressed send is visible in the log rather than silently gone. A same-turn nudge catches an agent that ends a fire with inert blocks; the previous echo-suppression heuristics are removed with the ambiguity they served. Chat sessions are unchanged. The host now stamps is_task on session_routing at session creation — the container reads that instead of sniffing the thread-id prefix. BREAKING: task prompts that relied on final-text <message to> delivery must call send_message(to=...); tasks created with the standard scaffold already instruct this. Rebuild the container image and restart so runners pick up the new poll loop (see CHANGELOG migration note). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| /** | ||
| * Lazy, on-open migration for session_routing (mirrors migrateMessagesInTable): | ||
| * pre-existing session DBs lack the is_task column. Idempotent; no-op when the | ||
| * table itself is missing (older DBs error on upsert exactly as before). | ||
| */ | ||
| export function migrateSessionRoutingTable(db: Database.Database): void { | ||
| const cols = (db.prepare("PRAGMA table_info('session_routing')").all() as Array<{ name: string }>).map((c) => c.name); | ||
| if (cols.length > 0 && !cols.includes('is_task')) { | ||
| db.prepare('ALTER TABLE session_routing ADD COLUMN is_task INTEGER NOT NULL DEFAULT 0').run(); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Migration is necessary because we split between PRs? We should avoid unnecessary migrations and extra overhead from splitting up PRs...
Can we avoid migrating and this being a breaking change by supporting backwards compatibility in the code? If this is relatively simple then it's preferred
There was a problem hiding this comment.
Agreed. Removed the is_task schema change and migration. Task mode now derives from the existing canonical task-session routing, and legacy generated prompt suffixes are normalized at read time.
| .join(', '); | ||
| query.push( | ||
| `<system>Your <message> block was NOT delivered — task sessions deliver only via the send_message tool. ` + | ||
| `Re-send now with send_message({to: "<name>", ...}). Your destinations: ${names}.</system>`, |
There was a problem hiding this comment.
This is directing unconditionally to resend when it may actually not be desired. This should be more flexible and and explicit.
| `Re-send now with send_message({to: "<name>", ...}). Your destinations: ${names}.</system>`, | |
| `If and only if this message needs to be sent to the user, re-send now with send_message({to: "<name>", ...}). Your destinations: ${names}.</system>`, |
There was a problem hiding this comment.
Agreed. The nudge is now conditional: it asks the agent to send only if notification is still needed and the content was not already sent.
| .map((d) => d.name) | ||
| .join(', '); | ||
| query.push( | ||
| `<system>Your <message> block was NOT delivered — task sessions deliver only via the send_message tool. ` + |
There was a problem hiding this comment.
Is it clear enough to the agent which message this refers to? Can we include the message block with content here? e.g. [message content here]
There was a problem hiding this comment.
Done. The nudge now includes the exact undelivered destination and escaped block content, so the agent can identify the message unambiguously.
| ## Task scheduling (`ncl tasks`) | ||
|
|
||
| Use `ncl tasks` for one-shot and recurring tasks. A task runs in this agent group's system session, not in the current chat session, so when it fires you must choose a destination explicitly with `<message to="name">...</message>` or `send_message({ to: "name", ... })`. | ||
| Use `ncl tasks` for one-shot and recurring tasks. A task runs in this agent group's system session, not in the current chat session. When it fires, the ONLY way to message anyone is `send_message({ to: "name", ... })` with an explicit destination — final text and `<message>` blocks are not delivered from task sessions; the fire's final text is recorded in the task's run log instead. |
There was a problem hiding this comment.
I don't think this addition is needed for all sessions, only for task sessions. Also, not sure the agent has enough information to unambiguously understan that it is running in a task session.
Ideally, the agent should just see the instructions that are related to task sessions when running in a task session - meaning we do not give conditional instructions and it doesn't see any instructions about delivering through message blocks in output. Instead, the only delivery path that it knows about when running in a task session is the send_message tool
| name: 'send_message', | ||
| description: 'Send a message to a named destination. If you have only one destination, you can omit `to`.', | ||
| description: | ||
| 'Send a message to a named destination. If you have only one destination, you can omit `to` — except in task sessions, where `to` is always required (this tool is the ONLY delivery path there; final text is never sent).', |
There was a problem hiding this comment.
I think we want to move away from implicit message destination. Best to keep it simple and have the agent always explicitly address
| 'Send a message to a named destination. If you have only one destination, you can omit `to` — except in task sessions, where `to` is always required (this tool is the ONLY delivery path there; final text is never sent).', | |
| 'Send a message to a named destination.', |
| // without it and derive task-ness from the thread prefix. | ||
| try { | ||
| const row = db | ||
| .prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1') | ||
| .get() as Omit<SessionRouting, 'is_task'> | undefined; | ||
| if (row) return { ...row, is_task: row.thread_id?.startsWith('system:tasks') ? 1 : 0 }; | ||
| } catch { | ||
| // Table may not exist on an older session DB — fall through to defaults | ||
| } |
There was a problem hiding this comment.
Why both migration and backwards compatibility? Seems a bit over defensive
There was a problem hiding this comment.
Agreed. Removed the migration and is_task fallback entirely. Backward compatibility is handled by a small read-time normalization of known legacy prompt suffixes.
| const willRetryTaskBlocks = shouldNudgeTaskBlocks(routing.taskFire === true, taskBlocks, taskBlockNudged); | ||
| // One-door task delivery: the final text becomes the run log entry | ||
| // (guarded — an explicit append-log this fire wins). Errors included: | ||
| // a failed fire's text belongs in the run log, not in a chat. | ||
| // When we nudge on inert <message> blocks, DEFER the append to the | ||
| // retry's result so the fire logs exactly once. | ||
| if (routing.taskFire && !willRetryTaskBlocks) autoAppendTaskLog(event.text, turnStartSeq); | ||
| if (sent === 0 && event.isError === true && !routing.taskFire) { |
There was a problem hiding this comment.
Too late to rename taskFire to taskRun?
There was a problem hiding this comment.
Not too late, renamed taskFire to taskRun
| lines.push( | ||
| 'This is an isolated task run with no attached chat. Only notify someone when the task asks you to. For a user-visible message, call `send_message({ to: "name", text: "..." })`; for a file, call `send_file` with `to`. Always pass the explicit named destination.', | ||
| '', | ||
| `Your final response is not delivered. End with a concise work-log summary of what happened and why; explain a no-op and name files you changed. It is recorded automatically in \`tasks/${mode.taskId}.md\`. Read that file when you need context from earlier runs. Use \`ncl tasks append-log --msg "…"\` only for optional mid-run notes.`, |
There was a problem hiding this comment.
| `Your final response is not delivered. End with a concise work-log summary of what happened and why; explain a no-op and name files you changed. It is recorded automatically in \`tasks/${mode.taskId}.md\`. Read that file when you need context from earlier runs. Use \`ncl tasks append-log --msg "…"\` only for optional mid-run notes.`, | |
| `Your final output is not sent to the user. End with a concise work-log summary. It is recorded automatically in \`tasks/${mode.taskId}.md\`. Read that file when you need context from earlier runs. Use \`ncl tasks append-log --msg "…"\` only for optional mid-run notes.`, |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Part 3/5 of the scheduled-tasks train — follows #2980 and #2981 (both merged).
What
Delivery now always uses an explicit named destination.
send_messageandsend_filecall requiresto. There is no reply-in-place or single-destination fallback.<message to>blocks are inert in task sessions.tasks/<id>.md.ncl tasks append-logadds optional mid-run notes; it never suppresses the automatic final summary.<message>delivery guidance.Compatibility
No schema or session-DB migration is required. Task mode is derived from the existing canonical task-session routing.
This is a breaking change for custom instructions or tool calls that omit
to. Rebuild the agent image and restart NanoClaw so existing installations use the updated runner.