Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog.d/journal-tool.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- `journal({content})` — a synthesized private note-taking tool beside `think`
and `skip_reply`. The entry stays in the agent's own context and is sent
nowhere; it does not end the turn and does not affect prose routing. It exists
because long prose kept in `skip_reply.reason` (or `think.content`) makes
replayed history read as a reasoning trace, and every memory-compression
request over it is refused `reasoning_extraction` regardless of content,
while the same prose in a note-taking tool passes. Context-manager's
`compressionToolProseFallback` rung rewrites old history into calls to this
tool and mirrors its result wording.
3 changes: 3 additions & 0 deletions changelog.d/skip-reply-reason-short.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- `skip_reply.reason` is now described as ONE short line (under ~100
characters), pointing at `journal()` for anything longer. Description only —
no length is enforced and existing calls behave exactly as before.
6 changes: 3 additions & 3 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10953,11 +10953,11 @@ export class AgentFramework {
return;
}

// Route synthesized 'think' (private reasoning) and 'skip_reply' (deliberate
// stay-silent) tools — handled by the channel registry like the other
// Route synthesized 'think' (private reasoning), 'journal' (private
// long-form notes) and 'skip_reply' (deliberate stay-silent) tools — handled by the channel registry like the other
// synthesized channel tools, but they aren't `channel_`-prefixed so they
// need an explicit route here.
if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
if ((enrichedCall.name === 'think' || enrichedCall.name === 'journal' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
// skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
// NOW" can also mean "back in a moment" — ends the turn, then wakes
// the agent after N seconds unless something else wakes it first
Expand Down
45 changes: 44 additions & 1 deletion src/mcpl/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,22 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
required: [],
},
},
{
name: 'journal',
description:
'Write an entry in your private journal. The entry stays in your own context and ' +
'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' +
'than a line that you want to keep for yourself — reflections, what you decided and ' +
'why, notes for later. It does not end your turn and does not affect where ordinary ' +
'text is routed; to end the turn without replying, call skip_reply afterwards.',
inputSchema: {
type: 'object' as const,
properties: {
content: { type: 'string', description: 'The journal entry (private; not sent anywhere).' },
},
required: ['content'],
},
},
{
name: 'skip_reply',
description:
Expand All @@ -414,7 +430,9 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
properties: {
reason: {
type: 'string',
description: 'Optional private note on why you are not replying (not sent anywhere).',
description:
'Optional ONE short line on why you are not replying (private; not sent anywhere). ' +
'Keep it under ~100 characters — put anything longer in journal() first.',
},
wake_in_seconds: {
type: 'number',
Expand Down Expand Up @@ -1218,6 +1236,9 @@ export class ChannelRegistry {
case 'think':
return this.handleToolThink(input as { content?: string });

case 'journal':
return this.handleToolJournal(input as { content?: string });

case 'skip_reply':
return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: number });

Expand Down Expand Up @@ -2670,6 +2691,28 @@ export class ChannelRegistry {
};
}

/**
* Handle the synthesized `journal` tool — a private place for long-form
* notes. Sends nothing, does not end the turn, does not touch prose routing.
*
* Why it exists (sill, 2026-09-19): residents were keeping 2–3KB diaries in
* `skip_reply.reason`. Long prose in a private-REASONING tool argument
* (`skip_reply.reason`, `think.content`) makes replayed history read as a
* reasoning trace, and every memory-compression request over it is refused
* `reasoning_extraction` regardless of content; the same prose in a
* note-taking tool passes (canary record: context-manager
* `tool-prose-hoist.ts`, whose fallback rung rewrites old history into calls
* to THIS tool — so the result wording below is mirrored there as
* DEFAULT_TOOL_PROSE_RESULT; keep the two in step).
*/
private handleToolJournal(_input: { content?: string }): ToolResult {
return {
success: true,
// No echo: the entry is already in the tool_use block.
data: { recorded: true, note: 'Journal entry recorded (private — not sent anywhere).' },
};
}

/**
* Handle the synthesized `skip_reply` tool — the deliberate "stay silent"
* signal. A no-op as far as any surface is concerned (sends nothing); its
Expand Down
42 changes: 42 additions & 0 deletions test/journal-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ChannelRegistry } from '../src/mcpl/channel-registry.js';
import type { McplServerRegistry } from '../src/mcpl/server-registry.js';
import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js';

// `journal` — private long-form notes (sill, 2026-09-19). Long prose kept in
// `skip_reply.reason` / `think.content` gets every memory-compression request
// over that history refused; a note-taking tool does not. context-manager's
// tool-prose hoist rung rewrites old history into calls to this tool and
// mirrors its result wording (DEFAULT_TOOL_PROSE_RESULT) — the literal below
// is that contract.

function makeRegistry(): ChannelRegistry {
return new ChannelRegistry({} as McplServerRegistry, {} as FeatureSetManager, () => {}, () => {}, {});
}

test('journal is a declared tool with a required content argument', () => {
const tool = makeRegistry().getChannelTools().find((t) => t.name === 'journal');
assert.ok(tool, 'journal is exposed');
assert.deepEqual((tool!.inputSchema as { required: string[] }).required, ['content']);
assert.match(tool!.description, /NOT sent/);
});

test('journal records privately: no echo, does not end the turn, wording matches the context-manager mirror', async () => {
const entry = 'a long private reflection '.repeat(40);
const result = await makeRegistry().handleChannelToolCall('journal', { content: entry });
assert.equal(result.success, true);
assert.equal((result as { endTurn?: boolean }).endTurn, undefined, 'journal must not end the turn');
assert.equal(
JSON.stringify(result.data),
'{"recorded":true,"note":"Journal entry recorded (private — not sent anywhere)."}',
);
assert.ok(!JSON.stringify(result).includes(entry.slice(0, 40)), 'entry is not echoed back');
});

test('skip_reply.reason asks for one short line and points at journal', () => {
const tool = makeRegistry().getChannelTools().find((t) => t.name === 'skip_reply')!;
const reason = tool.inputSchema.properties!.reason!.description ?? '';
assert.match(reason, /short line/);
assert.match(reason, /journal\(\)/);
});
Loading