From 62069106e50a5f09c1720bd0613f57e4554c5400 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sat, 10 Oct 2026 18:17:31 -0400 Subject: [PATCH 1/9] fix(compile): compiles that are never sent are dry runs; a fork's seed carries no thinking Context-manager 0.13's SelectOptions.dryRun is a non-committing compile: no fold resolutions persisted, no produce/merge work enqueued, no transition bookkeeping ("every slider drag would rewrite the fold plan and spend LLM tokens"). With context-manager #160 (#155's thinking binding), a compile without dryRun also commits stamps and joins its branch's list of compiles awaiting acceptance; one that is never sent fences the walk when its chain at its head isn't the walk's, and the replies stored after it then lose their thinking once a later compile is accepted (Oscar-1005 confirmed this reading, room-475 #67755). Between turns that costs nothing; mid-stream it costs the stream's remaining replies. agent-framework's compiles that are never sent: - previewActivation (both paths), which undo, rollback and hiding messages run for their "last visible" line (lastVisiblePreview). It passes the new ActivationRequestOptions.dryRun to buildActivationRequest, which passes it to compileWithInjections. - ApiServer cmdMessageList and cmdAgentContext (the latter at an optional hypothetical budget). - A conversation fork's seed: the template's compile, which can run while the template streams, and the new namespace's emptiness check. Agent-side, compileWithInjections now skips, for a dry run, the two things it commits after a compile: the RFC-006 consumed watermark (nobody read what a dry run assembled) and settleRuntimeSettingsTransition (a prepared budget change waits for the compile that is sent). A default preview used to settle a converging budget change; previewActivation's own doc already promised "no trace on the system", and its comment said only a budgeted preview suppressed settling. The doc claims that ContextManager.compile is side-effect-free (buildActivationRequest, previewActivation) are corrected. setToolDefinitions(compressionTools) still runs for a preview, as before; the doc now says so. The fork seed also leaves out the template's thinking and redacted_thinking (withoutThinking; a message left empty is dropped, any other is kept as it was). Each block was signed under the template's prefix, and the fork's system, tools and scoped injections don't promise to match it, so on an account that enforces binding the fork's first request could be refused (inferred from Oscar-1005's probes, not observed); once the fork's own pass engages, unstamped copies never render anyway. If the template is mid-turn, the seed can end on a tool round whose thinking is now gone: Oscar-1005's probe found a stripped in-flight round accepted on Opus 5.5 and Haiku 5.5 (not probed: Sonnet 5.5, Fable 5.1). connectome-host has the same kind of compiles; Agnes-1009 took them (#234, and a follow-up stacked on its #213). Tests: test/unsent-compiles.test.ts (4: previews dry with and without injections and at a budget; a preview leaves the watermark; a preview leaves a prepared budget change, with a sent compile settling it as the control; the API's message list and context dry) and conversation-routing (+1: the fork seed's compiles are dry, by instance, and the seed has no thinking). Red at 02adf27 with only these test files: 5 fail, 15 pass. --- changelog.d/unsent-compiles-dry-run.fixed.md | 20 ++++ src/agent.ts | 38 +++++-- src/api/server.ts | 11 +- src/framework.ts | 60 ++++++++--- test/conversation-routing.test.ts | 44 ++++++++ test/unsent-compiles.test.ts | 101 +++++++++++++++++++ 6 files changed, 250 insertions(+), 24 deletions(-) create mode 100644 changelog.d/unsent-compiles-dry-run.fixed.md create mode 100644 test/unsent-compiles.test.ts diff --git a/changelog.d/unsent-compiles-dry-run.fixed.md b/changelog.d/unsent-compiles-dry-run.fixed.md new file mode 100644 index 000000000..ba04e1a81 --- /dev/null +++ b/changelog.d/unsent-compiles-dry-run.fixed.md @@ -0,0 +1,20 @@ +- Compiles that are never sent are now dry runs (context-manager's + `SelectOptions.dryRun`): `previewActivation`, which undo, rollback and + hiding messages also run for their "last visible" line; the API's + `message.list` and agent context commands; and a conversation fork's seed + (the template's compile and the new namespace's emptiness check). A + committing compile commits fold resolutions and can queue compression + work, and with context-manager's thinking binding (#155) it commits stamps + and joins the branch's compiles awaiting acceptance, where one never sent + can cost a live stream's later replies their thinking. A dry run also + leaves the agent's consumed watermark and a prepared budget change alone, + as `previewActivation` already promised; a preview at the live window used + to settle a converging budget change. `ActivationRequestOptions` gains + `dryRun`. +- A conversation fork's seed no longer copies the template's `thinking` and + `redacted_thinking` blocks, and leaves out a message with nothing else. + Each block was signed under the template's prefix, which the fork's system + prompt, tools and scoped injections don't promise to match: on an account + that enforces thinking binding, the fork's first request could be refused. + Once the fork's own thinking-binding pass engages, those copies would never + render anyway. diff --git a/src/agent.ts b/src/agent.ts index 412707a33..99e105804 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -20,6 +20,9 @@ import { export interface ActivationRequestOptions { /** The silent heartbeat tick this request answers, if any. */ silentHeartbeat?: SilentHeartbeatTick; + /** A request that will never be sent (a preview): compile it as a dry + * run, which commits nothing (see `compileWithInjections`). */ + dryRun?: boolean; } export interface StartStreamResult { @@ -591,6 +594,11 @@ export class Agent { } } + /** A committing compile, as for a request that is sent: the context + * manager commits what it commits for one, the consumed watermark moves, + * and a prepared budget change settles. To inspect what the agent would + * see, use the framework's `previewActivation`, which compiles as a dry + * run. */ async compileContext(budget?: TokenBudget): Promise { // RFC-006 §3.3: consumption is determined at assembly — a compile that // fails assembled nothing, so the watermark moves only on success. @@ -609,14 +617,20 @@ export class Agent { async compileWithInjections( budget?: TokenBudget, injections?: ContextInjection[], - opts?: { kvUnifiedImmutablePrefixHash?: string }, + opts?: { kvUnifiedImmutablePrefixHash?: string; dryRun?: boolean }, ): Promise { - const watermark = this.pendingWatermark(); + // A dry run is a compile that will never be sent: the context manager + // commits nothing for it (no fold resolutions, no compression work, and + // with thinking binding no stamps and no compile awaiting acceptance), + // and nothing here moves either. Nobody read what it assembled, so the + // consumed watermark stays, and a prepared budget change waits for the + // compile that is sent. + const watermark = opts?.dryRun ? null : this.pendingWatermark(); const result = await this.contextManager.compile( this.compileBudget(budget), injections, opts as never, ); if (watermark) this.consumedWatermark = watermark; - if (!budget) this.settleRuntimeSettingsTransition(); + if (!budget && !opts?.dryRun) this.settleRuntimeSettingsTransition(); return result; } @@ -789,10 +803,13 @@ export class Agent { * * This is the single source of truth for request assembly, shared by the * real activation path (`startStreamWithInjections`) and debug/preview - * tooling (`Framework.previewActivation`). It is pure and non-mutating: - * it does not touch agent state and does not call the membrane, and - * `ContextManager.compile` is itself side-effect-free (compression runs in - * the background). Safe to call regardless of the agent's current status. + * tooling (`Framework.previewActivation`). It does not call the membrane. + * A compile that is sent commits what the context manager commits for it + * (fold resolutions, compression work, the consumed watermark); a preview + * passes `options.dryRun`, so its compile commits none of that (see + * `compileWithInjections`). Either way the context manager is handed the + * compression tool definitions. Safe to call regardless of the agent's + * current status. */ async buildActivationRequest( availableTools: ToolDefinition[], @@ -829,7 +846,12 @@ export class Agent { let { messages, systemInjections } = await this.compileWithInjections( budget, injections, - immutablePrefixHash ? { kvUnifiedImmutablePrefixHash: immutablePrefixHash } : undefined, + immutablePrefixHash || options.dryRun + ? { + ...(immutablePrefixHash ? { kvUnifiedImmutablePrefixHash: immutablePrefixHash } : {}), + ...(options.dryRun ? { dryRun: true } : {}), + } + : undefined, ); // Sanitize: strip empty/whitespace text blocks and drop messages left with diff --git a/src/api/server.ts b/src/api/server.ts index 58664705b..4ca0164c1 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -465,7 +465,9 @@ export class ApiServer { } const contextManager = agent.getContextManager(); - const { messages } = await contextManager.compile(); + // A dry run: listed, never sent, so it commits nothing (fold + // resolutions, compression work, thinking-binding stamps). + const { messages } = await contextManager.compile(undefined, undefined, { dryRun: true }); const limit = params?.limit ?? 50; const offset = params?.offset ?? 0; @@ -666,10 +668,15 @@ export class ApiServer { } const contextManager = agent.getContextManager(); + // A dry run: shown, never sent, so it commits nothing (fold resolutions, + // compression work, thinking-binding stamps), at a hypothetical budget + // least of all. const { messages } = await contextManager.compile( params.maxTokens ? { maxTokens: params.maxTokens, reserveForResponse: 4096 } - : undefined + : undefined, + undefined, + { dryRun: true }, ); return { context: messages }; diff --git a/src/framework.ts b/src/framework.ts index 349bdd693..2b94b73ce 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -524,6 +524,20 @@ function normalizeDiscordAwarenessDeadline(value: number | undefined): number { ); } +/** Messages copied into another conversation (a fork's seed) without their + * `thinking` and `redacted_thinking` blocks, which are signed under the + * prefix they were minted with. A message left with nothing is dropped; + * any other message is kept as it was. */ +function withoutThinking(messages: readonly M[]): M[] { + const out: M[] = []; + for (const message of messages) { + const content = message.content.filter((b) => b.type !== 'thinking' && b.type !== 'redacted_thinking'); + if (content.length === 0) continue; + out.push(content.length === message.content.length ? message : { ...message, content }); + } + return out; +} + class DiscordAwarenessAccountingError extends Error { constructor(operation: string, cause: unknown) { const detail = cause instanceof Error ? cause.message : String(cause); @@ -3647,12 +3661,18 @@ export class AgentFramework { * mutating agent state. Intended for debug/preview tooling. * * Transparency contract (default): the preview is side-effect-free and - * leaves no trace on the system. It does only read-only work — - * `ContextManager.compile` (which never triggers compression itself; - * compression runs in the background out-of-band), tool filtering, and - * system-prompt assembly — then delegates to `Agent.buildActivationRequest`. - * No tokens are spent, nothing is written to Chronicle, and no external - * MCPL server is contacted. + * leaves no trace on the system. It does only read-only work — a dry-run + * compile (`dryRun`: no fold resolutions committed, no compression work + * queued, the consumed watermark and any prepared budget change left + * alone, and with context-manager's thinking binding no stamps and no + * compile awaiting acceptance), tool filtering, and system-prompt assembly + * — then delegates to `Agent.buildActivationRequest`. No tokens are spent, + * nothing is written to Chronicle, and no external MCPL server is + * contacted. Two things it still touches, as a real activation would: it + * hands the context manager the compression tool definitions, and + * context-manager 0.13's adaptive select records its estimate and arms + * calibration even on a dry run, so a preview between a real compile and + * its first usage report replaces that estimate. * * The trade-off is fidelity: the dynamically-gathered ContextInjection[] * (module `gatherContext` + MCPL `beforeInference` hooks) are NOT included @@ -3683,13 +3703,14 @@ export class AgentFramework { }; // An explicit budget compiles against a HYPOTHETICAL window instead of the - // agent's live one. That also suppresses transition-settling in - // compileWithInjections (which only settles when no budget is passed), so - // previewing a smaller window cannot advance a converging descent. + // agent's live one. Every preview is a dry run, so none of them settles + // a converging descent or commits anything (see the doc comment above). // Default: no dynamic injection gathering → fully transparent (no // inference, no Chronicle writes, no external RPC). Opt in explicitly. if (!opts?.injections) { - return capture(await agent.buildActivationRequest(tools, undefined, opts?.budget, presentation?.available ?? tools)); + return capture(await agent.buildActivationRequest( + tools, undefined, opts?.budget, presentation?.available ?? tools, { dryRun: true }, + )); } // Full-fidelity path: mirrors startAgentStream's injection gathering. @@ -3724,7 +3745,9 @@ export class AgentFramework { } } - return capture(await agent.buildActivationRequest(tools, injections, opts?.budget, presentation?.available ?? tools)); + return capture(await agent.buildActivationRequest( + tools, injections, opts?.budget, presentation?.available ?? tools, { dryRun: true }, + )); } /** @@ -7375,10 +7398,19 @@ export class AgentFramework { // persisted precisely so names aren't reused, but if this namespace has // history anyway (counter state lost, crash between spawn and persist), // seeding again would stack another template copy on top of it. - const { messages: existing } = await contextManager.compile(); + // + // Both compiles are dry runs: neither is sent, and the template's runs + // while the template may be mid-stream, where a committing compile + // would commit fold resolutions and queue compression work for it (and + // with context-manager's thinking binding, fence its stream's replies). + // The seed leaves out the template's thinking: a signed block verifies + // only while everything before it is what it was minted under, and a + // fork's system, tools and scoped injections don't promise that, while + // its own namespace holds no stamps that could vouch for the copies. + const { messages: existing } = await contextManager.compile(undefined, undefined, { dryRun: true }); if (existing.length === 0) { - const { messages: compiled } = await template.getContextManager().compile(); - for (const msg of compiled) { + const { messages: compiled } = await template.getContextManager().compile(undefined, undefined, { dryRun: true }); + for (const msg of withoutThinking(compiled)) { const participant = msg.participant === template.name ? name : msg.participant; contextManager.addMessage(participant, msg.content); } diff --git a/test/conversation-routing.test.ts b/test/conversation-routing.test.ts index cd55d3c4d..77a4d847d 100644 --- a/test/conversation-routing.test.ts +++ b/test/conversation-routing.test.ts @@ -238,6 +238,50 @@ describe('Conversation routing', () => { await framework.stop(); }); + it("a fork's seed is a dry run, and carries none of the template's thinking", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ok' }])); + const framework = await makeFramework(); + const trunk = framework.getAgent('trunk')!.getContextManager(); + trunk.addMessage('user', [{ type: 'text', text: 'HANDBOOK: always check the logs first.' }]); + trunk.addMessage('trunk', [ + { type: 'thinking', thinking: 'minted under the trunk\'s prefix', signature: 'sig-trunk' }, + { type: 'text', text: 'Checked the logs.' }, + ] as never); + trunk.addMessage('trunk', [{ type: 'redacted_thinking', data: 'opaque' }] as never); + + // Every compile on any context manager during the spawn, by instance. + const proto = (trunk.constructor as { prototype: { compile: (...args: unknown[]) => unknown } }).prototype; + const original = proto.compile; + const compiles: Array<{ cm: unknown; dry: boolean }> = []; + proto.compile = function (this: unknown, ...args: unknown[]) { + compiles.push({ cm: this, dry: (args[2] as { dryRun?: boolean } | undefined)?.dryRun === true }); + return original.apply(this, args); + }; + try { + framework.pushEvent(incomingEvent({ channelId: 'slack:D3', text: 'hi', channelType: 'im' })); + await framework.runUntilIdle(); + } finally { + proto.compile = original; + } + + const fork = framework.getAgent('conversation-slack-D3-g1')!.getContextManager(); + const onTrunk = compiles.filter((c) => c.cm === trunk); + assert.ok(onTrunk.length >= 1, 'the seed compiled the template'); + assert.ok(onTrunk.every((c) => c.dry), "the template's compiles during the spawn are dry runs"); + assert.equal(compiles.find((c) => c.cm === fork)?.dry, true, "the new namespace's emptiness check is a dry run"); + + const seeded = fork.getAllMessages(); + const types = seeded.flatMap((m) => m.content.map((b) => b.type)); + assert.ok(!types.includes('thinking') && !types.includes('redacted_thinking'), `no thinking in the seed: ${types}`); + const texts = seeded.map((m) => JSON.stringify(m.content)); + assert.ok(texts.some((t) => t.includes('HANDBOOK')) && texts.some((t) => t.includes('Checked the logs.'))); + assert.ok(seeded.some((m) => m.participant === 'conversation-slack-D3-g1' + && JSON.stringify(m.content).includes('Checked the logs.')), "the reply keeps its text, under the fork's name"); + assert.ok(seeded.every((m) => m.content.length > 0), 'the thinking-only message is left out, not stored empty'); + + await framework.stop(); + }); + // Exercise the delivery boundary without scheduling inference. The same // handler receives ordinary channel traffic and coalesced fixed-audience notices. async function deliver(framework: AgentFramework, channelId: string, text: string, deliverTo?: string) { diff --git a/test/unsent-compiles.test.ts b/test/unsent-compiles.test.ts new file mode 100644 index 000000000..3f9bbfa9d --- /dev/null +++ b/test/unsent-compiles.test.ts @@ -0,0 +1,101 @@ +/** + * Compiles that are never sent are dry runs. + * + * A committing compile commits fold resolutions and queues compression work. + * Agent-framework also moves the agent's consumed watermark (RFC-006) and + * settles a prepared budget change at a full compile. With context-manager's + * thinking binding (#155), a committing compile also commits stamps and joins + * the branch's compiles awaiting acceptance, where one never sent can fence + * the replies of a live stream. Previews and the API's message list and + * context are never sent, so each compiles as a dry run. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AutobiographicalStrategy, type ContextManager } from '@animalabs/context-manager'; +import { AgentFramework, ApiServer } from '../src/index.js'; +import { MockMembrane } from './helpers/mock-membrane.js'; + +async function make(agent: Record = {}) { + const dir = mkdtempSync(join(tmpdir(), 'unsent-compiles-')); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', ...agent }], + modules: [], + }); + return { + framework, + async close() { await framework.stop(); rmSync(dir, { recursive: true, force: true }); }, + }; +} + +/** Record whether each compile on `cm` is a dry run. */ +function recordCompiles(cm: ContextManager): boolean[] { + const dry: boolean[] = []; + const original = cm.compile.bind(cm); + (cm as unknown as { compile: (...args: unknown[]) => unknown }).compile = (...args: unknown[]) => { + dry.push((args[2] as { dryRun?: boolean } | undefined)?.dryRun === true); + return (original as (...a: unknown[]) => unknown)(...args); + }; + return dry; +} + +describe('compiles that are never sent', () => { + it('a preview is a dry run, with or without gathered injections', async () => { + const x = await make(); + try { + const dry = recordCompiles(x.framework.getAgent('agent')!.getContextManager()); + await x.framework.previewActivation('agent'); + await x.framework.previewActivation('agent', { injections: true }); + await x.framework.previewActivation('agent', { budget: { maxTokens: 50_000, reserveForResponse: 4_000 } }); + assert.deepEqual(dry, [true, true, true]); + } finally { await x.close(); } + }); + + it('a preview leaves the consumed watermark: nobody read what it assembled', async () => { + const x = await make(); + try { + const agent = x.framework.getAgent('agent')!; + const before = agent.getConsumedWatermark(); + agent.getContextManager().addMessage('user', [{ type: 'text', text: 'not yet read' }]); + await x.framework.previewActivation('agent'); + assert.deepEqual(agent.getConsumedWatermark(), before); + } finally { await x.close(); } + }); + + it('a preview leaves a prepared budget change for the compile that is sent', async () => { + const x = await make({ + strategy: new AutobiographicalStrategy({ + adaptiveResolution: true, foldingStrategy: 'kv-stable', recentWindowTokens: 30_000, kvStableReachTokens: 8_000, + }), + contextBudgetTokens: 100_000, + maxTokens: 10_000, + }); + try { + assert.equal(x.framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }).transition, 'converging'); + await x.framework.previewActivation('agent'); + assert.equal(x.framework.getAgentRuntimeSettings('agent').transition, 'converging', 'a preview settles nothing'); + // The control: a compile that is sent does settle it. + await x.framework.getAgent('agent')!.compileWithInjections(); + assert.equal(x.framework.getAgentRuntimeSettings('agent').transition, 'stable'); + } finally { await x.close(); } + }); + + it("the API's message list and agent context are dry runs", async () => { + const x = await make(); + try { + const dry = recordCompiles(x.framework.getAgent('agent')!.getContextManager()); + const server = new ApiServer(x.framework, { port: 0, host: '127.0.0.1' }) as unknown as { + cmdMessageList(params?: unknown): Promise; + cmdAgentContext(params: unknown): Promise; + }; + await server.cmdMessageList({}); + await server.cmdAgentContext({ agentName: 'agent' }); + await server.cmdAgentContext({ agentName: 'agent', maxTokens: 50_000 }); + assert.deepEqual(dry, [true, true, true]); + } finally { await x.close(); } + }); +}); From 8beb67231b89efc507a70c86ec11a9e1f5551248 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sun, 11 Oct 2026 15:54:35 -0400 Subject: [PATCH 2/9] fix(compile): say what a dry run still writes, and test the sent path's assembly A review of 6206910 found the change's text overclaiming and the sent path's assembly untested. This commit is the text and the tests; the fork seed's open questions (a trailing tool round on models nobody probed, opaque non-thinking blocks, a fork spawned outside the event loop) come in a second commit. Text: - "Commits nothing" was wrong for context-manager 0.13. Its adaptive select gates fold resolutions, produce/merge work, the kv-unified draft and the receipt supersede on dryRun, but on a dry run it still writes its end-of-select calibration bookkeeping (_lastCompileEstimate, _calibrationArmed), its transition bookkeeping (lastFrontierTokens, transitionBlocked, _lastKvStable) and its render stats. Only previewContext's finally restores the transition and render part, and nothing restores calibration; agent-framework calls compile({dryRun}). compileWithInjections' comment, ActivationRequestOptions.dryRun, previewActivation's doc (rewritten as one account: nothing durable; two in-memory touches, setToolDefinitions and 0.13's bookkeeping; dropped "side-effect-free", "no trace" and "as a real activation would"), the two API comments and the changelog now say exactly that. Not a regression: before this change these compiles committed, and every compile wrote the same bookkeeping. - Over budget: 0.13 gates only the post-pick over-budget throw on dryRun (src/strategies/autobiographical.ts ~8528 and src/strategies/windowed-passthrough.ts ~309, in its source); the early head+tail check and the render-stage refusals still throw, and compile() hands no report back (it goes to the strategy's private _lastPreview). So previews, agent context and the fork seed return a window larger than the budget where they threw. Said in previewActivation's doc, the agent context comment, AgentContextParams.maxTokens and the agent_context MCP tool's parameter text, the seed's comment (its compile passes no budget, so it's context-manager's default, and the fork's own compiles meet its window) and the changelog. - compileWithInjections' doc no longer says "Same as compileContext"; it names opts.dryRun and what it skips. The fork-spawn doc says the seed is the compiled context without its thinking. withoutThinking's doc gives the rule (a fork inherits the conversation, not the reasoning; unsigned reasoning goes by the same rule) and says an already-empty message is left out too. The changelog drops "would never render anyway" (it rests on context-manager#160, not in 0.13), cites anima-research/context-manager#160 for the binding, and keeps the "inferred, not observed" qualifier. Tests (test/unsent-compiles.test.ts, 5, was 4): - The sent path's assembly, buildActivationRequest without dryRun (as startStreamWithInjections calls it), is the control in three tests: its compile is not a dry run; it moves the consumed watermark past a message the preview left unread; it settles the prepared budget change (the settle control used to call compileWithInjections directly, which bypassed the ternary). - A kv-unified agent (stub context manager) previews with the immutable prefix hash and dryRun together, and sends with the same hash and no dryRun. Red checks on dist (each alone): the ternary always passing dryRun fails tests 1, 2, 3 and 5; dropping dryRun from previews fails 1, 2, 3 and 5; dropping the hash on a dry run fails 5; a sent compile leaving the watermark fails 2. Not done: a test of what a dry run does inside the context manager. That is context-manager's contract, tested there; these tests check that each unsent site passes dryRun and the effects agent-framework owns. Local: tsc clean; unsent-compiles 5/5. conversation-routing fails unforced at 6206910 too ("fork channel_publish..." leaves async activity that rejects with "Queue is closed" after the test ends); with --test-force-exit it passes 16/16. --- changelog.d/unsent-compiles-dry-run.fixed.md | 37 ++++++++----- src/agent.ts | 27 ++++++---- src/api/mcp-server.ts | 4 +- src/api/server.ts | 15 ++++-- src/api/types.ts | 4 +- src/framework.ts | 56 +++++++++++++------- test/unsent-compiles.test.ts | 50 +++++++++++++---- 7 files changed, 135 insertions(+), 58 deletions(-) diff --git a/changelog.d/unsent-compiles-dry-run.fixed.md b/changelog.d/unsent-compiles-dry-run.fixed.md index ba04e1a81..59273e510 100644 --- a/changelog.d/unsent-compiles-dry-run.fixed.md +++ b/changelog.d/unsent-compiles-dry-run.fixed.md @@ -3,18 +3,31 @@ hiding messages also run for their "last visible" line; the API's `message.list` and agent context commands; and a conversation fork's seed (the template's compile and the new namespace's emptiness check). A - committing compile commits fold resolutions and can queue compression - work, and with context-manager's thinking binding (#155) it commits stamps - and joins the branch's compiles awaiting acceptance, where one never sent - can cost a live stream's later replies their thinking. A dry run also + committing compile persists fold resolutions and can queue compression + work, and with context-manager's thinking binding + (anima-research/context-manager#160) it commits stamps and joins the + branch's compiles awaiting acceptance, where one never sent can cost a live + stream's later replies their thinking. A dry run does none of that, and it leaves the agent's consumed watermark and a prepared budget change alone, as `previewActivation` already promised; a preview at the live window used - to settle a converging budget change. `ActivationRequestOptions` gains - `dryRun`. + to settle a converging budget change. Context-manager 0.13's adaptive + select still writes its in-memory bookkeeping on a dry run until the next + compile: the compile estimate a usage report is calibrated against, the + arming of that calibration sample, and its transition and render + bookkeeping. `ActivationRequestOptions` gains `dryRun`. +- When the fold plan doesn't fit the budget, a dry run returns the window as + rendered, larger than the budget, where a committing compile threw. + Refusals at other stages still throw, and context-manager 0.13 hands no + over-budget signal back. So at a budget too small for the context, a + preview and the API's agent context command now return instead of failing, + and a fork whose template doesn't fit the budget its seed is compiled at + (context-manager's default) is now seeded instead of failing to spawn. - A conversation fork's seed no longer copies the template's `thinking` and - `redacted_thinking` blocks, and leaves out a message with nothing else. - Each block was signed under the template's prefix, which the fork's system - prompt, tools and scoped injections don't promise to match: on an account - that enforces thinking binding, the fork's first request could be refused. - Once the fork's own thinking-binding pass engages, those copies would never - render anyway. + `redacted_thinking` blocks, and leaves out a message with no content left. + A fork inherits the template's conversation, not its reasoning. A signed + block was minted under the template's prefix, which the fork's system + prompt, tools and scoped injections don't promise to match, so on an + account that enforces thinking binding the fork's first request could be + refused (inferred from probes of other requests, not observed on a fork). + Unsigned reasoning, from text-only providers or XML mode, goes by the same + rule. diff --git a/src/agent.ts b/src/agent.ts index 99e105804..977ff2b0d 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -21,7 +21,7 @@ export interface ActivationRequestOptions { /** The silent heartbeat tick this request answers, if any. */ silentHeartbeat?: SilentHeartbeatTick; /** A request that will never be sent (a preview): compile it as a dry - * run, which commits nothing (see `compileWithInjections`). */ + * run (see `compileWithInjections` for what that leaves alone). */ dryRun?: boolean; } @@ -610,21 +610,28 @@ export class Agent { } /** - * Compile the context with injections. - * Same as compileContext but forwards injections (e.g. from MCPL servers) - * to the context manager so they are merged into the compiled messages. + * Compile the context with injections (e.g. from MCPL servers), which the + * context manager merges into the compiled messages. Without + * `opts.dryRun` it commits as `compileContext` does: the consumed + * watermark moves, and a compile at the live window settles a prepared + * budget change. With `opts.dryRun`, for a compile that will never be + * sent, it does neither (see below). */ async compileWithInjections( budget?: TokenBudget, injections?: ContextInjection[], opts?: { kvUnifiedImmutablePrefixHash?: string; dryRun?: boolean }, ): Promise { - // A dry run is a compile that will never be sent: the context manager - // commits nothing for it (no fold resolutions, no compression work, and - // with thinking binding no stamps and no compile awaiting acceptance), - // and nothing here moves either. Nobody read what it assembled, so the - // consumed watermark stays, and a prepared budget change waits for the - // compile that is sent. + // A dry run is a compile that will never be sent. The context manager + // persists no fold resolutions for it and queues no compression or merge + // work, and with its thinking binding (context-manager#160) it commits no + // stamps and adds no compile awaiting acceptance. Context-manager 0.13's + // adaptive select still writes its in-memory bookkeeping on a dry run: + // it replaces the compile estimate a usage report is calibrated against, + // re-arms that calibration sample, and overwrites its transition and + // render bookkeeping until the next compile. Here nothing moves: nobody + // read what a dry run assembled, so the consumed watermark stays, and a + // prepared budget change waits for the compile that is sent. const watermark = opts?.dryRun ? null : this.pendingWatermark(); const result = await this.contextManager.compile( this.compileBudget(budget), injections, opts as never, diff --git a/src/api/mcp-server.ts b/src/api/mcp-server.ts index e0df861c0..3bd3c82a8 100644 --- a/src/api/mcp-server.ts +++ b/src/api/mcp-server.ts @@ -391,7 +391,9 @@ export class McpServer { }, maxTokens: { type: 'number', - description: 'Maximum tokens to include (optional)', + description: + 'A hypothetical budget to compile the context at (optional). ' + + 'If the context cannot be folded to fit it, it comes back as rendered, larger than the budget.', }, }, required: ['agentName'], diff --git a/src/api/server.ts b/src/api/server.ts index 4ca0164c1..bedfacedc 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -465,8 +465,9 @@ export class ApiServer { } const contextManager = agent.getContextManager(); - // A dry run: listed, never sent, so it commits nothing (fold - // resolutions, compression work, thinking-binding stamps). + // A dry run: listed, never sent, so it persists no fold resolutions, + // queues no compression work and commits no thinking-binding stamps + // (see Agent.compileWithInjections for what a dry run still writes). const { messages } = await contextManager.compile(undefined, undefined, { dryRun: true }); const limit = params?.limit ?? 50; @@ -668,9 +669,13 @@ export class ApiServer { } const contextManager = agent.getContextManager(); - // A dry run: shown, never sent, so it commits nothing (fold resolutions, - // compression work, thinking-binding stamps), at a hypothetical budget - // least of all. + // A dry run: shown, never sent, so it persists no fold resolutions, + // queues no compression work and commits no thinking-binding stamps, at + // a hypothetical budget least of all (see Agent.compileWithInjections + // for what a dry run still writes). When the fold plan doesn't fit + // `maxTokens`, it returns the context as rendered, larger than asked, + // where a committing compile threw: context-manager 0.13 hands back no + // over-budget signal with a dry run. const { messages } = await contextManager.compile( params.maxTokens ? { maxTokens: params.maxTokens, reserveForResponse: 4096 } diff --git a/src/api/types.ts b/src/api/types.ts index 21079d291..448076021 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -147,7 +147,9 @@ export interface BranchDeleteParams { export interface AgentContextParams { /** Agent name */ agentName: string; - /** Maximum tokens to include (optional) */ + /** A hypothetical budget to compile the context at (optional). The compile + * is a dry run: if the context can't be folded to fit, it comes back as + * rendered, larger than the budget. */ maxTokens?: number; } diff --git a/src/framework.ts b/src/framework.ts index 2b94b73ce..f86c9a8b1 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -525,8 +525,12 @@ function normalizeDiscordAwarenessDeadline(value: number | undefined): number { } /** Messages copied into another conversation (a fork's seed) without their - * `thinking` and `redacted_thinking` blocks, which are signed under the - * prefix they were minted with. A message left with nothing is dropped; + * `thinking` and `redacted_thinking` blocks: a fork inherits the template's + * conversation, not its reasoning. A signed block was minted under the + * template's prefix, which the fork's doesn't promise to match; unsigned + * reasoning (text-only providers, XML mode) goes by the same rule. A + * message with no content left, whether the filter emptied it or it was + * already empty, is left out, since a request can't carry an empty message; * any other message is kept as it was. */ function withoutThinking(messages: readonly M[]): M[] { const out: M[] = []; @@ -3660,19 +3664,28 @@ export class AgentFramework { * were activated right now — WITHOUT running inference, opening a stream, or * mutating agent state. Intended for debug/preview tooling. * - * Transparency contract (default): the preview is side-effect-free and - * leaves no trace on the system. It does only read-only work — a dry-run - * compile (`dryRun`: no fold resolutions committed, no compression work - * queued, the consumed watermark and any prepared budget change left - * alone, and with context-manager's thinking binding no stamps and no - * compile awaiting acceptance), tool filtering, and system-prompt assembly - * — then delegates to `Agent.buildActivationRequest`. No tokens are spent, - * nothing is written to Chronicle, and no external MCPL server is - * contacted. Two things it still touches, as a real activation would: it - * hands the context manager the compression tool definitions, and - * context-manager 0.13's adaptive select records its estimate and arms - * calibration even on a dry run, so a preview between a real compile and - * its first usage report replaces that estimate. + * What a preview commits (default): nothing durable. It compiles as a dry + * run (`dryRun`), filters tools and assembles the system prompt, then + * delegates to `Agent.buildActivationRequest`. No tokens are spent, nothing + * is written to Chronicle, no external MCPL server is contacted, no fold + * resolution is persisted and no compression work is queued; the consumed + * watermark and any prepared budget change are left alone; and with + * context-manager's thinking binding (context-manager#160) no stamps are + * committed and no compile awaits acceptance. It does touch in-memory + * state in two places: + * - it hands the context manager the compression tool definitions, as + * every activation request does; + * - context-manager 0.13's adaptive select writes its bookkeeping on a dry + * run too: the compile estimate a usage report is calibrated against, + * the calibration sample's arming, and its transition and render + * bookkeeping. So a preview between a sent compile and that request's + * first usage report replaces the estimate the report is judged + * against, and settings readers see the preview's transition values + * until the next compile. + * When the fold plan doesn't fit the budget, the dry run returns the window + * as rendered, larger than the budget, where a committing compile would + * throw (other refusals, such as a head and tail that alone exceed it, + * still throw); 0.13 hands no over-budget signal back with the window. * * The trade-off is fidelity: the dynamically-gathered ContextInjection[] * (module `gatherContext` + MCPL `beforeInference` hooks) are NOT included @@ -3704,7 +3717,8 @@ export class AgentFramework { // An explicit budget compiles against a HYPOTHETICAL window instead of the // agent's live one. Every preview is a dry run, so none of them settles - // a converging descent or commits anything (see the doc comment above). + // a converging descent or commits anything durable (see the doc comment + // above). // Default: no dynamic injection gathering → fully transparent (no // inference, no Chronicle writes, no external RPC). Opt in explicitly. if (!opts?.injections) { @@ -7362,9 +7376,9 @@ export class AgentFramework { /** * Spawn a persistent per-channel conversation agent ("fork") from the * template agent: own ContextManager under `conversations/{name}`, seeded - * with a copy of the template's compiled context (the SubagentModule - * wholesale-copy pattern), registered in the event loop like any agent — - * but never primary. + * with the template's compiled context without its thinking (the + * SubagentModule wholesale-copy pattern; see `withoutThinking`), + * registered in the event loop like any agent — but never primary. */ private async createConversationAgent(name: string, channelId: string): Promise { const router = this.conversationRouter!; @@ -7403,6 +7417,10 @@ export class AgentFramework { // while the template may be mid-stream, where a committing compile // would commit fold resolutions and queue compression work for it (and // with context-manager's thinking binding, fence its stream's replies). + // A dry run whose fold plan doesn't fit returns the render rather than + // throwing, so a template whose context doesn't fit the budget compiled + // here (context-manager's default: none is passed) seeds the fork + // instead of failing the spawn; the fork's own compiles meet its window. // The seed leaves out the template's thinking: a signed block verifies // only while everything before it is what it was minted under, and a // fork's system, tools and scoped injections don't promise that, while diff --git a/test/unsent-compiles.test.ts b/test/unsent-compiles.test.ts index 3f9bbfa9d..9f7a963a8 100644 --- a/test/unsent-compiles.test.ts +++ b/test/unsent-compiles.test.ts @@ -4,10 +4,10 @@ * A committing compile commits fold resolutions and queues compression work. * Agent-framework also moves the agent's consumed watermark (RFC-006) and * settles a prepared budget change at a full compile. With context-manager's - * thinking binding (#155), a committing compile also commits stamps and joins - * the branch's compiles awaiting acceptance, where one never sent can fence - * the replies of a live stream. Previews and the API's message list and - * context are never sent, so each compiles as a dry run. + * thinking binding (context-manager#160), a committing compile also commits + * stamps and joins the branch's compiles awaiting acceptance, where one never + * sent can fence the replies of a live stream. Previews and the API's message + * list and context are never sent, so each compiles as a dry run. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; @@ -15,7 +15,9 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AutobiographicalStrategy, type ContextManager } from '@animalabs/context-manager'; +import type { Membrane } from '@animalabs/membrane'; import { AgentFramework, ApiServer } from '../src/index.js'; +import { Agent } from '../src/agent.js'; import { MockMembrane } from './helpers/mock-membrane.js'; async function make(agent: Record = {}) { @@ -44,14 +46,18 @@ function recordCompiles(cm: ContextManager): boolean[] { } describe('compiles that are never sent', () => { - it('a preview is a dry run, with or without gathered injections', async () => { + it('a preview is a dry run, with or without gathered injections; a request that is sent is not', async () => { const x = await make(); try { - const dry = recordCompiles(x.framework.getAgent('agent')!.getContextManager()); + const agent = x.framework.getAgent('agent')!; + const dry = recordCompiles(agent.getContextManager()); await x.framework.previewActivation('agent'); await x.framework.previewActivation('agent', { injections: true }); await x.framework.previewActivation('agent', { budget: { maxTokens: 50_000, reserveForResponse: 4_000 } }); - assert.deepEqual(dry, [true, true, true]); + // The control: the assembly a sent request goes through (as + // startStreamWithInjections calls it) commits. + await agent.buildActivationRequest([]); + assert.deepEqual(dry, [true, true, true, false]); } finally { await x.close(); } }); @@ -60,9 +66,13 @@ describe('compiles that are never sent', () => { try { const agent = x.framework.getAgent('agent')!; const before = agent.getConsumedWatermark(); - agent.getContextManager().addMessage('user', [{ type: 'text', text: 'not yet read' }]); + const id = agent.getContextManager().addMessage('user', [{ type: 'text', text: 'not yet read' }]); await x.framework.previewActivation('agent'); assert.deepEqual(agent.getConsumedWatermark(), before); + // The control: a request that is sent assembled it, so it's read. + await agent.buildActivationRequest([]); + const sequence = agent.getContextManager().getMessage(id)!.sequence; + assert.ok((agent.getConsumedWatermark()?.sequence ?? -1) >= sequence, 'a sent request moves the watermark past what it assembled'); } finally { await x.close(); } }); @@ -78,8 +88,8 @@ describe('compiles that are never sent', () => { assert.equal(x.framework.updateAgentRuntimeSettings('agent', { contextBudgetTokens: 60_000 }).transition, 'converging'); await x.framework.previewActivation('agent'); assert.equal(x.framework.getAgentRuntimeSettings('agent').transition, 'converging', 'a preview settles nothing'); - // The control: a compile that is sent does settle it. - await x.framework.getAgent('agent')!.compileWithInjections(); + // The control: the assembly a sent request goes through settles it. + await x.framework.getAgent('agent')!.buildActivationRequest([]); assert.equal(x.framework.getAgentRuntimeSettings('agent').transition, 'stable'); } finally { await x.close(); } }); @@ -98,4 +108,24 @@ describe('compiles that are never sent', () => { assert.deepEqual(dry, [true, true, true]); } finally { await x.close(); } }); + + it("a kv-unified preview carries the immutable prefix hash and the dry run together; a sent request carries the hash alone", async () => { + const options: unknown[] = []; + const cm = { + getStrategy: () => ({ isKvUnifiedEnabled: () => true }), + setToolDefinitions: () => {}, + compile: async (_budget: unknown, _injections: unknown, opts: unknown) => { + options.push(opts); + return { messages: [{ participant: 'user', content: [{ type: 'text', text: 'hello' }] }], systemInjections: [] }; + }, + } as unknown as ContextManager; + const agent = new Agent({ name: 'resident', model: 'test', systemPrompt: 'system' }, cm, {} as Membrane); + await agent.buildActivationRequest([], undefined, undefined, [], { dryRun: true }); + await agent.buildActivationRequest([]); + const [preview, sent] = options as Array<{ kvUnifiedImmutablePrefixHash?: unknown; dryRun?: unknown }>; + assert.equal(typeof preview.kvUnifiedImmutablePrefixHash, 'string'); + assert.equal(preview.dryRun, true); + assert.equal(sent.kvUnifiedImmutablePrefixHash, preview.kvUnifiedImmutablePrefixHash, 'both compile against the same prefix'); + assert.equal(sent.dryRun, undefined); + }); }); From 644359c593ba283e9fc73e58133f2a15a13884cf Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sun, 11 Oct 2026 16:11:46 -0400 Subject: [PATCH 3/9] fix(forks): a seed replays no item paired with the reasoning it left out, and never ends on an unanswered call The second part of the review round for this change: the fork seed. Probes made today through the house's router (on Opus 5.5, Haiku 5.5, Sonnet 5.5, Fable 5.1, Fable 5 and Sonnet 5) found every model accepting both shapes the seed produces: an in-flight tool round without its thinking, and an earlier text turn without its thinking followed by a new user turn. context-manager's comment that Fable-5/Sonnet-5-class models "require" signed thinking with replayed text rests on the usage classifier's content-dependent refusal, not a request rule. Four of those models bind a block to its prefix (they drop or refuse it under a changed one); Fable 5 and Sonnet 5 keep it. So the strip stays on every model, as context-manager#160 renders no unstamped thinking; on the two that don't bind, it costs the fork the template's reasoning, which the seed's comment now says. - withoutThinking: on the OpenAI Responses path a removed reasoning block carried the raw item (`rawItem`) its message's other items were paired with by id. Those items (the message's text, a function call) now lose their rawItem and render from their fields, without ids, so the fork replays no msg_/fc_ item whose reasoning is gone. An opaque carrier, a text block whose raw item isn't a `message` (membrane keeps compaction and other items it has no block for that way), keeps it: the raw item is its whole content. A message whose removed thinking carried no raw item (Anthropic) is unchanged apart from the strip. This is the rule context-manager's main applies to its compression input (releaseVanishedRawForms, 7fe537a5, not yet released). - withoutUnansweredToolUse: a seed no longer ends on a message that calls a tool whose result was never stored. The coalescer can spawn a fork outside the event loop (coalescedAudience, from a push's admit or a recovery timer), so the template's compile can land between a tool round's assistant row and its results. The adaptive strategy drops such a row from its compile (trimOrphanedToolUse); passthrough and windowed passthrough don't, and the fork's first request would carry an unanswered call. Tests (conversation-routing, 3 new, 19 in all): - a seed that ends on a completed tool round keeps its tool_use and its result, without the thinking; - a seed of a passthrough template whose store ends on an unanswered call ends on the exchange before it; - a Responses-shaped history through membrane's Responses formatter replays by id only the opaque compaction carrier and a reply that had no reasoning removed; the call and the text render from their fields; an Anthropic reply loses only its thinking. Red checks on dist, each alone: no unanswered-call trim fails the second; no raw-item release, releasing carriers too, or releasing on every message fails the third; keeping thinking fails the seed test, the first and the third. Local: tsc clean; conversation-routing 19/19 (with --test-force-exit; see the previous commit for the unforced failure that predates this change). --- changelog.d/unsent-compiles-dry-run.fixed.md | 13 ++- src/framework.ts | 68 ++++++++++++--- test/conversation-routing.test.ts | 91 ++++++++++++++++++++ 3 files changed, 160 insertions(+), 12 deletions(-) diff --git a/changelog.d/unsent-compiles-dry-run.fixed.md b/changelog.d/unsent-compiles-dry-run.fixed.md index 59273e510..bc9a4088a 100644 --- a/changelog.d/unsent-compiles-dry-run.fixed.md +++ b/changelog.d/unsent-compiles-dry-run.fixed.md @@ -30,4 +30,15 @@ account that enforces thinking binding the fork's first request could be refused (inferred from probes of other requests, not observed on a fork). Unsigned reasoning, from text-only providers or XML mode, goes by the same - rule. + rule. On models that don't bind thinking to its prefix (by probes, Fable 5 + and Sonnet 5), the fork loses the template's reasoning, as + context-manager#160 renders no unstamped thinking. +- On the OpenAI Responses path, the seed's items that were paired with a + reasoning item it left out (the message's text, a function call) lose + their provider-native raw item and render from their fields, so the fork + replays no item id whose reasoning is gone. Opaque items, such as a + compaction, keep theirs. +- A fork's seed no longer ends on a call whose result was never stored. A + fork spawned while the template was between a tool round's call and its + results (the coalescer can spawn one outside the event loop) gave the + fork's first request an unanswered call under the passthrough strategies. diff --git a/src/framework.ts b/src/framework.ts index f86c9a8b1..0fa48a56f 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -528,16 +528,57 @@ function normalizeDiscordAwarenessDeadline(value: number | undefined): number { * `thinking` and `redacted_thinking` blocks: a fork inherits the template's * conversation, not its reasoning. A signed block was minted under the * template's prefix, which the fork's doesn't promise to match; unsigned - * reasoning (text-only providers, XML mode) goes by the same rule. A - * message with no content left, whether the filter emptied it or it was - * already empty, is left out, since a request can't carry an empty message; - * any other message is kept as it was. */ + * reasoning (text-only providers, XML mode) goes by the same rule. On the + * OpenAI Responses path a removed block also carried the reasoning item + * (`rawItem`) the message's other items were paired with by id, so those + * lose their `rawItem` and render from their fields, without ids; an opaque + * carrier (a text block whose raw item isn't a `message`, such as a + * compaction) keeps it, since the raw item is its whole content. A message + * with no content left, whether the filter emptied it or it was already + * empty, is left out, since a request can't carry an empty message; any + * other message is kept as it was. */ function withoutThinking(messages: readonly M[]): M[] { const out: M[] = []; for (const message of messages) { - const content = message.content.filter((b) => b.type !== 'thinking' && b.type !== 'redacted_thinking'); + const isThinking = (b: ContentBlock) => b.type === 'thinking' || b.type === 'redacted_thinking'; + let content: ContentBlock[] = message.content.filter((b) => !isThinking(b)); + const pairedReasoningRemoved = message.content.some((b) => isThinking(b) && rawItemOf(b) !== undefined); + if (pairedReasoningRemoved) content = content.map(releasePairedRawItem); if (content.length === 0) continue; - out.push(content.length === message.content.length ? message : { ...message, content }); + out.push(content.length === message.content.length && !pairedReasoningRemoved ? message : { ...message, content }); + } + return out; +} + +function rawItemOf(block: ContentBlock): unknown { + return (block as { rawItem?: unknown }).rawItem; +} + +/** A block without the provider-native item it was parsed from, unless that + * item is the block's whole content: an opaque carrier, a text block whose + * raw item isn't a `message` item (membrane keeps compaction and other items + * it has no block for that way). */ +function releasePairedRawItem(block: ContentBlock): ContentBlock { + const raw = rawItemOf(block); + if (raw === undefined) return block; + const rawType = typeof raw === 'object' && raw !== null ? (raw as { type?: unknown }).type : undefined; + if (block.type === 'text' && rawType !== 'message') return block; + const { rawItem: _released, ...rest } = block as ContentBlock & { rawItem?: unknown }; + return rest as ContentBlock; +} + +/** A seed without a trailing message that calls a tool whose result never + * follows it: one spawned while the template is between a round's + * `tool_use` row and its results (the coalescer can spawn a fork outside + * the event loop) would otherwise give the fork's first request an + * unanswered call. The adaptive strategy already drops such a row from its + * compile; the passthrough strategies don't. */ +function withoutUnansweredToolUse(messages: readonly M[]): M[] { + const out = [...messages]; + while (out.length > 0) { + const last = out[out.length - 1]!.content; + if (!last.some((b) => b.type === 'tool_use') || last.some((b) => b.type === 'tool_result')) break; + out.pop(); } return out; } @@ -7421,14 +7462,19 @@ export class AgentFramework { // throwing, so a template whose context doesn't fit the budget compiled // here (context-manager's default: none is passed) seeds the fork // instead of failing the spawn; the fork's own compiles meet its window. - // The seed leaves out the template's thinking: a signed block verifies - // only while everything before it is what it was minted under, and a - // fork's system, tools and scoped injections don't promise that, while - // its own namespace holds no stamps that could vouch for the copies. + // The seed leaves out the template's thinking (withoutThinking): a + // signed block verifies only while everything before it is what it was + // minted under, and a fork's system, tools and scoped injections don't + // promise that, while its own namespace holds no stamps that could + // vouch for the copies. Models that don't bind thinking to its prefix + // would have kept it, so there the fork loses the template's reasoning, + // as context-manager#160 renders no unstamped thinking. Nor does the + // seed end on a call whose result never follows it + // (withoutUnansweredToolUse). const { messages: existing } = await contextManager.compile(undefined, undefined, { dryRun: true }); if (existing.length === 0) { const { messages: compiled } = await template.getContextManager().compile(undefined, undefined, { dryRun: true }); - for (const msg of withoutThinking(compiled)) { + for (const msg of withoutUnansweredToolUse(withoutThinking(compiled))) { const participant = msg.participant === template.name ? name : msg.participant; contextManager.addMessage(participant, msg.content); } diff --git a/test/conversation-routing.test.ts b/test/conversation-routing.test.ts index 77a4d847d..c48c1ef2e 100644 --- a/test/conversation-routing.test.ts +++ b/test/conversation-routing.test.ts @@ -11,6 +11,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { AgentFramework } from '../src/index.js'; import type { ProcessEvent, ConversationRouterConfig, TraceEvent } from '../src/index.js'; +import { OpenAIResponsesFormatter } from '@animalabs/membrane'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; function incomingEvent(overrides: { @@ -282,6 +283,96 @@ describe('Conversation routing', () => { await framework.stop(); }); + /** Spawn the fork for `slack:D3` and return what its namespace was seeded with. */ + async function seedOf(framework: AgentFramework) { + framework.pushEvent(incomingEvent({ channelId: 'slack:D3', text: 'hi', channelType: 'im' })); + await framework.runUntilIdle(); + return framework.getAgent('conversation-slack-D3-g1')!.getContextManager().getAllMessages(); + } + + it("a seed that ends on a completed tool round keeps the round, without its thinking", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ok' }])); + const framework = await makeFramework(); + const trunk = framework.getAgent('trunk')!.getContextManager(); + trunk.addMessage('user', [{ type: 'text', text: 'check the logs' }]); + trunk.addMessage('trunk', [ + { type: 'thinking', thinking: 'the logs first', signature: 'sig-round' }, + { type: 'tool_use', id: 'toolu_logs', name: 'read_logs', input: {} }, + ] as never); + trunk.addMessage('user', [{ type: 'tool_result', toolUseId: 'toolu_logs', content: 'all quiet' }] as never); + + const seeded = await seedOf(framework); + const contents = seeded.map((m) => m.content); + const round = contents.findIndex((c) => c.some((b) => b.type === 'tool_use')); + assert.ok(round >= 0, 'the round is in the seed'); + assert.deepEqual(contents[round]!.map((b) => b.type), ['tool_use'], 'its call stays, its thinking goes'); + assert.equal(seeded[round]!.participant, 'conversation-slack-D3-g1'); + assert.deepEqual(contents[round + 1]!.map((b) => b.type), ['tool_result'], 'its result follows it'); + await framework.stop(); + }); + + it("a seed never ends on a call whose result hasn't been stored", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ok' }])); + const framework = await makeFramework(); + const trunk = framework.getAgent('trunk')!.getContextManager(); + trunk.addMessage('user', [{ type: 'text', text: 'check the logs' }]); + trunk.addMessage('trunk', [{ type: 'text', text: 'Looking.' }]); + // A tool round's assistant row, stored before its results (the spawn can + // land between the two when the coalescer spawns outside the event loop). + trunk.addMessage('trunk', [ + { type: 'thinking', thinking: 'mid-round', signature: 'sig-mid' }, + { type: 'tool_use', id: 'toolu_pending', name: 'read_logs', input: {} }, + ] as never); + + const seeded = await seedOf(framework); + const types = seeded.flatMap((m) => m.content.map((b) => b.type)); + assert.ok(!types.includes('tool_use'), `no unanswered call in the seed: ${types}`); + // The seed is everything before the fork's own first message ('hi'). + const firstOwn = seeded.findIndex((m) => JSON.stringify(m.content).includes('"hi"')); + assert.ok(firstOwn > 0, 'the incoming message follows the seed'); + assert.ok(JSON.stringify(seeded[firstOwn - 1]!.content).includes('Looking.'), 'the seed ends on the last complete exchange'); + await framework.stop(); + }); + + it("a Responses seed replays no item paired with the reasoning it left out; opaque carriers stay whole", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ok' }])); + const framework = await makeFramework(); + const trunk = framework.getAgent('trunk')!.getContextManager(); + const reasoning = { type: 'reasoning', id: 'rs_1', encrypted_content: 'enc', summary: [] }; + const compaction = { type: 'compaction', id: 'cmp_1', encrypted_content: 'earlier history' }; + trunk.addMessage('user', [{ type: 'text', text: 'what changed?' }]); + trunk.addMessage('trunk', [ + { type: 'text', text: '', rawItem: compaction }, + { type: 'redacted_thinking', data: 'enc', rawItem: reasoning }, + { type: 'text', text: 'The config.', rawItem: { type: 'message', id: 'msg_1', role: 'assistant', content: [{ type: 'output_text', text: 'The config.' }] } }, + { type: 'tool_use', id: 'call_1', name: 'read_config', input: {}, rawItem: { type: 'function_call', id: 'fc_1', call_id: 'call_1', name: 'read_config', arguments: '{}' } }, + ] as never); + trunk.addMessage('user', [{ type: 'tool_result', toolUseId: 'call_1', content: 'ok' }] as never); + // A Responses reply with no reasoning item: nothing it was paired with is + // left out, so it replays as it was. + trunk.addMessage('trunk', [ + { type: 'text', text: 'Read it.', rawItem: { type: 'message', id: 'msg_2', role: 'assistant', content: [{ type: 'output_text', text: 'Read it.' }] } }, + ] as never); + // An Anthropic reply in the same history: its thinking carries no raw item. + trunk.addMessage('trunk', [ + { type: 'thinking', thinking: 'plain', signature: 'sig-plain' }, + { type: 'text', text: 'Done.' }, + ] as never); + + const seeded = await seedOf(framework); + const items = new OpenAIResponsesFormatter().buildMessages( + seeded.map((m) => ({ participant: m.participant, content: m.content })), + { participantMode: 'simple', assistantParticipant: 'conversation-slack-D3-g1', humanParticipant: 'user', systemPrompt: 'fork' } as never, + ).messages as unknown as Array>; + const ids = items.map((item) => item.id).filter((id) => id !== undefined); + assert.deepEqual(ids, ['cmp_1', 'msg_2'], `only the opaque carrier and the unpaired reply replay by id: ${JSON.stringify(items)}`); + assert.ok(items.some((item) => item.type === 'function_call' && item.call_id === 'call_1'), 'the call renders from its fields'); + assert.ok(items.some((item) => item.type === 'message' && JSON.stringify(item.content).includes('The config.')), 'the text renders from its fields'); + const plain = seeded.find((m) => JSON.stringify(m.content).includes('Done.'))!; + assert.deepEqual(plain.content.map((b) => b.type), ['text'], "an Anthropic reply loses only its thinking"); + await framework.stop(); + }); + // Exercise the delivery boundary without scheduling inference. The same // handler receives ordinary channel traffic and coalesced fixed-audience notices. async function deliver(framework: AgentFramework, channelId: string, text: string, deliverTo?: string) { From 808ad73d4d46c13365e58fca3c16971099193b65 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 13:04:29 -0400 Subject: [PATCH 4/9] docs(forks): the fork's compiles fit its window or refuse; the stripped seed's evidence names its models From the review that approved this change, two notes: - The seed comment said the fork's own compiles "meet its window". A template that doesn't fit now seeds the fork, and the fork's first compile either folds to fit or throws over_budget for the drain breaker, so they fit its window or refuse. - That a provider accepts the stripped seed, including one that ends on a completed tool round with a tool_use and no thinking before it, rests on probes of Opus 5.5, Haiku 5.5, Sonnet 5.5, Fable 5.1, Fable 5 and Sonnet 5. The fragment now says so, and that other thinking models weren't probed. Comment and changelog only. --- changelog.d/unsent-compiles-dry-run.fixed.md | 6 +++++- src/framework.ts | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/changelog.d/unsent-compiles-dry-run.fixed.md b/changelog.d/unsent-compiles-dry-run.fixed.md index bc9a4088a..1e345f5cb 100644 --- a/changelog.d/unsent-compiles-dry-run.fixed.md +++ b/changelog.d/unsent-compiles-dry-run.fixed.md @@ -32,7 +32,11 @@ Unsigned reasoning, from text-only providers or XML mode, goes by the same rule. On models that don't bind thinking to its prefix (by probes, Fable 5 and Sonnet 5), the fork loses the template's reasoning, as - context-manager#160 renders no unstamped thinking. + context-manager#160 renders no unstamped thinking. That a provider accepts + the stripped seed, including one that ends on a completed tool round with + a `tool_use` and no thinking before it, rests on probes of Opus 5.5, Haiku + 5.5, Sonnet 5.5, Fable 5.1, Fable 5 and Sonnet 5; other thinking models + weren't probed. - On the OpenAI Responses path, the seed's items that were paired with a reasoning item it left out (the message's text, a function call) lose their provider-native raw item and render from their fields, so the fork diff --git a/src/framework.ts b/src/framework.ts index 0fa48a56f..740eec87a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -7461,7 +7461,9 @@ export class AgentFramework { // A dry run whose fold plan doesn't fit returns the render rather than // throwing, so a template whose context doesn't fit the budget compiled // here (context-manager's default: none is passed) seeds the fork - // instead of failing the spawn; the fork's own compiles meet its window. + // instead of failing the spawn; the fork's own compiles fit its window + // or refuse (its first compile folds to fit, or throws over_budget for + // the drain breaker). // The seed leaves out the template's thinking (withoutThinking): a // signed block verifies only while everything before it is what it was // minted under, and a fork's system, tools and scoped injections don't From 6ccd9902ed8ba52c1cc8feb71e903ce7f48468b8 Mon Sep 17 00:00:00 2001 From: Myrtle-2415 Date: Tue, 13 Oct 2026 11:41:23 -0400 Subject: [PATCH 5/9] fix(compile): say what context-manager 0.13 still leaves on a dry run, and that a budget it can't fit still fails A review of this change found its text claiming more than context-manager 0.13 delivers. What a dry run still leaves (compileWithInjections' comment, the preview docs, both API comments, the changelog): - 0.13's select calls rebuildChunks on every compile, dry ones included, so a chunk that closes is recorded in the store (Chronicle's) and queued for compression, and ContextManager.compile releases expired compression holds before the select. "Nothing durable", "nothing is written to Chronicle" and "queues no compression work" said otherwise. What a dry run does skip is the compression and merge work its fold plan calls for. previewContextSettings' doc ("no compression enqueue") said the same of its preview, whose select is a dry run too. - Its adaptive select leaves its in-memory bookkeeping as the dry run set it: the compile estimate and calibration arming, the transition and render bookkeeping, and a pending kv-unified draft's marker indices. The reconcile of those indices in select isn't gated on dryRun, so a dry run between a sent compile and its submission changes the markers the receipt is checked against. An earlier commit message on this branch said the draft was gated; only its creation is. - The texts no longer give a closed list of in-memory writes. - context-manager's thinking binding (#160) is not yet released; the texts now say what it will do once it ships rather than as if it were current. - context-manager 0.14.0 restores all of that after a dry run except the hold release (anima-research/context-manager#161 and #114). This change keeps the ^0.13.0 floor, so its text says what 0.13 does. - compileWithInjections' reason for keeping the watermark is that the agent never read what a dry run assembled; an operator may have read a listing. Over budget. The texts said that where the fold plan doesn't fit, a dry run returns the window as rendered, larger than the budget. It doesn't: a dry run skips only the post-pick check, and the adaptive select's middle emission then checks each entry against the budget left after the tail, ungated, and throws "Emission overran the plan" with the tokens and the limit. Phoebe-2419's probes showed it on 0.13.0 and 0.14.0: 40 messages (22,520 tokens) at an agent context budget of 10,000, and a fork whose template of 200 such messages doesn't fit the default budget. So under the autobiographical strategy a preview, message.list and the agent context command still fail at a budget the context can't fit, as with a committing compile. Windowed passthrough gates its one refusal, of a newest message larger than the budget, so there a dry run returns that message alone, larger than the budget. Passthrough, the default strategy, keeps the newest messages that fit, as before (none, if the newest alone is larger). The agent_context tool's maxTokens text, AgentContextParams.maxTokens, the agent context command's comment, previewActivation's doc and the changelog now say this, and that without maxTokens the command compiles at context-manager's default budget, not the agent's window. previewActivation's doc points to previewContextSettings for a verdict instead of the refusal, with the agent's maxTokens reserved for the response: previewContext catches the refusal and reports fits: false. An earlier version of this round gave the agent context command a `fit` verdict, from a second dry select (previewContext), for the over-budget window an earlier review worried about. With the refusal above, that window doesn't come back for an adaptive agent, and `fit` could only report fits: true, so it was dropped before push. Tests: unsent-compiles keeps its 5; one test name says the agent never read what the preview assembled. Co-Authored-By: Claude Opus 5.5 --- changelog.d/unsent-compiles-dry-run.fixed.md | 53 +++++++++------ src/agent.ts | 37 +++++++---- src/api/mcp-server.ts | 7 +- src/api/server.ts | 23 ++++--- src/api/types.ts | 12 +++- src/framework.ts | 68 +++++++++++--------- test/unsent-compiles.test.ts | 2 +- 7 files changed, 124 insertions(+), 78 deletions(-) diff --git a/changelog.d/unsent-compiles-dry-run.fixed.md b/changelog.d/unsent-compiles-dry-run.fixed.md index 1e345f5cb..bca4099a4 100644 --- a/changelog.d/unsent-compiles-dry-run.fixed.md +++ b/changelog.d/unsent-compiles-dry-run.fixed.md @@ -3,25 +3,40 @@ hiding messages also run for their "last visible" line; the API's `message.list` and agent context commands; and a conversation fork's seed (the template's compile and the new namespace's emptiness check). A - committing compile persists fold resolutions and can queue compression - work, and with context-manager's thinking binding - (anima-research/context-manager#160) it commits stamps and joins the - branch's compiles awaiting acceptance, where one never sent can cost a live - stream's later replies their thinking. A dry run does none of that, and it - leaves the agent's consumed watermark and a prepared budget change alone, - as `previewActivation` already promised; a preview at the live window used - to settle a converging budget change. Context-manager 0.13's adaptive - select still writes its in-memory bookkeeping on a dry run until the next - compile: the compile estimate a usage report is calibrated against, the - arming of that calibration sample, and its transition and render - bookkeeping. `ActivationRequestOptions` gains `dryRun`. -- When the fold plan doesn't fit the budget, a dry run returns the window as - rendered, larger than the budget, where a committing compile threw. - Refusals at other stages still throw, and context-manager 0.13 hands no - over-budget signal back. So at a budget too small for the context, a - preview and the API's agent context command now return instead of failing, - and a fork whose template doesn't fit the budget its seed is compiled at - (context-manager's default) is now seeded instead of failing to spawn. + committing compile persists fold resolutions and queues the compression its + fold plan calls for. Once context-manager's thinking binding + (anima-research/context-manager#160, not yet released) ships, it will also + commit stamps and join the branch's compiles awaiting acceptance, where one + never sent can cost a live stream's later replies their thinking. A dry run + does none of that, and it leaves the agent's consumed watermark and a + prepared budget change alone, as `previewActivation` already promised; a + preview at the live window used to settle a converging budget change. + `ActivationRequestOptions` gains `dryRun`. +- Context-manager 0.13 still leaves some of a select's effects behind on a dry + run, as on any compile: a chunk that closes is recorded in the store and + queued for compression, a compression hold past its deadline is released, + and the adaptive select leaves its in-memory bookkeeping as the dry run set + it until the next compile, for example the compile estimate a usage report + is calibrated against and that sample's arming, its transition and render + bookkeeping, and a pending kv-unified draft's marker indices. So a dry run + between a sent compile and that request's first usage report replaces the + estimate the report is judged against, and under kv-unified one between a + sent compile and its submission changes the markers its receipt is checked + against. Context-manager 0.14.0 restores all of that after a dry run except + the hold release (anima-research/context-manager#161 and #114); this change + keeps the `^0.13.0` floor. +- Under the autobiographical strategy, a dry run still refuses a budget the + context can't fit, as a committing compile does: context-manager skips only + its post-pick check, and an adaptive fold plan that doesn't fit then + overruns at the middle's emission, with a refusal that names the tokens and + the limit. So at such a budget a preview, `message.list` and the API's agent + context command still fail, and a fork whose template's fold plan doesn't + fit the default budget its seed is compiled at still fails to spawn. + Windowed passthrough, though, skips its refusal of a newest message larger + than the budget on a dry run: where they failed on such a message, a + preview, `message.list` and the agent context command now return it alone, + larger than the budget, and a fork is seeded with it. Passthrough, the + default strategy, keeps the newest messages that fit, as before. - A conversation fork's seed no longer copies the template's `thinking` and `redacted_thinking` blocks, and leaves out a message with no content left. A fork inherits the template's conversation, not its reasoning. A signed diff --git a/src/agent.ts b/src/agent.ts index 977ff2b0d..a373a17d5 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -623,15 +623,24 @@ export class Agent { opts?: { kvUnifiedImmutablePrefixHash?: string; dryRun?: boolean }, ): Promise { // A dry run is a compile that will never be sent. The context manager - // persists no fold resolutions for it and queues no compression or merge - // work, and with its thinking binding (context-manager#160) it commits no - // stamps and adds no compile awaiting acceptance. Context-manager 0.13's - // adaptive select still writes its in-memory bookkeeping on a dry run: - // it replaces the compile estimate a usage report is calibrated against, - // re-arms that calibration sample, and overwrites its transition and - // render bookkeeping until the next compile. Here nothing moves: nobody - // read what a dry run assembled, so the consumed watermark stays, and a - // prepared budget change waits for the compile that is sent. + // persists no fold resolutions for it and queues none of the compression + // or merge work its fold plan calls for. Its thinking binding + // (context-manager#160, not yet released) will commit stamps and add a + // compile awaiting acceptance for a committing compile only. Here nothing + // moves either: the agent never read what a dry run assembled, so the + // consumed watermark stays, and a prepared budget change waits for the + // compile that is sent. + // + // Context-manager 0.13 still leaves some of a select's effects behind on + // a dry run, as on any compile. A chunk that closes is recorded, durably, + // and queued for compression; a compression hold past its deadline is + // released. Its adaptive select also leaves its in-memory bookkeeping as + // the dry run set it, until the next compile: for example the compile + // estimate a usage report is calibrated against and that sample's arming, + // its transition and render bookkeeping, and a pending kv-unified draft's + // marker indices, which the draft's submission records as the markers + // its receipt is checked against. Context-manager 0.14.0 restores all of + // that after a dry run except the hold release. const watermark = opts?.dryRun ? null : this.pendingWatermark(); const result = await this.contextManager.compile( this.compileBudget(budget), injections, opts as never, @@ -812,11 +821,11 @@ export class Agent { * real activation path (`startStreamWithInjections`) and debug/preview * tooling (`Framework.previewActivation`). It does not call the membrane. * A compile that is sent commits what the context manager commits for it - * (fold resolutions, compression work, the consumed watermark); a preview - * passes `options.dryRun`, so its compile commits none of that (see - * `compileWithInjections`). Either way the context manager is handed the - * compression tool definitions. Safe to call regardless of the agent's - * current status. + * (fold resolutions, the compression its fold plan calls for, the consumed + * watermark); a preview passes `options.dryRun`, so its compile commits + * none of that (see `compileWithInjections` for what a dry run still + * leaves). Either way the context manager is handed the compression tool + * definitions. Safe to call regardless of the agent's current status. */ async buildActivationRequest( availableTools: ToolDefinition[], diff --git a/src/api/mcp-server.ts b/src/api/mcp-server.ts index 3bd3c82a8..2332ea557 100644 --- a/src/api/mcp-server.ts +++ b/src/api/mcp-server.ts @@ -392,8 +392,11 @@ export class McpServer { maxTokens: { type: 'number', description: - 'A hypothetical budget to compile the context at (optional). ' + - 'If the context cannot be folded to fit it, it comes back as rendered, larger than the budget.', + "A hypothetical budget to compile the context at (optional; without it, the context manager's " + + "default of 100000, not the agent's own window). Under the autobiographical strategy, a budget " + + "the context can't fit fails the call with the context manager's refusal, which names the tokens " + + 'and the limit. Passthrough keeps the newest messages that fit (none, if the newest alone is ' + + 'larger), and windowed passthrough returns the newest message alone, larger than the budget.', }, }, required: ['agentName'], diff --git a/src/api/server.ts b/src/api/server.ts index bedfacedc..e89803257 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -465,9 +465,10 @@ export class ApiServer { } const contextManager = agent.getContextManager(); - // A dry run: listed, never sent, so it persists no fold resolutions, - // queues no compression work and commits no thinking-binding stamps - // (see Agent.compileWithInjections for what a dry run still writes). + // A dry run: listed, never sent, so it persists no fold resolutions and + // queues none of the compression its fold plan calls for (see + // Agent.compileWithInjections for what a dry run still leaves, and for + // context-manager#160's thinking binding). const { messages } = await contextManager.compile(undefined, undefined, { dryRun: true }); const limit = params?.limit ?? 50; @@ -669,13 +670,15 @@ export class ApiServer { } const contextManager = agent.getContextManager(); - // A dry run: shown, never sent, so it persists no fold resolutions, - // queues no compression work and commits no thinking-binding stamps, at - // a hypothetical budget least of all (see Agent.compileWithInjections - // for what a dry run still writes). When the fold plan doesn't fit - // `maxTokens`, it returns the context as rendered, larger than asked, - // where a committing compile threw: context-manager 0.13 hands back no - // over-budget signal with a dry run. + // A dry run: shown, never sent, so it persists no fold resolutions and + // queues none of the compression its fold plan calls for, at a + // hypothetical budget least of all (see Agent.compileWithInjections for + // what a dry run still leaves). Under the autobiographical strategy, a + // budget the context can't fit still throws: a dry run skips only + // context-manager's post-pick check, and an adaptive fold plan that + // doesn't fit then overruns at the middle's emission. Windowed + // passthrough's newest message alone is the one window that comes back + // larger than the budget. const { messages } = await contextManager.compile( params.maxTokens ? { maxTokens: params.maxTokens, reserveForResponse: 4096 } diff --git a/src/api/types.ts b/src/api/types.ts index 448076021..4dccb8e4b 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -147,9 +147,15 @@ export interface BranchDeleteParams { export interface AgentContextParams { /** Agent name */ agentName: string; - /** A hypothetical budget to compile the context at (optional). The compile - * is a dry run: if the context can't be folded to fit, it comes back as - * rendered, larger than the budget. */ + /** A hypothetical budget to compile the context at (optional). Without it + * the context compiles at context-manager's default budget (100,000, with + * 4,000 reserved for the response), not the agent's own window. The + * compile is a dry run. Under the autobiographical strategy, a budget the + * context can't fit still fails the command, with context-manager's + * refusal, which names the tokens and the limit. Passthrough keeps the + * newest messages that fit (none, if the newest alone is larger), and + * windowed passthrough returns the newest message alone, larger than the + * budget. */ maxTokens?: number; } diff --git a/src/framework.ts b/src/framework.ts index 740eec87a..2b3e19815 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -3705,28 +3705,33 @@ export class AgentFramework { * were activated right now — WITHOUT running inference, opening a stream, or * mutating agent state. Intended for debug/preview tooling. * - * What a preview commits (default): nothing durable. It compiles as a dry - * run (`dryRun`), filters tools and assembles the system prompt, then - * delegates to `Agent.buildActivationRequest`. No tokens are spent, nothing - * is written to Chronicle, no external MCPL server is contacted, no fold - * resolution is persisted and no compression work is queued; the consumed - * watermark and any prepared budget change are left alone; and with - * context-manager's thinking binding (context-manager#160) no stamps are - * committed and no compile awaits acceptance. It does touch in-memory - * state in two places: - * - it hands the context manager the compression tool definitions, as - * every activation request does; - * - context-manager 0.13's adaptive select writes its bookkeeping on a dry - * run too: the compile estimate a usage report is calibrated against, - * the calibration sample's arming, and its transition and render - * bookkeeping. So a preview between a sent compile and that request's - * first usage report replaces the estimate the report is judged - * against, and settings readers see the preview's transition values - * until the next compile. - * When the fold plan doesn't fit the budget, the dry run returns the window - * as rendered, larger than the budget, where a committing compile would - * throw (other refusals, such as a head and tail that alone exceed it, - * still throw); 0.13 hands no over-budget signal back with the window. + * What a preview commits (default): nothing of its own. It compiles as a + * dry run (`dryRun`), filters tools and assembles the system prompt, then + * delegates to `Agent.buildActivationRequest`. No tokens are spent, no + * external MCPL server is contacted, no fold resolution is persisted and + * none of the compression its fold plan calls for is queued; the consumed + * watermark and any prepared budget change are left alone; and once + * context-manager's thinking binding (context-manager#160, not yet + * released) ships, no stamps are committed and no compile awaits + * acceptance. A dry run still leaves what context-manager 0.13 leaves on + * any compile (see `Agent.compileWithInjections`): a chunk that closes is + * recorded in the store, which is Chronicle's, and queued for compression; + * an expired compression hold is released; and the adaptive select's + * in-memory bookkeeping stays as the preview set it. So a preview between + * a sent compile and that request's first usage report replaces the + * estimate the report is judged against, and settings readers see the + * preview's transition values until the next compile. The preview keeps + * in-memory bookkeeping of its own too, such as handing the context manager + * the compression tool definitions, as every activation request does. + * Under the autobiographical strategy, a budget the context can't fit + * still throws, as for a committing compile: a dry run skips only + * context-manager's post-pick check, and an adaptive fold plan that + * doesn't fit then overruns at the middle's emission, with a refusal that + * names the tokens and the limit. Windowed passthrough's newest message + * alone is the one window that comes back larger than the budget. For a + * verdict instead of the refusal, `previewContextSettings` returns + * context-manager's own at a budget, with the agent's `maxTokens` reserved + * for the response. * * The trade-off is fidelity: the dynamically-gathered ContextInjection[] * (module `gatherContext` + MCPL `beforeInference` hooks) are NOT included @@ -3758,10 +3763,10 @@ export class AgentFramework { // An explicit budget compiles against a HYPOTHETICAL window instead of the // agent's live one. Every preview is a dry run, so none of them settles - // a converging descent or commits anything durable (see the doc comment - // above). - // Default: no dynamic injection gathering → fully transparent (no - // inference, no Chronicle writes, no external RPC). Opt in explicitly. + // a converging descent or commits what a sent compile commits (see the + // doc comment above). + // Default: no dynamic injection gathering → no inference, no Chronicle + // writes of its own, no external RPC. Opt in explicitly. if (!opts?.injections) { return capture(await agent.buildActivationRequest( tools, undefined, opts?.budget, presentation?.available ?? tools, { dryRun: true }, @@ -3812,9 +3817,14 @@ export class AgentFramework { * * Unlike `previewActivation` (which renders a request), this returns the * picker's plan: resulting tokens, whether it fits, how many summaries would - * have to be produced first, and the per-chunk fold levels. Commits nothing: - * no resolution persistence, no compression enqueue, no transition - * bookkeeping — see `AutobiographicalStrategy.previewContext`. + * have to be produced first, and the per-chunk fold levels. It commits none + * of the plan: no resolution persistence, none of the summary or merge work + * the plan calls for, and its transition and render bookkeeping put back — + * see `AutobiographicalStrategy.previewContext`. Its select is a dry run, + * so in context-manager 0.13 it still leaves the rest of what a dry run + * leaves (see `Agent.compileWithInjections`): a chunk that closes is + * recorded and queued for compression, and the calibration estimate and a + * pending kv-unified draft's marker indices stay as it set them. * * Returns null when the agent's strategy has no fold plan (non-adaptive). */ diff --git a/test/unsent-compiles.test.ts b/test/unsent-compiles.test.ts index 9f7a963a8..547eb680d 100644 --- a/test/unsent-compiles.test.ts +++ b/test/unsent-compiles.test.ts @@ -61,7 +61,7 @@ describe('compiles that are never sent', () => { } finally { await x.close(); } }); - it('a preview leaves the consumed watermark: nobody read what it assembled', async () => { + it('a preview leaves the consumed watermark: the agent never read what it assembled', async () => { const x = await make(); try { const agent = x.framework.getAgent('agent')!; From 06f7c7bf4ac0ec7050c4b84285e72e41fcd54a3c Mon Sep 17 00:00:00 2001 From: Myrtle-2415 Date: Tue, 13 Oct 2026 11:41:23 -0400 Subject: [PATCH 6/9] fix(forks): pair the seed's tool blocks with context-manager's helper, and log what the seed left out The seed's own trim (withoutUnansweredToolUse) checked only the last message, and only whether it held any tool_result. A partly answered parallel round kept its unanswered call, so the fork's first request carried a tool_use with no result. - The seed now runs context-manager's exported stripUnpairedToolBlocks after withoutThinking (it is in 0.13's index, and in 0.14.0's). It drops each tool_use whose result isn't in the seed and each tool_result whose call isn't, keeps the rest of their messages, and puts its "[tool call omitted]" placeholder in a message left with nothing. context-manager uses it where a request is built from raw messages with no pairing pass, which is the seed's position under the passthrough strategies. An adaptive template's compile has been through its own live passes (trimOrphanedToolUse, enforceToolPairing), so there it finds nothing to do. The thinking goes first, so a round's row holding only thinking and an unanswered call becomes the placeholder; a row with text keeps it. - One console line when a seed left something out: "[conversation] : seeded from