From ce0cfea89ea99912d0f7fd0466a75ad057d725ff Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 31 Jul 2026 15:41:15 +0800 Subject: [PATCH 1/3] feat(agent-core-v2): persist the terminal turn.ended wire record - add a persisted turn.ended op (turnId, reason, error, durationMs) dispatched from the loop's runTurn finally block, alongside the event - fold the record back in the transcript cold rebuild: terminal state (blocked folded into failed, mirroring the live wire edge), durationMs, error message and endedAt; journals without the record keep the grouping default - restrict the test harness's snapshot waiters to emit entries so the same-named wire record no longer shadows the turn.ended event --- .../agent-core-v2/docs/wire-manifest.d.ts | 59 +++++++++++++- .../src/agent/loop/loopService.ts | 6 +- .../agent-core-v2/src/agent/loop/turnOps.ts | 17 ++++- .../test/agent/loop/loop.test.ts | 16 ++++ .../test/agent/plan/plan.test.ts | 2 + .../test/app/config/config.test.ts | 2 + packages/agent-core-v2/test/harness/agent.ts | 6 ++ packages/agent-core-v2/test/index.test.ts | 7 +- .../agent-core-v2/test/snapshot/events.ts | 8 +- packages/agent-core-v2/test/tool/tool.test.ts | 2 + packages/transcript/src/history/foldFacts.ts | 76 +++++++++++++++++-- packages/transcript/src/history/groupTurns.ts | 7 +- packages/transcript/test/layers.test.ts | 71 +++++++++++++++++ 13 files changed, 261 insertions(+), 18 deletions(-) diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 62a029c96a..9f79e4be23 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -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 @@ -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 @@ -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>; + retryable: boolean; + cause?: { + code: (typeof ErrorCodes)[keyof typeof ErrorCodes]; + message: string; + name?: string; + details?: Readonly>; + retryable: boolean; + cause?: { + code: (typeof ErrorCodes)[keyof typeof ErrorCodes]; + message: string; + name?: string; + details?: Readonly>; + retryable: boolean; + cause?: { + code: (typeof ErrorCodes)[keyof typeof ErrorCodes]; + message: string; + name?: string; + details?: Readonly>; + retryable: boolean; + cause?: { + code: (typeof ErrorCodes)[keyof typeof ErrorCodes]; + message: string; + name?: string; + details?: Readonly>; + retryable: boolean; + cause?: { + code: ErrorCode; + message: string; + name?: string; + details?: Readonly>; + retryable: boolean; + cause?: ErrorPayload; + }; + }; + }; + }; + }; + }; + durationMs?: number; +} + /** * model: turn · persisted * owner: src/agent/loop/turnOps.ts @@ -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; diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index ab568cef23..110e1c76f5 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -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'; @@ -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') { diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index 2a5c0dd236..4082a3421a 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -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'; @@ -46,6 +50,7 @@ declare module '#/wire/types' { 'turn.prompt': typeof promptTurn; 'turn.steer': typeof steerTurn; 'turn.cancel': typeof cancelTurn; + 'turn.ended': typeof endTurn; } } @@ -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().optional(), + durationMs: z.number().optional(), + }), + apply: (s) => s, +}); + function advanceTurnClock( state: TurnModelState, nextTurnId: number, diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 9d0242ec1d..89bddbe559 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -78,6 +78,7 @@ describe('Agent loop', () => { [wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "", "turnId": "0", "step": 1, "stepUuid": "", "part": { "type": "think", "think": "" } }, "time": "