refactor(agent-core-v2): rebuild context projection as a staged block pipeline (#3001)
Some checks are pending
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-vscode-legacy (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* 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

* refactor(agent-core-v2): rebuild context projection as a staged block pipeline

Split the 650-line projector service into three modules by concern:
mediaProjection (read-side media degrade/strip fallbacks), projection
(the structural transform), and the service (DI binding plus repair
reporting). Rebuild the structural projection as a two-stage pipeline:
pairBlocks groups tool exchanges into blocks that own their calls'
results, flattenBlocks serializes them back to wire order and merges
consecutive user prompts. The shared slot sentinel and index
back-patching are gone; the trailing-close and sizing-slice rules are
named and documented in the module header. Behavior is pinned unchanged
by the existing projector and llmRequester suites.

* docs(agent-core-v2): trim the projection helper header to its external role

* docs(agent-core-v2): keep the contextProjector module headers at the external-role level
This commit is contained in:
7Sageer 2026-08-17 20:34:39 +08:00 committed by GitHub
parent 09976b0914
commit 5dffed2545
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 621 additions and 521 deletions

View file

@ -1,46 +1,43 @@
/**
* `contextProjector` domain projects stored context history into the wire
* messages sent to the model, and surfaces every repair it had to apply.
* `contextProjector` domain `IAgentContextProjectorService` implementation.
*
* `AgentContextProjectorService` is the Agent-scope binding. The projection
* itself stays a pure transform over the history; repairs that keep the
* outgoing wire valid (a displaced result moved back to its call, a synthetic
* result invented for a lost one, an orphan/duplicate dropped, leading
* non-user messages dropped, consecutive assistants merged, blank text
* dropped, wholly-vacuous messages nothing sendable was recorded, e.g. an
* assistant step that kept only an empty thinking part dropped whole) are
* reported through an optional sink and surfaced once here as a
* single deduped warning plus a `context_projection_repaired` telemetry event,
* so a silently-mangled history always leaves a trace. The mutable
* repair-dedup signature (`lastRepairSignature`) is registered into
* `agentState` (`IAgentStateService`) and read/written through it.
*
* `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.
* Projects stored context history into the wire messages sent to the model,
* applies the read-side media fallbacks selected by `policy.media`, and
* surfaces every repair the projection had to apply: the repairs are
* summarized once per distinct signature into a single deduped warning
* (through `log`) plus a `context_projection_repaired` telemetry event
* (through `telemetry`), so a silently-mangled history always leaves a
* trace. The mutable repair-dedup signature (`lastRepairSignature`) is
* registered into `agentState` (`IAgentStateService`) and read/written
* through it. Bound at Agent scope.
*/
import { createHash } from 'node:crypto';
import { LifecycleScope } from '#/app/scopes';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { ILogService } from '#/_base/log/log';
import { defineState } from '#/state/state';
import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent';
import { IAgentStateService } from '#/agent/state/agentState';
import { ErrorCodes, Error2 } from '#/errors';
import type { ContentPart, Message } from '#/kosong/contract/message';
import type { Message } from '#/kosong/contract/message';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import {
IAgentContextProjectorService,
type MediaStripSnapshot,
type ProjectionPolicy,
} from './contextProjector';
import {
MEDIA_DEGRADE_KEEP_RECENT,
captureMediaStripSnapshot,
degradeOlderMediaParts,
stripMediaPartsBySnapshot,
} from './mediaProjection';
import {
project,
projectStrict,
summarizeProjectionRepairs,
type OnAnomaly,
type ProjectionAnomaly,
} from './projection';
export const contextProjectorLastRepairSignatureKey = defineState<string | null>(
'contextProjector.lastRepairSignature',
@ -86,7 +83,7 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi
private projectWithTrace(
messages: readonly ContextMessage[],
fn: (history: readonly ContextMessage[], onAnomaly?: (anomaly: ProjectionAnomaly) => void) => Message[],
fn: (history: readonly ContextMessage[], onAnomaly?: OnAnomaly) => Message[],
): readonly Message[] {
const anomalies: ProjectionAnomaly[] = [];
const result = fn(messages, (anomaly) => anomalies.push(anomaly));
@ -151,499 +148,6 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi
}
}
type ProjectionAnomaly =
| { readonly kind: 'tool_result_reordered'; readonly toolCallId: string }
| { readonly kind: 'tool_result_synthesized'; readonly toolCallId: string; readonly trailing: boolean }
| { readonly kind: 'orphan_tool_result_dropped'; readonly toolCallId: string }
| { readonly kind: 'duplicate_tool_call_dropped'; readonly toolCallId: string }
| { readonly kind: 'duplicate_tool_result_dropped'; readonly toolCallId: string }
| { readonly kind: 'leading_non_user_dropped'; readonly role: string }
| { readonly kind: 'consecutive_assistants_merged' }
| { 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;
const MEDIA_DEGRADED_PLACEHOLDERS = {
image_url:
'[image omitted: dropped to fit the provider request size limit; re-read the file to view it]',
audio_url:
'[audio omitted: dropped to fit the provider request size limit; re-read the file to hear it]',
video_url:
'[video omitted: dropped to fit the provider request size limit; re-read the file to view it]',
} as const;
export const MEDIA_STRIPPED_PLACEHOLDERS = {
image_url:
'[image omitted for provider compatibility; re-read the file to view it or get conversion guidance]',
audio_url:
'[audio omitted for provider compatibility; re-read the file to hear it]',
video_url:
'[video omitted for provider compatibility; re-read the file to view it]',
} as const;
type MediaPlaceholderSet = typeof MEDIA_DEGRADED_PLACEHOLDERS | typeof MEDIA_STRIPPED_PLACEHOLDERS;
type DegradableMediaPart = Extract<
ContentPart,
{ readonly type: keyof MediaPlaceholderSet }
>;
interface MediaContainer {
readonly url: string;
readonly id?: string;
}
interface MediaStripSnapshotData {
readonly keys: ReadonlySet<string>;
}
type MediaContainerKeyCache = Partial<Record<DegradableMediaPart['type'], string>>;
const MEDIA_CONTAINER_KEY_CACHE = new WeakMap<MediaContainer, MediaContainerKeyCache>();
function isDegradableMediaPart(
part: ContentPart,
): part is DegradableMediaPart {
return part.type in MEDIA_DEGRADED_PLACEHOLDERS;
}
function mediaContainer(part: DegradableMediaPart): MediaContainer {
if (part.type === 'image_url') return part.imageUrl;
if (part.type === 'audio_url') return part.audioUrl;
return part.videoUrl;
}
function mediaStripKey(part: DegradableMediaPart): string {
const container = mediaContainer(part);
let cache = MEDIA_CONTAINER_KEY_CACHE.get(container);
const cached = cache?.[part.type];
if (cached !== undefined) return cached;
const key = createHash('sha256')
.update(part.type)
.update('\0')
.update(container.id ?? '')
.update('\0')
.update(container.url)
.digest('hex');
if (cache === undefined) {
cache = {};
MEDIA_CONTAINER_KEY_CACHE.set(container, cache);
}
cache[part.type] = key;
return key;
}
function mediaStripSnapshotKeys(snapshot: MediaStripSnapshot): ReadonlySet<string> {
return (snapshot as unknown as MediaStripSnapshotData).keys;
}
export function captureMediaStripSnapshot(
messages: readonly Message[],
): MediaStripSnapshot {
const keys = new Set<string>();
for (const message of messages) {
for (const part of message.content) {
if (isDegradableMediaPart(part)) keys.add(mediaStripKey(part));
}
}
return Object.freeze({ keys }) as unknown as MediaStripSnapshot;
}
export function stripMediaPartsBySnapshot(
messages: readonly Message[],
snapshot: MediaStripSnapshot,
): readonly Message[] {
const keys = mediaStripSnapshotKeys(snapshot);
let changed = false;
const result = messages.map((message) => {
let messageChanged = false;
const content = message.content.map((part): ContentPart => {
if (!isDegradableMediaPart(part) || !keys.has(mediaStripKey(part))) return part;
changed = true;
messageChanged = true;
return { type: 'text', text: MEDIA_STRIPPED_PLACEHOLDERS[part.type] };
});
return messageChanged ? { ...message, content } : message;
});
return changed ? result : messages;
}
export function degradeOlderMediaParts(
messages: readonly Message[],
keepRecent: number,
placeholders: MediaPlaceholderSet = MEDIA_DEGRADED_PLACEHOLDERS,
): readonly Message[] {
const mediaCount = messages.reduce(
(count, message) => count + message.content.filter(isDegradableMediaPart).length,
0,
);
let toDegrade = Math.max(0, mediaCount - keepRecent);
if (toDegrade === 0) return messages;
return messages.map((message) => {
if (toDegrade === 0 || !message.content.some(isDegradableMediaPart)) return message;
const content = message.content.map((part): ContentPart => {
if (toDegrade === 0 || !isDegradableMediaPart(part)) return part;
toDegrade -= 1;
return { type: 'text', text: placeholders[part.type] };
});
return { ...message, content };
});
}
function projectStrict(history: readonly ContextMessage[], onAnomaly?: OnAnomaly): Message[] {
const projected = project(history, onAnomaly);
return dropLeadingNonUserMessages(
mergeConsecutiveAssistantMessages(dedupeDuplicateToolCalls(projected, onAnomaly), onAnomaly),
onAnomaly,
);
}
function dedupeDuplicateToolCalls(messages: readonly Message[], onAnomaly?: OnAnomaly): Message[] {
const seenToolCallIds = new Set<string>();
const keptToolResultIndexes = new Map<string, number>();
const out: Message[] = [];
for (const message of messages) {
if (message.role === 'assistant' && message.toolCalls.length > 0) {
const kept = message.toolCalls.filter((toolCall) => {
if (seenToolCallIds.has(toolCall.id)) {
onAnomaly?.({ kind: 'duplicate_tool_call_dropped', toolCallId: toolCall.id });
return false;
}
seenToolCallIds.add(toolCall.id);
return true;
});
if (kept.length === message.toolCalls.length) {
out.push(message);
} else if (kept.length > 0 || !message.content.every(isVacuousContentPart)) {
out.push({ ...message, toolCalls: kept });
} else if (message.content.length > 0) {
onAnomaly?.({ kind: 'vacuous_message_dropped', role: message.role });
}
continue;
}
if (message.role === 'tool' && message.toolCallId !== undefined) {
const previousIndex = keptToolResultIndexes.get(message.toolCallId);
if (previousIndex !== undefined) {
if (isInterruptedToolResult(out[previousIndex]) && !isInterruptedToolResult(message)) {
out[previousIndex] = message;
} else {
onAnomaly?.({ kind: 'duplicate_tool_result_dropped', toolCallId: message.toolCallId });
}
continue;
}
keptToolResultIndexes.set(message.toolCallId, out.length);
}
out.push(message);
}
return out;
}
function mergeConsecutiveAssistantMessages(
messages: readonly Message[],
onAnomaly?: OnAnomaly,
): Message[] {
const out: Message[] = [];
for (const message of messages) {
const previous = out.at(-1);
if (previous !== undefined && previous.role === 'assistant' && message.role === 'assistant') {
out[out.length - 1] = {
...previous,
content: [...previous.content, ...message.content],
toolCalls: [...previous.toolCalls, ...message.toolCalls],
};
onAnomaly?.({ kind: 'consecutive_assistants_merged' });
continue;
}
out.push(message);
}
return out;
}
function dropLeadingNonUserMessages(messages: readonly Message[], onAnomaly?: OnAnomaly): Message[] {
let start = 0;
while (start < messages.length && messages[start]?.role !== 'user') {
onAnomaly?.({ kind: 'leading_non_user_dropped', role: messages[start]!.role });
start += 1;
}
return start === 0 ? [...messages] : messages.slice(start);
}
function project(history: readonly ContextMessage[], onAnomaly?: OnAnomaly): Message[] {
const hasAssistant = history.some(
(message) => message.partial !== true && message.role === 'assistant',
);
let lastNonToolIndex = history.length - 1;
while (
lastNonToolIndex >= 0 &&
(history[lastNonToolIndex]?.role === 'tool' || history[lastNonToolIndex]?.partial === true)
) {
lastNonToolIndex -= 1;
}
const out: Message[] = [];
const openSlots = new Map<string, OpenSlot>();
let merge: MergeGroup | undefined;
const flushMerge = (): void => {
if (merge === undefined) return;
if (merge.singleContent === undefined) {
const text = merge.texts.join('\n\n');
const content: ContentPart[] = text === '' ? [] : [{ type: 'text', text }];
content.push(...merge.parts);
out[merge.index] = {
role: 'user',
name: undefined,
content,
toolCalls: [],
toolCallId: undefined,
partial: undefined,
};
}
merge = undefined;
};
const markForeignBetween = (): void => {
for (const slot of openSlots.values()) slot.foreignBetween = true;
};
const emit = (source: ContextMessage): void => {
const content = projectedContent(source, onAnomaly);
if (source.toolCalls.length === 0 && !hasDeclaredTools(source)) {
if (content.length === 0) return;
if (content.every(isVacuousContentPart)) {
onAnomaly?.({ kind: 'vacuous_message_dropped', role: source.role });
return;
}
}
if (openSlots.size > 0) markForeignBetween();
if (canMergeUserMessage(source)) {
if (merge === undefined) {
out.push(toWireMessage(source, content));
merge = { index: out.length - 1, singleContent: content, texts: [], parts: [] };
} else {
if (merge.singleContent !== undefined) {
appendMergeContent(merge, merge.singleContent);
merge.singleContent = undefined;
}
appendMergeContent(merge, content);
}
return;
}
flushMerge();
out.push(toWireMessage(source, content));
};
for (const [index, message] of history.entries()) {
if (message.partial === true) continue;
if (message.role === 'tool') {
if (!hasAssistant) {
emit(message);
continue;
}
if (message.toolCallId === undefined) continue;
const slot = openSlots.get(message.toolCallId);
if (slot === undefined) {
if (openSlots.size > 0) markForeignBetween();
onAnomaly?.({ kind: 'orphan_tool_result_dropped', toolCallId: message.toolCallId });
continue;
}
openSlots.delete(message.toolCallId);
if (slot.foreignBetween) {
onAnomaly?.({ kind: 'tool_result_reordered', toolCallId: message.toolCallId });
}
out[slot.index] = toWireMessage(message, projectedContent(message, onAnomaly));
continue;
}
emit(message);
for (const call of message.toolCalls) {
const reopened = openSlots.get(call.id);
if (reopened !== undefined) {
out[reopened.index] = createInterruptedToolResult(call.id);
onAnomaly?.({
kind: 'tool_result_synthesized',
toolCallId: call.id,
trailing: reopened.ownerIndex >= lastNonToolIndex,
});
}
openSlots.set(call.id, { index: out.length, ownerIndex: index, foreignBetween: false });
out.push(TOOL_RESULT_SLOT);
}
}
for (const [id, slot] of openSlots) {
out[slot.index] = createInterruptedToolResult(id);
onAnomaly?.({
kind: 'tool_result_synthesized',
toolCallId: id,
trailing: slot.ownerIndex >= lastNonToolIndex,
});
}
flushMerge();
return out;
}
interface OpenSlot {
index: number;
ownerIndex: number;
foreignBetween: boolean;
}
interface MergeGroup {
index: number;
singleContent: readonly ContentPart[] | undefined;
texts: string[];
parts: ContentPart[];
}
function appendMergeContent(group: MergeGroup, content: readonly ContentPart[]): void {
let text = '';
for (const part of content) {
if (part.type === 'text') text += part.text;
else group.parts.push(part);
}
if (text.length > 0) group.texts.push(text);
}
function projectedContent(source: ContextMessage, onAnomaly?: OnAnomaly): ContentPart[] {
const content =
source.role === 'tool'
? renderToolResultForModel({
output: outputFromToolContent(source.content),
isError: source.isError,
note: source.note,
})
: source.content;
return cleanContent(source, content, onAnomaly);
}
function cleanContent(
source: ContextMessage,
rawContent: readonly ContentPart[],
onAnomaly?: OnAnomaly,
): ContentPart[] {
const hasBlank = rawContent.some(isBlankText);
let content: readonly ContentPart[] = rawContent;
if (hasBlank) {
const filtered: ContentPart[] = [];
for (const part of rawContent) {
if (isBlankText(part)) {
if (part.type === 'text' && part.text.length > 0) {
onAnomaly?.({ kind: 'whitespace_text_dropped', role: source.role });
}
} else {
filtered.push(part);
}
}
content = filtered;
}
if (source.role === 'tool' && content.length === 0) {
throw new Error2(
ErrorCodes.REQUEST_INVALID,
'Tool result message content cannot be empty after removing empty text blocks.',
{ details: { toolCallId: source.toolCallId } },
);
}
return [...content];
}
function outputFromToolContent(content: readonly ContentPart[]): string | readonly ContentPart[] {
const only = content[0];
return content.length === 1 && only?.type === 'text' ? only.text : content;
}
const TOOL_INTERRUPTED_TEXT =
'Tool result is not available in the current context. Do not assume the tool completed successfully.';
const TOOL_RESULT_SLOT: Message = createInterruptedToolResult('');
function createInterruptedToolResult(toolCallId: string): Message {
return {
role: 'tool',
name: undefined,
content: [{ type: 'text', text: TOOL_INTERRUPTED_TEXT }],
toolCalls: [],
toolCallId,
partial: undefined,
};
}
function isInterruptedToolResult(message: Message | undefined): boolean {
if (message?.role !== 'tool') return false;
const [part] = message.content;
return part?.type === 'text' && part.text === TOOL_INTERRUPTED_TEXT;
}
function isBlankText(part: ContentPart): boolean {
return part.type === 'text' && part.text.trim().length === 0;
}
function canMergeUserMessage(message: ContextMessage): boolean {
return message.role === 'user' && message.origin?.kind === 'user';
}
function hasDeclaredTools(message: ContextMessage): boolean {
return message.tools !== undefined && message.tools.length > 0;
}
function toWireMessage(message: ContextMessage, content: ContentPart[]): Message {
return {
role: message.role,
name: message.name,
content,
toolCalls: message.toolCalls,
toolCallId: message.toolCallId,
partial: message.partial,
tools: message.tools,
};
}
registerScopedService(
LifecycleScope.Agent,
IAgentContextProjectorService,

View file

@ -0,0 +1,148 @@
/**
* `contextProjector` domain read-side media fallbacks for the two
* deterministic provider rejections.
*
* The degraded projection replaces all but the most recent media parts with
* text markers after an HTTP 413 body-size rejection; the strip projection
* replaces exactly the snapshotted media identities after a rejected-format
* or still-too-large resend, so a newly generated recovery image stays
* visible on later steps. Both rewrite only the projected wire messages
* the stored history keeps its media.
*/
import { createHash } from 'node:crypto';
import type { ContentPart, Message } from '#/kosong/contract/message';
import type { MediaStripSnapshot } from './contextProjector';
export const MEDIA_DEGRADE_KEEP_RECENT = 2;
const MEDIA_DEGRADED_PLACEHOLDERS = {
image_url:
'[image omitted: dropped to fit the provider request size limit; re-read the file to view it]',
audio_url:
'[audio omitted: dropped to fit the provider request size limit; re-read the file to hear it]',
video_url:
'[video omitted: dropped to fit the provider request size limit; re-read the file to view it]',
} as const;
export const MEDIA_STRIPPED_PLACEHOLDERS = {
image_url:
'[image omitted for provider compatibility; re-read the file to view it or get conversion guidance]',
audio_url:
'[audio omitted for provider compatibility; re-read the file to hear it]',
video_url:
'[video omitted for provider compatibility; re-read the file to view it]',
} as const;
type MediaPlaceholderSet = typeof MEDIA_DEGRADED_PLACEHOLDERS | typeof MEDIA_STRIPPED_PLACEHOLDERS;
type DegradableMediaPart = Extract<
ContentPart,
{ readonly type: keyof MediaPlaceholderSet }
>;
interface MediaContainer {
readonly url: string;
readonly id?: string;
}
interface MediaStripSnapshotData {
readonly keys: ReadonlySet<string>;
}
type MediaContainerKeyCache = Partial<Record<DegradableMediaPart['type'], string>>;
const MEDIA_CONTAINER_KEY_CACHE = new WeakMap<MediaContainer, MediaContainerKeyCache>();
function isDegradableMediaPart(
part: ContentPart,
): part is DegradableMediaPart {
return part.type in MEDIA_DEGRADED_PLACEHOLDERS;
}
function mediaContainer(part: DegradableMediaPart): MediaContainer {
if (part.type === 'image_url') return part.imageUrl;
if (part.type === 'audio_url') return part.audioUrl;
return part.videoUrl;
}
function mediaStripKey(part: DegradableMediaPart): string {
const container = mediaContainer(part);
let cache = MEDIA_CONTAINER_KEY_CACHE.get(container);
const cached = cache?.[part.type];
if (cached !== undefined) return cached;
const key = createHash('sha256')
.update(part.type)
.update('\0')
.update(container.id ?? '')
.update('\0')
.update(container.url)
.digest('hex');
if (cache === undefined) {
cache = {};
MEDIA_CONTAINER_KEY_CACHE.set(container, cache);
}
cache[part.type] = key;
return key;
}
function mediaStripSnapshotKeys(snapshot: MediaStripSnapshot): ReadonlySet<string> {
return (snapshot as unknown as MediaStripSnapshotData).keys;
}
export function captureMediaStripSnapshot(
messages: readonly Message[],
): MediaStripSnapshot {
const keys = new Set<string>();
for (const message of messages) {
for (const part of message.content) {
if (isDegradableMediaPart(part)) keys.add(mediaStripKey(part));
}
}
return Object.freeze({ keys }) as unknown as MediaStripSnapshot;
}
export function stripMediaPartsBySnapshot(
messages: readonly Message[],
snapshot: MediaStripSnapshot,
): readonly Message[] {
const keys = mediaStripSnapshotKeys(snapshot);
let changed = false;
const result = messages.map((message) => {
let messageChanged = false;
const content = message.content.map((part): ContentPart => {
if (!isDegradableMediaPart(part) || !keys.has(mediaStripKey(part))) return part;
changed = true;
messageChanged = true;
return { type: 'text', text: MEDIA_STRIPPED_PLACEHOLDERS[part.type] };
});
return messageChanged ? { ...message, content } : message;
});
return changed ? result : messages;
}
export function degradeOlderMediaParts(
messages: readonly Message[],
keepRecent: number,
placeholders: MediaPlaceholderSet = MEDIA_DEGRADED_PLACEHOLDERS,
): readonly Message[] {
const mediaCount = messages.reduce(
(count, message) => count + message.content.filter(isDegradableMediaPart).length,
0,
);
let toDegrade = Math.max(0, mediaCount - keepRecent);
if (toDegrade === 0) return messages;
return messages.map((message) => {
if (toDegrade === 0 || !message.content.some(isDegradableMediaPart)) return message;
const content = message.content.map((part): ContentPart => {
if (toDegrade === 0 || !isDegradableMediaPart(part)) return part;
toDegrade -= 1;
return { type: 'text', text: placeholders[part.type] };
});
return { ...message, content };
});
}

View file

@ -0,0 +1,447 @@
/**
* `contextProjector` domain rebuilds stored context history into
* provider-valid wire messages and reports every repair through an anomaly
* sink.
*
* The default projection pairs tool calls with their results (a displaced
* result returns to its call, an orphan is dropped, a call left open is
* closed with a synthetic interrupted result), renders stored tool-result
* facts for the model, drops blank text and wholly-vacuous messages, skips
* partial messages, and merges consecutive user prompts. The strict
* projection adds the repairs strict providers need: duplicate tool calls
* dropped, consecutive assistants merged, leading non-user messages dropped.
*
* A history slice without any assistant message is a sizing slice (used to
* size tool results): tool messages project like any other message instead
* of pairing into exchanges. A synthesized close counts as `trailing` an
* expected in-flight close rather than a defect exactly when no non-tool,
* non-partial message follows the owning message in the slice.
*/
import { ErrorCodes, Error2 } from '#/errors';
import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent';
import type { ContentPart, Message } from '#/kosong/contract/message';
export type ProjectionAnomaly =
| { readonly kind: 'tool_result_reordered'; readonly toolCallId: string }
| { readonly kind: 'tool_result_synthesized'; readonly toolCallId: string; readonly trailing: boolean }
| { readonly kind: 'orphan_tool_result_dropped'; readonly toolCallId: string }
| { readonly kind: 'duplicate_tool_call_dropped'; readonly toolCallId: string }
| { readonly kind: 'duplicate_tool_result_dropped'; readonly toolCallId: string }
| { readonly kind: 'leading_non_user_dropped'; readonly role: string }
| { readonly kind: 'consecutive_assistants_merged' }
| { readonly kind: 'whitespace_text_dropped'; readonly role: string }
| { readonly kind: 'vacuous_message_dropped'; readonly role: string };
export type OnAnomaly = (anomaly: ProjectionAnomaly) => void;
export 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;
}
export 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;
}
export function project(history: readonly ContextMessage[], onAnomaly?: OnAnomaly): Message[] {
const layout = sliceLayout(history);
return flattenBlocks(pairBlocks(history, layout, onAnomaly), layout, onAnomaly);
}
export function projectStrict(
history: readonly ContextMessage[],
onAnomaly?: OnAnomaly,
): Message[] {
const projected = project(history, onAnomaly);
return dropLeadingNonUserMessages(
mergeConsecutiveAssistantMessages(dedupeDuplicateToolCalls(projected, onAnomaly), onAnomaly),
onAnomaly,
);
}
interface SliceLayout {
readonly sizing: boolean;
readonly lastNonToolIndex: number;
}
function sliceLayout(history: readonly ContextMessage[]): SliceLayout {
let sizing = true;
let lastNonToolIndex = -1;
for (const [index, message] of history.entries()) {
if (message.partial === true || message.role === 'tool') continue;
lastNonToolIndex = index;
if (message.role === 'assistant') sizing = false;
}
return { sizing, lastNonToolIndex };
}
interface AttachedResult {
readonly source: ContextMessage;
readonly content: ContentPart[];
}
const INTERRUPTED_RESULT = Symbol('interruptedResult');
interface PendingCall {
readonly callId: string;
result: AttachedResult | typeof INTERRUPTED_RESULT | undefined;
foreignBetween: boolean;
}
interface Exchange {
readonly source: ContextMessage;
readonly content: ContentPart[];
readonly ownerIndex: number;
readonly pending: PendingCall[];
}
type Block =
| {
readonly kind: 'message';
readonly source: ContextMessage;
readonly content: ContentPart[];
}
| { readonly kind: 'exchange'; readonly exchange: Exchange };
function pairBlocks(
history: readonly ContextMessage[],
layout: SliceLayout,
onAnomaly?: OnAnomaly,
): Block[] {
const blocks: Block[] = [];
const openCalls = new Map<string, { exchange: Exchange; pending: PendingCall }>();
const markForeignBetween = (): void => {
for (const { pending } of openCalls.values()) pending.foreignBetween = true;
};
for (const [index, message] of history.entries()) {
if (message.partial === true) continue;
if (message.role === 'tool' && !layout.sizing) {
if (message.toolCallId === undefined) continue;
const open = openCalls.get(message.toolCallId);
if (open === undefined) {
markForeignBetween();
onAnomaly?.({ kind: 'orphan_tool_result_dropped', toolCallId: message.toolCallId });
continue;
}
openCalls.delete(message.toolCallId);
open.pending.result = { source: message, content: projectedContent(message, onAnomaly) };
if (open.pending.foreignBetween) {
onAnomaly?.({ kind: 'tool_result_reordered', toolCallId: message.toolCallId });
}
continue;
}
const content = projectedContent(message, onAnomaly);
if (message.toolCalls.length === 0 && !hasDeclaredTools(message)) {
if (content.length === 0) continue;
if (content.every(isVacuousContentPart)) {
onAnomaly?.({ kind: 'vacuous_message_dropped', role: message.role });
continue;
}
}
markForeignBetween();
if (message.toolCalls.length === 0) {
blocks.push({ kind: 'message', source: message, content });
continue;
}
const exchange: Exchange = { source: message, content, ownerIndex: index, pending: [] };
blocks.push({ kind: 'exchange', exchange });
for (const call of message.toolCalls) {
const superseded = openCalls.get(call.id);
if (superseded !== undefined) {
superseded.pending.result = INTERRUPTED_RESULT;
onAnomaly?.({
kind: 'tool_result_synthesized',
toolCallId: call.id,
trailing: superseded.exchange.ownerIndex >= layout.lastNonToolIndex,
});
}
const pending: PendingCall = { callId: call.id, result: undefined, foreignBetween: false };
exchange.pending.push(pending);
openCalls.set(call.id, { exchange, pending });
}
}
return blocks;
}
interface MergeState {
single: { readonly source: ContextMessage; readonly content: ContentPart[] } | undefined;
readonly texts: string[];
readonly parts: ContentPart[];
}
function flattenBlocks(
blocks: readonly Block[],
layout: SliceLayout,
onAnomaly?: OnAnomaly,
): Message[] {
const out: Message[] = [];
let merge: MergeState | undefined;
const flushMerge = (): void => {
if (merge === undefined) return;
if (merge.single !== undefined) {
out.push(toWireMessage(merge.single.source, merge.single.content));
} else {
const text = merge.texts.join('\n\n');
const content: ContentPart[] = text === '' ? [] : [{ type: 'text', text }];
content.push(...merge.parts);
out.push({
role: 'user',
name: undefined,
content,
toolCalls: [],
toolCallId: undefined,
partial: undefined,
});
}
merge = undefined;
};
for (const block of blocks) {
if (block.kind === 'message') {
if (canMergeUserMessage(block.source)) {
if (merge === undefined) {
merge = { single: block, texts: [], parts: [] };
} else {
if (merge.single !== undefined) {
appendMergeContent(merge, merge.single.content);
merge.single = undefined;
}
appendMergeContent(merge, block.content);
}
continue;
}
flushMerge();
out.push(toWireMessage(block.source, block.content));
continue;
}
flushMerge();
const { exchange } = block;
out.push(toWireMessage(exchange.source, exchange.content));
for (const pending of exchange.pending) {
if (pending.result === undefined) {
out.push(createInterruptedToolResult(pending.callId));
onAnomaly?.({
kind: 'tool_result_synthesized',
toolCallId: pending.callId,
trailing: exchange.ownerIndex >= layout.lastNonToolIndex,
});
} else if (pending.result === INTERRUPTED_RESULT) {
out.push(createInterruptedToolResult(pending.callId));
} else {
out.push(toWireMessage(pending.result.source, pending.result.content));
}
}
}
flushMerge();
return out;
}
function dedupeDuplicateToolCalls(messages: readonly Message[], onAnomaly?: OnAnomaly): Message[] {
const seenToolCallIds = new Set<string>();
const keptToolResultIndexes = new Map<string, number>();
const out: Message[] = [];
for (const message of messages) {
if (message.role === 'assistant' && message.toolCalls.length > 0) {
const kept = message.toolCalls.filter((toolCall) => {
if (seenToolCallIds.has(toolCall.id)) {
onAnomaly?.({ kind: 'duplicate_tool_call_dropped', toolCallId: toolCall.id });
return false;
}
seenToolCallIds.add(toolCall.id);
return true;
});
if (kept.length === message.toolCalls.length) {
out.push(message);
} else if (kept.length > 0 || !message.content.every(isVacuousContentPart)) {
out.push({ ...message, toolCalls: kept });
} else if (message.content.length > 0) {
onAnomaly?.({ kind: 'vacuous_message_dropped', role: message.role });
}
continue;
}
if (message.role === 'tool' && message.toolCallId !== undefined) {
const previousIndex = keptToolResultIndexes.get(message.toolCallId);
if (previousIndex !== undefined) {
if (isInterruptedToolResult(out[previousIndex]) && !isInterruptedToolResult(message)) {
out[previousIndex] = message;
} else {
onAnomaly?.({ kind: 'duplicate_tool_result_dropped', toolCallId: message.toolCallId });
}
continue;
}
keptToolResultIndexes.set(message.toolCallId, out.length);
}
out.push(message);
}
return out;
}
function mergeConsecutiveAssistantMessages(
messages: readonly Message[],
onAnomaly?: OnAnomaly,
): Message[] {
const out: Message[] = [];
for (const message of messages) {
const previous = out.at(-1);
if (previous !== undefined && previous.role === 'assistant' && message.role === 'assistant') {
out[out.length - 1] = {
...previous,
content: [...previous.content, ...message.content],
toolCalls: [...previous.toolCalls, ...message.toolCalls],
};
onAnomaly?.({ kind: 'consecutive_assistants_merged' });
continue;
}
out.push(message);
}
return out;
}
function dropLeadingNonUserMessages(messages: readonly Message[], onAnomaly?: OnAnomaly): Message[] {
let start = 0;
while (start < messages.length && messages[start]?.role !== 'user') {
onAnomaly?.({ kind: 'leading_non_user_dropped', role: messages[start]!.role });
start += 1;
}
return start === 0 ? [...messages] : messages.slice(start);
}
function appendMergeContent(group: MergeState, content: readonly ContentPart[]): void {
let text = '';
for (const part of content) {
if (part.type === 'text') text += part.text;
else group.parts.push(part);
}
if (text.length > 0) group.texts.push(text);
}
function projectedContent(source: ContextMessage, onAnomaly?: OnAnomaly): ContentPart[] {
const content =
source.role === 'tool'
? renderToolResultForModel({
output: outputFromToolContent(source.content),
isError: source.isError,
note: source.note,
})
: source.content;
return cleanContent(source, content, onAnomaly);
}
function cleanContent(
source: ContextMessage,
rawContent: readonly ContentPart[],
onAnomaly?: OnAnomaly,
): ContentPart[] {
const hasBlank = rawContent.some(isBlankText);
let content: readonly ContentPart[] = rawContent;
if (hasBlank) {
const filtered: ContentPart[] = [];
for (const part of rawContent) {
if (isBlankText(part)) {
if (part.type === 'text' && part.text.length > 0) {
onAnomaly?.({ kind: 'whitespace_text_dropped', role: source.role });
}
} else {
filtered.push(part);
}
}
content = filtered;
}
if (source.role === 'tool' && content.length === 0) {
throw new Error2(
ErrorCodes.REQUEST_INVALID,
'Tool result message content cannot be empty after removing empty text blocks.',
{ details: { toolCallId: source.toolCallId } },
);
}
return [...content];
}
function outputFromToolContent(content: readonly ContentPart[]): string | readonly ContentPart[] {
const only = content[0];
return content.length === 1 && only?.type === 'text' ? only.text : content;
}
const TOOL_INTERRUPTED_TEXT =
'Tool result is not available in the current context. Do not assume the tool completed successfully.';
function createInterruptedToolResult(toolCallId: string): Message {
return {
role: 'tool',
name: undefined,
content: [{ type: 'text', text: TOOL_INTERRUPTED_TEXT }],
toolCalls: [],
toolCallId,
partial: undefined,
};
}
function isInterruptedToolResult(message: Message | undefined): boolean {
if (message?.role !== 'tool') return false;
const [part] = message.content;
return part?.type === 'text' && part.text === TOOL_INTERRUPTED_TEXT;
}
function isBlankText(part: ContentPart): boolean {
return part.type === 'text' && part.text.trim().length === 0;
}
function canMergeUserMessage(message: ContextMessage): boolean {
return message.role === 'user' && message.origin?.kind === 'user';
}
function hasDeclaredTools(message: ContextMessage): boolean {
return message.tools !== undefined && message.tools.length > 0;
}
function toWireMessage(message: ContextMessage, content: ContentPart[]): Message {
return {
role: message.role,
name: message.name,
content,
toolCalls: message.toolCalls,
toolCallId: message.toolCallId,
partial: message.partial,
tools: message.tools,
};
}

View file

@ -604,6 +604,7 @@ export * from '#/features/dateChange/dateChangeService';
import '#/features/dateChange/dateChangeFeature';
export * from '#/agent/contextProjector/contextProjector';
export * from '#/agent/contextProjector/contextProjectorService';
export * from '#/agent/contextProjector/mediaProjection';
export * from '#/agent/tokenCounting/tokenCounting';
export * from '#/agent/tokenCounting/tokenCountingOps';
export * from '#/agent/tokenCounting/tokenCountingService';