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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions changelog.d/unsent-compiles-dry-run.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
- 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 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`.
- With context-manager 0.14, a dry run leaves what a select leaves behind as
the last committing compile left it: no chunk record, no calibration, and
none of the bookkeeping the next compile or an observer reads. The one thing
it can still leave is the release of a compression hold whose deadline has
passed, as any compile does.
- 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
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. On models that don't bind thinking to its prefix (by probes, Fable 5
and Sonnet 5), a copy would have been kept, so there the fork loses the
template's reasoning. That a provider accepts the stripped seed rests on
probes of two shapes, each sent without its thinking: an assistant text
turn followed by a new user turn, on Sonnet 5.5, Fable 5.1, Fable 5 and
Sonnet 5; and a tool round, a `tool_use` with no thinking before it and
its result, continued by the model, on those four, Opus 5.5 and Haiku 5.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
replays no item id whose reasoning is gone. Opaque items, such as a
compaction or a hosted tool's call, keep theirs.
- A fork's seed keeps no tool call whose result isn't in it, and no result
whose call isn't (context-manager's `stripUnpairedToolBlocks`): the rest of
their messages stays, and a message left with nothing becomes
`[tool call omitted]`. 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.
- A fork's spawn logs one line when its seed left something out:
`[conversation] <fork>: seeded from <template> without N thinking
block(s), N unanswered tool call(s) and N orphaned tool result(s)`.
62 changes: 51 additions & 11 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (see `compileWithInjections` for what that leaves alone). */
dryRun?: boolean;
}

export interface StartStreamResult {
Expand Down Expand Up @@ -827,6 +830,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<CompileResult> {
// RFC-006 §3.3: consumption is determined at assembly — a compile that
// fails assembled nothing, so the watermark moves only on success.
Expand All @@ -838,18 +846,42 @@ 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 },
opts?: { kvUnifiedImmutablePrefixHash?: string; dryRun?: boolean },
): Promise<CompileResult> {
const watermark = this.pendingWatermark();
// A dry run is a compile that will never be sent. The context manager
// 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. A dry run proves nothing about the budget
// either, so it goes straight to the compile, around
// compileAtLiveBudget: it settles no descent (a prepared budget change
// waits for the compile that is sent), notes no compiled budget, and
// restores nothing the boot applied at once, whether it fits or is
// refused.
//
// Context-manager 0.14 leaves what a select leaves behind as the last
// committing compile left it, on every path out of a dry run, throws
// included: no chunk record and no compression queued for a chunk the
// dry run closes, no calibration, and none of the bookkeeping the next
// compile or an observer reads (its `SelectOptions.dryRun` gives the
// contract). The one thing a dry run can still leave is the release of a
// compression hold whose deadline has passed, which `compile` does for
// any compile before it selects.
const watermark = opts?.dryRun ? null : this.pendingWatermark();
const compile = () => this.contextManager.compile(this.compileBudget(budget), injections, opts as never);
const result = budget ? await compile() : await this.compileAtLiveBudget(compile);
const result = budget || opts?.dryRun ? await compile() : await this.compileAtLiveBudget(compile);
if (watermark) this.consumedWatermark = watermark;
return result;
}
Expand Down Expand Up @@ -1035,10 +1067,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, 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.
* It doesn't wait for the tool-result guard's annotations, so a preview
* never waits on a workspace write (rollback, undo and redo report one), and
* may show a withheld stub before its place is added; the activation waits
Expand Down Expand Up @@ -1079,7 +1114,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
Expand Down
7 changes: 6 additions & 1 deletion src/api/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,12 @@ export class McpServer {
},
maxTokens: {
type: 'number',
description: 'Maximum tokens to include (optional)',
description:
"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'],
Expand Down
19 changes: 17 additions & 2 deletions src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,11 @@ export class ApiServer {
}

const contextManager = agent.getContextManager();
const { messages } = await contextManager.compile();
// 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;
const offset = params?.offset ?? 0;
Expand Down Expand Up @@ -705,10 +709,21 @@ export class ApiServer {
}

const contextManager = agent.getContextManager();
// 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 }
: undefined
: undefined,
undefined,
{ dryRun: true },
);

return { context: messages };
Expand Down
10 changes: 9 additions & 1 deletion src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,15 @@ export interface BranchDeleteParams {
export interface AgentContextParams {
/** Agent name */
agentName: string;
/** Maximum tokens to include (optional) */
/** 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;
}

Expand Down
Loading
Loading