From ec0a23d93d6276add88dcb3b23b714612f6f2194 Mon Sep 17 00:00:00 2001 From: antra-tess Date: Sat, 19 Sep 2026 10:26:34 -0700 Subject: [PATCH 1/6] feat(compression): tool-prose hoist fallback rung Long prose in a private-reasoning tool argument (skip_reply.reason, think.content) gets every L1 compression over that history refused reasoning_extraction, independent of content (sill, 2026-09-19: 2-3KB diaries in skip_reply.reason; canonical AND source-only refused on every chunk; tea filler in the same field refuses; <=~100 chars passes). compressionToolProseFallback retries a refused canonical request once with each such argument moved into a call to a note-taking tool the agent really has (agent-framework `journal`), as its own round before the original call, and gives the source-only final rung the same rewrite if it also refuses. The target must be a real, declared tool: the summarizer is the agent and will imitate whatever shape it sees itself use. Validated with the built code on sill's logged refused requests: 12/13 pass (canonical 70-143k tokens and source-only); the 13th carried an operator marker string that is itself a trigger. Co-Authored-By: Claude Fable 5.1 --- .../compression-tool-prose-fallback.added.md | 15 ++ src/strategies/autobiographical.ts | 143 +++++++++++- src/tool-prose-hoist.ts | 123 +++++++++++ src/types/strategy.ts | 30 +++ test/tool-prose-hoist.test.ts | 205 ++++++++++++++++++ 5 files changed, 513 insertions(+), 3 deletions(-) create mode 100644 changelog.d/compression-tool-prose-fallback.added.md create mode 100644 src/tool-prose-hoist.ts create mode 100644 test/tool-prose-hoist.test.ts diff --git a/changelog.d/compression-tool-prose-fallback.added.md b/changelog.d/compression-tool-prose-fallback.added.md new file mode 100644 index 0000000..be6ac95 --- /dev/null +++ b/changelog.d/compression-tool-prose-fallback.added.md @@ -0,0 +1,15 @@ +- `compressionToolProseFallback: { intoTool, fromTools, field?, result?, minChars? }` + — an opt-in L1 compression fallback rung. Long prose kept in an argument of a + private-reasoning tool (`skip_reply.reason`, `think.content`) makes replayed + history read as a reasoning trace, and the memory-write is refused + `reasoning_extraction` regardless of content. On a canonical **refusal** the + request is retried once with each such argument moved into a call to + `intoTool` — a note-taking tool the agent really has (agent-framework's + `journal`) — placed as its own round just before the original call, with a + short stub left behind; if the source-only final rung is enabled and also + refuses, it gets the same rewrite once. Nothing the agent wrote is dropped. + The rung is skipped unless `intoTool` is among the declared tools and at least + one argument qualifies. Enabling or changing it is a new request regime, so + already-quarantined chunks earn a fresh bounded attempt without a manual + clear. Off by default: with the option unset, canonical requests, request + hashes and quarantine identity are byte-identical. diff --git a/src/strategies/autobiographical.ts b/src/strategies/autobiographical.ts index 1951b31..a93ddd4 100644 --- a/src/strategies/autobiographical.ts +++ b/src/strategies/autobiographical.ts @@ -33,6 +33,13 @@ import { getSummaryParentId } from '../types/strategy.js'; import { resolveEffectiveConfig, type ConfigLayer, type EffectiveConfigReport } from '../config-provenance.js'; import { selectKeeperL1s } from './keeper-selection.js'; import { splitMixedToolMessages, stripUnpairedToolBlocks } from '../normalize-tool-messages.js'; +import { + hoistToolProse, + DEFAULT_TOOL_PROSE_FIELD, + DEFAULT_TOOL_PROSE_MIN_CHARS, + DEFAULT_TOOL_PROSE_RESULT, + type ToolProseHoistOptions, +} from '../tool-prose-hoist.js'; import { recallEnvelopeAddedText, wrapRecallAnswerContent } from '../recall-envelope.js'; import { MessageStore } from '../message-store.js'; import { persistMintRequestPreimage } from '../mint-preimage.js'; @@ -468,6 +475,8 @@ interface CompressionRefusalNormalizedConfig { requestConfig: NormalizedRequest['config']; sourceOnlyFallbackEnabled?: boolean; sourceOnlyFallbackRequestHash?: string; + /** Normalized tool-prose hoist options; absent when the rung is off. */ + toolProseFallback?: ToolProseHoistOptions; } type CompressionAttemptOutcome = @@ -3515,6 +3524,38 @@ export class AutobiographicalStrategy implements ResettableStrategy { } } + /** Normalized `compressionToolProseFallback`, or undefined when off/invalid. */ + private toolProseHoistOptions(): ToolProseHoistOptions | undefined { + const raw = this.config.compressionToolProseFallback; + if (!raw || typeof raw.intoTool !== 'string' || !raw.intoTool) return undefined; + if (!Array.isArray(raw.fromTools) || raw.fromTools.length === 0) return undefined; + return { + intoTool: raw.intoTool, + field: raw.field || DEFAULT_TOOL_PROSE_FIELD, + result: raw.result || DEFAULT_TOOL_PROSE_RESULT, + minChars: typeof raw.minChars === 'number' && raw.minChars >= 0 + ? raw.minChars + : DEFAULT_TOOL_PROSE_MIN_CHARS, + fromTools: [...raw.fromTools], + }; + } + + /** + * The request with long private-reasoning tool arguments moved into calls to + * the configured real tool, or undefined when the rung is off, the target + * tool is not among the declared tools (never show the summarizer a tool the + * agent does not have), or nothing qualifies (an identical retry is a burned + * call). See `tool-prose-hoist.ts`. + */ + private toolProseHoistedRequest(request: NormalizedRequest): NormalizedRequest | undefined { + const options = this.toolProseHoistOptions(); + if (!options) return undefined; + if (!request.tools?.some((tool) => tool.name === options.intoTool)) return undefined; + const { messages, hoisted } = hoistToolProse(request.messages, options); + if (hoisted === 0) return undefined; + return { ...request, messages: messages as NormalizedRequest['messages'] }; + } + private compressionRefusalQuarantineRecord( chunk: Chunk, model: string, @@ -3544,6 +3585,9 @@ export class AutobiographicalStrategy implements ResettableStrategy { ...(sourceOnlyFallbackRequestHash !== undefined ? { sourceOnlyFallbackRequestHash } : {}), + // Conditionally spread: with the rung off the record, its key and its + // family stay byte-identical to what they were before the rung existed. + ...(this.toolProseHoistOptions() ? { toolProseFallback: this.toolProseHoistOptions()! } : {}), }; const familyKey = sha256Json({ model, @@ -5675,7 +5719,11 @@ export class AutobiographicalStrategy implements ResettableStrategy { active.record.normalizedConfig?.sourceOnlyFallbackEnabled === quarantineRecord.normalizedConfig.sourceOnlyFallbackEnabled && active.record.normalizedConfig?.sourceOnlyFallbackRequestHash === - quarantineRecord.normalizedConfig.sourceOnlyFallbackRequestHash, + quarantineRecord.normalizedConfig.sourceOnlyFallbackRequestHash && + // Same rule for the tool-prose rung: enabling or re-pointing it is a + // request-family change (sha of undefined-vs-options differs). + sha256Json(active.record.normalizedConfig?.toolProseFallback ?? null) === + sha256Json(quarantineRecord.normalizedConfig.toolProseFallback ?? null), ); const durableActive = durableQuarantine.get(quarantineRecord.key) ?? sameRegime.find((active) => active.record.familyKey === quarantineRecord.familyKey) @@ -5942,7 +5990,69 @@ export class AutobiographicalStrategy implements ResettableStrategy { metadata: attemptTraces[0], }); let fallbackResponse: NormalizedResponse | undefined; + + // One bounded attempt of a tool-prose-hoisted request. Same receipts + // as the source-only final rung; provider errors never escalate. + const runToolProseRung = async ( + hoistedRequest: NormalizedRequest, + curveLabel: string, + recallIds: string[], + recallLevels: number[], + coverageHash: string, + ): Promise => { + const requestHash = sha256Json(hoistedRequest); + try { + const rungResponse = await runAttempt(hoistedRequest, curveLabel, recallIds, recallLevels, coverageHash); + const trace = attemptTraces[attemptTraces.length - 1]!; + const assessment = this.assessFallbackCompressionResponse(rungResponse); + if (assessment.outcome === 'valid') { + trace.outcome = 'success'; + fallbackResponse = assessment.response; + response = assessment.response; + successfulTrace = trace; + } else { + trace.outcome = assessment.outcome; + outcomes.push({ + curveLabel, requestHash, outcome: assessment.outcome, + ...(assessment.stopReason !== undefined ? { stopReason: assessment.stopReason } : {}), + ...(assessment.outcome === 'provider_error' ? { errorType: assessment.errorType } : {}), + }); + } + logCompressionCall({ event: 'compression:curve-attempt', operation: 'compress_l1', metadata: trace }); + } catch (error) { + if (error instanceof Error && error.name === 'CompressionBranchDiscard') throw error; + const errorType = error && typeof error === 'object' && 'type' in error + ? String((error as { type: unknown }).type) + : error instanceof Error ? error.name : typeof error; + const trace = attemptTraces[attemptTraces.length - 1]; + if (trace?.curveLabel === curveLabel) { trace.outcome = 'provider_error'; trace.errorType = errorType; } + outcomes.push({ curveLabel, requestHash, outcome: 'provider_error', errorType }); + } + }; + + // ---- tool-prose hoist rung (compressionToolProseFallback) ---- + // FIRST after a canonical refusal, ahead of the recall variants: the + // variants vary the recall frontier and cannot dodge a carrier that + // lives in the chunk's own tool calls, so each would be a burned call + // (sill 2026-09-19: canonical AND source-only refused on every chunk; + // 22/22 passed with only this rewrite). Keeps the full canonical + // context, so a win here costs no voice. Refusal-gated: a truncated or + // tool_use canonical is not this rung's problem. + if (canonicalOutcome === 'refusal') { + const hoistedCanonical = this.toolProseHoistedRequest(request); + if (hoistedCanonical) { + await runToolProseRung( + hoistedCanonical, + 'tool-prose-hoist', + keptSummaries.map((summary) => summary.id), + keptSummaries.map((summary) => summary.level), + canonicalCoverageHash, + ); + } + } + for (const planned of fallbackPlan) { + if (fallbackResponse) break; const variant = variants.find((candidate) => candidate.parent.id === planned.parentId && candidate.requestHash === planned.requestHash, ); @@ -6120,6 +6230,20 @@ export class AutobiographicalStrategy implements ResettableStrategy { } } + // Source-only final refused too: the same carrier sits in the target + // chunk itself, so give the source-only shape the same rewrite once. + if (!fallbackResponse && sourceOnlyFallbackRequest) { + const hoistedSourceOnly = this.toolProseHoistedRequest(sourceOnlyFallbackRequest); + if (hoistedSourceOnly) { + await runToolProseRung( + hoistedSourceOnly, + 'source-only-tool-prose-hoist', + [], [], + sha256Json(chunk.messages.map((message) => message.id)), + ); + } + } + // ---- split-stitch rung (compressionSplitFallback) ---- // Last rung before quarantine. Observed 2026-09-05 (princess, Bedrock/Sonnet-4.5): // chunks refused whole in every presentation while sub-ranges folded cleanly; the @@ -6350,7 +6474,14 @@ export class AutobiographicalStrategy implements ResettableStrategy { // content exists; ask once, explicitly, for it as plain prose before // giving up. Retry-only: first attempts stay byte-canonical. const sourceOnlyFinalWon = successfulTrace?.curveLabel === 'source-only-final'; - if (!summaryText.trim() && !sourceOnlyFinalWon) { + // A tool-prose rung only runs after the canonical request was REFUSED, so + // the canonical plain-prose retry below would be a guaranteed burned call. + const toolProseWonTrace = + successfulTrace?.curveLabel === 'tool-prose-hoist' || + successfulTrace?.curveLabel === 'source-only-tool-prose-hoist' + ? successfulTrace + : undefined; + if (!summaryText.trim() && !sourceOnlyFinalWon && !toolProseWonTrace) { console.warn( `[autobiographical] L1 summary stripped to empty (thinking-wrapped generation) — retrying once with plain-prose instruction`, ); @@ -6386,7 +6517,13 @@ export class AutobiographicalStrategy implements ResettableStrategy { // exhaustion path as the refusal curves; the record is sticky by // chunk hash and clears via success-clear / sweep / operator. if (this.isCompressionBranchCurrent(sourceBranch)) { - const emptyOutcomes: CompressionRefusalOutcomeRecord[] = sourceOnlyFinalWon + const emptyOutcomes: CompressionRefusalOutcomeRecord[] = toolProseWonTrace + ? [{ + curveLabel: toolProseWonTrace.curveLabel, + outcome: 'unusable_empty', + requestHash: toolProseWonTrace.requestHash, + }] + : sourceOnlyFinalWon ? [{ curveLabel: 'source-only-final', outcome: 'unusable_empty', diff --git a/src/tool-prose-hoist.ts b/src/tool-prose-hoist.ts new file mode 100644 index 0000000..e00879e --- /dev/null +++ b/src/tool-prose-hoist.ts @@ -0,0 +1,123 @@ +import type { ContentBlock } from '@animalabs/membrane'; + +/** + * Tool-prose hoist (2026-09-19, sill). + * + * Observed: an L1 compression request whose replayed history contains a + * tool_use carrying LONG prose in an argument of a "private reasoning" tool + * (sill's `skip_reply.reason`, used as a 2–3KB diary) is refused + * `reasoning_extraction` in ~1s. Canary-established on the logged refused + * requests, one variable at a time: + * - content-INDEPENDENT (2.3KB of tea filler in the same field refuses; + * ≤ ~100 chars passes; 400 chars refuses), and independent of the + * tool_result text and of the call's position in the chunk; + * - sensitive to the tool's NAME/semantics, not to having a destination: + * `think{content}` refuses in every arrangement, `skip_reply{reason|note}` + * refuses, while `send_message`, `workspace--write`, and a note-taking + * tool (`journal{content}`, 8/8 real requests with the tool declared) pass. + * + * The rewrite therefore moves the prose into a call to a tool THE AGENT + * REALLY HAS (`intoTool`), placed as its own round immediately before the + * original call, and leaves a short stub in the original argument. Nothing the + * agent wrote is dropped. The target must be a real tool because a summarizer + * is the agent itself: whatever shape it sees itself using, it may use again + * after waking. A shape it cannot actually perform (plain assistant text that + * would be routed to a channel, a framework-voiced annotation, a file write + * that never happened) teaches a habit that leaks or fails. The caller is + * responsible for checking `intoTool` is among the declared tools. + * + * Pure function over wire-shape messages. Only top-level string arguments are + * considered. A call whose tool_result cannot be found is left untouched. + * Inserted tool ids are derived from the original id, so the rewritten request + * (and its hash) is deterministic. + */ +export interface ToolProseHoistOptions { + /** Name of the real tool that receives the prose, exactly as declared. */ + intoTool: string; + /** Argument of `intoTool` that receives the prose. */ + field: string; + /** tool_result content recorded for each inserted call. */ + result: string; + /** Rewrite string arguments strictly longer than this many characters. */ + minChars: number; + /** + * Tools to rewrite FROM. A name matches exactly or as the final + * `--`-separated segment. Required and never defaulted to "all": moving a + * `send_message` body into a journal would author a false memory. + */ + fromTools: readonly string[]; +} + +export const DEFAULT_TOOL_PROSE_MIN_CHARS = 100; +export const DEFAULT_TOOL_PROSE_FIELD = 'content'; +export const DEFAULT_TOOL_PROSE_RESULT = + '{"recorded":true,"note":"Journal entry recorded (private — not sent anywhere)."}'; + +export function toolProseStub(intoTool: string, chars: number): string { + return `(written to ${intoTool} just before this — ${chars} chars)`; +} + +export function toolNameMatches(name: string, tools: readonly string[]): boolean { + return tools.some((t) => name === t || name.endsWith(`--${t}`)); +} + +type WireMessage = { participant: string; content: ContentBlock[] }; +type ToolUse = ContentBlock & { type: 'tool_use'; id: string; name: string; input: unknown }; + +export function hoistToolProse( + messages: readonly T[], + options: ToolProseHoistOptions, +): { messages: WireMessage[]; hoisted: number } { + // tool_use id -> participant of the message carrying its tool_result. + const resultParticipant = new Map(); + for (const message of messages) { + for (const block of message.content) { + if (block.type === 'tool_result') { + resultParticipant.set((block as { toolUseId: string }).toolUseId, message.participant); + } + } + } + + let hoisted = 0; + const out: WireMessage[] = []; + for (const message of messages) { + const rounds: WireMessage[] = []; + const content = message.content.map((block) => { + if (block.type !== 'tool_use') return block; + const use = block as ToolUse; + if (use.name === options.intoTool || !toolNameMatches(use.name, options.fromTools)) return block; + const resultSide = resultParticipant.get(use.id); + if (resultSide === undefined) return block; + if (!use.input || typeof use.input !== 'object' || Array.isArray(use.input)) return block; + let changed = false; + const input: Record = {}; + for (const [field, value] of Object.entries(use.input as Record)) { + if (typeof value !== 'string' || value.length <= options.minChars) { + input[field] = value; + continue; + } + const id = `${use.id}_${hoisted}`; + rounds.push( + { + participant: message.participant, + content: [{ type: 'tool_use', id, name: options.intoTool, input: { [options.field]: value } } as ContentBlock], + }, + { + participant: resultSide, + content: [{ type: 'tool_result', toolUseId: id, content: options.result } as ContentBlock], + }, + ); + input[field] = toolProseStub(options.intoTool, value.length); + changed = true; + hoisted++; + } + return changed ? ({ ...use, input } as ContentBlock) : block; + }); + if (rounds.length === 0) { + out.push(message); + continue; + } + out.push(...rounds, { ...message, content }); + } + return { messages: out, hoisted }; +} diff --git a/src/types/strategy.ts b/src/types/strategy.ts index 4923fd6..119061e 100644 --- a/src/types/strategy.ts +++ b/src/types/strategy.ts @@ -982,6 +982,36 @@ export interface AutobiographicalConfig { /** Final bounded source-only L1 attempt after canonical + recall variants. */ compressionSourceOnlyFallback?: boolean; + /** + * Tool-prose hoist rung (2026-09-19, sill). Off unless set. + * + * Long prose in an argument of a private-reasoning tool (`skip_reply.reason`, + * `think.content`) makes the replayed history read as a reasoning trace and + * the L1 request is refused `reasoning_extraction` regardless of content. On + * a canonical REFUSAL (only then) the request is retried once with each such + * argument moved into a call to `intoTool` — a note-taking tool the agent + * really has — placed as its own round just before the original call, a short + * stub left behind. If the source-only final rung is enabled and also refuses, + * it gets the same rewrite once. Nothing the agent wrote is dropped. + * + * The rung is SKIPPED unless `intoTool` is among the tools the host declared: + * the summarizer is the agent, and it must never be shown itself using a tool + * it does not have. Enabling (or changing) this is a new request regime, so + * already-quarantined chunks earn a fresh bounded attempt without a manual + * clear. See `tool-prose-hoist.ts` for the canary record. + */ + compressionToolProseFallback?: { + /** Declared name of the real note-taking tool that receives the prose. */ + intoTool: string; + /** Tools to rewrite from (exact name or final `--` segment). Required. */ + fromTools: string[]; + /** Argument of `intoTool` that receives the prose (default `content`). */ + field?: string; + /** tool_result content for the inserted calls (default: journal receipt). */ + result?: string; + /** Rewrite string arguments longer than this many chars (default 100). */ + minChars?: number; + }; /** * Split-stitch rung (2026-09-05, princess): when every L1 rung (canonical, recall * expansions, source-only-final) is refused, fold the chunk in halves recursively diff --git a/test/tool-prose-hoist.test.ts b/test/tool-prose-hoist.test.ts new file mode 100644 index 0000000..7ffc441 --- /dev/null +++ b/test/tool-prose-hoist.test.ts @@ -0,0 +1,205 @@ +import { after, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, rmSync } from 'node:fs'; +import type { ContentBlock, NormalizedRequest, ToolDefinition } from '@animalabs/membrane'; + +import { ContextManager, AutobiographicalStrategy } from '../src/index.js'; +import type { Chunk } from '../src/strategies/autobiographical.js'; +import type { StrategyContext, SummaryEntry } from '../src/types/index.js'; +import { hoistToolProse, toolProseStub, DEFAULT_TOOL_PROSE_RESULT } from '../src/tool-prose-hoist.js'; + +// Tool-prose hoist rung (2026-09-19, sill). Long prose in a private-reasoning +// tool argument (skip_reply.reason) gets an L1 request refused regardless of +// content; the rung retries with the prose moved into a REAL note-taking tool. + +const BASE = './test-tool-prose-hoist'; +let sequence = 0; +const paths: string[] = []; +function freshPath(): string { const p = `${BASE}-${sequence++}`; paths.push(p); return p; } +after(() => { for (const p of paths) if (existsSync(p)) rmSync(p, { recursive: true, force: true }); }); + +const text = (t: string): ContentBlock => ({ type: 'text', text: t }); +const DIARY = 'I weighed the thread and decided to stay quiet. '.repeat(12); // ~580 chars +const use = (id: string, name: string, input: Record): ContentBlock => + ({ type: 'tool_use', id, name, input } as ContentBlock); +const result = (id: string, content: string): ContentBlock => + ({ type: 'tool_result', toolUseId: id, content } as ContentBlock); + +const OPTS = { intoTool: 'journal', field: 'content', result: DEFAULT_TOOL_PROSE_RESULT, minChars: 100, fromTools: ['skip_reply'] }; + +function toolUses(req: { messages: Array<{ content: ContentBlock[] }> }) { + return req.messages.flatMap((m) => m.content.filter((b) => b.type === 'tool_use') as Array }>); +} + +describe('hoistToolProse (pure)', () => { + const messages = [ + { participant: 'User', content: [text('hello')] }, + { participant: 'Claude', content: [text('said aloud'), use('t1', 'skip_reply', { reason: DIARY, wake_in_seconds: 1500 })] }, + { participant: 'User', content: [result('t1', '{"skipped":true}'), text('next event')] }, + ]; + + it('moves the prose into its own round of the real tool, just before the original call', () => { + const { messages: out, hoisted } = hoistToolProse(messages, OPTS); + assert.equal(hoisted, 1); + assert.equal(out.length, 5); + assert.deepEqual(out[1], { participant: 'Claude', content: [use('t1_0', 'journal', { content: DIARY })] }); + assert.deepEqual(out[2], { participant: 'User', content: [result('t1_0', DEFAULT_TOOL_PROSE_RESULT)] }); + const original = out[3]!.content[1] as ContentBlock & { input: Record }; + assert.equal(original.input.reason, toolProseStub('journal', DIARY.length)); + assert.equal(original.input.wake_in_seconds, 1500, 'other arguments untouched'); + assert.deepEqual(out[3]!.content[0], text('said aloud'), 'assistant text stays where it was'); + assert.equal(out[4], messages[2], 'untouched messages keep their identity'); + // nothing the agent wrote is dropped + assert.ok(JSON.stringify(out).includes(DIARY)); + // input was not mutated + assert.equal((messages[1]!.content[1] as unknown as { input: { reason: string } }).input.reason, DIARY); + }); + + it('is deterministic (stable request hash)', () => { + assert.deepEqual(hoistToolProse(messages, OPTS), hoistToolProse(messages, OPTS)); + }); + + it('NEGATIVE: short arguments, other tools, the target tool itself, and unpaired calls are left alone', () => { + const others = [ + { participant: 'Claude', content: [use('a', 'skip_reply', { reason: 'nothing to add' })] }, + { participant: 'User', content: [result('a', 'ok')] }, + { participant: 'Claude', content: [use('b', 'mcpl--discord--send_message', { channelId: 'c', content: DIARY })] }, + { participant: 'User', content: [result('b', 'ok')] }, + { participant: 'Claude', content: [use('c', 'journal', { content: DIARY })] }, + { participant: 'User', content: [result('c', 'ok')] }, + { participant: 'Claude', content: [use('d', 'skip_reply', { reason: DIARY })] }, // no tool_result + ]; + const { messages: out, hoisted } = hoistToolProse(others, { ...OPTS, fromTools: ['skip_reply', 'journal'] }); + assert.equal(hoisted, 0); + assert.deepEqual(out, others); + }); + + it('matches a namespaced tool by its final segment', () => { + const ns = [ + { participant: 'Claude', content: [use('n', 'agent--think', { content: DIARY })] }, + { participant: 'User', content: [result('n', 'ok')] }, + ]; + assert.equal(hoistToolProse(ns, { ...OPTS, fromTools: ['think'] }).hoisted, 1); + }); +}); + +class ProbeStrategy extends AutobiographicalStrategy { + seed(entry: SummaryEntry): void { this.pushSummary(entry); } + run(chunk: Chunk, ctx: StrategyContext): Promise { return this.compressChunkHierarchical(chunk, ctx); } +} +function managerContext(manager: ContextManager): StrategyContext { + return (manager as unknown as { createStrategyContext(): StrategyContext }).createStrategyContext(); +} +const tool = (name: string): ToolDefinition => ({ name, description: 'd', inputSchema: { type: 'object', properties: {} } } as never); +const REFUSAL = { content: [], stopReason: 'refusal', usage: { inputTokens: 100, outputTokens: 0 }, raw: { response: { stop_details: { category: 'reasoning_extraction' } } } }; +const OK = (t: string) => ({ content: [text(t)], stopReason: 'end_turn', usage: { inputTokens: 80, outputTokens: 20 } }); + +interface Opts { hoist?: boolean; sourceOnlyFallback?: boolean; tools?: string[]; reason?: string; path?: string; } +function config(opts: Opts) { + return { + compressionModel: 'same-model', targetChunkTokens: 100, recentWindowTokens: 0, headWindowTokens: 100_000, + autoTickOnNewMessage: false, minChunkCharsForLLM: 0, mergeThreshold: 99, + compressionRefusalCurveFallbacks: 0, + compressionSourceOnlyFallback: opts.sourceOnlyFallback, + ...(opts.hoist ? { compressionToolProseFallback: { intoTool: 'journal', fromTools: ['skip_reply'] } } : {}), + } as never; +} +async function build(membrane: unknown, opts: Opts = {}) { + const strategy = new ProbeStrategy(config(opts)); + const manager = await ContextManager.open({ path: opts.path ?? freshPath(), strategy, membrane: membrane as never }); + manager.setToolDefinitions((opts.tools ?? ['skip_reply', 'journal']).map(tool)); + const ids: string[] = []; + for (let i = 0; i < 10; i++) ids.push(manager.addMessage(i % 2 ? 'Claude' : 'User', [text(`raw-${i} ` + 'substantive '.repeat(12))])); + ids.push(manager.addMessage('User', [text('raw-10 ambient chatter ' + 'substantive '.repeat(12))])); + ids.push(manager.addMessage('Claude', [use('skip1', 'skip_reply', { reason: opts.reason ?? DIARY, wake_in_seconds: 1500 })])); + ids.push(manager.addMessage('User', [result('skip1', '{"skipped":true}')])); + strategy.seed({ id: 'L1-100', level: 1, content: 'authored L1-100', tokens: 20, sourceLevel: 0, sourceIds: [ids[0]!, ids[1]!], sourceRange: { first: ids[0]!, last: ids[1]! }, created: 100 }); + return { manager, strategy, ids, target: () => targetOf(manager, ids) }; +} +function targetOf(manager: ContextManager, ids: string[]): Chunk { + const want = new Set(ids.slice(10)); + return { index: 999, startIndex: 10, endIndex: 13, messages: managerContext(manager).messageStore.getAll().filter((m) => want.has(m.id)), tokens: 100, compressed: false }; +} +function scripted(responses: Array>) { + const calls: NormalizedRequest[] = []; let n = 0; + return { calls, membrane: { complete: async (request: NormalizedRequest) => { calls.push(structuredClone(request)); return responses[Math.min(n++, responses.length - 1)]; } } as never }; +} + +// The fixture's large head window replays the chunk's messages in the head as +// well as in the target slot, so canonical requests carry each call twice. +const names = (req: NormalizedRequest): string => [...new Set(toolUses(req).map((u) => u.name))].join('+'); + +describe('tool-prose hoist rung', () => { + it('RUNG: canonical refusal → one hoisted canonical retry, which lands the memory', async () => { + const { calls, membrane } = scripted([REFUSAL, OK('hoisted memory')]); + const fx = await build(membrane, { hoist: true }); + await fx.strategy.run(fx.target(), managerContext(fx.manager)); + assert.equal(calls.length, 2); + assert.equal(names(calls[0]!), 'skip_reply', 'canonical is untouched'); + assert.ok(toolUses(calls[0]!).every((u) => u.input.reason === DIARY)); + assert.equal(names(calls[1]!), 'journal+skip_reply'); + for (const u of toolUses(calls[1]!)) { + if (u.name === 'journal') assert.equal(u.input.content, DIARY); + else assert.equal(u.input.reason, toolProseStub('journal', DIARY.length), 'no long argument survives anywhere in the request'); + } + // full canonical context kept: same head/recall text on both calls + const flat = (r: NormalizedRequest) => r.messages.flatMap((m) => m.content.filter((b) => b.type === 'text').map((b) => (b as { text: string }).text)); + assert.deepEqual(flat(calls[1]!), flat(calls[0]!)); + assert.equal(fx.strategy.getCompressionQuarantineStatus().count, 0); + }); + + it('INVARIANT: canonical success never triggers the rung; canonical bytes identical with the option on vs off', async () => { + const on = scripted([OK('m')]); const off = scripted([OK('m')]); + const a = await build(on.membrane, { hoist: true }); await a.strategy.run(a.target(), managerContext(a.manager)); + const b = await build(off.membrane, { hoist: false }); await b.strategy.run(b.target(), managerContext(b.manager)); + assert.equal(on.calls.length, 1); assert.equal(off.calls.length, 1); + const strip = (r: NormalizedRequest) => JSON.stringify(r.messages.map((m) => m.content.map((blk) => ({ ...blk, id: undefined, toolUseId: undefined })))); + assert.equal(strip(on.calls[0]!), strip(off.calls[0]!)); + }); + + it('GUARD: skipped when the target tool is not declared (never show a tool the agent does not have)', async () => { + const { calls, membrane } = scripted([REFUSAL]); + const fx = await build(membrane, { hoist: true, tools: ['skip_reply'] }); + await fx.strategy.run(fx.target(), managerContext(fx.manager)); + assert.equal(calls.length, 1); + assert.ok(fx.strategy.getCompressionQuarantineStatus().count >= 1); + }); + + it('GUARD: nothing to hoist → no identical burned retry', async () => { + const { calls, membrane } = scripted([REFUSAL]); + const fx = await build(membrane, { hoist: true, reason: 'nothing to add' }); + await fx.strategy.run(fx.target(), managerContext(fx.manager)); + assert.equal(calls.length, 1); + }); + + it('ORDER + BOUND: canonical → hoist → source-only → source-only hoist, then quarantine', async () => { + const { calls, membrane } = scripted([REFUSAL]); + const fx = await build(membrane, { hoist: true, sourceOnlyFallback: true }); + await fx.strategy.run(fx.target(), managerContext(fx.manager)); + assert.deepEqual(calls.map(names), + ['skip_reply', 'journal+skip_reply', 'skip_reply', 'journal+skip_reply']); + assert.ok(calls[2]!.messages.length < calls[0]!.messages.length, 'third call is source-only'); + assert.ok(calls[3]!.messages.length < calls[1]!.messages.length, 'fourth call is source-only, hoisted'); + assert.ok(fx.strategy.getCompressionQuarantineStatus().count >= 1); + }); + + it('FAMILY: enabling the rung gives an already-quarantined chunk a fresh attempt without a manual clear', async () => { + const path = freshPath(); + const first = scripted([REFUSAL]); + const a = await build(first.membrane, { hoist: false, path }); + await a.strategy.run(a.target(), managerContext(a.manager)); + assert.ok(a.strategy.getCompressionQuarantineStatus().count >= 1); + // same regime: sticky, no new call + await a.strategy.run(a.target(), managerContext(a.manager)); + assert.equal(first.calls.length, 1); + a.manager.close(); + + const second = scripted([REFUSAL, OK('fresh family')]); + const strategy = new ProbeStrategy(config({ hoist: true })); + const manager = await ContextManager.open({ path, strategy, membrane: second.membrane }); + manager.setToolDefinitions(['skip_reply', 'journal'].map(tool)); + await strategy.run(targetOf(manager, a.ids), managerContext(manager)); + assert.equal(second.calls.length, 2, 'canonical then hoisted'); + manager.close(); + }); +}); From cf1b297e33729f80641e0a66600422071a572905 Mon Sep 17 00:00:00 2001 From: antra-tess Date: Sat, 19 Sep 2026 10:32:09 -0700 Subject: [PATCH 2/6] fix(scripts): drain passes through the tool-prose and split fallback rungs An offline drain that silently drops the recipe's refusal-fallback rungs reproduces the live refusals it was stopped to get away from. Co-Authored-By: Claude Fable 5.1 --- scripts/drain-autobiographical.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/drain-autobiographical.ts b/scripts/drain-autobiographical.ts index de2a97e..6464ce3 100644 --- a/scripts/drain-autobiographical.ts +++ b/scripts/drain-autobiographical.ts @@ -79,6 +79,11 @@ const passthroughKeys = [ 'compressionSourceOnlyFallback', 'compressionMergeSourceOnly', 'compressionMergeSourceOnlyFallback', + 'compressionToolProseFallback', + 'compressionSplitFallback', + 'compressionSplitPlaceholder', + 'compressionSplitMaxCallsPerChunk', + 'compressionSplitMaxCallsPer10Min', 'compressionRecallBudgetTokens', 'summaryTargetTokens', 'identityReminder', From f0c340939d79d509ce4472b10d7cbdb8b734ba97 Mon Sep 17 00:00:00 2001 From: antra-tess Date: Sun, 20 Sep 2026 15:26:23 -0700 Subject: [PATCH 3/6] =?UTF-8?q?feat(compile):=20primaryToolProseHoist=20?= =?UTF-8?q?=E2=80=94=20render=20long=20private-tool=20args=20as=20journal?= =?UTF-8?q?=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diary carrier blocks PRIMARY turns as well as compression (sill, 2026-09-20): five skip_reply reasons of 400-570 chars in the raw tail got every primary wake refused reasoning_extraction. On the exact refused request, shortening them passes, hoisting them into journal rounds passes, and removing all 174 fleet-watch alert blocks does not. A restart cannot clear it: the reasons sit inside the tail, and each new turn adds another. primaryToolProseHoist applies the existing rewrite on every primary compile, recent turns included. Always-on so the render is deterministic (stable cached prefix); a view only, stored history is untouched; skipped unless the target tool is declared. Inserted ids are now per-call so they do not shift when earlier hoisted calls fold out of the window. Co-Authored-By: Claude Fable 5.1 --- changelog.d/primary-tool-prose-hoist.added.md | 11 ++++ .../tool-prose-hoist-stable-ids.changed.md | 3 ++ src/context-manager.ts | 13 +++++ src/strategies/autobiographical.ts | 26 ++++------ src/tool-prose-hoist.ts | 31 +++++++++++- src/types/strategy.ts | 30 +++++++++++ test/tool-prose-hoist.test.ts | 50 +++++++++++++++++++ 7 files changed, 148 insertions(+), 16 deletions(-) create mode 100644 changelog.d/primary-tool-prose-hoist.added.md create mode 100644 changelog.d/tool-prose-hoist-stable-ids.changed.md diff --git a/changelog.d/primary-tool-prose-hoist.added.md b/changelog.d/primary-tool-prose-hoist.added.md new file mode 100644 index 0000000..550f751 --- /dev/null +++ b/changelog.d/primary-tool-prose-hoist.added.md @@ -0,0 +1,11 @@ +- `primaryToolProseHoist: { intoTool, fromTools, field?, result?, minChars? }` — + the same rewrite as `compressionToolProseFallback`, applied to **every primary + compile**, recent turns included. Long prose in a private-reasoning tool + argument (`skip_reply.reason`, `think.content`) blocks primary turns too: five + 400–570-character reasons in the raw tail got every primary wake refused + `reasoning_extraction`, and moving them into `journal` rounds passed on the + exact refused request. Always-on rather than refusal-triggered so the render is + deterministic turn over turn (stable cached prefix). A view only — stored + history keeps the original words. Skipped unless `intoTool` is among the + declared tools. Off by default. Exposed to `ContextManager.compile` through the + new optional `ContextStrategy.getPrimaryToolProseHoist()`. diff --git a/changelog.d/tool-prose-hoist-stable-ids.changed.md b/changelog.d/tool-prose-hoist-stable-ids.changed.md new file mode 100644 index 0000000..0193d49 --- /dev/null +++ b/changelog.d/tool-prose-hoist-stable-ids.changed.md @@ -0,0 +1,3 @@ +- Tool-prose hoist: an inserted call's id is now `_` instead of counting across the whole request, so a hoisted + call renders identically after earlier hoisted calls leave the window. diff --git a/src/context-manager.ts b/src/context-manager.ts index d376151..22e7b6f 100644 --- a/src/context-manager.ts +++ b/src/context-manager.ts @@ -47,6 +47,7 @@ import { ContextLog } from './context-log.js'; import { filterMessageStoreView, mergeMessageStoreViews } from './message-view.js'; import { PassthroughStrategy } from './strategies/passthrough.js'; import { splitMixedToolMessages } from './normalize-tool-messages.js'; +import { hoistToolProse } from './tool-prose-hoist.js'; import { markStoreBranchSwitch, observeStoreBranch } from './branch-generation.js'; import type { StoreBranchGeneration } from './branch-generation.js'; @@ -743,6 +744,18 @@ export class ContextManager { } } + // Primary render hoist (`primaryToolProseHoist`): long private-reasoning + // tool arguments (skip_reply.reason, think.content) become calls to a + // note-taking tool the agent really has. A VIEW — the store is untouched. + // Only when that tool is declared: never show the agent a tool it lacks. + // Inserted rounds carry no cache breakpoint; a rewritten message keeps its + // own (object spread in hoistToolProse), so seam placement is unchanged. + const primaryHoist = this.strategy.getPrimaryToolProseHoist?.(); + if (primaryHoist && this.toolDefinitions?.some((tool) => tool.name === primaryHoist.intoTool)) { + const hoisted = hoistToolProse(messages, primaryHoist); + if (hoisted.hoisted > 0) messages.splice(0, messages.length, ...(hoisted.messages as NormalizedMessage[])); + } + // If no injections, log and return early if (!injections || injections.length === 0) { const result: CompileResult = { messages, systemInjections: [] }; diff --git a/src/strategies/autobiographical.ts b/src/strategies/autobiographical.ts index a93ddd4..200c728 100644 --- a/src/strategies/autobiographical.ts +++ b/src/strategies/autobiographical.ts @@ -35,9 +35,7 @@ import { selectKeeperL1s } from './keeper-selection.js'; import { splitMixedToolMessages, stripUnpairedToolBlocks } from '../normalize-tool-messages.js'; import { hoistToolProse, - DEFAULT_TOOL_PROSE_FIELD, - DEFAULT_TOOL_PROSE_MIN_CHARS, - DEFAULT_TOOL_PROSE_RESULT, + normalizeToolProseHoist, type ToolProseHoistOptions, } from '../tool-prose-hoist.js'; import { recallEnvelopeAddedText, wrapRecallAnswerContent } from '../recall-envelope.js'; @@ -3526,18 +3524,16 @@ export class AutobiographicalStrategy implements ResettableStrategy { /** Normalized `compressionToolProseFallback`, or undefined when off/invalid. */ private toolProseHoistOptions(): ToolProseHoistOptions | undefined { - const raw = this.config.compressionToolProseFallback; - if (!raw || typeof raw.intoTool !== 'string' || !raw.intoTool) return undefined; - if (!Array.isArray(raw.fromTools) || raw.fromTools.length === 0) return undefined; - return { - intoTool: raw.intoTool, - field: raw.field || DEFAULT_TOOL_PROSE_FIELD, - result: raw.result || DEFAULT_TOOL_PROSE_RESULT, - minChars: typeof raw.minChars === 'number' && raw.minChars >= 0 - ? raw.minChars - : DEFAULT_TOOL_PROSE_MIN_CHARS, - fromTools: [...raw.fromTools], - }; + return normalizeToolProseHoist(this.config.compressionToolProseFallback); + } + + /** + * Options for the PRIMARY render hoist (`primaryToolProseHoist`), or + * undefined when off. Read by ContextManager.compile, which owns the final + * wire-shape message list and the declared tools. + */ + getPrimaryToolProseHoist(): ToolProseHoistOptions | undefined { + return normalizeToolProseHoist(this.config.primaryToolProseHoist); } /** diff --git a/src/tool-prose-hoist.ts b/src/tool-prose-hoist.ts index e00879e..3bfc80b 100644 --- a/src/tool-prose-hoist.ts +++ b/src/tool-prose-hoist.ts @@ -26,6 +26,9 @@ import type { ContentBlock } from '@animalabs/membrane'; * that never happened) teaches a habit that leaks or fails. The caller is * responsible for checking `intoTool` is among the declared tools. * + * Used on refused L1 compression requests (fallback rung) and, when + * `primaryToolProseHoist` is set, on every primary compile. + * * Pure function over wire-shape messages. Only top-level string arguments are * considered. A call whose tool_result cannot be found is left untouched. * Inserted tool ids are derived from the original id, so the rewritten request @@ -90,13 +93,17 @@ export function hoistToolProse( if (resultSide === undefined) return block; if (!use.input || typeof use.input !== 'object' || Array.isArray(use.input)) return block; let changed = false; + let argIndex = 0; const input: Record = {}; for (const [field, value] of Object.entries(use.input as Record)) { if (typeof value !== 'string' || value.length <= options.minChars) { input[field] = value; continue; } - const id = `${use.id}_${hoisted}`; + // Indexed per CALL, not per request: in a live render an earlier hoisted + // call leaves the window when its chunk folds, and ids that counted + // across the request would all shift (breaking the cached prefix). + const id = `${use.id}_${argIndex++}`; rounds.push( { participant: message.participant, @@ -121,3 +128,25 @@ export function hoistToolProse( } return { messages: out, hoisted }; } + +/** Raw option shape accepted from strategy config (both hoist options share it). */ +export interface ToolProseHoistConfig { + intoTool: string; + fromTools: string[]; + field?: string; + result?: string; + minChars?: number; +} + +/** Normalize a config value; undefined when absent or unusable (treated as "off"). */ +export function normalizeToolProseHoist(raw: ToolProseHoistConfig | undefined): ToolProseHoistOptions | undefined { + if (!raw || typeof raw.intoTool !== 'string' || !raw.intoTool) return undefined; + if (!Array.isArray(raw.fromTools) || raw.fromTools.length === 0) return undefined; + return { + intoTool: raw.intoTool, + field: raw.field || DEFAULT_TOOL_PROSE_FIELD, + result: raw.result || DEFAULT_TOOL_PROSE_RESULT, + minChars: typeof raw.minChars === 'number' && raw.minChars >= 0 ? raw.minChars : DEFAULT_TOOL_PROSE_MIN_CHARS, + fromTools: [...raw.fromTools], + }; +} diff --git a/src/types/strategy.ts b/src/types/strategy.ts index 119061e..13b514c 100644 --- a/src/types/strategy.ts +++ b/src/types/strategy.ts @@ -137,6 +137,12 @@ export interface ContextStrategy { */ tick?(ctx: StrategyContext): Promise; + /** + * Primary render hoist options (see `primaryToolProseHoist`), or undefined + * when off. Optional: strategies without the option simply omit it. + */ + getPrimaryToolProseHoist?(): import('../tool-prose-hoist.js').ToolProseHoistOptions | undefined; + /** * React to new messages. * Called after a message is added to the store. @@ -1012,6 +1018,30 @@ export interface AutobiographicalConfig { /** Rewrite string arguments longer than this many chars (default 100). */ minChars?: number; }; + /** + * Primary render hoist (2026-09-20, sill). Same option shape and the same + * rewrite as `compressionToolProseFallback`, applied to EVERY primary compile + * (recent turns included), not only on a refusal. + * + * The diary carrier blocks primary turns too: five `skip_reply` reasons of + * 400–570 chars in the raw tail got every primary wake refused + * `reasoning_extraction`; shortening them, or moving them into `journal` + * rounds, passed on the exact refused request. Always-on rather than + * refusal-triggered because the render must be deterministic turn over turn + * (a stable cached prefix) and a refusal-first design pays a refused attempt + * on every affected turn. The store is never touched — this is a view. The + * agent sees itself journaling, which is the habit the tool exists for. + * + * Skipped (canonical render) unless `intoTool` is among the declared tools. + * Off unless set. + */ + primaryToolProseHoist?: { + intoTool: string; + fromTools: string[]; + field?: string; + result?: string; + minChars?: number; + }; /** * Split-stitch rung (2026-09-05, princess): when every L1 rung (canonical, recall * expansions, source-only-final) is refused, fold the chunk in halves recursively diff --git a/test/tool-prose-hoist.test.ts b/test/tool-prose-hoist.test.ts index 7ffc441..a35a3b1 100644 --- a/test/tool-prose-hoist.test.ts +++ b/test/tool-prose-hoist.test.ts @@ -203,3 +203,53 @@ describe('tool-prose hoist rung', () => { manager.close(); }); }); + +describe('primary render hoist (primaryToolProseHoist)', () => { + const PRIMARY = { primaryToolProseHoist: { intoTool: 'journal', fromTools: ['skip_reply'] } }; + async function buildPrimary(extra: Record, tools: string[]) { + const strategy = new ProbeStrategy({ ...(config({}) as Record), ...extra } as never); + const manager = await ContextManager.open({ path: freshPath(), strategy, membrane: scripted([OK('m')]).membrane }); + manager.setToolDefinitions(tools.map(tool)); + manager.addMessage('User', [text('hello there ' + 'substantive '.repeat(8))]); + manager.addMessage('Claude', [use('skipA', 'skip_reply', { reason: DIARY, wake_in_seconds: 600 })]); + manager.addMessage('User', [result('skipA', '{"skipped":true}'), text('a new message arrives')]); + manager.addMessage('Claude', [use('skipB', 'skip_reply', { reason: DIARY + ' second', wake_in_seconds: 600 })]); + manager.addMessage('User', [result('skipB', '{"skipped":true}'), text('and another')]); + return manager; + } + const compiledUses = async (manager: ContextManager) => toolUses(await manager.compile({ maxTokens: 100_000, reserveForResponse: 1_000 })); + + it('RENDER: every compile shows long reasons as journal rounds, recent turns included; the store is untouched', async () => { + const manager = await buildPrimary(PRIMARY, ['skip_reply', 'journal']); + const uses = await compiledUses(manager); + assert.deepEqual(uses.map((u) => `${u.name}:${u.id}`), ['journal:skipA_0', 'skip_reply:skipA', 'journal:skipB_0', 'skip_reply:skipB']); + assert.equal(uses[2]!.input.content, DIARY + ' second'); + assert.equal(uses[3]!.input.reason, toolProseStub('journal', (DIARY + ' second').length)); + const stored = managerContext(manager).messageStore.getAll().flatMap((m) => m.content).filter((b) => b.type === 'tool_use') as unknown as Array<{ input: { reason: string } }>; + assert.deepEqual(stored.map((b) => b.input.reason), [DIARY, DIARY + ' second'], 'a view only — stored history keeps the original words'); + // deterministic turn over turn (stable cached prefix) + assert.deepEqual(await compiledUses(manager), uses); + manager.close(); + }); + + it('GUARD: canonical render when the target tool is not declared, and when the option is off', async () => { + const noTool = await buildPrimary(PRIMARY, ['skip_reply']); + assert.deepEqual((await compiledUses(noTool)).map((u) => u.name), ['skip_reply', 'skip_reply']); + noTool.close(); + const off = await buildPrimary({}, ['skip_reply', 'journal']); + const uses = await compiledUses(off); + assert.deepEqual(uses.map((u) => u.name), ['skip_reply', 'skip_reply']); + assert.equal(uses[0]!.input.reason, DIARY); + off.close(); + }); + + it('STABLE IDS: an inserted id depends only on its own call, not on earlier hoists in the window', () => { + const round = (id: string) => [ + { participant: 'Claude', content: [use(id, 'skip_reply', { reason: DIARY })] }, + { participant: 'User', content: [result(id, 'ok')] }, + ]; + const both = hoistToolProse([...round('a'), ...round('b')], OPTS).messages; + const onlyB = hoistToolProse(round('b'), OPTS).messages; + assert.deepEqual(both.slice(4), onlyB, 'b renders identically once a has folded away'); + }); +}); From 937472bd9542158a6d57999bb33af7ee7641a8d9 Mon Sep 17 00:00:00 2001 From: antra-tess Date: Sun, 20 Sep 2026 16:41:05 -0700 Subject: [PATCH 4/6] revert: primaryToolProseHoist (render-time rewrite) Not wanted: the diaries are rewritten once in the resident's stored history instead of on every render. Returns the branch to the compression-only fallback rung. This reverts commit f0c3409. Co-Authored-By: Claude Fable 5.1 --- changelog.d/primary-tool-prose-hoist.added.md | 11 ---- .../tool-prose-hoist-stable-ids.changed.md | 3 -- src/context-manager.ts | 13 ----- src/strategies/autobiographical.ts | 26 ++++++---- src/tool-prose-hoist.ts | 31 +----------- src/types/strategy.ts | 30 ----------- test/tool-prose-hoist.test.ts | 50 ------------------- 7 files changed, 16 insertions(+), 148 deletions(-) delete mode 100644 changelog.d/primary-tool-prose-hoist.added.md delete mode 100644 changelog.d/tool-prose-hoist-stable-ids.changed.md diff --git a/changelog.d/primary-tool-prose-hoist.added.md b/changelog.d/primary-tool-prose-hoist.added.md deleted file mode 100644 index 550f751..0000000 --- a/changelog.d/primary-tool-prose-hoist.added.md +++ /dev/null @@ -1,11 +0,0 @@ -- `primaryToolProseHoist: { intoTool, fromTools, field?, result?, minChars? }` — - the same rewrite as `compressionToolProseFallback`, applied to **every primary - compile**, recent turns included. Long prose in a private-reasoning tool - argument (`skip_reply.reason`, `think.content`) blocks primary turns too: five - 400–570-character reasons in the raw tail got every primary wake refused - `reasoning_extraction`, and moving them into `journal` rounds passed on the - exact refused request. Always-on rather than refusal-triggered so the render is - deterministic turn over turn (stable cached prefix). A view only — stored - history keeps the original words. Skipped unless `intoTool` is among the - declared tools. Off by default. Exposed to `ContextManager.compile` through the - new optional `ContextStrategy.getPrimaryToolProseHoist()`. diff --git a/changelog.d/tool-prose-hoist-stable-ids.changed.md b/changelog.d/tool-prose-hoist-stable-ids.changed.md deleted file mode 100644 index 0193d49..0000000 --- a/changelog.d/tool-prose-hoist-stable-ids.changed.md +++ /dev/null @@ -1,3 +0,0 @@ -- Tool-prose hoist: an inserted call's id is now `_` instead of counting across the whole request, so a hoisted - call renders identically after earlier hoisted calls leave the window. diff --git a/src/context-manager.ts b/src/context-manager.ts index 22e7b6f..d376151 100644 --- a/src/context-manager.ts +++ b/src/context-manager.ts @@ -47,7 +47,6 @@ import { ContextLog } from './context-log.js'; import { filterMessageStoreView, mergeMessageStoreViews } from './message-view.js'; import { PassthroughStrategy } from './strategies/passthrough.js'; import { splitMixedToolMessages } from './normalize-tool-messages.js'; -import { hoistToolProse } from './tool-prose-hoist.js'; import { markStoreBranchSwitch, observeStoreBranch } from './branch-generation.js'; import type { StoreBranchGeneration } from './branch-generation.js'; @@ -744,18 +743,6 @@ export class ContextManager { } } - // Primary render hoist (`primaryToolProseHoist`): long private-reasoning - // tool arguments (skip_reply.reason, think.content) become calls to a - // note-taking tool the agent really has. A VIEW — the store is untouched. - // Only when that tool is declared: never show the agent a tool it lacks. - // Inserted rounds carry no cache breakpoint; a rewritten message keeps its - // own (object spread in hoistToolProse), so seam placement is unchanged. - const primaryHoist = this.strategy.getPrimaryToolProseHoist?.(); - if (primaryHoist && this.toolDefinitions?.some((tool) => tool.name === primaryHoist.intoTool)) { - const hoisted = hoistToolProse(messages, primaryHoist); - if (hoisted.hoisted > 0) messages.splice(0, messages.length, ...(hoisted.messages as NormalizedMessage[])); - } - // If no injections, log and return early if (!injections || injections.length === 0) { const result: CompileResult = { messages, systemInjections: [] }; diff --git a/src/strategies/autobiographical.ts b/src/strategies/autobiographical.ts index 200c728..a93ddd4 100644 --- a/src/strategies/autobiographical.ts +++ b/src/strategies/autobiographical.ts @@ -35,7 +35,9 @@ import { selectKeeperL1s } from './keeper-selection.js'; import { splitMixedToolMessages, stripUnpairedToolBlocks } from '../normalize-tool-messages.js'; import { hoistToolProse, - normalizeToolProseHoist, + DEFAULT_TOOL_PROSE_FIELD, + DEFAULT_TOOL_PROSE_MIN_CHARS, + DEFAULT_TOOL_PROSE_RESULT, type ToolProseHoistOptions, } from '../tool-prose-hoist.js'; import { recallEnvelopeAddedText, wrapRecallAnswerContent } from '../recall-envelope.js'; @@ -3524,16 +3526,18 @@ export class AutobiographicalStrategy implements ResettableStrategy { /** Normalized `compressionToolProseFallback`, or undefined when off/invalid. */ private toolProseHoistOptions(): ToolProseHoistOptions | undefined { - return normalizeToolProseHoist(this.config.compressionToolProseFallback); - } - - /** - * Options for the PRIMARY render hoist (`primaryToolProseHoist`), or - * undefined when off. Read by ContextManager.compile, which owns the final - * wire-shape message list and the declared tools. - */ - getPrimaryToolProseHoist(): ToolProseHoistOptions | undefined { - return normalizeToolProseHoist(this.config.primaryToolProseHoist); + const raw = this.config.compressionToolProseFallback; + if (!raw || typeof raw.intoTool !== 'string' || !raw.intoTool) return undefined; + if (!Array.isArray(raw.fromTools) || raw.fromTools.length === 0) return undefined; + return { + intoTool: raw.intoTool, + field: raw.field || DEFAULT_TOOL_PROSE_FIELD, + result: raw.result || DEFAULT_TOOL_PROSE_RESULT, + minChars: typeof raw.minChars === 'number' && raw.minChars >= 0 + ? raw.minChars + : DEFAULT_TOOL_PROSE_MIN_CHARS, + fromTools: [...raw.fromTools], + }; } /** diff --git a/src/tool-prose-hoist.ts b/src/tool-prose-hoist.ts index 3bfc80b..e00879e 100644 --- a/src/tool-prose-hoist.ts +++ b/src/tool-prose-hoist.ts @@ -26,9 +26,6 @@ import type { ContentBlock } from '@animalabs/membrane'; * that never happened) teaches a habit that leaks or fails. The caller is * responsible for checking `intoTool` is among the declared tools. * - * Used on refused L1 compression requests (fallback rung) and, when - * `primaryToolProseHoist` is set, on every primary compile. - * * Pure function over wire-shape messages. Only top-level string arguments are * considered. A call whose tool_result cannot be found is left untouched. * Inserted tool ids are derived from the original id, so the rewritten request @@ -93,17 +90,13 @@ export function hoistToolProse( if (resultSide === undefined) return block; if (!use.input || typeof use.input !== 'object' || Array.isArray(use.input)) return block; let changed = false; - let argIndex = 0; const input: Record = {}; for (const [field, value] of Object.entries(use.input as Record)) { if (typeof value !== 'string' || value.length <= options.minChars) { input[field] = value; continue; } - // Indexed per CALL, not per request: in a live render an earlier hoisted - // call leaves the window when its chunk folds, and ids that counted - // across the request would all shift (breaking the cached prefix). - const id = `${use.id}_${argIndex++}`; + const id = `${use.id}_${hoisted}`; rounds.push( { participant: message.participant, @@ -128,25 +121,3 @@ export function hoistToolProse( } return { messages: out, hoisted }; } - -/** Raw option shape accepted from strategy config (both hoist options share it). */ -export interface ToolProseHoistConfig { - intoTool: string; - fromTools: string[]; - field?: string; - result?: string; - minChars?: number; -} - -/** Normalize a config value; undefined when absent or unusable (treated as "off"). */ -export function normalizeToolProseHoist(raw: ToolProseHoistConfig | undefined): ToolProseHoistOptions | undefined { - if (!raw || typeof raw.intoTool !== 'string' || !raw.intoTool) return undefined; - if (!Array.isArray(raw.fromTools) || raw.fromTools.length === 0) return undefined; - return { - intoTool: raw.intoTool, - field: raw.field || DEFAULT_TOOL_PROSE_FIELD, - result: raw.result || DEFAULT_TOOL_PROSE_RESULT, - minChars: typeof raw.minChars === 'number' && raw.minChars >= 0 ? raw.minChars : DEFAULT_TOOL_PROSE_MIN_CHARS, - fromTools: [...raw.fromTools], - }; -} diff --git a/src/types/strategy.ts b/src/types/strategy.ts index 13b514c..119061e 100644 --- a/src/types/strategy.ts +++ b/src/types/strategy.ts @@ -137,12 +137,6 @@ export interface ContextStrategy { */ tick?(ctx: StrategyContext): Promise; - /** - * Primary render hoist options (see `primaryToolProseHoist`), or undefined - * when off. Optional: strategies without the option simply omit it. - */ - getPrimaryToolProseHoist?(): import('../tool-prose-hoist.js').ToolProseHoistOptions | undefined; - /** * React to new messages. * Called after a message is added to the store. @@ -1018,30 +1012,6 @@ export interface AutobiographicalConfig { /** Rewrite string arguments longer than this many chars (default 100). */ minChars?: number; }; - /** - * Primary render hoist (2026-09-20, sill). Same option shape and the same - * rewrite as `compressionToolProseFallback`, applied to EVERY primary compile - * (recent turns included), not only on a refusal. - * - * The diary carrier blocks primary turns too: five `skip_reply` reasons of - * 400–570 chars in the raw tail got every primary wake refused - * `reasoning_extraction`; shortening them, or moving them into `journal` - * rounds, passed on the exact refused request. Always-on rather than - * refusal-triggered because the render must be deterministic turn over turn - * (a stable cached prefix) and a refusal-first design pays a refused attempt - * on every affected turn. The store is never touched — this is a view. The - * agent sees itself journaling, which is the habit the tool exists for. - * - * Skipped (canonical render) unless `intoTool` is among the declared tools. - * Off unless set. - */ - primaryToolProseHoist?: { - intoTool: string; - fromTools: string[]; - field?: string; - result?: string; - minChars?: number; - }; /** * Split-stitch rung (2026-09-05, princess): when every L1 rung (canonical, recall * expansions, source-only-final) is refused, fold the chunk in halves recursively diff --git a/test/tool-prose-hoist.test.ts b/test/tool-prose-hoist.test.ts index a35a3b1..7ffc441 100644 --- a/test/tool-prose-hoist.test.ts +++ b/test/tool-prose-hoist.test.ts @@ -203,53 +203,3 @@ describe('tool-prose hoist rung', () => { manager.close(); }); }); - -describe('primary render hoist (primaryToolProseHoist)', () => { - const PRIMARY = { primaryToolProseHoist: { intoTool: 'journal', fromTools: ['skip_reply'] } }; - async function buildPrimary(extra: Record, tools: string[]) { - const strategy = new ProbeStrategy({ ...(config({}) as Record), ...extra } as never); - const manager = await ContextManager.open({ path: freshPath(), strategy, membrane: scripted([OK('m')]).membrane }); - manager.setToolDefinitions(tools.map(tool)); - manager.addMessage('User', [text('hello there ' + 'substantive '.repeat(8))]); - manager.addMessage('Claude', [use('skipA', 'skip_reply', { reason: DIARY, wake_in_seconds: 600 })]); - manager.addMessage('User', [result('skipA', '{"skipped":true}'), text('a new message arrives')]); - manager.addMessage('Claude', [use('skipB', 'skip_reply', { reason: DIARY + ' second', wake_in_seconds: 600 })]); - manager.addMessage('User', [result('skipB', '{"skipped":true}'), text('and another')]); - return manager; - } - const compiledUses = async (manager: ContextManager) => toolUses(await manager.compile({ maxTokens: 100_000, reserveForResponse: 1_000 })); - - it('RENDER: every compile shows long reasons as journal rounds, recent turns included; the store is untouched', async () => { - const manager = await buildPrimary(PRIMARY, ['skip_reply', 'journal']); - const uses = await compiledUses(manager); - assert.deepEqual(uses.map((u) => `${u.name}:${u.id}`), ['journal:skipA_0', 'skip_reply:skipA', 'journal:skipB_0', 'skip_reply:skipB']); - assert.equal(uses[2]!.input.content, DIARY + ' second'); - assert.equal(uses[3]!.input.reason, toolProseStub('journal', (DIARY + ' second').length)); - const stored = managerContext(manager).messageStore.getAll().flatMap((m) => m.content).filter((b) => b.type === 'tool_use') as unknown as Array<{ input: { reason: string } }>; - assert.deepEqual(stored.map((b) => b.input.reason), [DIARY, DIARY + ' second'], 'a view only — stored history keeps the original words'); - // deterministic turn over turn (stable cached prefix) - assert.deepEqual(await compiledUses(manager), uses); - manager.close(); - }); - - it('GUARD: canonical render when the target tool is not declared, and when the option is off', async () => { - const noTool = await buildPrimary(PRIMARY, ['skip_reply']); - assert.deepEqual((await compiledUses(noTool)).map((u) => u.name), ['skip_reply', 'skip_reply']); - noTool.close(); - const off = await buildPrimary({}, ['skip_reply', 'journal']); - const uses = await compiledUses(off); - assert.deepEqual(uses.map((u) => u.name), ['skip_reply', 'skip_reply']); - assert.equal(uses[0]!.input.reason, DIARY); - off.close(); - }); - - it('STABLE IDS: an inserted id depends only on its own call, not on earlier hoists in the window', () => { - const round = (id: string) => [ - { participant: 'Claude', content: [use(id, 'skip_reply', { reason: DIARY })] }, - { participant: 'User', content: [result(id, 'ok')] }, - ]; - const both = hoistToolProse([...round('a'), ...round('b')], OPTS).messages; - const onlyB = hoistToolProse(round('b'), OPTS).messages; - assert.deepEqual(both.slice(4), onlyB, 'b renders identically once a has folded away'); - }); -}); From c1613ddaddb9caef24150e1fa224ebf88064d2c9 Mon Sep 17 00:00:00 2001 From: ereshkigal Date: Tue, 22 Sep 2026 15:11:52 -0700 Subject: [PATCH 5/6] compression: the source-only hoist runs only after a source-only REFUSAL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool-prose rung's second attempt keyed on `!fallbackResponse`, which is also true when source-only-final threw a provider error or came back truncated or empty — so an infrastructure failure bought a fourth, paid, hoisted call the rewrite could not help (Sol, #106 review). Track how the source-only attempt ended and run source-only-tool-prose-hoist only on 'refusal', the one outcome a carrier rewrite addresses; the canonical hoist was already gated this way. Test: GATE — refusal, refusal, thrown server_error → three calls, no hoisted fourth; refusal, refusal, max_tokens → the same. Red on 937472b (four calls), green here. tool-prose-hoist 11/11, full suite 807/807. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h --- src/strategies/autobiographical.ts | 15 +++++++++++++-- test/tool-prose-hoist.test.ts | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/strategies/autobiographical.ts b/src/strategies/autobiographical.ts index a93ddd4..cb50e33 100644 --- a/src/strategies/autobiographical.ts +++ b/src/strategies/autobiographical.ts @@ -6195,6 +6195,13 @@ export class AutobiographicalStrategy implements ResettableStrategy { break; } + // How the source-only final rung ended, for the hoist below: only a + // REFUSAL is a carrier problem the rewrite can address. A provider + // error, a truncated or empty generation is not, and a paid retry on + // top of one would contradict "provider errors never escalate" + // (Sol, #106 review: a thrown server_error on source-only-final still + // bought a fourth call). + let sourceOnlyOutcome: CompressionAttemptOutcome | 'incomplete' | undefined; if (!fallbackResponse && sourceOnlyFallbackRequest) { const curveLabel = 'source-only-final'; const requestHash = sha256Json(sourceOnlyFallbackRequest); @@ -6211,6 +6218,7 @@ export class AutobiographicalStrategy implements ResettableStrategy { response = assessment.response; successfulTrace = trace; } else { + sourceOnlyOutcome = assessment.outcome; trace.outcome = assessment.outcome; outcomes.push({ curveLabel, requestHash, outcome: assessment.outcome, @@ -6227,12 +6235,15 @@ export class AutobiographicalStrategy implements ResettableStrategy { const trace = attemptTraces[attemptTraces.length - 1]; if (trace?.curveLabel === curveLabel) { trace.outcome = 'provider_error'; trace.errorType = errorType; } outcomes.push({ curveLabel, requestHash, outcome: 'provider_error', errorType }); + sourceOnlyOutcome = 'provider_error'; } } - // Source-only final refused too: the same carrier sits in the target + // Source-only final REFUSED too: the same carrier sits in the target // chunk itself, so give the source-only shape the same rewrite once. - if (!fallbackResponse && sourceOnlyFallbackRequest) { + // Refusal-gated like the canonical hoist: any other way that attempt + // ended is not this rung's problem. + if (!fallbackResponse && sourceOnlyFallbackRequest && sourceOnlyOutcome === 'refusal') { const hoistedSourceOnly = this.toolProseHoistedRequest(sourceOnlyFallbackRequest); if (hoistedSourceOnly) { await runToolProseRung( diff --git a/test/tool-prose-hoist.test.ts b/test/tool-prose-hoist.test.ts index 7ffc441..45fbcc1 100644 --- a/test/tool-prose-hoist.test.ts +++ b/test/tool-prose-hoist.test.ts @@ -183,6 +183,26 @@ describe('tool-prose hoist rung', () => { assert.ok(fx.strategy.getCompressionQuarantineStatus().count >= 1); }); + it('GATE: the source-only hoist runs only after a source-only REFUSAL — a provider error or a truncated generation there ends the ladder at three calls', async () => { + // a provider error thrown by the source-only final attempt (Sol, #106 + // review: the reviewed head still made a fourth, hoisted call) + const thrown = (() => { + const calls: NormalizedRequest[] = []; let n = 0; + const plan: Array = [REFUSAL, REFUSAL, { throw: { type: 'server_error' } }]; + return { calls, membrane: { complete: async (request: NormalizedRequest) => { calls.push(structuredClone(request)); const r = plan[Math.min(n++, plan.length - 1)]!; if ('throw' in r) throw r.throw; return r; } } as never }; + })(); + const a = await build(thrown.membrane, { hoist: true, sourceOnlyFallback: true }); + await a.strategy.run(a.target(), managerContext(a.manager)); + assert.deepEqual(thrown.calls.map(names), ['skip_reply', 'journal+skip_reply', 'skip_reply'], + 'no source-only hoist after a provider error: the rewrite cannot address it'); + // a truncated source-only generation is not a refusal either + const truncated = scripted([REFUSAL, REFUSAL, { content: [text('a partial mem')], stopReason: 'max_tokens', usage: { inputTokens: 80, outputTokens: 20 } } as ReturnType]); + const b = await build(truncated.membrane, { hoist: true, sourceOnlyFallback: true }); + await b.strategy.run(b.target(), managerContext(b.manager)); + assert.deepEqual(truncated.calls.map(names), ['skip_reply', 'journal+skip_reply', 'skip_reply'], + 'no source-only hoist after a truncated source-only generation'); + }); + it('FAMILY: enabling the rung gives an already-quarantined chunk a fresh attempt without a manual clear', async () => { const path = freshPath(); const first = scripted([REFUSAL]); From 53bc975c9f7ca9e69e81c99705ffa1277a79b1eb Mon Sep 17 00:00:00 2001 From: antra-tess Date: Fri, 25 Sep 2026 12:43:53 -0700 Subject: [PATCH 6/6] compression: read toolProseHoistOptions() once in the quarantine record; changelog the drain passthrough The normalized-config spread called toolProseHoistOptions() twice; bind it once. Record in a fixed fragment that drain-autobiographical now honours the four split-fallback keys (slimepriestess, #106 review). Co-Authored-By: Claude Opus 5.5 --- changelog.d/drain-split-fallback-passthrough.fixed.md | 6 ++++++ src/strategies/autobiographical.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 changelog.d/drain-split-fallback-passthrough.fixed.md diff --git a/changelog.d/drain-split-fallback-passthrough.fixed.md b/changelog.d/drain-split-fallback-passthrough.fixed.md new file mode 100644 index 0000000..90c6d3d --- /dev/null +++ b/changelog.d/drain-split-fallback-passthrough.fixed.md @@ -0,0 +1,6 @@ +- `scripts/drain-autobiographical.ts` now passes the four split-fallback keys + (`compressionSplitFallback`, `compressionSplitPlaceholder`, + `compressionSplitMaxCallsPerChunk`, `compressionSplitMaxCallsPer10Min`) + through from the recipe, so an offline drain honours them; previously they + were silently dropped and a drain ran without the split-stitch rung even when + the resident had it on. (`compressionToolProseFallback` is passed through too.) diff --git a/src/strategies/autobiographical.ts b/src/strategies/autobiographical.ts index cb50e33..2e696c1 100644 --- a/src/strategies/autobiographical.ts +++ b/src/strategies/autobiographical.ts @@ -3574,6 +3574,7 @@ export class AutobiographicalStrategy implements ResettableStrategy { const fallbackLimit = this.normalizedCompressionFallbackLimit(); const contextBudgetTokens = this.normalizedCompressionContextBudget(); const canonicalRequestBoundTokens = this.compressionRequestInputBoundTokens(canonicalRequest); + const toolProseFallback = this.toolProseHoistOptions(); const normalizedConfig: CompressionRefusalNormalizedConfig = { accountingVersion: COMPRESSION_BUDGET_ACCOUNTING_VERSION, fallbackLimit, @@ -3587,7 +3588,7 @@ export class AutobiographicalStrategy implements ResettableStrategy { : {}), // Conditionally spread: with the rung off the record, its key and its // family stay byte-identical to what they were before the rung existed. - ...(this.toolProseHoistOptions() ? { toolProseFallback: this.toolProseHoistOptions()! } : {}), + ...(toolProseFallback ? { toolProseFallback } : {}), }; const familyKey = sha256Json({ model,