Skip to content

Commit fed19d2

Browse files
authored
feat(agent-core-v2): report fork request prefix parity in the prompt cache probe (#4179)
* feat(agent-core-v2): report fork request prefix parity in the prompt cache probe The fork prompt cache probe only reported the child's cache usage, so a low hit rate could not be told apart between a client-side prefix divergence and a server-side miss, and the hit rate itself was diluted by the new tail that can never be cached. The requester now keeps a snapshot of each agent's last completed turn request (the tools and system prompt hashes it already records in llm.request, input tokens and timing). A forked agent captures its parent's snapshot when it is created and reports tools_match, system_prompt_match, parent_input_tokens and parent_request_gap_ms with its first request. * fix(agent-core-v2): clear the parent request snapshot when the context is rewritten Undo and compaction rewrite the parent context without touching the non-persisted snapshot, so a /btw fork made before the next turn request compared against a request whose input the child no longer inherits. * fix(agent-core-v2): drop stale parent baselines from the fork cache probe The probe compared the child's first request against the parent snapshot captured at scope creation, but a parent undo or compaction landing before the context copy left that snapshot describing a request the child no longer inherits, and a model switch between the parent's last turn and the fork compared across cache namespaces that can never share a prompt cache. The probe now watches the parent's ContextSpliced/ContextUndone events and drops the captured snapshot once the parent context is rewritten, and the snapshot carries the model alias so the comparison fields and the parent input token baseline are only reported when both sides ran the same model. * fix(agent-core-v2): keep the turn request snapshot across conversation undo Undo only truncates the tail of the parent context, so the request a /btw fork inherits is still a strict prefix of the request the parent last cached: the server cache keeps hitting and the snapshot's hashes stay accurate. Clearing the snapshot on undo threw away a baseline that was still valid, and made the requester depend on the undo domain for no benefit. Only compaction rewrites the prefix, so it remains the sole event that clears the snapshot. * refactor(agent-core-v2): validate the fork cache probe baseline by context prefix revision The requester subscribed to ContextSpliced solely to keep a telemetry snapshot fresh, which made the module understand context rewrites and grow an event bus dependency for the probe's sake. The probe's own subscription could not cover forks created after a compaction either, which is the only reason the requester had to listen at all. Context memory now exposes a prefix revision that increments on every deleting splice, the turn request snapshot records the revision it was taken at, and the probe compares it with the parent's current revision at probe time. Read-time validation covers both the compact-then-fork and the fork-then-compact windows with no subscriptions, and the requester is back to only recording facts about its own requests. * fix(agent-core-v2): stamp the turn request snapshot with the build-time context revision The public SDK clearContext path has no busy check and can empty the parent context while a turn request is in flight. Reading the revision at completion stamped the snapshot with the post-clear value, so a later fork saw matching revisions and hashes and silently attributed the client-side prefix divergence to the server. The request input is fixed by recordRequest time, so capture the revision in the same synchronous block; a mid-flight clear then leaves the snapshot on the pre-clear revision and the probe drops the baseline on comparison. * fix(agent-core-v2): capture the snapshot context revision where the request messages are copied The build-time capture still ran after the awaited media resolution, so a clearContext or compaction landing in that window stamped the snapshot with the post-rewrite revision for a request whose messages were copied earlier. resolveRequest is synchronous and copies the context messages there, so the revision captured alongside the copy always describes the same context the request was built from. * fix(agent-core-v2): omit the parent token baseline when the parent request reported no usage A provider that completes without a usage event left the snapshot with inputTokens: 0, so a later same-model fork emitted parent_input_tokens: 0 and the input_cache_read / parent_input_tokens analysis divided by zero or treated an unknown baseline as real. The field is now optional and absent for that snapshot, which the optional telemetry property already allows.
1 parent cbd7540 commit fed19d2

15 files changed

Lines changed: 439 additions & 7 deletions

File tree

‎packages/agent-core-v2/docs/state-manifest.d.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
// references become '(circular)', and class instances collapse to a '(ClassName)'
2828
// marker — the wire shape of an entry is the JSON projection of the type here.
2929
//
30-
// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 72 keys)
30+
// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 73 keys)
3131
// App
3232
// Workspace
3333
// workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts
@@ -66,6 +66,7 @@
6666
// llm.requestTrace src/agent/llmRequester/llmRequestOps.ts
6767
// llmRequester.emittedThinkingEffortWarnings src/agent/llmRequester/llmRequesterService.ts
6868
// llmRequester.lastConfigLogSignature src/agent/llmRequester/llmRequesterService.ts
69+
// llmRequester.lastTurnRequest src/agent/llmRequester/llmRequesterService.ts
6970
// llmRequester.mediaDegradedTurns src/agent/llmRequester/llmRequesterService.ts
7071
// llmRequester.mediaStrippedTurns src/agent/llmRequester/llmRequesterService.ts
7172
// llmRequester.turnConfigs src/agent/llmRequester/llmRequesterService.ts
@@ -972,6 +973,15 @@ export interface AgentStateSnapshot {
972973
// src/agent/llmRequester/llmRequesterService.ts
973974
'llmRequester.emittedThinkingEffortWarnings': Set<string>;
974975
'llmRequester.lastConfigLogSignature': string | undefined;
976+
'llmRequester.lastTurnRequest': /* AgentLLMTurnRequestSnapshot — packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts */ {
977+
readonly modelAlias: string;
978+
readonly contextRevision: number;
979+
readonly toolsHash: string;
980+
readonly systemPromptHash: string;
981+
readonly inputTokens?: number;
982+
readonly startedAt: number;
983+
readonly finishedAt: number;
984+
} | undefined;
975985
'llmRequester.mediaDegradedTurns': Set<number>;
976986
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
977987
readonly "__@mediaStripSnapshotBrand": undefined;

‎packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export interface IAgentContextMemoryService {
3434

3535
get(): readonly ContextMessage[];
3636

37+
prefixRevision(): number;
38+
3739
append(...messages: readonly ContextMessage[]): void;
3840

3941
appendLoopEvent(event: LoopRecordedEvent): void;

‎packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import type { ContextMessage } from './types';
2727
export class AgentContextMemoryService extends Disposable implements IAgentContextMemoryService {
2828
declare readonly _serviceBrand: undefined;
2929

30+
private revision = 0;
31+
3032
constructor(
3133
@IEventDispatcher private readonly dispatcher: IEventDispatcher,
3234
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
@@ -49,6 +51,10 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
4951
return this.agentState.get(contextMemoryKey) as readonly ContextMessage[];
5052
}
5153

54+
prefixRevision(): number {
55+
return this.revision;
56+
}
57+
5258
append(...messages: readonly ContextMessage[]): void {
5359
if (messages.length === 0) return;
5460
const start = this.get().length;
@@ -128,6 +134,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
128134
}
129135

130136
private publishSplice(input: Omit<ContextSplicedPayload, 'agentId'>): void {
137+
if (input.deleteCount > 0) this.revision += 1;
131138
void this.dispatcher.dispatch(
132139
new ContextSpliced({ agentId: this.scopeContext.agentId, ...input }),
133140
);

‎packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ export interface PreparedTurnRequestConfig {
5656
readonly thinkingEffort: ThinkingEffort;
5757
}
5858

59+
export interface AgentLLMTurnRequestSnapshot {
60+
readonly modelAlias: string;
61+
readonly contextRevision: number;
62+
readonly toolsHash: string;
63+
readonly systemPromptHash: string;
64+
readonly inputTokens?: number;
65+
readonly startedAt: number;
66+
readonly finishedAt: number;
67+
}
68+
5969
export interface IAgentLLMRequesterService {
6070
readonly _serviceBrand: undefined;
6171

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

6676
credentialProviderForTurn(turnId: number): LlmCredentialProvider | undefined;
6777

78+
lastTurnRequest(): AgentLLMTurnRequestSnapshot | undefined;
79+
6880
request(
6981
overrides?: AgentLLMRequestOverrides,
7082
onPart?: AgentLLMRequestPartHandler,

‎packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
type AgentLLMRequestPartHandler,
6363
type AgentLLMRequestSource,
6464
type AgentLLMRequestTask,
65+
type AgentLLMTurnRequestSnapshot,
6566
type PreparedTurnRequestConfig,
6667
} from './llmRequester';
6768
import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace';
@@ -105,6 +106,7 @@ interface ResolvedLLMRequest {
105106
readonly systemPrompt: string;
106107
readonly tools: readonly Tool[];
107108
readonly messages: Message[];
109+
readonly contextRevision: number;
108110
readonly source: AgentLLMRequestSource | undefined;
109111
readonly logFields: AgentLLMRequestLogFields;
110112
}
@@ -148,6 +150,10 @@ export const llmRequesterEmittedThinkingEffortWarningsKey = defineState<Set<stri
148150
'llmRequester.emittedThinkingEffortWarnings',
149151
() => new Set(),
150152
);
153+
export const llmRequesterLastTurnRequestKey = defineState<AgentLLMTurnRequestSnapshot | undefined>(
154+
'llmRequester.lastTurnRequest',
155+
() => undefined as AgentLLMTurnRequestSnapshot | undefined,
156+
);
151157

152158
export class AgentLLMRequesterService implements IAgentLLMRequesterService {
153159
declare readonly _serviceBrand: undefined;
@@ -179,6 +185,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
179185
this.states.contributeState(llmRequesterMediaDegradedTurnsKey);
180186
this.states.contributeState(llmRequesterMediaStrippedTurnsKey);
181187
this.states.contributeState(llmRequesterEmittedThinkingEffortWarningsKey);
188+
this.states.contributeState(llmRequesterLastTurnRequestKey);
182189
}
183190

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

232+
lastTurnRequest(): AgentLLMTurnRequestSnapshot | undefined {
233+
return this.states.get(llmRequesterLastTurnRequestKey);
234+
}
235+
225236
async request(
226237
overrides: AgentLLMRequestOverrides = {},
227238
onPart: AgentLLMRequestPartHandler = noopOnPart,
@@ -413,7 +424,8 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
413424
fields,
414425
};
415426
this.logRequest(logInput);
416-
this.recordRequest(logInput);
427+
const recorded = this.recordRequest(logInput);
428+
const startedAt = Date.now();
417429

418430
let message: Message | undefined;
419431
let usage: TokenUsage | undefined;
@@ -474,6 +486,17 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
474486
throw error;
475487
}
476488

489+
if (request.source?.type === 'turn') {
490+
this.states.set(llmRequesterLastTurnRequestKey, {
491+
modelAlias: request.modelAlias,
492+
contextRevision: request.contextRevision,
493+
toolsHash: recorded.toolsHash,
494+
systemPromptHash: recorded.systemPromptHash,
495+
inputTokens: usage === undefined ? undefined : inputTotal(usage),
496+
startedAt,
497+
finishedAt: Date.now(),
498+
});
499+
}
477500
void this.usage.record(
478501
this.scopeContext.agentContext,
479502
request.modelAlias,
@@ -723,6 +746,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
723746
systemPrompt: overrides.systemPrompt ?? turnConfig?.systemPrompt ?? this.profile.getSystemPrompt(),
724747
tools: [...(overrides.tools ?? this.defaultTools())],
725748
messages: [...messages],
749+
contextRevision: this.context.prefixRevision(),
726750
source: overrides.source,
727751
logFields: logFieldsForSource(overrides.source),
728752
};
@@ -776,7 +800,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
776800
this.log.info('llm request', requestFields);
777801
}
778802

779-
private recordRequest(input: LLMRequestLogInput): void {
803+
private recordRequest(input: LLMRequestLogInput): LlmRequestPayload {
780804
const fields = input.fields ?? {};
781805
const wireTools = providerVisibleTools(input.tools);
782806
const tools = toolSignature(wireTools);
@@ -822,6 +846,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
822846
droppedCount: numberField(fields, 'droppedCount'),
823847
};
824848
void this.dispatcher.dispatch(new LlmRequest(payload));
849+
return payload;
825850
}
826851

827852
private logResponse(

‎packages/agent-core-v2/src/agent/usage/cacheProbeService.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1+
import { IInstantiationService } from '#/_base/di/instantiation';
12
import { Service } from '#/_base/di/service';
23
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
34
import { LifecycleScope } from '#/app/scopes';
5+
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
6+
import {
7+
IAgentLLMRequesterService,
8+
type AgentLLMTurnRequestSnapshot,
9+
} from '#/agent/llmRequester/llmRequester';
410
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
511
import { ITelemetryService } from '#/app/telemetry/telemetry';
612
import { inputTotal } from '#human/llm/usage';
713
import { IModelCatalog } from '#/llm-adapter/model/catalog';
14+
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
815
import { ISessionUsageService } from '#/session/usage/sessionUsage';
916

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

23+
private parentId: string | undefined;
24+
25+
private parentRequest: AgentLLMTurnRequestSnapshot | undefined;
26+
1627
constructor(
1728
@ISessionUsageService usage: ISessionUsageService,
1829
@IAgentScopeContext scopeContext: IAgentScopeContext,
1930
@ITelemetryService private readonly telemetry: ITelemetryService,
2031
@IModelCatalog private readonly models: IModelCatalog,
32+
@IInstantiationService private readonly instantiation: IInstantiationService,
33+
@IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService,
2134
) {
2235
super();
2336
if (scopeContext.forkedFrom === undefined) return;
37+
this.parentId = scopeContext.forkedFrom;
38+
this.parentRequest = agentLifecycle
39+
.handleOf(this.parentId)
40+
?.accessor.get(IAgentLLMRequesterService)
41+
.lastTurnRequest();
2442
this._register(
2543
usage.onDidRecord((e) => {
2644
if (e.agent.agentId === scopeContext.agentId) this.probe(e);
@@ -30,13 +48,29 @@ export class AgentCacheProbeService extends Service implements IAgentCacheProbeS
3048

3149
private probe(e: UsageRecordedContext): void {
3250
if (!e.firstRecord || e.source?.type !== 'turn') return;
51+
const parentRevision =
52+
this.parentId === undefined
53+
? undefined
54+
: this.agentLifecycle
55+
.handleOf(this.parentId)
56+
?.accessor.get(IAgentContextMemoryService)
57+
.prefixRevision();
58+
const parent =
59+
this.parentRequest !== undefined && this.parentRequest.contextRevision === parentRevision
60+
? this.parentRequest
61+
: undefined;
3362
let providerType: string | undefined;
3463
let protocol: string | undefined;
3564
try {
3665
const model = this.models.get(e.model);
3766
providerType = model.providerType ?? model.protocol;
3867
protocol = model.protocol;
3968
} catch { }
69+
const own = this.instantiation
70+
.invokeFunction((accessor) => accessor.get(IAgentLLMRequesterService))
71+
.lastTurnRequest();
72+
const sameModel = parent !== undefined && parent.modelAlias === e.model;
73+
const compared = sameModel && own !== undefined;
4074
this.telemetry.track2('prompt_cache_probe', {
4175
source: 'fork',
4276
turn_id: e.source.turnId,
@@ -46,6 +80,10 @@ export class AgentCacheProbeService extends Service implements IAgentCacheProbeS
4680
input_cache_read: e.usage.inputCacheRead,
4781
input_cache_creation: e.usage.inputCacheCreation,
4882
output_tokens: e.usage.output,
83+
tools_match: compared ? own.toolsHash === parent.toolsHash : undefined,
84+
system_prompt_match: compared ? own.systemPromptHash === parent.systemPromptHash : undefined,
85+
parent_input_tokens: sameModel ? parent?.inputTokens : undefined,
86+
parent_request_gap_ms: compared ? own.startedAt - parent.finishedAt : undefined,
4987
});
5088
}
5189
}

‎packages/agent-core-v2/src/app/telemetry/events.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ export interface PromptCacheProbeEvent {
9696
input_cache_read: number;
9797
input_cache_creation: number;
9898
output_tokens: number;
99+
tools_match?: boolean;
100+
system_prompt_match?: boolean;
101+
parent_input_tokens?: number;
102+
parent_request_gap_ms?: number;
99103
}
100104

101105
export type ToolCallOutcome = 'success' | 'error' | 'cancelled';
@@ -672,7 +676,7 @@ export const telemetryEventDefinitions = {
672676
prompt_cache_probe: defineAgentTelemetryEvent<PromptCacheProbeEvent>({
673677
owner: 'kimi-code',
674678
comment:
675-
'An agent whose first request is expected to hit the prompt cache reports that request\'s cache usage.',
679+
'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.',
676680
properties: {
677681
source: 'Why a cache hit was expected for this request',
678682
turn_id: 'Per-agent turn index of the probed request',
@@ -682,6 +686,14 @@ export const telemetryEventDefinitions = {
682686
input_cache_read: 'Cache-read input tokens of the probed request',
683687
input_cache_creation: 'Cache-creation input tokens of the probed request',
684688
output_tokens: 'Output tokens of the probed request',
689+
tools_match:
690+
"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",
691+
system_prompt_match:
692+
"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",
693+
parent_input_tokens:
694+
"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",
695+
parent_request_gap_ms:
696+
"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",
685697
},
686698
}),
687699
tool_call: defineAgentTelemetryEvent<ToolCallEvent>({

‎packages/agent-core-v2/test/agent/contextMemory/stubs.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,14 @@ function publishSplice(
4343

4444
export function stubContextMemory(eventBus?: IEventBus): StubContextMemory {
4545
const messages: ContextMessage[] = [];
46+
let revision = 0;
4647
return {
4748
_serviceBrand: undefined,
4849
get messages() {
4950
return messages;
5051
},
5152
get: () => [...messages],
53+
prefixRevision: () => revision,
5254
append: (...inserted) => {
5355
const start = messages.length;
5456
messages.push(...inserted);
@@ -60,13 +62,15 @@ export function stubContextMemory(eventBus?: IEventBus): StubContextMemory {
6062
const deleteCount = messages.length;
6163
if (deleteCount === 0) return;
6264
messages.splice(0, deleteCount);
65+
revision += 1;
6366
publishSplice(eventBus, { start: 0, deleteCount, messages: [] });
6467
},
6568
undo: (count) => {
6669
const cut = computeUndoCut(messages, count);
6770
if (cut.cutIndex >= 0 && cut.removedCount >= count) {
6871
const deleteCount = messages.length - cut.cutIndex;
6972
messages.splice(cut.cutIndex, deleteCount);
73+
revision += 1;
7074
publishSplice(eventBus, { start: cut.cutIndex, deleteCount, messages: [] });
7175
}
7276
return cut;
@@ -75,6 +79,7 @@ export function stubContextMemory(eventBus?: IEventBus): StubContextMemory {
7579
const shape = buildContextCompactionShape(messages, input);
7680
const previousLength = messages.length;
7781
messages.splice(0, previousLength, ...shape.messages);
82+
if (previousLength > 0) revision += 1;
7883
publishSplice(eventBus, {
7984
start: 0,
8085
deleteCount: previousLength,
@@ -115,6 +120,9 @@ class StubContextMemoryService implements IAgentContextMemoryService {
115120
applyCompaction(input: ContextCompactionInput): ContextCompactionResult {
116121
return this.impl.applyCompaction(input);
117122
}
123+
prefixRevision(): number {
124+
return this.impl.prefixRevision();
125+
}
118126
}
119127

120128
export function registerContextMemoryServices(reg: ServiceRegistration): void {

0 commit comments

Comments
 (0)