Skip to content

fix(calibration): the sample carries the request's system prompt and tools (context-manager #323) - #763

Merged
Anarchid merged 3 commits into
anima-research:mainfrom
Anarchid:fix/calibration-request-overhead
Oct 23, 2026
Merged

Anarchid merged 3 commits into
anima-research:mainfrom
Anarchid:fix/calibration-request-overhead

Conversation

@Anarchid

Copy link
Copy Markdown
Collaborator

Problem

anima-research/context-manager#323. The calibration sample hands the strategy the provider's input total for the first call after a compile. That total counts the system prompt and the tool definitions the request sent, and the strategy's estimate of its compile holds neither. A tool-heavy agent's multiplier then learns its tool schemas as a per-token rate, or sits at 1.00 rejecting every sample as out of band.

Changes

Tests

  • test/calibration-request-overhead.test.ts: a turn whose request has a system prompt and a module tool. The spied reportRealInputTokens gets 4900 (input + cache write + cache read) and exactly { system, tools } of the request membrane received. Fails without the change, passes with it.
  • npm test: 6634 pass / 0 fail / 45 skipped (6679 tests).
  • tsc --noEmit clean.

Not verified


  • Changelog fragment added — changelog.d/calibration-request-overhead.fixed.md

🤖 Generated with Claude Code

…tools

The provider's input total counts the system prompt and the tool
definitions the request sent; the strategy's estimate of its compile holds
neither. reportRealInputTokens now gets them as its second argument
(context-manager anima-research#323), so the strategy compares the total with the whole
request. A context-manager without that argument ignores it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review

Recommendation: merge. The review found nothing of medium or high severity.

Reviewed commit 0a0c71f with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This fits. It's a narrow fix to calibration the framework already does: at the one call site that already reports the provider's input total to the strategy, it also passes the request's system prompt and tool definitions.

4 reasons with sources

It comes with a test and a changelog fragment, and it declares its companion PR the way CONTRIBUTING.md asks.

  • It fixes calibration the framework already does, in the place it already does it. reportRealInputTokens is called at only one place in src/, the per-call usage handling in driveStream. The change adds an optional second argument there and widens the existing duck type of the strategy to match. It adds no new component and no second way of doing calibration. (src/framework.ts (driveStream usage site))
  • The companion PR is declared with merge-order guidance, as the contributing guide asks. The body names context-manager#324, says the two are safe to merge in either order, and says what happens if only one lands (an older strategy ignores the argument). That is the guidance CONTRIBUTING.md requires because this repo sits on context-manager. (CONTRIBUTING.md, How changes land)
  • It keeps to the boundary between the framework and context-manager. The framework only passes along what the request sent. Pricing that overhead into the estimate stays with context-manager's strategy, in the companion PR. (CONTRIBUTING.md (ecosystem description))
  • The PR has the shape the guide asks for: a test, numeric suite counts, a Not verified section and a changelog fragment. The body reports 6634 pass / 0 fail / 45 skipped, says it was not run against a live provider or against #324's build, and adds changelog.d/calibration-request-overhead.fixed.md. The review's note that the fixed fragment has no effect with the pinned context-manager is about how the entry is worded, not about whether the change belongs here. (CONTRIBUTING.md, What a PR should contain / Changelog)

Findings

No findings of medium or high severity.

3 more findings, less severe
  • Low severity, documentation, changelog.d/calibration-request-overhead.fixed.md:1-7. fixed fragment describes a fix that has no effect with the pinned context-manager. The fragment says the strategy now compares the provider total with an estimate of the whole request, and that before this a tool-heavy agent's multiplier learned its tool schemas as a per-token rate. package.json still pins @animalabs/context-manager at ^0.20.0, and the PR description says no dependency floor moves. The installed strategy ignores the second argument, so after this release the multiplier behaves exactly as it did before until a context-manager containing #324 is installed. A reader of CHANGELOG.md would think the miscalibration is fixed. The entry should say that the fix needs context-manager #324 (or the version that ships it), or the floor should move when #324 is released.
  • Low severity, test coverage, test/calibration-request-overhead.test.ts:90-107. Test never checks that system-position injections reach the sample. The PR relies on compiledRequest.system already holding system-position injections (assembleActivationRequest builds system from buildSystemPrompt(systemInjections)), so the sample's overhead covers them. The test only sets a static systemPrompt and adds no module that returns a position: 'system' injection. A later change could build the sampled system from the agent's base prompt alone, leaving injections out of the overhead, and this test would still pass. Add a module whose injection is system-position, and assert that the sampled system contains its text.
  • Low severity, documentation, src/framework.ts:20434-20445. Comment and changelog say "whole request", but rows added after the compile are still left out. The new comment and the changelog say the strategy now prices the whole request, not just its compiled window. But assembleActivationRequest (src/agent.ts:1460-1487) adds messages after compileWithInjections returns: request-only rows (renderRequestOnlyRows), the silent-heartbeat separator turn, and the continueTurn() [Continue] row. These are counted in the provider total but are not in the compile's estimate, and they are not in the { system, tools } overhead either. So the sample still carries some unexplained difference. Either say in the comment that only system and tools are passed, or include those rows.

… term in the sample; say the fix waits for context-manager anima-research#324

From our internal review of anima-research#763.

- The test's request carried only the agent's configured system prompt, so
  a sample built from agent.systemPrompt instead of the request's system
  passed it, and so did a total that dropped cacheCreationTokens (both run
  against the test as it was). Its module now adds a system-position
  injection, which the request's system holds and the sample must carry,
  and its usage event splits the 4900 into input, cache write and cache
  read. Each of the two variants now fails it.
- The changelog fragment says that context-manager 0.20.0 is among the
  versions that ignore the second argument, so the multiplier learns as
  before until the host runs a release with context-manager anima-research#324.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor

Our review of this PR: it passes the request's own system prompt and tools with the sample, and Anarchid#2, a pull request into this branch, tightens its test and the changelog fragment. Nothing is needed from you beyond reading it. Our internal review read 0a0c71f1.

Written by Esther-2584.

— Lottie-2661

Calibration test: pin the injected system prompt and the cache-write term; say the fix waits for anima-research/context-manager#324

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nightly dual review · round R1 at cfda701 · reviewers: Codex gpt-6-astra (xhigh) and Claude claude-fable-5-1, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at cfda701 against main 091fa49

Verdict: approve. The change is what it says: at the one usage site that already feeds the strategy a per-call input total, it now also hands over the system and tools of the request membrane actually streamed, and the strategy duck type widens to match. Traced end to end, compiledRequest is the object startStreamWithInjections passed to membrane.streamYielding, so what the sample carries is what the framework sent, system-position injections and MCPL tools included. I ran the new test three ways: against the installed context-manager 0.20.0 (passes, strategy prices nothing, as the changelog says), with the source change reverted (fails on the overhead assertion, as claimed), and against a local build of context-manager #324 at d2bd6fb7 (passes, and the strategy's diagnostic shows the overhead being priced: est=1k incl. 1k system+tools). One minor item below; nothing blocks.

This is review round 1. No earlier review by Anarchid exists on this PR (the prior comments are the automated review of 0a0c71f and nissa-seru's note). Both nomination facts hold: the PR is the agent-framework half of context-manager#323, companion to context-manager#324 (open, unmerged at d2bd6fb7), and Anarchid#2 (the tightened test and changelog) is merged into this branch at the merge commit under review.

Blockers

None.

Major

None.

Minor

R1-1 — Provider-level overrides can make the sample describe content the provider never saw. src/framework.ts:20444 reads system and tools from the normalized request. An agent's providerParams (src/agent.ts:1519) ride along as extra and the adapters apply them after the system and tools are built: membrane src/providers/anthropic.ts:1309-1329 does Object.assign(params, rest), and the OpenAI Responses adapter does the same. A host that overrides tools (say, to []) or system through providerParams, or a membrane beforeRequest hook that rewrites the provider request, sends different bytes than the sample declares. With the companion context-manager change installed, the strategy then prices schemas that were never sent (or misses replacement text), and under its new consistent-run rule three such samples move the multiplier. Codex reproduced this in memory with the real adapter and strategy methods: one normalized tool, zero wire tools, three samples drove the multiplier from 1.0 to 0.6. This is an escape-hatch configuration, so I rate it minor rather than major: a host overriding its advertised tool list at the provider seam is already out of sync with the framework's own tool dispatch. Suggested fix, cheapest first: when compiledRequest.providerParams carries any of system, tools, or messages, pass undefined (the strategy then stays on its pre-#324 behaviour for that store) and say so in the comment; or, properly, have membrane report the effective request on its usage/receipt event and sample from that. The present test cannot see this because MockMembrane bypasses formatting and adapters.

Settling the nomination questions

Is what it passes exactly what the provider counted? It is exactly what the framework sent, which is the right contract for this seam; the provider counts a shaped version of it:

  • System-position injections: yes. assembleActivationRequest builds system via buildSystemPrompt(systemInjections) (src/agent.ts:1491, :1858-1866), and the test now pins an injected line.
  • MCPL tools: yes. tools is the advertised presentation surface (src/framework.ts:18312-18315), which includes MCPL definitions; the request gets availableTools.length > 0 ? availableTools : undefined (src/agent.ts:1492).
  • Cached tool and system blocks: consistent. realTotal sums fresh input, cache write and cache read (src/framework.ts:20390-20393), so tools and system are counted whether they hit the cache or not. Cache markers are added to the provider-side representation without touching the string system field.
  • Retries: each framework retry is a fresh compile and a fresh compiledRequest (membraneRetries: false, src/framework.ts:18398-18402; retry re-enters startAgentStream). A refusal retry replays the same request inside membrane, so the same { system, tools } describe it. Membrane emits usage once per completed round, before tool dispatch, so the first sample after a compile is the first round, and the strategy's arm-once gate ignores the rest.
  • Forks and ephemerals: each is its own agent with its own strategy and its own pass through the same code; nothing special-cases them.
  • What differs on the wire: the native formatter renames inputSchema, the Anthropic adapter flattens root schema unions, XML tool protocols render tools as text into system or conversation, OpenAI-style adapters wrap tools as functions, and Anthropic's own tool-use system prompt is never visible. All proportional or fixed, and the estimate is a character heuristic anyway; the multiplier absorbs them.

What does an older context-manager do with the extra fields? Ignores them. v0.20.0 (src/strategies/autobiographical.ts:13705) and current main at 178a774c (:13935) declare reportRealInputTokens(realTotal: number) and never read a second argument. Run-verified: the test against 0.20.0 passes and the diagnostic shows est=0k real=5k with no overhead term. ^0.20.0 is unchanged in package.json, so the changelog's "0.20.0 included ... until a context-manager release with #324" is accurate.

What it doesn't claim to touch, and still leaves in the gap between estimate and provider total:

  • Message-position injections (beforeUser / afterUser): in the request's messages, in neither the window estimate nor { system, tools }. Context-manager owns them and Anarchid/context-manager#2 (open, into the #324 branch) prices them there; nothing for this PR.
  • Rows the framework adds after the compile: request-only rows, the silent-heartbeat separator, the [Continue] turn, and the tool-result guard's edits in prepareRequest. Counted by the provider, not in the estimate. Small and pre-existing.
  • R1-1's override path, above.

Verified fine

  • compiledRequest identity: startStreamWithInjections returns the very object it handed to membrane.streamYielding (src/agent.ts:1644, :1669-1671); driveStream receives it as a frame-local parameter (src/framework.ts:18391-18398, :18490-18496) and never reassigns it. A superseded stream's events are dropped before the usage case (:19103-19109). No path passes an undefined or stale request.
  • Type compatibility: membrane's NormalizedRequest.system is string | undefined (src/types/request.ts:213), tools is ToolDefinition[]; context-manager #324's RequestOverhead imports the same ToolDefinition from membrane and takes ReadonlyArray, so the shapes line up with no cast games once the floor moves.
  • Membrane never mutates request.system or request.tools in place (all uses on main are reads), so the sample is not polluted by cache-control blocks.
  • Test wiring: the strategy instance passed in agents[].strategy is the one getContextManager().getStrategy() returns; the spy shadows the prototype method on that instance; asMembrane() returns this, so the subclass streamYielding override is reached; the 4900 total is input 900 + cache write 600 + cache read 3400.
  • tsc build clean at head; the single test passes in 80 ms; negative run fails exactly on the deepEqual of the overhead.
  • Changelog fragment follows changelog.d/README.md (flat file, .fixed.md, bullet with two-space continuations); its version claims are correct.
  • Follow-up, not a finding: when the context-manager release containing #324 ships, bump the floor and replace the inline duck type with the exported RequestOverhead.

Method: two passes merged. Codex (gpt-6-astra, reasoning effort xhigh, read-only sandbox, no network) ran 122 commands over the worktree and the local git refs of membrane and context-manager (2.39M input tokens, 2.21M cached, 15k output), including in-memory executions of the real adapter and strategy methods. Claude's pass had network: fetched the PR head, both upstream mains and context-manager #324, built the framework at head, ran the test against context-manager 0.20.0, with the fix reverted, and against a local build of #324. R1-1 was found by Codex and independently re-verified against the adapter source. The identity of compiledRequest, retry and refusal paths, type compatibility, and the 0.20.0 behaviour were confirmed by both passes. Codex could not run the test suite (its sandbox lacked dependencies); Claude ran the new test but not the full 6.6k-test suite.

@Anarchid
Anarchid merged commit 7b4738b into anima-research:main Oct 23, 2026
12 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 24, 2026
…qualified channels meet the registry, tune-out and the reply anchor

What main brings since e4d1e46, the sixth catch-up's base: 0.25.0 and
0.26.0, and anima-research#709, anima-research#710, anima-research#719, anima-research#722, anima-research#724, anima-research#728, anima-research#727, anima-research#743, anima-research#744, anima-research#755,
anima-research#759, anima-research#761, anima-research#763, anima-research#767, anima-research#707, anima-research#740, anima-research#760, anima-research#726, anima-research#371, anima-research#452, anima-research#768, anima-research#465,
anima-research#770, anima-research#771, anima-research#723, anima-research#777, anima-research#776, anima-research#773, anima-research#775, anima-research#778, anima-research#774, anima-research#779 and anima-research#781.
Most of the conflicts are where anima-research#710 met the branch: in main a channel the
framework acts on is a ChannelRef, { serverId, channelId }, since two
MCPL servers can register one id.

Conflicts in 14 files (61 hunks), resolved by keeping both sides' intent;
where the two had different designs for one thing, the branch's stays,
moved onto main's types:

- src/framework.ts: main's conversationAgentHomes map stays gone. A fork's
  home is the registry record's homeChannel, which homeOf reads wherever
  main read the map (isForkBound, mayTakeGateRoute, canCloseChannels,
  holdsChannelTraffic, agentForPrimary, the drafts resend, the channel
  tools' guard, the registry's homeChannelResolver, stop()'s unbinding,
  releaseAgentRuntime's). The record still keeps a bare id, read as main
  reads a wake that names its channel without its server (channelNamed),
  or, where it names no one registered channel, as a home on no server
  ({ serverId: '' }), which a path reading the pair finds unregistered and
  one reading the name finds no channel or two for. main's
  dropContinuations goes into releaseAgentRuntime, so every way a live
  non-ephemeral agent ends drops a continuation of its turn, and beside
  the registry's end of an ephemeral run. Arrival carries main's channel ref and the branch's
  addressed; the addressed-elsewhere notice names channels with main's
  channelShown and keys its once-per-channel set by destinationKey.
  isAddressedMessage stays tag-only, with main's ambient marks.
  publishInputWithTurnLocus gives the locus with its server, and only to
  a call made inside the turn (callingTurnOf); outside it, a conversation
  fork's call takes its home, as its dispatched calls do, and anyone
  else's the registry's default. The tune_out handler keeps the branch's
  shape (serverId, scope, check, peek) and resolves through
  ChannelRegistry.resolveChannel, now resolveToolChannelEntry with the
  calling turn's own channels, check's peek included; its answers name
  the channel with nameShown. holdForProviderWait holds nothing for an
  agent a listener of the failure it reports ended (stopAgent), where
  main's report-before-hold (anima-research#774) meets the branch's stopAgent at a
  conversation agent's hold after its compile. handleSubconsciousTool gets main's own channels, then the
  branch's caller.
- src/tune-out/coordinator.ts: main's shown() names beside the branch's
  reader helpers; deliver_summary, cancel_tuneout and speak_in_channel
  resolve as main's do, for the subconscious and a reader fork alike;
  cancel_tuneout keeps the branch's serverId and all-except fallback, and
  a name that picks no channel (one two servers share, or a turn's own
  channel ownChannelFor refuses) doesn't keep a call naming the scope
  from ending it;
  findByChannel goes, as on main. focusChannel() returns a ref.
- src/mcpl/channel-registry.ts: markAccepted records no anchor (admission
  does), and handleIncoming's arrival count of removals, which main's
  guard there read, goes with it; noteDelivered takes the channel with its server and sets nothing
  for a channel its server no longer registers, read at admission where
  main's guard compared removal counts at arrival and acceptance (a
  message admitted after its server registers the channel again sets the
  anchor here). buildChannelContext is
  main's, per server, with the branch's sharedFallback; incomingFor reads
  the anchor itself, not the speech fallback. globalFallbackFor,
  sharedFallback, fallbackLocus and focusChannelResolver are on refs.
- src/index.ts: main's DefaultErrorPolicy beside the branch's exports.
- Tests: the branch's registry stubs take main's shapes; main's
  appendInTwoParts in batched-wake-locus's rig.

Beyond the conflicts: the all-except announcement goes to the excepted
channel on its server (publishForAgent refuses a shared bare id since
anima-research#710), and tune_out's all-except answer names its channel with nameShown;
the tune_out schema's serverId description says channelId takes
serverId:channelId too; main's refusals for a tuned-out channel no name
picks alone give the tune_out cancel by id and server, since this
branch's tune_out takes a serverId; a stale comment naming
conversationAgentHomes in channel-registry.ts names the registry;
docs/dendrite.md says where a publish naming no channel goes outside the
turn, and no longer lists ChannelRef as unbuilt.

Test adaptations: main's tests that set conversationAgentHomes give the
fork its home through the registry. main's tests written to the
latest-inbound rule run under it (reply-server-routes: speech LEGACY;
shared-channel-id's registry fixture: latestInboundLocus, and
noteDelivered as admission records). Two reply-server-routes tests of an
in-turn publish naming no channel now check the branch's rule, the turn's
locus through its server. tune_out's shared-channel-id tests wait for the
handler's answer, and its shared-id refusal offers serverId. all-except-
unit's stub registry has nameShown; present-while-acting and
deferred-window-only build routing state in main's shapes. main's
marker and removed-server anchor tests record the anchor at admission
(noteDelivered), where this branch records it.

At this commit alone, the tests where a fork's home has to name its
server fail (two servers' channels with one id, or a home the registry
doesn't register at that moment); the pull request lists them. The next
commit stores the home with its server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants