Skip to content
Merged
12 changes: 11 additions & 1 deletion packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
// references become '(circular)', and class instances collapse to a '(ClassName)'
// marker — the wire shape of an entry is the JSON projection of the type here.
//
// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 72 keys)
// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 73 keys)
// App
// Workspace
// workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts
Expand Down Expand Up @@ -66,6 +66,7 @@
// llm.requestTrace src/agent/llmRequester/llmRequestOps.ts
// llmRequester.emittedThinkingEffortWarnings src/agent/llmRequester/llmRequesterService.ts
// llmRequester.lastConfigLogSignature src/agent/llmRequester/llmRequesterService.ts
// llmRequester.lastTurnRequest src/agent/llmRequester/llmRequesterService.ts
// llmRequester.mediaDegradedTurns src/agent/llmRequester/llmRequesterService.ts
// llmRequester.mediaStrippedTurns src/agent/llmRequester/llmRequesterService.ts
// llmRequester.turnConfigs src/agent/llmRequester/llmRequesterService.ts
Expand Down Expand Up @@ -972,6 +973,15 @@ export interface AgentStateSnapshot {
// src/agent/llmRequester/llmRequesterService.ts
'llmRequester.emittedThinkingEffortWarnings': Set<string>;
'llmRequester.lastConfigLogSignature': string | undefined;
'llmRequester.lastTurnRequest': /* AgentLLMTurnRequestSnapshot — packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts */ {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document consumers of the new state key

This adds llmRequester.lastTurnRequest to the generated AgentStateSnapshot contract, but the change description only discusses probe internals and does not name the desktop/web/VS Code or other state-manifest consumers, nor explain how previous-release clients or snapshot payloads tolerate the new required key. Record the consumer and compatibility behavior before merging this contract change.

AGENTS.md reference: AGENTS.md:L110-L112

Useful? React with 👍 / 👎.

readonly modelAlias: string;
readonly contextRevision: number;
readonly toolsHash: string;
readonly systemPromptHash: string;
readonly inputTokens?: number;
readonly startedAt: number;
readonly finishedAt: number;
} | undefined;
'llmRequester.mediaDegradedTurns': Set<number>;
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
readonly "__@mediaStripSnapshotBrand": undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface IAgentContextMemoryService {

get(): readonly ContextMessage[];

prefixRevision(): number;

append(...messages: readonly ContextMessage[]): void;

appendLoopEvent(event: LoopRecordedEvent): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import type { ContextMessage } from './types';
export class AgentContextMemoryService extends Disposable implements IAgentContextMemoryService {
declare readonly _serviceBrand: undefined;

private revision = 0;

constructor(
@IEventDispatcher private readonly dispatcher: IEventDispatcher,
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
Expand All @@ -49,6 +51,10 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
return this.agentState.get(contextMemoryKey) as readonly ContextMessage[];
}

prefixRevision(): number {
return this.revision;
}

append(...messages: readonly ContextMessage[]): void {
if (messages.length === 0) return;
const start = this.get().length;
Expand Down Expand Up @@ -128,6 +134,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
}

private publishSplice(input: Omit<ContextSplicedPayload, 'agentId'>): void {
if (input.deleteCount > 0) this.revision += 1;
void this.dispatcher.dispatch(
new ContextSpliced({ agentId: this.scopeContext.agentId, ...input }),
);
Expand Down
12 changes: 12 additions & 0 deletions packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ export interface PreparedTurnRequestConfig {
readonly thinkingEffort: ThinkingEffort;
}

export interface AgentLLMTurnRequestSnapshot {
readonly modelAlias: string;
readonly contextRevision: number;
readonly toolsHash: string;
readonly systemPromptHash: string;
readonly inputTokens?: number;
readonly startedAt: number;
readonly finishedAt: number;
}

export interface IAgentLLMRequesterService {
readonly _serviceBrand: undefined;

Expand All @@ -65,6 +75,8 @@ export interface IAgentLLMRequesterService {

credentialProviderForTurn(turnId: number): LlmCredentialProvider | undefined;

lastTurnRequest(): AgentLLMTurnRequestSnapshot | undefined;

request(
overrides?: AgentLLMRequestOverrides,
onPart?: AgentLLMRequestPartHandler,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
type AgentLLMRequestPartHandler,
type AgentLLMRequestSource,
type AgentLLMRequestTask,
type AgentLLMTurnRequestSnapshot,
type PreparedTurnRequestConfig,
} from './llmRequester';
import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace';
Expand Down Expand Up @@ -105,6 +106,7 @@ interface ResolvedLLMRequest {
readonly systemPrompt: string;
readonly tools: readonly Tool[];
readonly messages: Message[];
readonly contextRevision: number;
readonly source: AgentLLMRequestSource | undefined;
readonly logFields: AgentLLMRequestLogFields;
}
Expand Down Expand Up @@ -148,6 +150,10 @@ export const llmRequesterEmittedThinkingEffortWarningsKey = defineState<Set<stri
'llmRequester.emittedThinkingEffortWarnings',
() => new Set(),
);
export const llmRequesterLastTurnRequestKey = defineState<AgentLLMTurnRequestSnapshot | undefined>(
'llmRequester.lastTurnRequest',
() => undefined as AgentLLMTurnRequestSnapshot | undefined,
);

export class AgentLLMRequesterService implements IAgentLLMRequesterService {
declare readonly _serviceBrand: undefined;
Expand Down Expand Up @@ -179,6 +185,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
this.states.contributeState(llmRequesterMediaDegradedTurnsKey);
this.states.contributeState(llmRequesterMediaStrippedTurnsKey);
this.states.contributeState(llmRequesterEmittedThinkingEffortWarningsKey);
this.states.contributeState(llmRequesterLastTurnRequestKey);
}

private get lastConfigLogSignature(): string | undefined {
Expand Down Expand Up @@ -222,6 +229,10 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
return this.modelCatalog.get(resolved.modelAlias).credentialProvider;
}

lastTurnRequest(): AgentLLMTurnRequestSnapshot | undefined {
return this.states.get(llmRequesterLastTurnRequestKey);
}

async request(
overrides: AgentLLMRequestOverrides = {},
onPart: AgentLLMRequestPartHandler = noopOnPart,
Expand Down Expand Up @@ -413,7 +424,8 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
fields,
};
this.logRequest(logInput);
this.recordRequest(logInput);
const recorded = this.recordRequest(logInput);
const startedAt = Date.now();

let message: Message | undefined;
let usage: TokenUsage | undefined;
Expand Down Expand Up @@ -474,6 +486,17 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
throw error;
}

if (request.source?.type === 'turn') {
this.states.set(llmRequesterLastTurnRequestKey, {
modelAlias: request.modelAlias,
contextRevision: request.contextRevision,
toolsHash: recorded.toolsHash,
systemPromptHash: recorded.systemPromptHash,
Comment on lines +491 to +494

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Capture thinking retention in the parity snapshot

Fresh evidence beyond the prior effort-switch issue is the reloadable [thinking].keep setting: with unchanged effort/model and a history containing earlier thinking parts, changing it between the parent's request and the fork changes whether Kimi serializes those parts (packages/agent-core-v2/src/human/llm-kimi/trait.ts:89-102). This snapshot omits the effective keep value, so the probe can still report both matches and a parent baseline for a client-diverged prefix; capture/compare the retention setting actually sent, or hash the provider-visible messages.

AGENTS.md reference: AGENTS.md:L118-L120

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted as a known blind spot for now. Divergence through this dimension needs a [thinking].keep / effort change between the parent's request and the fork plus earlier thinking parts in history — narrow enough that the probe documents it instead of comparing it (noted in the PR description). If more serialization dimensions show up, the snapshot carries one more field per dimension; happy to revisit then.

inputTokens: usage === undefined ? undefined : inputTotal(usage),
startedAt,
finishedAt: Date.now(),
});
}
void this.usage.record(
this.scopeContext.agentContext,
request.modelAlias,
Expand Down Expand Up @@ -723,6 +746,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
systemPrompt: overrides.systemPrompt ?? turnConfig?.systemPrompt ?? this.profile.getSystemPrompt(),
tools: [...(overrides.tools ?? this.defaultTools())],
messages: [...messages],
contextRevision: this.context.prefixRevision(),
source: overrides.source,
logFields: logFieldsForSource(overrides.source),
};
Expand Down Expand Up @@ -776,7 +800,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
this.log.info('llm request', requestFields);
}

private recordRequest(input: LLMRequestLogInput): void {
private recordRequest(input: LLMRequestLogInput): LlmRequestPayload {
const fields = input.fields ?? {};
const wireTools = providerVisibleTools(input.tools);
const tools = toolSignature(wireTools);
Expand Down Expand Up @@ -822,6 +846,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
droppedCount: numberField(fields, 'droppedCount'),
};
void this.dispatcher.dispatch(new LlmRequest(payload));
return payload;
}

private logResponse(
Expand Down
38 changes: 38 additions & 0 deletions packages/agent-core-v2/src/agent/usage/cacheProbeService.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { IInstantiationService } from '#/_base/di/instantiation';
import { Service } from '#/_base/di/service';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { LifecycleScope } from '#/app/scopes';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import {
IAgentLLMRequesterService,
type AgentLLMTurnRequestSnapshot,
} from '#/agent/llmRequester/llmRequester';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { inputTotal } from '#human/llm/usage';
import { IModelCatalog } from '#/llm-adapter/model/catalog';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { ISessionUsageService } from '#/session/usage/sessionUsage';

import { IAgentCacheProbeService } from './cacheProbe';
Expand All @@ -13,14 +20,25 @@ import { type UsageRecordedContext } from './usage';
export class AgentCacheProbeService extends Service implements IAgentCacheProbeService {
declare readonly _serviceBrand: undefined;

private parentId: string | undefined;

private parentRequest: AgentLLMTurnRequestSnapshot | undefined;

constructor(
@ISessionUsageService usage: ISessionUsageService,
@IAgentScopeContext scopeContext: IAgentScopeContext,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IModelCatalog private readonly models: IModelCatalog,
@IInstantiationService private readonly instantiation: IInstantiationService,
@IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService,
) {
super();
if (scopeContext.forkedFrom === undefined) return;
this.parentId = scopeContext.forkedFrom;
this.parentRequest = agentLifecycle
.handleOf(this.parentId)
?.accessor.get(IAgentLLMRequesterService)
.lastTurnRequest();
this._register(
usage.onDidRecord((e) => {
if (e.agent.agentId === scopeContext.agentId) this.probe(e);
Expand All @@ -30,13 +48,29 @@ export class AgentCacheProbeService extends Service implements IAgentCacheProbeS

private probe(e: UsageRecordedContext): void {
if (!e.firstRecord || e.source?.type !== 'turn') return;
const parentRevision =
this.parentId === undefined
? undefined
: this.agentLifecycle
.handleOf(this.parentId)
?.accessor.get(IAgentContextMemoryService)
.prefixRevision();
const parent =
this.parentRequest !== undefined && this.parentRequest.contextRevision === parentRevision
? this.parentRequest
: undefined;
let providerType: string | undefined;
let protocol: string | undefined;
try {
const model = this.models.get(e.model);
providerType = model.providerType ?? model.protocol;
protocol = model.protocol;
} catch { }
const own = this.instantiation
.invokeFunction((accessor) => accessor.get(IAgentLLMRequesterService))
.lastTurnRequest();
const sameModel = parent !== undefined && parent.modelAlias === e.model;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare the resolved cache namespace instead of the alias

When a provider's model or base URL is reconfigured in place under the same alias after the parent turn, ModelCatalog.invalidate() rebuilds that alias but this equality remains true, so unchanged tools/system hashes and old parent tokens are compared across cache namespaces. Fresh evidence since the earlier model-parity comment is the supported same-alias invalidation path in llm-adapter/model/catalog-service.ts; snapshot the resolved provider/model endpoint (or invalidate request snapshots on catalog changes) rather than treating the alias as identity.

Useful? React with 👍 / 👎.

const compared = sameModel && own !== undefined;
Comment on lines +72 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate cache parity on the effective thinking effort

When /effort changes after the parent's last completed turn but before /btw, AgentLifecycleService.fork() copies the new thinkingLevel while AgentLLMTurnRequestSnapshot and this gate ignore it; EFFORT_SWITCH_CACHE_WARNING in apps/kimi-code/src/tui/commands/config.ts explicitly records that such a change invalidates the prompt cache. The model alias, tools, and system prompt can all still match, so the probe emits a baseline that makes the expected client-side miss look like a server miss; store/compare the effective effort or invalidate the snapshot on effort changes.

Useful? React with 👍 / 👎.

this.telemetry.track2('prompt_cache_probe', {
source: 'fork',
turn_id: e.source.turnId,
Expand All @@ -46,6 +80,10 @@ export class AgentCacheProbeService extends Service implements IAgentCacheProbeS
input_cache_read: e.usage.inputCacheRead,
input_cache_creation: e.usage.inputCacheCreation,
output_tokens: e.usage.output,
tools_match: compared ? own.toolsHash === parent.toolsHash : undefined,
system_prompt_match: compared ? own.systemPromptHash === parent.systemPromptHash : undefined,
Comment on lines +83 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include deferred schemas in the prefix parity check

When progressive tool disclosure is enabled and a loaded deferred tool is disabled between the parent request and the fork (for example through the supported session disabled_tools policy), AgentToolSelectService.shapeActiveHistory() removes that tool's message-level schema from the child's inherited history. Because providerVisibleTools() excludes deferred tools from both top-level hashes, these comparisons still report both matches as true even though the provider-visible message prefix changed, so the expected client-side cache miss is attributed to the server; message-level schema/history parity must also be tracked or such policy changes must invalidate the baseline.

Useful? React with 👍 / 👎.

parent_input_tokens: sameModel ? parent?.inputTokens : undefined,
parent_request_gap_ms: compared ? own.startedAt - parent.finishedAt : undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Measure cache age from input processing, not stream completion

When the parent response streams or is back-pressured for appreciable time, subtracting parent.finishedAt reports only the idle time after the stream (often nearly zero). Anthropic reports the input/cache usage in message_start, before any output is consumed (packages/agent-core-v2/src/human/llm/requester/bases/anthropic/format.ts:270-280), so the cache write has the entire response duration to settle and a long response can even consume much of its TTL while this field remains near zero. Since this probe is intended to use short versus long gaps to distinguish write races from expiry, these samples are silently misclassified; capture the input-processing/first-response timestamp or include the parent request duration/use startedAt.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted. The stream-duration bias only misclassifies samples whose parent finished with a long single-step stream, and gap_ms is the weakest of the four fields (a heuristic for write-race vs expiry while the hashes and token baseline stay exact). Documented as a known limitation in the PR description rather than changing the field semantics.

});
}
}
Expand Down
14 changes: 13 additions & 1 deletion packages/agent-core-v2/src/app/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ export interface PromptCacheProbeEvent {
input_cache_read: number;
input_cache_creation: number;
output_tokens: number;
tools_match?: boolean;
system_prompt_match?: boolean;
parent_input_tokens?: number;
parent_request_gap_ms?: number;
}

export type ToolCallOutcome = 'success' | 'error' | 'cancelled';
Expand Down Expand Up @@ -671,7 +675,7 @@ export const telemetryEventDefinitions = {
prompt_cache_probe: defineAgentTelemetryEvent<PromptCacheProbeEvent>({
owner: 'kimi-code',
comment:
'An agent whose first request is expected to hit the prompt cache reports that request\'s cache usage.',
'An agent whose first request is expected to hit the prompt cache reports that request\'s cache usage and how its prefix compares with the last turn request the parent agent completed before the fork.',
properties: {
source: 'Why a cache hit was expected for this request',
turn_id: 'Per-agent turn index of the probed request',
Expand All @@ -681,6 +685,14 @@ export const telemetryEventDefinitions = {
input_cache_read: 'Cache-read input tokens of the probed request',
input_cache_creation: 'Cache-creation input tokens of the probed request',
output_tokens: 'Output tokens of the probed request',
tools_match:
"Whether the provider-visible tool table hash equals the parent's; absent when the parent completed no turn request before the fork, or its context was rewritten (undo, compaction) after the last one",
system_prompt_match:
"Whether the system prompt hash equals the parent's; absent when the parent completed no turn request before the fork, or its context was rewritten (undo, compaction) after the last one",
parent_input_tokens:
"Total input tokens of the parent's last completed turn request before the fork, i.e. the prefix this request could read from the cache; absent when there is none, or the parent's context was rewritten (undo, compaction) after it",
parent_request_gap_ms:
"Milliseconds from the parent's last completed turn request finishing to the probed request starting; absent when the parent completed no turn request before the fork, or its context was rewritten (undo, compaction) after the last one",
},
}),
tool_call: defineAgentTelemetryEvent<ToolCallEvent>({
Expand Down
8 changes: 8 additions & 0 deletions packages/agent-core-v2/test/agent/contextMemory/stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ function publishSplice(

export function stubContextMemory(eventBus?: IEventBus): StubContextMemory {
const messages: ContextMessage[] = [];
let revision = 0;
return {
_serviceBrand: undefined,
get messages() {
return messages;
},
get: () => [...messages],
prefixRevision: () => revision,
append: (...inserted) => {
const start = messages.length;
messages.push(...inserted);
Expand All @@ -60,13 +62,15 @@ export function stubContextMemory(eventBus?: IEventBus): StubContextMemory {
const deleteCount = messages.length;
if (deleteCount === 0) return;
messages.splice(0, deleteCount);
revision += 1;
publishSplice(eventBus, { start: 0, deleteCount, messages: [] });
},
undo: (count) => {
const cut = computeUndoCut(messages, count);
if (cut.cutIndex >= 0 && cut.removedCount >= count) {
const deleteCount = messages.length - cut.cutIndex;
messages.splice(cut.cutIndex, deleteCount);
revision += 1;
publishSplice(eventBus, { start: cut.cutIndex, deleteCount, messages: [] });
}
return cut;
Expand All @@ -75,6 +79,7 @@ export function stubContextMemory(eventBus?: IEventBus): StubContextMemory {
const shape = buildContextCompactionShape(messages, input);
const previousLength = messages.length;
messages.splice(0, previousLength, ...shape.messages);
if (previousLength > 0) revision += 1;
publishSplice(eventBus, {
start: 0,
deleteCount: previousLength,
Expand Down Expand Up @@ -115,6 +120,9 @@ class StubContextMemoryService implements IAgentContextMemoryService {
applyCompaction(input: ContextCompactionInput): ContextCompactionResult {
return this.impl.applyCompaction(input);
}
prefixRevision(): number {
return this.impl.prefixRevision();
}
}

export function registerContextMemoryServices(reg: ServiceRegistration): void {
Expand Down
Loading
Loading