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 {