Skip to content
Merged
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ All notable changes to NanoClaw will be documented in this file.

## [Unreleased]

- [BREAKING] **Explicit destinations and one-door task delivery.** Every `send_message` and `send_file` call now requires a named `to` destination; there is no single-destination or reply-in-place shortcut. In task sessions, only explicitly addressed tool calls deliver, while final output becomes the automatic run summary and `ncl tasks append-log` adds optional progress notes. Existing task DBs need no schema migration; legacy generated task instructions are normalized when read. **Migration:** rebuild the agent image, restart NanoClaw, update custom instructions that omit `to`, and clear or compact existing sessions.
- **The guard seam.** Every privileged action crossing the container or channel boundary now passes one decision function — `guard()` in `src/guard/` — before it executes: `allow`, `hold` (the existing approval flows), or `deny`. Today's checks are preserved verbatim as each action's decision; ncl commands and delivery actions cannot register without a guard, and approved replays re-enter carrying the approval row as a **grant** (the forgeable `approved: true` boolean is deleted) with the checks re-run live. Two deliberate outcome changes: **(a)** approving a held a2a message after its destination was revoked no longer delivers it — the requester is told "approved, but not delivered" and the host logs a warning; **(b)** a forged, already-consumed, or mismatched grant refuses the replay instead of executing.
- **Delivery-registry hardening.** Re-registering a guard-wrapped delivery action *without* a guard spec now throws instead of silently disarming the guard.
- [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter.
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off.
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
Expand Down
21 changes: 21 additions & 0 deletions container/agent-runner/src/compact-instructions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'bun:test';

import { buildCompactInstructions } from './compact-instructions.js';

describe('compaction delivery reminder', () => {
it('preserves final-output addressing in chat sessions', () => {
const instructions = buildCompactInstructions(['family'], null);

expect(instructions).toContain('<message to="name">');
expect(instructions).toContain('`family`');
});

it('preserves explicit-tool delivery in task sessions without teaching final-output blocks', () => {
const instructions = buildCompactInstructions(['family'], 'daily-digest-a1b2');

expect(instructions).toContain('send_message');
expect(instructions).toContain('explicit to destination');
expect(instructions).toContain('tasks/daily-digest-a1b2.md');
expect(instructions).not.toContain('<message to="name">');
});
});
55 changes: 36 additions & 19 deletions container/agent-runner/src/compact-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,42 @@
* "command": "bun /app/src/compact-instructions.ts"
*/
import { getAllDestinations } from './destinations.js';
import { getTaskSeriesId } from './db/session-routing.js';

const destinations = getAllDestinations();
const names = destinations.map((d) => d.name);
export function buildCompactInstructions(names: string[], taskId: string | null): string {
const deliveryReminder = taskId
? [
' "This is an isolated task run. If you need to send the user a message, use send_message with an explicit to destination.',
` Final output is not delivered; it becomes the automatic summary in tasks/${taskId}.md.`,
` Available destinations: ${formatDestinationNames(names)}."`,
]
: [
' "You MUST wrap all responses in <message to="name">...</message> blocks.',
` Available destinations: ${formatDestinationNames(names)}."`,
];

const instructions = [
'Preserve the following in the compaction summary:',
'',
'1. For recent messages, keep the full XML structure including all attributes:',
' - <message from="..." sender="..." time="..."> for chat messages',
' - <task from="..." time="..."> for scheduled tasks',
' - <webhook from="..." source="..." event="..."> for webhooks',
' The message content can be summarized if long, but the XML tags and attributes must remain.',
'',
'2. Preserve the chronological message/reply sequence of recent exchanges.',
' The agent needs to see: who said what, in what order, and from which destination.',
'',
'3. At the END of the compaction summary, include this verbatim reminder:',
' "You MUST wrap all responses in <message to="name">...</message> blocks.',
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}."`,
];
return [
'Preserve the following in the compaction summary:',
'',
'1. For recent messages, keep the full XML structure including all attributes:',
' - <message from="..." sender="..." time="..."> for chat messages',
' - <task from="..." time="..."> for scheduled tasks',
' - <webhook from="..." source="..." event="..."> for webhooks',
' The message content can be summarized if long, but the XML tags and attributes must remain.',
'',
'2. Preserve the chronological message/reply sequence of recent exchanges.',
' The agent needs to see: who said what, in what order, and from which destination.',
'',
'3. At the END of the compaction summary, include this verbatim reminder:',
...deliveryReminder,
].join('\n');
}

console.log(instructions.join('\n'));
function formatDestinationNames(names: string[]): string {
return names.length > 0 ? names.map((name) => `\`${name}\``).join(', ') : '(none)';
}

if (import.meta.main) {
const names = getAllDestinations().map((destination) => destination.name);
console.log(buildCompactInstructions(names, getTaskSeriesId()));
}
19 changes: 0 additions & 19 deletions container/agent-runner/src/db/messages-out.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,22 +142,3 @@ export function getUndeliveredMessages(): MessageOutRow[] {
)
.all() as MessageOutRow[];
}

/**
* True if a deliberate send with this exact destination + text already exists
* (an MCP send_message row from the current turn). Used by the task-fire
* final-text dispatcher to drop the turn-final <message> echo of a send the
* agent already made — the dedup happens where the duplication originates.
*/
export function hasIdenticalSend(platformId: string, channelType: string, text: string): boolean {
const row = getOutboundDb()
.prepare(
`SELECT 1 FROM messages_out
WHERE platform_id = $platform_id AND channel_type = $channel_type
AND (in_reply_to IS NULL OR in_reply_to = '')
AND json_extract(content, '$.text') = $text
LIMIT 1`,
)
.get({ $platform_id: platformId, $channel_type: channelType, $text: text });
return row != null;
}
25 changes: 15 additions & 10 deletions container/agent-runner/src/db/session-routing.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
/**
* Default reply routing for this session — written by the host on every
* Current chat/thread routing for this session — written by the host on every
* container wake (see src/session-manager.ts `writeSessionRouting`).
*
* Read by the MCP tools as the default destination for outbound messages
* when the agent doesn't specify an explicit `to`. This is what makes
* "agent replies in the thread it's currently in" work: the router strips
* or preserves thread_id based on the adapter's thread support, and we
* just read the fixed routing the host committed for this session.
* Read by MCP tools to preserve the current thread when an explicitly named
* destination resolves to the chat this session is bound to.
*/
import { getInboundDb } from './connection.js';

Expand All @@ -19,12 +16,20 @@ export interface SessionRouting {
export function getSessionRouting(): SessionRouting {
const db = getInboundDb();
try {
const row = db
.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1')
.get() as SessionRouting | undefined;
const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as
| SessionRouting
| undefined;
if (row) return row;
} catch {
// Table may not exist on an older session DB — fall through to defaults
// Table may not exist on an older session DB — fall through to defaults.
}
return { channel_type: null, platform_id: null, thread_id: null };
}

const TASK_THREAD_PREFIX = 'system:tasks:';

/** The task id encoded in this isolated task session's canonical thread id. */
export function getTaskSeriesId(): string | null {
const threadId = getSessionRouting().thread_id;
return threadId?.startsWith(TASK_THREAD_PREFIX) ? threadId.slice(TASK_THREAD_PREFIX.length) : null;
}
13 changes: 13 additions & 0 deletions container/agent-runner/src/destinations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,17 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
expect(prompt).toContain('default to addressing the destination it came `from`');
expect(prompt).toContain('`casa`');
});

it('gives task sessions only explicit-tool delivery instructions', () => {
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');

const prompt = buildSystemPromptAddendum('Casa', { kind: 'task', taskId: 'daily-briefing-a25c' });

expect(prompt).toContain('isolated task run');
expect(prompt).toContain('send_message({ to: "name"');
expect(prompt).toContain('tasks/daily-briefing-a25c.md');
expect(prompt).toContain('Only notify someone when the task asks');
expect(prompt).not.toContain('<message to=');
expect(prompt).not.toContain('default to addressing');
});
});
38 changes: 25 additions & 13 deletions container/agent-runner/src/destinations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface DestinationEntry {
agentGroupId?: string;
}

export type SessionMode = { kind: 'chat' } | { kind: 'task'; taskId: string };

interface DestRow {
name: string;
display_name: string | null;
Expand Down Expand Up @@ -79,31 +81,26 @@ export function findByRouting(
* per-agent-group and changes when the operator renames an agent, while
* the shared base is identical across all agents.
*/
export function buildSystemPromptAddendum(assistantName?: string): string {
export function buildSystemPromptAddendum(assistantName?: string, mode: SessionMode = { kind: 'chat' }): string {
const sections: string[] = [];

if (assistantName) {
sections.push(['# You are ' + assistantName, '', `Your name is **${assistantName}**. Use it when the channel asks who you are, when introducing yourself, and when signing any message that explicitly calls for a signature.`].join('\n'));
}

sections.push(buildDestinationsSection());
sections.push(buildDestinationsSection(mode));

return sections.join('\n\n');
}

function buildDestinationsSection(): string {
function buildDestinationsSection(mode: SessionMode): string {
const all = getAllDestinations();
const lines = ['## Sending messages', ''];

if (all.length === 0) {
return [
'## Sending messages',
'',
'You currently have no configured destinations. You cannot send messages until an admin wires one up.',
].join('\n');
}

const lines = ['## Sending messages', ''];
if (all.length === 1) {
lines.push('You currently have no configured destinations. You cannot send messages until an admin wires one up.');
if (mode.kind === 'chat') return lines.join('\n');
} else if (all.length === 1) {
const d = all[0];
lines.push(`Your destination is \`${d.name}\`${destinationLabel(d)}.`);
} else {
Expand All @@ -112,7 +109,18 @@ function buildDestinationsSection(): string {
lines.push(`- \`${d.name}\`${destinationLabel(d)}`);
}
}

lines.push('');

if (mode.kind === 'task') {
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 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.`,
);
return lines.join('\n');
}

lines.push(
'Wrap each delivered message in a `<message to="name">…</message>` block; include several blocks in one response to address several destinations. `<internal>…</internal>` marks thinking you don\'t want sent.',
);
Expand All @@ -122,7 +130,11 @@ function buildDestinationsSection(): string {
);
lines.push('');
lines.push(
'The `send_message` MCP tool is the same delivery, available mid-turn — handy for a quick acknowledgment ("on it") before a slow tool call. Each `send_message` call and each final-response `<message>` block lands as its own message in the conversation, so they read as a sequence rather than as one combined reply.',
'The `send_message` MCP tool is the same delivery, available mid-turn — handy for a quick acknowledgment ("on it") before a slow tool call. Always pass its explicit `to` destination. Each `send_message` call and each final-response `<message>` block lands as its own message in the conversation, so they read as a sequence rather than as one combined reply.',
);
lines.push('');
lines.push(
'For a short turn, do not narrate. For longer work, send one acknowledgment and then updates only at meaningful milestones, especially before slow operations. Never narrate micro-steps; finish with the outcome, not a play-by-play.',
);
return lines.join('\n');
}
Expand Down
34 changes: 33 additions & 1 deletion container/agent-runner/src/formatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';

import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js';
import { getPendingMessages } from './db/messages-in.js';
import { formatMessages, stripInternalTags } from './formatter.js';
import { formatMessages, stripInternalTags, stripLegacyTaskContract } from './formatter.js';
import { TIMEZONE, formatLocalTime } from './timezone.js';

beforeEach(() => {
Expand Down Expand Up @@ -62,6 +62,38 @@ describe('context timezone header', () => {
});
});

describe('task prompt compatibility', () => {
it('strips the generated #2981 delivery suffix without mutating stored data', () => {
const prompt =
'Send the daily digest\n\n' +
'[A task serves the user two separate ways — legacy generated delivery instructions]';

expect(stripLegacyTaskContract(prompt)).toBe('Send the daily digest');
});

it('strips the generated #2988 delivery suffix', () => {
const prompt = 'Check the feeds\n\n[Task delivery contract:\nlegacy generated instructions]';

expect(stripLegacyTaskContract(prompt)).toBe('Check the feeds');
});

it('leaves ordinary user prompts unchanged', () => {
const prompt = 'Explain [Task delivery contract:] as plain text';

expect(stripLegacyTaskContract(prompt)).toBe(prompt);
});

it('does not expose a legacy delivery contract in a formatted task run', () => {
insertMessage('task-1', 'task', {
prompt: 'Check the feeds\n\n[Task delivery contract:\nlegacy generated instructions]',
});

const result = formatMessages(getPendingMessages());
expect(result).toContain('Instructions:\nCheck the feeds');
expect(result).not.toContain('legacy generated instructions');
});
});

describe('multi-message chat batches', () => {
// Regression guard for #2555: an outer `<messages>` envelope around
// multiple chat messages caused the Claude Agent SDK to emit a synthetic
Expand Down
Loading
Loading