From 26d499bca7c25329dcea24b9ac99b6f798dfe989 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 14 Jul 2026 19:51:29 +0800 Subject: [PATCH] refactor(agent-core-v2): consolidate wire services (#1680) --- apps/vis/server/src/lib/session-store.ts | 20 +- apps/vis/server/test/fixtures/build.ts | 13 +- .../vis/server/test/lib/session-store.test.ts | 47 ++ .../scripts/check-domain-layers.mjs | 11 +- .../agent-core-v2/src/activity/activity.ts | 25 +- .../agent-core-v2/src/activity/activityOps.ts | 74 +--- .../src/activity/agentActivityService.ts | 307 ++++++++++--- .../contextInjector/contextInjectorService.ts | 22 +- .../contextMemory/contextMemoryService.ts | 16 +- .../src/agent/contextMemory/contextOps.ts | 6 +- .../agent/contextMemory/contextTranscript.ts | 34 +- .../src/agent/contextMemory/loopEventFold.ts | 2 +- .../agent/contextSize/contextSizeService.ts | 5 +- .../src/agent/fullCompaction/compactionOps.ts | 6 +- .../fullCompaction/fullCompactionService.ts | 14 +- .../agent-core-v2/src/agent/goal/goalOps.ts | 4 +- .../src/agent/goal/goalService.ts | 50 ++- .../agent/llmRequester/llmRequesterService.ts | 5 +- .../src/agent/loop/loopService.ts | 5 +- .../agent-core-v2/src/agent/mcp/mcpService.ts | 13 +- .../agent/permissionMode/permissionModeOps.ts | 14 +- .../permissionMode/permissionModeService.ts | 27 +- .../permissionRules/permissionRulesService.ts | 5 +- .../src/agent/plan/planService.ts | 12 +- .../src/agent/profile/profileService.ts | 5 +- .../src/agent/prompt/promptService.ts | 5 +- .../replayBuilder/replayTimelineModel.ts | 107 ----- .../src/agent/runtime/runtime.ts | 86 ---- .../src/agent/runtime/runtimeOps.ts | 140 ------ .../src/agent/runtime/runtimeService.ts | 345 --------------- .../src/agent/skill/skillService.ts | 5 +- .../src/agent/swarm/swarmService.ts | 5 +- .../agent-core-v2/src/agent/task/taskOps.ts | 2 +- .../src/agent/task/taskService.ts | 64 ++- .../agent-core-v2/src/agent/usage/usageOps.ts | 2 +- .../src/agent/usage/usageService.ts | 5 +- .../src/agent/userTool/userToolOps.ts | 5 +- .../src/agent/userTool/userToolService.ts | 17 +- .../src/agent/wireRecord/agentWireService.ts | 44 -- .../src/agent/wireRecord/errors.ts | 13 - .../src/agent/wireRecord/metadataOps.ts | 43 -- .../src/agent/wireRecord/wireRecord.ts | 49 --- .../src/agent/wireRecord/wireRecordService.ts | 193 --------- .../agent-core-v2/src/app/event/eventBus.ts | 3 +- .../src/app/messageLegacy/messageLegacy.ts | 14 +- .../app/messageLegacy/messageLegacyService.ts | 35 +- .../src/app/sessionExport/manifest.ts | 4 +- .../app/sessionExport/sessionExportService.ts | 4 +- .../sessionLifecycleService.ts | 80 ++-- packages/agent-core-v2/src/errors.ts | 3 - packages/agent-core-v2/src/index.ts | 15 +- .../backends/node-fs/fileStorageService.ts | 2 +- .../agentLifecycle/agentLifecycleService.ts | 36 +- .../session/cron/sessionCronServiceImpl.ts | 27 +- .../session/sessionInit/sessionInitService.ts | 6 +- .../sessionMetadata/sessionMetadata.ts | 4 +- .../src/session/todo/sessionTodoService.ts | 37 +- .../agent-core-v2/src/session/todo/todoOps.ts | 8 +- packages/agent-core-v2/src/wire/errors.ts | 8 +- .../migration/migration.ts | 15 +- .../wireRecord => wire}/migration/v1.1.ts | 0 .../wireRecord => wire}/migration/v1.2.ts | 0 .../wireRecord => wire}/migration/v1.3.ts | 0 .../wireRecord => wire}/migration/v1.4.ts | 0 packages/agent-core-v2/src/wire/model.ts | 25 +- packages/agent-core-v2/src/wire/op.ts | 12 +- packages/agent-core-v2/src/wire/record.ts | 59 +++ packages/agent-core-v2/src/wire/tokens.ts | 20 - packages/agent-core-v2/src/wire/wire.ts | 36 ++ .../agent-core-v2/src/wire/wireService.ts | 350 ++++++++++++--- .../agent-core-v2/src/wire/wireServiceImpl.ts | 403 ------------------ .../test/activity/activity.test.ts | 218 ++++++++-- .../contextInjector/contextInjector.test.ts | 4 +- .../test/agent/contextMemory/context.test.ts | 5 +- .../contextMemory/contextTranscript.test.ts | 12 +- .../contextMemory/message-history.test.ts | 9 +- .../agent/contextMemory/splice-replay.test.ts | 85 ++-- .../test/agent/contextMemory/stubs.ts | 17 +- .../fullCompaction/compactionOps.test.ts | 54 ++- .../test/agent/goal/goal.test.ts | 18 +- .../test/agent/goal/goalOps.test.ts | 63 +-- .../goal/injection/goalInjection.test.ts | 2 +- .../llmRequester/llmRequesterService.test.ts | 22 +- .../test/agent/loop/loop.test.ts | 37 +- .../agent-core-v2/test/agent/loop/stubs.ts | 4 +- .../agent-core-v2/test/agent/mcp/mcp.test.ts | 81 ++-- .../permissionMode/permissionMode.test.ts | 49 ++- .../permissionRules/permissionRules.test.ts | 42 +- .../test/agent/plan/plan.test.ts | 36 +- .../test/agent/plan/planOps.test.ts | 43 +- .../test/agent/profile/binding.test.ts | 6 +- .../test/agent/profile/profileOps.test.ts | 73 +++- .../test/agent/prompt/promptService.test.ts | 4 +- .../test/agent/runtime/runtime.test.ts | 228 ---------- .../test/agent/skill/skill.test.ts | 13 +- .../test/agent/swarm/swarm.test.ts | 39 +- .../test/agent/task/taskOps.test.ts | 40 +- .../test/agent/task/taskService.test.ts | 52 +-- .../test/agent/toolDedupe/toolDedupe.test.ts | 12 +- .../agent/toolExecutor/toolExecutor.test.ts | 11 +- .../test/agent/usage/usage.test.ts | 65 ++- .../test/agent/userTool/userTool.test.ts | 53 +-- .../test/app/config/config.test.ts | 30 +- .../externalHooksRunner/integration.test.ts | 13 +- .../app/messageLegacy/messageLegacy.test.ts | 28 +- .../app/sessionExport/sessionExport.test.ts | 17 +- .../sessionLifecycle/sessionLifecycle.test.ts | 8 +- .../skillCatalog/plugin-session-start.test.ts | 5 +- packages/agent-core-v2/test/harness/agent.ts | 185 ++++---- .../agent-core-v2/test/harness/snapshots.ts | 11 +- packages/agent-core-v2/test/index.test.ts | 134 ++---- .../agentLifecycle/agentLifecycle.test.ts | 71 ++- .../session/sessionInit/sessionInit.test.ts | 4 +- .../sessionMetadata/sessionMetadata.test.ts | 2 - .../test/session/swarm/sessionSwarm.test.ts | 11 - .../test/session/todo/sessionTodo.test.ts | 79 +--- .../agent-core-v2/test/snapshot/events.ts | 4 +- packages/agent-core-v2/test/tool/tool.test.ts | 50 ++- .../migration/migration.test.ts | 2 +- .../wireRecord => wire}/migration/utils.ts | 4 +- .../migration/v1.1.test.ts | 2 +- .../migration/v1.2.test.ts | 2 +- .../migration/v1.4.test.ts | 2 +- .../wireRecord => wire}/persistence.test.ts | 75 ++-- .../{agent/wireRecord => wire}/resume.test.ts | 96 ++--- .../test/wire/store-event.test.ts | 33 +- packages/agent-core-v2/test/wire/stubs.ts | 127 ++++++ .../test/wire/wire-compat.test.ts | 33 +- ...erviceImpl.test.ts => wireService.test.ts} | 182 +++++--- packages/agent-core/src/session/index.ts | 12 +- packages/agent-core/test/session/init.test.ts | 44 ++ .../src/services/legacyStatus/legacyStatus.ts | 88 ++-- .../ws/v1/sessionEventBroadcaster.ts | 84 ++-- packages/kap-server/test/messages.test.ts | 6 +- .../test/sessionEventBroadcaster.test.ts | 100 ++++- 135 files changed, 2697 insertions(+), 3322 deletions(-) delete mode 100644 packages/agent-core-v2/src/agent/replayBuilder/replayTimelineModel.ts delete mode 100644 packages/agent-core-v2/src/agent/runtime/runtime.ts delete mode 100644 packages/agent-core-v2/src/agent/runtime/runtimeOps.ts delete mode 100644 packages/agent-core-v2/src/agent/runtime/runtimeService.ts delete mode 100644 packages/agent-core-v2/src/agent/wireRecord/agentWireService.ts delete mode 100644 packages/agent-core-v2/src/agent/wireRecord/errors.ts delete mode 100644 packages/agent-core-v2/src/agent/wireRecord/metadataOps.ts delete mode 100644 packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts delete mode 100644 packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts rename packages/agent-core-v2/src/{agent/wireRecord => wire}/migration/migration.ts (86%) rename packages/agent-core-v2/src/{agent/wireRecord => wire}/migration/v1.1.ts (100%) rename packages/agent-core-v2/src/{agent/wireRecord => wire}/migration/v1.2.ts (100%) rename packages/agent-core-v2/src/{agent/wireRecord => wire}/migration/v1.3.ts (100%) rename packages/agent-core-v2/src/{agent/wireRecord => wire}/migration/v1.4.ts (100%) create mode 100644 packages/agent-core-v2/src/wire/record.ts delete mode 100644 packages/agent-core-v2/src/wire/tokens.ts create mode 100644 packages/agent-core-v2/src/wire/wire.ts delete mode 100644 packages/agent-core-v2/src/wire/wireServiceImpl.ts delete mode 100644 packages/agent-core-v2/test/agent/runtime/runtime.test.ts rename packages/agent-core-v2/test/{agent/wireRecord => wire}/migration/migration.test.ts (93%) rename packages/agent-core-v2/test/{agent/wireRecord => wire}/migration/utils.ts (93%) rename packages/agent-core-v2/test/{agent/wireRecord => wire}/migration/v1.1.test.ts (97%) rename packages/agent-core-v2/test/{agent/wireRecord => wire}/migration/v1.2.test.ts (98%) rename packages/agent-core-v2/test/{agent/wireRecord => wire}/migration/v1.4.test.ts (96%) rename packages/agent-core-v2/test/{agent/wireRecord => wire}/persistence.test.ts (84%) rename packages/agent-core-v2/test/{agent/wireRecord => wire}/resume.test.ts (92%) create mode 100644 packages/agent-core-v2/test/wire/stubs.ts rename packages/agent-core-v2/test/wire/{wireServiceImpl.test.ts => wireService.test.ts} (53%) diff --git a/apps/vis/server/src/lib/session-store.ts b/apps/vis/server/src/lib/session-store.ts index f10f7ad9d..fc74fa94c 100644 --- a/apps/vis/server/src/lib/session-store.ts +++ b/apps/vis/server/src/lib/session-store.ts @@ -20,15 +20,15 @@ export function isSafeAgentId(id: string): boolean { } interface StateJson { - createdAt?: string; - updatedAt?: string; + createdAt?: string | number; + updatedAt?: string | number; title?: string; isCustomTitle?: boolean; lastPrompt?: string; // Agent metadata comes from an untrusted state.json (a corrupt or imported // bundle may hold non-object entries like `{ "main": null }`), so the value // type allows null and inventoryAgents skips anything that isn't an object. - agents?: Record; + agents?: Record; custom?: Record; } @@ -103,7 +103,7 @@ async function readImportedDetail(home: string, importId: string): Promise { +async function inventoryAgents(sessionDir: string, state: StateJson): Promise { const result: AgentInfo[] = []; for (const [id, meta] of Object.entries(state.agents ?? {})) { if (!isSafeAgentId(id)) continue; @@ -280,11 +280,8 @@ async function inventoryAgents(sessionDir: string, state: StateJson, deriveHomed result.push({ agentId: id, type: meta.type, - parentAgentId: meta.parentAgentId, - // For imported bundles the persisted homedir is the exporting machine's - // absolute path; re-derive it from the local extraction so blob reads - // (which join homedir) resolve under the imported directory. - homedir: deriveHomedir ? join(sessionDir, 'agents', id) : meta.homedir, + parentAgentId: meta.parentAgentId ?? null, + homedir: join(sessionDir, 'agents', id), wireExists: readable, wireRecordCount: info.count, wireProtocolVersion: info.protocolVersion, @@ -360,7 +357,8 @@ async function scanWire(path: string): Promise<{ count: number; protocolVersion: return { count, protocolVersion }; } -function parseTs(input: string | undefined): number { +function parseTs(input: string | number | undefined): number { + if (typeof input === 'number') return Number.isFinite(input) ? input : 0; if (!input) return 0; const n = Date.parse(input); return Number.isFinite(n) ? n : 0; diff --git a/apps/vis/server/test/fixtures/build.ts b/apps/vis/server/test/fixtures/build.ts index e0149c0bd..03bc97631 100644 --- a/apps/vis/server/test/fixtures/build.ts +++ b/apps/vis/server/test/fixtures/build.ts @@ -1,9 +1,8 @@ -import { cp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'; +import { cp, mkdir, writeFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -/** Copy a fixture session into a temp dir, rewriting state.json.agents.*.homedir - * to the real path so wire-reader / agent-tree can resolve them. */ +/** Copy a fixture session into a temporary KIMI_CODE_HOME. */ export async function buildSessionFixture(name: string): Promise<{ home: string; sessionDir: string; @@ -16,14 +15,6 @@ export async function buildSessionFixture(name: string): Promise<{ await mkdir(sessionsDir, { recursive: true }); await cp(src, sessionDir, { recursive: true }); - // Rewrite homedir placeholders. - const statePath = join(sessionDir, 'state.json'); - const state = JSON.parse(await readFile(statePath, 'utf8')); - for (const id of Object.keys(state.agents)) { - state.agents[id].homedir = join(sessionDir, 'agents', id); - } - await writeFile(statePath, JSON.stringify(state, null, 2)); - // Write session_index.jsonl. await writeFile( join(home, 'session_index.jsonl'), diff --git a/apps/vis/server/test/lib/session-store.test.ts b/apps/vis/server/test/lib/session-store.test.ts index b01caaa05..c6c596459 100644 --- a/apps/vis/server/test/lib/session-store.test.ts +++ b/apps/vis/server/test/lib/session-store.test.ts @@ -234,6 +234,53 @@ describe('session-store', () => { expect(sub.parentAgentId).toBe('main'); }); + it('ignores persisted agent homedirs and uses the standard paths', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const { readFile, writeFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const statePath = join(sessionDir, 'state.json'); + const state = JSON.parse(await readFile(statePath, 'utf8')); + delete state.agents.main.parentAgentId; + await writeFile(statePath, JSON.stringify(state)); + + const detail = await readSessionDetail(home, 'session_fixture'); + + expect( + detail!.agents + .map(({ agentId, homedir, parentAgentId }) => ({ agentId, homedir, parentAgentId })) + .toSorted((a, b) => a.agentId.localeCompare(b.agentId)), + ).toEqual([ + { + agentId: 'agent-0', + homedir: join(sessionDir, 'agents', 'agent-0'), + parentAgentId: 'main', + }, + { + agentId: 'main', + homedir: join(sessionDir, 'agents', 'main'), + parentAgentId: null, + }, + ]); + }); + + it('reads v2 epoch millisecond timestamps', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const { readFile, writeFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const statePath = join(sessionDir, 'state.json'); + const state = JSON.parse(await readFile(statePath, 'utf8')); + state.createdAt = 1_784_012_345_678; + state.updatedAt = 1_784_023_456_789; + await writeFile(statePath, JSON.stringify(state)); + + const [summary] = await listSessions(home); + + expect(summary!.createdAt).toBe(state.createdAt); + expect(summary!.updatedAt).toBe(state.updatedAt); + }); + it('surfaces swarmItem from state.json onto AgentInfo (null when absent)', async () => { const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index ccff07317..8e558eb70 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -94,10 +94,9 @@ const DOMAIN_LAYER = new Map([ ['os/backends', 6], // L2 — data & cross-cutting capabilities ['records', 2], - ['wireRecord', 2], - // `wire` is the scope-agnostic Model/Op/Signal state-machine layer: it - // consumes `persistence/interface` (L1) and is consumed by the scope tiers, - // so it sits in L2 beside the other data/cross-cutting layers. + // `wire` owns the Agent-scoped replayable-state aggregate plus its pure + // Model/Op/record/migration language. It consumes only L1 infrastructure + // and same-layer blob storage, and is consumed by the scope tiers. ['wire', 2], ['blob', 2], ['file', 2], @@ -316,7 +315,6 @@ const ALLOWED_EXCEPTIONS = new Set([ 'cron>agentLifecycle', 'cron>sessionContext', 'todo>agentLifecycle', - 'wireRecord>hooks', // L3/L4 type-sharing: tool contract + execution hook contexts now live in // `tool`; the remaining upward import is a `loop` error/event helper. 'contextMemory>agentTask', @@ -345,9 +343,6 @@ const ALLOWED_EXCEPTIONS = new Set([ 'btw>agentLifecycle', 'toolExecutor>loop', 'userTool>profile', - 'wireRecord>contextMemory', - 'wireRecord>loop', - 'wireRecord>tool', 'hostFolderBrowser>os/backends', 'filestore>persistence/backends', 'process>os/backends', diff --git a/packages/agent-core-v2/src/activity/activity.ts b/packages/agent-core-v2/src/activity/activity.ts index 0026e9a09..b8e9d23c9 100644 --- a/packages/agent-core-v2/src/activity/activity.ts +++ b/packages/agent-core-v2/src/activity/activity.ts @@ -2,15 +2,16 @@ * `activity` domain (L4) — Agent / Session activity kernel contracts. * * Defines the authoritative activity state machines shared by the Agent and - * Session scopes. `IAgentActivityService` is the Agent-scope lane machine: it + * Session scopes. `IAgentActivityService` is the Agent-scope activity machine: it * owns turn admission (`begin`/`tryBegin`), cancellation, background-activity - * registration and disposal settlement, and is the sole dispatcher of the - * `activityLane` wire Model (`activityOps`). `ISessionActivityKernel` is the + * registration, disposal settlement, and the live activity projection emitted + * as `agent.activity.updated`. `ISessionActivityKernel` is the * Session-scope lifecycle lane + admission table that the Agent kernel consults * synchronously on every `begin` (child-injects-parent), so admission stays * atomic inside a single event-loop turn. The `ActivityLease` returned by - * `begin` carries the turn's `AbortSignal` and is the only path back to `idle` - * (`lease.end`). Multi-scope domain: `IAgentActivityService` bound at Agent + * `begin` carries the turn's `AbortSignal`; `lease.end` releases the active + * turn independently of the Agent lifecycle. Multi-scope domain: + * `IAgentActivityService` bound at Agent * scope, `ISessionActivityKernel` bound at Session scope. */ @@ -19,7 +20,7 @@ import type { IDisposable } from '#/_base/di/lifecycle'; import type { PromptOrigin } from '#/agent/contextMemory/types'; import type { TurnEndReason } from '@moonshot-ai/protocol'; -export type AgentLane = 'initializing' | 'idle' | 'turn' | 'disposing' | 'disposed'; +export type AgentLifecycleState = 'initializing' | 'ready' | 'disposing' | 'disposed'; export interface BeginOptions { readonly origin?: PromptOrigin; @@ -45,7 +46,7 @@ export interface BackgroundActivityRef { export interface IAgentActivityService { readonly _serviceBrand: undefined; - lane(): AgentLane; + isIdle(): boolean; begin(kind: 'turn', opts?: BeginOptions): ActivityLease; @@ -144,9 +145,15 @@ export interface ActivityLastTurnState { readonly at: number; } -export interface AgentActivitySnapshot { - readonly lane: AgentLane; +export interface AgentActivityState { + readonly lifecycle: AgentLifecycleState; readonly turn?: ActivityTurnState; readonly lastTurn?: ActivityLastTurnState; readonly background: readonly BackgroundActivityRef[]; } + +declare module '#/app/event/eventBus' { + interface DomainEventMap { + 'agent.activity.updated': AgentActivityState & { readonly type: 'agent.activity.updated' }; + } +} diff --git a/packages/agent-core-v2/src/activity/activityOps.ts b/packages/agent-core-v2/src/activity/activityOps.ts index f7c7dc43c..63dce783c 100644 --- a/packages/agent-core-v2/src/activity/activityOps.ts +++ b/packages/agent-core-v2/src/activity/activityOps.ts @@ -1,87 +1,23 @@ /** - * `activity` domain (L4) — wire Model (`LaneModel`) and the `activity.set_lane` - * Op that holds the Agent activity lane. + * `activity` domain (L4) — Session activity lane wire state. * - * The lane is a live-only Model (`persist: false`): nothing is persisted or - * replayed, so a resumed agent starts back at `idle`. The Agent kernel - * (`agentActivityService`) is the sole dispatcher of `setLane`; `apply` returns - * the SAME reference when the incoming state is unchanged under `laneEqual` - * (which ignores the `since` / `at` timestamps) so redundant dispatches do not - * flood subscribers. The Op derives no event here — the outward snapshot event - * is emitted by the projector so there is a single event source (PR5). The - * initial lane is `idle` (fresh agents accept turns immediately); the - * half-replay window is gated at the Session kernel (`restoring`), not here. - * Consumed by the Agent-scope `agentActivityService` and (PR5) the projector. + * The Session kernel projects its live lane and active lease count into the + * non-persisted `SessionLaneModel`. Agent activity state is owned directly by + * `IAgentActivityService` and is not duplicated in wire state. */ import { z } from 'zod'; import { defineModel } from '#/wire/model'; -import type { PromptOrigin } from '#/agent/contextMemory/types'; -import type { AgentLane, BackgroundActivityRef, SessionLane } from './activity'; - -export interface LaneTurnState { - readonly turnId: number; - readonly origin: PromptOrigin; - readonly ending: boolean; - readonly endingReason?: 'aborted' | 'max_steps' | 'error'; - readonly since: number; -} - -export interface LaneLastTurnState { - readonly turnId: number; - readonly reason: 'completed' | 'cancelled' | 'failed'; - readonly at: number; -} - -export interface LaneModelState { - readonly lane: AgentLane; - readonly turn?: LaneTurnState; - readonly lastTurn?: LaneLastTurnState; - readonly background: readonly BackgroundActivityRef[]; -} - -export const LaneModel = defineModel('activityLane', () => ({ - lane: 'idle', - background: [], -})); +import type { SessionLane } from './activity'; declare module '#/wire/types' { interface TransientOpMap { - 'activity.set_lane': typeof setLane; 'activity.set_session_lane': typeof setSessionLane; } } -export const setLane = LaneModel.defineOp('activity.set_lane', { - schema: z.object({ next: z.custom() }), - persist: false, - apply: (s, p) => (laneEqual(s, p.next) ? s : p.next), -}); - -export function laneEqual(a: LaneModelState, b: LaneModelState): boolean { - if (a.lane !== b.lane) return false; - if (a.background.length !== b.background.length) return false; - if ((a.turn === undefined) !== (b.turn === undefined)) return false; - if (a.turn !== undefined && b.turn !== undefined) { - if ( - a.turn.turnId !== b.turn.turnId || - a.turn.ending !== b.turn.ending || - a.turn.endingReason !== b.turn.endingReason - ) { - return false; - } - } - if ((a.lastTurn === undefined) !== (b.lastTurn === undefined)) return false; - if (a.lastTurn !== undefined && b.lastTurn !== undefined) { - if (a.lastTurn.turnId !== b.lastTurn.turnId || a.lastTurn.reason !== b.lastTurn.reason) { - return false; - } - } - return true; -} - export interface SessionLaneModelState { readonly lane: SessionLane; readonly activeLeases: number; diff --git a/packages/agent-core-v2/src/activity/agentActivityService.ts b/packages/agent-core-v2/src/activity/agentActivityService.ts index f3012ee00..2444f6593 100644 --- a/packages/agent-core-v2/src/activity/agentActivityService.ts +++ b/packages/agent-core-v2/src/activity/agentActivityService.ts @@ -1,15 +1,16 @@ /** * `activity` domain (L4) — `IAgentActivityService` implementation. * - * Owns the Agent activity lane (`idle ⇄ turn(active|ending)`, plus `disposing` - * / `disposed`) and is the sole dispatcher of the `activityLane` wire Model - * (`activity.set_lane`). `begin('turn')` atomically consults the Session kernel + * Owns the Agent lifecycle (`initializing → ready → disposing → disposed`) and + * its independent active turn, then projects lifecycle, turn, stream, retry, + * approval, tool-call and background state onto `agent.activity.updated`. + * `begin('turn')` atomically consults the Session kernel * (`ISessionActivityKernel.admitTurn`, child-injects-parent), reads the next - * turn id from the `turn` `TurnModel`, enters the turn lane and returns an + * turn id from the `turn` `TurnModel`, records the active turn and returns an * `ActivityLease`; the lease's `AbortSignal` is the only cancellation channel, * and `lease.end()` is the only path back to `idle`. Background activities * (`registerBackground`) are tracked so disposal can abort and await them. The - * lane starts at `initializing` and is driven to `idle` by `markReady()` once + * lifecycle starts at `initializing` and is driven to `ready` by `markReady()` once * the agent bootstrap (`agentLifecycle.create`) finishes; until then `begin` * rejects with `activity.initializing`. The half-replay window on resume is * gated by the Session kernel (`restoring`). Bound at Agent scope. @@ -19,25 +20,33 @@ import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { userCancellationReason } from '#/_base/utils/abort'; +import { IEventBus } from '#/app/event/eventBus'; import { ErrorCodes, Error2 } from '#/errors'; import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types'; import type { PromptOrigin } from '#/agent/contextMemory/types'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { TurnModel } from '#/agent/loop/turnOps'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import type { + ActivityRetryState, ActivityLease, - AgentLane, + ActivityLastTurnState, + ActivityTurnState, + AgentActivityState, + AgentLifecycleState, + ApprovalRef, BackgroundActivityRef, BeginOptions, + ToolCallRef, + TurnPhase, } from './activity'; import { IAgentActivityService, ISessionActivityKernel } from './activity'; -import { type LaneLastTurnState, setLane } from './activityOps'; let nextBackgroundId = 0; +type ActivityEndingReason = NonNullable; + interface BackgroundEntry { readonly ref: BackgroundActivityRef; readonly controller: AbortController; @@ -51,7 +60,7 @@ class LeaseImpl implements ActivityLease { private readonly controller = new AbortController(); private _ending = false; private _ended = false; - private _endingReason: 'aborted' | 'max_steps' | 'error' | undefined; + private _endingReason: ActivityEndingReason | undefined; registration: IDisposable = Disposable.None; constructor( @@ -72,7 +81,7 @@ class LeaseImpl implements ActivityLease { return this._ending; } - get endingReason(): 'aborted' | 'max_steps' | 'error' | undefined { + get endingReason(): ActivityEndingReason | undefined { return this._endingReason; } @@ -83,6 +92,12 @@ class LeaseImpl implements ActivityLease { this.controller.abort(reason ?? userCancellationReason()); } + markInterrupted(reason: ActivityEndingReason): void { + if (this._ending || this._ended) return; + this._ending = true; + this._endingReason = reason; + } + end(outcome: 'completed' | 'cancelled' | 'failed', detail?: { error?: unknown }): void { if (this._ended) return; this._ended = true; @@ -96,44 +111,110 @@ class LeaseImpl implements ActivityLease { export class AgentActivityService extends Disposable implements IAgentActivityService { declare readonly _serviceBrand: undefined; - private _lane: AgentLane = 'initializing'; + private _lifecycle: AgentLifecycleState = 'initializing'; + private _step = 0; + private _phase: TurnPhase = 'running'; + private _stream: 'assistant' | 'thinking' | 'tool_call' | undefined; + private _retry: ActivityRetryState | undefined; + private _currentState: AgentActivityState = { lifecycle: 'initializing', background: [] }; private activeLease: LeaseImpl | undefined; - private lastTurn: LaneLastTurnState | undefined; + private lastTurn: ActivityLastTurnState | undefined; private readonly background = new Map(); + private readonly pendingApprovals = new Map(); + private readonly activeToolCalls = new Map(); private readonly settleWaiters: Array<() => void> = []; constructor( - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @ISessionActivityKernel private readonly sessionKernel: ISessionActivityKernel, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IEventBus private readonly eventBus: IEventBus, ) { super(); + this._register( + this.eventBus.subscribe('turn.step.started', (e) => this.onStepStarted(e.step)), + ); + this._register( + this.eventBus.subscribe('assistant.delta', () => this.onDelta('assistant')), + ); + this._register( + this.eventBus.subscribe('thinking.delta', () => this.onDelta('thinking')), + ); + this._register( + this.eventBus.subscribe('tool.call.delta', () => this.onDelta('tool_call')), + ); + this._register( + this.eventBus.subscribe('tool.call.started', (e) => + this.onToolCallStarted(e.toolCallId, e.name), + ), + ); + this._register( + this.eventBus.subscribe('tool.result', (e) => this.onToolResult(e.toolCallId)), + ); + this._register( + this.eventBus.subscribe('turn.step.retrying', (e) => { + this._phase = 'retrying'; + this._stream = undefined; + this._retry = { + failedAttempt: e.failedAttempt, + nextAttempt: e.nextAttempt, + maxAttempts: e.maxAttempts, + delayMs: e.delayMs, + errorName: e.errorName, + statusCode: e.statusCode, + }; + this.publishActivity(); + }), + ); + this._register( + this.eventBus.subscribe('turn.step.completed', () => { + this.resetStepState(); + this.publishActivity(); + }), + ); + this._register( + this.eventBus.subscribe('turn.step.interrupted', (e) => + this.onStepInterrupted(e.turnId, e.reason), + ), + ); + this._register(this.eventBus.subscribe('turn.ended', () => this.resetTurnState())); + this._register( + this.eventBus.subscribe('permission.approval.requested', (e) => + this.onApprovalRequested(e.toolCallId), + ), + ); + this._register( + this.eventBus.subscribe('permission.approval.resolved', (e) => + this.onApprovalResolved(e.toolCallId), + ), + ); } - lane(): AgentLane { - return this._lane; + isIdle(): boolean { + return this._lifecycle === 'ready' && this.activeLease === undefined; } begin(kind: 'turn', opts?: BeginOptions): ActivityLease { if (kind !== 'turn') { throw new Error2(ErrorCodes.NOT_IMPLEMENTED, `Unsupported activity kind: ${String(kind)}`); } - switch (this._lane) { - case 'turn': - throw new Error2( - ErrorCodes.ACTIVITY_AGENT_BUSY, - `Cannot begin a new turn while turn ${this.activeLease?.turnId ?? '?'} is active`, - { details: { turnId: this.activeLease?.turnId } }, - ); + switch (this._lifecycle) { case 'disposing': throw new Error2(ErrorCodes.ACTIVITY_DISPOSING, 'Agent is disposing'); case 'disposed': throw new Error2(ErrorCodes.ACTIVITY_DISPOSED, 'Agent is disposed'); case 'initializing': throw new Error2(ErrorCodes.ACTIVITY_INITIALIZING, 'Agent is still restoring'); - case 'idle': + case 'ready': break; } + if (this.activeLease !== undefined) { + throw new Error2( + ErrorCodes.ACTIVITY_AGENT_BUSY, + `Cannot begin a new turn while turn ${this.activeLease.turnId} is active`, + { details: { turnId: this.activeLease.turnId } }, + ); + } const turnId = opts?.turnId ?? this.wire.getModel(TurnModel).nextTurnId; const origin = opts?.origin ?? USER_PROMPT_ORIGIN; @@ -141,8 +222,7 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe lease.registration = this.sessionKernel.admitTurn(this.scopeContext.agentId, lease); this.activeLease = lease; - this._lane = 'turn'; - this.publishLane(); + this.publishActivity(); return lease; } @@ -156,9 +236,9 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe } markReady(): void { - if (this._lane !== 'initializing') return; - this._lane = 'idle'; - this.publishLane(); + if (this._lifecycle !== 'initializing') return; + this._lifecycle = 'ready'; + this.publishActivity(); } cancel(reason?: unknown): boolean { @@ -166,7 +246,7 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe if (lease === undefined) return false; if (lease.ending) return true; lease.markEnding(reason); - this.publishLane(); + this.publishActivity(); return true; } @@ -179,10 +259,10 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe signal: controller.signal, }; this.background.set(id, { ref, controller }); - this.publishLane(); + this.publishActivity(); const dispose = (): void => { if (this.background.delete(id)) { - this.publishLane(); + this.publishActivity(); } this.maybeSettle(); }; @@ -190,20 +270,20 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe } beginDisposal(): void { - if (this._lane === 'disposing' || this._lane === 'disposed') return; - this._lane = 'disposing'; + if (this._lifecycle === 'disposing' || this._lifecycle === 'disposed') return; + this._lifecycle = 'disposing'; this.activeLease?.markEnding(); for (const entry of this.background.values()) { entry.controller.abort(); } - this.publishLane(); + this.publishActivity(); this.maybeSettle(); } settled(): Promise { - if (this._lane === 'disposed') return Promise.resolve(); + if (this._lifecycle === 'disposed') return Promise.resolve(); if ( - this._lane !== 'disposing' && + this._lifecycle !== 'disposing' && this.activeLease === undefined && this.background.size === 0 ) { @@ -224,48 +304,147 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe lease.registration.dispose(); lease.registration = Disposable.None; this.lastTurn = { turnId: lease.turnId, reason: outcome, at: Date.now() }; - if (this._lane === 'disposing') { + if (this._lifecycle === 'disposing') { this.maybeSettle(); return; } - this._lane = 'idle'; - this.publishLane(); + this.publishActivity(); this.maybeSettle(); } private maybeSettle(): void { if (this.activeLease !== undefined || this.background.size > 0) return; - if (this._lane === 'disposing') { - this._lane = 'disposed'; - this.publishLane(); + if (this._lifecycle === 'disposing') { + this._lifecycle = 'disposed'; + this.publishActivity(); } if (this.settleWaiters.length === 0) return; const waiters = this.settleWaiters.splice(0); for (const resolve of waiters) resolve(); } - private publishLane(): void { - const lease = this.activeLease; - this.wire.dispatch( - setLane({ - next: { - lane: this._lane, - turn: - lease === undefined - ? undefined - : { - turnId: lease.turnId, - origin: lease.origin, - ending: lease.ending, - endingReason: lease.endingReason, - since: lease.since, - }, - lastTurn: this.lastTurn, - background: [...this.background.values()].map((entry) => entry.ref), - }, - }), - ); + private onStepStarted(step: number): void { + this._step = step; + this.resetStepState(); + this.publishActivity(); } + + private onStepInterrupted(turnId: number, reason: string): void { + if (reason !== 'aborted' && reason !== 'max_steps' && reason !== 'error') return; + const lease = this.activeLease; + if (lease === undefined || lease.turnId !== turnId) return; + lease.markInterrupted(reason); + this.publishActivity(); + } + + private onDelta(stream: 'assistant' | 'thinking' | 'tool_call'): void { + this._phase = 'streaming'; + this._stream = stream; + this._retry = undefined; + this.publishActivity(); + } + + private onToolCallStarted(toolCallId: string, name: string): void { + this._phase = 'tool_call'; + this._stream = undefined; + this._retry = undefined; + this.activeToolCalls.set(toolCallId, { toolCallId, name, since: Date.now() }); + this.publishActivity(); + } + + private onToolResult(toolCallId: string): void { + this.activeToolCalls.delete(toolCallId); + this._phase = this.activeToolCalls.size === 0 ? 'running' : 'tool_call'; + this._stream = undefined; + this._retry = undefined; + this.publishActivity(); + } + + private resetTurnState(): void { + this._step = 0; + this.resetStepState(); + this.pendingApprovals.clear(); + this.activeToolCalls.clear(); + } + + private onApprovalRequested(toolCallId: string): void { + this.pendingApprovals.set(toolCallId, { + approvalId: toolCallId, + toolCallId, + since: Date.now(), + }); + this.publishActivity(); + } + + private onApprovalResolved(toolCallId: string): void { + this.pendingApprovals.delete(toolCallId); + this.publishActivity(); + } + + private resetStepState(): void { + this._phase = 'running'; + this._stream = undefined; + this._retry = undefined; + } + + private publishActivity(): void { + const lease = this.activeLease; + const turn = + lease === undefined + ? undefined + : { + turnId: lease.turnId, + origin: lease.origin, + phase: this._phase, + stream: this._stream, + step: this._step, + ending: lease.ending, + endingReason: lease.endingReason, + retry: this._retry, + pendingApprovals: [...this.pendingApprovals.values()], + activeToolCalls: [...this.activeToolCalls.values()], + since: lease.since, + }; + const state: AgentActivityState = { + lifecycle: this._lifecycle, + turn, + lastTurn: this.lastTurn, + background: [...this.background.values()].map((entry) => entry.ref), + }; + if (activityEqual(this._currentState, state)) return; + this._currentState = state; + this.eventBus.publish({ type: 'agent.activity.updated', ...state }); + } +} + +function activityEqual(a: AgentActivityState, b: AgentActivityState): boolean { + if (a.lifecycle !== b.lifecycle) return false; + if (a.background.length !== b.background.length) return false; + if ((a.turn === undefined) !== (b.turn === undefined)) return false; + if (a.turn !== undefined && b.turn !== undefined) { + const ta = a.turn; + const tb = b.turn; + if ( + ta.turnId !== tb.turnId || + ta.phase !== tb.phase || + ta.stream !== tb.stream || + ta.step !== tb.step || + ta.ending !== tb.ending || + ta.endingReason !== tb.endingReason || + ta.pendingApprovals.length !== tb.pendingApprovals.length || + ta.activeToolCalls.length !== tb.activeToolCalls.length + ) { + return false; + } + if (ta.retry?.nextAttempt !== tb.retry?.nextAttempt) return false; + } + if ((a.lastTurn === undefined) !== (b.lastTurn === undefined)) return false; + if (a.lastTurn !== undefined && b.lastTurn !== undefined) { + if (a.lastTurn.turnId !== b.lastTurn.turnId || a.lastTurn.reason !== b.lastTurn.reason) { + return false; + } + } + return true; } registerScopedService( diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index b282b6746..0c0934a16 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -15,8 +15,7 @@ import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IEventBus } from '#/app/event/eventBus'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import { IAgentContextInjectorService, type ContextInjectionProvider, @@ -38,7 +37,7 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon @IAgentLoopService loopService: IAgentLoopService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IEventBus private readonly eventBus: IEventBus, - @IAgentWireService wire: IWireService, + @IWireService wire: IWireService, ) { super(); this._register( @@ -52,12 +51,17 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon this.isNewTurn = true; }), ); - this._register(this.eventBus.subscribe('context.spliced', (e) => { - this.handleSplice(e); - })); - this._register(wire.onRestored(() => { - this.resyncPositions(); - })); + this._register( + this.eventBus.subscribe('context.spliced', (e) => { + this.handleSplice(e); + }), + ); + this._register( + wire.hooks.onDidRestore.register('context-injector', async (_ctx, next) => { + this.resyncPositions(); + await next(); + }), + ); } register( diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index 8057573b8..d18d7c694 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -10,11 +10,11 @@ * changes the measured prefix — `clear` resets it, `applyCompaction` adopts * `tokensAfter`, and `undo` rebases it (to an estimate when the measured * aggregate is truncated); `append` leaves the measured prefix untouched since - * new messages are the unmeasured tail (see `contextSizeService`). Every - * mutation still fires `onSpliced` from the live path only (replay rebuilds - * the Model silently and never invokes these methods), so existing subscribers - * (context-injector, task-notification) observe the same - * splice-shaped change events regardless of which Op was persisted. Messages + * new messages are the unmeasured tail (see `contextSizeService`). + * Splice-shaped mutations publish `context.spliced` from the live path only + * (replay rebuilds the Model silently and never invokes these methods), so + * existing subscribers observe the same change regardless of which Op was + * persisted. Messages * are persisted without local ids — the on-disk record matches v1's field set * and public message ids are derived from the transcript index. Blob * dehydrate/rehydrate is declared on `ContextModel.blobs`. Bound at @@ -27,9 +27,8 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { estimateTokensForMessages } from '#/_base/utils/tokens'; import { IEventBus } from '#/app/event/eventBus'; import { ContextSizeModel, contextSizeMeasured } from '#/agent/contextSize/contextSizeOps'; -import { IAgentWireService } from '#/wire/tokens'; +import { IWireService } from '#/wire/wire'; import type { Op } from '#/wire/op'; -import type { IWireService } from '#/wire/wireService'; import { IAgentContextMemoryService, @@ -66,7 +65,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte declare readonly _serviceBrand: undefined; constructor( - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, ) { super(); @@ -86,6 +85,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte appendLoopEvent(event: LoopRecordedEvent): void { this.wire.dispatch(contextAppendLoopEvent({ event })); } + clear(): void { const deleteCount = this.get().length; if (deleteCount === 0) return; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index 5f630a250..7638a5c3d 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -36,7 +36,7 @@ import { z } from 'zod'; import type { ContentPart } from '#/app/llmProtocol/message'; import { defineModel, type PartsTransformer } from '#/wire/model'; -import type { PersistedRecord } from '#/wire/wireService'; +import type { WireRecord } from '#/wire/record'; import { buildContextCompactionShape, @@ -70,9 +70,9 @@ async function dehydrateMessages( } async function dehydrateRecord( - record: PersistedRecord, + record: WireRecord, transform: PartsTransformer, -): Promise { +): Promise { if (record.type === 'context.append_message') { const message = record['message'] as ContextMessage | undefined; if (message === undefined) return record; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 92c179763..481dd5865 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -29,7 +29,7 @@ */ import { type ContentPart, type ToolCall } from '#/app/llmProtocol/message'; -import type { PersistedRecord } from '#/wire/wireService'; +import type { WireRecord } from '#/wire/record'; import { COMPACT_USER_MESSAGE_MAX_TOKENS, @@ -49,6 +49,11 @@ export interface ContextTranscript { readonly foldedLength: number; } +export interface ContextTranscriptReducer { + add(record: WireRecord): void; + result(): ContextTranscript; +} + interface MutableMessage { id?: string; role: ContextMessage['role']; @@ -64,7 +69,13 @@ interface MutableEntry { time?: number; } -export function reduceContextTranscript(records: Iterable): ContextTranscript { +export function reduceContextTranscript(records: Iterable): ContextTranscript { + const reducer = createContextTranscriptReducer(); + for (const record of records) reducer.add(record); + return reducer.result(); +} + +export function createContextTranscriptReducer(): ContextTranscriptReducer { const transcript: MutableEntry[] = []; let foldedLength = 0; let clearFloor = 0; @@ -176,7 +187,7 @@ export function reduceContextTranscript(records: Iterable): Con resetOpenState(); }; - for (const record of records) { + const add = (record: WireRecord): void => { switch (record.type) { case 'context.append_message': { const entry = toMutableEntry(record['message'] as ContextMessage, record.time); @@ -212,12 +223,15 @@ export function reduceContextTranscript(records: Iterable): Con default: break; } - } + }; return { - entries: transcript.map((e) => e.message), - times: transcript.map((e) => e.time), - foldedLength, + add, + result: () => ({ + entries: transcript.map((e) => e.message), + times: transcript.map((e) => e.time), + foldedLength, + }), }; } @@ -237,7 +251,7 @@ function toMutableEntry(message: ContextMessage, time: number | undefined): Muta } function recoverFoldedLength( - record: PersistedRecord, + record: WireRecord, transcript: readonly MutableEntry[], clearFloor: number, foldedLength: number, @@ -258,7 +272,7 @@ function recoverFoldedLength( return keptUserMessages.length + 1; } -function readCompactionSummaryText(record: PersistedRecord): string { +function readCompactionSummaryText(record: WireRecord): string { const summary = record['summary']; if (typeof summary === 'string') return summary; const contextSummary = record['contextSummary']; @@ -281,7 +295,7 @@ function textOfParts(content: readonly ContentPart[]): string { return text; } -function readNumber(record: PersistedRecord, key: string): number | undefined { +function readNumber(record: WireRecord, key: string): number | undefined { const value = record[key]; return typeof value === 'number' ? value : undefined; } diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index a9dccda2f..546efab46 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -9,7 +9,7 @@ * the v2 live loop emits the same records (`LoopService` → * `ContextMemory.appendLoopEvent`), keeping the on-disk shape byte-compatible. * This fold turns them into assistant / tool messages — at live dispatch time - * and again when `WireService.replay` restores a session. Without it, replay + * and again when `WireService.restore` restores an Agent. Without it, restore * would skip those records (no Op is registered for the type) and the restored * `ContextModel` — and every consumer built on it (`/messages`, `/snapshot`, * live resume) — would show only the user prompts. diff --git a/packages/agent-core-v2/src/agent/contextSize/contextSizeService.ts b/packages/agent-core-v2/src/agent/contextSize/contextSizeService.ts index 3bb96e3f4..67082d30f 100644 --- a/packages/agent-core-v2/src/agent/contextSize/contextSizeService.ts +++ b/packages/agent-core-v2/src/agent/contextSize/contextSizeService.ts @@ -26,8 +26,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import type { ContextMessage } from '#/agent/contextMemory/types'; import type { Message } from '#/app/llmProtocol/message'; import type { TokenUsage } from '#/app/llmProtocol/usage'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import { IAgentContextSizeService, type ContextSize } from './contextSize'; import { ContextSizeModel, contextSizeMeasured } from './contextSizeOps'; @@ -39,7 +38,7 @@ export class AgentContextSizeService extends Disposable implements IAgentContext constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, ) { super(); } diff --git a/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts b/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts index 9de43c597..9162d71d8 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts @@ -24,15 +24,15 @@ * the in-flight worker promise — stays OUT of the Model (live-only service * members): none of it can be resumed, and a session never restores mid-flight. * A `running` phase stranded by a crash is reset to `idle` by the service's - * `wire.onRestored` handler (mirroring `goal`'s post-replay normalization). + * `wire.hooks.onDidRestore` hook (mirroring `goal`'s post-replay normalization). * * The `compaction.*` events publish to `IEventBus` (`compaction.started` via the * `begin` Op's `toEvent`; the rest directly from the service); they are * declared here via interface-merge (`error` is already declared by `mcp`, so * it is not re-declared). The `full_compaction.*` record shapes are registered in * `PersistedOpMap` (`#/wire/types`, below) because the records still - * ride the per-agent `wire.jsonl` log read by `wireRecord.restore()` / - * `getRecords()`. Consumed by the Agent-scope `fullCompactionService`. + * ride the per-agent `wire.jsonl` journal restored by `IWireService`. + * Consumed by the Agent-scope `fullCompactionService`. */ import { z } from 'zod'; diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 93fd8d054..03e169ac5 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -39,8 +39,7 @@ import { IEventBus } from '#/app/event/eventBus'; import type { CompactionFinishedEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2, isCodedError, isError2, toKimiErrorPayload, unwrapErrorCause } from "#/errors"; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import compactionInstructionTemplate from './compaction-instruction.md?raw'; import { IAgentFullCompactionService, @@ -123,7 +122,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull @IInstantiationService private readonly instantiation: IInstantiationService, @ISessionTodoService private readonly todo: ISessionTodoService, @ITelemetryService private readonly telemetry: ITelemetryService, - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, @IAgentActivityService private readonly activity: IAgentActivityService, @ILogService private readonly log: ILogService, @@ -131,7 +130,12 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull ) { super(); this.strategy = new RuntimeCompactionStrategy(() => this.resolveModelContextWithEffectiveMax()); - this._register(this.wire.onRestored(() => this.normalizeAfterReplay())); + this._register( + this.wire.hooks.onDidRestore.register('full-compaction', async (_ctx, next) => { + this.normalizeAfterReplay(); + await next(); + }), + ); this._register( this.eventBus.subscribe('turn.started', () => this.resetForTurn()), ); @@ -266,7 +270,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull if (history.length === 0) { throw new Error2(ErrorCodes.COMPACTION_UNABLE, 'No messages to compact in current history.'); } - if (source === 'manual' && this.activity.lane() !== 'idle') { + if (source === 'manual' && !this.activity.isIdle()) { throw new Error2( ErrorCodes.COMPACTION_UNABLE, 'Cannot compact while a turn is active. Wait for it to finish, then retry.', diff --git a/packages/agent-core-v2/src/agent/goal/goalOps.ts b/packages/agent-core-v2/src/agent/goal/goalOps.ts index 3fbf3f286..03bf62bc4 100644 --- a/packages/agent-core-v2/src/agent/goal/goalOps.ts +++ b/packages/agent-core-v2/src/agent/goal/goalOps.ts @@ -14,8 +14,8 @@ * replay). Each `apply` returns the same reference when nothing changes so the * wire's reference-equality gate stays quiet. The `goal.updated` fact is * published live to `IEventBus` by the service (declared here via - * interface-merge); `wire.replay` rebuilds the Model silently and the - * service's `wire.onRestored` + * interface-merge); `wire.restore` rebuilds the Model silently and the + * service's `wire.hooks.onDidRestore` * forces a replayed `active` goal back to `paused`. Consumed by the Agent-scope * `goalService`. */ diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index a198e0c19..d40740951 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -5,13 +5,14 @@ * `GoalModel` (`GoalState | null`) through the `goal.create` / `goal.update` / * `goal.clear` Ops (`wire.dispatch`), reads it through `wire.getModel`, * publishes `goal.updated` live to `IEventBus`, and forces a replayed `active` - * goal back to `paused` via `wire.onRestored`. The accumulated `wallClockMs` - * lives in the Model (set from each Op payload, never by `Date.now()` inside - * `apply`); the `wallClockResumedAt` cursor is a live-only field, reset on - * replay and (re)started on the live path. A `forked` wire Op clears the Model + * goal back to `paused` via `wire.hooks.onDidRestore`. The accumulated + * `wallClockMs` lives in the Model (set from each Op payload, never by + * `Date.now()` inside `apply`); the `wallClockResumedAt` cursor is a live-only + * field, reset on replay and (re)started on the live path. A `forked` wire Op + * clears the Model * at a fork boundary; the `goal.*` payload shapes are registered in * `PersistedOpMap` (`#/wire/types`) inside `goalOps` because they still ride - * the shared wire log read by `getRecords()` and replayed into the Model. + * the Agent wire journal restored into the Model. * Injects reminders through * `contextInjector`, drives continuation turns by enqueueing `newTurn` * `StepRequest`s onto `loop` (the continuation message materializes when the @@ -52,9 +53,8 @@ import { toKimiErrorPayload, type KimiErrorPayload, } from '#/errors'; -import { IAgentWireService } from '#/wire/tokens'; -import { defineDerivedModel } from '#/wire/model'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; +import { defineModel } from '#/wire/model'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentGoalService, type GoalReasonInput, type ResumeGoalInput } from './goal'; @@ -157,20 +157,22 @@ interface PendingContinuation { turnId?: number; } -const GoalForkNoticeModel = defineDerivedModel( +const GoalForkNoticeModel = defineModel( 'goalForkNotice', () => ({ goalPresent: false, reminderPending: false }), { - 'goal.create': (state) => ({ ...state, goalPresent: true }), - 'goal.clear': (state) => ({ ...state, goalPresent: false }), - forked: (state) => ({ - goalPresent: false, - reminderPending: state.goalPresent || state.reminderPending, - }), - 'context.append_message': (state, payload: { message?: ContextMessage }) => - state.reminderPending && isGoalForkClearedReminder(payload.message) - ? { ...state, reminderPending: false } - : state, + reducers: { + 'goal.create': (state) => ({ ...state, goalPresent: true }), + 'goal.clear': (state) => ({ ...state, goalPresent: false }), + forked: (state) => ({ + goalPresent: false, + reminderPending: state.goalPresent || state.reminderPending, + }), + 'context.append_message': (state, payload: { message?: ContextMessage }) => + state.reminderPending && isGoalForkClearedReminder(payload.message) + ? { ...state, reminderPending: false } + : state, + }, }, ); @@ -195,7 +197,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private pendingContinuation?: PendingContinuation; constructor( - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @ITelemetryService private readonly telemetry: ITelemetryService, @@ -214,8 +216,12 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { dynamicInjector, ), ); - this._register(this.wire.attach(GoalForkNoticeModel)); - this._register(this.wire.onRestored(() => this.normalizeAfterReplay())); + this._register( + this.wire.hooks.onDidRestore.register('goal', async (_ctx, next) => { + this.normalizeAfterReplay(); + await next(); + }), + ); this._register( this.eventBus.subscribe('turn.started', (e) => this.handleTurnLaunched(e.turnId)), ); diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index e4c0ef11c..7a27849d6 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -61,9 +61,8 @@ import { applyCompletionBudget, resolveCompletionBudget } from '#/app/model/comp import type { Protocol } from '#/app/protocol/protocol'; import type { ApiErrorEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IAgentWireService } from '#/wire/tokens'; +import { IWireService } from '#/wire/wire'; import type { PayloadOf } from '#/wire/types'; -import type { IWireService } from '#/wire/wireService'; import { THINKING_SECTION, type ThinkingConfig } from '#/agent/profile/configSection'; import { resolveThinkingKeep } from '#/agent/profile/thinking'; @@ -143,7 +142,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { @IConfigService private readonly config: IConfigService, @ILogService private readonly log: ILogService, @ITelemetryService private readonly telemetry: ITelemetryService, - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @IFaultInjectionService private readonly faultInjection: IFaultInjectionService, ) {} diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 956ee7d10..10792dec6 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -66,8 +66,7 @@ import type { TurnStartedEvent as TurnStartedTelemetryEvent, } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import { LOOP_CONTROL_SECTION, type LoopControl } from './configSection'; import { createMaxStepsExceededError, @@ -132,7 +131,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { @IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService, @IConfigService private readonly config: IConfigService, @IAgentActivityService private readonly activity: IAgentActivityService, - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, ) { diff --git a/packages/agent-core-v2/src/agent/mcp/mcpService.ts b/packages/agent-core-v2/src/agent/mcp/mcpService.ts index b70eaef2c..065300505 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpService.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpService.ts @@ -24,8 +24,7 @@ import type { McpServerEntry } from './connection-manager'; import { IAgentMcpService } from './mcp'; import { qualifyMcpToolName } from './tool-naming'; import type { MCPClient, MCPToolDefinition } from './types'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import { McpDiscoveryModel, mcpToolsDiscovered, @@ -58,7 +57,7 @@ export class AgentMcpService extends Disposable implements IAgentMcpService { @IAgentToolRegistryService private readonly registry: IAgentToolRegistryService, @IEventBus private readonly eventBus: IEventBus, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @ITelemetryService private readonly telemetry: ITelemetryService, ) { super(); @@ -72,8 +71,12 @@ export class AgentMcpService extends Disposable implements IAgentMcpService { }, ), ); - this._register(this.wire.onRestored(() => this.flushPendingDiscoveries())); - this._register(this.wire.onEmission(() => this.flushPendingDiscoveries())); + this._register( + this.wire.hooks.onDidRestore.register('mcp', async (_ctx, next) => { + this.flushPendingDiscoveries(); + await next(); + }), + ); } get oauthService() { diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts index d17fb9670..a55268a08 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts @@ -2,11 +2,10 @@ * `permissionMode` domain (L3) — wire Model (`PermissionModeModel`) and the * `permission.set_mode` Op (`setMode`) for the agent's permission mode. * - * Declares the mode as a scalar `wire` Model (initial `manual`) plus the single - * Op that replaces it; `defineOp` registers the Op into the global registry at - * import, so `wire.dispatch(setMode({ mode }))` mutates the model and - * `wire.replay` rebuilds it from persisted records (skipping every other record - * type). Consumed by the Agent-scope `permissionModeService`. + * Declares the mode as a scalar `wire` Model (initial `manual`) plus a replay + * marker that distinguishes an explicit persisted mode from the default. The + * single Op replaces the mode and sets that marker. Consumed by the Agent-scope + * `permissionModeService` and session bootstrap. */ import { z } from 'zod'; @@ -15,6 +14,11 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { defineModel } from '#/wire/model'; export const PermissionModeModel = defineModel('permissionMode', () => 'manual'); +export const PermissionModeConfiguredModel = defineModel( + 'permissionMode.configured', + () => false, + { reducers: { 'permission.set_mode': () => true } }, +); declare module '#/wire/types' { interface PersistedOpMap { diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts index e6f5eee37..81f7acce7 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts @@ -4,9 +4,9 @@ * Holds the agent's permission mode (`manual` / `auto`) in the `wire` * `PermissionModeModel`, mutating it only through the `permission.set_mode` Op * (`wire.dispatch(setMode({ mode }))`) and reading it through `wire.getModel`. - * The `onDidChangeMode` event is driven by a `wire.subscribe` on that model - * (firing only on actual changes), and mode-aware reminders are registered - * through the permission-mode injection helper. Bound at Agent scope. + * `setMode` emits `onDidChangeMode` after an actual change, and mode-aware + * reminders are registered through the permission-mode injection helper. Bound + * at Agent scope. */ import type { PermissionMode } from '#/agent/permissionPolicy/types'; @@ -16,10 +16,13 @@ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; import { PermissionModeInjection } from '#/agent/permissionMode/injection/permissionModeInjection'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import { IAgentPermissionModeService, type PermissionModeChangedContext } from './permissionMode'; -import { PermissionModeModel, setMode } from './permissionModeOps'; +import { + PermissionModeConfiguredModel, + PermissionModeModel, + setMode, +} from './permissionModeOps'; export class AgentPermissionModeService extends Disposable implements IAgentPermissionModeService { declare readonly _serviceBrand: undefined; @@ -28,16 +31,10 @@ export class AgentPermissionModeService extends Disposable implements IAgentPerm readonly onDidChangeMode: Event = this._onDidChangeMode.event; constructor( - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @IInstantiationService instantiation: IInstantiationService, ) { super(); - this._register( - wire.subscribe(PermissionModeModel, (mode, previousMode) => { - if (mode === previousMode) return; - this._onDidChangeMode.fire({ mode, previousMode }); - }), - ); this._register(instantiation.createInstance(PermissionModeInjection, this)); } @@ -46,7 +43,11 @@ export class AgentPermissionModeService extends Disposable implements IAgentPerm } setMode(mode: PermissionMode): void { + const previousMode = this.mode; + const changed = mode !== previousMode; + if (!changed && this.wire.getModel(PermissionModeConfiguredModel)) return; this.wire.dispatch(setMode({ mode })); + if (changed) this._onDidChangeMode.fire({ mode, previousMode }); } } diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts index 2a2fdcd2d..16caea70f 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts @@ -11,8 +11,7 @@ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import { IAgentPermissionRulesService, type PermissionApprovalResultRecord, @@ -27,7 +26,7 @@ import { export class AgentPermissionRulesService implements IAgentPermissionRulesService { declare readonly _serviceBrand: undefined; - constructor(@IAgentWireService private readonly wire: IWireService) {} + constructor(@IWireService private readonly wire: IWireService) {} get rules(): readonly PermissionRule[] { return [...this.wire.getModel(PermissionRulesModel).rules]; diff --git a/packages/agent-core-v2/src/agent/plan/planService.ts b/packages/agent-core-v2/src/agent/plan/planService.ts index ff3a4ecc6..9c63cec35 100644 --- a/packages/agent-core-v2/src/agent/plan/planService.ts +++ b/packages/agent-core-v2/src/agent/plan/planService.ts @@ -21,8 +21,7 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import { IAgentPlanService, type PlanData, @@ -43,13 +42,18 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { @IHostFileSystem private readonly hostFs: IHostFileSystem, @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @ISessionContext private readonly sessionCtx: ISessionContext, @IAgentScopeContext private readonly agentCtx: IAgentScopeContext, ) { super(); - this._register(this.wire.onRestored(() => this.restoreTelemetryMode())); + this._register( + this.wire.hooks.onDidRestore.register('plan', async (_ctx, next) => { + this.restoreTelemetryMode(); + await next(); + }), + ); this._register(new PlanModeInjection(dynamicInjector, this, this.context)); } diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 1648c1818..3a949b05f 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -54,9 +54,8 @@ import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/ import type { WarningEvent } from '@moonshot-ai/protocol'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; -import { IAgentWireService } from '#/wire/tokens'; +import { IWireService } from '#/wire/wire'; import type { PayloadOf } from '#/wire/types'; -import type { IWireService } from '#/wire/wireService'; import { IEventBus } from '#/app/event/eventBus'; import { prepareSystemPromptContext } from './context'; import type { @@ -105,7 +104,7 @@ export class AgentProfileService implements IAgentProfileService { private activeProfile: ResolvedAgentProfile | undefined; constructor( - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 3c0ed743b..52d70fe68 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -27,8 +27,7 @@ import type { ContentPart } from '#/app/llmProtocol/message'; import { IEventBus } from '#/app/event/eventBus'; import { ErrorCodes, Error2 } from '#/errors'; import { OrderedHookSlot } from '#/hooks'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import { IAgentPromptService, @@ -73,7 +72,7 @@ export class AgentPromptService implements IAgentPromptService { @IInstantiationService private readonly instantiation: IInstantiationService, @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, ) { toolExecutor.hooks.onDidExecuteTool.register('prompt-service-delivery', async (ctx, next) => { diff --git a/packages/agent-core-v2/src/agent/replayBuilder/replayTimelineModel.ts b/packages/agent-core-v2/src/agent/replayBuilder/replayTimelineModel.ts deleted file mode 100644 index f90eace3e..000000000 --- a/packages/agent-core-v2/src/agent/replayBuilder/replayTimelineModel.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * `replayBuilder` domain — `ReplayTimelineModel`, a derived wire model that - * folds heterogeneous Ops from multiple domains into a single ordered timeline. - * - * This is the v2 replacement for v1's imperative `ReplayBuilder` class: instead - * of each domain service pushing records into a mutable accumulator, the model - * declares which Op types it reduces and the wire engine folds them - * automatically — during both `replay` (silent) and `dispatch` (live). - * - * The timeline entries are op-native: they carry the raw op payloads, not the - * v1 `AgentReplayRecordPayload` DTO shape. The projection to the SDK/edge DTO - * (e.g. computing `GoalSnapshot` from `GoalState`) is a read-time concern, not - * a reduce-time concern. - */ - -import { - contextAppendMessage, - contextApplyCompaction, -} from '#/agent/contextMemory/contextOps'; -import { - fullCompactionBegin, - fullCompactionCancel, - fullCompactionComplete, -} from '#/agent/fullCompaction/compactionOps'; -import { clearGoal, createGoal, updateGoal } from '#/agent/goal/goalOps'; -import { planModeCancel, planModeEnter, planModeExit } from '#/agent/plan/planOps'; -import { configUpdate } from '#/agent/profile/profileOps'; -import type { PermissionMode } from '#/agent/permissionPolicy/types'; -import { setMode } from '#/agent/permissionMode/permissionModeOps'; -import type { PermissionApprovalResultRecord } from '#/agent/permissionRules/permissionRules'; -import { recordApprovalResult } from '#/agent/permissionRules/permissionRulesOps'; -import { type DerivedModelDef, defineDerivedModel } from '#/wire/model'; -import type { ModelReducers, OpPayload, OpType, PayloadOf } from '#/wire/types'; - -type TimelineMapperMap = { - [K in OpType]?: (payload: OpPayload) => unknown; -}; - -type TimelineEntry = { - [K in keyof M]: M[K] extends (...args: never[]) => infer E ? E : never; -}[keyof M]; - -type ErasedTimelineMapper = (payload: unknown) => E; - -function defineDerivedTimeline( - name: string, - mappers: M & Record, never>, -): DerivedModelDef[]> { - type E = TimelineEntry; - const entries = Object.entries(mappers) as [OpType, ErasedTimelineMapper][]; - const reducers = Object.fromEntries( - entries.map( - ([opType, mapper]) => - [opType, (state: readonly E[], payload: unknown) => [...state, mapper(payload)]] as const, - ), - ) as ModelReducers; - return defineDerivedModel(name, () => [], reducers); -} - -export const ReplayTimelineModel = defineDerivedTimeline('agent.replayTimeline', { - [contextAppendMessage.type]: (p: PayloadOf) => - ({ type: contextAppendMessage.type, payload: p }) as const, - - [contextApplyCompaction.type]: (p: PayloadOf) => - ({ type: contextApplyCompaction.type, payload: p }) as const, - - [fullCompactionBegin.type]: (p: PayloadOf) => - ({ type: fullCompactionBegin.type, payload: p }) as const, - - [fullCompactionCancel.type]: () => - ({ type: fullCompactionCancel.type }) as const, - - [fullCompactionComplete.type]: (p: PayloadOf) => - ({ type: fullCompactionComplete.type, payload: p }) as const, - - [createGoal.type]: (p: PayloadOf) => - ({ type: createGoal.type, payload: p }) as const, - - [updateGoal.type]: (p: PayloadOf) => - ({ type: updateGoal.type, payload: p }) as const, - - [clearGoal.type]: () => - ({ type: clearGoal.type }) as const, - - [planModeEnter.type]: (p: PayloadOf) => - ({ type: planModeEnter.type, payload: p }) as const, - - [planModeCancel.type]: (p: PayloadOf) => - ({ type: planModeCancel.type, payload: p }) as const, - - [planModeExit.type]: (p: PayloadOf) => - ({ type: planModeExit.type, payload: p }) as const, - - [configUpdate.type]: (p: PayloadOf) => - ({ type: configUpdate.type, payload: p }) as const, - - [setMode.type]: (p: { mode: PermissionMode }) => - ({ type: setMode.type, payload: p }) as const, - - [recordApprovalResult.type]: (p: PermissionApprovalResultRecord) => - ({ type: recordApprovalResult.type, payload: p }) as const, -}); - -type InferTimelineEntry = D extends DerivedModelDef ? E : never; - -export type ReplayTimelineEntry = InferTimelineEntry; -export type ReplayTimeline = readonly ReplayTimelineEntry[]; diff --git a/packages/agent-core-v2/src/agent/runtime/runtime.ts b/packages/agent-core-v2/src/agent/runtime/runtime.ts deleted file mode 100644 index 7a29db0af..000000000 --- a/packages/agent-core-v2/src/agent/runtime/runtime.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * `runtime` domain (L4) — Agent-scope live phase contract. - * - * Defines the public contract of the agent's whole live phase: the `AgentPhase` - * discriminated union (each variant carries its own ancillary fields) and the - * `IAgentRuntimeService` used to read the current phase via `phase()`. The - * phase is the agent-level, fine-grained counterpart of the session-level - * `sessionActivity` status: it splits `running` into waiting / streaming / - * tool_call / retrying and adds `interrupted` / `ended`. Agent-scoped — one - * instance per agent. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { TurnEndedEvent } from '@moonshot-ai/protocol'; - -export type AgentPhase = - | { readonly kind: 'idle' } - | { - readonly kind: 'running'; - readonly turnId: number; - readonly step: number; - readonly stepId: string; - readonly since: number; - } - | { - readonly kind: 'streaming'; - readonly turnId: number; - readonly step: number; - readonly stepId: string; - readonly stream: 'assistant' | 'thinking' | 'tool_call'; - readonly toolCallId?: string; - readonly toolName?: string; - readonly since: number; - } - | { - readonly kind: 'tool_call'; - readonly turnId: number; - readonly step: number; - readonly toolCallId: string; - readonly name: string; - readonly since: number; - } - | { - readonly kind: 'retrying'; - readonly turnId: number; - readonly step: number; - readonly stepId: string; - readonly failedAttempt: number; - readonly nextAttempt: number; - readonly maxAttempts: number; - readonly delayMs: number; - readonly errorName?: string; - readonly statusCode?: number; - readonly since: number; - } - | { - readonly kind: 'awaiting_approval'; - readonly turnId: number; - readonly step?: number; - readonly approval: unknown; - readonly since: number; - } - | { - readonly kind: 'interrupted'; - readonly turnId: number; - readonly step?: number; - readonly reason: 'aborted' | 'max_steps' | 'error'; - readonly message?: string; - readonly at: number; - } - | { - readonly kind: 'ended'; - readonly turnId: number; - readonly reason: TurnEndedEvent['reason']; - readonly durationMs?: number; - readonly at: number; - }; - -export interface IAgentRuntimeService { - readonly _serviceBrand: undefined; - - phase(): AgentPhase; -} - -export const IAgentRuntimeService: ServiceIdentifier = - createDecorator('agentRuntimeService'); diff --git a/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts b/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts deleted file mode 100644 index f6ec4bc19..000000000 --- a/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * `runtime` domain (L4) — wire Model (`RuntimeModel`) and the `runtime.set_phase` - * Op (`setRuntimePhase`) that holds the agent's whole live phase. - * - * Declares the phase as a single-field wire Model (`{ phase }`, initial - * `{ kind: 'idle' }`) plus one Op whose `apply` is a pure, edge-triggered - * replacement: it returns the SAME reference when the incoming phase is - * unchanged under `phaseEqual` (which ignores `since` / `at` timestamps), so the - * wire's reference-equality gate stays quiet and high-frequency deltas do not - * flood subscribers. The Op is live-only because `runtime.set_phase` is not a - * v1 record type: nothing is persisted or replayed, and resumed agents start - * back at `idle`. The `agent.status.updated` `phase` slice is derived from - * the Op's `toEvent` (published on `dispatch`, never on `replay`). Consumed - * by the Agent-scope `runtimeService`. - */ - -import { z } from 'zod'; - -import { defineModel } from '#/wire/model'; - -import type { AgentPhase } from './runtime'; - -export interface RuntimeModelState { - readonly phase: AgentPhase; -} - -export const RuntimeModel = defineModel('runtime', () => ({ - phase: { kind: 'idle' }, -})); - -declare module '#/wire/types' { - interface TransientOpMap { - 'runtime.set_phase': typeof setRuntimePhase; - 'activity.set_snapshot': typeof setActivitySnapshot; - } -} - -export const setRuntimePhase = RuntimeModel.defineOp('runtime.set_phase', { - schema: z.object({ phase: z.custom() }), - persist: false, - apply: (s, p) => (phaseEqual(s.phase, p.phase) ? s : { phase: p.phase }), - toEvent: (p) => ({ type: 'agent.status.updated' as const, phase: p.phase }), -}); - -export function phaseEqual(a: AgentPhase, b: AgentPhase): boolean { - if (a.kind !== b.kind) return false; - switch (a.kind) { - case 'idle': - return true; - case 'running': { - const c = b as typeof a; - return a.turnId === c.turnId && a.step === c.step && a.stepId === c.stepId; - } - case 'streaming': { - const c = b as typeof a; - return ( - a.turnId === c.turnId && - a.step === c.step && - a.stepId === c.stepId && - a.stream === c.stream && - a.toolCallId === c.toolCallId - ); - } - case 'tool_call': { - const c = b as typeof a; - return a.turnId === c.turnId && a.toolCallId === c.toolCallId; - } - case 'retrying': { - const c = b as typeof a; - return ( - a.turnId === c.turnId && - a.step === c.step && - a.failedAttempt === c.failedAttempt && - a.nextAttempt === c.nextAttempt - ); - } - case 'awaiting_approval': { - const c = b as typeof a; - return a.turnId === c.turnId; - } - case 'interrupted': { - const c = b as typeof a; - return a.turnId === c.turnId && a.reason === c.reason; - } - case 'ended': { - const c = b as typeof a; - return a.turnId === c.turnId && a.reason === c.reason; - } - } -} - -import type { AgentActivitySnapshot } from '#/activity/activity'; - -export const ActivityModel = defineModel('activity', () => ({ - lane: 'idle', - background: [], -})); - -export const setActivitySnapshot = ActivityModel.defineOp('activity.set_snapshot', { - schema: z.object({ next: z.custom() }), - persist: false, - apply: (s, p) => (snapshotEqual(s, p.next) ? s : p.next), - toEvent: (p) => ({ type: 'agent.activity.updated' as const, ...p.next }), -}); - -export function snapshotEqual(a: AgentActivitySnapshot, b: AgentActivitySnapshot): boolean { - if (a.lane !== b.lane) return false; - if (a.background.length !== b.background.length) return false; - if ((a.turn === undefined) !== (b.turn === undefined)) return false; - if (a.turn !== undefined && b.turn !== undefined) { - const ta = a.turn; - const tb = b.turn; - if ( - ta.turnId !== tb.turnId || - ta.phase !== tb.phase || - ta.stream !== tb.stream || - ta.step !== tb.step || - ta.ending !== tb.ending || - ta.endingReason !== tb.endingReason || - ta.pendingApprovals.length !== tb.pendingApprovals.length || - ta.activeToolCalls.length !== tb.activeToolCalls.length - ) { - return false; - } - if (ta.retry?.nextAttempt !== tb.retry?.nextAttempt) return false; - } - if ((a.lastTurn === undefined) !== (b.lastTurn === undefined)) return false; - if (a.lastTurn !== undefined && b.lastTurn !== undefined) { - if (a.lastTurn.turnId !== b.lastTurn.turnId || a.lastTurn.reason !== b.lastTurn.reason) { - return false; - } - } - return true; -} - -declare module '#/app/event/eventBus' { - interface DomainEventMap { - 'agent.activity.updated': AgentActivitySnapshot & { readonly type: 'agent.activity.updated' }; - } -} diff --git a/packages/agent-core-v2/src/agent/runtime/runtimeService.ts b/packages/agent-core-v2/src/agent/runtime/runtimeService.ts deleted file mode 100644 index b27383264..000000000 --- a/packages/agent-core-v2/src/agent/runtime/runtimeService.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * `runtime` domain (L4) — `IAgentRuntimeService` implementation and the Agent - * activity projector. - * - * Folds the agent's live activity into a structured `AgentActivitySnapshot` - * (`ActivityModel`, mutated only through the `activity.set_snapshot` Op) and a - * legacy `AgentPhase` (`RuntimeModel`, through `runtime.set_phase`). Inputs: - * the `activity` kernel's `LaneModel` (authoritative lane / turn / lastTurn / - * background) plus the existing `IEventBus` facts (step / stream / retry / - * approval / tool-call). The snapshot adds a pending-approval SET and an - * active-tool-call SET (keyed by id), so a parallel approval resolve no longer - * drops the still-waiting ones (矛盾 d) and parallel tool calls are all - * visible. Subscriptions are edge-triggered: `publishSnapshot` only dispatches - * when `snapshotEqual` says it changed. Live-only — `wire.replay` stays silent - * and resumes into `idle`. Bound at Agent scope. - */ - -import { Disposable } from '#/_base/di/lifecycle'; -import { InstantiationType } from '#/_base/di/extensions'; -import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { IEventBus } from '#/app/event/eventBus'; -import type { PermissionApprovalRequestContext } from '#/agent/permissionGate/permissionGateService'; -import type { TurnEndedEvent } from '@moonshot-ai/protocol'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; -import type { - ActivityRetryState, - AgentActivitySnapshot, - ApprovalRef, - ToolCallRef, - TurnPhase, -} from '#/activity/activity'; -import { LaneModel } from '#/activity/activityOps'; - -import { type AgentPhase, IAgentRuntimeService } from './runtime'; -import { - phaseEqual, - RuntimeModel, - setActivitySnapshot, - setRuntimePhase, -} from './runtimeOps'; - -interface TurnCursor { - readonly turnId: number; - readonly step: number; - readonly stepId: string; -} - -export class AgentRuntimeService extends Disposable implements IAgentRuntimeService { - declare readonly _serviceBrand: undefined; - - private cursor: TurnCursor = { turnId: -1, step: 0, stepId: '' }; - private current: AgentPhase = { kind: 'idle' }; - private priorForApproval: AgentPhase | undefined; - private subPhase: TurnPhase = 'running'; - private subStream: 'assistant' | 'thinking' | 'tool_call' | undefined; - private subRetry: ActivityRetryState | undefined; - private readonly pendingApprovals = new Map(); - private readonly activeToolCalls = new Map(); - - constructor( - @IAgentWireService private readonly wire: IWireService, - @IEventBus private readonly eventBus: IEventBus, - ) { - super(); - this._register(this.eventBus.subscribe('turn.started', (e) => this.onTurnStarted(e.turnId))); - this._register( - this.eventBus.subscribe('turn.step.started', (e) => - this.onStepStarted(e.turnId, e.step, e.stepId ?? ''), - ), - ); - this._register( - this.eventBus.subscribe('assistant.delta', () => this.onDelta('assistant')), - ); - this._register( - this.eventBus.subscribe('thinking.delta', () => this.onDelta('thinking')), - ); - this._register( - this.eventBus.subscribe('tool.call.delta', (e) => - this.onToolCallDelta(e.toolCallId, e.name), - ), - ); - this._register( - this.eventBus.subscribe('tool.call.started', (e) => - this.onToolCallStarted(e.toolCallId, e.name), - ), - ); - this._register( - this.eventBus.subscribe('tool.result', (e) => this.onToolResult(e.toolCallId)), - ); - this._register( - this.eventBus.subscribe('turn.step.retrying', (e) => { - this.subPhase = 'retrying'; - this.subStream = undefined; - this.subRetry = { - failedAttempt: e.failedAttempt, - nextAttempt: e.nextAttempt, - maxAttempts: e.maxAttempts, - delayMs: e.delayMs, - errorName: e.errorName, - statusCode: e.statusCode, - }; - this.setPhase({ - kind: 'retrying', - turnId: e.turnId, - step: e.step, - stepId: e.stepId ?? '', - failedAttempt: e.failedAttempt, - nextAttempt: e.nextAttempt, - maxAttempts: e.maxAttempts, - delayMs: e.delayMs, - errorName: e.errorName, - statusCode: e.statusCode, - since: Date.now(), - }); - this.publishSnapshot(); - }), - ); - this._register( - this.eventBus.subscribe('turn.step.interrupted', (e) => - this.setPhase({ - kind: 'interrupted', - turnId: e.turnId, - step: e.step, - reason: e.reason as 'aborted' | 'max_steps' | 'error', - message: e.message, - at: Date.now(), - }), - ), - ); - this._register( - this.eventBus.subscribe('turn.step.completed', () => { - this.subPhase = 'running'; - this.subStream = undefined; - this.subRetry = undefined; - this.setPhase(this.running()); - this.publishSnapshot(); - }), - ); - this._register( - this.eventBus.subscribe('turn.ended', (e) => - this.onTurnEnded(e.turnId, e.reason, e.durationMs), - ), - ); - this._register( - this.eventBus.subscribe('permission.approval.requested', (e) => - this.onApprovalRequested(e), - ), - ); - this._register( - this.eventBus.subscribe('permission.approval.resolved', (e) => - this.onApprovalResolved(e.toolCallId), - ), - ); - this._register(this.wire.subscribe(LaneModel, () => this.publishSnapshot())); - } - - phase(): AgentPhase { - return this.wire.getModel(RuntimeModel).phase; - } - - private onTurnStarted(turnId: number): void { - this.cursor = { turnId, step: 0, stepId: '' }; - this.priorForApproval = undefined; - this.subPhase = 'running'; - this.subStream = undefined; - this.subRetry = undefined; - this.pendingApprovals.clear(); - this.activeToolCalls.clear(); - this.setPhase(this.running()); - this.publishSnapshot(); - } - - private onStepStarted(turnId: number, step: number, stepId: string): void { - this.cursor = { turnId, step, stepId }; - this.subPhase = 'running'; - this.subStream = undefined; - this.subRetry = undefined; - this.setPhase(this.running()); - this.publishSnapshot(); - } - - private onDelta(stream: 'assistant' | 'thinking'): void { - this.subPhase = 'streaming'; - this.subStream = stream; - this.subRetry = undefined; - this.setPhase({ - kind: 'streaming', - turnId: this.cursor.turnId, - step: this.cursor.step, - stepId: this.cursor.stepId, - stream, - since: Date.now(), - }); - this.publishSnapshot(); - } - - private onToolCallDelta(toolCallId: string, name: string | undefined): void { - this.subPhase = 'streaming'; - this.subStream = 'tool_call'; - this.subRetry = undefined; - this.setPhase({ - kind: 'streaming', - turnId: this.cursor.turnId, - step: this.cursor.step, - stepId: this.cursor.stepId, - stream: 'tool_call', - toolCallId, - toolName: name, - since: Date.now(), - }); - this.publishSnapshot(); - } - - private onToolCallStarted(toolCallId: string, name: string): void { - this.subPhase = 'tool_call'; - this.subStream = undefined; - this.subRetry = undefined; - this.activeToolCalls.set(toolCallId, { toolCallId, name, since: Date.now() }); - this.setPhase({ - kind: 'tool_call', - turnId: this.cursor.turnId, - step: this.cursor.step, - toolCallId, - name, - since: Date.now(), - }); - this.publishSnapshot(); - } - - private onToolResult(toolCallId: string): void { - this.activeToolCalls.delete(toolCallId); - this.subPhase = 'running'; - this.subStream = undefined; - this.subRetry = undefined; - this.setPhase(this.running()); - this.publishSnapshot(); - } - - private onTurnEnded( - turnId: number, - reason: TurnEndedEvent['reason'], - durationMs: number | undefined, - ): void { - this.setPhase({ kind: 'ended', turnId, reason, durationMs, at: Date.now() }); - this.cursor = { turnId: -1, step: 0, stepId: '' }; - this.priorForApproval = undefined; - this.subPhase = 'running'; - this.subStream = undefined; - this.subRetry = undefined; - this.pendingApprovals.clear(); - this.activeToolCalls.clear(); - this.publishSnapshot(); - } - - private onApprovalRequested(approval: PermissionApprovalRequestContext): void { - this.priorForApproval = this.current; - this.pendingApprovals.set(approval.toolCallId, { - approvalId: approval.toolCallId, - toolCallId: approval.toolCallId, - since: Date.now(), - }); - this.setPhase({ - kind: 'awaiting_approval', - turnId: approval.turnId, - step: this.cursor.step || undefined, - approval, - since: Date.now(), - }); - this.publishSnapshot(); - } - - private onApprovalResolved(toolCallId: string): void { - this.pendingApprovals.delete(toolCallId); - const resume = this.priorForApproval; - this.priorForApproval = undefined; - if (this.pendingApprovals.size > 0) { - this.setPhase({ - kind: 'awaiting_approval', - turnId: this.cursor.turnId, - step: this.cursor.step || undefined, - approval: undefined, - since: Date.now(), - }); - } else if (resume !== undefined && resume.kind !== 'idle' && resume.kind !== 'ended') { - this.setPhase(resume); - } else { - this.setPhase(this.running()); - } - this.publishSnapshot(); - } - - private running(): AgentPhase { - return { - kind: 'running', - turnId: this.cursor.turnId, - step: this.cursor.step, - stepId: this.cursor.stepId, - since: Date.now(), - }; - } - - private setPhase(phase: AgentPhase): void { - if (phaseEqual(this.current, phase)) return; - this.current = phase; - this.wire.dispatch(setRuntimePhase({ phase })); - } - - private publishSnapshot(): void { - const lane = this.wire.getModel(LaneModel); - const turn = - lane.turn === undefined - ? undefined - : { - turnId: lane.turn.turnId, - origin: lane.turn.origin, - phase: this.subPhase, - stream: this.subStream, - step: this.cursor.step, - ending: lane.turn.ending, - endingReason: lane.turn.endingReason, - retry: this.subRetry, - pendingApprovals: [...this.pendingApprovals.values()], - activeToolCalls: [...this.activeToolCalls.values()], - since: lane.turn.since, - }; - const snapshot = { - lane: lane.lane, - turn, - lastTurn: lane.lastTurn, - background: lane.background, - }; - this.wire.dispatch( - setActivitySnapshot({ next: snapshot as unknown as AgentActivitySnapshot }), - ); - } -} - -registerScopedService( - LifecycleScope.Agent, - IAgentRuntimeService, - AgentRuntimeService, - InstantiationType.Eager, - 'runtime', -); diff --git a/packages/agent-core-v2/src/agent/skill/skillService.ts b/packages/agent-core-v2/src/agent/skill/skillService.ts index 3b03ac85e..3f787ffd3 100644 --- a/packages/agent-core-v2/src/agent/skill/skillService.ts +++ b/packages/agent-core-v2/src/agent/skill/skillService.ts @@ -26,8 +26,7 @@ import { isUserActivatableSkillType, type SkillDefinition } from '#/app/skillCat import { IAgentPromptService } from '#/agent/prompt/prompt'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { Turn } from '#/agent/loop/loop'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import { IAgentSkillService, type SkillActivationInput } from './skill'; import { skillActivate } from './skillOps'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; @@ -38,7 +37,7 @@ export class AgentSkillService extends Disposable implements IAgentSkillService constructor( @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, @IAgentPromptService private readonly prompt: IAgentPromptService, - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @ITelemetryService private readonly telemetry: ITelemetryService, @ISessionContext private readonly sessionContext: ISessionContext, ) { diff --git a/packages/agent-core-v2/src/agent/swarm/swarmService.ts b/packages/agent-core-v2/src/agent/swarm/swarmService.ts index 2eb6e8dc8..4ed949ff7 100644 --- a/packages/agent-core-v2/src/agent/swarm/swarmService.ts +++ b/packages/agent-core-v2/src/agent/swarm/swarmService.ts @@ -21,8 +21,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IEventBus } from '#/app/event/eventBus'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md?raw'; import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw'; import { IAgentSwarmService, type SwarmModeTrigger } from './swarm'; @@ -32,7 +31,7 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService declare readonly _serviceBrand: undefined; constructor( - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IEventBus private readonly eventBus: IEventBus, diff --git a/packages/agent-core-v2/src/agent/task/taskOps.ts b/packages/agent-core-v2/src/agent/task/taskOps.ts index d7055a125..5a520d33f 100644 --- a/packages/agent-core-v2/src/agent/task/taskOps.ts +++ b/packages/agent-core-v2/src/agent/task/taskOps.ts @@ -13,7 +13,7 @@ * carries no non-determinism. The live `ManagedTask` (the running process, its * `AbortController`, output ring, timers) stays OUT of the Model (live-only); * the Model is the restore seed for `ghosts`, applied by the service's single - * `wire.onRestored` handler before disk load + reconcile. The Ops are + * `wire.hooks.onDidRestore` hook before disk load + reconcile. The Ops are * live-only because task records are not v1 wire types; the durable registry * lives in `AgentTaskPersistence` and is reconciled on resume. Consumed by the * Agent-scope `taskService`. diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 632d2d510..f3fab796d 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -9,10 +9,10 @@ * session-level task root without writing back to it, reads * limits through `config`, records lifecycle and broadcasts through `wire` * (`task.started` / `task.terminated` Ops into `TaskModel`, plus the matching - * signals), restores ghosts through a single `wire.onRestored` handler (wire - * replay -> disk load -> reconcile, in that order), delivers live terminal - * notifications by enqueueing `TaskNotificationStepRequest`s onto `loop` with - * `activeOrNewTurn` admission (mid-turn ones fold into the active turn's + * signals), restores ghosts through a single `wire.hooks.onDidRestore` hook + * (wire replay -> disk load -> reconcile, in that order), delivers live + * terminal notifications by enqueueing `TaskNotificationStepRequest`s onto + * `loop` with `activeOrNewTurn` admission (mid-turn ones fold into the active turn's * following step; idle ones launch a fresh turn themselves, matching v1's * `turn.steer`, so the model consumes the notification without waiting for * the user), silently appends restored notifications through `contextMemory`, @@ -58,12 +58,8 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { - IAgentWireRecordService, - type PersistedWireRecord, -} from '#/agent/wireRecord/wireRecord'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { defineModel } from '#/wire/model'; +import { IWireService } from '#/wire/wire'; import { IAgentTaskService, type AgentTaskNotificationContext, @@ -108,6 +104,21 @@ interface AgentTaskNotificationBuildContext { readonly notification: AgentTaskNotification; } +const TaskNotificationDeliveryModel = defineModel( + 'task.notificationDelivery', + () => [], + { + reducers: { + 'context.append_message': (state, payload: { message?: unknown }) => { + const origin = taskOriginFromMessage(payload.message); + if (origin === undefined) return state; + const key = notificationKey(origin); + return state.includes(key) ? state : [...state, key]; + }, + }, + }, +); + interface ManagedTask { readonly taskId: string; readonly task: AgentTask | undefined; @@ -215,8 +226,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { @ISessionContext session: ISessionContext, @IAgentScopeContext scopeContext: IAgentScopeContext, @ITaskService private readonly taskService: ITaskService, - @IAgentWireRecordService wireRecord: IAgentWireRecordService, - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentLoopService private readonly loop: IAgentLoopService, @@ -234,11 +244,12 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { fallbackRoot, ); this._register( - this.wire.onRestored(async () => { - for (const record of wireRecord.getRecords()) { - this.markDeliveredNotificationsFromRecord(record); + this.wire.hooks.onDidRestore.register('task', async (_ctx, next) => { + for (const key of this.wire.getModel(TaskNotificationDeliveryModel)) { + this.deliveredNotificationKeys.add(key); } await this.restoreAfterReplay(); + await next(); }), ); this._register( @@ -281,12 +292,6 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } } - private markDeliveredNotificationsFromRecord(record: PersistedWireRecord): void { - for (const origin of taskOriginsFromRecord(record)) { - this.markDeliveredNotification(origin); - } - } - registerTask(task: AgentTask, options: RegisterAgentTaskOptions = {}): string { const detached = options.detached ?? true; const timeoutMs = options.timeoutMs ?? task.timeoutMs; @@ -1214,21 +1219,10 @@ function notificationKey(origin: TaskNotificationOrigin): string { return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`; } -function taskOriginsFromRecord(record: PersistedWireRecord): readonly TaskNotificationOrigin[] { - const raw = record as { - readonly type: string; - readonly message?: unknown; - }; - if (raw.type === 'context.append_message') { - return taskOriginFromMessage(raw.message); - } - return []; -} - -function taskOriginFromMessage(message: unknown): readonly TaskNotificationOrigin[] { - if (typeof message !== 'object' || message === null) return []; +function taskOriginFromMessage(message: unknown): TaskNotificationOrigin | undefined { + if (typeof message !== 'object' || message === null) return undefined; const origin = (message as { readonly origin?: unknown }).origin; - return isTaskOrigin(origin) ? [origin] : []; + return isTaskOrigin(origin) ? origin : undefined; } function buildAgentTaskNotificationBody(info: AgentTaskInfo): string { diff --git a/packages/agent-core-v2/src/agent/usage/usageOps.ts b/packages/agent-core-v2/src/agent/usage/usageOps.ts index 98fa3d013..fca26069e 100644 --- a/packages/agent-core-v2/src/agent/usage/usageOps.ts +++ b/packages/agent-core-v2/src/agent/usage/usageOps.ts @@ -15,9 +15,9 @@ */ import { z } from 'zod'; +import type { AgentPhase } from '@moonshot-ai/protocol'; import { addUsage, type TokenUsage } from '#/app/llmProtocol/usage'; -import type { AgentPhase } from '#/agent/runtime/runtime'; import { defineModel } from '#/wire/model'; import type { UsageStatus } from './usage'; diff --git a/packages/agent-core-v2/src/agent/usage/usageService.ts b/packages/agent-core-v2/src/agent/usage/usageService.ts index 8ff62eb22..cc78a3f4f 100644 --- a/packages/agent-core-v2/src/agent/usage/usageService.ts +++ b/packages/agent-core-v2/src/agent/usage/usageService.ts @@ -19,8 +19,7 @@ import { Emitter, type Event } from '#/_base/event'; import type { LLMRequestSource } from '#/agent/llmRequester/llmRequester'; import { IEventBus } from '#/app/event/eventBus'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import type { UsageRecordedContext, UsageStatus } from './usage'; import { IAgentUsageService } from './usage'; import { @@ -41,7 +40,7 @@ export class AgentUsageService extends Disposable implements IAgentUsageService private currentTurn: TokenUsage | undefined; constructor( - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, @IEventBus private readonly eventBus?: IEventBus, ) { super(); diff --git a/packages/agent-core-v2/src/agent/userTool/userToolOps.ts b/packages/agent-core-v2/src/agent/userTool/userToolOps.ts index 9b54e8963..e4036097c 100644 --- a/packages/agent-core-v2/src/agent/userTool/userToolOps.ts +++ b/packages/agent-core-v2/src/agent/userTool/userToolOps.ts @@ -13,8 +13,9 @@ * reference-equality gate stays quiet. The side effects — `registry.register` * and `profile.addActiveTool` (and the matching dispose / `removeActiveTool`) — * are NOT part of `apply`: they run after `wire.dispatch` on the live path and - * are re-derived from the rebuilt Model by `wire.onRestored` after replay, so a - * resumed agent re-registers exactly the tools the persisted ops describe. + * are re-derived from the rebuilt Model by `wire.hooks.onDidRestore` after + * restore, so a resumed agent re-registers exactly the tools the persisted ops + * describe. * Consumed by the Agent-scope `userToolService`. */ diff --git a/packages/agent-core-v2/src/agent/userTool/userToolService.ts b/packages/agent-core-v2/src/agent/userTool/userToolService.ts index 530d30edc..201c52119 100644 --- a/packages/agent-core-v2/src/agent/userTool/userToolService.ts +++ b/packages/agent-core-v2/src/agent/userTool/userToolService.ts @@ -7,8 +7,9 @@ * (`wire.dispatch(...)`). The live side effects — `registry.register` + * `profile.addActiveTool` (and the matching dispose / `removeActiveTool`) — run * after the dispatch, and are re-derived from the rebuilt Model by - * `wire.onRestored` after `wire.replay`, so a resumed agent re-registers exactly - * the tools the persisted ops describe without re-firing any live notification. + * `wire.hooks.onDidRestore` after `wire.restore`, so a resumed agent re-registers + * exactly the tools the persisted ops describe without re-firing any live + * notification. * The restore re-registers into the tool registry only: the active-tool set is * owned by the persisted `ActiveToolsModel`, so the ephemeral `addActiveTool` * overlay is not rebuilt (it is live-only by design). The per-tool @@ -28,8 +29,7 @@ import type { } from '#/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { ISessionInteractionService } from '#/session/interaction/interaction'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import { IAgentUserToolService, type UserToolRegistration } from './userTool'; import { registerUserTool, unregisterUserTool, UserToolModel } from './userToolOps'; @@ -50,10 +50,15 @@ export class AgentUserToolService extends Disposable implements IAgentUserToolSe @IAgentToolRegistryService private readonly registry: IAgentToolRegistryService, @IAgentProfileService private readonly profile: IAgentProfileService, @ISessionInteractionService private readonly interaction: ISessionInteractionService, - @IAgentWireService private readonly wire: IWireService, + @IWireService private readonly wire: IWireService, ) { super(); - this._register(this.wire.onRestored(() => this.restoreRegisteredTools())); + this._register( + this.wire.hooks.onDidRestore.register('user-tool', async (_ctx, next) => { + this.restoreRegisteredTools(); + await next(); + }), + ); } list(): readonly UserToolRegistration[] { diff --git a/packages/agent-core-v2/src/agent/wireRecord/agentWireService.ts b/packages/agent-core-v2/src/agent/wireRecord/agentWireService.ts deleted file mode 100644 index 7ccdc2ead..000000000 --- a/packages/agent-core-v2/src/agent/wireRecord/agentWireService.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * `wireRecord` domain (L2), Agent scope — the `IAgentWireService` binding. - * - * Thin Agent-scope adapter over the scope-agnostic `WireService`: derives the - * persistence addressing (`logScope` / `logKey`) from `IAgentScopeContext` - * instead of receiving it as constructor options, so no per-agent scope seed - * is required. `WireService` itself stays scope-agnostic; a future - * Session-scope wire binds the same way. - */ - -import { InstantiationType } from '#/_base/di/extensions'; -import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { IAgentBlobService } from '#/agent/blob/agentBlobService'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IEventBus } from '#/app/event/eventBus'; -import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; -import { IAgentWireService } from '#/wire/tokens'; -import { WireService } from '#/wire/wireServiceImpl'; - -import { WIRE_RECORD_FILENAME } from './wireRecordService'; - -export class AgentWireService extends WireService { - constructor( - @IAgentScopeContext scopeContext: IAgentScopeContext, - @IAppendLogStore log?: IAppendLogStore, - @IAgentBlobService blobService?: IAgentBlobService, - @IEventBus eventBus?: IEventBus, - ) { - super( - { logScope: scopeContext.scope(), logKey: WIRE_RECORD_FILENAME }, - log, - blobService, - eventBus, - ); - } -} - -registerScopedService( - LifecycleScope.Agent, - IAgentWireService, - AgentWireService, - InstantiationType.Eager, - 'wireRecord', -); diff --git a/packages/agent-core-v2/src/agent/wireRecord/errors.ts b/packages/agent-core-v2/src/agent/wireRecord/errors.ts deleted file mode 100644 index a5b6f3156..000000000 --- a/packages/agent-core-v2/src/agent/wireRecord/errors.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * `wireRecord` domain error codes — record persistence failures. - */ - -import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; - -export const WireRecordErrors = { - codes: { - RECORDS_WRITE_FAILED: 'records.write_failed', - }, -} as const satisfies ErrorDomain; - -registerErrorDomain(WireRecordErrors); diff --git a/packages/agent-core-v2/src/agent/wireRecord/metadataOps.ts b/packages/agent-core-v2/src/agent/wireRecord/metadataOps.ts deleted file mode 100644 index 59429a4e3..000000000 --- a/packages/agent-core-v2/src/agent/wireRecord/metadataOps.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * `wireRecord` domain (L6) — wire-log metadata envelope op. - * - * Declares a marker-only wire Model and the `metadata` Op whose flattened - * record carries the wire-protocol envelope (`protocol_version`, `created_at`) - * as the first record of each agent `wire.jsonl`. It is the only persisted - * record that opts out of the `time` stamp, matching v1. Defined through the - * low-level `wire` registry so `WireService` can persist the envelope through - * the same append path as every other Op. `metadataRecord()` is the single - * shared factory for the envelope — restore-time healing and fork-time log - * copies both use it instead of hand-rolling the shape. Scope-agnostic. - */ - -import { z } from 'zod'; - -import { defineModel } from '#/wire/model'; -import { - AGENT_WIRE_PROTOCOL_VERSION, -} from '#/agent/wireRecord/migration/migration'; -import type { WireRecordMetadata } from './wireRecord'; - -const MetadataModel = defineModel('wire.metadata', () => null); - -declare module '#/wire/types' { - interface PersistedOpMap { - metadata: typeof wireMetadata; - } -} - -export const wireMetadata = MetadataModel.defineOp('metadata', { - schema: z.object({ protocol_version: z.string(), created_at: z.number() }), - stamp: false, - apply: (s) => s, -}); - -/** A fresh metadata envelope stamped at the current protocol version. */ -export function metadataRecord(): WireRecordMetadata { - return { - type: 'metadata', - protocol_version: AGENT_WIRE_PROTOCOL_VERSION, - created_at: Date.now(), - }; -} diff --git a/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts b/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts deleted file mode 100644 index e7b9b531f..000000000 --- a/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * `wireRecord` contract (L6) — the persisted wire journal's public surface. - * - * Defines the on-disk record vocabulary (the `metadata` envelope and the - * migration records) and `IAgentWireRecordService`. `seal` starts a fresh log - * with the `metadata` envelope at agent creation (a no-op once any record - * exists) so released v1 builds — whose replay hard-rejects a non-empty log - * lacking the envelope — can read sessions on a shared `KIMI_CODE_HOME`; - * legacy envelope-less logs are healed by `restore`, never by `seal`. Bound - * at Agent scope. - */ - -import { createDecorator } from '#/_base/di/instantiation'; - -import type { WireMigrationRecord } from '#/agent/wireRecord/migration/migration'; - -export * from '#/agent/wireRecord/migration/migration'; - -export interface WireRecordMetadata { - readonly type: 'metadata'; - readonly protocol_version: string; - readonly created_at: number; - readonly time?: number; -} - -export type PersistedWireRecord = WireRecordMetadata | WireMigrationRecord; - -export interface WireRecordRestoreOptions { - readonly rewriteMigratedRecords?: boolean; -} - -export interface WireRecordRestoreResult { - readonly warning?: string; -} - -export interface IAgentWireRecordService { - readonly _serviceBrand: undefined; - - seal(): Promise; - getRecords(): readonly PersistedWireRecord[]; - restore( - records?: readonly PersistedWireRecord[], - options?: WireRecordRestoreOptions, - ): Promise; - flush(): Promise; - close(): Promise; -} - -export const IAgentWireRecordService = createDecorator('agentWireRecordService'); diff --git a/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts b/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts deleted file mode 100644 index 12d8ba837..000000000 --- a/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * `wireRecord` domain (L2) — `IAgentWireRecordService` implementation. - * - * Restores and retains the owning agent's wire journal, applies protocol - * migrations, rejects non-empty unversioned logs, and awaits durable atomic - * rewrites before restore completes. Seals fresh logs with the `metadata` - * envelope at creation (`seal`) so released v1 builds — whose replay - * hard-rejects envelope-less logs — can read sessions on a shared - * `KIMI_CODE_HOME`; legacy envelope-less logs are healed on `restore`. - * Tracks live records through `wire`, uses `agent/scopeContext` for storage - * addressing, and persists through the `appendLog` access-pattern store. - * Bound at Agent scope. - */ - -import { relative } from 'pathe'; - -import { InstantiationType } from '#/_base/di/extensions'; -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { onUnexpectedError } from '#/_base/errors/unexpectedError'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; -import { - AGENT_WIRE_PROTOCOL_VERSION, - applyWireMigrations, - isNewerWireVersion, - resolveWireMigrations, - type WireMigration, - type WireMigrationRecord, -} from '#/agent/wireRecord/migration/migration'; -import { metadataRecord } from './metadataOps'; -import { - IAgentWireRecordService, - type PersistedWireRecord, - type WireRecordMetadata, - type WireRecordRestoreOptions, - type WireRecordRestoreResult, -} from './wireRecord'; - -export class AgentWireRecordService extends Disposable implements IAgentWireRecordService { - declare readonly _serviceBrand: undefined; - private readonly records: PersistedWireRecord[] = []; - private readonly wireScope: string; - - constructor( - @IAgentScopeContext scopeContext: IAgentScopeContext, - @IAppendLogStore private readonly log?: IAppendLogStore, - @IAgentWireService private readonly wire?: IWireService, - ) { - super(); - this.wireScope = scopeContext.scope(); - if (this.log !== undefined) { - this._register(this.log.acquire(this.wireScope, WIRE_RECORD_FILENAME)); - } - if (wire !== undefined) { - this._register( - wire.onEmission((emission) => { - if (emission.type === 'record' && emission.record.type !== 'metadata') { - this.records.push(emission.record as PersistedWireRecord); - } - }), - ); - } - } - - getRecords(): readonly PersistedWireRecord[] { - return [...this.records]; - } - - async seal(): Promise { - if (this.log === undefined) return; - if (await hasAnyRecord(this.log, this.wireScope, WIRE_RECORD_FILENAME)) return; - this.log.append(this.wireScope, WIRE_RECORD_FILENAME, metadataRecord(), { - onError: onUnexpectedError, - }); - } - - async restore( - records?: readonly PersistedWireRecord[], - options: WireRecordRestoreOptions = {}, - ): Promise { - const fromPersistence = records === undefined; - const source = - records ?? - (this.log !== undefined - ? this.log.read(this.wireScope, WIRE_RECORD_FILENAME) - : undefined); - if (source === undefined) { - return {}; - } - - const rewriteMigratedRecords = - fromPersistence && (options.rewriteMigratedRecords ?? true); - const restoredRecords: PersistedWireRecord[] | undefined = - rewriteMigratedRecords ? [] : undefined; - let migrations: readonly WireMigration[] = []; - let shouldRewrite = false; - let warning: string | undefined; - - const collected: PersistedWireRecord[] = []; - for await (const record of source) { - collected.push(record); - } - - let sourceRecords = collected; - const firstRecord = sourceRecords[0]; - if (firstRecord !== undefined) { - if (firstRecord.type !== 'metadata') { - // Envelope-less log: a fresh agent (creation no longer seals the log) - // or a pre-envelope legacy log. Heal it in place: synthesize the - // envelope at the current protocol version — records written by - // current builds need no migration — and rewrite so the invariant - // holds from now on. - sourceRecords = [metadataRecord(), ...sourceRecords]; - shouldRewrite = fromPersistence; - } else { - if (!isWireRecordMetadata(firstRecord)) { - throw new Error('WireRecord restore expected metadata protocol_version'); - } - const readVersion = firstRecord.protocol_version; - if (isNewerWireVersion(readVersion)) { - warning = `Session wire protocol version ${readVersion} is newer than the current version ${AGENT_WIRE_PROTOCOL_VERSION}. Records will be restored without migration.`; - shouldRewrite = false; - } else { - migrations = resolveWireMigrations(readVersion); - shouldRewrite = readVersion !== AGENT_WIRE_PROTOCOL_VERSION; - } - } - } - - const migratedRecords = applyWireMigrations( - sourceRecords as WireMigrationRecord[], - migrations, - ) as PersistedWireRecord[]; - for (let migratedRecord of migratedRecords) { - if (migratedRecord.type === 'metadata') { - migratedRecord = { - ...migratedRecord, - protocol_version: AGENT_WIRE_PROTOCOL_VERSION, - }; - } - restoredRecords?.push(migratedRecord); - if (migratedRecord.type === 'metadata') continue; - this.records.push(migratedRecord); - } - - if (shouldRewrite && restoredRecords !== undefined && this.log !== undefined) { - await this.log.rewrite(this.wireScope, WIRE_RECORD_FILENAME, restoredRecords); - } - return warning === undefined ? {} : { warning }; - } - - async flush(): Promise { - await this.wire?.flush(); - await this.log?.flush(); - } - - async close(): Promise { - await this.log?.close(); - } -} - -registerScopedService( - LifecycleScope.Agent, - IAgentWireRecordService, - AgentWireRecordService, - InstantiationType.Eager, - 'wireRecord', -); - -function isWireRecordMetadata(record: PersistedWireRecord): record is WireRecordMetadata { - return record.type === 'metadata' && typeof record['protocol_version'] === 'string'; -} - -async function hasAnyRecord(log: IAppendLogStore, scope: string, key: string): Promise { - for await (const record of log.read(scope, key)) { - void record; - return true; - } - return false; -} - -export const WIRE_RECORD_FILENAME = 'wire.jsonl'; - -export function missingWireMetadataError(): Error { - return new Error('WireRecord restore expected metadata as the first record'); -} - -export function wireRecordScope(homedir: string, homeDir: string): string { - return relative(homeDir, homedir); -} diff --git a/packages/agent-core-v2/src/app/event/eventBus.ts b/packages/agent-core-v2/src/app/event/eventBus.ts index eaf090d45..8f3177752 100644 --- a/packages/agent-core-v2/src/app/event/eventBus.ts +++ b/packages/agent-core-v2/src/app/event/eventBus.ts @@ -7,8 +7,7 @@ * `publish(event)` and consumers `subscribe(handler)` (all events) or * `subscribe(type, handler)` (one type). It is bound at Agent scope — one * instance per agent — so a subscription sees only that agent's events (the - * server fans out per agent and tags `agentId` / `sessionId`, exactly like the - * former `IAgentWireService.onEmission`). Process-global events (model catalog, + * server fans out per agent and tags `agentId` / `sessionId`). Process-global events (model catalog, * session lifecycle, auth) stay on the legacy `IEventService` (`./event`), * which is retained as the global channel; its payload type is re-exported from * the barrel as `GlobalEvent`. Domains declare their agent-event shapes by diff --git a/packages/agent-core-v2/src/app/messageLegacy/messageLegacy.ts b/packages/agent-core-v2/src/app/messageLegacy/messageLegacy.ts index e36ca0834..5f70bd7e3 100644 --- a/packages/agent-core-v2/src/app/messageLegacy/messageLegacy.ts +++ b/packages/agent-core-v2/src/app/messageLegacy/messageLegacy.ts @@ -7,14 +7,12 @@ * The native `IAgentContextMemoryService` (Agent scope, serving `/api/v2` * `messages:*`) holds the model's CURRENT, folded context and is NOT the full * transcript: after a compaction it collapses into `[...keptUserMessages, - * compaction_summary]`. The full transcript is reduced from the main agent's - * in-memory record journal (`IAgentWireRecordService.getRecords()`), which - * `ISessionLifecycleService.resume` seeds from `wire.jsonl` and live dispatch - * then keeps current — so neither a live nor a cold session is read back from - * disk here. The `ContextMessage → Message` projection is shared with the - * `snapshot` and `:undo` edges via `contextMemory/messageProjection`. Bound at - * App scope — a stateless dispatcher that resolves the target session/agent per - * call. + * compaction_summary]`. The full transcript is reduced on demand by streaming + * the main agent's `wire.jsonl`; the service does not make every live Agent + * retain its raw journal in memory. The `ContextMessage → Message` projection + * is shared with the `snapshot` and `:undo` edges via + * `contextMemory/messageProjection`. Bound at App scope — a stateless + * dispatcher that resolves the target session/agent per call. * * Error contract (mapped at the route layer): * - `session.not_found` → 40401 diff --git a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts index be1606f58..168b8ef09 100644 --- a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts +++ b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts @@ -5,13 +5,10 @@ * its main agent), sources the transcript, and projects it into the v1 wire * shape. * - * History source is the main agent's in-memory record journal - * (`IAgentWireRecordService.getRecords()`), seeded from `wire.jsonl` by - * `ISessionLifecycleService.resume` and then kept current as live dispatch - * appends each record — so a transcript read never re-reads the file. The - * journal is reduced by `reduceContextTranscript` (the same reducer v1's - * `MessageService` uses), which keeps the full history across compactions - * (inserting a summary marker instead of folding) — unlike the live + * History is streamed from the main agent's append log after its pending wire + * writes are flushed. The journal is folded incrementally by the same + * transcript reducer v1's `MessageService` uses, keeping full history across + * compactions (inserting a summary marker instead of folding) — unlike the live * `IAgentContextMemoryService.get()`, whose folded context collapses into * `[...keptUserMessages, compaction_summary]` and would lose the prefix. * `foldedLength` is what the live history length WOULD be from the journal's @@ -28,17 +25,19 @@ import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '# import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { - reduceContextTranscript, + createContextTranscriptReducer, type ContextTranscript, } from '#/agent/contextMemory/contextTranscript'; import { toProtocolMessage } from '#/agent/contextMemory/messageProjection'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; +import { IWireService } from '#/wire/wire'; +import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; import { ErrorCodes, Error2 } from '#/errors'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; -import type { PersistedRecord } from '#/wire/wireService'; import { IMessageLegacyService, type MessageListQuery } from './messageLegacy'; @@ -51,6 +50,7 @@ export class MessageLegacyService implements IMessageLegacyService { constructor( @ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService, @ISessionIndex private readonly index: ISessionIndex, + @IAppendLogStore private readonly appendLog: IAppendLogStore, ) {} async list(sessionId: string, query: MessageListQuery): Promise> { @@ -105,7 +105,7 @@ export class MessageLegacyService implements IMessageLegacyService { if (session === undefined) return []; const agent = await ensureMainAgent(session); - const transcript = this.readTranscript(agent); + const transcript = await this.readTranscript(agent); const contextMessages = agent.accessor.get(IAgentContextMemoryService).get(); const merged = mergeLiveTail(transcript, contextMessages); const entries = await this.rehydrate(agent, merged.messages); @@ -143,11 +143,14 @@ export class MessageLegacyService implements IMessageLegacyService { return changed ? out : messages; } - private readTranscript(agent: IAgentScopeHandle): ContextTranscript { - const records = agent - .accessor.get(IAgentWireRecordService) - .getRecords() as readonly PersistedRecord[]; - return reduceContextTranscript(records); + private async readTranscript(agent: IAgentScopeHandle): Promise { + await agent.accessor.get(IWireService).flush(); + const scope = agent.accessor.get(IAgentScopeContext).scope(); + const reducer = createContextTranscriptReducer(); + for await (const record of this.appendLog.read(scope, AGENT_WIRE_RECORD_KEY)) { + reducer.add(record); + } + return reducer.result(); } } diff --git a/packages/agent-core-v2/src/app/sessionExport/manifest.ts b/packages/agent-core-v2/src/app/sessionExport/manifest.ts index 3a27dd407..49b418987 100644 --- a/packages/agent-core-v2/src/app/sessionExport/manifest.ts +++ b/packages/agent-core-v2/src/app/sessionExport/manifest.ts @@ -6,7 +6,7 @@ * version facts, and wire-log activity timestamps discovered during export. */ -import { AGENT_WIRE_PROTOCOL_VERSION } from '#/agent/wireRecord/wireRecord'; +import { WIRE_PROTOCOL_VERSION } from '#/wire/migration/migration'; import type { ExportSessionManifest, @@ -14,8 +14,6 @@ import type { } from './sessionExport'; import type { SessionWireScan } from './wire-scan'; -export const WIRE_PROTOCOL_VERSION = AGENT_WIRE_PROTOCOL_VERSION; - export interface ExportSessionManifestSummary { readonly id: string; readonly title?: string | undefined; diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts index e48aef704..9aaf411d5 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -12,7 +12,7 @@ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { resolveGlobalLogPath } from '#/_base/log/logConfig'; -import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; +import { IWireService } from '#/wire/wire'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; @@ -126,7 +126,7 @@ export class SessionExportService implements ISessionExportService { const agents = handle.accessor.get(IAgentLifecycleService); for (const agent of agents.list()) { await this.warnIfFails('export agent wire flush failed', () => - agent.accessor.get(IAgentWireRecordService).flush(), + agent.accessor.get(IWireService).flush(), ); } diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 3805d8105..8141a6a90 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -13,8 +13,8 @@ * roots are remembered through `workspaceRegistry`. On create / fork the * session is also appended to the shared `session_index.jsonl` so v1 clients * (TUI, export) can discover sessions created by the v2 engine. Fork flushes - * live agent logs and rejects non-empty logs without a protocol metadata - * envelope instead of stamping legacy data as current. + * live Agent wire journals, normalizes a missing protocol envelope, and + * appends the fork boundary before restoring the target Agent. */ import { randomUUID } from 'node:crypto'; @@ -35,15 +35,8 @@ import { unwrapErrorCause } from '#/_base/errors/errors'; import { Emitter, type Event } from '#/_base/event'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { ISessionActivityKernel } from '#/activity/activity'; -import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { DEFAULT_PLAN_MODE_SECTION } from '#/agent/plan/configSection'; import { IAgentPlanService } from '#/agent/plan/plan'; -import { - IAgentWireRecordService, - type PersistedWireRecord, -} from '#/agent/wireRecord/wireRecord'; -import { metadataRecord } from '#/agent/wireRecord/metadataOps'; -import { WIRE_RECORD_FILENAME, wireRecordScope } from '#/agent/wireRecord/wireRecordService'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { CRON_SESSION_TAG, type CronTask } from '#/app/cron/cronTask'; import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; @@ -74,8 +67,12 @@ import { ISessionCronService } from '#/session/cron/sessionCronService'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import { IAgentWireService } from '#/wire/tokens'; -import type { PersistedRecord } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; +import { + AGENT_WIRE_RECORD_KEY, + createWireMetadataRecord, + type WireRecord, +} from '#/wire/record'; import { type CreateChildSessionOptions, @@ -247,15 +244,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec }); const agents = handle.accessor.get(IAgentLifecycleService); if (agents.get(MAIN_AGENT_ID) === undefined) { - const main = await agents.create({ agentId: MAIN_AGENT_ID }); - // Resolve context memory BEFORE restoring so its reducers are registered; - // otherwise the wire replay applies context records into a void and the - // restored transcript never lands in context memory. - main.accessor.get(IAgentContextMemoryService); - const mainWireRecord = main.accessor.get(IAgentWireRecordService); - await mainWireRecord.restore(); - const records = mainWireRecord.getRecords() as readonly PersistedRecord[]; - await main.accessor.get(IAgentWireService).replay(...records); + await agents.create({ agentId: MAIN_AGENT_ID }); } await this.announceCreated({ sessionId, handle, source: 'resume' }); return handle; @@ -370,10 +359,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const sourceAgents = sourceMeta?.agents ?? {}; const agentIds = Object.keys(sourceAgents); for (const agentId of agentIds) { - const sourceHomedir = sourceAgents[agentId]!.homedir; await this.copyAgentWire({ sourceHandle, - sourceHomedir, + sourceWorkspaceId: workspaceId, + sourceSessionId: sourceId, agentId, targetWorkspaceId: targetCtx.workspaceId, targetSessionId: targetCtx.sessionId, @@ -394,15 +383,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec for (const agentId of agentIds) { const sourceAgent = sourceAgents[agentId]!; - const agentHandle = await target.accessor.get(IAgentLifecycleService).create({ + await target.accessor.get(IAgentLifecycleService).create({ agentId, forkedFrom: sourceAgent.forkedFrom, labels: labelsFromAgentMeta(sourceAgent), }); - const forkWireRecord = agentHandle.accessor.get(IAgentWireRecordService); - await forkWireRecord.restore(); - const forkRecords = forkWireRecord.getRecords() as readonly PersistedRecord[]; - await agentHandle.accessor.get(IAgentWireService).replay(...forkRecords); } await this.appendSessionIndexEntry(targetId, workspace.root); @@ -459,7 +444,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private async copyAgentWire(args: { readonly sourceHandle: ISessionScopeHandle | undefined; - readonly sourceHomedir: string; + readonly sourceWorkspaceId: string; + readonly sourceSessionId: string; readonly agentId: string; readonly targetWorkspaceId: string; readonly targetSessionId: string; @@ -469,34 +455,34 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec .get(IAgentLifecycleService) .get(args.agentId); if (agentHandle !== undefined) { - await agentHandle.accessor.get(IAgentWireRecordService).flush(); + await agentHandle.accessor.get(IWireService).flush(); } } const records = await collect( - this.appendLogStore.read( - wireRecordScope(args.sourceHomedir, this.bootstrap.homeDir), - WIRE_RECORD_FILENAME, + this.appendLogStore.read( + this.bootstrap.agentScope( + args.sourceWorkspaceId, + args.sourceSessionId, + args.agentId, + ), + AGENT_WIRE_RECORD_KEY, ), ); - // Keep the copied log well-formed for the target's first restore: prepend - // the metadata envelope when the source lacks one (restore() would heal it - // anyway, but the forked copy should be valid on its own). if (records.length === 0) { - records.push(metadataRecord()); + records.push(createWireMetadataRecord()); } else if (records[0]?.type !== 'metadata') { - records.unshift(metadataRecord()); + records.unshift(createWireMetadataRecord()); } records.push(forkedRecord()); - const targetHomedir = this.bootstrap.agentHomedir( - args.targetWorkspaceId, - args.targetSessionId, - args.agentId, - ); await this.appendLogStore.rewrite( - wireRecordScope(targetHomedir, this.bootstrap.homeDir), - WIRE_RECORD_FILENAME, + this.bootstrap.agentScope( + args.targetWorkspaceId, + args.targetSessionId, + args.agentId, + ), + AGENT_WIRE_RECORD_KEY, records, ); } @@ -520,7 +506,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ): Promise { for (const entry of entries) { const rel = relBase === '' ? entry.name : `${relBase}/${entry.name}`; - if (rel === 'state.json' || rel === 'logs' || entry.name === WIRE_RECORD_FILENAME) { + if (rel === 'state.json' || rel === 'logs' || entry.name === AGENT_WIRE_RECORD_KEY) { continue; } if (entry.isSymbolicLink === true) continue; @@ -597,8 +583,8 @@ function createSessionId(): string { return `session_${randomUUID()}`; } -function forkedRecord(): PersistedWireRecord { - return { type: 'forked', time: Date.now() } as PersistedWireRecord; +function forkedRecord(): WireRecord { + return { type: 'forked', time: Date.now() }; } function forkCustomMetadata( diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index 8e9e2cc80..7bba21b30 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -34,7 +34,6 @@ import { StorageErrors } from '#/persistence/interface/storage'; import { TerminalErrors } from '#/os/interface/terminalErrors'; import { UsageErrors } from '#/agent/usage/errors'; import { WireErrors } from '#/wire/errors'; -import { WireRecordErrors } from '#/agent/wireRecord/errors'; export * from '#/_base/errors/codes'; export * from '#/_base/errors/errorMessage'; @@ -67,7 +66,6 @@ export { StorageErrors } from '#/persistence/interface/storage'; export { TerminalErrors } from '#/os/interface/terminalErrors'; export { UsageErrors } from '#/agent/usage/errors'; export { WireErrors } from '#/wire/errors'; -export { WireRecordErrors } from '#/agent/wireRecord/errors'; export const ErrorCodes = { ...CoreErrors.codes, @@ -97,5 +95,4 @@ export const ErrorCodes = { ...TerminalErrors.codes, ...UsageErrors.codes, ...WireErrors.codes, - ...WireRecordErrors.codes, } as const; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index f7bdfd0c1..7c819c018 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -19,9 +19,10 @@ export * from '#/_base/log/logConfig'; export * from '#/_base/log/formatter'; export * from '#/_base/log/fileLog'; export * from '#/_base/log/logService'; -export { IAgentWireService, ISessionWireService } from '#/wire/tokens'; -export { type IWireService, type WireEmission } from '#/wire/wireService'; -export { defineDerivedModel, type DerivedModelDef } from '#/wire/model'; +export * from '#/wire/wire'; +export * from '#/wire/wireService'; +export * from '#/wire/record'; +export * from '#/wire/migration/migration'; export * from '#/session/sessionLog/sessionLogService'; export * from '#/app/telemetry/telemetry'; export * from '#/app/telemetry/events'; @@ -189,9 +190,6 @@ export * from '#/agent/swarm/swarm'; export * from '#/agent/swarm/swarmService'; export * from '#/agent/usage/usage'; export * from '#/agent/usage/usageService'; -export * from '#/agent/runtime/runtime'; -export * from '#/agent/runtime/runtimeOps'; -export * from '#/agent/runtime/runtimeService'; export * from '#/agent/toolDedupe/toolDedupe'; export * from '#/agent/toolDedupe/toolDedupeService'; import '#/agent/toolSelect/flag'; @@ -425,7 +423,6 @@ export * from '#/agent/prompt/promptService'; import '#/app/messageLegacy/errors'; export * from '#/app/messageLegacy/messageLegacy'; export * from '#/app/messageLegacy/messageLegacyService'; -export * from '#/agent/replayBuilder/replayTimelineModel'; export * from '#/agent/replayBuilder/types'; export * from '#/agent/shellCommand/shellCommand'; export * from '#/agent/shellCommand/shellCommandService'; @@ -464,7 +461,3 @@ export type { ToolContribution, ToolContributionOptions } from '#/agent/toolRegi export * from '#/agent/userTool/userTool'; export * from '#/agent/userTool/userToolOps'; export * from '#/agent/userTool/userToolService'; -export * from '#/agent/wireRecord/wireRecord'; -export * from '#/agent/wireRecord/wireRecordService'; -export * from '#/agent/wireRecord/agentWireService'; -export * from '#/agent/wireRecord/metadataOps'; diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts index e5dbf0d8a..4bb02fcbf 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts @@ -17,7 +17,7 @@ * It uses raw `node:fs` rather than `kaos`: the storage kernel needs direct * control over append offsets, fsync, atomic rename and streaming, which the * agent-execution-environment abstraction does not expose. Higher-level code - * (`wireRecord`, `blobStore`) goes through the Store / Storage interfaces above + * (wire journal, blob store) goes through the Store / Storage interfaces above * this backend, never `node:fs` directly. */ diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index dc4917e6c..473c0a15c 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -34,6 +34,7 @@ import { IConfigService } from '#/app/config/config'; import { IEventBus } from '#/app/event/eventBus'; import { ErrorCodes, Error2 } from '#/errors'; import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSection'; +import { PermissionModeConfiguredModel } from '#/agent/permissionMode/permissionModeOps'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { IAgentToolDedupeService } from '#/agent/toolDedupe/toolDedupe'; import { IAgentTaskService } from '#/agent/task/task'; @@ -49,14 +50,19 @@ import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; +import { IAgentGoalService } from '#/agent/goal/goal'; +import { IAgentPlanService } from '#/agent/plan/plan'; +import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IAgentBuiltinToolsRegistrar } from '#/agent/toolRegistry/builtinToolsRegistrar'; import { IAgentMediaToolsRegistrar } from '#/agent/media/mediaTools'; import { IImageConfigBridge } from '#/agent/media/imageConfigBridge'; import { IAgentMcpService } from '#/agent/mcp/mcp'; import { IAgentExternalHooksService } from '#/agent/externalHooks/externalHooks'; import { IAgentPluginService } from '#/agent/plugin/agentPlugin'; -import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; import { ISessionInteractionService } from '#/session/interaction/interaction'; +import { IWireService } from '#/wire/wire'; import { type AgentListFilter, type CreateAgentOptions, @@ -144,9 +150,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle private async doCreate(agentId: string, opts: CreateAgentOptions): Promise { const mcpReady = this.sessionMcp.ensureMcpReady(); - // Per-agent homedir → the wire-record persistence key (`hashKey(homedir)`). - // Bootstrap computes it under the session dir, mirroring v1's - // `/agents/`; business code never assembles the path itself. const agentHomedir = this.bootstrap.agentHomedir( this.ctx.workspaceId, this.ctx.sessionId, @@ -163,13 +166,14 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle agentId, // The only per-agent seed: identity facts. Every other agent-scope // service either derives its configuration from `IAgentScopeContext` - // (wire, wireRecord, blob) or resolves it through the scope tree (the + // (wire, blob) or resolves it through the scope tree (the // session's shared MCP manager via `ISessionMcpService`). { extra: [[IAgentScopeContext, makeAgentScopeContext({ agentId, agentScope })]] }, ) as IAgentScopeHandle; this.handles.set(agentId, handle); try { - await handle.accessor.get(IAgentWireRecordService).seal(); + const wire = handle.accessor.get(IWireService); + await wire.seal(); await this.sessionMetadata.registerAgent(agentId, { homedir: agentHomedir, type: agentId === 'main' ? 'main' : 'sub', @@ -180,6 +184,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle this.onDidCreateEmitter.fire(handle); this.igniteEagerServices(handle); await mcpReady; + await wire.restore(); await this.bindBootstrap(handle, opts); // Bootstrap (profile binding and the force-instantiated observer // services) is complete: drive the activity kernel `initializing → idle` @@ -232,6 +237,13 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle handle.accessor.get(IAgentToolSelectAnnouncementsService); handle.accessor.get(IAgentStepRetryService); handle.accessor.get(IAgentLoopContinuationService); + handle.accessor.get(IAgentContextMemoryService); + handle.accessor.get(IAgentContextInjectorService); + handle.accessor.get(IAgentGoalService); + handle.accessor.get(IAgentPlanService); + handle.accessor.get(IAgentTaskService); + handle.accessor.get(IAgentUserToolService); + handle.accessor.get(IAgentFullCompactionService); } private async bindBootstrap( @@ -241,12 +253,14 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle if (opts.binding !== undefined) { await handle.accessor.get(IAgentProfileService).bind(opts.binding); } - // Every fresh agent starts from the configured default permission posture; - // dispatchers that want a specific mode (subagent inheritance) set it on - // the child themselves after creation. On resume the wire replay - // overwrites this with the persisted mode. + // Apply the configured default only when restore found no persisted mode. + // A resumed Agent's journal owns its permission posture; callers that need + // an explicit override (for example subagent inheritance) do so after + // creation through the permission service. + const wire = handle.accessor.get(IWireService); const permissionMode = this.config.get(DEFAULT_PERMISSION_MODE_SECTION); - if (permissionMode !== undefined) { + const hasRestoredPermissionMode = wire.getModel(PermissionModeConfiguredModel); + if (permissionMode !== undefined && !hasRestoredPermissionMode) { handle.accessor.get(IAgentPermissionModeService).setMode(permissionMode); } } diff --git a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts index bfedd6ea0..52b4e3ce4 100644 --- a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts +++ b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts @@ -6,7 +6,7 @@ * (tick / coalesce / jitter / cursor), persists mutations through the * App-scoped `ICronTaskPersistence`, mirrors mutations as `cron.add` / * `cron.delete` / `cron.cursor` Ops on the main agent's `wire` (cross-scope - * borrow) so `wire.replay` can rebuild the `CronModel`, publishes `cron.fired` + * borrow) so wire restore can rebuild the `CronModel`, publishes `cron.fired` * to the main agent's `IEventBus`, steers the main agent * through `IAgentPromptService` when a task fires, and registers the cron * tools (`CronCreate` / `CronList` / `CronDelete`) into the main agent's @@ -39,7 +39,7 @@ import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle' import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import type { Op } from '#/wire/op'; -import { IAgentWireService } from '#/wire/tokens'; +import { IWireService } from '#/wire/wire'; import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; @@ -91,13 +91,13 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe this._register( this.agentLifecycle.onDidCreate((handle) => { if (handle.id !== 'main') return; - void this.bindMainAgent(handle); + this.bindMainAgent(handle); }), ); const existingMain = this.agentLifecycle.get('main'); if (existingMain) { - void this.bindMainAgent(existingMain); + this.bindMainAgent(existingMain); } this._register( @@ -107,24 +107,23 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe ); } - private async bindMainAgent(handle: IAgentScopeHandle): Promise { - await this.config.ready; - this.resolveClocks(); - const wire = handle.accessor.get(IAgentWireService); + private bindMainAgent(handle: IAgentScopeHandle): void { + const wire = handle.accessor.get(IWireService); this._register( - wire.onRestored(() => { + wire.hooks.onDidRestore.register('cron', async (_ctx, next) => { + await this.config.ready; + this.resolveClocks(); this.tasks.clear(); for (const [id, task] of wire.getModel(CronModel)) { this.tasks.set(id, task as CronTask); } - void this.loadFromStore({ replace: false }).then(() => this.start()); + await this.loadFromStore({ replace: false }); + await this.start(); + await next(); }), ); this.registerCronTools(handle); - - await this.loadFromStore(); - await this.start(); } private registerCronTools(handle: IAgentScopeHandle): void { @@ -485,7 +484,7 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe private dispatchCron(op: Op): void { const mainHandle = this.agentLifecycle.get('main'); if (!mainHandle) return; - mainHandle.accessor.get(IAgentWireService).dispatch(op); + mainHandle.accessor.get(IWireService).dispatch(op); } private signalCron(event: DomainEvent): void { diff --git a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts index 45c5dea05..a8eac5136 100644 --- a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts +++ b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts @@ -10,7 +10,7 @@ * subagent finishes, reloads `AGENTS.md` through the `profile` context helper * (over the os `hostFs` + host home dir, with the `bootstrap` brand dir) and * appends an `init`-variant system reminder to the main agent via - * `systemReminder`, then flushes the main agent's `wireRecord` log. Bound at + * `systemReminder`, then flushes the main agent's wire journal. Bound at * Session scope. * * Port of v1 `Session.generateAgentsMd()`. The main-agent lookup is a hard @@ -31,7 +31,7 @@ import { IAgentProfileService } from '#/agent/profile/profile'; import { loadAgentsMd } from '#/agent/profile/context'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; -import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; +import { IWireService } from '#/wire/wire'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; @@ -117,7 +117,7 @@ export class SessionInitService implements ISessionInitService { kind: 'injection', variant: 'init', }); - await main.accessor.get(IAgentWireRecordService).flush(); + await main.accessor.get(IWireService).flush(); } catch (error) { // User cancellations (Ctrl+C → cancelInit) must surface as aborts, not // as init failures — the TUI resets quietly on `isAbortError`. diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index fbaed0f29..28c14ad24 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -13,7 +13,9 @@ import type { Event } from '#/_base/event'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; export interface AgentMeta { - readonly homedir: string; + /** Absolute standard path retained for older v1 readers. Current readers + * derive the agent directory from the session scope and ignore this field. */ + readonly homedir?: string; readonly type?: 'main' | 'sub' | 'independent'; readonly parentAgentId?: string | null; readonly forkedFrom?: string; diff --git a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts index 25e27587e..8d9fb24de 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts @@ -4,21 +4,17 @@ * Holds the session's shared todo list as a stateless facade over the main * agent's `TodoModel`: `getTodos` reads `wire.getModel(TodoModel)` live, and * every mutation only dispatches a `tools.update_store` Op to the main agent's - * wire (the - * single source of truth and replayable timeline); `onDidChange` is bridged - * from `wire.subscribe(TodoModel)`. The service keeps no list copy of its own, - * so the live view and the post-replay view can never drift. Binds the + * wire (the single source of truth and replayable timeline), then emits + * `onDidChange` from the rebuilt Model. The service keeps no list copy of its + * own, so the live view and the post-replay view can never drift. Binds the * `TodoListTool` and the stale-todo reminder into every agent (`onDidCreate`), - * and the model subscription into the main agent (`onDidCreateMain`), * borrowing each agent's services through its `IAgentScopeHandle.accessor`. - * Per-agent bindings are disposed when the agent is disposed. Bound at Session - * scope. + * Per-agent bindings are disposed when the agent is disposed. Bound at + * Session scope. * - * Debt: the session's todo list is still persisted on the MAIN agent's wire (a - * Session → Agent edge), so it follows the main agent's lifetime. Once - * `ISessionWireService` is wired up with its own log + replay, move `TodoModel` - * there — swap `@IAgentWireService` for `@ISessionWireService` and drop the - * main-agent subscription. The stateless facade makes that a one-line change. + * The session owns the todo facade and tool bindings, while the main Agent wire + * owns the replayable state. This is an explicit cross-scope orchestration + * boundary: there is no second session-level wire aggregate or journal. */ import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; @@ -30,7 +26,7 @@ import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInj import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; -import { IAgentWireService } from '#/wire/tokens'; +import { IWireService } from '#/wire/wire'; import { ISessionTodoService } from './sessionTodo'; import { TodoModel, todoSet } from './todoOps'; @@ -55,7 +51,6 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic this._register( this.agentLifecycle.onDidCreate((handle) => { this.bindAgent(handle); - if (handle.id === MAIN_AGENT_ID) this.bindMainWire(handle); }), ); this._register( @@ -64,7 +59,6 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic for (const handle of this.agentLifecycle.list()) { this.bindAgent(handle); - if (handle.id === MAIN_AGENT_ID) this.bindMainWire(handle); } this._register( @@ -79,7 +73,7 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic getTodos(): readonly TodoItem[] { const main = this.agentLifecycle.get(MAIN_AGENT_ID); if (main === undefined) return []; - return main.accessor.get(IAgentWireService).getModel(TodoModel); + return main.accessor.get(IWireService).getModel(TodoModel); } setTodos(todos: readonly TodoItem[]): void { @@ -97,16 +91,9 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic private dispatchTodoSet(todos: readonly TodoItem[]): void { const main = this.agentLifecycle.get(MAIN_AGENT_ID); if (main === undefined) return; - const wire = main.accessor.get(IAgentWireService); + const wire = main.accessor.get(IWireService); wire.dispatch(todoSet({ key: 'todo', value: todos })); - } - - private bindMainWire(handle: IAgentScopeHandle): void { - const wire = handle.accessor.get(IAgentWireService); - const disposable = wire.subscribe(TodoModel, (state) => { - this.onDidChangeEmitter.fire(state); - }); - this.trackAgentBinding(handle.id, disposable); + this.onDidChangeEmitter.fire(wire.getModel(TodoModel)); } private bindAgent(handle: IAgentScopeHandle): void { diff --git a/packages/agent-core-v2/src/session/todo/todoOps.ts b/packages/agent-core-v2/src/session/todo/todoOps.ts index 148d8346b..29a931aa4 100644 --- a/packages/agent-core-v2/src/session/todo/todoOps.ts +++ b/packages/agent-core-v2/src/session/todo/todoOps.ts @@ -11,10 +11,10 @@ * render, the stale reminder, the compaction summary) can trust the Model * without re-validating. Consumed cross-scope by the Session-scope * `SessionTodoService`: it dispatches to the MAIN agent's wire (the single - * source of truth and replayable timeline) and, on `wire.onRestored`, reads the - * rebuilt Model back from that same wire. The Ops register into the global - * `OP_REGISTRY` at import time, so they are in place before the main agent - * replays. + * source of truth and replayable timeline), and `getTodos` reads the rebuilt + * Model back from that same wire after restore. The Ops register into the + * global `OP_REGISTRY` at import time, so they are in place before the main + * agent restores. */ import { z } from 'zod'; diff --git a/packages/agent-core-v2/src/wire/errors.ts b/packages/agent-core-v2/src/wire/errors.ts index e109cf6e2..30e934b06 100644 --- a/packages/agent-core-v2/src/wire/errors.ts +++ b/packages/agent-core-v2/src/wire/errors.ts @@ -4,7 +4,7 @@ * * Aggregates the wire domain's coded errors: `DuplicateOpError` (thrown by * `defineOp` in `op.ts`) and `CycleError` (thrown by the dispatch drain in - * `wireServiceImpl.ts`) stay co-located with their throw sites but extend + * `wireService.ts`) stay co-located with their throw sites but extend * `WireError`; `wire.unknown_record` is constructed here for replay-time * reporting of records whose Op type is absent from `OP_REGISTRY`. */ @@ -17,6 +17,7 @@ export const WireErrors = { WIRE_DUPLICATE_OP: 'wire.duplicate_op', WIRE_CYCLE: 'wire.cycle', WIRE_UNKNOWN_RECORD: 'wire.unknown_record', + RECORDS_WRITE_FAILED: 'records.write_failed', }, info: { 'wire.duplicate_op': { @@ -37,6 +38,11 @@ export const WireErrors = { public: true, action: 'The record was written by a newer version; upgrade or drop it.', }, + 'records.write_failed': { + title: 'Wire journal write failed', + retryable: false, + public: true, + }, }, } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/agent/wireRecord/migration/migration.ts b/packages/agent-core-v2/src/wire/migration/migration.ts similarity index 86% rename from packages/agent-core-v2/src/agent/wireRecord/migration/migration.ts rename to packages/agent-core-v2/src/wire/migration/migration.ts index 41e0fe5bb..73508ed10 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/migration/migration.ts +++ b/packages/agent-core-v2/src/wire/migration/migration.ts @@ -1,3 +1,5 @@ +import type { WireRecord } from '#/wire/record'; + import { migrateV1_0ToV1_1 } from './v1.1'; import { migrateV1_1ToV1_2 } from './v1.2'; import { migrateV1_2ToV1_3 } from './v1.3'; @@ -10,12 +12,9 @@ export { migrateV1_3ToV1_4, }; -export const AGENT_WIRE_PROTOCOL_VERSION = '1.4'; +export const WIRE_PROTOCOL_VERSION = '1.4'; -export interface WireMigrationRecord { - readonly type: string; - [key: string]: unknown; -} +export type WireMigrationRecord = WireRecord; export interface WireMigration { readonly sourceVersion: string; @@ -31,17 +30,17 @@ const MIGRATIONS: readonly WireMigration[] = [ ]; export function isNewerWireVersion(readVersion: string): boolean { - return compareWireVersions(readVersion, AGENT_WIRE_PROTOCOL_VERSION) > 0; + return compareWireVersions(readVersion, WIRE_PROTOCOL_VERSION) > 0; } export function resolveWireMigrations(readVersion: string): readonly WireMigration[] { - if (compareWireVersions(readVersion, AGENT_WIRE_PROTOCOL_VERSION) >= 0) { + if (compareWireVersions(readVersion, WIRE_PROTOCOL_VERSION) >= 0) { return []; } const migrations: WireMigration[] = []; let version = readVersion; - while (compareWireVersions(version, AGENT_WIRE_PROTOCOL_VERSION) < 0) { + while (compareWireVersions(version, WIRE_PROTOCOL_VERSION) < 0) { const migration = findMigration(version); if (migration === undefined) { throw new Error(`Missing wire migration for version ${version}`); diff --git a/packages/agent-core-v2/src/agent/wireRecord/migration/v1.1.ts b/packages/agent-core-v2/src/wire/migration/v1.1.ts similarity index 100% rename from packages/agent-core-v2/src/agent/wireRecord/migration/v1.1.ts rename to packages/agent-core-v2/src/wire/migration/v1.1.ts diff --git a/packages/agent-core-v2/src/agent/wireRecord/migration/v1.2.ts b/packages/agent-core-v2/src/wire/migration/v1.2.ts similarity index 100% rename from packages/agent-core-v2/src/agent/wireRecord/migration/v1.2.ts rename to packages/agent-core-v2/src/wire/migration/v1.2.ts diff --git a/packages/agent-core-v2/src/agent/wireRecord/migration/v1.3.ts b/packages/agent-core-v2/src/wire/migration/v1.3.ts similarity index 100% rename from packages/agent-core-v2/src/agent/wireRecord/migration/v1.3.ts rename to packages/agent-core-v2/src/wire/migration/v1.3.ts diff --git a/packages/agent-core-v2/src/agent/wireRecord/migration/v1.4.ts b/packages/agent-core-v2/src/wire/migration/v1.4.ts similarity index 100% rename from packages/agent-core-v2/src/agent/wireRecord/migration/v1.4.ts rename to packages/agent-core-v2/src/wire/migration/v1.4.ts diff --git a/packages/agent-core-v2/src/wire/model.ts b/packages/agent-core-v2/src/wire/model.ts index 83e303134..2693f2529 100644 --- a/packages/agent-core-v2/src/wire/model.ts +++ b/packages/agent-core-v2/src/wire/model.ts @@ -29,11 +29,10 @@ * cast happens once inside `WireService`. * * A primary Model may register cross-model reducers keyed by foreign op types: - * `WireService.execute` runs them on both dispatch and replay, so v1-derived + * `WireService` runs them on both dispatch and restore, so v1-derived * restore effects can stay replayable without persisting extra records. - * * `DeepReadonly` recursively maps a state type to its deeply-readonly view - * for the references returned by `getModel` / `subscribe`: functions pass + * for the references returned by `getModel`: functions pass * through, `Map` / `Set` widen to `ReadonlyMap` / `ReadonlySet`, arrays and * tuples widen to `ReadonlyArray`, plain objects become a readonly mapped type, * and primitives are unchanged. It pairs with the runtime `Object.freeze` @@ -42,12 +41,12 @@ import { bindDefineOp, type DefineOpFn } from '#/wire/op'; import type { ModelReducers } from '#/wire/types'; -import type { PersistedRecord } from '#/wire/wireService'; +import type { WireRecord } from '#/wire/record'; export type PartsTransformer = (parts: readonly unknown[]) => Promise; export interface ModelBlobCodec { - dehydrate(record: PersistedRecord, transform: PartsTransformer): PersistedRecord | Promise; + dehydrate(record: WireRecord, transform: PartsTransformer): WireRecord | Promise; rehydrate(state: S, transform: PartsTransformer): S | Promise; } @@ -95,22 +94,6 @@ export function defineModel( return def; } -export interface DerivedModelDef { - readonly name: string; - readonly initial: () => S; - readonly reducers: Readonly>; - readonly blobs?: ModelBlobCodec; -} - -export function defineDerivedModel( - name: string, - initial: () => S, - reducers: ModelReducers, - opts?: { blobs?: ModelBlobCodec }, -): DerivedModelDef { - return { name, initial, reducers, blobs: opts?.blobs }; -} - export type DeepReadonly = T extends (...args: infer A) => infer R ? (...args: A) => R : T extends ReadonlyMap diff --git a/packages/agent-core-v2/src/wire/op.ts b/packages/agent-core-v2/src/wire/op.ts index ea2b0db12..d635ddb5d 100644 --- a/packages/agent-core-v2/src/wire/op.ts +++ b/packages/agent-core-v2/src/wire/op.ts @@ -8,12 +8,12 @@ * callable (`goalCreate(payload)`) and inspectable (`goalCreate.apply`, * `goalCreate.type`). Every Op carries a mandatory pure `apply` and may carry * an optional `toEvent` that derives an `IEventBus` fact from the payload and - * the post-apply state (published by `WireService` on `dispatch`, never on - * `replay`). A mandatory `schema` (zod, declared before `apply`) is the + * the post-apply state (published by `WireService` on live `dispatch`, + * never during `restore`). A mandatory `schema` (zod, declared before `apply`) is the * payload's single source of truth: `P` is inferred from it, so Op authors * never restate payload interfaces, and it is stored on the descriptor for * payload validation at wire boundaries; the runtime paths (`dispatch` / - * `replay`) never consult it. The descriptor's payload is erased + * `restore`) never consult it. The descriptor's payload is erased * to `any` on `Op.descriptor` (mirroring `OP_REGISTRY`) so `Op` stays * covariant in `P` — a heterogeneous batch of Ops, each with a different * payload type, stays assignable to the single `dispatch(...ops: Op[])` rest @@ -23,8 +23,7 @@ * definition into the `types.ts` registries (which map op types to `typeof` * the Op); registration constrains only the persistence policy — a registered * type must honor its map, an unregistered type keeps its free `persist` - * option. Descriptors may opt out of timestamp stamping (`stamp: false`) for - * the metadata envelope. Scope-agnostic. + * option. Scope-agnostic. */ import type { z } from 'zod'; @@ -50,7 +49,6 @@ export interface OpDescriptor { readonly apply: (state: S, payload: P) => S; readonly toEvent?: (payload: P, state: S) => unknown; readonly persist?: boolean; - readonly stamp?: boolean; } export interface Op { @@ -67,7 +65,6 @@ interface OpBehaviorOptions { readonly schema: z.ZodType

; readonly apply: (state: S, payload: P) => S; readonly toEvent?: (payload: P, state: S) => unknown; - readonly stamp?: boolean; } type RegisteredOpConstraint = K extends ConflictingOpType @@ -122,7 +119,6 @@ export function defineOp( apply: behavior.apply, toEvent: behavior.toEvent, persist: behavior.persist, - stamp: behavior.stamp, }; OP_REGISTRY.set(type, descriptor); const factory = (payload: P): Op => ({ type, payload, descriptor }); diff --git a/packages/agent-core-v2/src/wire/record.ts b/packages/agent-core-v2/src/wire/record.ts new file mode 100644 index 000000000..3a02da4ea --- /dev/null +++ b/packages/agent-core-v2/src/wire/record.ts @@ -0,0 +1,59 @@ +/** + * `wire` domain (L2) — the persisted journal record language. + * + * A `WireRecord` is the flat JSONL representation of one persisted Op. The + * first line of an Agent journal is a `WireMetadataRecord`; metadata is a + * journal envelope, not an Op, so it never enters the model reducer registry. + * This module owns only pure encoding and decoding. + */ + +import type { Op } from '#/wire/op'; + +import { WIRE_PROTOCOL_VERSION } from './migration/migration'; + +export const AGENT_WIRE_RECORD_KEY = 'wire.jsonl'; + +export interface WireRecord { + readonly type: string; + readonly time?: number; + readonly [key: string]: unknown; +} + +export interface WireMetadataRecord extends WireRecord { + readonly type: 'metadata'; + readonly protocol_version: string; + readonly created_at: number; +} + +export function createWireMetadataRecord(now = Date.now()): WireMetadataRecord { + return { + type: 'metadata', + protocol_version: WIRE_PROTOCOL_VERSION, + created_at: now, + }; +} + +export function isWireMetadataRecord(record: WireRecord): record is WireMetadataRecord { + return ( + record.type === 'metadata' && + typeof record['protocol_version'] === 'string' && + typeof record['created_at'] === 'number' + ); +} + +export function opToWireRecord(op: Op, now = Date.now()): WireRecord { + const payload = op.payload; + const record: Record = + payload !== null && typeof payload === 'object' && !Array.isArray(payload) + ? { type: op.type, ...(payload as Record) } + : { type: op.type, payload }; + if (record['time'] === undefined) record['time'] = now; + return record as WireRecord; +} + +export function wireRecordToPayload(record: WireRecord): unknown { + const { type: _type, time: _time, ...payload } = record; + return Object.keys(payload).length === 1 && 'payload' in payload + ? payload['payload'] + : payload; +} diff --git a/packages/agent-core-v2/src/wire/tokens.ts b/packages/agent-core-v2/src/wire/tokens.ts deleted file mode 100644 index 144060b42..000000000 --- a/packages/agent-core-v2/src/wire/tokens.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * `wire` domain (L2) — scope-specific DI tokens (`IAgentWireService`, - * `ISessionWireService`) over the single `IWireService` contract. - * - * One `WireService` implementation serves every scope; per-scope isolation - * comes from distinct tokens, each seeded with its own persistence key at scope - * creation. Domain services inject the token for their scope - * (`@IAgentWireService`, `@ISessionWireService`). No App-scope token yet — add - * one when a use case appears. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - -import type { IWireService } from './wireService'; - -export const IAgentWireService: ServiceIdentifier = - createDecorator('agentWireService'); - -export const ISessionWireService: ServiceIdentifier = - createDecorator('sessionWireService'); diff --git a/packages/agent-core-v2/src/wire/wire.ts b/packages/agent-core-v2/src/wire/wire.ts new file mode 100644 index 000000000..86e36fd66 --- /dev/null +++ b/packages/agent-core-v2/src/wire/wire.ts @@ -0,0 +1,36 @@ +/** + * `wire` domain (L2) — the single Agent-scoped wire aggregate contract. + * + * The service owns one Agent's replayable model state and its journal as one + * consistency boundary: restore reads, validates, migrates, rewrites, replays, + * rehydrates, and then runs the ordered restore hook. Seal initializes a fresh + * journal before session metadata makes the Agent visible to legacy readers. + * Live dispatch applies an Op and appends its record. Callers do not coordinate + * journal and model state through separate services. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Hooks } from '#/hooks'; + +import type { DeepReadonly, ModelDef } from './model'; +import type { Op } from './op'; + +export type WireHooks = { + readonly onDidRestore: Record; +}; + +export interface IWireService { + readonly _serviceBrand: undefined; + + readonly hooks: Hooks; + + dispatch(...ops: Op[]): void; + seal(): Promise; + restore(): Promise; + flush(): Promise; + + getModel(model: ModelDef): DeepReadonly; +} + +export const IWireService: ServiceIdentifier = + createDecorator('wireService'); diff --git a/packages/agent-core-v2/src/wire/wireService.ts b/packages/agent-core-v2/src/wire/wireService.ts index c6f54c9a2..d4a12a093 100644 --- a/packages/agent-core-v2/src/wire/wireService.ts +++ b/packages/agent-core-v2/src/wire/wireService.ts @@ -1,67 +1,313 @@ /** - * `wire` domain (L2) — `IWireService` contract and its supporting types - * (`PersistedRecord`, `OpGroup`, `ModelChange`). + * `wire` domain (L2) — `IWireService` implementation. * - * The scope-agnostic state-machine engine: `dispatch` persists + applies + - * notifies (OpGroup `{ silent: false }`), `replay` (async — rehydrates blob - * references via `ModelDef.blobs` first) applies only (`{ silent: true }`); - * `flush` drains the serialized persist queue. Reads go through `getModel` / - * `subscribe`; the live append-log record stream streams via `onEmission`, - * restore completion via `onRestored`, and Op-derived facts flow out through - * `IEventBus` (see `op.ts` `toEvent`). A single implementation serves every - * scope — instances are isolated per scope through the distinct DI tokens in - * `tokens`, each seeded with its own persistence key. `PersistedRecord` is the - * on-the-wire append-log shape (`wire.jsonl`): intentionally flat - * (`{ type, ...payload }`, optional `time`) so it stays byte-compatible with the - * existing wire journal (`{ type, time?, ...fields }`) — payload fields - * sit at the top level next to `type`, never nested under a `payload` key; the - * index signature keeps it scope-agnostic and domains narrow via their Op - * payload types. Scope-agnostic. + * `WireService` is the sole runtime owner of an Agent wire aggregate. It + * combines the model reducer engine with the `wire.jsonl` journal protocol, + * including creation-time sealing, metadata, migrations, atomic healing + * rewrites, blob dehydration and rehydration plus an ordered post-restore hook. + * It is bound at Agent scope because the aggregate identity is the Agent + * identity. */ -import type { IDisposable } from '#/_base/di/lifecycle'; +/* eslint-disable @typescript-eslint/no-explicit-any */ -import type { DeepReadonly, DerivedModelDef, ModelDef } from './model'; +import { InstantiationType } from '#/_base/di/extensions'; +import { BugIndicatingError } from '#/_base/errors/errors'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentBlobService } from '#/agent/blob/agentBlobService'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; +import type { ContentPart } from '#/app/llmProtocol/message'; +import { OrderedHookSlot } from '#/hooks'; +import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; +import { StorageError, StorageErrors } from '#/persistence/interface/storage'; + +import { IWireService } from './wire'; +import { WireError, WireErrors } from './errors'; +import { + WIRE_PROTOCOL_VERSION, + isNewerWireVersion, + migrateWireRecord, + resolveWireMigrations, + type WireMigration, +} from './migration/migration'; +import type { DeepReadonly, ModelDef, PartsTransformer } from './model'; +import { MODEL_CROSS_REDUCERS } from './model'; import type { Op } from './op'; +import { OP_REGISTRY } from './op'; +import { + AGENT_WIRE_RECORD_KEY, + createWireMetadataRecord, + isWireMetadataRecord, + opToWireRecord, + wireRecordToPayload, + type WireRecord, +} from './record'; -export interface PersistedRecord { - readonly type: string; - readonly time?: number; - readonly [key: string]: unknown; +const MAX_DRAIN = 100; + +export class CycleError extends WireError { + constructor(readonly depth: number, readonly opTypes: readonly string[]) { + super( + WireErrors.codes.WIRE_CYCLE, + `Wire dispatch cascade exceeded MAX_DRAIN (${depth}); possible op cycle`, + { details: { depth, opTypes: opTypes.slice(0, 20) } }, + ); + this.name = 'CycleError'; + } } -export interface OpGroup { +interface ModelInstance { + state: any; +} + +interface OpGroup { readonly ops: readonly Op[]; readonly silent: boolean; } -export interface ModelChange { - readonly state: S; - readonly prev: S; +type RestorePhase = 'new' | 'restoring' | 'ready' | 'failed'; + +export class WireService extends Disposable implements IWireService { + declare readonly _serviceBrand: undefined; + + readonly hooks: IWireService['hooks'] = { + onDidRestore: new OrderedHookSlot(), + }; + + private readonly models = new Map, ModelInstance>(); + private readonly wireScope: string; + + private restorePhase: RestorePhase = 'new'; + private dispatching = false; + private queue: Op[] = []; + private drainDepth = 0; + private persistQueue: Promise | undefined; + + constructor( + @IAgentScopeContext scopeContext: IAgentScopeContext, + @IAppendLogStore private readonly log: IAppendLogStore, + @IAgentBlobService private readonly blobService: IAgentBlobService, + @IEventBus private readonly eventBus: IEventBus, + ) { + super(); + this.wireScope = scopeContext.scope(); + this._register(this.log.acquire(this.wireScope, AGENT_WIRE_RECORD_KEY)); + } + + getModel(model: ModelDef): DeepReadonly { + return this.ensureModel(model).state as DeepReadonly; + } + + dispatch(...ops: Op[]): void { + if (ops.length === 0) return; + if (this.dispatching) { + this.queue.push(...ops); + return; + } + this.dispatching = true; + try { + this.execute({ ops, silent: false }); + while (this.queue.length > 0) { + if (++this.drainDepth > MAX_DRAIN) { + throw new CycleError(this.drainDepth, this.queue.map((op) => op.type)); + } + this.execute({ ops: this.queue.splice(0), silent: false }); + } + } finally { + this.queue.length = 0; + this.dispatching = false; + this.drainDepth = 0; + } + } + + async seal(): Promise { + for await (const record of this.log.read(this.wireScope, AGENT_WIRE_RECORD_KEY)) { + void record; + return; + } + this.appendRecord(createWireMetadataRecord()); + } + + async restore(): Promise { + if ( + this.restorePhase === 'restoring' || + this.restorePhase === 'failed' || + this.restorePhase === 'ready' + ) { + throw new BugIndicatingError(`Agent wire restore called while phase is ${this.restorePhase}`); + } + this.restorePhase = 'restoring'; + try { + const source = this.log.read(this.wireScope, AGENT_WIRE_RECORD_KEY); + let migrations: readonly WireMigration[] = []; + let rewrittenRecords: WireRecord[] | undefined; + let newerWireVersion = false; + let recordIndex = 0; + let hasRecords = false; + + for await (const sourceRecord of source) { + if (!hasRecords) { + hasRecords = true; + if (sourceRecord.type !== 'metadata') { + rewrittenRecords = [createWireMetadataRecord()]; + } else if (!isWireMetadataRecord(sourceRecord)) { + throw new StorageError( + StorageErrors.codes.STORAGE_CORRUPTED, + 'Agent wire metadata is malformed', + { details: { scope: this.wireScope, key: AGENT_WIRE_RECORD_KEY } }, + ); + } else if (isNewerWireVersion(sourceRecord.protocol_version)) { + newerWireVersion = true; + } else { + migrations = resolveWireMigrations(sourceRecord.protocol_version); + if (sourceRecord.protocol_version !== WIRE_PROTOCOL_VERSION) { + rewrittenRecords = []; + } + } + } + + const migratedRecord = migrateWireRecord(sourceRecord, migrations); + const record = + !newerWireVersion && migratedRecord.type === 'metadata' + ? { ...migratedRecord, protocol_version: WIRE_PROTOCOL_VERSION } + : migratedRecord; + rewrittenRecords?.push(record); + if (record.type === 'metadata') continue; + + this.replayRecord(record, recordIndex); + recordIndex++; + } + + if (!hasRecords) { + rewrittenRecords = [createWireMetadataRecord()]; + } + if (rewrittenRecords !== undefined) { + await this.log.rewrite(this.wireScope, AGENT_WIRE_RECORD_KEY, rewrittenRecords); + } + + await this.rehydrateModels(); + this.restorePhase = 'ready'; + await this.hooks.onDidRestore.run({}); + } catch (error) { + this.restorePhase = 'failed'; + throw error; + } + } + + async flush(): Promise { + await this.persistQueue; + await this.log.flush(); + } + + private replayRecord(record: WireRecord, index: number): void { + const descriptor = OP_REGISTRY.get(record.type); + if (descriptor === undefined) { + onUnexpectedError( + new WireError( + WireErrors.codes.WIRE_UNKNOWN_RECORD, + `Unknown wire record type '${record.type}' skipped during restore`, + { details: { type: record.type, index } }, + ), + ); + return; + } + this.execute({ + ops: [{ type: record.type, payload: wireRecordToPayload(record), descriptor }], + silent: true, + }); + } + + private execute(group: OpGroup): void { + for (const op of group.ops) { + const inst = this.ensureModel(op.descriptor.model); + const prev = inst.state; + inst.state = Object.freeze(op.descriptor.apply(prev, op.payload)); + if (!group.silent) { + if (op.descriptor.persist !== false) { + const record = opToWireRecord(op); + this.appendToJournal(record, op.descriptor.model); + } + const event = op.descriptor.toEvent?.(op.payload, inst.state); + if (event !== undefined) { + this.eventBus.publish(event as DomainEvent); + } + } + const crossReducers = MODEL_CROSS_REDUCERS.get(op.type); + if (crossReducers !== undefined) { + for (const entry of crossReducers) { + if (entry.model === op.descriptor.model) continue; + const crossInst = this.ensureModel(entry.model); + crossInst.state = Object.freeze(entry.reducer(crossInst.state, op.payload)); + } + } + } + } + + private ensureModel(def: ModelDef): ModelInstance { + let inst = this.models.get(def); + if (inst === undefined) { + inst = { state: Object.freeze(def.initial()) }; + this.models.set(def, inst); + } + return inst; + } + + private appendToJournal(record: WireRecord, model: ModelDef): void { + const dehydrate = model.blobs?.dehydrate?.bind(model.blobs); + if (dehydrate === undefined && this.persistQueue === undefined) { + try { + this.appendRecord(record); + } catch (error) { + onUnexpectedError(error); + } + return; + } + const transform: PartsTransformer = (parts) => + this.blobService.offloadParts( + parts as readonly ContentPart[], + ) as Promise; + const queued = (this.persistQueue ?? Promise.resolve()) + .then(async () => { + let output = record; + if (dehydrate !== undefined) { + const prepared = dehydrate(record, transform); + output = await prepared; + } + this.appendRecord(output); + }) + .catch((error: unknown) => onUnexpectedError(error)); + this.persistQueue = queued; + void queued.then(() => { + if (this.persistQueue === queued) this.persistQueue = undefined; + }); + } + + private appendRecord(record: WireRecord): void { + this.log.append(this.wireScope, AGENT_WIRE_RECORD_KEY, record, { + onError: onUnexpectedError, + }); + } + + private async rehydrateModels(): Promise { + const transform: PartsTransformer = (parts) => + this.blobService.loadParts( + parts as readonly ContentPart[], + ) as Promise; + for (const [def, inst] of this.models) { + if (def.blobs?.rehydrate === undefined) continue; + const result = def.blobs.rehydrate(inst.state, transform); + inst.state = Object.freeze(await result); + } + } } -export interface ReplayResult { - readonly unknownRecords: number; -} - -export interface WireEmission { - readonly type: 'record'; - readonly record: PersistedRecord; -} - -export interface IWireService { - readonly _serviceBrand: undefined; - - dispatch(...ops: Op[]): void; - replay(...records: PersistedRecord[]): Promise; - flush(): Promise; - - attach(model: DerivedModelDef): IDisposable; - getModel(model: ModelDef | DerivedModelDef): DeepReadonly; - subscribe( - model: ModelDef | DerivedModelDef, - handler: (state: DeepReadonly, prev: DeepReadonly) => void, - ): IDisposable; - onEmission(handler: (emission: WireEmission) => void): IDisposable; - onRestored(handler: () => void | Promise): IDisposable; -} +registerScopedService( + LifecycleScope.Agent, + IWireService, + WireService, + InstantiationType.Eager, + 'wire', +); diff --git a/packages/agent-core-v2/src/wire/wireServiceImpl.ts b/packages/agent-core-v2/src/wire/wireServiceImpl.ts deleted file mode 100644 index fe13b1e8d..000000000 --- a/packages/agent-core-v2/src/wire/wireServiceImpl.ts +++ /dev/null @@ -1,403 +0,0 @@ -/** - * `wire` domain (L2) — `WireService`, the single scope-agnostic implementation - * of `IWireService`, plus its construction options (`WireServiceOptions`) - * and the coded `CycleError`. - * - * One class serves every scope: per-scope isolation comes from the distinct DI - * tokens in `tokens`, each seeded with its own `WireServiceOptions` - * (`logScope` / `logKey`) as the leading (non-service) constructor argument - * through a `SyncDescriptor`, mirroring `WireRecordServiceOptions`. `dispatch` - * and `replay` both lower to one primitive, `execute(OpGroup)` — apply-all THEN - * onChange-all, so a subscriber never observes a partially-applied group — with - * `dispatch` adding persistence + emission + Op-derived `IEventBus` events - * (`silent: false`) and `replay` staying silent (apply only, skipping - * unknown record types, then `onRestored`). A reentrancy guard (`dispatching` + - * `queue` + `drain`, capped by `MAX_DRAIN = 100`) lets onChange handlers enqueue - * further ops without reentering `execute`; a cascade past the cap throws - * `CycleError` (`wire.cycle`), co-located here like `DuplicateOpError` - * (`wire.duplicate_op` in `op.ts`) — both extend `WireError` from - * `wire/errors.ts`. After every - * `apply` the new state is `Object.freeze`d — the runtime half of the - * immutability guarantee whose compile-time half is `DeepReadonly`. Internally - * each per-model instance is erased to `any` (the same localized erasure as - * `OP_REGISTRY`) and restored at the public boundary; an Op's optional `toEvent` - * derives an `IEventBus` fact on `dispatch` (never on `replay`). - * - * Persists each dispatched op through `persistence` (`IAppendLogStore`) as a - * flat `{ type, ...payload }` record — scalar / array payloads nested so a - * JSONL line stays an object, stamped with `time` unless the op opts out - * (`stamp: false`, only the `metadata` envelope), with `type` / `time` - * stripped back out on replay. Ops declared `persist: false` apply and notify - * like any other but never reach the emission stream or the log — the on-disk - * record vocabulary stays exactly v1's. After each op, cross-model reducers - * registered via `defineModel(..., { reducers })` (`MODEL_CROSS_REDUCERS`) - * fold the op into foreign primary models on both dispatch and replay. - * - * Blob handling is driven by each `ModelDef`'s optional `blobs` codec - * (`ModelBlobCodec`), which declares two symmetric directions: - * - * - **Dehydrate (dispatch → persist)**: `model.blobs.dehydrate(record, transform)` - * lets the model traverse its own record structure, pass each `ContentPart[]` - * through `transform` (which offloads oversized inline data to blob storage), - * and return the transformed record. `apply` and the live emission still see - * the original inline payload. Records whose model has no `blobs` codec - * short-circuit synchronously (no queue, no microtask). - * - * - **Rehydrate (replay → model)**: after all records are applied, - * `rehydrateModels` calls `model.blobs.rehydrate(state, transform)` on each - * model that declares a `blobs` codec, replacing blobref URLs with inline data - * *only* in the surviving final state — skipping I/O for data later removed by - * compaction (a 20×+ speedup for long sessions with many images). - * - * Scope-agnostic. - */ - -import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; -import { onUnexpectedError } from '#/_base/errors/unexpectedError'; -import { Emitter } from '#/_base/event'; -import { IAgentBlobService } from '#/agent/blob/agentBlobService'; -import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; -import type { ContentPart } from '#/app/llmProtocol/message'; -import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; - -import { WireError, WireErrors } from './errors'; -import type { DeepReadonly, DerivedModelDef, ModelDef, PartsTransformer } from './model'; -import { MODEL_CROSS_REDUCERS } from './model'; -import type { Op } from './op'; -import { OP_REGISTRY } from './op'; -import type { - IWireService, - ModelChange, - OpGroup, - PersistedRecord, - ReplayResult, - WireEmission, -} from './wireService'; - -const MAX_DRAIN = 100; - -export class CycleError extends WireError { - constructor(readonly depth: number, readonly opTypes: readonly string[]) { - super( - WireErrors.codes.WIRE_CYCLE, - `Wire dispatch cascade exceeded MAX_DRAIN (${depth}); possible op cycle`, - { - details: { depth, opTypes: opTypes.slice(0, 20) }, - }, - ); - this.name = 'CycleError'; - } -} - -export interface WireServiceOptions { - readonly logScope: string; - readonly logKey: string; -} - -interface ModelInstance { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - state: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - emitter: Emitter>; -} - -interface ReducerEntry { - readonly inst: ModelInstance; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly reducer: (state: any, payload: any) => any; -} - -export class WireService extends Disposable implements IWireService { - declare readonly _serviceBrand: undefined; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private readonly models = new Map, ModelInstance>(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private readonly derivedModels = new Map, ModelInstance>(); - private readonly reducerIndex = new Map(); - private readonly emissionEmitter = this._register(new Emitter()); - private readonly restoredHandlers = new Set<() => void | Promise>(); - - private dispatching = false; - private queue: Op[] = []; - private drainDepth = 0; - private persistQueue: Promise = Promise.resolve(); - - constructor( - private readonly options: WireServiceOptions, - @IAppendLogStore private readonly log?: IAppendLogStore, - @IAgentBlobService private readonly blobService?: IAgentBlobService, - @IEventBus private readonly eventBus?: IEventBus, - ) { - super(); - if (this.log !== undefined) { - this._register(this.log.acquire(this.options.logScope, this.options.logKey)); - } - } - - getModel(model: ModelDef | DerivedModelDef): DeepReadonly { - if ('reducers' in model) { - const inst = this.derivedModels.get(model); - return (inst?.state ?? Object.freeze(model.initial())) as DeepReadonly; - } - return this.ensureModel(model).state as DeepReadonly; - } - - subscribe( - model: ModelDef | DerivedModelDef, - handler: (state: DeepReadonly, prev: DeepReadonly) => void, - ): IDisposable { - const inst = 'reducers' in model - ? this.derivedModels.get(model) - : this.ensureModel(model); - if (inst === undefined) return { dispose: () => {} }; - return inst.emitter.event((change) => - handler(change.state as DeepReadonly, change.prev as DeepReadonly), - ); - } - - onEmission(handler: (emission: WireEmission) => void): IDisposable { - return this.emissionEmitter.event(handler); - } - - onRestored(handler: () => void | Promise): IDisposable { - this.restoredHandlers.add(handler); - return toDisposable(() => this.restoredHandlers.delete(handler)); - } - - attach(model: DerivedModelDef): IDisposable { - const inst: ModelInstance = { - state: Object.freeze(model.initial()), - emitter: new Emitter>(), - }; - this._register(inst.emitter); - this.derivedModels.set(model, inst); - - for (const [opType, reducer] of Object.entries(model.reducers)) { - if (reducer === undefined) continue; - let list = this.reducerIndex.get(opType); - if (list === undefined) { - list = []; - this.reducerIndex.set(opType, list); - } - list.push({ inst, reducer }); - } - - return { - dispose: () => { - this.derivedModels.delete(model); - for (const [opType, list] of this.reducerIndex) { - const filtered = list.filter((e) => e.inst !== inst); - if (filtered.length === 0) { - this.reducerIndex.delete(opType); - } else if (filtered.length !== list.length) { - this.reducerIndex.set(opType, filtered); - } - } - }, - }; - } - - dispatch(...ops: Op[]): void { - if (ops.length === 0) return; - if (this.dispatching) { - this.queue.push(...ops); - return; - } - this.dispatching = true; - try { - this.execute({ ops, silent: false }); - while (this.queue.length > 0) { - if (++this.drainDepth > MAX_DRAIN) { - throw new CycleError(this.drainDepth, this.queue.map((op) => op.type)); - } - this.execute({ ops: this.queue.splice(0), silent: false }); - } - } finally { - this.queue.length = 0; - this.dispatching = false; - this.drainDepth = 0; - } - } - - async replay(...records: PersistedRecord[]): Promise { - const ops: Op[] = []; - let unknownRecords = 0; - for (let index = 0; index < records.length; index++) { - const record = records[index]!; - const descriptor = OP_REGISTRY.get(record.type); - if (descriptor === undefined) { - unknownRecords++; - onUnexpectedError( - new WireError( - WireErrors.codes.WIRE_UNKNOWN_RECORD, - `Unknown wire record type '${record.type}' skipped during replay`, - { details: { type: record.type, index } }, - ), - ); - continue; - } - ops.push({ type: record.type, payload: recordToPayload(record), descriptor }); - } - this.execute({ ops, silent: true }); - await this.rehydrateModels(); - await this.fireRestored(); - return { unknownRecords }; - } - - async flush(): Promise { - await this.persistQueue; - await this.log?.flush(); - } - - private execute(group: OpGroup): void { - const changes: { inst: ModelInstance; change: ModelChange }[] = []; - - for (const op of group.ops) { - const inst = this.ensureModel(op.descriptor.model); - const prev = inst.state; - inst.state = Object.freeze(op.descriptor.apply(prev, op.payload)); - if (!group.silent) { - if (op.descriptor.persist !== false) { - const record = this.toRecord(op); - this.emissionEmitter.fire({ type: 'record', record }); - this.appendToWireLog(record, op.descriptor.model); - } - const event = op.descriptor.toEvent?.(op.payload, inst.state); - if (event !== undefined && this.eventBus !== undefined) { - this.eventBus.publish(event as DomainEvent); - } - } - if (inst.state !== prev) { - changes.push({ inst, change: { state: inst.state, prev } }); - } - - const entries = this.reducerIndex.get(op.type); - if (entries !== undefined) { - for (const entry of entries) { - const dPrev = entry.inst.state; - entry.inst.state = Object.freeze(entry.reducer(dPrev, op.payload)); - if (entry.inst.state !== dPrev) { - changes.push({ inst: entry.inst, change: { state: entry.inst.state, prev: dPrev } }); - } - } - } - - const crossReducers = MODEL_CROSS_REDUCERS.get(op.type); - if (crossReducers !== undefined) { - for (const entry of crossReducers) { - if (entry.model === op.descriptor.model) continue; - const crossInst = this.ensureModel(entry.model); - const crossPrev = crossInst.state; - crossInst.state = Object.freeze(entry.reducer(crossPrev, op.payload)); - if (crossInst.state !== crossPrev) { - changes.push({ - inst: crossInst, - change: { state: crossInst.state, prev: crossPrev }, - }); - } - } - } - } - - if (!group.silent) { - for (const { inst, change } of changes) { - inst.emitter.fire(change); - } - } - } - - private ensureModel(def: ModelDef): ModelInstance { - let inst = this.models.get(def); - if (inst === undefined) { - inst = { - state: Object.freeze(def.initial()), - emitter: new Emitter>(), - }; - this._register(inst.emitter); - this.models.set(def, inst); - } - return inst; - } - - private toRecord(op: Op): PersistedRecord { - const payload = op.payload; - const record: Record = - payload !== null && typeof payload === 'object' && !Array.isArray(payload) - ? { type: op.type, ...(payload as Record) } - : { type: op.type, payload }; - if (op.descriptor.stamp !== false && record['time'] === undefined) { - record['time'] = Date.now(); - } - return record as PersistedRecord; - } - - private async fireRestored(): Promise { - for (const handler of Array.from(this.restoredHandlers)) { - try { - await handler(); - } catch (error) { - onUnexpectedError(error); - } - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private appendToWireLog(record: PersistedRecord, model: ModelDef): void { - if (this.log === undefined) return; - if (this.blobService === undefined) { - this.log.append(this.options.logScope, this.options.logKey, record, { - onError: onUnexpectedError, - }); - return; - } - const dehydrate = model.blobs?.dehydrate?.bind(model.blobs); - const transform: PartsTransformer = (parts) => - this.blobService!.offloadParts( - parts as readonly ContentPart[], - ) as Promise; - this.persistQueue = this.persistQueue - .then(async () => { - let out = record; - if (dehydrate !== undefined) { - const prepared = dehydrate(record, transform); - out = isPromise(prepared) ? await prepared : prepared; - } - this.log?.append(this.options.logScope, this.options.logKey, out, { - onError: onUnexpectedError, - }); - }) - .catch((error: unknown) => onUnexpectedError(error)); - } - - private async rehydrateModels(): Promise { - if (this.blobService === undefined) return; - const transform: PartsTransformer = (parts) => - this.blobService!.loadParts( - parts as readonly ContentPart[], - ) as Promise; - for (const [def, inst] of this.models) { - if (def.blobs?.rehydrate === undefined) continue; - const result = def.blobs.rehydrate(inst.state, transform); - inst.state = Object.freeze(isPromise(result) ? await result : result); - } - for (const [def, inst] of this.derivedModels) { - if (def.blobs?.rehydrate === undefined) continue; - const result = def.blobs.rehydrate(inst.state, transform); - inst.state = Object.freeze(isPromise(result) ? await result : result); - } - } -} - -function recordToPayload(record: PersistedRecord): unknown { - const payload: Record = {}; - for (const key of Object.keys(record)) { - if (key === 'type' || key === 'time') continue; - payload[key] = record[key]; - } - return payload; -} - -function isPromise(value: T | Promise): value is Promise { - return value !== null && typeof (value as Promise).then === 'function'; -} diff --git a/packages/agent-core-v2/test/activity/activity.test.ts b/packages/agent-core-v2/test/activity/activity.test.ts index 7b42328bc..bac7e40e4 100644 --- a/packages/agent-core-v2/test/activity/activity.test.ts +++ b/packages/agent-core-v2/test/activity/activity.test.ts @@ -1,11 +1,9 @@ /** * `activity` kernel unit tests — drives the real `AgentActivityService` with a - * stub Session kernel and an in-memory wire service. + * stub Session kernel, event bus and in-memory wire service. * - * Asserts the PR1 turn-lane contract: `begin` admits a turn and rejects a - * concurrent one with `activity.agent_busy`, `cancel` moves the lane to - * `turn(ending)` and aborts the lease signal, and `lease.end` returns the lane - * to `idle` (idempotently). Run: + * Asserts turn admission and lifecycle transitions plus the live projection of + * streaming, tool calls, approvals, retries and step interruptions. Run: * `pnpm test -- test/activity/activity.test.ts` */ @@ -18,55 +16,72 @@ import { IAgentActivityService, ISessionActivityKernel } from '#/activity/activi import type { ActivityLease } from '#/activity/activity'; import { AgentActivityService } from '#/activity/agentActivityService'; import { SessionActivityKernel } from '#/activity/sessionActivityKernel'; +import type { PermissionApprovalRequestContext } from '#/agent/permissionGate/permissionGateService'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; +import { EventBusService } from '#/app/event/eventBusService'; import { ErrorCodes } from '#/errors'; -import { IAgentWireService, ISessionWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; -import { WireService } from '#/wire/wireServiceImpl'; import { stubSessionActivityKernel } from './stubs'; +import { registerTestAgentWireServices } from '../wire/stubs'; describe('AgentActivityService (turn lane)', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let activity: IAgentActivityService; + let eventBus: IEventBus; beforeEach(() => { disposables = new DisposableStore(); ix = createServices(disposables, { additionalServices: (reg) => { - reg.defineInstance( - IAgentWireService, - disposables.add(new WireService({ logScope: 'wire', logKey: 'activity' })), - ); + registerTestAgentWireServices(reg, 'wire/activity'); reg.defineInstance(ISessionActivityKernel, stubSessionActivityKernel()); reg.defineInstance( IAgentScopeContext, makeAgentScopeContext({ agentId: 'agent', agentScope: 'agent' }), ); + reg.define(IEventBus, EventBusService); reg.define(IAgentActivityService, AgentActivityService); }, }); activity = ix.get(IAgentActivityService); + eventBus = ix.get(IEventBus); }); afterEach(() => { disposables.dispose(); }); + function collectActivity(): DomainEvent<'agent.activity.updated'>[] { + const snapshots: DomainEvent<'agent.activity.updated'>[] = []; + disposables.add( + eventBus.subscribe('agent.activity.updated', (snapshot) => snapshots.push(snapshot)), + ); + return snapshots; + } + + function startTurn(): ActivityLease { + activity.markReady(); + const lease = activity.begin('turn', { turnId: 1 }); + eventBus.publish({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }); + eventBus.publish({ type: 'turn.step.started', turnId: 1, step: 1, stepId: 's1' }); + return lease; + } + it('starts initializing and admits a turn only after markReady', () => { - expect(activity.lane()).toBe('initializing'); + expect(activity.isIdle()).toBe(false); expect(() => activity.begin('turn')).toThrowError( expect.objectContaining({ code: ErrorCodes.ACTIVITY_INITIALIZING }), ); activity.markReady(); - expect(activity.lane()).toBe('idle'); + expect(activity.isIdle()).toBe(true); const lease: ActivityLease = activity.begin('turn'); expect(lease.kind).toBe('turn'); expect(lease.signal.aborted).toBe(false); - expect(activity.lane()).toBe('turn'); + expect(activity.isIdle()).toBe(false); lease.end('completed'); - expect(activity.lane()).toBe('idle'); + expect(activity.isIdle()).toBe(true); }); it('rejects a concurrent begin with activity.agent_busy', () => { @@ -85,17 +100,155 @@ describe('AgentActivityService (turn lane)', () => { lease.end('completed'); }); - it('cancel aborts the lease signal and keeps the lane until end', () => { + it('cancel aborts the lease signal and keeps the turn active until end', () => { activity.markReady(); const lease = activity.begin('turn'); expect(activity.cancel('stop')).toBe(true); expect(lease.signal.aborted).toBe(true); expect(lease.ending).toBe(true); - expect(activity.lane()).toBe('turn'); + expect(activity.isIdle()).toBe(false); lease.end('cancelled'); - expect(activity.lane()).toBe('idle'); + expect(activity.isIdle()).toBe(true); }); + it('publishes lifecycle independently from turn activity', () => { + const states: Array<{ lifecycle: string; hasTurn: boolean; ending?: boolean }> = []; + disposables.add( + eventBus.subscribe('agent.activity.updated', (state) => { + states.push({ + lifecycle: state.lifecycle, + hasTurn: state.turn !== undefined, + ending: state.turn?.ending, + }); + }), + ); + + activity.markReady(); + const lease = activity.begin('turn'); + activity.cancel(); + lease.end('cancelled'); + + expect(states).toEqual([ + { lifecycle: 'ready', hasTurn: false, ending: undefined }, + { lifecycle: 'ready', hasTurn: true, ending: false }, + { lifecycle: 'ready', hasTurn: true, ending: true }, + { lifecycle: 'ready', hasTurn: false, ending: undefined }, + ]); + }); + + it('publishes the first streaming delta and suppresses equivalent deltas', () => { + const snapshots = collectActivity(); + const lease = startTurn(); + const baseline = snapshots.length; + + eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'he' }); + eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'llo' }); + + expect(snapshots).toHaveLength(baseline + 1); + expect(snapshots.at(-1)?.turn).toMatchObject({ phase: 'streaming', stream: 'assistant' }); + lease.end('completed'); + }); + + it('projects active tool calls until their results arrive', () => { + const snapshots = collectActivity(); + const lease = startTurn(); + + eventBus.publish({ type: 'tool.call.started', turnId: 1, toolCallId: 'c1', name: 'Read', args: {} }); + eventBus.publish({ type: 'tool.call.started', turnId: 1, toolCallId: 'c2', name: 'Write', args: {} }); + expect(snapshots.at(-1)?.turn?.activeToolCalls.map((tool) => tool.toolCallId)).toEqual([ + 'c1', + 'c2', + ]); + + eventBus.publish({ type: 'tool.result', turnId: 1, toolCallId: 'c1', output: 'ok', isError: false }); + expect(snapshots.at(-1)?.turn?.activeToolCalls.map((tool) => tool.toolCallId)).toEqual(['c2']); + lease.end('completed'); + }); + + it('projects all pending approvals until each is resolved', () => { + const snapshots = collectActivity(); + const lease = startTurn(); + const approval = (toolCallId: string): PermissionApprovalRequestContext => + ({ + toolCallId, + toolName: 'Read', + action: 'read', + display: {}, + turnId: 1, + toolInput: { path: '/tmp/example' }, + }) as unknown as PermissionApprovalRequestContext; + + eventBus.publish({ type: 'permission.approval.requested', ...approval('c1') }); + eventBus.publish({ type: 'permission.approval.requested', ...approval('c2') }); + expect(snapshots.at(-1)?.turn?.pendingApprovals.map((item) => item.toolCallId)).toEqual([ + 'c1', + 'c2', + ]); + + eventBus.publish({ + type: 'permission.approval.resolved', + ...approval('c1'), + decision: 'approved', + }); + expect(snapshots.at(-1)?.turn?.pendingApprovals.map((item) => item.toolCallId)).toEqual(['c2']); + lease.end('completed'); + }); + + it('projects retry state for the active turn', () => { + const snapshots = collectActivity(); + const lease = startTurn(); + + eventBus.publish({ + type: 'turn.step.retrying', + turnId: 1, + step: 1, + stepId: 's1', + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 3, + delayMs: 500, + errorName: 'RateLimitError', + errorMessage: 'slow down', + statusCode: 429, + }); + + expect(snapshots.at(-1)?.turn).toMatchObject({ + phase: 'retrying', + retry: { + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 3, + delayMs: 500, + errorName: 'RateLimitError', + statusCode: 429, + }, + }); + lease.end('completed'); + }); + + it.each(['max_steps', 'error'] as const)( + 'projects %s as the ending reason when a step is interrupted', + (reason) => { + const snapshots = collectActivity(); + const lease = startTurn(); + + eventBus.publish({ + type: 'turn.step.interrupted', + turnId: 1, + step: 1, + reason, + }); + + expect(snapshots.at(-1)?.turn).toMatchObject({ + turnId: 1, + step: 1, + ending: true, + endingReason: reason, + }); + lease.end('failed'); + }, + ); + it('cancel is a no-op when idle', () => { activity.markReady(); expect(activity.cancel()).toBe(false); @@ -106,19 +259,22 @@ describe('AgentActivityService (turn lane)', () => { const lease = activity.begin('turn'); lease.end('completed'); expect(() => lease.end('completed')).not.toThrow(); - expect(activity.lane()).toBe('idle'); + expect(activity.isIdle()).toBe(true); }); it('beginDisposal aborts the in-flight lease and settles after end', async () => { + const states = collectActivity(); activity.markReady(); const lease = activity.begin('turn'); activity.beginDisposal(); expect(lease.signal.aborted).toBe(true); - expect(activity.lane()).toBe('disposing'); + expect(activity.isIdle()).toBe(false); + expect(states.at(-1)).toMatchObject({ lifecycle: 'disposing', turn: { turnId: lease.turnId } }); const settled = activity.settled(); lease.end('cancelled'); await settled; - expect(activity.lane()).toBe('disposed'); + expect(activity.isIdle()).toBe(false); + expect(states.at(-1)).toMatchObject({ lifecycle: 'disposed', turn: undefined }); }); }); @@ -126,25 +282,9 @@ describe('SessionActivityKernel (session lane)', () => { let host: ReturnType; let kernel: ISessionActivityKernel; - function stubWire(): IWireService { - return { - _serviceBrand: undefined, - dispatch: () => undefined, - replay: () => Promise.resolve(), - flush: () => Promise.resolve(), - attach: () => ({ dispose: () => undefined }), - getModel: (model: { initial: () => unknown }) => model.initial(), - subscribe: () => ({ dispose: () => undefined }), - onEmission: () => ({ dispose: () => undefined }), - onRestored: () => ({ dispose: () => undefined }), - } as unknown as IWireService; - } - beforeEach(() => { host = createScopedTestHost(); - const session = host.child(LifecycleScope.Session, 'session', [ - [ISessionWireService, stubWire()], - ]); + const session = host.child(LifecycleScope.Session, 'session'); kernel = session.accessor.get(ISessionActivityKernel); }); diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index fbe474552..eb748ba94 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -23,7 +23,7 @@ import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; import { IEventBus } from '#/app/event/eventBus'; -import { IAgentWireService } from '#/wire/tokens'; +import { IWireService } from '#/wire/wire'; import { registerContextMemoryServices, type StubContextMemory } from '../contextMemory/stubs'; import { stubLoopWithHooks, stubWire } from '../loop/stubs'; @@ -71,7 +71,7 @@ describe('AgentContextInjectorService', () => { strict: true, additionalServices: (reg) => { reg.defineInstance(IAgentLoopService, stubLoopWithHooks()); - reg.defineInstance(IAgentWireService, stubWire()); + reg.defineInstance(IWireService, stubWire()); reg.define(IAgentSystemReminderService, AgentSystemReminderService); reg.define(IAgentContextInjectorService, AgentContextInjectorService); }, diff --git a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts index 13b352d2d..859ead839 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -3,8 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { estimateTokensForMessages } from '#/_base/utils/tokens'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService } from '#/wire/wireService'; +import { IWireService } from '#/wire/wire'; import { IAgentContextMemoryService, IAgentContextSizeService, @@ -25,7 +24,7 @@ describe('Agent context', () => { context = ctx.get(IAgentContextMemoryService); contextSize = ctx.get(IAgentContextSizeService); profile = ctx.get(IAgentProfileService); - wire = ctx.get(IAgentWireService); + wire = ctx.get(IWireService); }); afterEach(async () => { diff --git a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts index 4cc538b2c..d6e2170d7 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -14,7 +14,7 @@ import { } from '#/agent/contextMemory/contextTranscript'; import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold'; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; -import type { PersistedRecord } from '#/wire/wireService'; +import type { WireRecord } from '#/wire/record'; function userMessage(text: string, origin?: PromptOrigin): ContextMessage { return { @@ -29,15 +29,15 @@ function assistantMessage(text: string): ContextMessage { return { role: 'assistant', content: [{ type: 'text', text }], toolCalls: [] }; } -function appendMessage(message: ContextMessage): PersistedRecord { +function appendMessage(message: ContextMessage): WireRecord { return { type: 'context.append_message', message }; } -function loopEvent(event: LoopRecordedEvent): PersistedRecord { +function loopEvent(event: LoopRecordedEvent): WireRecord { return { type: 'context.append_loop_event', event }; } -function assistantStep(uuid: string, text: string): PersistedRecord[] { +function assistantStep(uuid: string, text: string): WireRecord[] { return [ loopEvent({ type: 'step.begin', uuid }), loopEvent({ type: 'content.part', stepUuid: uuid, part: { type: 'text', text } }), @@ -50,7 +50,7 @@ function compaction( compactedCount: number, keptUserMessageCount?: number, keptHeadUserMessageCount?: number, -): PersistedRecord { +): WireRecord { return { type: 'context.apply_compaction', summary, @@ -63,7 +63,7 @@ function compaction( }; } -function undo(count: number): PersistedRecord { +function undo(count: number): WireRecord { return { type: 'context.undo', count }; } diff --git a/packages/agent-core-v2/test/agent/contextMemory/message-history.test.ts b/packages/agent-core-v2/test/agent/contextMemory/message-history.test.ts index 7add9daff..80f4ba41b 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/message-history.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/message-history.test.ts @@ -6,12 +6,10 @@ import { TestInstantiationService } from '#/_base/di/test'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { AgentContextMemoryService } from '#/agent/contextMemory/contextMemoryService'; -import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; -import { IAgentWireService } from '#/wire/tokens'; -import { WireService } from '#/wire/wireServiceImpl'; -import { stubWireRecord } from './stubs'; + +import { registerTestAgentWire } from '../../wire/stubs'; function textMessage(role: ContextMessage['role'], text: string): ContextMessage { return { @@ -35,9 +33,8 @@ describe('message history (IAgentContextMemoryService)', () => { beforeEach(() => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); - ix.stub(IAgentWireRecordService, stubWireRecord()); - ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: 'wire', logKey: 'message' }])); ix.set(IEventBus, new SyncDescriptor(EventBusService)); + registerTestAgentWire(ix, 'wire/message-history', { eventBus: ix.get(IEventBus) }); ix.set(IAgentContextMemoryService, new SyncDescriptor(AgentContextMemoryService)); }); afterEach(() => disposables.dispose()); diff --git a/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts b/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts index d28f96552..676c58c5b 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts @@ -32,9 +32,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService, PersistedRecord } from '#/wire/wireService'; -import { WireService } from '#/wire/wireServiceImpl'; +import { IWireService } from '#/wire/wire'; +import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; + +import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs'; const SCOPE = 'wire'; const KEY = 'ctx-live'; @@ -152,26 +153,25 @@ function buildHost(key: string): Host { const ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); - ix.set( - IAgentWireService, - new SyncDescriptor(WireService, [ - { logScope: SCOPE, logKey: key }, - ]), - ); ix.stub(IAgentBlobService, blob); ix.set(IEventBus, new SyncDescriptor(EventBusService)); ix.set(IAgentContextMemoryService, new SyncDescriptor(AgentContextMemoryService)); + const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), { + log: ix.get(IAppendLogStore), + blob, + eventBus: ix.get(IEventBus), + }); return { - wire: ix.get(IAgentWireService), + wire, svc: ix.get(IAgentContextMemoryService), log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus), }; } -async function readRecords(log: IAppendLogStore, key = KEY): Promise { - const out: PersistedRecord[] = []; - for await (const record of log.read(SCOPE, key)) { +async function readRecords(log: IAppendLogStore, key = KEY): Promise { + const out: WireRecord[] = []; + for await (const record of log.read(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) { out.push(record); } return out; @@ -236,7 +236,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { }); it('folds v1 context.append_loop_event records into the ContextModel on replay', async () => { - const records: PersistedRecord[] = [ + const records: WireRecord[] = [ { type: 'context.append_message', message: userMessage('q') }, { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's1', turnId: '0', step: 1 } }, { @@ -276,7 +276,12 @@ describe('AgentContextMemoryService (wire-backed)', () => { ]; const replay = buildHost(REPLAY_KEY); - await replay.wire.replay(...records); + await restoreTestAgentWire( + replay.wire, + replay.log, + testWireScope(SCOPE, REPLAY_KEY), + records, + ); const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[]; expect(model.map((message) => message.role)).toEqual(['user', 'assistant', 'tool']); @@ -290,7 +295,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { }); it('replays v1 context.apply_compaction records with contextSummary as the model summary', async () => { - const records: PersistedRecord[] = [ + const records: WireRecord[] = [ { type: 'context.append_message', message: userMessage('old') }, { type: 'context.append_message', message: userMessage('tail') }, { @@ -304,7 +309,12 @@ describe('AgentContextMemoryService (wire-backed)', () => { ]; const replay = buildHost(REPLAY_KEY); - await replay.wire.replay(...records); + await restoreTestAgentWire( + replay.wire, + replay.log, + testWireScope(SCOPE, REPLAY_KEY), + records, + ); const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[]; expect(model.map(textOf)).toEqual(['model-facing summary', 'tail']); @@ -315,7 +325,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { }); it('replays new context.apply_compaction records with kept user messages before contextSummary', async () => { - const records: PersistedRecord[] = [ + const records: WireRecord[] = [ { type: 'context.append_message', message: userMessage('old user') }, { type: 'context.append_message', @@ -338,7 +348,12 @@ describe('AgentContextMemoryService (wire-backed)', () => { ]; const replay = buildHost(REPLAY_KEY); - await replay.wire.replay(...records); + await restoreTestAgentWire( + replay.wire, + replay.log, + testWireScope(SCOPE, REPLAY_KEY), + records, + ); const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[]; expect(model.map((message) => message.role)).toEqual(['user', 'user', 'user']); @@ -349,7 +364,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { }); it('replays pre-contextSummary kept-user records without adding a new prefix', async () => { - const records: PersistedRecord[] = [ + const records: WireRecord[] = [ { type: 'context.append_message', message: userMessage('old user') }, { type: 'context.append_message', message: userMessage('recent user') }, { @@ -363,7 +378,12 @@ describe('AgentContextMemoryService (wire-backed)', () => { ]; const replay = buildHost(REPLAY_KEY); - await replay.wire.replay(...records); + await restoreTestAgentWire( + replay.wire, + replay.log, + testWireScope(SCOPE, REPLAY_KEY), + records, + ); const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[]; expect(model.map(textOf)).toEqual(['old user', 'recent user', 'OLD SUMMARY']); @@ -380,7 +400,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { toolCalls: [], origin: { kind: 'compaction_summary' }, }; - const records: PersistedRecord[] = [ + const records: WireRecord[] = [ { type: 'context.append_message', message: userMessage('old') }, { type: 'context.append_message', message: userMessage('tail') }, { @@ -391,7 +411,12 @@ describe('AgentContextMemoryService (wire-backed)', () => { ]; const replay = buildHost(REPLAY_KEY); - await replay.wire.replay(...records); + await restoreTestAgentWire( + replay.wire, + replay.log, + testWireScope(SCOPE, REPLAY_KEY), + records, + ); const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[]; expect(model).toHaveLength(2); @@ -420,7 +445,12 @@ describe('AgentContextMemoryService (wire-backed)', () => { expect(mediaUrl(persisted)).not.toContain(big); const replay = buildHost(REPLAY_KEY); - await replay.wire.replay(...records); + await restoreTestAgentWire( + replay.wire, + replay.log, + testWireScope(SCOPE, REPLAY_KEY), + records, + ); expect(blob.loadCalls).toBeGreaterThanOrEqual(1); const rebuilt = replay.wire.getModel(ContextModel) as readonly ContextMessage[]; @@ -446,7 +476,12 @@ describe('AgentContextMemoryService (wire-backed)', () => { disposables.add(replay.eventBus.subscribe('context.spliced', (event) => { replayed.push({ start: event.start, deleteCount: event.deleteCount }); })); - await replay.wire.replay(...records); + await restoreTestAgentWire( + replay.wire, + replay.log, + testWireScope(SCOPE, REPLAY_KEY), + records, + ); expect(replayed).toHaveLength(0); expect(replay.wire.getModel(ContextModel) as readonly ContextMessage[]).toHaveLength(2); }); diff --git a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts index fec907db1..ba6aef562 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts @@ -1,6 +1,6 @@ /** * `contextMemory` test stubs — shared doubles for `IAgentContextMemoryService` and its - * collaborator (`IAgentWireRecordService`). + * collaborator (`IWireService`). * * Lives under `test/` (not `src/`) so test-support code stays out of the * production tree. Import from a relative path (`./stubs` or @@ -19,18 +19,9 @@ import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; -import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; +import { IWireService } from '#/wire/wire'; -export function stubWireRecord(): IAgentWireRecordService { - return { - _serviceBrand: undefined, - seal: () => Promise.resolve(), - restore: () => Promise.resolve({}), - flush: () => Promise.resolve(), - close: () => Promise.resolve(), - getRecords: () => [], - }; -} +import { stubAgentWire } from '../../wire/stubs'; export interface StubContextMemory extends IAgentContextMemoryService { readonly messages: readonly ContextMessage[]; @@ -124,7 +115,7 @@ class StubContextMemoryService implements IAgentContextMemoryService { } export function registerContextMemoryServices(reg: ServiceRegistration): void { - reg.defineInstance(IAgentWireRecordService, stubWireRecord()); + reg.defineInstance(IWireService, stubAgentWire()); reg.define(IEventBus, EventBusService); reg.define(IAgentContextMemoryService, StubContextMemoryService); } diff --git a/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts index f36d05d5b..167d5d656 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts @@ -15,9 +15,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService, PersistedRecord } from '#/wire/wireService'; -import { WireService } from '#/wire/wireServiceImpl'; +import { IWireService } from '#/wire/wire'; +import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; + +import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs'; const SCOPE = 'wire'; const KEY = 'full-compaction-test'; @@ -30,9 +31,12 @@ function buildHost(key: string): { wire: IWireService; log: IAppendLogStore; eve const ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); - ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }])); ix.set(IEventBus, new SyncDescriptor(EventBusService)); - return { wire: ix.get(IAgentWireService), log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus) }; + const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), { + log: ix.get(IAppendLogStore), + eventBus: ix.get(IEventBus), + }); + return { wire, log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus) }; } beforeEach(() => { @@ -44,9 +48,10 @@ beforeEach(() => { afterEach(() => disposables.dispose()); -async function readRecords(key = KEY): Promise { - const out: PersistedRecord[] = []; - for await (const record of log.read(SCOPE, key)) { +async function readRecords(key = KEY): Promise { + await wire.flush(); + const out: WireRecord[] = []; + for await (const record of log.read(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) { out.push(record); } return out; @@ -97,7 +102,7 @@ describe('fullCompaction ops (wire-backed)', () => { expect(wire.getModel(CompactionModel)).toBe(running); }); - it('replay rebuilds the phase silently (no emissions, no subscriber notifications)', async () => { + it('replay rebuilds the phase silently', async () => { wire.dispatch(fullCompactionBegin({ source: 'manual' })); wire.dispatch(fullCompactionComplete({})); const records = await readRecords(); @@ -107,27 +112,36 @@ describe('fullCompaction ops (wire-backed)', () => { host.eventBus.subscribe((e) => { emissions.push(e.type); }); - let modelChanges = 0; - host.wire.subscribe(CompactionModel, () => { - modelChanges += 1; - }); - - await host.wire.replay(...records); + await restoreTestAgentWire( + host.wire, + host.log, + testWireScope(SCOPE, 'full-compaction-replay'), + records, + ); expect(host.wire.getModel(CompactionModel).phase).toBe('idle'); expect(emissions).toEqual([]); - expect(modelChanges).toBe(0); const stranded = buildHost('full-compaction-stranded'); - await stranded.wire.replay({ type: 'full_compaction.begin', source: 'auto' }); + await restoreTestAgentWire( + stranded.wire, + stranded.log, + testWireScope(SCOPE, 'full-compaction-stranded'), + [{ type: 'full_compaction.begin', source: 'auto' }], + ); expect(stranded.wire.getModel(CompactionModel).phase).toBe('running'); }); it('replays legacy complete payloads that carried accounting numbers', async () => { const host = buildHost('full-compaction-legacy-complete-replay'); - await host.wire.replay( - { type: 'full_compaction.begin', source: 'manual' }, - { type: 'full_compaction.complete', compactedCount: 1, tokensBefore: 50, tokensAfter: 10 }, + await restoreTestAgentWire( + host.wire, + host.log, + testWireScope(SCOPE, 'full-compaction-legacy-complete-replay'), + [ + { type: 'full_compaction.begin', source: 'manual' }, + { type: 'full_compaction.complete', compactedCount: 1, tokensBefore: 50, tokensAfter: 10 }, + ], ); expect(host.wire.getModel(CompactionModel).phase).toBe('idle'); diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index daeb86616..1fd8e0b73 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -17,7 +17,7 @@ import { IAgentLoopService, type AfterStepContext, type EnqueueReceipt, type Ste import { MessageStepRequest } from '#/agent/loop/stepRequest'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentUsageService } from '#/agent/usage/usage'; -import type { PersistedWireRecord } from '#/agent/wireRecord/wireRecord'; +import type { WireRecord } from '#/wire/record'; import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; import { APIConnectionError, APIStatusError } from '#/app/llmProtocol/errors'; import type { ToolCall } from '#/app/llmProtocol/message'; @@ -38,7 +38,7 @@ import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/st import { stubLoopWithHooks, type StubLoop } from '../loop/stubs'; type GoalServiceTestManager = IAgentGoalService & AgentGoalService; -type GoalRecord = PersistedWireRecord & { type: `goal.${string}` }; +type GoalRecord = WireRecord & { type: `goal.${string}` }; type AgentEvent = DomainEvent; type GoalUpdatedEvent = Extract; type TurnEndedInput = { @@ -53,17 +53,17 @@ const zeroUsage: TokenUsage = { output: 0, }; -function goalRecords(records: readonly PersistedWireRecord[]): readonly GoalRecord[] { +function goalRecords(records: readonly WireRecord[]): readonly GoalRecord[] { return records.filter((record): record is GoalRecord => record.type.startsWith('goal.')); } async function restoreGoalRecords( ctx: TestAgentContext, goals: IAgentGoalService, - records: readonly PersistedWireRecord[], + records: readonly WireRecord[], ): Promise { goals.getGoal(); - await ctx.restore(records as readonly PersistedWireRecord[]); + await ctx.restore(records as readonly WireRecord[]); } function makeTurn(id: number): Turn { @@ -146,7 +146,7 @@ describe('AgentGoalService', () => { let ctx: TestAgentContext; let context: IAgentContextMemoryService; let goals: GoalServiceTestManager; - let records: PersistedWireRecord[]; + let records: WireRecord[]; let events: GoalUpdatedEvent[]; let telemetry: TelemetryRecord[]; @@ -241,7 +241,7 @@ describe('AgentGoalService', () => { it('replaces an existing goal when replace is set', async () => { const first = await goals.createGoal({ objective: 'first' }); const second = await goals.createGoal({ objective: 'second', replace: true }); - await ctx.wireRecord.flush(); + await ctx.wire.flush(); expect(second.goalId).not.toBe(first.goalId); expect(goals.getGoal().goal?.objective).toBe('second'); @@ -469,7 +469,7 @@ describe('AgentGoalService', () => { await goals.markBlocked({ reason: 'stuck' }); await goals.resumeGoal(); await goals.cancelGoal(); - await ctx.wireRecord.flush(); + await ctx.wire.flush(); const recordsWithoutMetadata = goalRecords(records); expect(recordsWithoutMetadata).toEqual([ @@ -548,7 +548,7 @@ describe('AgentGoalService', () => { status: 'paused', terminalReason: 'Paused after agent resume', }); - expect(goalRecords(records)).toEqual([ + expect(goalRecords(records).filter((record) => record.type === 'goal.update')).toEqual([ expect.objectContaining({ type: 'goal.update', status: 'paused', diff --git a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts index 62f70affb..98b43c8de 100644 --- a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts @@ -21,9 +21,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; -import { IAgentWireService } from '#/wire/tokens'; -import type { IWireService, PersistedRecord } from '#/wire/wireService'; -import { WireService } from '#/wire/wireServiceImpl'; +import { IWireService } from '#/wire/wire'; +import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; + +import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs'; const SCOPE = 'wire'; const KEY = 'goal-test'; @@ -102,7 +103,6 @@ function buildHost(key: string): { const ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); - ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }])); ix.set(IEventBus, new SyncDescriptor(EventBusService)); ix.stub(IAgentLoopService, createLoopStub()); ix.stub(IAgentUsageService, { @@ -114,9 +114,13 @@ function buildHost(key: string): { ix.stub(ITelemetryService, createTelemetryStub()); ix.stub(IAgentToolExecutorService, createToolExecutorStub()); ix.stub(IConfigService, createConfigStub()); - ix.set(IAgentGoalService, new SyncDescriptor(AgentGoalService, [{}])); + ix.set(IAgentGoalService, new SyncDescriptor(AgentGoalService)); + const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), { + log: ix.get(IAppendLogStore), + eventBus: ix.get(IEventBus), + }); return { - wire: ix.get(IAgentWireService), + wire, svc: ix.get(IAgentGoalService), log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus), @@ -134,9 +138,10 @@ beforeEach(() => { afterEach(() => disposables.dispose()); -async function readRecords(key = KEY): Promise { - const out: PersistedRecord[] = []; - for await (const record of log.read(SCOPE, key)) { +async function readRecords(key = KEY): Promise { + await wire.flush(); + const out: WireRecord[] = []; + for await (const record of log.read(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) { out.push(record); } return out; @@ -179,24 +184,17 @@ describe('AgentGoalService (wire-backed)', () => { expect(records.map((record) => record.type)).toEqual(['goal.create', 'goal.clear']); }); - it('goal.updated signal and model subscription are live-only and silent on replay', async () => { + it('goal.updated is live-only and silent on replay', async () => { const signals: string[] = []; const sub = eventBus.subscribe((e) => { if (e.type === 'goal.updated') { signals.push(e.type); } }); - let modelChanges = 0; - const modelSub = wire.subscribe(GoalModel, () => { - modelChanges += 1; - }); - await svc.createGoal({ objective: 'work' }); await svc.pauseGoal(); expect(signals.length).toBeGreaterThanOrEqual(2); - expect(modelChanges).toBeGreaterThanOrEqual(2); sub.dispose(); - modelSub.dispose(); const records = await readRecords(); const host = buildHost('goal-replay'); @@ -206,37 +204,44 @@ describe('AgentGoalService (wire-backed)', () => { replaySignals.push(e.type); } }); - let replayModelChanges = 0; - host.wire.subscribe(GoalModel, () => { - replayModelChanges += 1; - }); - - await host.wire.replay(...records); + await restoreTestAgentWire( + host.wire, + host.log, + testWireScope(SCOPE, 'goal-replay'), + records, + ); expect(modelOf(host.wire)?.status).toBe('paused'); expect(replaySignals).toEqual([]); - expect(replayModelChanges).toBe(0); }); - it('onRestored forces a replayed active goal to paused after replay', async () => { + it('onDidRestore forces a replayed active goal to paused after replay', async () => { const created = await svc.createGoal({ objective: 'resume me' }); const records = await readRecords(); const host = buildHost('goal-restore'); void host.svc; - await host.wire.replay(...records); + await restoreTestAgentWire( + host.wire, + host.log, + testWireScope(SCOPE, 'goal-restore'), + records, + ); expect(modelOf(host.wire)?.status).toBe('paused'); expect(modelOf(host.wire)?.terminalReason).toBe('Paused after agent resume'); expect(modelOf(host.wire)?.goalId).toBe(created.goalId); const written = await (async () => { - const out: PersistedRecord[] = []; - for await (const record of host.log.read(SCOPE, 'goal-restore')) { + const out: WireRecord[] = []; + for await (const record of host.log.read( + testWireScope(SCOPE, 'goal-restore'), + AGENT_WIRE_RECORD_KEY, + )) { out.push(record); } return out; })(); - expect(written).toEqual([ + expect(written.filter((record) => record.type === 'goal.update')).toEqual([ expect.objectContaining({ type: 'goal.update', status: 'paused', diff --git a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts index 3938878b8..32cdb671c 100644 --- a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts +++ b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts @@ -246,7 +246,7 @@ async function flushedGoalReminderRecords( ctx: TestAgentContext, persistence: InMemoryWireRecordPersistence, ) { - await ctx.wireRecord.flush(); + await ctx.wire.flush(); return goalReminderRecords(persistence); } diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index 864fa58e1..4b5626b8b 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -40,11 +40,12 @@ import type { LLMEvent, LLMRequestInput, Model } from '#/app/model/modelInstance import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ILogService } from '#/_base/log/log'; import { Error2, ErrorCodes } from '#/errors'; -import { IAgentWireService } from '#/wire/tokens'; -import type { PersistedRecord } from '#/wire/wireService'; -import { WireService } from '#/wire/wireServiceImpl'; +import { IWireService } from '#/wire/wire'; +import type { WireRecord } from '#/wire/record'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { recordingWireLog, registerTestAgentWire } from '../../wire/stubs'; + const capabilities: ModelCapability = { image_in: false, video_in: false, @@ -193,21 +194,15 @@ function createService( ix.stub(IConfigService, config); ix.stub(ILogService, log); ix.stub(ITelemetryService, telemetry); - ix.set( - IAgentWireService, - new SyncDescriptor(WireService, [{ logScope: 'wire', logKey: 'strict-resend' }]), - ); + const records: WireRecord[] = []; + registerTestAgentWire(ix, 'wire/llm-requester', { log: recordingWireLog(records) }); ix.set(IFaultInjectionService, new SyncDescriptor(FaultInjectionService)); ix.set(IAgentLLMRequesterService, new SyncDescriptor(AgentLLMRequesterService)); - const records: PersistedRecord[] = []; - disposables.add( - ix.get(IAgentWireService).onEmission((emission) => records.push(emission.record)), - ); - return { service: ix.get(IAgentLLMRequesterService), faultInjection: ix.get(IFaultInjectionService), + wire: ix.get(IWireService), records, }; } @@ -424,7 +419,7 @@ describe('AgentLLMRequesterService media-degraded resend', () => { it('records repeated-413 recovery projections on the sticky later request', async () => { const calls = { value: 0 }; - const { service, records } = createService( + const { service, wire, records } = createService( createModel(calls, BODY_TOO_LARGE_413, [BODY_TOO_LARGE_413]), { project: (messages: readonly ContextMessage[]) => messages, @@ -436,6 +431,7 @@ describe('AgentLLMRequesterService media-degraded resend', () => { await service.request({ source: { type: 'turn', turnId: 1, step: 1 } }); await service.request({ source: { type: 'turn', turnId: 1, step: 2 } }); + await wire.flush(); expect( records 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 04e433d5e..889d83135 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -55,23 +55,29 @@ describe('Agent loop', () => { expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(` [wire] tools.set_active_tools { "names": [], "time": "