Skip to content

Tasks: one-door delivery — send_message is the only path out of a task session#2988

Merged
gavrielc merged 10 commits into
mainfrom
tasks/04-one-door-delivery
Jul 13, 2026
Merged

Tasks: one-door delivery — send_message is the only path out of a task session#2988
gavrielc merged 10 commits into
mainfrom
tasks/04-one-door-delivery

Conversation

@omri-maya

@omri-maya omri-maya commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Part 3/5 of the scheduled-tasks train — follows #2980 and #2981 (both merged).

What

Delivery now always uses an explicit named destination.

  • Every send_message and send_file call requires to. There is no reply-in-place or single-destination fallback.
  • Task sessions have exactly one user-visible delivery path: explicitly addressed outbound tools.
  • Final-text <message to> blocks are inert in task sessions.
  • A task's original final output is automatically appended to tasks/<id>.md.
  • ncl tasks append-log adds optional mid-run notes; it never suppresses the automatic final summary.
  • When an inert block is detected, the correction nudge includes its exact destination/content and only asks the agent to send it if notification is still required and it was not already sent.
  • Task sessions receive task-only delivery instructions, including after compaction. They do not receive final-output <message> delivery guidance.
  • Task prompts store only the user's instructions. Legacy generated contract suffixes are normalized when read.

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.

… 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>
@github-actions github-actions Bot added the core-team PR opened by a core team member label Jul 9, 2026
@moshe-nanoco moshe-nanoco marked this pull request as draft July 9, 2026 13:13
@moshe-nanoco moshe-nanoco marked this pull request as ready for review July 9, 2026 13:14
Comment thread src/db/session-db.ts Outdated
Comment on lines +43 to +54
/**
* 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();
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread container/agent-runner/src/poll-loop.ts Outdated
.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>`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is directing unconditionally to resend when it may actually not be desired. This should be more flexible and and explicit.

Suggested change
`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>`,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread container/agent-runner/src/poll-loop.ts Outdated
.map((d) => d.name)
.join(', ');
query.push(
`<system>Your <message> block was NOT delivered — task sessions deliver only via the send_message tool. ` +

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to move away from implicit message destination. Best to keep it simple and have the agent always explicitly address

Suggested change
'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.',

Comment on lines +30 to +38
// 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
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why both migration and backwards compatibility? Seems a bit over defensive

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Removed the migration and is_task fallback entirely. Backward compatibility is handled by a small read-time normalization of known legacy prompt suffixes.

Comment thread container/agent-runner/src/poll-loop.ts Outdated
Comment on lines +499 to +506
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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Too late to rename taskFire to taskRun?

@omri-maya omri-maya Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`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.`,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied verbatim in 379fd28. Also merged main into the branch (9811234) — the run-log local-time stamping from #3005/#3006 now lives inside the shared appendRunLog helper, so both writers keep the YYYY-MM-DD HH:mm format.

Comment thread container/agent-runner/src/mcp-tools/scheduling.instructions.md Outdated
Comment thread container/agent-runner/src/compact-instructions.ts Outdated
Comment thread CHANGELOG.md Outdated

@gavrielc gavrielc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great!!

@gavrielc gavrielc merged commit 1e1bd27 into main Jul 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core-team PR opened by a core team member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants