refactor(agent-core-v2): rebuild context projection as a staged block pipeline - #3001
Conversation
…onverge fold/projection internals
- ContextModel state is now { messages, fold }: the loop-event fold cursor
(openStepUuid / pending / deferred) lives in the state instead of a
module-level WeakMap keyed by array identity, so wholesale replacements
(undo / clear / compaction / swarm exit) reset it structurally via
EMPTY_FOLD instead of a manual resetFold at five call sites.
- The display transcript and the wire model now share one generic fold
kernel (FoldFrame / FoldEntryAdapter), eliminating the mirrored second
implementation. Events tagged with a non-open step uuid are dropped and
step.end settles only the step it names — defensive in abnormal streams,
identical on well-formed ones (v1 replay unaffected).
- IAgentContextProjectorService converges to project(messages, policy) with
a ProjectionPolicy data object; llmRequester builds the policy from retry
state instead of selecting among four methods.
- Blob rehydrate now also covers messages still deferred in the fold cursor.
- ContextState is deeply frozen at the op boundary to preserve the consumer
immutability the wire's shallow freeze gave the bare array state.
…s in context memory - isVacuousContentPart and dehydrateRecord now switch exhaustively over ContentPart / LoopRecordedEvent variants, so a new variant fails compilation until it takes an explicit position - the transcript/model parity comparator spreads whole messages and masks only summary content, so new ContextMessage fields join the comparison automatically - correct two stale header comments: local message ids persist with append_message records, and undo's prompt-owned-injection pairing depends on them after a resume
The model Op and the display transcript each walked the undo anchors with their own loop, and the transcript partially removed the tail when an undo was blocked (compaction summary / clear floor / too few anchors) while the model side no-ops at the precheck. Move the walk into conversationTime as computeUndoCut/computeUndoCutFrom applied destructively by the context.undo Op and non-destructively by the transcript reducer, so a blocked undo reads identically on both sides. Also: make isUndoAnchor exhaustive over origin kinds with a never assertion, mirrors the compaction result message count via compactionHandoff, and extend UndoCut with anchorIndex distinguishing the counted anchor from the injection-extended cut point.
…anscript undo The transcript's kept-loop retained every injection after the oldest counted anchor, so with count > 1 a prompt-owned injection of a newer removed prompt (e.g. an image-compression caption) survived the display undo while the model Op removed it. Collect the removed anchors' ids on the same pass and keep only injections not owned by them, so the header's 'prompt-owned ones leave with their prompt' holds for every count.
The llmRequester retry chain kept a RequestProjection union and translated
it into a ProjectionPolicy per attempt; repairs were mutually exclusive,
so a strict resend rejected again for body size or image format either
aborted or silently dropped the strict repair. Retry state is now the
ProjectionPolicy itself: each rejection adds its repair on its own axis
(media: 413 -> degraded -> strip; wire: structure -> strict) without
discarding the other, requestInput's translation layer and the unreachable
snapshot ??= disappear, and the persisted llm.request projection name
derives from the policy (the op enum gains strict-media-degraded /
strict-media-stripped). Also narrows ProjectionPolicy to the variants
actually produced (wire 'strict'; media 'degraded' | { strip }), dropping
the dead 'default'/'keep' literals and their guard.
…pend-only log context.apply_compaction now appends a summary marker carrying the record fields as CompactionMeta instead of replacing the folded history; the model-visible window is derived at read time (visibleWindow) with the same [head, elision?, tail, summary] layout, one deterministic derivation for live dispatch and replay alike. The record format is unchanged, so v1- and v2-written sessions keep replaying identically both ways. - undo maps the visible-window cut back to a log position (the verbatim legacy-summary edge falls back to the pre-append-only destructive cut) - replay rehydrate only loads blobs the window derivation can surface - contextInjector drops position tracking; injection positions become a read-time scan that splices can never desync - fullCompaction's safety check moves to the stable-identity log
… surfaces The first append-only rehydrate rule kept pre-marker real user input plus markers, but a legacyTail derivation keeps window.slice(compactedCount) visible — assistant/tool media in that range stayed blobref after replay and could be resent unresolved. Decide survivors by identity membership in the derived window instead, which covers every derivation branch at once and skips the unselected pre-marker pool as a bonus.
…cy marker SwarmService.exit decides the pop on the derived window's tail, but the swarm_mode.exit reducer tested the raw log tail — after a legacyTail compaction the survivor reminder is visible at the window tail while the marker is the log tail, so the pop silently skipped and the stale reminder stayed in the model context. Mirror the visible-tail decision in the reducer and remove the entry by its stable log identity.
…ds mid-fold An overflow-triggered compaction arrives with the failed attempt's vacuous partial still open. The transcript appended the summary marker and reset the fold but left the frame; the retried step's step.begin then settled it (-1) alongside the new frame (+1), so foldedLength stayed one short of the model-visible window and kap-server's live-tail merge could duplicate the retry tail. Settle the frame at the marker through the shared kernel; recoverFoldedLength recomputes the absolute count right after either way.
Apply settleModelOpenStep inside context.apply_compaction so a marker only ever lands on a settled frame: no partial survives a marker and nothing mutates the log behind one, making the append-only invariant structural (the identity-prefix check in historySafeToCompact relied on it) instead of timing-dependent. Mirrors the transcript's settle-at-marker. Also freeze the derived visible window before caching it (an in-place consumer mutation now throws instead of silently polluting the shared cache), and drop the production-unreachable legacy branch of buildContextCompactionShape so the legacy tail layout lives only in deriveCompactionWindow.
…y internals - Slim file headers to the package header-only comment convention - Rename PR-introduced identifiers for clarity: getMessageLog, ProjectionPolicy.structure, pendingToolCallIds/deferredEntries, removedEntryCount, deriveVisibleWindowAfterCompaction, compactedWindowMessageCount - Extract nextProjectionPolicyForError, removeUndoOwnedEntries and summarizeProjectionRepairs; name fold intermediates after their business stage - Regroup splice-replay tests by topic and unify projection-call recording in llmRequester tests
… pipeline Split the 650-line projector service into three modules by concern: mediaProjection (read-side media degrade/strip fallbacks), projection (the structural transform), and the service (DI binding plus repair reporting). Rebuild the structural projection as a two-stage pipeline: pairBlocks groups tool exchanges into blocks that own their calls' results, flattenBlocks serializes them back to wire order and merges consecutive user prompts. The shared slot sentinel and index back-patching are gone; the trailing-close and sizing-slice rules are named and documented in the module header. Behavior is pinned unchanged by the existing projector and llmRequester suites.
# Conflicts: # packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts
|
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dc282494db
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| * `project` runs as a two-stage pipeline. `pairBlocks` walks the history | ||
| * once, normalizing each message's content (tool-result facts rendered for | ||
| * the model, blank text dropped, wholly-vacuous messages dropped, partial | ||
| * messages skipped) and grouping tool exchanges into blocks: an exchange | ||
| * owns its calls' results, so a displaced result attaches to its call, an |
There was a problem hiding this comment.
Keep the helper header at the external-role level
This header walks through the internal pairBlocks/flattenBlocks stages and their mechanics, while the package convention explicitly forbids narrating implementation steps or enumerating exports in module comments. Reduce it to the helper's externally visible responsibility so the comment does not become stale whenever the pipeline is rearranged.
AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L36-L40
Useful? React with 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f55abfa05d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| * Delegates the stored-history → wire-message transform to the pure | ||
| * `projection.ts` pipeline (`project` / `projectStrict`) and the read-side | ||
| * media fallbacks selected by `policy.media` to `mediaProjection.ts`, and |
There was a problem hiding this comment.
Keep the service header at the external-role level
Rewrite this header around the service's externally visible responsibility rather than naming projection.ts, project/projectStrict, and the delegation stages; these implementation details will become stale when the pipeline is reorganized and are expressly excluded from module headers.
AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L36-L40
Useful? React with 👍 / 👎.
| * `contextProjector` domain helper — read-side media fallbacks for the two | ||
| * deterministic provider rejections. | ||
| * | ||
| * `degradeOlderMediaParts` replaces all but the most recent media parts with | ||
| * text markers after an HTTP 413 body-size rejection; a strip snapshot |
There was a problem hiding this comment.
Describe the media module without enumerating its pipeline
Use the required `contextProjector` domain — <role> identity and describe only the module's responsibility; the current header adds “helper” to the identity and enumerates degradeOlderMediaParts, snapshot helpers, and their mechanics, contrary to the package's external-role-only convention.
AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L36-L40
Useful? React with 👍 / 👎.
…external-role level
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f86e2cffb3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| type ProjectionPolicy, | ||
| } from './contextProjector'; | ||
| import { | ||
| MEDIA_DEGRADE_KEEP_RECENT, |
There was a problem hiding this comment.
Preserve the existing contextProjectorService subpath exports
Any consumer importing MEDIA_DEGRADE_KEEP_RECENT, MEDIA_STRIPPED_PLACEHOLDERS, or the media helpers from @moonshot-ai/agent-core-v2/agent/contextProjector/contextProjectorService now fails because these symbols were moved without being re-exported from that module; this is a supported public subpath under the package's ./* export pattern, even though the root barrel was updated. Re-export the moved symbols from contextProjectorService.ts to keep the stated public surface unchanged.
AGENTS.md reference: AGENTS.md:L61-L61
Useful? React with 👍 / 👎.
commit: |
Related Issue
No linked issue — the problem is described below.
Problem
The context projector (stored history → provider wire messages) had grown into a single ~650-line service file mixing three concerns: the DI service with repair reporting, the media degrade/strip fallbacks, and the structural projection itself. The structural projection fused partial filtering, tool call/result pairing, displaced-result reordering, synthetic interrupted results, user-message merging, and content cleaning into one loop over shared mutable state, relying on a shared sentinel message patched later by output index — an unstated invariant that made the repairs hard to reason about and impossible to test independently.
What changed
contextProjectorService.tsby concern:mediaProjection.ts(the read-side media degrade/strip fallbacks),projection.ts(the pure structural transform), and a slim service keeping only the DI binding and deduped repair warning/telemetry. The package's public export surface is unchanged.pairBlockswalks the history once, normalizes content, and groups tool exchanges into blocks that own their calls' results — a displaced result attaches to its call, an orphan is dropped, a superseded call is closed.flattenBlocksserializes the blocks back to wire order (results follow their owning message in call order), closes still-open calls with synthetic interrupted results, and merges consecutive user prompts. The slot sentinel and index back-patching are gone.Behavior is pinned unchanged by the existing suites: the projector and llmRequester tests pass unmodified, and the full agent-core-v2 suite (5242 tests), typecheck, and import-boundary checks are green.
Checklist
gen-changesetsskill, or this PR needs no changeset. (No changeset: agent-core-v2 internal refactor, not user-perceivable.)gen-docsskill, or this PR needs no doc update. (No user-facing behavior or docs impact.)