Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1004,7 +1004,7 @@ export interface AgentStateSnapshot {
'llmRequester.lastConfigLogSignature': string | undefined;
'llmRequester.mediaDegradedTurns': Set<number>;
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
readonly "__@mediaStripSnapshotBrand@2376": undefined;
readonly "__@mediaStripSnapshotBrand": undefined;
}>;
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {
Expand Down
59 changes: 58 additions & 1 deletion packages/agent-core-v2/docs/wire-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
// owning model offloads inline media to blob storage), cross-reducers
// (foreign models that also reduce this record on dispatch and replay).

// Index (44 record types)
// Index (45 record types)
// config.update profile persisted src/agent/profile/profileOps.ts
// context_size.measured contextSize transient src/agent/contextSize/contextSizeOps.ts
// context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts
Expand Down Expand Up @@ -63,6 +63,7 @@
// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts
// tools.update_store todo persisted src/session/todo/todoOps.ts
// turn.cancel turn persisted src/agent/loop/turnOps.ts
// turn.ended turn persisted src/agent/loop/turnOps.ts
// turn.prompt turn persisted src/agent/loop/turnOps.ts
// turn.steer turn persisted src/agent/loop/turnOps.ts
// usage.record usage persisted src/agent/usage/usageOps.ts
Expand Down Expand Up @@ -571,6 +572,61 @@ interface TurnCancelPayload {
target?: 'active' | 'queued';
}

/**
* model: turn · persisted
* owner: src/agent/loop/turnOps.ts
*/
interface TurnEndedPayload {
_name: 'turn.ended';
turnId: number;
reason: 'completed' | 'cancelled' | 'failed' | 'blocked';
/** KimiErrorPayload */
error?: {
code: (typeof ErrorCodes)[keyof typeof ErrorCodes];
message: string;
name?: string;
details?: Readonly<Record<string, unknown>>;
retryable: boolean;
cause?: {
code: (typeof ErrorCodes)[keyof typeof ErrorCodes];
message: string;
name?: string;
details?: Readonly<Record<string, unknown>>;
retryable: boolean;
cause?: {
code: (typeof ErrorCodes)[keyof typeof ErrorCodes];
message: string;
name?: string;
details?: Readonly<Record<string, unknown>>;
retryable: boolean;
cause?: {
code: (typeof ErrorCodes)[keyof typeof ErrorCodes];
message: string;
name?: string;
details?: Readonly<Record<string, unknown>>;
retryable: boolean;
cause?: {
code: (typeof ErrorCodes)[keyof typeof ErrorCodes];
message: string;
name?: string;
details?: Readonly<Record<string, unknown>>;
retryable: boolean;
cause?: {
code: ErrorCode;
message: string;
name?: string;
details?: Readonly<Record<string, unknown>>;
retryable: boolean;
cause?: ErrorPayload;
};
};
};
};
};
};
durationMs?: number;
}

/**
* model: turn · persisted
* owner: src/agent/loop/turnOps.ts
Expand Down Expand Up @@ -654,6 +710,7 @@ interface WirePayloadMap {
"tools.unregister_user_tool": ToolsUnregisterUserToolPayload;
"tools.update_store": ToolsUpdateStorePayload;
"turn.cancel": TurnCancelPayload;
"turn.ended": TurnEndedPayload;
"turn.prompt": TurnPromptPayload;
"turn.steer": TurnSteerPayload;
"usage.record": UsageRecordPayload;
Expand Down
12 changes: 11 additions & 1 deletion packages/agent-core-v2/scripts/gen-state-manifest.mts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ function tsFieldKey(key: string): string {
return /^[$A-Z_a-z][$\w]*$/.test(key) ? key : JSON.stringify(key);
}

/**
* The checker names a `unique symbol` key `__@<declName>@<globalSymbolId>` —
* the numeric id is a compilation-global counter that shifts with unrelated
* edits, so the manifest renders the stable `__@<declName>` form instead.
*/
function stableSymbolKey(key: string): string {
const match = /^__@(.+)@\d+$/.exec(key);
return match === null ? key : `__@${match[1]}`;
}

// ---------------------------------------------------------------------------
// Static pass — key constants and their register call sites
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -635,7 +645,7 @@ class TypeRenderer {
const propLines = rendered.split('\n');
propLines[propLines.length - 1] += ';';
lines.push(
` ${readonly}${tsFieldKey(prop.getName())}${optional ? '?' : ''}: ${propLines[0]}`,
` ${readonly}${tsFieldKey(stableSymbolKey(prop.getName()))}${optional ? '?' : ''}: ${propLines[0]}`,
...propLines.slice(1).map((line) => ` ${line}`),
);
}
Expand Down
6 changes: 4 additions & 2 deletions packages/agent-core-v2/src/agent/loop/loopService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ import {
} from './stepRequest';
import { StepRequestQueue, type StepRequestBatch } from './stepRequestQueue';
import { isDisplayablePromptOrigin, turnPromptText } from './turnEvents';
import { cancelTurn, promptTurn, TurnModel } from './turnOps';
import { cancelTurn, endTurn, promptTurn, TurnModel } from './turnOps';

export type LoopInterruptReason = 'aborted' | 'max_steps' | 'error';

Expand Down Expand Up @@ -495,12 +495,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
: this.activeRequestTrace?.traceId;
if (result !== undefined) {
const error = result.type === 'failed' ? toKimiErrorPayload(result.error) : undefined;
const durationMs = Date.now() - startedAt;
this.wire.dispatch(endTurn({ turnId: turn.id, reason: result.type, error, durationMs }));
this.eventBus.publish({
type: 'turn.ended',
turnId: turn.id,
reason: result.type,
error,
durationMs: Date.now() - startedAt,
durationMs,
});
if (error !== undefined) this.eventBus.publish({ type: 'error', ...error });
if (result.type !== 'completed') {
Expand Down
17 changes: 16 additions & 1 deletion packages/agent-core-v2/src/agent/loop/turnOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
* identity.
*
* Owns the next available turn id, including cancelled queued reservations and
* legacy loop-event observations.
* legacy loop-event observations. Also persists the terminal `turn.ended`
* record (reason / error / durationMs) so downstream history rebuilds can
* recover how a turn ended; the record carries no engine-restorable state, so
* its `apply` is a no-op.
*/

import { z } from 'zod';

import { defineModel } from '#/wire/model';
import type { KimiErrorPayload } from '#/_base/errors/serialize';
import type { ContentPart } from '#/kosong/contract/message';
import type { PromptOrigin } from '#/agent/contextMemory/types';

Expand Down Expand Up @@ -46,6 +50,7 @@ declare module '#/wire/types' {
'turn.prompt': typeof promptTurn;
'turn.steer': typeof steerTurn;
'turn.cancel': typeof cancelTurn;
'turn.ended': typeof endTurn;
}
}

Expand All @@ -70,6 +75,16 @@ export const cancelTurn = TurnModel.defineOp('turn.cancel', {
},
});

export const endTurn = TurnModel.defineOp('turn.ended', {
schema: z.object({
turnId: z.number(),
reason: z.enum(['completed', 'cancelled', 'failed', 'blocked']),
error: z.custom<KimiErrorPayload>().optional(),
durationMs: z.number().optional(),
}),
apply: (s) => s,
});

function advanceTurnClock(
state: TurnModelState,
nextTurnId: number,
Expand Down
16 changes: 16 additions & 0 deletions packages/agent-core-v2/test/agent/loop/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ describe('Agent loop', () => {
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "think", "think": "<think-1>" } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-3>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "<text-1>" } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "finishReason": "end_turn", "usage": { "inputOther": 3, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "messageId": "mock-1", "providerFinishReason": "completed", "rawFinishReason": "stop" }, "time": "<time>" }
[wire] turn.ended { "turnId": 0, "reason": "completed", "time": "<time>" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
Expand All @@ -88,6 +89,19 @@ describe('Agent loop', () => {
`);
});

it('persists a turn.ended wire record with the end reason and duration', async () => {
profile.update({ activeToolNames: [] });

ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] });
await ctx.untilTurnEnd();

const record = (await ctx.persistedWireRecords()).find((entry) => entry.type === 'turn.ended');
expect(record).toMatchObject({ turnId: 0, reason: 'completed' });
expect(record?.['durationMs']).toEqual(expect.any(Number));
expect(record?.['time']).toEqual(expect.any(Number));
});

it('fails the turn after a filtered step completes', async () => {
ctx.mockNextProviderResponse({
parts: [{ type: 'text', text: 'blocked' }],
Expand Down Expand Up @@ -115,6 +129,7 @@ describe('Agent loop', () => {
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "blocked" } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "finishReason": "filtered", "usage": { "inputOther": 3, "output": 5, "inputCacheRead": 0, "inputCacheCreation": 0 }, "messageId": "mock-1", "providerFinishReason": "filtered", "rawFinishReason": "filtered" }, "time": "<time>" }
[wire] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "provider.filtered", "message": "Provider safety policy blocked the response.", "name": "ProviderFilteredError", "details": { "finishReason": "filtered" }, "retryable": false }, "time": "<time>" }
[emit] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "provider.filtered", "message": "Provider safety policy blocked the response.", "name": "ProviderFilteredError", "details": { "finishReason": "filtered" }, "retryable": false } }
`);

Expand Down Expand Up @@ -368,6 +383,7 @@ describe('Agent loop', () => {
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 2, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-5>", "turnId": "0", "step": 2, "stepUuid": "<uuid-4>", "part": { "type": "text", "text": "The lookup result is lookup-result." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-4>", "turnId": "0", "step": 2, "finishReason": "end_turn", "usage": { "inputOther": 25, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "messageId": "mock-2", "providerFinishReason": "completed", "rawFinishReason": "stop" }, "time": "<time>" }
[wire] turn.ended { "turnId": 0, "reason": "completed", "time": "<time>" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/test/agent/plan/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,7 @@ describe('Plan service', () => {
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 2, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-5>", "turnId": "0", "step": 2, "stepUuid": "<uuid-4>", "part": { "type": "text", "text": "The safe command printed plan-safe." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-4>", "turnId": "0", "step": 2, "finishReason": "end_turn", "usage": { "inputOther": 592, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "messageId": "mock-2", "providerFinishReason": "completed", "rawFinishReason": "stop" }, "time": "<time>" }
[wire] turn.ended { "turnId": 0, "reason": "completed", "time": "<time>" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);

Expand Down Expand Up @@ -843,6 +844,7 @@ describe('Plan service', () => {
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 2, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-5>", "turnId": "0", "step": 2, "stepUuid": "<uuid-4>", "part": { "type": "text", "text": "The command completed." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-4>", "turnId": "0", "step": 2, "finishReason": "end_turn", "usage": { "inputOther": 588, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "messageId": "mock-2", "providerFinishReason": "completed", "rawFinishReason": "stop" }, "time": "<time>" }
[wire] turn.ended { "turnId": 0, "reason": "completed", "time": "<time>" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
expect(toolResultText(context.get())).toContain('removed');
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/test/app/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ describe('Agent config', () => {
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 2, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-5>", "turnId": "0", "step": 2, "stepUuid": "<uuid-4>", "part": { "type": "text", "text": "Still using the original turn config." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-4>", "turnId": "0", "step": 2, "finishReason": "end_turn", "usage": { "inputOther": 31, "output": 13, "inputCacheRead": 0, "inputCacheCreation": 0 }, "messageId": "mock-2", "providerFinishReason": "completed", "rawFinishReason": "stop" }, "time": "<time>" }
[wire] turn.ended { "turnId": 0, "reason": "completed", "time": "<time>" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
Expand Down Expand Up @@ -395,6 +396,7 @@ describe('Agent config', () => {
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 1, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-7>", "turnId": "1", "step": 1, "stepUuid": "<uuid-6>", "part": { "type": "text", "text": "Now the changed config is active." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-6>", "turnId": "1", "step": 1, "finishReason": "end_turn", "usage": { "inputOther": 50, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "messageId": "mock-3", "providerFinishReason": "completed", "rawFinishReason": "stop" }, "time": "<time>" }
[wire] turn.ended { "turnId": 1, "reason": "completed", "time": "<time>" }
[emit] turn.ended { "turnId": 1, "reason": "completed" }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-core-v2/test/harness/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1569,6 +1569,12 @@ export class AgentTestContext {
return this.snapshots.until('turn.ended');
}

/** The agent's persisted wire journal (drains the persistence queue first). */
async persistedWireRecords(): Promise<WireRecord[]> {
await this.drainWirePersistence();
return this.persistedRecords();
}

untilApprovalRequest(): Promise<EventSnapshot> {
return this.snapshots.until('requestApproval');
}
Expand Down
7 changes: 4 additions & 3 deletions packages/agent-core-v2/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,16 @@ const V2_ONLY_RECORD_TYPES: ReadonlySet<string> = new Set([

// Persisted record types introduced after the v1 vocabulary: the task
// lifecycle journal (the restore seed for ghosts and the cold transcript
// fold), the interaction request/resolution journal, and the plan revision
// reference journal. Replay tolerates unknown record types (skip + warn), so
// older readers degrade gracefully.
// fold), the interaction request/resolution journal, the plan revision
// reference journal, and the terminal turn record. Replay tolerates unknown
// record types (skip + warn), so older readers degrade gracefully.
const V2_RECORD_TYPES: ReadonlySet<string> = new Set([
'task.started',
'task.terminated',
'interaction.request',
'interaction.resolved',
'plan.revision',
'turn.ended',
]);

describe('v1 wire vocabulary', () => {
Expand Down
8 changes: 6 additions & 2 deletions packages/agent-core-v2/test/snapshot/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,13 @@ export function recordAgentEvents() {
const emit = (entry: RecordedEventEntry): RecordedEventEntry => {
entries.push(entry);

// Snapshot-returning waiters match EMIT entries only: a persisted wire
// record can share its type with an event (e.g. `turn.ended`), and the
// waiter's intent is the event — resolving on the wire entry would also
// truncate the matching emit entry out of the returned snapshot.
for (let index = eventWaiters.length - 1; index >= 0; index -= 1) {
const waiter = eventWaiters[index]!;
if (waiter.event !== entry.event) continue;
if (entry.type !== '[rpc]' || waiter.event !== entry.event) continue;
eventWaiters.splice(index, 1);
cursor = Math.max(cursor, entries.length);
waiter.resolve(snapshotFrom(waiter.start));
Expand All @@ -79,7 +83,7 @@ export function recordAgentEvents() {

for (let index = takeWaiters.length - 1; index >= 0; index -= 1) {
const waiter = takeWaiters[index]!;
if (waiter.event !== entry.event) continue;
if (entry.type !== '[rpc]' || waiter.event !== entry.event) continue;
takeWaiters.splice(index, 1);
cursor = Math.max(cursor, entries.length);
waiter.resolve({
Expand Down
Loading
Loading