Skip to content

common: convert and count each history message once, not per step - #1588

Open
Salhi-zh wants to merge 3 commits into
CodebuffAI:mainfrom
Salhi-zh:runtime/incremental-step-conversions
Open

Salhi-zh wants to merge 3 commits into
CodebuffAI:mainfrom
Salhi-zh:runtime/incremental-step-conversions

Conversation

@Salhi-zh

@Salhi-zh Salhi-zh commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What

convertCbToModelMessages ran on every agent step over the full history, and almost all of its cost was per-message work that is a pure function of the message: cloneDeep of every content part, the lone-surrogate scrub across every string, and a zod safeParse per converted part. sdk/run.ts measured the clone alone at ~230 ms for an 8 MB history (~575 ms at 20 MB) — paid every step, quadratic over a turn. The per-step token estimate had the same shape: the full history re-walked 3–5× per step.

Commit 1 — convert each message exactly once

Memoize the per-message product on a WeakMap keyed by message identity: each history message is cloned, scrubbed, converted and schema-checked exactly once per process; a step's remaining cost is the envelope logic plus O(1) per message. Identity keying is sound because history messages are immutable once appended — verified at every rewrite site (fit-tool-results clones before clipping, trimMessages cloneDeeps, compaction allocates, dropUnansweredToolCalls spreads).

Because cached forms are shared across requests, both mutation passes were made non-destructive: withCacheControl shallow-copies down to the marked leaf instead of cloneDeep-ing whole messages, and cache-control placement replaces entries rather than writing through aggregated arrays. The orphan-tool-call repair moved out of the converter (provably a no-op there on the hot path — run-agent-step repairs and assigns the history before the request is built); auxiliary SDK paths in sdk/impl/llm.ts repair at their own boundary via repairToolCallHistory. expireMessages returns the input array by reference when nothing expired and nothing is pruneable by keepLastTags — this PR is rebased on #1587, so the fast path now tracks keepLastTags too (previously two INSTRUCTIONS_PROMPT messages could be wrongly identity-stable).

Equivalence: verified by a randomized differential harness against the pre-change implementation — tool calls, parallel batches with interleaved media, tags/providerOptions/timeToLive, lone/valid unicode, histories to 60 messages × 20 trials × both cache-control modes — byte-identical request JSON for every fixture. (The harness caught and I fixed a real bug in the first draft: deep strings inside tool-call inputs escaping the scrub fast-path.)

Commit 2 — memoize the per-message token estimate

countTokensMessages split into per-message countTokensMessage memoized on a WeakMap; list totals remain the exact same sum. One semantics clash surfaced and is handled: fitToolResults clips defensive copies in place during binary search, so the budget loop probes with uncached counting; the already-fits fast path still memoizes the accurate final count.

Merge-time change (post-#1587 rebase)

Two small follow-ups this PR adds on top of the main resolution:

  • expireMessages fast path tracks keepLastTags too, so two INSTRUCTIONS_PROMPT messages in history are still superseded rather than deemed identity-stable.
  • The expireMessages identity test assertion updated to content equality: legacy timeToLives always take the copy path under the new expiresAt machinery (the snitch marker forces one), so reference-identity assertions no longer hold for those cases; content equality is what the contract actually guarantees.

Reviewer caveats (stated, not hidden)

  1. By design: unpaired tool-call history passed to the converter is no longer silently repaired inside the converter. The hot path is repaired upstream; SDK auxiliary entry points repair at their boundary. Pinned by a test.
  2. New contract: history messages must never be mutated in place after conversion/counting. True today (verified at all rewrite sites); called out in header comments, though a future violating site would stale-cache silently.
  3. withCacheControl's shallow copy assumes providerMetadata is plain JSON (true today).

Tests

message-conversion-cache.test.ts (warmed-cache byte-identity, no cache-control leakage, replacement invalidation, nested-surrogate scrub), expire-messages.test.ts (updated per above), token-counter-cache.test.ts (sum + recount equivalence at sizes 1–100, replacement). common 51/51 messages tests; agent-runtime util 227/227; full agent-runtime suite 995 pass / 1 pre-existing env failure; sdk + agent-runtime suites match base failure sets exactly; typechecks clean (one pre-existing common error unrelated to this change).

convertCbToModelMessages ran on every agent step over the full message
history, and almost all of its cost was per-message work that is a pure
function of the message: cloneDeep of every content part, the
lone-surrogate scrub across every string, and the zod safeParse per
converted part (sdk/run.ts measured the clone alone at ~230ms for an 8MB
history, ~575ms at 20MB — paid every step, quadratic over a turn).

Memoize the per-message product on a WeakMap keyed by message identity:
each history message is cloned, scrubbed, converted and schema-checked
exactly once per process; a step's remaining cost is the envelope logic
(media buffering, same-aux aggregation, cache-control placement) plus
O(1) per message. Keyed on identity this is sound because history
messages are immutable once appended (fit-tool-results, trim, compaction
all allocate replacements — verified at every rewrite site), so a cache
entry can only go stale if the message object itself is replaced, which
is precisely the invalidation case.

Safety within a request: cached converted forms are shared across
requests, so both mutation passes were made non-destructive.
withCacheControl no longer cloneDeeps — it shallow-copies down to the
providerOptions leaf it marks — and the cache-control pass replaces
entries instead of writing through aggregated arrays, so repeated
conversions of the same history are byte-identical and no mark leaks
across requests. Aggregation copies once per request (flagged
__aggregatedCopy, non-enumerable) instead of merging into the cached
form. The lone-surrogate fast path pre-scans without copying; messages
that need a scrub are cloned once and the scrubbed form is cached, so
the caller's history is never rewritten.

The orphan-tool-call repair moves out of the converter:
dropUnansweredToolCalls ran twice per repaired step history (once at
run-agent-step, again here — provably a no-op the second time).
Rebuild-at-request-time paths that cannot guarantee a repaired history
(sdk/impl/llm.ts auxiliary calls) now repair at their own boundary;
dropUnansweredToolCalls returns the input array unchanged when there is
nothing to repair, so the common case costs one boundary scan. expireMessages
likewise returns the input array by reference when nothing expired
instead of allocating a full-history copy every step.

Equivalence was verified by a randomized differential harness against
the pre-change implementation (tool calls, parallel batches, media
results, tags, providerOptions, timeToLive, lone/valid unicode,
histories to 60 messages x 20 trials x both cache-control modes): every
fixture produced byte-identical request messages.
The context estimator (estimateContextTokens and the step loop's
input/output estimates beside it) called countTokensMessages over the
full history 3-5x per step, plus per-message recounts from the
truncation and compaction guards. The walk itself is string-length
arithmetic, so a whole-history recount was comparatively cheap but still
re-scanned every byte of every tool result every step.

Split countTokensMessages into a per-message estimate memoized on a
WeakMap keyed by message identity; the list count remains the exact
sum of per-message counts, so numbers are unchanged (same integer terms,
associative regrouping only). Histories are immutable after append, so
identity invalidation (message replacement) is the only staleness path.

The one post-count rewrite site: fitToolResults clips its defensive
copies in place while binary-searching the budget, and re-probing each
intermediate form with the memoized estimator could pin a probe's count
onto a message the caller still owns (context-size-guard's original
would have reported 71 tokens for a 2.5MB result). fitToolResults now
probes with the uncached probeTokensMessages; the already-fits fast
path still memoizes the accurate final count.

Tests: cached-vs-fresh recount equivalence at several history sizes,
per-message sum == list count, replacement invalidation, and the
expireMessages no-op identity from the previous commit.
The identity fast path returned messages unchanged when no TTL/expiresAt
was set, but INSTRUCTIONS_PROMPT messages carry no TTL — they rely purely
on keepLastTags, which is pruned downstream of the filter. Two such
messages in history therefore piled up instead of superseding.

Track keepLastTags alongside the expiring count; only return by reference
when neither is present. Also align the identity test: legacy timeToLives
always take the copy path under the new expiresAt machinery (the snitch
marker forces one), so assert content equality, not reference identity.
@Salhi-zh
Salhi-zh force-pushed the runtime/incremental-step-conversions branch from 582ec0f to bf3ac82 Compare October 30, 2026 15:44
@codebuff-team

Copy link
Copy Markdown
Contributor

The underlying diagnosis is correct and valuable: re-running cloneDeep + surrogate scrubbing + zod validation over the full history on every step is genuinely quadratic, and the WeakMap-per-message memoization is a sound shape for the fix. The differential-harness methodology described in the PR body is the right way to validate an invisible-refactor like this.

That said, a few things make this hard to port as-is:

  1. It's stacked on agent-runtime: expire turn-scoped nudges instead of carrying them forever #1587 ("this PR is rebased on agent-runtime: expire turn-scoped nudges instead of carrying them forever #1587"), which isn't merged here. The expireMessages keepLastTags fast-path change in packages/agent-runtime/src/util/messages.ts only makes sense in that context, and mergeable: unstable suggests this diff doesn't apply cleanly on its own.

  2. convertCbToModelMessages stops calling dropUnansweredToolCalls. That's a behavior change to a shared, widely-used entry point, not just a caching change — confirmed by the flipped assertion in common/src/util/__tests__/messages.test.ts ("drops a call" → "passes through a call"). You found and patched three call sites in sdk/src/impl/llm.ts that relied on the old defensive repair, which is good diligence, but it means the converter's contract silently changed for every caller, and there's no guarantee every current or future caller remembers to repair upstream. That's a landmine for a shared utility.

  3. Footprint. 860 lines across a conversion cache, a token-counter cache, an expireMessages fast path, and an SDK-level repair relocation is too much to review and reason about safety invariants for in one pass — especially invariants like "history messages are immutable once appended," which the PR itself admits needed auditing at every rewrite site and still depends on no future rewrite site violating it silently.

Splitting into (a) the token-counter memoization (self-contained, lower risk) and (b) the conversion cache + repair relocation (needs its own scrutiny and ideally lands after #1587) would make this portable.

@codebuff-team codebuff-team added bot:triaged Classified by the community triage bot pr:needs-work Right idea, not mergeable as written labels Oct 30, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:triaged Classified by the community triage bot pr:needs-work Right idea, not mergeable as written

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants