From b1efd9b54e876c2b907b3458beb7677e778e1957 Mon Sep 17 00:00:00 2001 From: antra-tess Date: Sat, 19 Sep 2026 10:26:52 -0700 Subject: [PATCH] =?UTF-8?q?feat(tools):=20journal=20=E2=80=94=20a=20privat?= =?UTF-8?q?e=20note-taking=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: every memory-compression request over it is refused reasoning_extraction, independent of content. The same prose in a note-taking tool passes (8/8 real requests with the tool declared; think{content} refuses 4/4). journal({content}) gives that habit a correct home: private, no endTurn, no routing effect. skip_reply.reason's description now asks for one short line. The result wording is mirrored by context-manager's tool-prose hoist rung (DEFAULT_TOOL_PROSE_RESULT), which rewrites old history into journal calls. Co-Authored-By: Claude Fable 5.1 --- changelog.d/journal-tool.added.md | 9 ++++ .../skip-reply-reason-short.changed.md | 3 ++ src/framework.ts | 6 +-- src/mcpl/channel-registry.ts | 45 ++++++++++++++++++- test/journal-tool.test.ts | 42 +++++++++++++++++ 5 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 changelog.d/journal-tool.added.md create mode 100644 changelog.d/skip-reply-reason-short.changed.md create mode 100644 test/journal-tool.test.ts diff --git a/changelog.d/journal-tool.added.md b/changelog.d/journal-tool.added.md new file mode 100644 index 0000000..b8756ca --- /dev/null +++ b/changelog.d/journal-tool.added.md @@ -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. diff --git a/changelog.d/skip-reply-reason-short.changed.md b/changelog.d/skip-reply-reason-short.changed.md new file mode 100644 index 0000000..1b46271 --- /dev/null +++ b/changelog.d/skip-reply-reason-short.changed.md @@ -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. diff --git a/src/framework.ts b/src/framework.ts index 3bad576..4d9ee6c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -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 diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 636e032..d999eb0 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -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: @@ -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', @@ -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 }); @@ -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 diff --git a/test/journal-tool.test.ts b/test/journal-tool.test.ts new file mode 100644 index 0000000..9a26cef --- /dev/null +++ b/test/journal-tool.test.ts @@ -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\(\)/); +});