From 02aa24e2f460e7806ee08793c7bd260bb614d295 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 17 Aug 2026 18:14:44 +0800 Subject: [PATCH] refactor(agent-core-v2): carry the context fold cursor in state and converge fold/projection internals (#2875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(agent-core-v2): carry the context fold cursor in state and converge 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. * test(agent-core-v2): move fold parity rationales into the test file header * docs(agent-core-v2): move fold declaration comments into module headers * refactor(agent-core-v2): merge FoldFrame into generic ContextState * refactor(agent-core-v2): compile-enforce part/event handling decisions 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 * refactor(agent-core-v2): converge undo-cut decision in conversationTime 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. * fix(agent-core-v2): drop removed prompts' injections on multi-turn transcript 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. * refactor(agent-core-v2): accumulate request projection repairs as policy 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. * refactor(agent-core-v2): derive the visible context window from an append-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 * fix(agent-core-v2): rehydrate exactly the messages the visible window 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. * fix(agent-core-v2): pop the visible-tail swarm reminder behind a legacy 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. * fix(agent-core-v2): settle open transcript frames when compaction lands 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. * fix(agent-core-v2): settle open frames at the compaction marker 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. * refactor(agent-core-v2): tighten naming and comments in context memory 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 * fix(agent-core-v2): preserve bounded context state * docs(agent-core-v2): restore the domain identity line in the compactionHandoff header --- .../agent-core-v2/docs/wire-manifest.d.ts | 2 +- .../agent/contextMemory/compactionHandoff.ts | 25 +- .../src/agent/contextMemory/vacuousContent.ts | 18 +- .../contextProjector/contextProjector.ts | 22 +- .../contextProjectorService.ts | 121 ++++--- .../src/agent/llmRequester/llmRequestOps.ts | 2 +- .../agent/llmRequester/llmRequesterService.ts | 215 ++++++------ .../test/agent/contextMemory/context.test.ts | 26 ++ .../projector-tool-exchanges.test.ts | 50 +-- .../llmRequester/llmRequesterService.test.ts | 332 ++++++++---------- 10 files changed, 428 insertions(+), 385 deletions(-) diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 38873dbec..a3ff3e2e9 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -305,7 +305,7 @@ interface LlmRequestPayload { messageCount: number; turnStep?: string; attempt?: string; - projection?: 'strict' | 'media-degraded' | 'media-stripped'; + projection?: 'strict' | 'media-degraded' | 'media-stripped' | 'strict-media-degraded' | 'strict-media-stripped'; droppedCount?: number; } diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts index 913fb6ba8..a489ad9a2 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -1,14 +1,11 @@ /** - * `contextMemory` domain helper — derives the v1-compatible full-compaction - * handoff shape for live rewrites, wire replay, and snapshot reducers. + * `contextMemory` domain helper — builds the bounded context window produced + * by compaction and exposes the shared user-message selection rules used by + * live execution and replay. * - * Token budgeting runs through an injectable {@link TokenEstimate}: the live - * path (`AgentContextMemoryService.applyCompaction`) passes the estimator - * from `IAgentTokenCountingService` (the raw heuristics — the - * `[token_counting]` strategy never gates internal estimates); the pure - * wire-replay / reducer paths keep the same heuristics — their estimate - * fallback only fires when a record lacks `tokensAfter`, so the measured - * chain is unaffected. + * Estimates token sizes through `kosong`'s contract heuristics (injectable as + * `TokenEstimate`) and wraps elision notes through `systemReminder`. + * Scope-agnostic. */ import { estimateTokens, estimateTokensForMessage, estimateTokensForMessages } from '#/kosong/contract/tokens'; @@ -24,7 +21,6 @@ export const COMPACTION_ELISION_VARIANT = 'compaction_elision'; type MessageLike = ContextMessage; -/** Injectable token-count estimates; see the file header for who passes what. */ export interface TokenEstimate { readonly text: (text: string) => number; readonly message: (message: MessageLike) => number; @@ -51,15 +47,7 @@ export interface ContextCompactionShapeInput { readonly compactedCount: number; readonly tokensBefore: number; readonly tokensAfter?: number; - /** Measured output tokens of the compaction LLM exchange — the REAL size of - * the generated summary. Preferred over the summary-text estimate in the - * `tokensAfter` fallback when present. */ readonly summaryOutputTokens?: number; - /** Estimated fixed request overhead (system prompt + non-deferred tool - * schemas) surviving the compaction; counted into the `tokensAfter` - * fallback so the result stays on the same full-request basis as the - * measured exchange anchors. Live path only — replay reads the persisted - * `tokensAfter` verbatim. */ readonly requestOverheadTokens?: number; readonly keptUserMessageCount?: number; readonly keptHeadUserMessageCount?: number; @@ -139,6 +127,7 @@ export function buildContextCompactionShape( }; } + export function buildCompactionSummaryText(summary: string): string { const suffix = summary.trim(); return `${COMPACTION_SUMMARY_PREFIX}\n${suffix.length > 0 ? suffix : '(no summary available)'}`; diff --git a/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts b/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts index 293d26559..932de9214 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts @@ -10,7 +10,19 @@ import type { ContentPart } from '#/kosong/contract/message'; export function isVacuousContentPart(part: ContentPart): boolean { - if (part.type === 'text') return part.text.trim().length === 0; - if (part.type === 'think') return part.encrypted === undefined && part.think.trim().length === 0; - return false; + switch (part.type) { + case 'text': + return part.text.trim().length === 0; + case 'think': + return part.encrypted === undefined && part.think.trim().length === 0; + case 'image_url': + case 'audio_url': + case 'video_url': + return false; + default: { + const exhaustive: never = part; + void exhaustive; + return false; + } + } } diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts index 48987f673..1262ba471 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts @@ -4,6 +4,14 @@ * Defines wire-safe history projections and an opaque snapshot of the media * identities that a provider rejected, allowing later steps to strip only * that content while preserving newly generated recovery media. + * + * Projection variability is expressed as data: a `ProjectionPolicy` — + * `structure: 'strict'` adds the structural repairs strict providers need + * (duplicate tool calls dropped, consecutive assistants merged, leading + * non-user messages dropped); `media` selects the provider-rejection + * fallback (`'degraded'` replaces all but the most recent media with text + * markers after an HTTP 413; `{ strip }` replaces exactly the snapshotted + * media identities after a rejected-format or still-too-large resend). */ import { createDecorator } from '#/_base/di/instantiation'; @@ -17,17 +25,19 @@ export interface MediaStripSnapshot { readonly [mediaStripSnapshotBrand]: undefined; } +export interface ProjectionPolicy { + readonly structure?: 'strict'; + readonly media?: 'degraded' | { readonly strip: MediaStripSnapshot }; +} + export interface IAgentContextProjectorService { readonly _serviceBrand: undefined; - project(messages: readonly ContextMessage[]): readonly Message[]; - projectStrict(messages: readonly ContextMessage[]): readonly Message[]; - projectMediaDegraded(messages: readonly ContextMessage[]): readonly Message[]; - captureMediaStripSnapshot(messages: readonly ContextMessage[]): MediaStripSnapshot; - projectMediaStripped( + project( messages: readonly ContextMessage[], - snapshot?: MediaStripSnapshot, + policy?: ProjectionPolicy, ): readonly Message[]; + captureMediaStripSnapshot(messages: readonly ContextMessage[]): MediaStripSnapshot; } export const IAgentContextProjectorService = createDecorator( diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts index 01df03888..43becbb33 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts @@ -15,14 +15,13 @@ * repair-dedup signature (`lastRepairSignature`) is registered into * `agentState` (`IAgentStateService`) and read/written through it. * - * `projectMediaDegraded` / `projectMediaStripped` are the fallback - * projections for the two deterministic provider rejections: media-degraded - * (all but the most recent media replaced by text markers) resends after an - * HTTP 413 body-size rejection; media-stripped captures every media identity - * present when degraded media is still too large or an image format is - * rejected, then replaces only that snapshot on later steps so a newly - * generated recovery image remains visible. Both are read-side only — the - * history keeps its media. + * `policy.media` selects the fallback projections for the two deterministic + * provider rejections: `'degraded'` (all but the most recent media replaced + * by text markers) resends after an HTTP 413 body-size rejection; + * `{ strip }` replaces only the snapshotted media identities present when + * degraded media is still too large or an image format is rejected, so a + * newly generated recovery image remains visible on later steps. Both are + * read-side only — the history keeps its media. */ import { createHash } from 'node:crypto'; @@ -40,6 +39,7 @@ import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentContextProjectorService, type MediaStripSnapshot, + type ProjectionPolicy, } from './contextProjector'; export const contextProjectorLastRepairSignatureKey = defineState( @@ -66,36 +66,24 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi this.states.set(contextProjectorLastRepairSignatureKey, value); } - project(messages: readonly ContextMessage[]): readonly Message[] { - return this.projectWithTrace(messages, project); - } - - projectStrict(messages: readonly ContextMessage[]): readonly Message[] { - return this.projectWithTrace(messages, projectStrict); - } - - projectMediaDegraded(messages: readonly ContextMessage[]): readonly Message[] { - return degradeOlderMediaParts( - this.projectWithTrace(messages, project), - MEDIA_DEGRADE_KEEP_RECENT, + project( + messages: readonly ContextMessage[], + policy: ProjectionPolicy = {}, + ): readonly Message[] { + const projected = this.projectWithTrace( + messages, + policy.structure === 'strict' ? projectStrict : project, ); + const media = policy.media; + if (media === undefined) return projected; + if (media === 'degraded') return degradeOlderMediaParts(projected, MEDIA_DEGRADE_KEEP_RECENT); + return stripMediaPartsBySnapshot(projected, media.strip); } captureMediaStripSnapshot(messages: readonly ContextMessage[]): MediaStripSnapshot { return captureMediaStripSnapshot(this.projectWithTrace(messages, project)); } - projectMediaStripped( - messages: readonly ContextMessage[], - snapshot?: MediaStripSnapshot, - ): readonly Message[] { - const projected = this.projectWithTrace(messages, project); - return stripMediaPartsBySnapshot( - projected, - snapshot ?? captureMediaStripSnapshot(projected), - ); - } - private projectWithTrace( messages: readonly ContextMessage[], fn: (history: readonly ContextMessage[], onAnomaly?: (anomaly: ProjectionAnomaly) => void) => Message[], @@ -121,26 +109,17 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi if (signature === this.lastRepairSignature) return; this.lastRepairSignature = signature; - let reordered = 0; - let synthesized = 0; - let droppedOrphan = 0; - let duplicateCallsDropped = 0; - let duplicateResultsDropped = 0; - let leadingDropped = 0; - let assistantsMerged = 0; - let whitespaceDropped = 0; - let vacuousDropped = 0; - for (const anomaly of notable) { - if (anomaly.kind === 'tool_result_reordered') reordered += 1; - else if (anomaly.kind === 'tool_result_synthesized') synthesized += 1; - else if (anomaly.kind === 'orphan_tool_result_dropped') droppedOrphan += 1; - else if (anomaly.kind === 'duplicate_tool_call_dropped') duplicateCallsDropped += 1; - else if (anomaly.kind === 'duplicate_tool_result_dropped') duplicateResultsDropped += 1; - else if (anomaly.kind === 'leading_non_user_dropped') leadingDropped += 1; - else if (anomaly.kind === 'consecutive_assistants_merged') assistantsMerged += 1; - else if (anomaly.kind === 'vacuous_message_dropped') vacuousDropped += 1; - else whitespaceDropped += 1; - } + const { + reordered, + synthesized, + droppedOrphan, + duplicateCallsDropped, + duplicateResultsDropped, + leadingDropped, + assistantsMerged, + whitespaceDropped, + vacuousDropped, + } = summarizeProjectionRepairs(notable); const toolCallIds = [ ...new Set( notable.flatMap((anomaly) => ('toolCallId' in anomaly ? [anomaly.toolCallId] : [])), @@ -183,6 +162,46 @@ type ProjectionAnomaly = | { readonly kind: 'whitespace_text_dropped'; readonly role: string } | { readonly kind: 'vacuous_message_dropped'; readonly role: string }; +interface ProjectionRepairSummary { + readonly reordered: number; + readonly synthesized: number; + readonly droppedOrphan: number; + readonly duplicateCallsDropped: number; + readonly duplicateResultsDropped: number; + readonly leadingDropped: number; + readonly assistantsMerged: number; + readonly whitespaceDropped: number; + readonly vacuousDropped: number; +} + +function summarizeProjectionRepairs( + anomalies: readonly ProjectionAnomaly[], +): ProjectionRepairSummary { + const summary = { + reordered: 0, + synthesized: 0, + droppedOrphan: 0, + duplicateCallsDropped: 0, + duplicateResultsDropped: 0, + leadingDropped: 0, + assistantsMerged: 0, + whitespaceDropped: 0, + vacuousDropped: 0, + }; + for (const anomaly of anomalies) { + if (anomaly.kind === 'tool_result_reordered') summary.reordered += 1; + else if (anomaly.kind === 'tool_result_synthesized') summary.synthesized += 1; + else if (anomaly.kind === 'orphan_tool_result_dropped') summary.droppedOrphan += 1; + else if (anomaly.kind === 'duplicate_tool_call_dropped') summary.duplicateCallsDropped += 1; + else if (anomaly.kind === 'duplicate_tool_result_dropped') summary.duplicateResultsDropped += 1; + else if (anomaly.kind === 'leading_non_user_dropped') summary.leadingDropped += 1; + else if (anomaly.kind === 'consecutive_assistants_merged') summary.assistantsMerged += 1; + else if (anomaly.kind === 'vacuous_message_dropped') summary.vacuousDropped += 1; + else summary.whitespaceDropped += 1; + } + return summary; +} + type OnAnomaly = (anomaly: ProjectionAnomaly) => void; export const MEDIA_DEGRADE_KEEP_RECENT = 2; diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts index a8f6e2155..142b111a5 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts @@ -61,7 +61,7 @@ const llmRequestSchema = z.object({ messageCount: z.number(), turnStep: z.string().optional(), attempt: z.string().optional(), - projection: z.enum(['strict', 'media-degraded', 'media-stripped']).optional(), + projection: z.enum(['strict', 'media-degraded', 'media-stripped', 'strict-media-degraded', 'strict-media-stripped']).optional(), droppedCount: z.number().optional(), }); diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index ed0a182ea..cb2726031 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -7,8 +7,10 @@ * folds the completion-token budget into the profile's dialect-free intent * params, then drives a bounded request chain through the `ModelRequester` * resolved from `IModelCatalog`: one primary `requester.request(input, signal, - * params)` attempt plus projection rebuilds for request structure or media - * compatibility. Before each request the projected messages pass through `media`'s + * params)` attempt plus accumulating projection rebuilds — each repeated + * provider rejection (request structure, body size, image format) adds its own + * repair on top of the ones already applied. Before each request the projected + * messages pass through `media`'s * media resolver, which rewrites every `kimi-file://` prompt-media reference * to a provider-acceptable part (an uploaded `ms://` video, an inline base64 * `data:` part, or a degradation tag/drop) so the internal reference never @@ -41,6 +43,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import { IAgentContextProjectorService, type MediaStripSnapshot, + type ProjectionPolicy, } from '#/agent/contextProjector/contextProjector'; import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile'; @@ -128,8 +131,6 @@ interface ResolvedLLMRequest { readonly logFields: AgentLLMRequestLogFields; } -type RequestProjection = 'normal' | 'strict' | 'media-degraded' | 'media-stripped'; - interface LLMRequestLogInput { readonly protocol: Protocol; readonly providerType?: string; @@ -342,41 +343,34 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { ): Promise { this.toolCallIdNormalizer.seedFrom(this.context.get()); const shaped = this.toolSelect.shapeHistory(request.messages); - let mediaStripSnapshot = this.mediaStripSnapshotForTurn(request.source); - const requestInput = (projection: RequestProjection) => { - return { + const recoveredStrip = this.mediaStripSnapshotForTurn(request.source); + let policy: ProjectionPolicy | undefined = + recoveredStrip !== undefined + ? { media: { strip: recoveredStrip } } + : this.isRecoveryTurn(this.mediaDegradedTurns, request.source) + ? { media: 'degraded' } + : undefined; + const captureMediaStripPolicy = (): { readonly strip: MediaStripSnapshot } => { + const snapshot = this.projector.captureMediaStripSnapshot(shaped); + this.markMediaStrippedRecoveryTurn(snapshot, request.source); + return { strip: snapshot }; + }; + const run = async ( + policy: ProjectionPolicy | undefined, + ): Promise => { + onRequestTrace(undefined); + const projection = projectionNameOf(policy); + const fields = + projection === undefined ? request.logFields : { ...request.logFields, projection }; + const input = { systemPrompt: request.systemPrompt, tools: request.tools, - messages: - projection === 'strict' - ? this.projector.projectStrict(shaped) - : projection === 'media-degraded' - ? this.projector.projectMediaDegraded(shaped) - : projection === 'media-stripped' - ? this.projector.projectMediaStripped( - shaped, - (mediaStripSnapshot ??= - this.projector.captureMediaStripSnapshot(shaped)), - ) - : this.projector.project(shaped), - }; - }; - - const run = async (projection: RequestProjection): Promise => { - onRequestTrace(undefined); - const projected = requestInput(projection); - const input = { - ...projected, messages: await this.mediaResolver.resolve( - projected.messages, + this.projector.project(shaped, policy), request.requester, signal, ), }; - const fields = - projection === 'normal' - ? request.logFields - : { ...request.logFields, projection }; this.warnAboutAnthropicThinkingEffort(request); const logInput: LLMRequestLogInput = { protocol: request.model.protocol, @@ -473,75 +467,77 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { }; }; - const initialProjection: RequestProjection = mediaStripSnapshot !== undefined - ? 'media-stripped' - : this.isRecoveryTurn(this.mediaDegradedTurns, request.source) - ? 'media-degraded' - : 'normal'; - let projection: RequestProjection = initialProjection; for (;;) { try { - return await run(projection); + return await run(policy); } catch (error) { - if (signal?.aborted === true) throw error; - const raw = unwrapErrorCause(error); - if ( - raw instanceof APIRequestTooLargeError && - (projection === 'normal' || projection === 'media-degraded') - ) { - signal?.throwIfAborted(); - if (projection === 'normal') { - this.log.warn( - 'provider rejected request as too large; resending with degraded media', - { - model: request.model.name, - ...request.logFields, - }, - ); - this.markRecoveryTurn(this.mediaDegradedTurns, request.source); - projection = 'media-degraded'; - } else { - this.log.warn( - 'provider rejected degraded-media request as too large; resending with rejected media stripped', - { - model: request.model.name, - ...request.logFields, - }, - ); - mediaStripSnapshot = this.projector.captureMediaStripSnapshot(shaped); - this.markMediaStrippedRecoveryTurn(mediaStripSnapshot, request.source); - projection = 'media-stripped'; - } - continue; - } - if (projection !== 'media-stripped' && isImageFormatError(raw)) { - signal?.throwIfAborted(); - this.log.warn( - 'provider rejected an image in the request; resending with rejected media stripped', - { - model: request.model.name, - ...request.logFields, - }, - ); - mediaStripSnapshot = this.projector.captureMediaStripSnapshot(shaped); - this.markMediaStrippedRecoveryTurn(mediaStripSnapshot, request.source); - projection = 'media-stripped'; - continue; - } - if (projection === 'normal' && isRecoverableRequestStructureError(raw)) { - signal?.throwIfAborted(); - this.log.warn('provider rejected request structure; resending with strict projection', { - model: request.model.name, - ...request.logFields, - }); - projection = 'strict'; - continue; - } - throw error; + const nextPolicy = this.nextProjectionPolicyForError( + error, + policy, + request, + signal, + captureMediaStripPolicy, + ); + if (nextPolicy === undefined) throw error; + policy = nextPolicy; } } } + private nextProjectionPolicyForError( + error: unknown, + policy: ProjectionPolicy | undefined, + request: ResolvedLLMRequest, + signal: AbortSignal | undefined, + captureMediaStripPolicy: () => { readonly strip: MediaStripSnapshot }, + ): ProjectionPolicy | undefined { + if (signal?.aborted === true) return undefined; + const raw = unwrapErrorCause(error); + const media = policy?.media; + if ( + raw instanceof APIRequestTooLargeError && + (media === undefined || media === 'degraded') + ) { + signal?.throwIfAborted(); + if (media === undefined) { + this.log.warn('provider rejected request as too large; resending with degraded media', { + model: request.model.name, + ...request.logFields, + }); + this.markRecoveryTurn(this.mediaDegradedTurns, request.source); + return { ...policy, media: 'degraded' }; + } + this.log.warn( + 'provider rejected degraded-media request as too large; resending with rejected media stripped', + { + model: request.model.name, + ...request.logFields, + }, + ); + return { ...policy, media: captureMediaStripPolicy() }; + } + if (typeof media !== 'object' && isImageFormatError(raw)) { + signal?.throwIfAborted(); + this.log.warn( + 'provider rejected an image in the request; resending with rejected media stripped', + { + model: request.model.name, + ...request.logFields, + }, + ); + return { ...policy, media: captureMediaStripPolicy() }; + } + if (policy?.structure === undefined && isRecoverableRequestStructureError(raw)) { + signal?.throwIfAborted(); + this.log.warn('provider rejected request structure; resending with strict projection', { + model: request.model.name, + ...request.logFields, + }); + return { ...policy, structure: 'strict' }; + } + return undefined; + } + private normalizeStreamPart( toolCallIds: ToolCallIdResponseNormalizer, part: StreamedMessagePart, @@ -833,13 +829,32 @@ function numberField(fields: AgentLLMRequestLogFields, key: string): number | un return typeof value === 'number' ? value : undefined; } -function projectionField( - fields: AgentLLMRequestLogFields, -): 'strict' | 'media-degraded' | 'media-stripped' | undefined { +type LlmRequestProjection = NonNullable; + +function projectionNameOf(policy: ProjectionPolicy | undefined): LlmRequestProjection | undefined { + if (policy?.structure === 'strict') { + if (policy.media === 'degraded') return 'strict-media-degraded'; + if (typeof policy.media === 'object') return 'strict-media-stripped'; + return 'strict'; + } + if (policy === undefined) return undefined; + if (policy.media === 'degraded') return 'media-degraded'; + if (typeof policy.media === 'object') return 'media-stripped'; + return undefined; +} + +function projectionField(fields: AgentLLMRequestLogFields): LlmRequestProjection | undefined { const value = fields['projection']; - return value === 'strict' || value === 'media-degraded' || value === 'media-stripped' - ? value - : undefined; + switch (value) { + case 'strict': + case 'media-degraded': + case 'media-stripped': + case 'strict-media-degraded': + case 'strict-media-stripped': + return value; + default: + return undefined; + } } function fingerprint(content: string): string { diff --git a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts index 7165aebba..637dcb559 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -844,6 +844,32 @@ describe('Agent context', () => { expect(withOverhead.messages).toEqual(withoutOverhead.messages); }); }); + + describe('legacy compaction layout', () => { + it('keeps the verbatim summary followed by the uncompacted tail', () => { + const history = [userMessage('old'), userMessage('tail')]; + const legacySummary: ContextMessage = { + role: 'assistant', + content: [{ type: 'text', text: 'legacy summary' }], + toolCalls: [], + origin: { kind: 'compaction_summary' }, + }; + const input = { + summary: 'legacy summary', + legacySummaryMessage: legacySummary, + compactedCount: 1, + tokensBefore: 100, + tokensAfter: 20, + legacyTail: true, + }; + + const shape = buildContextCompactionShape(history, input); + + expect(shape.messages[0]).toBe(legacySummary); + expect(shape.messages[1]).toBe(history[1]); + expect(shape.messages.map(textOf)).toEqual(['legacy summary', 'tail']); + }); + }); }); function userMessage(text: string, origin?: ContextMessage['origin']): ContextMessage { diff --git a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts index ad501f1c0..d23423a5b 100644 --- a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts +++ b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts @@ -137,7 +137,7 @@ describe('projector tool-exchange normalization', () => { } function projectStrict(history: readonly ContextMessage[]): readonly Message[] { - return projector.projectStrict(history); + return projector.project(history, { structure: 'strict' }); } it('leaves a fully resolved exchange untouched', () => { @@ -656,7 +656,7 @@ describe('projector tool-exchange normalization', () => { }); }); - describe('projectMediaDegraded', () => { + describe('project with media: degraded policy', () => { function imageMessage(url: string): ContextMessage { return { role: 'user', @@ -667,13 +667,16 @@ describe('projector tool-exchange normalization', () => { } it('keeps the two most recent media parts and replaces older ones with markers', () => { - const projected = projector.projectMediaDegraded([ - imageMessage('data:image/png;base64,OLD1'), - user('middle'), - imageMessage('data:image/png;base64,OLD2'), - imageMessage('data:image/png;base64,KEEP1'), - imageMessage('data:image/png;base64,KEEP2'), - ]); + const projected = projector.project( + [ + imageMessage('data:image/png;base64,OLD1'), + user('middle'), + imageMessage('data:image/png;base64,OLD2'), + imageMessage('data:image/png;base64,KEEP1'), + imageMessage('data:image/png;base64,KEEP2'), + ], + { media: 'degraded' }, + ); const urls = projected .flatMap((message) => message.content) @@ -690,16 +693,16 @@ describe('projector tool-exchange normalization', () => { }); it('returns the projected messages untouched when media fits within keep-recent', () => { - const projected = projector.projectMediaDegraded([ - user('text'), - imageMessage('data:image/png;base64,AAAA'), - ]); + const projected = projector.project( + [user('text'), imageMessage('data:image/png;base64,AAAA')], + { media: 'degraded' }, + ); const allParts = projected.flatMap((message) => message.content); expect(allParts.some((part) => part.type === 'image_url')).toBe(true); }); }); - describe('projectMediaStripped', () => { + describe('project with media: stripped policy', () => { function imageMessage(url: string, id?: string): ContextMessage { return { role: 'user', @@ -709,8 +712,15 @@ describe('projector tool-exchange normalization', () => { }; } + function projectStripped( + history: readonly ContextMessage[], + snapshot = projector.captureMediaStripSnapshot(history), + ): readonly Message[] { + return projector.project(history, { media: { strip: snapshot } }); + } + it('replaces every media part with a text marker, keeping the surrounding text', () => { - const projected = projector.projectMediaStripped([ + const projected = projectStripped([ user('look at these'), imageMessage('data:image/png;base64,AAAA'), { @@ -742,7 +752,7 @@ describe('projector tool-exchange normalization', () => { }); it('returns the projected messages untouched when there is no media', () => { - const projected = projector.projectMediaStripped([user('just text')]); + const projected = projectStripped([user('just text')]); expect(projected).toEqual(project([user('just text')])); }); @@ -750,7 +760,7 @@ describe('projector tool-exchange normalization', () => { const rejected = imageMessage('data:image/png;base64,OLD', 'old-id'); const snapshot = projector.captureMediaStripSnapshot([rejected]); - const projected = projector.projectMediaStripped( + const projected = projectStripped( [rejected, imageMessage('data:image/png;base64,NEW', 'new-id')], snapshot, ); @@ -776,7 +786,7 @@ describe('projector tool-exchange normalization', () => { orphan, ]); - const projected = projector.projectMediaStripped( + const projected = projectStripped( [imageMessage(url, 'orphan-id')], snapshot, ); @@ -793,7 +803,7 @@ describe('projector tool-exchange normalization', () => { imageMessage('data:image/png;base64,SAME', 'same-id'), ]); - const projected = projector.projectMediaStripped( + const projected = projectStripped( [imageMessage('data:image/png;base64,SAME', 'same-id')], snapshot, ); @@ -809,7 +819,7 @@ describe('projector tool-exchange normalization', () => { const url = 'https://example.test/media/image.png'; const snapshot = projector.captureMediaStripSnapshot([imageMessage(url, 'old-id')]); - const projected = projector.projectMediaStripped( + const projected = projectStripped( [imageMessage(url, 'new-id')], snapshot, ); diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index a090c4f2b..14ec5e262 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -1,14 +1,13 @@ /** - * Scenario: LLM requester uses bounded recovery projections after a - * deterministic provider rejection — strict projection for tool-use - * adjacency, degraded media followed by full stripping for body-size 413s, - * and media stripping for image-format rejections. + * Scenario: LLM requests encounter deterministic provider rejections that + * require strict tool adjacency, degraded or stripped media, and accumulated + * recovery policies across retries. * - * Responsibilities: assert retry eligibility, projection order and bounds, - * per-turn recovery stickiness, request recording, and usage accounting. - * Wiring: real AgentLLMRequesterService with stubbed context memory, - * projector, context sizing, profile, model, telemetry, and wire/log services. Run: - * pnpm test -- test/agent/llmRequester/llmRequesterService.test.ts + * Responsibilities: assert retry eligibility and bounds, projection order, + * per-turn recovery state, request recording, normalization, and accounting. + * Wiring: real AgentLLMRequesterService with controlled model and stubbed + * context, projector, profile, tool, telemetry, state, wire, and log services. + * Run: pnpm test -- test/agent/llmRequester/llmRequesterService.test.ts */ import { createControlledPromise } from '@antfu/utils'; @@ -22,6 +21,7 @@ import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentContextProjectorService, type MediaStripSnapshot, + type ProjectionPolicy, } from '#/agent/contextProjector/contextProjector'; import { AgentContextProjectorService } from '#/agent/contextProjector/contextProjectorService'; import { AgentLLMRequesterService } from '#/agent/llmRequester/llmRequesterService'; @@ -85,6 +85,31 @@ const history: Message[] = [ { role: 'user', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, ]; +type ProjectionKind = 'normal' | 'strict' | 'degraded' | 'stripped'; + +function classifyProjectionPolicy(policy: ProjectionPolicy | undefined): ProjectionKind { + if (typeof policy?.media === 'object') return 'stripped'; + if (policy?.media === 'degraded') return 'degraded'; + if (policy?.structure === 'strict') return 'strict'; + return 'normal'; +} + +function recordProjectionCalls(): { + projector: Pick; + calls: ProjectionKind[]; +} { + const calls: ProjectionKind[] = []; + return { + projector: { + project: (messages: readonly ContextMessage[], policy) => { + calls.push(classifyProjectionPolicy(policy)); + return messages; + }, + }, + calls, + }; +} + function createRequester( calls: { value: number }, firstCallError?: Error | null, @@ -139,15 +164,8 @@ afterEach(() => disposables.dispose()); function createService( requester: ModelRequester, projector: - | (Pick & - Partial< - Pick< - IAgentContextProjectorService, - | 'captureMediaStripSnapshot' - | 'projectMediaDegraded' - | 'projectMediaStripped' - > - >) + | (Pick & + Partial>) | undefined, options: { readonly thinkingLevel?: ThinkingEffort; @@ -185,7 +203,9 @@ function createService( }, }; const usage = { record: () => undefined, status: () => ({}) }; - const context = { get: () => options.contextMessages ?? history }; + const context = { + get: () => options.contextMessages ?? history, + }; const tools = { list: () => [] }; const config: Partial = { get: (() => undefined) as IConfigService['get'], @@ -217,8 +237,6 @@ function createService( } else { ix.stub(IAgentContextProjectorService, { captureMediaStripSnapshot: () => testSnapshot, - projectMediaDegraded: projector.project, - projectMediaStripped: projector.project, ...projector, }); } @@ -311,26 +329,15 @@ describe('AgentLLMRequesterService Anthropic effort diagnostics', () => { describe('AgentLLMRequesterService strict resend', () => { it('resends once with strict projection after a recoverable structural 400', async () => { const calls = { value: 0 }; - let projectCalls = 0; - let strictCalls = 0; - const { service } = createService(createRequester(calls), { - project: (messages: readonly ContextMessage[]) => { - projectCalls += 1; - return messages; - }, - projectStrict: (messages: readonly ContextMessage[]) => { - strictCalls += 1; - return messages; - }, - }); + const projection = recordProjectionCalls(); + const { service } = createService(createRequester(calls), projection.projector); const result = await service.request(); expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]); expect(result.usage).toEqual(emptyUsage()); expect(calls.value).toBe(2); - expect(projectCalls).toBe(1); - expect(strictCalls).toBe(1); + expect(projection.calls).toEqual(['normal', 'strict']); }); it('does not resend for non-recoverable errors', async () => { @@ -342,19 +349,13 @@ describe('AgentLLMRequesterService strict resend', () => { throw new APIStatusError(401, 'unauthorized'); }, }); - let strictCalls = 0; - const { service } = createService(requester, { - project: (messages: readonly ContextMessage[]) => messages, - projectStrict: (messages: readonly ContextMessage[]) => { - strictCalls += 1; - return messages; - }, - }); + const projection = recordProjectionCalls(); + const { service } = createService(requester, projection.projector); await expect(service.request()).rejects.toMatchObject({ statusCode: 401, }); - expect(strictCalls).toBe(0); + expect(projection.calls).toEqual(['normal']); }); }); @@ -366,78 +367,41 @@ describe('AgentLLMRequesterService media-stripped resend', () => { it('resends once with the media-stripped projection after an image-format 400', async () => { const calls = { value: 0 }; - let projectCalls = 0; - let strictCalls = 0; - let strippedCalls = 0; - const { service } = createService(createRequester(calls, IMAGE_FORMAT_400), { - project: (messages: readonly ContextMessage[]) => { - projectCalls += 1; - return messages; - }, - projectStrict: (messages: readonly ContextMessage[]) => { - strictCalls += 1; - return messages; - }, - projectMediaStripped: (messages: readonly ContextMessage[]) => { - strippedCalls += 1; - return messages; - }, - }); + const projection = recordProjectionCalls(); + const { service } = createService(createRequester(calls, IMAGE_FORMAT_400), projection.projector); const result = await service.request(); expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]); expect(calls.value).toBe(2); - expect(projectCalls).toBe(1); - expect(strictCalls).toBe(0); - expect(strippedCalls).toBe(1); + expect(projection.calls).toEqual(['normal', 'stripped']); }); it('keeps later steps of the same turn on the stripped projection', async () => { const calls = { value: 0 }; - let projectCalls = 0; - let strippedCalls = 0; - const { service } = createService(createRequester(calls, IMAGE_FORMAT_400), { - project: (messages: readonly ContextMessage[]) => { - projectCalls += 1; - return messages; - }, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaStripped: (messages: readonly ContextMessage[]) => { - strippedCalls += 1; - return messages; - }, - }); + const projection = recordProjectionCalls(); + const { service } = createService(createRequester(calls, IMAGE_FORMAT_400), projection.projector); await service.request({ source: { type: 'turn', turnId: 1, step: 1 } }); expect(calls.value).toBe(2); - expect(projectCalls).toBe(1); - expect(strippedCalls).toBe(1); + expect(projection.calls).toEqual(['normal', 'stripped']); await service.request({ source: { type: 'turn', turnId: 1, step: 2 } }); expect(calls.value).toBe(3); - expect(projectCalls).toBe(1); - expect(strippedCalls).toBe(2); + expect(projection.calls).toEqual(['normal', 'stripped', 'stripped']); }); it('does not resend for an unrelated 400', async () => { const calls = { value: 0 }; - let strippedCalls = 0; + const projection = recordProjectionCalls(); const { service } = createService( createRequester(calls, new APIStatusError(400, 'some other validation problem')), - { - project: (messages: readonly ContextMessage[]) => messages, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaStripped: (messages: readonly ContextMessage[]) => { - strippedCalls += 1; - return messages; - }, - }, + projection.projector, ); await expect(service.request()).rejects.toMatchObject({ statusCode: 400 }); expect(calls.value).toBe(1); - expect(strippedCalls).toBe(0); + expect(projection.calls).toEqual(['normal']); }); }); @@ -446,9 +410,7 @@ describe('AgentLLMRequesterService media-degraded resend', () => { it('resends once with the media-degraded projection after an HTTP 413', async () => { const calls = { value: 0 }; - let projectCalls = 0; - let degradedCalls = 0; - let strippedCalls = 0; + const projection = recordProjectionCalls(); const { service } = createService( createRequester( calls, @@ -456,63 +418,29 @@ describe('AgentLLMRequesterService media-degraded resend', () => { cause: BODY_TOO_LARGE_413, }), ), - { - project: (messages: readonly ContextMessage[]) => { - projectCalls += 1; - return messages; - }, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaDegraded: (messages: readonly ContextMessage[]) => { - degradedCalls += 1; - return messages; - }, - projectMediaStripped: (messages: readonly ContextMessage[]) => { - strippedCalls += 1; - return messages; - }, - }, + projection.projector, ); const result = await service.request(); expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]); expect(calls.value).toBe(2); - expect(projectCalls).toBe(1); - expect(degradedCalls).toBe(1); - expect(strippedCalls).toBe(0); + expect(projection.calls).toEqual(['normal', 'degraded']); }); it('falls back to media-stripped when the media-degraded request still receives 413', async () => { const calls = { value: 0 }; - let projectCalls = 0; - let degradedCalls = 0; - let strippedCalls = 0; + const projection = recordProjectionCalls(); const { service } = createService( createRequester(calls, BODY_TOO_LARGE_413, [BODY_TOO_LARGE_413]), - { - project: (messages: readonly ContextMessage[]) => { - projectCalls += 1; - return messages; - }, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaDegraded: (messages: readonly ContextMessage[]) => { - degradedCalls += 1; - return messages; - }, - projectMediaStripped: (messages: readonly ContextMessage[]) => { - strippedCalls += 1; - return messages; - }, - }, + projection.projector, ); const result = await service.request({ source: { type: 'turn', turnId: 1, step: 1 } }); expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]); expect(calls.value).toBe(3); - expect(projectCalls).toBe(1); - expect(degradedCalls).toBe(1); - expect(strippedCalls).toBe(1); + expect(projection.calls).toEqual(['normal', 'degraded', 'stripped']); }); it('records repeated-413 recovery projections on the sticky later request', async () => { @@ -521,9 +449,6 @@ describe('AgentLLMRequesterService media-degraded resend', () => { createRequester(calls, BODY_TOO_LARGE_413, [BODY_TOO_LARGE_413]), { project: (messages: readonly ContextMessage[]) => messages, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaDegraded: (messages: readonly ContextMessage[]) => messages, - projectMediaStripped: (messages: readonly ContextMessage[]) => messages, }, ); @@ -580,62 +505,31 @@ describe('AgentLLMRequesterService media-degraded resend', () => { it('stops after the media-stripped request also receives 413', async () => { const calls = { value: 0 }; - let projectCalls = 0; - let degradedCalls = 0; - let strippedCalls = 0; + const projection = recordProjectionCalls(); const { service } = createService( createRequester(calls, BODY_TOO_LARGE_413, [BODY_TOO_LARGE_413, BODY_TOO_LARGE_413]), - { - project: (messages: readonly ContextMessage[]) => { - projectCalls += 1; - return messages; - }, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaDegraded: (messages: readonly ContextMessage[]) => { - degradedCalls += 1; - return messages; - }, - projectMediaStripped: (messages: readonly ContextMessage[]) => { - strippedCalls += 1; - return messages; - }, - }, + projection.projector, ); await expect( service.request({ source: { type: 'turn', turnId: 1, step: 1 } }), ).rejects.toBe(BODY_TOO_LARGE_413); expect(calls.value).toBe(3); - expect(projectCalls).toBe(1); - expect(degradedCalls).toBe(1); - expect(strippedCalls).toBe(1); + expect(projection.calls).toEqual(['normal', 'degraded', 'stripped']); }); it('keeps later steps of the same turn on the degraded projection', async () => { const calls = { value: 0 }; - let projectCalls = 0; - let degradedCalls = 0; - const { service } = createService(createRequester(calls, BODY_TOO_LARGE_413), { - project: (messages: readonly ContextMessage[]) => { - projectCalls += 1; - return messages; - }, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaDegraded: (messages: readonly ContextMessage[]) => { - degradedCalls += 1; - return messages; - }, - }); + const projection = recordProjectionCalls(); + const { service } = createService(createRequester(calls, BODY_TOO_LARGE_413), projection.projector); await service.request({ source: { type: 'turn', turnId: 1, step: 1 } }); expect(calls.value).toBe(2); - expect(projectCalls).toBe(1); - expect(degradedCalls).toBe(1); + expect(projection.calls).toEqual(['normal', 'degraded']); await service.request({ source: { type: 'turn', turnId: 1, step: 2 } }); expect(calls.value).toBe(3); - expect(projectCalls).toBe(1); - expect(degradedCalls).toBe(2); + expect(projection.calls).toEqual(['normal', 'degraded', 'degraded']); }); it('does not resend for a plain 400 or a non-413 status', async () => { @@ -644,27 +538,95 @@ describe('AgentLLMRequesterService media-degraded resend', () => { new APIStatusError(422, 'unprocessable'), ]) { const calls = { value: 0 }; - let degradedCalls = 0; - const { service } = createService(createRequester(calls, error), { - project: (messages: readonly ContextMessage[]) => messages, - projectStrict: (messages: readonly ContextMessage[]) => messages, - projectMediaDegraded: (messages: readonly ContextMessage[]) => { - degradedCalls += 1; - return messages; - }, - }); + const projection = recordProjectionCalls(); + const { service } = createService(createRequester(calls, error), projection.projector); await expect(service.request()).rejects.toBe(error); expect(calls.value).toBe(1); - expect(degradedCalls).toBe(0); + expect(projection.calls).toEqual(['normal']); } }); }); +describe('AgentLLMRequesterService combined recovery projections', () => { + const BODY_TOO_LARGE_413 = new APIRequestTooLargeError(413, 'Request Entity Too Large'); + const IMAGE_FORMAT_400 = new APIStatusError( + 400, + 'unsupported image format: image/avif is not supported', + ); + const STRUCTURAL_400 = new APIStatusError(400, 'messages: `tool_use` ids must be unique'); + + function createPolicyRecordingProjector(policies: { + policies: (ProjectionPolicy | undefined)[]; + }): Pick { + return { + project: (messages: readonly ContextMessage[], policy) => { + policies.policies.push(policy); + return messages; + }, + }; + } + + it('accumulates media repairs on top of strict across repeated rejections', async () => { + const calls = { value: 0 }; + const policies: (ProjectionPolicy | undefined)[] = []; + const { service, dispatcher, records } = createService( + createRequester(calls, STRUCTURAL_400, [BODY_TOO_LARGE_413, BODY_TOO_LARGE_413]), + createPolicyRecordingProjector({ policies }), + ); + + await service.request({ source: { type: 'turn', turnId: 1, step: 1 } }); + + expect(calls.value).toBe(4); + expect(policies).toEqual([ + undefined, + { structure: 'strict' }, + { structure: 'strict', media: 'degraded' }, + { structure: 'strict', media: { strip: expect.anything() } }, + ]); + await dispatcher.flush(); + expect( + records.filter((record) => record.type === 'llm.request').map((record) => record['projection']), + ).toEqual([undefined, 'strict', 'strict-media-degraded', 'strict-media-stripped']); + }); + + it('strips rejected images on top of strict after an image-format rejection on the strict resend', async () => { + const calls = { value: 0 }; + const policies: (ProjectionPolicy | undefined)[] = []; + const { service } = createService( + createRequester(calls, STRUCTURAL_400, [IMAGE_FORMAT_400]), + createPolicyRecordingProjector({ policies }), + ); + + await service.request(); + + expect(calls.value).toBe(3); + expect(policies.map((policy) => policy?.structure)).toEqual([undefined, 'strict', 'strict']); + expect(typeof policies[2]?.media).toBe('object'); + }); + + it('applies the strict repair on top of degraded media when a structural 400 follows a 413', async () => { + const calls = { value: 0 }; + const policies: (ProjectionPolicy | undefined)[] = []; + const { service } = createService( + createRequester(calls, BODY_TOO_LARGE_413, [STRUCTURAL_400]), + createPolicyRecordingProjector({ policies }), + ); + + await service.request(); + + expect(calls.value).toBe(3); + expect(policies).toEqual([ + undefined, + { media: 'degraded' }, + { structure: 'strict', media: 'degraded' }, + ]); + }); +}); + describe('AgentLLMRequesterService trace id', () => { const passthroughProjector = { project: (messages: readonly ContextMessage[]) => messages, - projectStrict: (messages: readonly ContextMessage[]) => messages, }; function createTracedRequester(traceId: string | null): ModelRequester {