From e0eca299b5b3406fc848e66576b218ae65b620c0 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 21:46:34 +0800 Subject: [PATCH 1/3] refactor(agent-core-v2): replace defineOp/Model with Event2 dispatch and replayable states - replace defineOp/Op/OpDescriptor/toEvent and defineModel/defineCheckpointedModel with Event2 subclasses: durable classes declare static durable + schema, serialize() keeps the wire record shape byte-frozen, transient classes stay off the journal - define states via defineState(...).replayable(...).on(Event2, fold): immer produceWithPatches folds with atomic prepare/commit, .undoable() trait drives prompt-submit checkpoints and context.undo, ephemeral kv keys keep imperative set - degrade IWireService to a journal adapter; the agent event dispatcher owns the pipeline (fold -> set -> appendRecord -> publish) and silent restore - align downstream event surfaces: kap-server WS envelope timestamp from event.time, klient event schemas gain time, node-sdk/acp-server/print wiring updated - rewrite gen-wire-manifest/gen-state-manifest for the unified registry and replace the op-uniqueness lint with event-uniqueness --- apps/kimi-code/src/cli/v2/run-v2-print.ts | 73 +- apps/kimi-code/test/cli/run-v2-print.test.ts | 2 +- apps/kimi-code/test/cli/v2-run-print.test.ts | 8 +- packages/acp-server/test/e2e-turn.test.ts | 27 +- packages/agent-core-v2/AGENTS.md | 8 +- packages/agent-core-v2/docs/errors.md | 2 +- packages/agent-core-v2/docs/features.md | 5 +- .../agent-core-v2/docs/state-manifest.d.ts | 401 ++++++++++- .../agent-core-v2/docs/wire-manifest.d.ts | 316 ++++----- packages/agent-core-v2/package.json | 1 + .../scripts/gen-state-manifest.mts | 125 +++- .../scripts/gen-wire-manifest.mts | 341 +++++++-- .../agent-core-v2/scripts/lib/jsonSchema.mts | 2 +- .../src/_base/state/stateRegistry.ts | 37 +- .../src/agent/activityView/activityView.ts | 11 +- .../agent/activityView/activityViewService.ts | 103 +-- .../agentsMdReminderService.ts | 22 +- .../contextInjector/contextInjectorService.ts | 3 +- .../src/agent/contextMemory/contextEvents.ts | 117 ++++ .../contextMemory/contextMemoryService.ts | 97 ++- .../src/agent/contextMemory/contextOps.ts | 178 ++--- .../agent/contextMemory/conversationTime.ts | 87 +-- .../src/agent/contextMemory/loopEventFold.ts | 17 +- .../contextProjectorService.ts | 4 +- .../externalHooks/externalHooksService.ts | 77 +- .../src/agent/fullCompaction/compactionOps.ts | 175 +++-- .../fullCompaction/fullCompactionService.ts | 58 +- .../agent-core-v2/src/agent/goal/goalOps.ts | 228 +++--- .../src/agent/goal/goalService.ts | 148 ++-- .../interruptionReminderOps.ts | 48 +- .../interruptionReminderService.ts | 8 +- .../src/agent/llmRequester/llmRequestOps.ts | 104 +-- .../agent/llmRequester/llmRequesterService.ts | 44 +- .../src/agent/loop/loopService.ts | 165 ++--- .../src/agent/loop/turnEvents.ts | 139 ++-- .../agent-core-v2/src/agent/loop/turnOps.ts | 174 +++-- .../src/agent/mcp/mcpDiscoveryOps.ts | 54 +- .../agent-core-v2/src/agent/mcp/mcpEvents.ts | 50 ++ .../agent-core-v2/src/agent/mcp/mcpService.ts | 135 ++-- .../src/agent/media/mediaResolverService.ts | 4 +- .../src/agent/media/mediaToolsRegistrar.ts | 7 +- .../src/agent/media/videoResolverService.ts | 244 ++++++- .../injection/permissionModeInjection.ts | 4 +- .../agent/permissionMode/permissionModeOps.ts | 47 +- .../permissionMode/permissionModeService.ts | 28 +- .../permissionRules/permissionRulesOps.ts | 112 +-- .../permissionRules/permissionRulesService.ts | 35 +- .../src/agent/plugin/agentPluginOps.ts | 45 +- .../src/agent/plugin/agentPluginService.ts | 20 +- .../src/agent/pluginCommand/pluginCommand.ts | 14 +- .../pluginCommand/pluginCommandService.ts | 30 +- .../src/agent/profile/profileOps.ts | 324 +++++---- .../src/agent/profile/profileService.ts | 155 +++-- .../src/agent/prompt/promptOps.ts | 37 +- .../src/agent/prompt/promptService.ts | 417 +++-------- .../agent/runtimeBinding/runtimeBindingOps.ts | 28 +- .../runtimeBinding/runtimeBindingService.ts | 49 +- .../agent/shellCommand/shellCommandService.ts | 115 +-- .../agent-core-v2/src/agent/skill/skillOps.ts | 86 +-- .../src/agent/skill/skillService.ts | 40 +- .../src/agent/state/agentState.ts | 12 + .../src/agent/state/agentStateService.ts | 81 ++- .../src/agent/stepRetry/stepRetryService.ts | 52 +- .../agent-core-v2/src/agent/task/taskOps.ts | 120 ++-- .../src/agent/task/taskService.ts | 99 +-- .../agent/tokenCounting/tokenCountingOps.ts | 139 ++-- .../tokenCounting/tokenCountingService.ts | 31 +- .../toolActivation/toolActivationService.ts | 3 +- .../agent/toolApproval/toolApprovalService.ts | 88 ++- .../src/agent/toolDedupe/toolDedupeService.ts | 18 +- .../agent/toolExecutor/toolExecutorEvents.ts | 42 +- .../agent/toolExecutor/toolExecutorService.ts | 62 +- .../src/agent/toolSelect/toolSelectService.ts | 10 +- .../src/agent/undo/undoService.ts | 57 +- .../src/agent/usage/usageEvents.ts | 31 + .../agent-core-v2/src/agent/usage/usageOps.ts | 78 +-- .../src/agent/usage/usageService.ts | 39 +- .../src/agent/userTool/userToolOps.ts | 97 +-- .../src/agent/userTool/userToolService.ts | 41 +- .../agent-core-v2/src/app/auth/authService.ts | 3 +- .../src/app/capability/capabilityEvents.ts | 17 + .../src/app/config/configEvents.ts | 45 ++ .../agent-core-v2/src/app/event/errors.ts | 44 ++ packages/agent-core-v2/src/app/event/event.ts | 21 +- .../agent-core-v2/src/app/event/event2.ts | 107 +++ .../agent-core-v2/src/app/event/eventBus.ts | 49 +- .../src/app/event/eventBusService.ts | 58 +- .../src/app/event/eventService.ts | 18 +- .../src/app/event/fiberEventResolver.ts | 9 +- .../src/app/kosongConfig/discovery.ts | 12 + .../src/app/kosongConfig/discoveryService.ts | 3 +- .../src/app/plugin/pluginEvents.ts | 10 + .../agent-core-v2/src/debug/debugCascade.ts | 14 +- .../src/debug/debugCascadeService.ts | 4 +- packages/agent-core-v2/src/errors.ts | 6 + .../features/dateChange/dateChangeService.ts | 4 +- .../plan/injection/planModeInjection.ts | 4 +- .../src/features/plan/planOps.ts | 185 +++-- .../src/features/plan/planService.ts | 52 +- .../sessionInit/sessionInitService.ts | 4 +- .../src/features/swarm/agent/swarmService.ts | 44 +- .../swarm/session/sessionSwarmService.ts | 27 +- .../src/features/swarm/swarmOps.ts | 70 +- .../src/features/tower/towerOps.ts | 49 +- .../src/features/tower/towerService.ts | 15 +- packages/agent-core-v2/src/index.ts | 16 +- .../agentLifecycle/agentLifecycleService.ts | 24 +- .../agent-core-v2/src/session/cron/cronOps.ts | 127 ++-- .../session/cron/sessionCronServiceImpl.ts | 57 +- .../src/session/interaction/interactionOps.ts | 110 +-- .../session/interaction/interactionService.ts | 45 +- .../sessionActivity/sessionActivityService.ts | 14 +- .../sessionOutcomeMirrorService.ts | 21 +- .../session/sessionLog/sessionLogService.ts | 4 +- .../session/sessionMetadata/promptMetadata.ts | 24 +- .../sessionMetadata/sessionMetaEvents.ts | 29 + .../sessionMetadata/sessionMetadataService.ts | 4 +- .../skillCatalogService.ts | 6 +- .../sessionTitle/sessionTitleService.ts | 20 +- .../sessionToolPolicyService.ts | 4 +- .../src/session/subagent/mirrorAgentRun.ts | 105 +-- .../src/session/todo/sessionTodoService.ts | 23 +- .../agent-core-v2/src/session/todo/todoOps.ts | 39 +- .../workspaceContextService.ts | 6 +- packages/agent-core-v2/src/state/errors.ts | 50 ++ .../src/state/eventDispatcher.ts | 43 ++ .../src/state/eventDispatcherService.ts | 442 ++++++++++++ packages/agent-core-v2/src/state/state.ts | 293 ++++++++ .../src/state/stateContribution.ts | 112 +++ packages/agent-core-v2/src/wire/errors.ts | 25 +- packages/agent-core-v2/src/wire/model.ts | 117 ---- packages/agent-core-v2/src/wire/op.ts | 126 ---- packages/agent-core-v2/src/wire/record.ts | 35 +- packages/agent-core-v2/src/wire/types.ts | 42 -- packages/agent-core-v2/src/wire/wire.ts | 30 +- .../src/wire/wireContribution.ts | 134 ---- .../agent-core-v2/src/wire/wireService.ts | 348 +++------ .../sessionLifecycleEvents.ts | 40 ++ .../sessionLifecycleService.ts | 10 +- .../workspaceDirs/workspaceDirsService.ts | 6 +- .../workspaceInstructionsService.ts | 4 +- .../workspaceSkillCatalogService.ts | 6 +- .../workspaceTrust/workspaceTrustService.ts | 4 +- .../test/_base/di/planSample.test.ts | 8 +- .../test/_base/state/stateRegistry.test.ts | 113 ++- .../agent/activityView/activityView.test.ts | 164 +++-- .../agentsMdReminder/agentsMdReminder.test.ts | 36 +- .../contextInjector/contextInjector.test.ts | 14 +- .../agent/contextMemory/loopEventFold.test.ts | 475 +++++++------ .../contextMemory/message-history.test.ts | 19 +- .../agent/contextMemory/splice-replay.test.ts | 137 ++-- .../test/agent/contextMemory/stubs.ts | 3 +- .../agent/contextMemory/undoPrecheck.test.ts | 26 +- .../fullCompaction/compactionOps.test.ts | 102 +-- .../fullCompaction/fullCompaction.test.ts | 24 +- .../test/agent/goal/goal.test.ts | 115 ++- .../test/agent/goal/goalOps.test.ts | 97 +-- .../test/agent/goal/tools/goal-tools.test.ts | 17 +- .../agent/llmRequester/llmRequester.test.ts | 6 +- .../llmRequester/llmRequesterService.test.ts | 24 +- .../test/agent/loop/loop.test.ts | 210 +++--- .../agent-core-v2/test/agent/loop/stubs.ts | 58 +- .../test/agent/loop/turnOps.test.ts | 90 ++- .../agent-core-v2/test/agent/mcp/mcp.test.ts | 32 +- .../test/agent/media/tools/read-media.test.ts | 12 +- .../permissionMode/permissionMode.test.ts | 26 +- .../permissionRules/permissionRules.test.ts | 25 +- .../test/agent/plugin/agentPlugin.test.ts | 43 +- .../agent/pluginCommand/pluginCommand.test.ts | 6 +- .../test/agent/profile/config-state.test.ts | 4 +- .../test/agent/profile/profileOps.test.ts | 91 +-- .../test/agent/prompt/promptService.test.ts | 608 +--------------- .../runtimeBindingService.test.ts | 12 +- .../agent/shellCommand/shellCommand.test.ts | 14 +- .../test/agent/state/agentState.test.ts | 9 +- .../test/agent/stepRetry/stepRetry.test.ts | 8 +- .../test/agent/task/heartbeat-stale.test.ts | 14 +- .../test/agent/task/reconcile.test.ts | 28 +- .../test/agent/task/rpc-events.test.ts | 72 +- .../test/agent/task/taskOps.test.ts | 87 ++- .../test/agent/task/taskService.test.ts | 83 +-- .../agent/tokenCounting/tokenCounting.test.ts | 23 +- .../agent/toolApproval/toolApproval.test.ts | 93 ++- .../agent/toolExecutor/toolExecutor.test.ts | 42 +- .../toolSelect/toolSelectService.test.ts | 99 +-- .../test/agent/undo/undo.test.ts | 61 +- .../test/agent/usage/usage.test.ts | 64 +- .../test/agent/userTool/userTool.test.ts | 61 +- .../agent-core-v2/test/app/auth/auth.test.ts | 11 +- .../test/app/config/config.test.ts | 82 ++- .../test/app/event/event.test.ts | 40 +- .../test/app/event/eventBus.test.ts | 135 ++-- .../externalHooksRunner/integration.test.ts | 92 ++- .../agent-core-v2/test/debug/debug.test.ts | 33 +- .../test/features/plan/plan.test.ts | 114 ++- .../test/features/plan/planOps.test.ts | 167 +++-- .../features/sessionInit/sessionInit.test.ts | 11 +- .../test/features/swarm/sessionSwarm.test.ts | 19 +- .../test/features/swarm/swarm.test.ts | 48 +- .../test/features/tower/towerService.test.ts | 54 +- packages/agent-core-v2/test/harness/agent.ts | 95 ++- packages/agent-core-v2/test/index.test.ts | 99 +-- .../test/lint/event-uniqueness.test.ts | 138 ++++ .../lint/fixtures/duplicate-events.fixture.ts | 23 + .../lint/fixtures/duplicate-ops.fixture.ts | 15 - .../test/lint/op-uniqueness.test.ts | 193 ----- .../agentLifecycle/agentLifecycle.test.ts | 117 +++- .../session/interaction/interaction.test.ts | 89 ++- .../sessionActivityService.test.ts | 25 +- .../sessionOutcomeMirror.test.ts | 61 +- .../sessionTitle/sessionTitleService.test.ts | 16 +- .../test/session/todo/sessionTodo.test.ts | 83 ++- .../test/state/builtinReplayableKeys.ts | 63 ++ .../test/state/eventDispatcher.test.ts | 462 ++++++++++++ packages/agent-core-v2/test/tool/tool.test.ts | 134 ++-- .../test/wire/persistence.test.ts | 28 +- .../agent-core-v2/test/wire/resume.test.ts | 12 +- .../test/wire/store-event.test.ts | 246 ++++--- packages/agent-core-v2/test/wire/stubs.ts | 41 +- .../test/wire/wire-compat.test.ts | 147 +++- .../test/wire/wireManifest.test.ts | 8 +- .../test/wire/wireService.test.ts | 658 ++++++++---------- .../kap-server/src/protocol/events-zod.ts | 105 ++- packages/kap-server/src/routes/config.ts | 11 +- packages/kap-server/src/routes/sessions.ts | 42 +- .../src/services/transcript/coreBinding.ts | 10 +- .../src/services/transcript/coreEventMap.ts | 190 +++-- packages/kap-server/src/start.ts | 19 +- .../kap-server/src/transport/ws/v1/events.ts | 10 +- .../ws/v1/sessionEventBroadcaster.ts | 68 +- .../test/services/transcript.test.ts | 63 +- .../test/sessionEventBroadcaster.test.ts | 24 + packages/kap-server/test/sessions.test.ts | 24 +- packages/kap-server/test/snapshot.test.ts | 8 +- packages/kap-server/test/transcript.test.ts | 6 +- packages/kap-server/test/wsV1Resync.test.ts | 14 +- packages/klient/src/contract/agent/events.ts | 19 + packages/node-sdk/src/v2/event-mapper.ts | 24 +- packages/node-sdk/src/v2/session-wiring.ts | 9 +- .../test/session-event-wiring.test.ts | 8 +- pnpm-lock.yaml | 5 +- 241 files changed, 10108 insertions(+), 7419 deletions(-) create mode 100644 packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts create mode 100644 packages/agent-core-v2/src/agent/mcp/mcpEvents.ts create mode 100644 packages/agent-core-v2/src/agent/usage/usageEvents.ts create mode 100644 packages/agent-core-v2/src/app/capability/capabilityEvents.ts create mode 100644 packages/agent-core-v2/src/app/config/configEvents.ts create mode 100644 packages/agent-core-v2/src/app/event/errors.ts create mode 100644 packages/agent-core-v2/src/app/event/event2.ts create mode 100644 packages/agent-core-v2/src/app/plugin/pluginEvents.ts create mode 100644 packages/agent-core-v2/src/session/sessionMetadata/sessionMetaEvents.ts create mode 100644 packages/agent-core-v2/src/state/errors.ts create mode 100644 packages/agent-core-v2/src/state/eventDispatcher.ts create mode 100644 packages/agent-core-v2/src/state/eventDispatcherService.ts create mode 100644 packages/agent-core-v2/src/state/state.ts create mode 100644 packages/agent-core-v2/src/state/stateContribution.ts delete mode 100644 packages/agent-core-v2/src/wire/model.ts delete mode 100644 packages/agent-core-v2/src/wire/op.ts delete mode 100644 packages/agent-core-v2/src/wire/types.ts delete mode 100644 packages/agent-core-v2/src/wire/wireContribution.ts create mode 100644 packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleEvents.ts create mode 100644 packages/agent-core-v2/test/lint/event-uniqueness.test.ts create mode 100644 packages/agent-core-v2/test/lint/fixtures/duplicate-events.fixture.ts delete mode 100644 packages/agent-core-v2/test/lint/fixtures/duplicate-ops.fixture.ts delete mode 100644 packages/agent-core-v2/test/lint/op-uniqueness.test.ts create mode 100644 packages/agent-core-v2/test/state/builtinReplayableKeys.ts create mode 100644 packages/agent-core-v2/test/state/eventDispatcher.test.ts diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 5165728804c..c6c955d18f1 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -7,7 +7,7 @@ * - `bootstrap()`s the app scope, * - creates / resumes a session and its main agent via native services, * - subscribes to the main agent's per-agent `IEventBus` and renders the - * native `DomainEvent` stream (payloads are already v1-protocol-shaped), + * native `Event2` stream (payloads are already v1-protocol-shaped), * - drives a turn through `IAgentPromptService.enqueue()` and awaits * `Turn.result` for authoritative completion, * - applies the print-mode background policy (config-driven, v1-aligned: @@ -49,7 +49,7 @@ import { resolveLoggingConfig, resolvePrintBackgroundMode, setClampedTimeout, - type DomainEvent, + type Event2, type IAgentScopeHandle, type ISessionScopeHandle, type LoopRunResult, @@ -57,6 +57,20 @@ import { type Scope, } from '@moonshot-ai/agent-core-v2'; import { createKimiDefaultHeaders, createKimiDeviceId } from '@moonshot-ai/kimi-code-oauth'; +import type { GoalUpdated } from '@moonshot-ai/agent-core-v2/agent/goal/goalOps'; +import type { TurnEnded } from '@moonshot-ai/agent-core-v2/agent/loop/turnOps'; +import type { + AssistantDelta, + ThinkingDelta, + ToolCallDelta, +} from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; +import type { TurnStepRetrying } from '@moonshot-ai/agent-core-v2/agent/stepRetry/stepRetryService'; +import type { HookResult } from '@moonshot-ai/agent-core-v2/agent/externalHooks/externalHooksService'; +import type { + ToolCallStarted, + ToolProgress, + ToolResultEvent, +} from '@moonshot-ai/agent-core-v2/agent/toolExecutor/toolExecutorEvents'; import { resolve } from 'pathe'; import { @@ -414,12 +428,12 @@ async function runNativeTurn( await agent.accessor.get(IAuthSummaryService).ensureReady(); const turnEndings = createPrintTurnEndings(); - const subscription = agent.accessor.get(IEventBus).subscribe((event: DomainEvent) => { + const subscription = agent.accessor.get(IEventBus).subscribe((event: Event2) => { dispatchNativeEvent(writer, event, stderr); // Arm the turn-endings collector before `turn.result` settles so a // background-task completion that steers a new turn right after the main // turn ends cannot have its `turn.ended` slip past the policy loop. - if (event.type === 'turn.ended') turnEndings.push(event); + if (event.type === 'turn.ended') turnEndings.push(event as TurnEnded); }); try { const handle = await agent.accessor.get(IAgentPromptService).enqueue({ @@ -511,13 +525,12 @@ async function runNativeGoal( replace: goal.replace, }); let completedSnapshot: { readonly status: string } | null = null; - const subscription = agent.accessor.get(IEventBus).subscribe((event: DomainEvent) => { - if ( - event.type === 'goal.updated' && - event.change?.kind === 'completion' && - event.snapshot !== null - ) { - completedSnapshot = event.snapshot; + const subscription = agent.accessor.get(IEventBus).subscribe((event: Event2) => { + if (event.type === 'goal.updated') { + const updated = event as unknown as GoalUpdated; + if (updated.change?.kind === 'completion' && updated.snapshot !== null) { + completedSnapshot = updated.snapshot; + } } }); try { @@ -538,7 +551,7 @@ async function runNativeGoal( function dispatchNativeEvent( writer: PromptTurnWriter, - event: DomainEvent, + event: Event2, stderr: PromptOutput, ): void { switch (event.type) { @@ -548,35 +561,43 @@ function dispatchNativeEvent( return; case 'turn.step.retrying': writer.discardAssistant(); - writer.writeRetrying(event); + writer.writeRetrying(event as unknown as TurnStepRetrying); return; case 'assistant.delta': - writer.writeAssistantDelta(event.delta); + writer.writeAssistantDelta((event as unknown as AssistantDelta).delta); return; case 'hook.result': - writer.writeHookResult(event); + writer.writeHookResult(event as unknown as HookResult); return; case 'thinking.delta': - writer.writeThinkingDelta(event.delta); + writer.writeThinkingDelta((event as unknown as ThinkingDelta).delta); return; - case 'tool.call.started': - writer.writeToolCall(event.toolCallId, event.name, event.args); + case 'tool.call.started': { + const started = event as unknown as ToolCallStarted; + writer.writeToolCall(started.toolCallId, started.name, started.args); return; - case 'tool.call.delta': - writer.writeToolCallDelta(event.toolCallId, event.name, event.argumentsPart); + } + case 'tool.call.delta': { + const delta = event as unknown as ToolCallDelta; + writer.writeToolCallDelta(delta.toolCallId, delta.name, delta.argumentsPart); return; - case 'tool.result': - writer.writeToolResult(event.toolCallId, event.output); + } + case 'tool.result': { + const result = event as unknown as ToolResultEvent; + writer.writeToolResult(result.toolCallId, result.output); return; - case 'tool.progress': - if (event.update.text !== undefined && event.update.text.length > 0) { - stderr.write(event.update.text.endsWith('\n') ? event.update.text : `${event.update.text}\n`); + } + case 'tool.progress': { + const progress = (event as unknown as ToolProgress).update; + if (progress.text !== undefined && progress.text.length > 0) { + stderr.write(progress.text.endsWith('\n') ? progress.text : `${progress.text}\n`); } return; + } } } -export type PrintTurnEnding = Extract; +export type PrintTurnEnding = TurnEnded; /** * Source of `turn.ended` events for the print steer loop. `next` resolves with diff --git a/apps/kimi-code/test/cli/run-v2-print.test.ts b/apps/kimi-code/test/cli/run-v2-print.test.ts index f4927455b65..63c9264c11b 100644 --- a/apps/kimi-code/test/cli/run-v2-print.test.ts +++ b/apps/kimi-code/test/cli/run-v2-print.test.ts @@ -13,7 +13,7 @@ function ending( turnId: number, reason: PrintTurnEnding['reason'] = 'completed', ): PrintTurnEnding { - return { type: 'turn.ended', turnId, reason }; + return { type: 'turn.ended', turnId, reason } as unknown as PrintTurnEnding; } interface ScriptedEntry { diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index 0b8aa47ae76..b55249f977c 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -22,7 +22,7 @@ import { ISessionManager, ITelemetryService, type BootstrapInput, - type DomainEvent, + type Event2, } from '@moonshot-ai/agent-core-v2'; import { runV2Print } from '../../src/cli/v2/run-v2-print'; @@ -123,7 +123,7 @@ function opts(overrides: Record = {}) { function makeFakeHarness() { // Native event listeners registered on the main agent's IEventBus; the turn // emits a streaming assistant delta before completing. - const eventListeners = new Set<(event: DomainEvent) => void>(); + const eventListeners = new Set<(event: Event2) => void>(); const profileState: { profileName: string | undefined } = { profileName: undefined }; const agentServices = new Map([ @@ -141,7 +141,7 @@ function makeFakeHarness() { [ IEventBus, { - subscribe: vi.fn((handler: (event: DomainEvent) => void) => { + subscribe: vi.fn((handler: (event: Event2) => void) => { eventListeners.add(handler); return { dispose: () => eventListeners.delete(handler) }; }), @@ -153,7 +153,7 @@ function makeFakeHarness() { enqueue: vi.fn(async () => { // Emit a native assistant delta on the main agent bus, then complete. for (const listener of [...eventListeners]) { - listener({ type: 'assistant.delta', turnId: 1, delta: 'hello world' } as DomainEvent); + listener({ type: 'assistant.delta', turnId: 1, delta: 'hello world' } as unknown as Event2); } return { launched: Promise.resolve({ diff --git a/packages/acp-server/test/e2e-turn.test.ts b/packages/acp-server/test/e2e-turn.test.ts index 7d48e93b4b4..5aea5404249 100644 --- a/packages/acp-server/test/e2e-turn.test.ts +++ b/packages/acp-server/test/e2e-turn.test.ts @@ -15,6 +15,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { getLiveSessionById, IAgentLifecycleService, IEventBus } from '@moonshot-ai/agent-core-v2'; +import { ToolProgress } from '@moonshot-ai/agent-core-v2/agent/toolExecutor/toolExecutorEvents'; import { afterEach, describe, expect, it } from 'vitest'; import { mapPromptLaunchError } from '../src/session'; @@ -508,18 +509,20 @@ describe('acp-server real prompt turn (scripted LLM)', () => { const agentHandle = session?.accessor.get(IAgentLifecycleService).get('main'); const bus = agentHandle?.accessor.get(IEventBus); expect(bus).toBeDefined(); - bus!.publish({ - type: 'tool.progress', - turnId, - toolCallId: 'call_1', - update: { kind: 'stdout', text: 'raw-stdout-bytes' }, - }); - bus!.publish({ - type: 'tool.progress', - turnId, - toolCallId: 'call_1', - update: { kind: 'status', text: 'Still working…' }, - }); + bus!.publish( + new ToolProgress({ + turnId, + toolCallId: 'call_1', + update: { kind: 'stdout', text: 'raw-stdout-bytes' }, + }), + ); + bus!.publish( + new ToolProgress({ + turnId, + toolCallId: 'call_1', + update: { kind: 'status', text: 'Still working…' }, + }), + ); const result = (await promptPromise) as { stopReason: string }; expect(result.stopReason).toBe('end_turn'); diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index b7475bdf921..cdb757c1f0c 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -17,7 +17,7 @@ The DI kernel (`src/_base/di/`) owns the unit layer on top of the scoped registr - `instantiation.ts` — the `@ref(IX)` decorator factory (`LiveRef`: `current` live read + `onDidChange` availability event; observation creates no binding and no graph edge) and `ScopeActivation`. - `src/app/feature/` — `IFeatureManager` (App scope): runtime unit assembly (`provideUnit` / `unprovideUnit` / `updateUnit`) and introspection (`units()` / `onDidChangeUnits`); managed units hang on the manager's own book. External package management stays with `IPluginService`. The `features` assembly (`src/features/featureAssemblyService.ts`) drains the module-level feature table through it. -The four contribution seams (token → fold): config sections — `ConfigSectionContribution` → `ConfigRegistry` fold (`src/app/config/`; module-level `registerConfigSection` stays the static built-in channel drained at construction, a withdrawn runtime record unregisters the section while user TOML values survive); agent tools — `AgentToolContribution` → `AgentToolActivationService` fold (built-in records provided once at App scope by `builtinToolAssemblyService`; `registerAgentToolService` stays the static channel: Agent-scope DI `OnDemand` registration + module table); agent profiles — `AgentProfileContribution` → `IAgentProfileRegistry` fold (see Scopes); wire vocabulary — `WireModelContribution` → `WireService` fold (a record bundles `models` / `ops` / `crossReducers` / `checkpointedModels`; the built-in layer is the module tables drained at fold time — `defineOp` / `defineModel` / `defineCheckpointedModel` stay the static channel — and replaying a withdrawn domain's history lands on the unknown-op skip-and-count path). A fifth seam: executable commands — `CommandContribution` → `IAgentCommandService` fold (`src/agent/command/`; a contributed command runs engine-side — `run(ctx)` gets `ctx.get` resolving through the agent container, valid only during the synchronous part of `run`; name-level dedup, last record wins; surfaced over RPC as `agentCommandService.list` / `run`). +The four contribution seams (token → fold): config sections — `ConfigSectionContribution` → `ConfigRegistry` fold (`src/app/config/`; module-level `registerConfigSection` stays the static built-in channel drained at construction, a withdrawn runtime record unregisters the section while user TOML values survive); agent tools — `AgentToolContribution` → `AgentToolActivationService` fold (built-in records provided once at App scope by `builtinToolAssemblyService`; `registerAgentToolService` stays the static channel: Agent-scope DI `OnDemand` registration + module table); agent profiles — `AgentProfileContribution` → `IAgentProfileRegistry` fold (see Scopes); event/state vocabulary — `EventStateContribution` → `IEventDispatcher` fold (a record bundles `events`; `registerEvent2Class` stays the static channel drained at fold time, while `defineState(...).replayable(...)` keys are explicit owner-service contributions: each replayable key's owning service contributes it via `contributeState` at construction — Agent-scope owners are eager, the session-domain todo/cron/interaction keys bridge through `agentLifecycle.onDidCreate` before `restore()` — and replaying a withdrawn domain's history lands on the unknown-type skip-and-count path). A fifth seam: executable commands — `CommandContribution` → `IAgentCommandService` fold (`src/agent/command/`; a contributed command runs engine-side — `run(ctx)` gets `ctx.get` resolving through the agent container, valid only during the synchronous part of `run`; name-level dedup, last record wins; surfaced over RPC as `agentCommandService.list` / `run`). `src/features/` — built-in capabilities authored as self-contained Feature units (`plan` was the first, extracted from `agent/plan` + `agent/tools/plan`; `swarm` followed, extracted from `agent/swarm` + `session/swarm` + `agent/tools/agent-swarm` into a scope-organized `agent/` + `session/` + `tools/` layout; `tower` lives here as `features/tower/` — protocol store, rate limit, tower-mode service, eleven `Tower*` tools, the `tower-worker` profile, and the `/tower` skill body). A `Feature` (`src/features/feature.ts`) is an App-scope unit recipe with a `static override readonly name` and `contribute*` helpers composing the seams: `contributeService(scope, id, ctor)` / `contributeAgentService` (per-scope materialization via `ScopeUnits` — provider death retracts everywhere, 连坐), `contributeTool` (per-agent `OnDemand` registration + the `AgentToolContribution` record), `contributeProfiles`, `contributeConfig`, `contributeCommand`, plus `onDispose`. Feature modules self-register at import (`registerFeature`, `src/features/featureRegistry.ts`); the App-scope `IFeatureAssemblyService` drains the table through `IFeatureManager.provideUnit`, so every feature is a named, introspectable, retractable managed unit. Built-in features keep user-facing static contracts — config sections, agent profiles, wire vocabulary — on the static import=register channels (the config/state manifest generators read static tables / call sites; wire records must stay replayable); the Feature unit carries the runtime capabilities (services, tools, commands). The string form of the unit `on(...)` capability (`this.on('turn.ended', …)`) is backed by the production `FiberEventResolver` registered in `src/app/event/fiberEventResolver.ts`, resolving against the scope's `IEventBus`. @@ -86,7 +86,7 @@ One accepted exception: `features/tower/protocol` manages the `.tower/` director ## Conversation undo -`context.undo` is the only persisted undo fact. `contextMemory/conversationTime.ts` owns the conversation clock (`isUndoAnchor` — the single tick predicate used by `computeUndoCut`, the checkpoint reducers, and the transcript reducer) and the checkpoint protocol. A wire Model whose state must follow conversation undo (todo, plan, task-notification delivery, …) **MUST** be defined with `defineCheckpointedModel` — never hand-roll the push/clear/restore reducers — which also registers it into `CHECKPOINTED_MODELS` for the undo pipeline's pre-cut depth check. World-time state (turn counters, task registries, revision counters) must stay outside checkpointed Models. +`context.undo` is the only persisted undo fact. `contextMemory/conversationTime.ts` owns the conversation clock (`isUndoAnchor` — the single tick predicate used by `computeUndoCut`, the checkpoint folds, and the transcript reducer) and registers the undoable protocol (`registerUndoableProtocol`). A state key whose value must follow conversation undo (todo, plan, task-notification delivery, …) **MUST** chain `.undoable()` on its `defineState(...).replayable(...)` definition — never hand-roll the checkpoint/clear/rollback folds — so the dispatcher expands the protocol folds (undo anchors push a checkpoint, compaction/clear drop the markers, `context.undo` rolls back through inverse patches; a custom `onUndo` replaces the rollback, as the conversation history does) and the undo pipeline's pre-cut depth check sees the key (the dispatcher tracks patch history and checkpoint markers per state key). World-time state (turn counters, task registries, revision counters) must stay outside undoable keys. ## Model-facing reminders @@ -103,5 +103,5 @@ Per-domain references live in `docs/`. - [`docs/di-testing.md`](docs/di-testing.md) — Read **before writing or touching any DI/Scope test**: picking the right harness (`InstantiationService` vs `TestInstantiationService` vs `createScopedTestHost`), declaring deps with `@IService`, stubbing collaborators, and teardown via `DisposableStore`. - [`docs/features.md`](docs/features.md) — Read **before adding or extracting a built-in feature** (`src/features//`): the `Feature` base class, the `contribute*` seams, the static-vs-feature channel rules, and the assembly/retraction lifecycle. - [`docs/config-manifest.toml`](docs/config-manifest.toml) — Generated list of every registered config section, in the on-disk `config.toml` shape (owner, scope, defaults, env bindings, schema fields). Do not edit by hand; regenerate with `pnpm gen:config-manifest` after adding or removing a `registerConfigSection` call — `test/app/config/configManifest.test.ts` enforces freshness. -- [`docs/wire-manifest.d.ts`](docs/wire-manifest.d.ts) — Generated declaration file listing every registered wire record type as a payload interface (model, persist policy, `toEvent`, cross-reducers in the doc comment; payload fields in real TS type syntax), plus a `WirePayloadMap`. Do not edit by hand; regenerate with `pnpm gen:wire-manifest` after adding or removing a `defineOp` call — `test/wire/wireManifest.test.ts` enforces freshness and checks the file parses. -- [`docs/state-manifest.d.ts`](docs/state-manifest.d.ts) — Generated declaration file listing every state key registered into `IAppStateService` / `IWorkspaceStateService` / `ISessionStateService` / `IAgentStateService`, as `AppStateSnapshot` / `WorkspaceStateSnapshot` / `SessionStateSnapshot` / `AgentStateSnapshot` interfaces (keys grouped by defining file), plus the `AppStateKey` / `WorkspaceStateKey` / `SessionStateKey` / `AgentStateKey` unions. Self-contained: every value type is expanded fully inline with each named type marked by a `/* TypeName — source/file.ts */` comment (recursion stops with a `recursive` marker) — no imports, no helper declarations. Do not edit by hand; regenerate with `pnpm gen:state-manifest` after adding or removing a `states.register(...)` call — `test/state/stateManifest.test.ts` enforces freshness and checks the file parses. +- [`docs/wire-manifest.d.ts`](docs/wire-manifest.d.ts) — Generated declaration file listing every durable wire record type (an `Event2` subclass with `static durable = true` + `static schema`) as a payload interface (folding states, blob codec owners, and owner file in the doc comment; payload fields in real TS type syntax), plus a `WirePayloadMap`. Do not edit by hand; regenerate with `pnpm gen:wire-manifest` after adding or removing a durable `Event2` class — `test/wire/wireManifest.test.ts` enforces freshness and checks the file parses. +- [`docs/state-manifest.d.ts`](docs/state-manifest.d.ts) — Generated declaration file listing every state key registered into `IAppStateService` / `IWorkspaceStateService` / `ISessionStateService` / `IAgentStateService`, as `AppStateSnapshot` / `WorkspaceStateSnapshot` / `SessionStateSnapshot` / `AgentStateSnapshot` interfaces (keys grouped by defining file), plus the `AppStateKey` / `WorkspaceStateKey` / `SessionStateKey` / `AgentStateKey` unions. Self-contained: every value type is expanded fully inline with each named type marked by a `/* TypeName — source/file.ts */` comment (recursion stops with a `recursive` marker) — no imports, no helper declarations. Do not edit by hand; regenerate with `pnpm gen:state-manifest` after adding or removing a `states.contributeState(...)` call or a `defineState(...).replayable(...)` key (the Agent section covers the replayable keys contributed by their owner services, with each replayable key's fold/durable/undoable info) — `test/state/stateManifest.test.ts` enforces freshness and checks the file parses. diff --git a/packages/agent-core-v2/docs/errors.md b/packages/agent-core-v2/docs/errors.md index c4355465d0c..5903c2a3208 100644 --- a/packages/agent-core-v2/docs/errors.md +++ b/packages/agent-core-v2/docs/errors.md @@ -72,7 +72,7 @@ The os / persistence / wire domains show the standard shapes: - **`os.fs` (`HostFsError`, `os/interface/hostFsErrors.ts`)** — every `IHostFileSystem` backend translates raw errnos at its boundary via the pure `toHostFsError(err, { path, op })`: `ENOENT→os.fs.not_found`, `EISDIR→os.fs.is_directory`, `ENOTDIR→os.fs.not_directory`, `EEXIST→os.fs.already_exists`, `EACCES/EPERM→os.fs.permission_denied`, `ENOTEMPTY→os.fs.not_empty`, everything else `os.fs.unknown`. `details` carries `{ path, op, errno?, syscall? }`. Documented boolean semantics (e.g. `createExclusive` returning `false` on `EEXIST`) stay booleans, not errors. - **`os.process` (`HostProcessError`, `os/interface/hostProcess.ts`)** — `os.process.spawn_failed` (details `{ command, args?, cwd?, errno? }`) and `os.process.kill_failed`; both carry the raw error as `cause`. Kill keeps its deliberate tolerances: `ESRCH` is a silent no-op, `EPERM` degrades to `child.kill()`. - **`storage` (`StorageError`, `persistence/interface/storage.ts`)** — `storage.not_found` / `decode_failed` / `corrupted` / `io_failed` / `locked` / `permission_denied` / `disk_full`. ENOENT keeps its established absence semantics (`read → undefined`, `list → []`) and is *not* an error; other I/O failures are mapped by errno at the backend boundary via `toStorageIoError`: `EACCES/EPERM→storage.permission_denied`, `ENOSPC→storage.disk_full`, an unexpected `ENOENT→storage.not_found`, everything else `storage.io_failed` (the only retryable one besides `storage.locked`). Codec parse failures become `storage.decode_failed` with `{ scope, key, format }`; append-log corruption is `AppendLogCorruptedError` (`storage.corrupted`). `storage.locked` is reserved for a store exclusively held by another process — consumers (e.g. `FileSessionIndex`) catch it explicitly and fall back to their non-read-model path with a one-time warning; there is no silent no-op degradation. (The minidb query-store backend is a multi-process `ClusterDb` and no longer throws it: peers share the store, and per-shard lock contention surfaces as a transient `LockError` instead.) -- **`wire` (`WireError`, `wire/errors.ts`)** — `DuplicateOpError` (`wire.duplicate_op`, a build-time bug), `CycleError` (`wire.cycle`, details carry the drain depth and a capped op-type sample), and `wire.unknown_record`: replay skips records whose Op type is absent from `OP_REGISTRY` (compatibility), reports each skip through `onUnexpectedError`, and returns `{ unknownRecords }` so the caller knows the restore was lossy. +- **`wire` (`WireError`, `wire/errors.ts`)** — `wire.unknown_record`: restore skips records whose durable event type is absent from the folded registry (compatibility) and reports each skip through `onUnexpectedError`; `wire.migration_missing` covers journals that predate the migration chain. The sibling `event`/`state` domains own `event.duplicate_event` (a build-time bug), `state.duplicate_fold`, `state.durability_mismatch`, and `CycleError` (`state.cycle`, details carry the drain depth and a capped event-type sample). ## Serialization & boundary translation diff --git a/packages/agent-core-v2/docs/features.md b/packages/agent-core-v2/docs/features.md index 43c330885de..e520fd1e9e9 100644 --- a/packages/agent-core-v2/docs/features.md +++ b/packages/agent-core-v2/docs/features.md @@ -71,8 +71,9 @@ they belong to a feature: `docs/config-manifest.toml`. - **Agent profiles** contributed via `registerAgentProfile` — same static-table reasoning. -- **Wire vocabulary** (`defineOp` / `defineModel` / `defineCheckpointedModel`) — wire - records must remain replayable even if the feature unit is retracted. +- **Wire vocabulary** (durable `Event2` classes / + `defineState(...).replayable(...)`) — wire records must remain replayable even if the + feature unit is retracted. The Feature unit carries the **runtime capabilities**: services, tools, commands, hook subscriptions. `PlanFeature` is the example: `configSection.ts` and `profile/plan.ts` diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index b369b8b8128..57afcec7edf 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -7,8 +7,12 @@ // Workspace-scope IWorkspaceStateService, the Session-scope // ISessionStateService, or the Agent-scope IAgentStateService (see // src/_base/state/stateRegistry.ts), collected statically from the -// `states.register(...)` call sites — a key defined via -// defineState but never registered does not appear here. Each entry shows the +// `states.contributeState(...)` call sites and the replayable key chains — a +// `defineState(...).replayable(...)` key is contributed into the Agent-scope +// service by its owner service at construction, and +// carries a `// replayable · durable|transient · undoable? — folds: ...` line. +// Replayable values are excluded from snapshot()/inspect(). A key defined via +// defineState but never registered nor replayable does not appear here. Each entry shows the // compile-time StateKey value type fully expanded inline, so the manifest is // self-contained (no imports, no helper declarations). A named type is marked // at its expansion site with a `/* TypeName — source/file.ts */` comment; a @@ -23,7 +27,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 70 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 98 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -61,14 +65,18 @@ // agentsMdReminder.cwd src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.known src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.seeded src/agent/agentsMdReminder/agentsMdReminderService.ts +// contextMemory src/agent/contextMemory/contextOps.ts // contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts +// cron src/session/cron/cronOps.ts // dateChange.seed src/features/dateChange/dateChangeService.ts // externalHooks.stopHookContinuationUsed src/agent/externalHooks/externalHooksService.ts +// fullCompaction src/agent/fullCompaction/compactionOps.ts // fullCompaction.activeTurnId src/agent/fullCompaction/fullCompactionService.ts // fullCompaction.compactionCountInTurn src/agent/fullCompaction/fullCompactionService.ts // fullCompaction.consecutiveOverflowCompactions src/agent/fullCompaction/fullCompactionService.ts // fullCompaction.lastCompactedTokenCount src/agent/fullCompaction/fullCompactionService.ts // fullCompaction.observedMaxContextTokensByModel src/agent/fullCompaction/fullCompactionService.ts +// goal src/agent/goal/goalOps.ts // goal.budgetGraceTurns src/agent/goal/goalService.ts // goal.countedGoalTurns src/agent/goal/goalService.ts // goal.exhaustedTurnBudgetGoals src/agent/goal/goalService.ts @@ -81,6 +89,10 @@ // goal.liveWallClockStartedAt src/agent/goal/goalService.ts // goal.pendingContinuationGoals src/agent/goal/goalService.ts // goal.resumeContinuation src/agent/goal/goalService.ts +// goalForkNotice src/agent/goal/goalService.ts +// interaction src/session/interaction/interactionOps.ts +// interruptionReminder src/agent/interruptionReminder/interruptionReminderOps.ts +// llm.requestTrace src/agent/llmRequester/llmRequestOps.ts // llmRequester.emittedThinkingEffortWarnings src/agent/llmRequester/llmRequesterService.ts // llmRequester.lastConfigLogSignature src/agent/llmRequester/llmRequesterService.ts // llmRequester.mediaDegradedTurns src/agent/llmRequester/llmRequesterService.ts @@ -89,26 +101,42 @@ // loop.disposing src/agent/loop/loopService.ts // loop.lastRequestTraceId src/agent/loop/loopService.ts // loop.nextReservedTurnId src/agent/loop/loopService.ts +// mcp.discovery src/agent/mcp/mcpDiscoveryOps.ts // mcp.discoveryWritesReady src/agent/mcp/mcpService.ts // mcp.mcpToolsByServer src/agent/mcp/mcpService.ts // media.registeredKey src/agent/media/mediaToolsRegistrar.ts -// media.resolved src/agent/media/mediaResolverService.ts +// media.resolved src/agent/media/videoResolverService.ts +// permissionMode src/agent/permissionMode/permissionModeOps.ts +// permissionMode.configured src/agent/permissionMode/permissionModeOps.ts // permissionMode.lastMode src/agent/permissionMode/injection/permissionModeInjection.ts +// permissionRules src/agent/permissionRules/permissionRulesOps.ts +// plan src/features/plan/planOps.ts // plan.wasActive src/features/plan/injection/planModeInjection.ts +// pluginSessionStartSnapshot src/agent/plugin/agentPluginOps.ts +// profile src/agent/profile/profileOps.ts // profile.activeToolNamesOverlay src/agent/profile/profileService.ts +// profile.activeTools src/agent/profile/profileOps.ts // profile.agentsMdWarning src/agent/profile/profileService.ts // profile.emittedPluginBudgetWarnings src/agent/profile/profileService.ts // profile.emittedThinkingEffortWarnings src/agent/profile/profileService.ts // profile.emittedToolPatternWarnings src/agent/profile/profileService.ts // prompt.launching src/agent/prompt/promptService.ts +// promptAdmission src/agent/prompt/promptOps.ts // runtime.binding src/agent/runtimeBinding/runtimeBindingService.ts +// runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts // shellCommand.tasks src/agent/shellCommand/shellCommandService.ts +// skill src/agent/skill/skillOps.ts // stepRetry.failedAttempts src/agent/stepRetry/stepRetryService.ts // stepRetry.lastFailedDriverId src/agent/stepRetry/stepRetryService.ts +// swarm src/features/swarm/swarmOps.ts +// task src/agent/task/taskOps.ts // task.activeTaskReminderPending src/agent/task/taskService.ts // task.deliveredNotificationKeys src/agent/task/taskService.ts // task.ghosts src/agent/task/taskService.ts +// task.notificationDelivery src/agent/task/taskService.ts // task.scheduledNotificationKeys src/agent/task/taskService.ts +// todo src/session/todo/todoOps.ts +// tokenCounting src/agent/tokenCounting/tokenCountingOps.ts // toolDedupe.activeStep src/agent/toolDedupe/toolDedupeService.ts // toolDedupe.activeTurnId src/agent/toolDedupe/toolDedupeService.ts // toolDedupe.callKeyByCallId src/agent/toolDedupe/toolDedupeService.ts @@ -120,8 +148,12 @@ // toolExecutor.dupTypeTurnId src/agent/toolExecutor/toolExecutorService.ts // toolExecutor.toolCallDupTypes src/agent/toolExecutor/toolExecutorService.ts // toolSelect.pendingLoaded src/agent/toolSelect/toolSelectService.ts +// tower src/features/tower/towerOps.ts +// turn src/agent/loop/turnOps.ts +// usage src/agent/usage/usageOps.ts // usage.currentTurn src/agent/usage/usageService.ts // usage.currentTurnId src/agent/usage/usageService.ts +// userTool src/agent/userTool/userToolOps.ts /** App-scope keys registered into IAppStateService. */ export interface AppStateSnapshot { @@ -1022,16 +1054,155 @@ export interface AgentStateSnapshot { 'agentsMdReminder.cwd': string | undefined; 'agentsMdReminder.known': Set; 'agentsMdReminder.seeded': boolean; + // src/agent/contextMemory/contextOps.ts + // replayable · durable · undoable — folds: ContextAppendMessage, ContextAppendLoopEvent, ContextClear, ContextApplyCompaction + 'contextMemory': (/* ContextMessage — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* Message — packages/agent-core-v2/src/kosong/contract/message.ts */ { + readonly role: /* Role — packages/agent-core-v2/src/kosong/contract/message.ts */ 'user' | 'assistant' | 'system' | 'tool'; + readonly name?: string; + readonly content: (/* ContentPart — packages/agent-core-v2/src/kosong/contract/message.ts */ /* TextPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { + type: 'text'; + text: string; + } | /* ThinkPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { + type: 'think'; + think: string; + encrypted?: string; + } | /* ImageURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { + type: 'image_url'; + imageUrl: { + url: string; + id?: string; + }; + } | /* AudioURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { + type: 'audio_url'; + audioUrl: { + url: string; + id?: string; + }; + } | /* VideoURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { + type: 'video_url'; + videoUrl: { + url: string; + id?: string; + }; + })[]; + readonly toolCalls: /* ToolCall — packages/agent-core-v2/src/kosong/contract/message.ts */ { + type: 'function'; + id: string; + name: string; + arguments: string | null; + extras?: Record; + _streamIndex?: string | number; + }[]; + readonly toolCallId?: string; + readonly partial?: boolean; + readonly tools?: readonly /* Tool — packages/agent-core-v2/src/kosong/contract/tool.ts */ { + name: string; + description: string; + parameters: Record; + deferred?: true; + }[]; + } & { + readonly id?: string; + readonly providerMessageId?: string; + readonly origin?: /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'user'; + readonly skillActivations?: readonly /* BundledSkillActivation — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + readonly skillType?: string; + readonly skillPath?: string; + readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin'; + }[]; + } | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'skill_activation'; + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill'; + readonly skillType?: string; + readonly skillPath?: string; + readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin'; + } | /* PluginCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'plugin_command'; + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly commandArgs?: string; + readonly trigger: 'user-slash'; + } | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'injection'; + readonly variant: string; + readonly ownerPromptId?: string; + readonly disclosure?: unknown; + } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'shell_command'; + readonly phase: 'input' | 'output'; + readonly isError?: boolean; + } | /* CompactionSummaryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'compaction_summary'; + } | /* SystemTriggerOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'system_trigger'; + readonly name: string; + } | /* TaskOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'task'; + readonly taskId: string; + readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; + readonly notificationId: string; + } | /* CronJobOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'cron_job'; + readonly jobId: string; + readonly cron: string; + readonly recurring: boolean; + readonly coalescedCount: number; + readonly stale: boolean; + } | /* CronMissedOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'cron_missed'; + readonly count: number; + } | /* HookResultOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'hook_result'; + readonly event: string; + readonly blocked?: boolean; + } | /* RetryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'retry'; + readonly trigger?: string; + }; + readonly isError?: boolean; + readonly note?: string; + })[]; // src/agent/contextProjector/contextProjectorService.ts 'contextProjector.lastRepairSignature': string | null; // src/agent/externalHooks/externalHooksService.ts 'externalHooks.stopHookContinuationUsed': boolean; + // src/agent/fullCompaction/compactionOps.ts + // replayable · durable — folds: FullCompactionBegin, FullCompactionCancel, FullCompactionComplete + 'fullCompaction': /* CompactionState — packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts */ { + readonly phase: /* CompactionPhase — packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts */ 'completed' | 'cancelled' | 'running' | 'idle'; + }; // src/agent/fullCompaction/fullCompactionService.ts 'fullCompaction.activeTurnId': number | undefined; 'fullCompaction.compactionCountInTurn': number; 'fullCompaction.consecutiveOverflowCompactions': number; 'fullCompaction.lastCompactedTokenCount': number | null; 'fullCompaction.observedMaxContextTokensByModel': Map; + // src/agent/goal/goalOps.ts + // replayable · durable — folds: GoalCreate, GoalUpdate, GoalClear, GoalForked + 'goal': /* GoalModelState — packages/agent-core-v2/src/agent/goal/goalOps.ts */ /* GoalState — packages/agent-core-v2/src/agent/goal/goalOps.ts */ { + readonly goalId: string; + readonly objective: string; + readonly completionCriterion?: string; + readonly status: /* GoalStatus — packages/agent-core-v2/src/agent/goal/types.ts */ 'blocked' | 'active' | 'paused' | 'complete'; + readonly turnsUsed: number; + readonly tokensUsed: number; + readonly wallClockMs: number; + readonly wallClockResumedAt?: number; + readonly budgetLimits: /* GoalBudgetLimits — packages/agent-core-v2/src/agent/goal/types.ts */ { + readonly tokenBudget?: number; + readonly turnBudget?: number; + readonly wallClockBudgetMs?: number; + }; + readonly terminalReason?: string; + } | null; // src/agent/goal/goalService.ts 'goal.budgetGraceTurns': Set; 'goal.countedGoalTurns': Set; @@ -1048,6 +1219,19 @@ export interface AgentStateSnapshot { readonly turnId: number; readonly goalId: string; } | undefined; + // replayable · durable — folds: GoalCreate, GoalClear, GoalForked, ContextAppendMessage + 'goalForkNotice': /* GoalForkNoticeState — packages/agent-core-v2/src/agent/goal/goalService.ts */ { + readonly goalPresent: boolean; + readonly reminderPending: boolean; + }; + // src/agent/interruptionReminder/interruptionReminderOps.ts + // replayable · durable — folds: InterruptionReminderRecorded + 'interruptionReminder': null; + // src/agent/llmRequester/llmRequestOps.ts + // replayable · durable — folds: LlmToolsSnapshot, LlmRequest + 'llm.requestTrace': /* LlmRequestTraceState — packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts */ { + readonly seenToolsHashes: readonly string[]; + }; // src/agent/llmRequester/llmRequesterService.ts 'llmRequester.emittedThinkingEffortWarnings': Set; 'llmRequester.lastConfigLogSignature': string | undefined; @@ -1093,10 +1277,28 @@ export interface AgentStateSnapshot { 'loop.disposing': boolean; 'loop.lastRequestTraceId': string | undefined; 'loop.nextReservedTurnId': number | undefined; + // src/agent/loop/turnOps.ts + // replayable · durable — folds: ContextAppendLoopEvent, TurnPrompt, TurnSteer, TurnCancel, TurnEnded + 'turn': /* TurnModelState — packages/agent-core-v2/src/agent/loop/turnOps.ts */ { + readonly nextTurnId: number; + readonly cancelledTurnIds: readonly number[]; + readonly lastEnded?: { + readonly turnId: number; + readonly reason: 'completed' | 'cancelled' | 'failed' | 'blocked'; + readonly durationMs?: number; + }; + }; + // src/agent/mcp/mcpDiscoveryOps.ts + // replayable · durable — folds: McpToolsDiscovered + 'mcp.discovery': /* McpDiscoveryState — packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts */ { + readonly seen: readonly string[]; + }; // src/agent/mcp/mcpService.ts 'mcp.discoveryWritesReady': boolean; 'mcp.mcpToolsByServer': Map; - // src/agent/media/mediaResolverService.ts + // src/agent/media/mediaToolsRegistrar.ts + 'media.registeredKey': string | undefined; + // src/agent/media/videoResolverService.ts 'media.resolved': Map; - // src/agent/media/mediaToolsRegistrar.ts - 'media.registeredKey': string | undefined; // src/agent/permissionMode/injection/permissionModeInjection.ts 'permissionMode.lastMode': 'manual' | 'yolo' | 'auto' | undefined; + // src/agent/permissionMode/permissionModeOps.ts + // replayable · durable — folds: PermissionSetMode + 'permissionMode': /* PermissionMode — packages/agent-core-v2/src/agent/permissionPolicy/types.ts */ 'manual' | 'yolo' | 'auto'; + // replayable · durable — folds: PermissionSetMode + 'permissionMode.configured': boolean; + // src/agent/permissionRules/permissionRulesOps.ts + // replayable · durable — folds: PermissionRulesAdd, PermissionRecordApprovalResult + 'permissionRules': /* PermissionRulesModelState — packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts */ { + readonly rules: readonly /* PermissionRule — packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts */ { + readonly decision: /* PermissionRuleDecision — packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts */ 'allow' | 'deny' | 'ask'; + readonly scope: /* PermissionRuleScope — packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts */ 'project' | 'user' | 'turn-override' | 'session-runtime'; + readonly pattern: string; + readonly reason?: string; + }[]; + readonly sessionApprovalRulePatterns: readonly string[]; + }; + // src/agent/plugin/agentPluginOps.ts + // replayable · durable — folds: PluginSessionStartEvent + 'pluginSessionStartSnapshot': /* PluginSessionStartSnapshotState — packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts */ { + readonly initialized: boolean; + readonly content?: string; + }; // src/agent/plugin/agentPluginService.ts 'agentPlugin.sessionStartRefreshPending': boolean; + // src/agent/profile/profileOps.ts + // replayable · durable — folds: ProfileBind, ConfigUpdate + 'profile': /* ProfileModelState — packages/agent-core-v2/src/agent/profile/profileOps.ts */ { + readonly modelAlias?: string; + readonly profileName?: string; + readonly thinkingLevel: string; + readonly systemPrompt: string; + readonly environmentDisclosure?: /* EnvironmentDisclosureSnapshot — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ { + readonly cwd: string; + readonly date: { + readonly disclosed: true; + readonly value: { + readonly localDate: string; + readonly timeZone: string; + }; + } | { + readonly disclosed: false; + }; + }; + readonly renderGeneration: number; + readonly agentsMdPaths?: readonly string[]; + readonly disallowedTools?: readonly string[]; + readonly subagents?: readonly string[]; + }; + // replayable · durable — folds: ToolsSetActiveTools, ToolsResetActiveTools, ProfileBind + 'profile.activeTools': /* ActiveToolsState — packages/agent-core-v2/src/agent/profile/profileOps.ts */ readonly string[] | undefined; // src/agent/profile/profileService.ts 'profile.activeToolNamesOverlay': readonly string[] | undefined; 'profile.agentsMdWarning': string | undefined; 'profile.emittedPluginBudgetWarnings': Set; 'profile.emittedThinkingEffortWarnings': Set; 'profile.emittedToolPatternWarnings': Set; + // src/agent/prompt/promptOps.ts + // replayable · durable — folds: PromptAccepted + 'promptAdmission': Map; // src/agent/prompt/promptService.ts 'prompt.launching': boolean; + // src/agent/runtimeBinding/runtimeBindingOps.ts + // replayable · durable — folds: RuntimeSetBinding + 'runtimeBinding': /* RuntimeBinding — packages/agent-core-v2/src/runtime/runtime.ts */ { + readonly workspaceId: string; + readonly runtimeId: string; + } | undefined; // src/agent/runtimeBinding/runtimeBindingService.ts 'runtime.binding': /* RuntimeBinding — packages/agent-core-v2/src/runtime/runtime.ts */ { readonly workspaceId: string; @@ -1144,13 +1401,31 @@ export interface AgentStateSnapshot { }; // src/agent/shellCommand/shellCommandService.ts 'shellCommand.tasks': Map; + // src/agent/skill/skillOps.ts + // replayable · transient — folds: SkillActivate + 'skill': null; // src/agent/stepRetry/stepRetryService.ts 'stepRetry.failedAttempts': number; 'stepRetry.lastFailedDriverId': string | undefined; - // src/agent/task/taskService.ts - 'task.activeTaskReminderPending': boolean; - 'task.deliveredNotificationKeys': Set; - 'task.ghosts': Map; + // src/agent/task/taskService.ts + 'task.activeTaskReminderPending': boolean; + 'task.deliveredNotificationKeys': Set; + 'task.ghosts': Map; + // replayable · durable · undoable — folds: ContextAppendMessage + 'task.notificationDelivery': readonly string[]; 'task.scheduledNotificationKeys': Set; + // src/agent/tokenCounting/tokenCountingOps.ts + // replayable · durable — folds: TokenCountingMeasured, TokenCountingTruncated, TokenCountingRebased + 'tokenCounting': /* TokenCountingState — packages/agent-core-v2/src/agent/tokenCounting/tokenCountingOps.ts */ { + readonly anchors: readonly /* TokenAnchor — packages/agent-core-v2/src/agent/tokenCounting/tokenCountingOps.ts */ { + readonly length: number; + readonly tokens: number; + readonly measured: boolean; + }[]; + readonly tokens: number; + }; // src/agent/toolDedupe/toolDedupeService.ts 'toolDedupe.activeStep': number; 'toolDedupe.activeTurnId': number | undefined; @@ -1209,6 +1527,16 @@ export interface AgentStateSnapshot { 'toolExecutor.toolCallDupTypes': Map; // src/agent/toolSelect/toolSelectService.ts 'toolSelect.pendingLoaded': Set; + // src/agent/usage/usageOps.ts + // replayable · durable — folds: UsageRecord + 'usage': /* UsageModelState — packages/agent-core-v2/src/agent/usage/usageOps.ts */ { + readonly byModel: Record; + }; // src/agent/usage/usageService.ts 'usage.currentTurn': /* TokenUsage — packages/agent-core-v2/src/kosong/contract/usage.ts */ { inputOther: number; @@ -1217,6 +1545,14 @@ export interface AgentStateSnapshot { inputCacheCreation: number; } | undefined; 'usage.currentTurnId': number | undefined; + // src/agent/userTool/userToolOps.ts + // replayable · durable — folds: ToolsRegisterUserTool, ToolsUnregisterUserTool + 'userTool': /* UserToolModelState — packages/agent-core-v2/src/agent/userTool/userToolOps.ts */ Map; + readonly disclosure?: 'deferred' | 'inline'; + }>; // src/features/dateChange/dateChangeService.ts 'dateChange.seed': /* DateDisclosure — packages/agent-core-v2/src/features/dateChange/dateChangeService.ts */ { readonly localDate: string; @@ -1225,6 +1561,47 @@ export interface AgentStateSnapshot { } | undefined; // src/features/plan/injection/planModeInjection.ts 'plan.wasActive': boolean; + // src/features/plan/planOps.ts + // replayable · durable · undoable — folds: PlanModeEnter, PlanModeCancel, PlanModeExit, PlanRevision + 'plan': /* PlanState — packages/agent-core-v2/src/features/plan/planOps.ts */ { + readonly active: boolean; + readonly id?: string; + readonly revisionCount?: Readonly>; + }; + // src/features/swarm/swarmOps.ts + // replayable · durable — folds: SwarmModeEnter, SwarmModeExit + 'swarm': 'task' | 'tool' | 'manual' | null; + // src/features/tower/towerOps.ts + // replayable · durable — folds: TowerModeEnter, TowerModeExit + 'tower': boolean; + // src/session/cron/cronOps.ts + // replayable · transient — folds: CronAdd, CronDelete, CronCursor + 'cron': /* CronModelState — packages/agent-core-v2/src/session/cron/cronOps.ts */ Map>; + }>; + // src/session/interaction/interactionOps.ts + // replayable · durable — folds: InteractionRequestEvent, InteractionResolvedEvent + 'interaction': /* InteractionModelState — packages/agent-core-v2/src/session/interaction/interactionOps.ts */ Map; + // src/session/todo/todoOps.ts + // replayable · durable · undoable — folds: ToolsUpdateStore + 'todo': readonly /* TodoItem — packages/agent-core-v2/src/session/todo/todoItem.ts */ { + readonly title: string; + readonly status: /* TodoStatus — packages/agent-core-v2/src/session/todo/todoItem.ts */ 'pending' | 'in_progress' | 'done'; + }[]; } export type AgentStateKey = keyof AgentStateSnapshot; diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 4181aad5436..38873dbec80 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -5,79 +5,77 @@ // // protocol_version: "1.5" (migrations: 1.0 -> 1.1 -> 1.2 -> 1.3 -> 1.4 -> 1.5) // -// One declaration per record type registered via defineOp(...) and drained from -// the runtime OP_REGISTRY. Every payload declaration carries its record type in -// a `_name` field. Payload sketches use TypeScript type syntax; when a -// named type is expanded inline, its name appears as a doc comment -// (`/** ContextMessage */`). Bare type names (ContentPart, ContextMessage, …) -// refer to the real types in src/ — they are intentionally not resolved here. -// `// …` marks a capped field list. On disk (wire.jsonl) the journal opens with -// a metadata line {"type": "metadata", "protocol_version", "created_at"}; each -// op record is {"type", ...payload, "time"} — object payloads spread at the -// top level, scalar payloads nest under a "payload" key. +// One declaration per durable record type — an Event2 subclass declaring +// `static type` + `static durable = true` + `static schema` — drained from the +// runtime EVENT2_REGISTRY ("import = register"). Every payload declaration +// carries its record type in a `_name` field. Payload sketches use TypeScript +// type syntax; when a named type is expanded inline, its name appears as a doc +// comment (`/** ContextMessage */`). Bare type names (ContentPart, +// ContextMessage, …) refer to the real types in src/ — they are intentionally +// not resolved here. `// …` marks a capped field list. On disk (wire.jsonl) +// the journal opens with a metadata line {"type": "metadata", +// "protocol_version", "created_at"}; each record is {"type", ...payload, +// "time"} — object payloads spread at the top level. // -// Declaration flags: persisted (written to the journal; absent = transient), -// toEvent (also publishes an IEventBus fact on live dispatch), blobs (the -// owning model offloads inline media to blob storage), cross-reducers -// (foreign models that also reduce this record on dispatch and replay). - -// Index (53 record types) -// config.update profile persisted src/agent/profile/profileOps.ts -// context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts -// cron.add cron transient src/session/cron/cronOps.ts -// cron.cursor cron transient src/session/cron/cronOps.ts -// cron.delete cron transient src/session/cron/cronOps.ts -// forked goal persisted src/agent/goal/goalOps.ts -// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// goal.clear goal persisted src/agent/goal/goalOps.ts -// goal.create goal persisted src/agent/goal/goalOps.ts -// goal.update goal persisted src/agent/goal/goalOps.ts -// interaction.request interaction persisted src/session/interaction/interactionOps.ts -// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts -// interruptionReminder.recorded interruptionReminder persisted src/agent/interruptionReminder/interruptionReminderOps.ts -// llm.request llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts -// llm.tools_snapshot llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts -// mcp.tools_discovered mcp.discovery persisted src/agent/mcp/mcpDiscoveryOps.ts -// permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts -// permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts -// permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts -// plan_mode.cancel plan persisted src/features/plan/planOps.ts -// plan_mode.enter plan persisted src/features/plan/planOps.ts -// plan_mode.exit plan persisted src/features/plan/planOps.ts -// plan.revision plan persisted src/features/plan/planOps.ts -// plugin.session_start pluginSessionStartSnapshot persisted src/agent/plugin/agentPluginOps.ts -// profile.bind profile persisted src/agent/profile/profileOps.ts -// prompt.accepted promptAdmission persisted src/agent/prompt/promptOps.ts -// runtime.set_binding runtimeBinding persisted src/agent/runtimeBinding/runtimeBindingOps.ts -// skill.activate skill transient src/agent/skill/skillOps.ts -// swarm_mode.enter swarm persisted src/features/swarm/swarmOps.ts -// swarm_mode.exit swarm persisted src/features/swarm/swarmOps.ts -// task.started task persisted src/agent/task/taskOps.ts -// task.terminated task persisted src/agent/task/taskOps.ts -// token_counting.measured tokenCounting persisted src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.rebased tokenCounting persisted src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.truncated tokenCounting persisted src/agent/tokenCounting/tokenCountingOps.ts -// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts -// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts -// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts -// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts -// tools.update_store todo persisted src/session/todo/todoOps.ts -// tower_mode.enter tower persisted src/features/tower/towerOps.ts -// tower_mode.exit tower persisted src/features/tower/towerOps.ts -// turn.cancel turn persisted src/agent/loop/turnOps.ts -// turn.ended turn persisted src/agent/loop/turnOps.ts -// turn.prompt turn persisted src/agent/loop/turnOps.ts -// turn.steer turn persisted src/agent/loop/turnOps.ts -// usage.record usage persisted src/agent/usage/usageOps.ts - -/** - * model: profile · persisted +// Every listed type is durable by construction — transient Event2 classes +// never enter EVENT2_REGISTRY, so there is no persisted flag. Declaration +// header lines: states (every state folding this record type on dispatch and +// replay; any state beyond the first is what the retired format listed as +// cross-reducers), blobs (the folding states whose blob codec offloads inline +// media to blob storage), owner (the source file declaring the class). + +// Index (48 record types) +// config.update profile src/agent/profile/profileOps.ts +// context.append_loop_event contextMemory, turn src/agent/contextMemory/contextEvents.ts +// context.append_message contextMemory, goalForkNotice, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts +// context.apply_compaction contextMemory, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts +// context.clear contextMemory, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts +// context.undo contextMemory, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts +// forked goal, goalForkNotice src/agent/goal/goalOps.ts +// full_compaction.begin fullCompaction src/agent/fullCompaction/compactionOps.ts +// full_compaction.cancel fullCompaction src/agent/fullCompaction/compactionOps.ts +// full_compaction.complete fullCompaction src/agent/fullCompaction/compactionOps.ts +// goal.clear goal, goalForkNotice src/agent/goal/goalOps.ts +// goal.create goal, goalForkNotice src/agent/goal/goalOps.ts +// goal.update goal src/agent/goal/goalOps.ts +// interaction.request interaction src/session/interaction/interactionOps.ts +// interaction.resolved interaction src/session/interaction/interactionOps.ts +// interruptionReminder.recorded interruptionReminder src/agent/interruptionReminder/interruptionReminderOps.ts +// llm.request llm.requestTrace src/agent/llmRequester/llmRequestOps.ts +// llm.tools_snapshot llm.requestTrace src/agent/llmRequester/llmRequestOps.ts +// mcp.tools_discovered mcp.discovery src/agent/mcp/mcpDiscoveryOps.ts +// permission.record_approval_result permissionRules src/agent/permissionRules/permissionRulesOps.ts +// permission.set_mode permissionMode, permissionMode.configured src/agent/permissionMode/permissionModeOps.ts +// plan_mode.cancel plan src/features/plan/planOps.ts +// plan_mode.enter plan src/features/plan/planOps.ts +// plan_mode.exit plan src/features/plan/planOps.ts +// plan.revision plan src/features/plan/planOps.ts +// plugin.session_start pluginSessionStartSnapshot src/agent/plugin/agentPluginOps.ts +// profile.bind profile, profile.activeTools src/agent/profile/profileOps.ts +// prompt.accepted promptAdmission src/agent/prompt/promptOps.ts +// runtime.set_binding runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts +// swarm_mode.enter swarm src/features/swarm/swarmOps.ts +// swarm_mode.exit contextMemory, swarm src/features/swarm/swarmOps.ts +// task.started task src/agent/task/taskOps.ts +// task.terminated task src/agent/task/taskOps.ts +// token_counting.measured tokenCounting src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.rebased tokenCounting src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.truncated tokenCounting src/agent/tokenCounting/tokenCountingOps.ts +// tools.register_user_tool userTool src/agent/userTool/userToolOps.ts +// tools.reset_active_tools profile.activeTools src/agent/profile/profileOps.ts +// tools.set_active_tools profile.activeTools src/agent/profile/profileOps.ts +// tools.unregister_user_tool userTool src/agent/userTool/userToolOps.ts +// tools.update_store todo src/session/todo/todoOps.ts +// tower_mode.enter tower src/features/tower/towerOps.ts +// tower_mode.exit tower src/features/tower/towerOps.ts +// turn.cancel turn src/agent/loop/turnOps.ts +// turn.ended turn src/agent/loop/turnOps.ts +// turn.prompt turn src/agent/loop/turnOps.ts +// turn.steer turn src/agent/loop/turnOps.ts +// usage.record usage src/agent/usage/usageOps.ts + +/** + * states: profile * owner: src/agent/profile/profileOps.ts */ interface ConfigUpdatePayload { @@ -100,8 +98,8 @@ interface ConfigUpdatePayload { } /** - * model: contextMemory · persisted · blobs · cross-reducers: turn - * owner: src/agent/contextMemory/contextOps.ts + * states: contextMemory, turn · blobs: contextMemory + * owner: src/agent/contextMemory/contextEvents.ts */ interface ContextAppendLoopEventPayload { _name: 'context.append_loop_event'; @@ -110,8 +108,8 @@ interface ContextAppendLoopEventPayload { } /** - * model: contextMemory · persisted · blobs · cross-reducers: plan, goalForkNotice, task.notificationDelivery, todo - * owner: src/agent/contextMemory/contextOps.ts + * states: contextMemory, goalForkNotice, plan, task.notificationDelivery, todo · blobs: contextMemory + * owner: src/agent/contextMemory/contextEvents.ts */ interface ContextAppendMessagePayload { _name: 'context.append_message'; @@ -145,23 +143,23 @@ interface ContextAppendMessagePayload { } /** - * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, todo - * owner: src/agent/contextMemory/contextOps.ts + * states: contextMemory, plan, task.notificationDelivery, todo · blobs: contextMemory + * owner: src/agent/contextMemory/contextEvents.ts * shared base: ...contextCompactionBaseShape */ type ContextApplyCompactionPayload = { _name: 'context.apply_compaction'; } & ({ summary: string, compactedCount: number, contextSummary?: string } | { contextSummary: string, compactedCount: number, summary?: string } | { summary: ContextMessage, count: number, compactedCount?: number }); /** - * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, todo - * owner: src/agent/contextMemory/contextOps.ts + * states: contextMemory, plan, task.notificationDelivery, todo · blobs: contextMemory + * owner: src/agent/contextMemory/contextEvents.ts */ interface ContextClearPayload { _name: 'context.clear'; } /** - * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, todo - * owner: src/agent/contextMemory/contextOps.ts + * states: contextMemory, plan, task.notificationDelivery, todo · blobs: contextMemory + * owner: src/agent/contextMemory/contextEvents.ts */ interface ContextUndoPayload { _name: 'context.undo'; @@ -169,44 +167,7 @@ interface ContextUndoPayload { } /** - * model: cron - * owner: src/session/cron/cronOps.ts - */ -interface CronAddPayload { - _name: 'cron.add'; - /** CronTask */ - task: { - id: string; - cron: string; - prompt: string; - createdAt: number; - recurring?: boolean; - lastFiredAt?: number; - tags?: Readonly>; - }; -} - -/** - * model: cron - * owner: src/session/cron/cronOps.ts - */ -interface CronCursorPayload { - _name: 'cron.cursor'; - id: string; - lastFiredAt: number; -} - -/** - * model: cron - * owner: src/session/cron/cronOps.ts - */ -interface CronDeletePayload { - _name: 'cron.delete'; - ids: string[]; -} - -/** - * model: goal · persisted · cross-reducers: goalForkNotice + * states: goal, goalForkNotice * owner: src/agent/goal/goalOps.ts */ interface ForkedPayload { @@ -214,7 +175,7 @@ interface ForkedPayload { } /** - * model: fullCompaction · persisted · toEvent + * states: fullCompaction * owner: src/agent/fullCompaction/compactionOps.ts * payload type: CompactionBeginData */ @@ -225,7 +186,7 @@ interface FullCompactionBeginPayload { } /** - * model: fullCompaction · persisted + * states: fullCompaction * owner: src/agent/fullCompaction/compactionOps.ts */ interface FullCompactionCancelPayload { @@ -233,7 +194,7 @@ interface FullCompactionCancelPayload { } /** - * model: fullCompaction · persisted + * states: fullCompaction * owner: src/agent/fullCompaction/compactionOps.ts */ interface FullCompactionCompletePayload { @@ -241,7 +202,7 @@ interface FullCompactionCompletePayload { } /** - * model: goal · persisted · cross-reducers: goalForkNotice + * states: goal, goalForkNotice * owner: src/agent/goal/goalOps.ts */ interface GoalClearPayload { @@ -249,7 +210,7 @@ interface GoalClearPayload { } /** - * model: goal · persisted · cross-reducers: goalForkNotice + * states: goal, goalForkNotice * owner: src/agent/goal/goalOps.ts */ interface GoalCreatePayload { @@ -268,7 +229,7 @@ interface GoalCreatePayload { } /** - * model: goal · persisted + * states: goal * owner: src/agent/goal/goalOps.ts */ interface GoalUpdatePayload { @@ -289,7 +250,7 @@ interface GoalUpdatePayload { } /** - * model: interaction · persisted + * states: interaction * owner: src/session/interaction/interactionOps.ts */ interface InteractionRequestPayload { @@ -302,7 +263,7 @@ interface InteractionRequestPayload { } /** - * model: interaction · persisted + * states: interaction * owner: src/session/interaction/interactionOps.ts */ interface InteractionResolvedPayload { @@ -312,7 +273,7 @@ interface InteractionResolvedPayload { } /** - * model: interruptionReminder · persisted + * states: interruptionReminder * owner: src/agent/interruptionReminder/interruptionReminderOps.ts */ interface InterruptionReminderRecordedPayload { @@ -321,7 +282,7 @@ interface InterruptionReminderRecordedPayload { } /** - * model: llm.requestTrace · persisted + * states: llm.requestTrace * owner: src/agent/llmRequester/llmRequestOps.ts */ interface LlmRequestPayload { @@ -349,7 +310,7 @@ interface LlmRequestPayload { } /** - * model: llm.requestTrace · persisted + * states: llm.requestTrace * owner: src/agent/llmRequester/llmRequestOps.ts */ interface LlmToolsSnapshotPayload { @@ -363,7 +324,7 @@ interface LlmToolsSnapshotPayload { } /** - * model: mcp.discovery · persisted + * states: mcp.discovery * owner: src/agent/mcp/mcpDiscoveryOps.ts */ interface McpToolsDiscoveredPayload { @@ -380,7 +341,7 @@ interface McpToolsDiscoveredPayload { } /** - * model: permissionRules · persisted + * states: permissionRules * owner: src/agent/permissionRules/permissionRulesOps.ts * payload type: PermissionApprovalResultRecord */ @@ -395,16 +356,7 @@ interface PermissionRecordApprovalResultPayload { } /** - * model: permissionRules - * owner: src/agent/permissionRules/permissionRulesOps.ts - */ -interface PermissionRulesAddPayload { - _name: 'permission.rules.add'; - rules: readonly PermissionRule[]; -} - -/** - * model: permissionMode · persisted · cross-reducers: permissionMode.configured + * states: permissionMode, permissionMode.configured * owner: src/agent/permissionMode/permissionModeOps.ts */ interface PermissionSetModePayload { @@ -414,7 +366,7 @@ interface PermissionSetModePayload { } /** - * model: plan · persisted · toEvent + * states: plan * owner: src/features/plan/planOps.ts */ interface PlanModeCancelPayload { @@ -423,7 +375,7 @@ interface PlanModeCancelPayload { } /** - * model: plan · persisted · toEvent + * states: plan * owner: src/features/plan/planOps.ts */ interface PlanModeEnterPayload { @@ -432,7 +384,7 @@ interface PlanModeEnterPayload { } /** - * model: plan · persisted · toEvent + * states: plan * owner: src/features/plan/planOps.ts */ interface PlanModeExitPayload { @@ -441,7 +393,7 @@ interface PlanModeExitPayload { } /** - * model: plan · persisted · toEvent + * states: plan * owner: src/features/plan/planOps.ts */ interface PlanRevisionPayload { @@ -454,7 +406,7 @@ interface PlanRevisionPayload { } /** - * model: pluginSessionStartSnapshot · persisted + * states: pluginSessionStartSnapshot * owner: src/agent/plugin/agentPluginOps.ts */ interface PluginSessionStartPayload { @@ -463,7 +415,7 @@ interface PluginSessionStartPayload { } /** - * model: profile · persisted · cross-reducers: profile.activeTools + * states: profile, profile.activeTools * owner: src/agent/profile/profileOps.ts */ interface ProfileBindPayload { @@ -486,7 +438,7 @@ interface ProfileBindPayload { } /** - * model: promptAdmission · persisted + * states: promptAdmission * owner: src/agent/prompt/promptOps.ts */ interface PromptAcceptedPayload { @@ -495,7 +447,7 @@ interface PromptAcceptedPayload { } /** - * model: runtimeBinding · persisted + * states: runtimeBinding * owner: src/agent/runtimeBinding/runtimeBindingOps.ts */ interface RuntimeSetBindingPayload { @@ -505,26 +457,7 @@ interface RuntimeSetBindingPayload { } /** - * model: skill · toEvent - * owner: src/agent/skill/skillOps.ts - */ -interface SkillActivatePayload { - _name: 'skill.activate'; - /** SkillActivationOrigin */ - origin: { - kind: 'skill_activation'; - activationId: string; - skillName: string; - skillArgs?: string | undefined; - trigger: 'user-slash' | 'model-tool' | 'nested-skill'; - skillType?: string | undefined; - skillPath?: string | undefined; - skillSource?: 'project' | 'user' | 'extra' | 'builtin' | undefined; - }; -} - -/** - * model: swarm · persisted · toEvent + * states: swarm * owner: src/features/swarm/swarmOps.ts */ interface SwarmModeEnterPayload { @@ -534,7 +467,7 @@ interface SwarmModeEnterPayload { } /** - * model: swarm · persisted · toEvent · cross-reducers: contextMemory + * states: contextMemory, swarm · blobs: contextMemory * owner: src/features/swarm/swarmOps.ts */ interface SwarmModeExitPayload { @@ -542,7 +475,7 @@ interface SwarmModeExitPayload { } /** - * model: task · persisted · toEvent + * states: task * owner: src/agent/task/taskOps.ts */ interface TaskStartedPayload { @@ -552,7 +485,7 @@ interface TaskStartedPayload { } /** - * model: task · persisted · toEvent + * states: task * owner: src/agent/task/taskOps.ts */ interface TaskTerminatedPayload { @@ -563,7 +496,7 @@ interface TaskTerminatedPayload { } /** - * model: tokenCounting · persisted · toEvent + * states: tokenCounting * owner: src/agent/tokenCounting/tokenCountingOps.ts */ interface TokenCountingMeasuredPayload { @@ -573,7 +506,7 @@ interface TokenCountingMeasuredPayload { } /** - * model: tokenCounting · persisted · toEvent + * states: tokenCounting * owner: src/agent/tokenCounting/tokenCountingOps.ts */ interface TokenCountingRebasedPayload { @@ -584,7 +517,7 @@ interface TokenCountingRebasedPayload { } /** - * model: tokenCounting · persisted · toEvent + * states: tokenCounting * owner: src/agent/tokenCounting/tokenCountingOps.ts */ interface TokenCountingTruncatedPayload { @@ -594,7 +527,7 @@ interface TokenCountingTruncatedPayload { } /** - * model: userTool · persisted + * states: userTool * owner: src/agent/userTool/userToolOps.ts * payload type: UserToolRegistration */ @@ -607,7 +540,7 @@ interface ToolsRegisterUserToolPayload { } /** - * model: profile.activeTools · persisted + * states: profile.activeTools * owner: src/agent/profile/profileOps.ts */ interface ToolsResetActiveToolsPayload { @@ -615,7 +548,7 @@ interface ToolsResetActiveToolsPayload { } /** - * model: profile.activeTools · persisted + * states: profile.activeTools * owner: src/agent/profile/profileOps.ts */ interface ToolsSetActiveToolsPayload { @@ -624,7 +557,7 @@ interface ToolsSetActiveToolsPayload { } /** - * model: userTool · persisted + * states: userTool * owner: src/agent/userTool/userToolOps.ts */ interface ToolsUnregisterUserToolPayload { @@ -633,7 +566,7 @@ interface ToolsUnregisterUserToolPayload { } /** - * model: todo · persisted + * states: todo * owner: src/session/todo/todoOps.ts */ interface ToolsUpdateStorePayload { @@ -643,7 +576,7 @@ interface ToolsUpdateStorePayload { } /** - * model: tower · persisted · toEvent + * states: tower * owner: src/features/tower/towerOps.ts */ interface TowerModeEnterPayload { @@ -651,7 +584,7 @@ interface TowerModeEnterPayload { } /** - * model: tower · persisted · toEvent + * states: tower * owner: src/features/tower/towerOps.ts */ interface TowerModeExitPayload { @@ -659,7 +592,7 @@ interface TowerModeExitPayload { } /** - * model: turn · persisted + * states: turn * owner: src/agent/loop/turnOps.ts */ interface TurnCancelPayload { @@ -670,7 +603,7 @@ interface TurnCancelPayload { } /** - * model: turn · persisted + * states: turn * owner: src/agent/loop/turnOps.ts */ interface TurnEndedPayload { @@ -725,7 +658,7 @@ interface TurnEndedPayload { } /** - * model: turn · persisted + * states: turn * owner: src/agent/loop/turnOps.ts */ interface TurnPromptPayload { @@ -736,7 +669,7 @@ interface TurnPromptPayload { } /** - * model: turn · persisted + * states: turn * owner: src/agent/loop/turnOps.ts */ interface TurnSteerPayload { @@ -747,7 +680,7 @@ interface TurnSteerPayload { } /** - * model: usage · persisted + * states: usage * owner: src/agent/usage/usageOps.ts */ interface UsageRecordPayload { @@ -772,9 +705,6 @@ interface WirePayloadMap { "context.apply_compaction": ContextApplyCompactionPayload; "context.clear": ContextClearPayload; "context.undo": ContextUndoPayload; - "cron.add": CronAddPayload; - "cron.cursor": CronCursorPayload; - "cron.delete": CronDeletePayload; "forked": ForkedPayload; "full_compaction.begin": FullCompactionBeginPayload; "full_compaction.cancel": FullCompactionCancelPayload; @@ -789,7 +719,6 @@ interface WirePayloadMap { "llm.tools_snapshot": LlmToolsSnapshotPayload; "mcp.tools_discovered": McpToolsDiscoveredPayload; "permission.record_approval_result": PermissionRecordApprovalResultPayload; - "permission.rules.add": PermissionRulesAddPayload; "permission.set_mode": PermissionSetModePayload; "plan_mode.cancel": PlanModeCancelPayload; "plan_mode.enter": PlanModeEnterPayload; @@ -799,7 +728,6 @@ interface WirePayloadMap { "profile.bind": ProfileBindPayload; "prompt.accepted": PromptAcceptedPayload; "runtime.set_binding": RuntimeSetBindingPayload; - "skill.activate": SkillActivatePayload; "swarm_mode.enter": SwarmModeEnterPayload; "swarm_mode.exit": SwarmModeExitPayload; "task.started": TaskStartedPayload; diff --git a/packages/agent-core-v2/package.json b/packages/agent-core-v2/package.json index 1fba43dfcd2..33cfb25f0ba 100644 --- a/packages/agent-core-v2/package.json +++ b/packages/agent-core-v2/package.json @@ -69,6 +69,7 @@ "ajv-formats": "^3.0.1", "chokidar": "^4.0.3", "ignore": "^5.3.2", + "immer": "^11.1.0", "jimp": "^1.6.1", "js-yaml": "^4.1.1", "linkedom": "^0.18.12", diff --git a/packages/agent-core-v2/scripts/gen-state-manifest.mts b/packages/agent-core-v2/scripts/gen-state-manifest.mts index b4e8d7acc33..4471cb4ee77 100644 --- a/packages/agent-core-v2/scripts/gen-state-manifest.mts +++ b/packages/agent-core-v2/scripts/gen-state-manifest.mts @@ -39,6 +39,7 @@ import { join, relative } from 'node:path'; import { pathToFileURL } from 'node:url'; import { + type CallExpression, Node, Project, SyntaxKind, @@ -95,6 +96,12 @@ interface KeyDef { readonly file: string; readonly exported: boolean; readonly declaration: VariableDeclaration; + /** Present when the key chains `.replayable(...)` — the key is materialized into the Agent-scope state service. */ + readonly replayable?: { + readonly durable: boolean; + readonly undoable: boolean; + readonly folds: readonly string[]; + }; } interface Registration { @@ -125,13 +132,21 @@ const FEATURES_RECEIVER_SCOPE: Readonly> = { IAgentStateService: 'agent', }; +/** Resolve the scope from the contributeState-call receiver's state-service type. */ +function receiverScope( + expression: PropertyAccessExpression, + checker: TypeChecker, +): ScopeDir | undefined { + const typeName = checker.getTypeAtLocation(expression.getExpression()).getSymbol()?.getName(); + return typeName === undefined ? undefined : FEATURES_RECEIVER_SCOPE[typeName]; +} + function featuresRegisterScope( expression: PropertyAccessExpression, checker: TypeChecker, sf: SourceFile, ): ScopeDir { - const typeName = checker.getTypeAtLocation(expression.getExpression()).getSymbol()?.getName(); - const scope = typeName === undefined ? undefined : FEATURES_RECEIVER_SCOPE[typeName]; + const scope = receiverScope(expression, checker); if (scope === undefined) { throw new Error( `[gen-state-manifest] cannot resolve the state-service scope of '${expression.getText()}' ` + @@ -181,15 +196,15 @@ function collectKeyDefs(project: Project): Map { for (const declaration of statement.getDeclarations()) { const initializer = declaration.getInitializer(); if (initializer === undefined || !Node.isCallExpression(initializer)) continue; - if (initializer.getExpression().getText() !== 'defineState') continue; - const [nameArg] = initializer.getArguments(); - if (nameArg === undefined || !Node.isStringLiteral(nameArg)) continue; + const parsed = parseDefineStateChain(initializer); + if (parsed === undefined) continue; defs.set(declaration, { constName: declaration.getName(), - keyName: nameArg.getLiteralValue(), + keyName: parsed.keyName, file: sf.getFilePath(), exported: statement.isExported(), declaration, + replayable: parsed.replayable, }); } } @@ -197,6 +212,54 @@ function collectKeyDefs(project: Project): Map { return defs; } +/** + * Walk a `defineState('name', ...)` / `.replayable({...})` / `.undoable(...)` / + * `.on(Event, fold)` call chain down to the `defineState` call; returns the key + * name plus the replayable metadata when the chain promotes the key. + */ +function parseDefineStateChain( + initializer: CallExpression, +): { keyName: string; replayable?: KeyDef['replayable'] } | undefined { + let durable = true; + let undoable = false; + let replayable = false; + const folds: string[] = []; + let current: CallExpression = initializer; + for (;;) { + const expression = current.getExpression(); + if (Node.isIdentifier(expression) && expression.getText() === 'defineState') { + const [nameArg] = current.getArguments(); + if (nameArg === undefined || !Node.isStringLiteral(nameArg)) return undefined; + return { + keyName: nameArg.getLiteralValue(), + replayable: replayable ? { durable, undoable, folds } : undefined, + }; + } + if (!Node.isPropertyAccessExpression(expression)) return undefined; + const method = expression.getName(); + if (method === 'replayable') { + replayable = true; + const [arg] = current.getArguments(); + if (arg !== undefined && Node.isObjectLiteralExpression(arg)) { + const durableProp = arg.getProperty('durable'); + if (durableProp !== undefined && Node.isPropertyAssignment(durableProp)) { + durable = durableProp.getInitializer()?.getText() !== 'false'; + } + } + } else if (method === 'undoable') { + undoable = true; + } else if (method === 'on') { + const [eventArg] = current.getArguments(); + if (eventArg !== undefined) folds.unshift(eventArg.getText()); + } else { + return undefined; + } + const inner = expression.getExpression(); + if (!Node.isCallExpression(inner)) return undefined; + current = inner; + } +} + /** Resolve a `.register(...)` argument back to its `defineState` constant. */ function resolveKeyDef( identifier: Identifier, @@ -219,14 +282,17 @@ function collectRegistrations( ): Registration[] { const checker = project.getTypeChecker(); const registrations: Registration[] = []; - const seen = new Set(); + const seen = new Map(); for (const sf of project.getSourceFiles()) { const fileScope = scopeDirOf(sf.getFilePath()); const featuresFile = isFeaturesFile(sf.getFilePath()); if (fileScope === undefined && !featuresFile) continue; for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) { const expression = call.getExpression(); - if (!Node.isPropertyAccessExpression(expression) || expression.getName() !== 'register') { + if ( + !Node.isPropertyAccessExpression(expression) || + expression.getName() !== 'contributeState' + ) { continue; } const args = call.getArguments(); @@ -234,7 +300,7 @@ function collectRegistrations( if (args.length !== 1 || arg === undefined || !Node.isIdentifier(arg)) continue; const def = resolveKeyDef(arg, defs); if (def === undefined) continue; - const scope = fileScope ?? featuresRegisterScope(expression, checker, sf); + const scope = receiverScope(expression, checker) ?? fileScope ?? featuresRegisterScope(expression, checker, sf); if (!def.exported) { throw new Error( `[gen-state-manifest] state key '${def.keyName}' (${srcRelative(def.file)}) is ` + @@ -242,12 +308,14 @@ function collectRegistrations( ); } const dedupe = `${scope}:${def.keyName}`; - if (seen.has(dedupe)) { + const seenFile = seen.get(dedupe); + if (seenFile !== undefined) { + if (seenFile === sf.getFilePath()) continue; throw new Error( `[gen-state-manifest] state key '${def.keyName}' is registered twice in ${scope} scope.`, ); } - seen.add(dedupe); + seen.set(dedupe, sf.getFilePath()); registrations.push({ def, scope }); } } @@ -786,6 +854,16 @@ function renderManifest( for (const file of [...byFile.keys()].toSorted()) { lines.push(` // ${srcRelative(file)}`); for (const r of byFile.get(file) ?? []) { + if (r.def.replayable !== undefined) { + const meta = r.def.replayable; + const flags = [ + meta.durable ? 'durable' : 'transient', + ...(meta.undoable ? ['undoable'] : []), + ]; + lines.push( + ` // replayable · ${flags.join(' · ')} — folds: ${meta.folds.length > 0 ? meta.folds.join(', ') : '(protocol only)'}`, + ); + } const rendered = renderer.renderKeyType(r.def).split('\n'); rendered[rendered.length - 1] += ';'; lines.push(` '${r.def.keyName}': ${rendered[0]}`, ...rendered.slice(1).map((l) => ` ${l}`)); @@ -810,8 +888,12 @@ function renderManifest( '// Workspace-scope IWorkspaceStateService, the Session-scope', '// ISessionStateService, or the Agent-scope IAgentStateService (see', '// src/_base/state/stateRegistry.ts), collected statically from the', - '// `states.register(...)` call sites — a key defined via', - '// defineState but never registered does not appear here. Each entry shows the', + '// `states.contributeState(...)` call sites and the replayable key chains — a', + '// `defineState(...).replayable(...)` key is contributed into the Agent-scope', + '// service by its owner service at construction, and', + '// carries a `// replayable · durable|transient · undoable? — folds: ...` line.', + '// Replayable values are excluded from snapshot()/inspect(). A key defined via', + '// defineState but never registered nor replayable does not appear here. Each entry shows the', '// compile-time StateKey value type fully expanded inline, so the manifest is', '// self-contained (no imports, no helper declarations). A named type is marked', '// at its expansion site with a `/* TypeName — source/file.ts */` comment; a', @@ -860,6 +942,23 @@ function buildAll(): BuildResult { const defs = collectKeyDefs(project); const registrations = collectRegistrations(project, defs); const registered = new Set(registrations.map((r) => r.def)); + for (const def of defs.values()) { + if (def.replayable === undefined) continue; + if (!registered.has(def)) { + throw new Error( + `[gen-state-manifest] replayable state key '${def.keyName}' (${srcRelative(def.file)}) is ` + + 'never contributed — its owner service must contributeState it into the Agent-scope state service.', + ); + } + for (const registration of registrations) { + if (registration.def === def && registration.scope !== 'agent') { + throw new Error( + `[gen-state-manifest] replayable state key '${def.keyName}' (${srcRelative(def.file)}) is ` + + `contributed into the ${registration.scope} scope — replayable keys belong to the Agent scope.`, + ); + } + } + } const unregistered = [...defs.values()].filter((def) => !registered.has(def)); const model: StateManifestModel = { registrations, unregistered }; const { manifest, warnings } = renderManifest(model, project); diff --git a/packages/agent-core-v2/scripts/gen-wire-manifest.mts b/packages/agent-core-v2/scripts/gen-wire-manifest.mts index a747a064c67..6841df8bbd9 100644 --- a/packages/agent-core-v2/scripts/gen-wire-manifest.mts +++ b/packages/agent-core-v2/scripts/gen-wire-manifest.mts @@ -1,15 +1,16 @@ /** - * Generates `docs/wire-manifest.d.ts` — the single place to see every wire - * record type registered via `defineOp(...)`. + * Generates `docs/wire-manifest.d.ts` — the single place to see every durable + * wire record type declared as an `Event2` subclass (`static type` + + * `static durable = true` + `static schema`). * * Two passes: - * 1. Static scan of `src/**` maps each op type to the source file that - * defines it — the "owner" — and collects the migration chain from + * 1. Static scan of `src/**` maps each durable event type to the source file + * that declares it — the "owner" — and collects the migration chain from * `src/wire/migration/v*.ts`. - * 2. Runtime pass imports `src/index.ts` plus every op module found in the - * static pass ("import = register") and drains `OP_REGISTRY`, capturing - * the owning model, the persist policy, `toEvent`, and the payload schema - * exactly as the running process sees them. + * 2. Runtime pass imports `src/index.ts` plus every event/state module found + * in the static pass ("import = register") and drains `EVENT2_REGISTRY` + * (type → class → schema) and `REPLAYABLE_STATE_KEYS` (folding states, blob + * codecs) exactly as the running process sees them. * * The output is a `.d.ts` — one payload declaration per record type, with a * `WirePayloadMap` from record type to declaration — using real TypeScript @@ -26,8 +27,7 @@ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from ' import { dirname, join, relative } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { MODEL_CROSS_REDUCERS } from '#/wire/model'; -import { OP_REGISTRY } from '#/wire/op'; +import { EVENT2_REGISTRY } from '#/app/event/event2'; import { asJsonSchema, @@ -44,7 +44,7 @@ const SRC = join(PKG, 'src'); export const MANIFEST_PATH = join(PKG, 'docs', 'wire-manifest.d.ts'); // --------------------------------------------------------------------------- -// Static pass — op type → owner file; migration chain +// Static pass — durable event type → owner file; migration chain // --------------------------------------------------------------------------- function walk(dir: string, out: string[] = []): string[] { @@ -56,22 +56,52 @@ function walk(dir: string, out: string[] = []): string[] { return out; } -/** op type → owner file (relative to the package root). */ -function scanOpOwners(): { owners: Map; opFiles: string[] } { +const TYPE_DECL_RE = /static\s+override\s+readonly\s+type\s*=\s*'([^']+)'/g; +const DURABLE_DECL_RE = /static\s+override\s+readonly\s+durable\s*=\s*true/; +const CLASS_DECL_RE = /class\s+(\w+)\s+extends\s+Event2/g; + +/** + * event type → owner file (relative to the package root), the files worth + * importing for the runtime pass, the types statically declared durable + * (the class window between one `type` declaration and the next carries + * `durable = true`), and the event class name → type map (the class window + * between one `class … extends Event2` declaration and the next carries one + * `static type`). + */ +function scanEventDeclarations(): { + owners: Map; + importFiles: string[]; + durableTypes: Set; + classTypes: Map; +} { const owners = new Map(); - const opFiles: string[] = []; + const importFiles: string[] = []; + const durableTypes = new Set(); + const classTypes = new Map(); for (const file of walk(SRC)) { const source = readFileSync(file, 'utf-8'); - if (!source.includes('defineOp(')) continue; - const matches = [...source.matchAll(/defineOp\(\s*'([^']+)'/g)]; - if (matches.length === 0) continue; - opFiles.push(file); - for (const match of matches) { + const matches = [...source.matchAll(TYPE_DECL_RE)]; + const hasStates = source.includes('.replayable('); + if (matches.length > 0 || hasStates) importFiles.push(file); + for (const [i, match] of matches.entries()) { const type = match[1]; - if (type !== undefined) owners.set(type, relative(PKG, file)); + if (type === undefined) continue; + owners.set(type, relative(PKG, file)); + const windowEnd = i + 1 < matches.length ? matches[i + 1]!.index : source.length; + if (DURABLE_DECL_RE.test(source.slice(match.index, windowEnd))) durableTypes.add(type); + } + const classMatches = [...source.matchAll(CLASS_DECL_RE)]; + for (const [i, match] of classMatches.entries()) { + const className = match[1]; + if (className === undefined) continue; + const windowEnd = i + 1 < classMatches.length ? classMatches[i + 1]!.index : source.length; + const typeMatch = /static\s+override\s+readonly\s+type\s*=\s*'([^']+)'/.exec( + source.slice(match.index, windowEnd), + ); + if (typeMatch?.[1] !== undefined) classTypes.set(className, typeMatch[1]); } } - return { owners, opFiles }; + return { owners, importFiles, durableTypes, classTypes }; } /** `1.0 -> 1.1 -> ...` chain read from the `src/wire/migration/v*.ts` files. */ @@ -92,6 +122,136 @@ function scanMigrationChain(): string { return chain.join(' -> '); } +// --------------------------------------------------------------------------- +// Static pass — replayable state keys and their fold targets +// --------------------------------------------------------------------------- + +interface ReplayableStateScan { + readonly keyName: string; + readonly constName: string; + readonly undoable: boolean; + readonly blobs: boolean; + readonly foldClasses: string[]; +} + +const ON_FOLD_RE = /\.on\(\s*([A-Za-z_$][\w$]*)/g; +const KEY_ON_RE = /\b([A-Za-z_$][\w$]*)\.on\(\s*([A-Za-z_$][\w$]*)/g; +const PROTOCOL_EVENT_RE = /(?:appendMessage|applyCompaction|clear|undo):\s*([A-Za-z_$][\w$]*)/g; + +/** Balanced-paren read of the argument list starting at the `(` after a chain method. */ +function readCallArguments(text: string, parenIndex: number): string { + let depth = 0; + for (let i = parenIndex; i < text.length; i++) { + const ch = text[i]; + if (ch === "'" || ch === '"' || ch === '`') { + const quote = ch; + i += 1; + while (i < text.length && text[i] !== quote) { + if (text[i] === '\\') i += 1; + i += 1; + } + continue; + } + if (ch === '(') depth += 1; + else if (ch === ')') { + depth -= 1; + if (depth === 0) return text.slice(parenIndex + 1, i); + } + } + return text.slice(parenIndex + 1); +} + +/** + * `readExpression` variant for `defineState(...)` chains: the fold bodies are + * arbitrary code, where `<` / `>` are comparison operators as often as generic + * brackets — only `()` `{}` `[]` delimit the statement reliably. + */ +function readChain(source: string, start: number): string { + let depth = 0; + const n = source.length; + for (let i = start; i < n; i++) { + const ch = source[i]; + if (ch === "'" || ch === '"' || ch === '`') { + const quote = ch; + i += 1; + while (i < n && source[i] !== quote) { + if (source[i] === '\\') i += 1; + i += 1; + } + continue; + } + if (ch === '{' || ch === '(' || ch === '[') depth += 1; + else if (ch === '}' || ch === ')' || ch === ']') depth = Math.max(0, depth - 1); + else if (ch === ';' && depth === 0) return source.slice(start, i); + } + return source.slice(start); +} + +function scanReplayableStates(): ReplayableStateScan[] { + const states: ReplayableStateScan[] = []; + const byConst = new Map(); + const constChainRe = + /(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*defineState\(\s*'([^']+)'/g; + for (const file of walk(SRC)) { + const source = readFileSync(file, 'utf-8'); + if (!source.includes('.replayable(') && !source.includes('.on(')) continue; + for (const match of source.matchAll(constChainRe)) { + const constName = match[1]; + const keyName = match[2]; + if (constName === undefined || keyName === undefined) continue; + const chain = readChain(source, source.indexOf('defineState', match.index)); + const replayableIndex = chain.indexOf('.replayable('); + if (replayableIndex === -1) continue; + const replayableArgs = readCallArguments(chain, replayableIndex + '.replayable'.length); + const scan: ReplayableStateScan = { + keyName, + constName, + undoable: chain.includes('.undoable('), + blobs: /\bblobs\s*:/.test(replayableArgs), + foldClasses: [...chain.matchAll(ON_FOLD_RE)].map((m) => m[1]!), + }; + states.push(scan); + byConst.set(constName, scan); + } + } + // A key's fold vocabulary may grow outside its defining chain + // (`otherKey.on(Event, …)` in a feature module). + for (const file of walk(SRC)) { + const source = readFileSync(file, 'utf-8'); + if (!source.includes('.on(')) continue; + for (const match of source.matchAll(KEY_ON_RE)) { + const scan = byConst.get(match[1]!); + const cls = match[2]; + if (scan === undefined || cls === undefined) continue; + if (!scan.foldClasses.includes(cls)) scan.foldClasses.push(cls); + } + } + return states; +} + +/** The four undoable-protocol event types, resolved through the class-name map. */ +function scanUndoableProtocolTypes(classTypes: ReadonlyMap): string[] { + for (const file of walk(SRC)) { + const source = readFileSync(file, 'utf-8'); + const index = source.indexOf('registerUndoableProtocol('); + if (index === -1) continue; + const window = readChain(source, index); + const types: string[] = []; + for (const match of window.matchAll(PROTOCOL_EVENT_RE)) { + const cls = match[1]!; + const type = classTypes.get(cls); + if (type === undefined) { + throw new Error( + `[gen-wire-manifest] undoable protocol event class '${cls}' has no resolved type`, + ); + } + types.push(type); + } + return types; + } + throw new Error('[gen-wire-manifest] registerUndoableProtocol call not found under src/'); +} + // --------------------------------------------------------------------------- // Payload sketch // @@ -260,8 +420,9 @@ function emitTsDict(lines: string[], dict: SketchDict, indent: string): void { /** One record type's payload declaration (`interface` for objects, `type` otherwise). */ function renderPayloadDecl( - entry: { type: string; model: { name: string }; persist?: boolean; toEvent?: unknown }, + entry: { type: string }, owner: string | undefined, + states: string[], flags: string[], sketch: Sketch, ): string[] { @@ -269,7 +430,7 @@ function renderPayloadDecl( const nameField = `_name: '${entry.type}';`; const header = [ '/**', - ` * model: ${entry.model.name}${flags.length > 0 ? ` · ${flags.join(' · ')}` : ''}`, + ` * states: ${states.length > 0 ? states.join(', ') : '(none)'}${flags.length > 0 ? ` · ${flags.join(' · ')}` : ''}`, ` * owner: ${owner ?? '(unresolved)'}`, ]; if (typeof sketch === 'string') { @@ -424,6 +585,10 @@ function tsQuote(raw: string): string { return raw.includes("'") ? JSON.stringify(raw) : `'${raw}'`; } +function escapeRegExp(raw: string): string { + return raw.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** Resolve a `schema:` expression to an object-literal body, following local consts. */ function resolveSchemaLiteral(expr: string, source: string, depth = 0): string | undefined { if (depth > 2) return undefined; @@ -790,7 +955,7 @@ function friendlyZodUnion(body: string, ownerFile: string, depth: number): strin const source = readCached(ownerFile); const bodies = members.map((m) => resolveSchemaLiteral(m, source)); if (members.length > 0 && bodies.every((b) => b !== undefined)) { - const fieldMaps = bodies.map((b) => splitObjectFields(b!)); + const fieldMaps = bodies.map((b) => splitObjectFields(b)); // Hoist spreads shared by every member (`...base & { … } | { … }`). const spreadSets = fieldMaps.map((fm) => [...fm.keys()].filter((k) => fm.get(k) === '')); const commonSpreads = (spreadSets[0] ?? []).filter((s) => @@ -826,18 +991,21 @@ function sketchPayloadFromSource( ): string | Map | undefined { const absFile = join(PKG, ownerFile); const source = readCached(absFile); - const callRe = new RegExp(`defineOp\\(\\s*'${type.replaceAll('.', '\\.')}'\\s*,\\s*\\{`); - const call = callRe.exec(source); - if (call === null) return undefined; - const optionsBody = objectBody(source, call.index + call[0].length - 1); - if (optionsBody === undefined) return undefined; - const schemaField = /(?:^|[,\n])\s*schema\s*:/.exec(optionsBody); - if (schemaField === null) return undefined; - const afterSchema = optionsBody.slice(schemaField.index + schemaField[0].length).trimStart(); - // The schema expression ends at the next top-level comma. - const exprFields = splitObjectFields(`schema: ${afterSchema}`); - const schemaExpr = exprFields.get('schema'); - if (schemaExpr === undefined) return undefined; + const typeRe = new RegExp( + `static\\s+override\\s+readonly\\s+type\\s*=\\s*'${escapeRegExp(type)}'`, + ); + const typeMatch = typeRe.exec(source); + if (typeMatch === null) return undefined; + const rest = source.slice(typeMatch.index + typeMatch[0].length); + const nextType = /static\s+override\s+readonly\s+type\s*=/.exec(rest); + const classWindow = nextType === null ? rest : rest.slice(0, nextType.index); + const schemaMatch = /static\s+override\s+readonly\s+schema\s*=\s*/.exec(classWindow); + if (schemaMatch === null) return undefined; + const schemaExpr = readExpression( + classWindow, + schemaMatch.index + schemaMatch[0].length, + ).trim(); + if (schemaExpr === '') return undefined; const literal = resolveSchemaLiteral(schemaExpr, source); if (literal === undefined) { const sketch = friendlyZodExpr(schemaExpr, absFile); @@ -862,20 +1030,60 @@ function sketchPayloadFromSource( // --------------------------------------------------------------------------- export async function buildWireManifest(): Promise { - const { owners, opFiles } = scanOpOwners(); - // "import = register": loading the package root plus every op module found in - // the static pass fills OP_REGISTRY, even for modules index.ts does not load. + const { owners, importFiles, durableTypes, classTypes } = scanEventDeclarations(); + // "import = register": loading the package root plus every event/state module + // found in the static pass fills EVENT2_REGISTRY, even for + // modules index.ts does not load. await import('../src/index.ts'); - for (const file of opFiles) { + for (const file of importFiles) { await import(relative(join(PKG, 'scripts'), file)); } const { WIRE_PROTOCOL_VERSION } = (await import('#/wire/migration/migration')) as { WIRE_PROTOCOL_VERSION: string; }; - const entries = [...OP_REGISTRY.values()].toSorted((a, b) => a.type.localeCompare(b.type)); + const entries = [...EVENT2_REGISTRY.values()].toSorted((a, b) => a.type.localeCompare(b.type)); const migrationChain = scanMigrationChain(); + // type → folding states / blob codec owners, scanned from the defineState chains. + const folding = new Map(); + const protocolTypes = scanUndoableProtocolTypes(classTypes); + for (const state of scanReplayableStates()) { + const eventTypes = new Set(); + for (const cls of state.foldClasses) { + const type = classTypes.get(cls); + if (type === undefined) { + throw new Error( + `[gen-wire-manifest] state '${state.keyName}' folds unresolved event class '${cls}'`, + ); + } + eventTypes.add(type); + } + if (state.undoable) { + for (const type of protocolTypes) eventTypes.add(type); + } + for (const type of eventTypes) { + let info = folding.get(type); + if (info === undefined) { + info = { states: [], blobs: [] }; + folding.set(type, info); + } + info.states.push(state.keyName); + if (state.blobs) info.blobs.push(state.keyName); + } + } + for (const info of folding.values()) { + info.states.sort(); + info.blobs.sort(); + } + + const unregistered = [...durableTypes].filter((type) => !EVENT2_REGISTRY.has(type)); + if (unregistered.length > 0) { + console.error( + `[gen-wire-manifest] declared durable but never registered (no fold, not in EVENT2_REGISTRY): ${unregistered.toSorted().join(', ')}`, + ); + } + const out: string[] = [ '// Wire Protocol Manifest', '//', @@ -884,48 +1092,49 @@ export async function buildWireManifest(): Promise { '//', `// protocol_version: "${WIRE_PROTOCOL_VERSION}" (migrations: ${migrationChain})`, '//', - '// One declaration per record type registered via defineOp(...) and drained from', - '// the runtime OP_REGISTRY. Every payload declaration carries its record type in', - '// a `_name` field. Payload sketches use TypeScript type syntax; when a', - '// named type is expanded inline, its name appears as a doc comment', - '// (`/** ContextMessage */`). Bare type names (ContentPart, ContextMessage, …)', - '// refer to the real types in src/ — they are intentionally not resolved here.', - '// `// …` marks a capped field list. On disk (wire.jsonl) the journal opens with', - '// a metadata line {"type": "metadata", "protocol_version", "created_at"}; each', - '// op record is {"type", ...payload, "time"} — object payloads spread at the', - '// top level, scalar payloads nest under a "payload" key.', + '// One declaration per durable record type — an Event2 subclass declaring', + '// `static type` + `static durable = true` + `static schema` — drained from the', + '// runtime EVENT2_REGISTRY ("import = register"). Every payload declaration', + '// carries its record type in a `_name` field. Payload sketches use TypeScript', + '// type syntax; when a named type is expanded inline, its name appears as a doc', + '// comment (`/** ContextMessage */`). Bare type names (ContentPart,', + '// ContextMessage, …) refer to the real types in src/ — they are intentionally', + '// not resolved here. `// …` marks a capped field list. On disk (wire.jsonl)', + '// the journal opens with a metadata line {"type": "metadata",', + '// "protocol_version", "created_at"}; each record is {"type", ...payload,', + '// "time"} — object payloads spread at the top level.', '//', - '// Declaration flags: persisted (written to the journal; absent = transient),', - '// toEvent (also publishes an IEventBus fact on live dispatch), blobs (the', - '// owning model offloads inline media to blob storage), cross-reducers', - '// (foreign models that also reduce this record on dispatch and replay).', + '// Every listed type is durable by construction — transient Event2 classes', + '// never enter EVENT2_REGISTRY, so there is no persisted flag. Declaration', + '// header lines: states (every state folding this record type on dispatch and', + '// replay; any state beyond the first is what the retired format listed as', + '// cross-reducers), blobs (the folding states whose blob codec offloads inline', + '// media to blob storage), owner (the source file declaring the class).', '', `// Index (${entries.length} record types)`, ]; const width = Math.max(...entries.map((e) => e.type.length)); - const modelWidth = Math.max(...entries.map((e) => e.model.name.length)); + const statesWidth = Math.max( + ...entries.map((e) => (folding.get(e.type)?.states.join(', ') ?? '(none)').length), + ); for (const entry of entries) { - const flags = entry.persist === false ? 'transient' : 'persisted'; + const states = folding.get(entry.type)?.states.join(', ') ?? '(none)'; out.push( - `// ${entry.type.padEnd(width)} ${entry.model.name.padEnd(modelWidth)} ${flags} ${owners.get(entry.type) ?? '(unresolved)'}`, + `// ${entry.type.padEnd(width)} ${states.padEnd(statesWidth)} ${owners.get(entry.type) ?? '(unresolved)'}`, ); } out.push(''); const declNames: [string, string][] = []; for (const entry of entries) { + const info = folding.get(entry.type); + const states = info?.states ?? []; const flags: string[] = []; - if (entry.persist !== false) flags.push('persisted'); - if (entry.toEvent !== undefined) flags.push('toEvent'); - if (entry.model.blobs !== undefined) flags.push('blobs'); - const crossReducers = (MODEL_CROSS_REDUCERS.get(entry.type) ?? []) - .map((r) => (r.model as { name: string }).name) - .filter((name) => name !== entry.model.name); - if (crossReducers.length > 0) flags.push(`cross-reducers: ${crossReducers.join(', ')}`); + if (info !== undefined && info.blobs.length > 0) flags.push(`blobs: ${info.blobs.join(', ')}`); const owner = owners.get(entry.type); const staticSketch = owner === undefined ? undefined : sketchPayloadFromSource(owner, entry.type); const sketch = buildPayloadSketch(entry.schema as unknown, staticSketch); - out.push(...renderPayloadDecl(entry, owner, flags, sketch)); + out.push(...renderPayloadDecl(entry, owner, states, flags, sketch)); declNames.push([entry.type, `${pascalCase(entry.type)}Payload`]); } diff --git a/packages/agent-core-v2/scripts/lib/jsonSchema.mts b/packages/agent-core-v2/scripts/lib/jsonSchema.mts index 4e2f9c793c1..a04b4d4912b 100644 --- a/packages/agent-core-v2/scripts/lib/jsonSchema.mts +++ b/packages/agent-core-v2/scripts/lib/jsonSchema.mts @@ -43,7 +43,7 @@ export function resolveRef(schema: unknown, root: JsonSchema): unknown { const defs = asJsonSchema(root.$defs); const name = s.$ref.slice('#/$defs/'.length); if (defs !== undefined && isRecord(defs) && name in defs) { - return (defs as Record)[name]; + return defs[name]; } } return schema; diff --git a/packages/agent-core-v2/src/_base/state/stateRegistry.ts b/packages/agent-core-v2/src/_base/state/stateRegistry.ts index 2801992dba7..d5fa5c0b35d 100644 --- a/packages/agent-core-v2/src/_base/state/stateRegistry.ts +++ b/packages/agent-core-v2/src/_base/state/stateRegistry.ts @@ -1,9 +1,10 @@ /** * `state` domain — scope-agnostic keyed state container primitives. * - * Owns the typed `StateKey` / `defineState(name, initial)` descriptor, the - * `IStateRegistry` base interface shared by the per-scope state services, and - * the `StateRegistry` implementation backing them: a `Map`-backed store + * Owns the typed `StateKey` descriptor (manufactured by `defineState` in + * the top-level `state` domain), the `IStateRegistry` base interface shared + * by the per-scope state services, and the `StateRegistry` implementation + * backing them: a `Map`-backed store * where keys are declared * up front (`register`), read and replaced (`get` / `set`), and observed * (`onDidChange(key)` per key, `onDidChangeAny` globally). Two exports serve @@ -14,7 +15,10 @@ * instances with a custom prototype (service references, tools, Promises) * collapse to a `'(ClassName)'` marker — plain data is recursed, resource * graphs are not, so a value that reaches into the DI object graph cannot - * fan the copy out until the heap is exhausted. Misuse (duplicate registration, reading or writing an + * fan the copy out until the heap is exhausted. A key flagged + * `snapshotExcluded` (replayable event-sourced state, whose authoritative + * copy is the wire journal) is skipped by `snapshot()` so the debug export + * never deep-copies it. Misuse (duplicate registration, reading or writing an * unregistered key) is a caller bug and raises `BugIndicatingError`. * * Cascading inspection: each scope's state service keeps a reference to the @@ -37,10 +41,7 @@ import { Emitter, type Event } from '../event'; export interface StateKey { readonly name: string; readonly initial: () => T; -} - -export function defineState(name: string, initial: () => T): StateKey { - return { name, initial }; + readonly snapshotExcluded?: boolean; } export interface StateChange { @@ -55,7 +56,7 @@ export interface StateInspection { } export interface IStateRegistry { - register(key: StateKey): IDisposable; + contributeState(key: StateKey): IDisposable; has(key: StateKey): boolean; get(key: StateKey): T; set(key: StateKey, value: T): void; @@ -70,6 +71,7 @@ export interface IStateRegistry { export class StateRegistry extends Disposable implements IStateRegistry { private readonly values = new Map(); private readonly registrations = new Map(); + private readonly excludedFromSnapshot = new Set(); private readonly keyEmitters = new Map>(); private readonly anyEmitter = this._register(new Emitter()); readonly onDidChangeAny: Event = this.anyEmitter.event; @@ -77,17 +79,31 @@ export class StateRegistry extends Disposable implements IStateRegistry { protected readonly inspectScope: string = 'unknown'; protected inspectParent?: IStateRegistry; - register(key: StateKey): IDisposable { + contributeState(key: StateKey): IDisposable { + const replayable = (key as StateKey & { readonly replayable?: unknown }).replayable; + if (typeof replayable === 'object' && replayable !== null) { + throw new BugIndicatingError( + `replayable state key '${key.name}' must be contributed to the Agent-scope state service`, + ); + } + return this.contributeKey(key); + } + + protected contributeKey(key: StateKey): IDisposable { if (this.values.has(key.name)) { throw new BugIndicatingError(`state key '${key.name}' is already registered`); } const registration = {}; this.registrations.set(key.name, registration); this.values.set(key.name, key.initial()); + if (key.snapshotExcluded === true) { + this.excludedFromSnapshot.add(key.name); + } return toDisposable(() => { if (this.registrations.get(key.name) !== registration) return; this.registrations.delete(key.name); this.values.delete(key.name); + this.excludedFromSnapshot.delete(key.name); this.keyEmitters.get(key.name)?.dispose(); this.keyEmitters.delete(key.name); }); @@ -129,6 +145,7 @@ export class StateRegistry extends Disposable implements IStateRegistry { snapshot(): Record { const out: Record = {}; for (const [key, value] of this.values) { + if (this.excludedFromSnapshot.has(key)) continue; out[key] = toJsonSafe(value, new WeakSet()); } return out; diff --git a/packages/agent-core-v2/src/agent/activityView/activityView.ts b/packages/agent-core-v2/src/agent/activityView/activityView.ts index 706915b12c9..4d707f71e99 100644 --- a/packages/agent-core-v2/src/agent/activityView/activityView.ts +++ b/packages/agent-core-v2/src/agent/activityView/activityView.ts @@ -12,9 +12,12 @@ * Agent scope — one instance per agent, dying with it. */ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ + import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { PromptOrigin } from '#/agent/contextMemory/types'; import type { TurnEndReason } from '#/agent/loop/turnEvents'; +import { Event2 } from '#/app/event/event2'; export type TurnPhase = 'running' | 'streaming' | 'tool_call' | 'retrying'; @@ -84,8 +87,8 @@ export interface IAgentActivityView { export const IAgentActivityView: ServiceIdentifier = createDecorator('agentActivityView'); -declare module '#/app/event/eventBus' { - interface DomainEventMap { - 'agent.activity.updated': AgentActivityState & { readonly type: 'agent.activity.updated' }; - } +export class AgentActivityUpdated extends Event2 { + static override readonly type = 'agent.activity.updated'; + static override readonly observable = true; } +export interface AgentActivityUpdated extends AgentActivityState {} diff --git a/packages/agent-core-v2/src/agent/activityView/activityViewService.ts b/packages/agent-core-v2/src/agent/activityView/activityViewService.ts index 7dfbc577922..0064aec2f14 100644 --- a/packages/agent-core-v2/src/agent/activityView/activityViewService.ts +++ b/packages/agent-core-v2/src/agent/activityView/activityViewService.ts @@ -7,31 +7,52 @@ * drive the pending-approval list, while task and full-compaction events drive * the background-work slice. The view seeds once from `IAgentLoopService`, * `IAgentTaskService`, and `IAgentFullCompactionService`, and recovers the - * last turn's outcome from the wire `TurnModel` through `IWireService`, so - * a cold-resumed agent still reports how its previous turn ended (reads, - * never writes). Otherwise the view holds only derived state, so it can be - * discarded and rebuilt at any time. The mutable view state (`lifecycle`, - * `turn`, `lastTurn`, `background`, `current`) is registered into - * `agentState` (`IAgentStateService`) and read/written through it; the - * event-bus subscription handles stay mechanism held by the `Disposable` - * base, and `MutableTurn`'s in-place-mutated Maps stay instance fields of - * that per-turn class. Bound at Agent scope. + * last turn's outcome from the durable `turnKey` state through `state` + * (`IEventDispatcher`), so a cold-resumed agent still reports how its + * previous turn ended (reads, never writes). Otherwise the view holds only + * derived state, so it can be discarded and rebuilt at any time. The mutable + * view state (`lifecycle`, `turn`, `lastTurn`, `background`, `current`) is + * registered into `agentState` (`IAgentStateService`) and read/written + * through it; the event-bus subscription handles stay mechanism held by the + * `Disposable` base, and `MutableTurn`'s in-place-mutated Maps stay instance + * fields of that per-turn class. Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; +import { defineState } from '#/state/state'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentLoopService } from '#/agent/loop/loop'; -import { TurnModel } from '#/agent/loop/turnOps'; +import { + AssistantDelta, + ThinkingDelta, + ToolCallDelta, + TurnStarted, + TurnStepStarted, + TurnStepCompleted, + TurnStepInterrupted, +} from '#/agent/loop/turnEvents'; +import { TurnEnded, turnKey } from '#/agent/loop/turnOps'; +import { TurnStepRetrying } from '#/agent/stepRetry/stepRetryService'; +import { ToolCallStarted, ToolResultEvent } from '#/agent/toolExecutor/toolExecutorEvents'; +import { + PermissionApprovalRequested, + PermissionApprovalResolved, +} from '#/agent/toolApproval/toolApprovalService'; +import { TaskStarted, TaskTerminatedNotice } from '#/agent/task/taskOps'; +import { + CompactionCancelled, + CompactionCompleted, + CompactionStarted, +} from '#/agent/fullCompaction/compactionOps'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentTaskService } from '#/agent/task/task'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types'; import type { PromptOrigin } from '#/agent/contextMemory/types'; import type { TurnEndReason } from '#/agent/loop/turnEvents'; -import { IWireService } from '#/wire/wire'; +import { IEventDispatcher } from '#/state/eventDispatcher'; import type { ActivityLastTurnState, @@ -44,7 +65,7 @@ import type { ToolCallRef, TurnPhase, } from './activityView'; -import { IAgentActivityView } from './activityView'; +import { AgentActivityUpdated, IAgentActivityView } from './activityView'; type EndingReason = NonNullable; const FULL_COMPACTION_BACKGROUND_ID = 'full-compaction'; @@ -80,35 +101,35 @@ export class AgentActivityView extends Disposable implements IAgentActivityView @IAgentTaskService private readonly tasks: IAgentTaskService, @IAgentFullCompactionService private readonly fullCompaction: IAgentFullCompactionService, @IAgentStateService private readonly states: IAgentStateService, - @IWireService private readonly wire: IWireService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, ) { super(); - this.states.register(activityViewLifecycleKey); - this.states.register(activityViewTurnKey); - this.states.register(activityViewLastTurnKey); - this.states.register(activityViewBackgroundKey); - this.states.register(activityViewCurrentKey); + this.states.contributeState(activityViewLifecycleKey); + this.states.contributeState(activityViewTurnKey); + this.states.contributeState(activityViewLastTurnKey); + this.states.contributeState(activityViewBackgroundKey); + this.states.contributeState(activityViewCurrentKey); this.seedFromLoop(); this.seedFromTasks(); this.seedFromFullCompaction(); this._register( - this.wire.hooks.onDidRestore.register('activityView', async (_ctx, next) => { + this.dispatcher.hooks.onDidRestore.register('activityView', async (_ctx, next) => { this.seedLastTurnFromWire(); await next(); }), ); - this._register(this.eventBus.subscribe('turn.started', (e) => this.onTurnStarted(e.turnId, e.origin))); - 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(TurnStarted, (e) => this.onTurnStarted(e.turnId, e.origin))); + this._register(this.eventBus.subscribe(TurnStepStarted, (e) => this.onStepStarted(e.step))); + this._register(this.eventBus.subscribe(AssistantDelta, () => this.onDelta('assistant'))); + this._register(this.eventBus.subscribe(ThinkingDelta, () => this.onDelta('thinking'))); + this._register(this.eventBus.subscribe(ToolCallDelta, () => this.onDelta('tool_call'))); this._register( - this.eventBus.subscribe('tool.call.started', (e) => this.onToolCallStarted(e.toolCallId, e.name)), + this.eventBus.subscribe(ToolCallStarted, (e) => this.onToolCallStarted(e.toolCallId, e.name)), ); - this._register(this.eventBus.subscribe('tool.result', (e) => this.onToolResult(e.toolCallId))); + this._register(this.eventBus.subscribe(ToolResultEvent, (e) => this.onToolResult(e.toolCallId))); this._register( - this.eventBus.subscribe('turn.step.retrying', (e) => { + this.eventBus.subscribe(TurnStepRetrying, (e) => { this.mutateTurn((t) => { t.phase = 'retrying'; t.stream = undefined; @@ -124,7 +145,7 @@ export class AgentActivityView extends Disposable implements IAgentActivityView }), ); this._register( - this.eventBus.subscribe('turn.step.completed', () => { + this.eventBus.subscribe(TurnStepCompleted, () => { this.mutateTurn((t) => { t.phase = 'running'; t.stream = undefined; @@ -133,23 +154,25 @@ export class AgentActivityView extends Disposable implements IAgentActivityView }), ); this._register( - this.eventBus.subscribe('turn.step.interrupted', (e) => this.onStepInterrupted(e.turnId, e.reason)), + this.eventBus.subscribe(TurnStepInterrupted, (e) => this.onStepInterrupted(e.turnId, e.reason)), ); this._register( - this.eventBus.subscribe('turn.ended', (e) => this.onTurnEnded(e.turnId, e.reason)), + this.eventBus.subscribe(TurnEnded, (e) => this.onTurnEnded(e.turnId, e.reason)), ); this._register( - this.eventBus.subscribe('permission.approval.requested', (e) => + this.eventBus.subscribe(PermissionApprovalRequested, (e) => this.onApprovalRequested(e.id ?? e.toolCallId, e.toolCallId), + ), ); this._register( - this.eventBus.subscribe('permission.approval.resolved', (e) => + this.eventBus.subscribe(PermissionApprovalResolved, (e) => this.onApprovalResolved(e.id ?? e.toolCallId), + ), ); this._register( - this.eventBus.subscribe('task.started', (e) => { + this.eventBus.subscribe(TaskStarted, (e) => { this.background.set(e.info.taskId, { kind: e.info.kind, id: e.info.taskId, @@ -159,12 +182,12 @@ export class AgentActivityView extends Disposable implements IAgentActivityView }), ); this._register( - this.eventBus.subscribe('task.terminated', (e) => { + this.eventBus.subscribe(TaskTerminatedNotice, (e) => { if (this.background.delete(e.info.taskId)) this.publish(); }), ); this._register( - this.eventBus.subscribe('compaction.started', () => { + this.eventBus.subscribe(CompactionStarted, () => { this.background.set(FULL_COMPACTION_BACKGROUND_ID, { kind: 'compaction', id: FULL_COMPACTION_BACKGROUND_ID, @@ -174,12 +197,12 @@ export class AgentActivityView extends Disposable implements IAgentActivityView }), ); this._register( - this.eventBus.subscribe('compaction.completed', () => { + this.eventBus.subscribe(CompactionCompleted, () => { this.onFullCompactionEnded(); }), ); this._register( - this.eventBus.subscribe('compaction.cancelled', () => { + this.eventBus.subscribe(CompactionCancelled, () => { this.onFullCompactionEnded(); }), ); @@ -243,7 +266,7 @@ export class AgentActivityView extends Disposable implements IAgentActivityView private seedLastTurnFromWire(): void { if (this.turn !== undefined || this.lastTurn !== undefined) return; - const lastEnded = this.wire.getModel(TurnModel).lastEnded; + const lastEnded = this.states.get(turnKey).lastEnded; if (lastEnded === undefined) return; this.lastTurn = { turnId: lastEnded.turnId, @@ -364,7 +387,7 @@ export class AgentActivityView extends Disposable implements IAgentActivityView }; if (activityEqual(this.current, next)) return; this.current = next; - this.eventBus.publish({ type: 'agent.activity.updated', ...next }); + void this.dispatcher.dispatch(new AgentActivityUpdated(next)); } } diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts index 16a10a85269..92a5617a947 100644 --- a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -4,7 +4,8 @@ * * Discovers AGENTS.md files reached through `toolExecutor` and the tool path * policy, parsing Bash targets through `bashParser` and probing through the os - * services. Restores prompt provenance through `wire` and `profile`, resolves + * services. Restores prompt provenance through the `profile` state on the + * event dispatcher, resolves * roots through `sessionContext` and `bootstrap`, stores discovery state in * `agentState`, appends through `systemReminder`, and reports through * `telemetry`. Bound at Agent scope. @@ -15,7 +16,7 @@ import { basename, dirname, isAbsolute, join, normalize } from 'pathe'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; +import { defineState } from '#/state/state'; import { IBashParserService } from '#/app/bashParser/bashParser'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import type { AgentsMdReminderShownEvent } from '#/app/telemetry/events'; @@ -33,12 +34,12 @@ import { extractAgentsMdPathsFromSystemPrompt, loadAgentsMdDetailed, } from '#/agent/profile/context'; -import { ProfileModel } from '#/agent/profile/profileOps'; +import { profileKey } from '#/agent/profile/profileOps'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; -import { IWireService } from '#/wire/wire'; +import { IEventDispatcher } from '#/state/eventDispatcher'; import { IAgentAgentsMdReminderService } from './agentsMdReminder'; import { extractBashTargetDirs } from './bashTargets'; @@ -75,15 +76,16 @@ export class AgentAgentsMdReminderService @IBootstrapService private readonly bootstrap: IBootstrapService, @IBashParserService private readonly bashParser: IBashParserService, @ITelemetryService private readonly telemetry: ITelemetryService, - @IWireService private readonly wire: IWireService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentStateService private readonly agentState: IAgentStateService, ) { super(); - this.states.register(agentsMdReminderKnownKey); - this.states.register(agentsMdReminderCwdKey); - this.states.register(agentsMdReminderSeededKey); + this.states.contributeState(agentsMdReminderKnownKey); + this.states.contributeState(agentsMdReminderCwdKey); + this.states.contributeState(agentsMdReminderSeededKey); this._register( - this.wire.hooks.onDidRestore.register('agentsMdReminder', async (_ctx, next) => { - const profile = this.wire.getModel(ProfileModel); + this.dispatcher.hooks.onDidRestore.register('agentsMdReminder', async (_ctx, next) => { + const profile = this.agentState.get(profileKey); const paths = profile.agentsMdPaths ?? extractAgentsMdPathsFromSystemPrompt(profile.systemPrompt); this.seedInjected(paths, this.sessionContext.cwd); diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 2419db72995..a8dcf5b6e30 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -17,6 +17,7 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { ContextSpliced } from '#/agent/contextMemory/contextEvents'; import { isCompactionSummaryMessage } from '#/agent/contextMemory/compactionHandoff'; import { IAgentLoopService, type BeforeStepContext } from '#/agent/loop/loop'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; @@ -55,7 +56,7 @@ export class AgentContextInjectorService extends Service implements IAgentContex ), ); this._register( - this.eventBus.subscribe('context.spliced', (splice) => { + this.eventBus.subscribe(ContextSpliced, (splice) => { if (isCompactionSplice(splice)) this.compactionRearmPending = true; }), ); diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts b/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts new file mode 100644 index 00000000000..55eb64d10c3 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts @@ -0,0 +1,117 @@ +/** + * `contextMemory` domain — the durable `context.*` Event2 classes and the + * observable `context.spliced` fact. + * + * The five durable classes are the wire-protocol 1.4 record vocabulary for + * the per-agent conversation history; their `serialize()` output is the + * on-disk record (flat payload, epoch-ms `time`), so v1- and v2-written + * sessions reduce identically and replay stays silent. `ContextSpliced` is + * the live-only observable counterpart broadcast after every splice-shaped + * mutation (`clear` / `applyCompaction` / `undo` / verified cross-model + * trailing removal). Scope-agnostic. + */ + +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ + +import { z } from 'zod'; + +import { Event2 } from '#/app/event/event2'; + +import type { LoopRecordedEvent } from './loopEventFold'; +import type { ContextMessage } from './types'; + +const contextMessageSchema = z.custom(); +const loopRecordedEventSchema = z.custom(); + +const contextAppendMessageSchema = z.object({ message: contextMessageSchema }); + +export class ContextAppendMessage extends Event2> { + static override readonly type = 'context.append_message'; + static override readonly durable = true; + static override readonly schema = contextAppendMessageSchema; +} +export interface ContextAppendMessage extends z.infer {} + +const contextAppendLoopEventSchema = z.object({ event: loopRecordedEventSchema }); + +export class ContextAppendLoopEvent extends Event2< + z.infer +> { + static override readonly type = 'context.append_loop_event'; + static override readonly durable = true; + static override readonly schema = contextAppendLoopEventSchema; +} +export interface ContextAppendLoopEvent + extends z.infer {} + +const contextClearSchema = z.object({}); + +export class ContextClear extends Event2> { + static override readonly type = 'context.clear'; + static override readonly durable = true; + static override readonly schema = contextClearSchema; +} +export interface ContextClear extends z.infer {} + +const contextCompactionBaseShape = { + tokensBefore: z.number().optional(), + tokensAfter: z.number().optional(), + summaryOutputTokens: z.number().optional(), + keptUserMessageCount: z.number().optional(), + keptHeadUserMessageCount: z.number().optional(), + droppedCount: z.number().optional(), + legacyTail: z.boolean().optional(), +}; + +const contextApplyCompactionSchema = z.union([ + z.object({ + ...contextCompactionBaseShape, + summary: z.string(), + compactedCount: z.number(), + contextSummary: z.string().optional(), + }), + z.object({ + ...contextCompactionBaseShape, + contextSummary: z.string(), + compactedCount: z.number(), + summary: z.string().optional(), + }), + z.object({ + ...contextCompactionBaseShape, + summary: contextMessageSchema, + count: z.number(), + compactedCount: z.number().optional(), + }), +]); + +export type ContextApplyCompactionPayload = z.infer; + +export class ContextApplyCompaction extends Event2 { + static override readonly type = 'context.apply_compaction'; + static override readonly durable = true; + static override readonly schema = contextApplyCompactionSchema; +} + +const contextUndoSchema = z.object({ + count: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), +}); + +export class ContextUndo extends Event2> { + static override readonly type = 'context.undo'; + static override readonly durable = true; + static override readonly schema = contextUndoSchema; +} +export interface ContextUndo extends z.infer {} + +export interface ContextSplicedPayload { + start: number; + deleteCount: number; + messages: readonly ContextMessage[]; + tokens?: number; +} + +export class ContextSpliced extends Event2 { + static override readonly type = 'context.spliced'; + static override readonly observable = true; +} +export interface ContextSpliced extends ContextSplicedPayload {} diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index d095be61594..339a53aaa1d 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -1,8 +1,8 @@ /** * `contextMemory` domain — `IAgentContextMemoryService` implementation. * - * Owns per-agent conversation history through `wire`, maintains measurements - * with `tokenCounting`, and broadcasts live mutations through `event`. Every + * Owns per-agent conversation history through the event dispatcher, maintains + * measurements with `tokenCounting`. Every * splice-shaped mutation (`clear` / `applyCompaction` / `undo`, plus verified * cross-model trailing removal) publishes `context.spliced` from the live path * only — replay rebuilds silently — and truncates the measured-anchor ledger @@ -13,15 +13,14 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IEventBus } from '#/app/event/eventBus'; -import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; import { - TokenCountingModel, - tokenCountingRebased, - tokenCountingTruncated, + TokenCountingRebased, + TokenCountingTruncated, + tokenCountingKey, } from '#/agent/tokenCounting/tokenCountingOps'; -import { IWireService } from '#/wire/wire'; -import type { Op } from '#/wire/op'; +import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IEventDispatcher } from '#/state/eventDispatcher'; import { IAgentContextMemoryService, @@ -29,41 +28,35 @@ import { type ContextCompactionResult, } from './contextMemory'; import { buildContextCompactionShape, type TokenEstimate } from './compactionHandoff'; +import { + ContextApplyCompaction, + ContextAppendLoopEvent, + ContextAppendMessage, + ContextClear, + ContextSpliced, + ContextUndo, + type ContextSplicedPayload, +} from './contextEvents'; import { computeUndoCut, - ContextModel, - contextAppendLoopEvent, - contextAppendMessage, - contextApplyCompaction, - contextClear, - contextUndo, + contextMemoryKey, isFullyUndoable, type UndoCut, } from './contextOps'; import type { LoopRecordedEvent } from './loopEventFold'; import type { ContextMessage } from './types'; -declare module '#/app/event/eventBus' { - interface DomainEventMap { - 'context.spliced': { - start: number; - deleteCount: number; - messages: readonly ContextMessage[]; - tokens?: number; - }; - } -} - // NOTE: stays Disposable — its own 'get' collides with the Fiber export class AgentContextMemoryService extends Disposable implements IAgentContextMemoryService { declare readonly _serviceBrand: undefined; constructor( - @IWireService private readonly wire: IWireService, - @IEventBus private readonly eventBus: IEventBus, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IAgentTokenCountingService private readonly tokenCounting: IAgentTokenCountingService, + @IAgentStateService private readonly agentState: IAgentStateService, ) { super(); + this.agentState.contributeState(contextMemoryKey); } private get tokenEstimateFns(): TokenEstimate { @@ -75,18 +68,20 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte } get(): readonly ContextMessage[] { - return this.wire.getModel(ContextModel) as readonly ContextMessage[]; + return this.agentState.get(contextMemoryKey) as readonly ContextMessage[]; } append(...messages: readonly ContextMessage[]): void { if (messages.length === 0) return; const start = this.get().length; - this.wire.dispatch(...messages.map((message) => contextAppendMessage({ message }))); + for (const message of messages) { + void this.dispatcher.dispatch(new ContextAppendMessage({ message })); + } this.publishSplice({ start, deleteCount: 0, messages: [...messages] }); } appendLoopEvent(event: LoopRecordedEvent): void { - this.wire.dispatch(contextAppendLoopEvent({ event })); + void this.dispatcher.dispatch(new ContextAppendLoopEvent({ event })); } publishTrailingRemoval(previous: readonly ContextMessage[]): boolean { @@ -99,7 +94,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte ) { return false; } - this.wire.dispatch(...this.sizeOpsForCut(cutIndex)); + this.dispatchCutEvents(cutIndex); this.publishSplice({ start: cutIndex, deleteCount: 1, messages: [] }); return true; } @@ -107,9 +102,9 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte clear(): void { const deleteCount = this.get().length; if (deleteCount === 0) return; - this.wire.dispatch( - contextClear({}), - tokenCountingRebased({ length: 0, tokens: 0, measured: true }), + void this.dispatcher.dispatch(new ContextClear({})); + void this.dispatcher.dispatch( + new TokenCountingRebased({ length: 0, tokens: 0, measured: true }), ); this.publishSplice({ start: 0, deleteCount, messages: [] }); } @@ -118,7 +113,8 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte const history = this.get(); const cut = computeUndoCut(history, count); if (isFullyUndoable(cut, count)) { - this.wire.dispatch(contextUndo({ count }), ...this.sizeOpsForCut(cut.cutIndex)); + void this.dispatcher.dispatch(new ContextUndo({ count })); + this.dispatchCutEvents(cut.cutIndex); this.publishSplice({ start: cut.cutIndex, deleteCount: history.length - cut.cutIndex, @@ -131,8 +127,8 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte applyCompaction(input: ContextCompactionInput): ContextCompactionResult { const history = this.get(); const result = buildContextCompactionShape(history, input, this.tokenEstimateFns); - this.wire.dispatch( - contextApplyCompaction({ + void this.dispatcher.dispatch( + new ContextApplyCompaction({ summary: result.summary, contextSummary: result.contextSummary, compactedCount: result.compactedCount, @@ -143,7 +139,9 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte keptHeadUserMessageCount: result.keptHeadUserMessageCount, droppedCount: result.droppedCount, }), - tokenCountingRebased({ + ); + void this.dispatcher.dispatch( + new TokenCountingRebased({ length: result.messages.length, tokens: result.tokensAfter, measured: false, @@ -160,27 +158,22 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte return publicResult; } - private publishSplice(input: { - start: number; - deleteCount: number; - messages: readonly ContextMessage[]; - tokens?: number; - }): void { - this.eventBus.publish({ type: 'context.spliced', ...input }); + private publishSplice(input: ContextSplicedPayload): void { + void this.dispatcher.dispatch(new ContextSpliced(input)); } - private sizeOpsForCut(cutIndex: number): Op[] { - const model = this.wire.getModel(TokenCountingModel); - if (!model.anchors.some((anchor) => anchor.length > cutIndex)) return []; + private dispatchCutEvents(cutIndex: number): void { + const model = this.agentState.get(tokenCountingKey); + if (!model.anchors.some((anchor) => anchor.length > cutIndex)) return; // The display tokens are the post-cut size computed from the CURRENT // ledger — anchors at or below the cut are identical before and after // the truncation, so the pre-dispatch read is exact. - return [ - tokenCountingTruncated({ + void this.dispatcher.dispatch( + new TokenCountingTruncated({ length: cutIndex, tokens: this.tokenCounting.get(0, cutIndex).size, }), - ]; + ); } } diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index a6ebad053ef..6ae90c0af49 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -1,32 +1,30 @@ /** - * `contextMemory` domain — wire Model (`ContextModel`) and the wire-protocol - * 1.4 Ops `context.append_message` (`contextAppendMessage`) / `context.clear` - * (`contextClear`) / `context.apply_compaction` (`contextApplyCompaction`) / - * `context.undo` (`contextUndo`) / `context.append_loop_event` - * (`contextAppendLoopEvent`) for the per-agent conversation history. + * `contextMemory` domain — the conversation-history state (`contextMemoryKey`) + * and its folds over the durable `context.*` events (`ContextAppendMessage` / + * `ContextAppendLoopEvent` / `ContextClear` / `ContextApplyCompaction` / + * `ContextUndo`), plus the undo-cut and compaction-record helpers. * - * Declares the history as `ContextMessage[]` (initial `[]`); every Op's `apply` - * is a pure array transform that returns a NEW reference on change and the SAME - * reference on a no-op (so the wire's reference-equality gate stays quiet), and - * carries no non-determinism. - * - * The live write path emits the v1 Ops: non-loop appends (user prompts, - * injections, hook/task notices) go on the wire as `append_message` (persisted - * without local ids — the on-disk record matches v1's field set), while the - * agent loop streams each turn as `context.append_loop_event` records — the - * same on-disk shape the v1 loop writes — and `contextAppendLoopEvent` folds - * them into assistant / tool messages both at live dispatch time and on - * replay, so v1- and v2-written sessions reduce - * identically. Swarm-mode announcements are owned by the `swarm` domain's - * context-injection provider; `swarm_mode.exit` additionally pops a trailing - * enter reminder through a replayable cross-model reducer. + * Declares the history as `ContextMessage[]` (initial `[]`); every fold runs + * on the immer draft and either mutates it or returns a replacement, so a + * no-op keeps the same reference (immer returns the base state untouched). + * The live write path emits the v1 vocabulary: non-loop appends (user + * prompts, injections, hook/task notices) go on the wire as + * `context.append_message` (persisted without local ids — the on-disk record + * matches v1's field set), while the agent loop streams each turn as + * `context.append_loop_event` records — the same on-disk shape the v1 loop + * writes — folded into assistant / tool messages both at live dispatch time + * and on replay, so v1- and v2-written sessions reduce identically. + * Swarm-mode announcements are owned by the `swarm` domain's + * context-injection provider; the trailing enter-reminder pop on + * `swarm_mode.exit` is registered by the swarm feature onto `contextMemoryKey` + * (see `popSwarmModeReminder`). * * `context.undo` counts conversation ticks with the single `isUndoAnchor` * predicate — the same definition the checkpoint * protocol pushes with, so anchor counting and checkpoint pushing can never * drift apart. * - * Blob handling is declared as a `ModelBlobCodec` on `ContextModel.blobs`: + * Blob handling is declared as a `StateBlobCodec` on `contextMemoryKey.replayable.blobs`: * - `dehydrate(record, transform)`: at dispatch time, traverses message content * in `context.append_message` and `context.append_loop_event` records, * passing each `ContentPart[]` through `transform` to offload oversized data @@ -40,7 +38,8 @@ import { z } from 'zod'; import { ErrorCodes, Error2 } from '#/errors'; import type { ContentPart } from '#/kosong/contract/message'; -import { defineModel, type PartsTransformer } from '#/wire/model'; +import { defineState } from '#/state/state'; +import type { PartsTransformer } from '#/wire/record'; import type { WireRecord } from '#/wire/record'; import { @@ -49,10 +48,13 @@ import { type ContextCompactionShapeInput, } from './compactionHandoff'; import { - isPromptOwnedInjection, - isUndoAnchor, - isValidUndoCount, -} from './conversationTime'; + ContextAppendLoopEvent, + ContextAppendMessage, + ContextApplyCompaction, + ContextClear, + type ContextApplyCompactionPayload, +} from './contextEvents'; +import { isPromptOwnedInjection, isUndoAnchor } from './conversationTime'; import { foldAppendMessage, foldLoopEvent, @@ -110,99 +112,47 @@ async function dehydrateRecord( return record; } -export const ContextModel = defineModel('contextMemory', () => [], { - blobs: { - dehydrate: dehydrateRecord, - rehydrate: async (state, transform) => { - const { changed, result } = await dehydrateMessages(state, transform); - return changed ? result : state; +export const contextMemoryKey = defineState('contextMemory', (): ContextMessage[] => []) + .replayable({ + schema: z.custom(), + blobs: { + dehydrate: dehydrateRecord, + rehydrate: async (state, transform) => { + const { changed, result } = await dehydrateMessages(state, transform); + return changed ? result : state; + }, + }, + }) + .undoable({ + onUndo: (s, count) => { + if (s.length === 0) return; + const cut = computeUndoCut(s, count); + if (!isFullyUndoable(cut, count)) return; + return resetFold(s.slice(0, cut.cutIndex)) as ContextMessage[]; }, - }, - reducers: { - 'swarm_mode.exit': popSwarmModeReminder, - }, -}); + }) + .on(ContextAppendMessage, (s, e) => foldAppendMessage(s, e.message) as ContextMessage[]) + .on(ContextAppendLoopEvent, (s, e) => foldLoopEvent(s, e.event) as ContextMessage[]) + .on(ContextClear, (s) => (s.length === 0 ? undefined : (resetFold([]) as ContextMessage[]))) + .on(ContextApplyCompaction, (s, e) => { + const result = buildContextCompactionShape( + s, + readContextCompactionShapeInput(e as unknown as ContextApplyCompactionPayload), + ); + return resetFold([...result.messages]) as ContextMessage[]; + }); -function popSwarmModeReminder(state: ContextMessage[]): ContextMessage[] { +export function popSwarmModeReminder(state: ContextMessage[]): ContextMessage[] { const last = state.at(-1); if (last?.origin?.kind !== 'injection' || last.origin.variant !== 'swarm_mode') return state; return resetFold(state.slice(0, -1)) as ContextMessage[]; } -declare module '#/wire/types' { - interface PersistedOpMap { - 'context.append_message': typeof contextAppendMessage; - 'context.append_loop_event': typeof contextAppendLoopEvent; - 'context.clear': typeof contextClear; - 'context.apply_compaction': typeof contextApplyCompaction; - 'context.undo': typeof contextUndo; - } -} - -const contextMessageSchema = z.custom(); -const loopRecordedEventSchema = z.custom(); - -export const contextAppendMessage = ContextModel.defineOp('context.append_message', { - schema: z.object({ message: contextMessageSchema }), - apply: (state, p) => foldAppendMessage(state, p.message) as ContextMessage[], -}); - -export const contextAppendLoopEvent = ContextModel.defineOp('context.append_loop_event', { - schema: z.object({ event: loopRecordedEventSchema }), - apply: (state, p) => foldLoopEvent(state, p.event) as ContextMessage[], -}); - -export const contextClear = ContextModel.defineOp('context.clear', { - schema: z.object({}), - apply: (state) => (state.length === 0 ? state : (resetFold([]) as ContextMessage[])), -}); - -const contextCompactionBaseShape = { - tokensBefore: z.number().optional(), - tokensAfter: z.number().optional(), - summaryOutputTokens: z.number().optional(), - keptUserMessageCount: z.number().optional(), - keptHeadUserMessageCount: z.number().optional(), - droppedCount: z.number().optional(), - legacyTail: z.boolean().optional(), -}; - -const contextApplyCompactionSchema = z.union([ - z.object({ - ...contextCompactionBaseShape, - summary: z.string(), - compactedCount: z.number(), - contextSummary: z.string().optional(), - }), - z.object({ - ...contextCompactionBaseShape, - contextSummary: z.string(), - compactedCount: z.number(), - summary: z.string().optional(), - }), - z.object({ - ...contextCompactionBaseShape, - summary: contextMessageSchema, - count: z.number(), - compactedCount: z.number().optional(), - }), -]); - -type ContextCompactionPayload = z.infer; - -export const contextApplyCompaction = ContextModel.defineOp('context.apply_compaction', { - schema: contextApplyCompactionSchema, - apply: (state, p) => { - const result = buildContextCompactionShape(state, readContextCompactionShapeInput(p)); - return resetFold([...result.messages]) as ContextMessage[]; - }, -}); - interface UnknownRecord { readonly [key: string]: unknown; } -type ContextCompactionRecord = ContextCompactionPayload | UnknownRecord; +type ContextCompactionRecord = ContextApplyCompactionPayload | UnknownRecord; export function applyContextCompactionRecord( state: readonly ContextMessage[], @@ -403,15 +353,3 @@ export function formatUndoUnavailableMessage( return 'Nothing to undo: conversation state checkpoints are incomplete'; } } - -export const contextUndo = ContextModel.defineOp('context.undo', { - schema: z.object({ - count: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), - }), - apply: (state, p) => { - if (!isValidUndoCount(p.count) || state.length === 0) return state; - const cut = computeUndoCut(state, p.count); - if (!isFullyUndoable(cut, p.count)) return state; - return resetFold(state.slice(0, cut.cutIndex)) as ContextMessage[]; - }, -}); diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts index 94060145a01..7878a8fd9ef 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts @@ -1,17 +1,26 @@ /** - * `contextMemory` domain — shared conversation clock and checkpointed - * wire-Model factory. + * `contextMemory` domain — shared conversation clock and the undoable + * protocol registration. * - * Defines the undo anchor vocabulary and registers conversation-time Models - * for undo validation. `CHECKPOINTED_MODELS` stays the undo domain's read - * path; the `WireModelContribution` fold also drains it into the built-in - * layer so the checkpointed list is part of the folded wire vocabulary. - * Scope-agnostic. + * Defines the undo anchor vocabulary and registers the undoable protocol + * consumed by the state domain's `.undoable()` expansion: the four protocol + * events (`context.append_message` / `context.apply_compaction` / + * `context.clear` / `context.undo`), the single `isUndoAnchor` tick + * predicate, and the undo-count guard. A state key whose value must follow + * conversation undo chains `.undoable()` — never hand-rolling the + * checkpoint/clear/rollback folds — so undo anchors push a checkpoint, + * compaction/clear drop the markers, and `context.undo` rolls back through + * inverse patches (or through the key's custom `onUndo`). Scope-agnostic. */ -import { defineModel, type ModelDef } from '#/wire/model'; -import type { ModelReducers } from '#/wire/types'; +import { registerUndoableProtocol } from '#/state/state'; +import { + ContextAppendMessage, + ContextApplyCompaction, + ContextClear, + ContextUndo, +} from './contextEvents'; import type { ContextMessage } from './types'; export function isUndoAnchor(message: ContextMessage): boolean { @@ -40,53 +49,13 @@ export function isValidUndoCount(count: number): boolean { return Number.isSafeInteger(count) && count > 0; } -export interface Checkpointed { - readonly current: T; - readonly checkpoints: readonly T[]; -} - -export const CHECKPOINTED_MODELS: ModelDef>[] = []; - -export interface CheckpointModelOptions { - readonly onAppendMessage?: (current: T, message: ContextMessage) => T; - readonly reducers?: ModelReducers>; -} - -export function defineCheckpointedModel( - name: string, - initial: () => T, - opts?: CheckpointModelOptions, -): ModelDef> { - const customReducers = opts?.reducers ?? {}; - const def = defineModel>( - name, - () => ({ current: initial(), checkpoints: [] }), - { - reducers: { - ...customReducers, - 'context.append_message': (state, { message }) => { - if (isUndoAnchor(message)) { - return { ...state, checkpoints: [...state.checkpoints, state.current] }; - } - if (opts?.onAppendMessage === undefined) return state; - const current = opts.onAppendMessage(state.current, message); - return current === state.current ? state : { ...state, current }; - }, - 'context.apply_compaction': (state) => - state.checkpoints.length === 0 ? state : { ...state, checkpoints: [] }, - 'context.clear': (state) => - state.checkpoints.length === 0 ? state : { ...state, checkpoints: [] }, - 'context.undo': (state, { count }) => { - if (!isValidUndoCount(count) || state.checkpoints.length < count) return state; - const checkpointIndex = state.checkpoints.length - count; - return { - current: state.checkpoints[checkpointIndex]!, - checkpoints: state.checkpoints.slice(0, checkpointIndex), - }; - }, - }, - }, - ); - CHECKPOINTED_MODELS.push(def as ModelDef>); - return def; -} +registerUndoableProtocol({ + events: { + appendMessage: ContextAppendMessage, + applyCompaction: ContextApplyCompaction, + clear: ContextClear, + undo: ContextUndo, + }, + isUndoAnchor: (message) => isUndoAnchor(message as ContextMessage), + isValidUndoCount, +}); diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 325b94ba906..d9ead31e543 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -8,7 +8,7 @@ * byte-compatible with v1. This fold turns them into assistant / tool * messages — at live dispatch time 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 + * registered for the type) and the restored `contextMemoryKey` — and every * consumer built on it — would show only the user prompts. * * Semantics mirror the v1 fold exactly: @@ -33,11 +33,14 @@ * assistant↔tool adjacency is preserved. * * The fold is stateful across records within one replay. State is carried in a - * `WeakMap` keyed by each evolving state array, so the public - * `wire.getModel(ContextModel)` view stays a plain `ContextMessage[]` and - * concurrent replays of different agent scopes never share fold state. + * `WeakMap` keyed by each committed state array (immer drafts resolve to + * their `original`), so the public `getState(ContextModel)` view stays a + * plain `ContextMessage[]` and concurrent replays of different agent scopes + * never share fold state. */ +import { isDraft, original } from 'immer'; + import type { FinishReason } from '#/kosong/contract/provider'; import { createToolMessage, type ContentPart, type ToolCall } from '#/kosong/contract/message'; import type { TokenUsage } from '#/kosong/contract/usage'; @@ -111,10 +114,12 @@ interface FoldCtx { const foldCtxMap = new WeakMap(); function ctxOf(state: readonly ContextMessage[]): FoldCtx { - let ctx = foldCtxMap.get(state); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const key = (isDraft(state) ? original(state as any) : state) as object; + let ctx = foldCtxMap.get(key); if (ctx === undefined) { ctx = { openStepUuid: undefined, pending: new Set(), deferred: [] }; - foldCtxMap.set(state, ctx); + foldCtxMap.set(key, ctx); } return ctx; } diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts index ca92b6205c3..01df03888d5 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts @@ -29,7 +29,7 @@ import { createHash } from 'node:crypto'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; -import { defineState } from '#/_base/state/stateRegistry'; +import { defineState } from '#/state/state'; import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; @@ -55,7 +55,7 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentStateService private readonly states: IAgentStateService, ) { - this.states.register(contextProjectorLastRepairSignatureKey); + this.states.contributeState(contextProjectorLastRepairSignatureKey); } private get lastRepairSignature(): string | null { diff --git a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts index 56ddf2aa0fd..a28ed0b2e54 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts @@ -21,11 +21,13 @@ * hook listener registrations stay ordinary disposables on the instance. */ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ + import { IInstantiationService } from '#/_base/di/instantiation'; import { Service } from '#/_base/di/service'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; +import { defineState } from '#/state/state'; import { isPlainRecord } from '#/_base/utils/canonical-args'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentTaskService, type AgentTaskInfo, type AgentTaskNotificationContext } from '#/agent/task/task'; @@ -38,12 +40,20 @@ import { import type { CompactionResult } from '#/agent/fullCompaction/types'; import { IAgentLoopService, type AfterStepContext } from '#/agent/loop/loop'; import { ContinuationStepRequest } from '#/agent/loop/stepRequest'; +import { TurnStarted } from '#/agent/loop/turnEvents'; +import { TurnEnded } from '#/agent/loop/turnOps'; import { IAgentPromptService, type PromptSubmitContext, } from '#/agent/prompt/prompt'; -import type { TurnEndedEvent, TurnStartedEvent } from '#/agent/loop/turnEvents'; +import { PromptQueued } from '#/agent/prompt/promptService'; +import { TaskNotified, TaskStarted } from '#/agent/task/taskOps'; +import { + PermissionApprovalRequested, + PermissionApprovalResolved, +} from '#/agent/toolApproval/toolApprovalService'; import { IEventBus } from '#/app/event/eventBus'; +import { Event2 } from '#/app/event/event2'; import type { ExecutableToolResult } from '#/tool/toolContract'; import type { ResolvedToolExecutionHookContext, ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; @@ -51,6 +61,7 @@ import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { toKimiErrorPayload } from '#/errors'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { IEventDispatcher } from '#/state/eventDispatcher'; import { IAgentExternalHooksService } from './externalHooks'; import { IExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunner'; @@ -60,19 +71,18 @@ import { renderUserPromptHookResult, } from './user-prompt'; -export interface HookResultEvent { - readonly type: 'hook.result'; +export interface HookResultPayload { readonly turnId?: number; readonly hookEvent: string; readonly content: string; readonly blocked?: boolean; } -declare module '#/app/event/eventBus' { - interface DomainEventMap { - 'hook.result': HookResultEvent; - } +export class HookResult extends Event2 { + static override readonly type = 'hook.result'; + static override readonly observable = true; } +export interface HookResult extends HookResultPayload {} export const externalHooksStopHookContinuationUsedKey = defineState( 'externalHooks.stopHookContinuationUsed', @@ -90,9 +100,10 @@ export class AgentExternalHooksService extends Service implements IAgentExternal @ISessionContext private readonly sessionContext: ISessionContext, @ISessionMetadata private readonly sessionMetadata: ISessionMetadata, @IAgentStateService private readonly states: IAgentStateService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, ) { super(); - this.states.register(externalHooksStopHookContinuationUsedKey); + this.states.contributeState(externalHooksStopHookContinuationUsedKey); void this.sessionMetadata .read() .then((meta) => { @@ -188,14 +199,14 @@ export class AgentExternalHooksService extends Service implements IAgentExternal private registerPermissionHooks(): void { this._register( - this.eventBus.subscribe('permission.approval.requested', (e) => { - const { type: _type, ...inputData } = e; + this.eventBus.subscribe(PermissionApprovalRequested, (e) => { + const { type: _type, time: _time, ...inputData } = e; this.fireAndForget('PermissionRequest', inputData, e.toolName); }), ); this._register( - this.eventBus.subscribe('permission.approval.resolved', (e) => { - const { type: _type, ...inputData } = e; + this.eventBus.subscribe(PermissionApprovalResolved, (e) => { + const { type: _type, time: _time, ...inputData } = e; this.fireAndForget('PermissionResult', inputData, e.toolName); }), ); @@ -212,7 +223,7 @@ export class AgentExternalHooksService extends Service implements IAgentExternal }), ); this._register( - this.eventBus.subscribe('prompt.queued', (e) => { + this.eventBus.subscribe(PromptQueued, (e) => { this.fireAndForget( 'UserPromptQueued', { promptId: e.promptId, prompt: e.content, queueLength: e.queueLength }, @@ -224,14 +235,14 @@ export class AgentExternalHooksService extends Service implements IAgentExternal private registerTurnHooks(): void { this._register( - this.eventBus.subscribe('turn.started', (e) => this.notifyTurnStarted(e)), + this.eventBus.subscribe(TurnStarted, (e) => this.notifyTurnStarted(e)), ); this._register( - this.eventBus.subscribe('turn.ended', (e) => this.notifyTurnEnded(e)), + this.eventBus.subscribe(TurnEnded, (e) => this.notifyTurnEnded(e)), ); } - private notifyTurnStarted(event: TurnStartedEvent): void { + private notifyTurnStarted(event: TurnStarted): void { this.fireAndForget( 'TurnStarted', { @@ -291,13 +302,13 @@ export class AgentExternalHooksService extends Service implements IAgentExternal private registerTaskHooks(_tasks: IAgentTaskService): void { this._register( - this.eventBus.subscribe('task.notified', (e) => { - const { type: _type, ...ctx } = e; + this.eventBus.subscribe(TaskNotified, (e) => { + const { type: _type, time: _time, ...ctx } = e; this.notifyTaskNotification(ctx); }), ); this._register( - this.eventBus.subscribe('task.started', (e) => this.notifyTaskStarted(e.info)), + this.eventBus.subscribe(TaskStarted, (e) => this.notifyTaskStarted(e.info)), ); } @@ -374,12 +385,13 @@ export class AgentExternalHooksService extends Service implements IAgentExternal toolCalls: [], origin: { kind: 'hook_result', event: block.event, blocked: true }, }); - this.eventBus.publish({ - type: 'hook.result', - hookEvent: block.event, - content: block.message, - blocked: true, - }); + void this.dispatcher.dispatch( + new HookResult({ + hookEvent: block.event, + content: block.message, + blocked: true, + }), + ); return true; } @@ -391,16 +403,17 @@ export class AgentExternalHooksService extends Service implements IAgentExternal toolCalls: [], origin: { kind: 'hook_result', event: append.event }, }); - this.eventBus.publish({ - type: 'hook.result', - hookEvent: append.event, - content: append.message, - }); + void this.dispatcher.dispatch( + new HookResult({ + hookEvent: append.event, + content: append.message, + }), + ); } return false; } - private notifyTurnEnded(event: Pick): void { + private notifyTurnEnded(event: TurnEnded): void { this.stopHookContinuationUsed = false; if (event.reason === 'failed' && event.error !== undefined) { this.notifyStopFailure(event.error, new AbortController().signal); diff --git a/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts b/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts index a910cf7884a..7845afa364c 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts @@ -1,107 +1,134 @@ /** - * `fullCompaction` domain — wire Model (`CompactionModel`) and the - * `full_compaction.begin` (`fullCompactionBegin`) / `full_compaction.cancel` - * (`fullCompactionCancel`) / `full_compaction.complete` - * (`fullCompactionComplete`) Ops that mirror the full-compaction lifecycle into - * a persisted, replayable phase, plus the `compaction.*` edge events - * (`started` / `blocked` / `cancelled` / `completed`) declared on `DomainEventMap` - * (`compaction.started` is derived from the `full_compaction.begin` Op's - * `toEvent`; the rest publish directly from the service). + * `fullCompaction` domain — the `fullCompactionKey` state, the durable + * `full_compaction.begin` (`FullCompactionBegin`) / `full_compaction.cancel` + * (`FullCompactionCancel`) / `full_compaction.complete` + * (`FullCompactionComplete`) events that mirror the full-compaction lifecycle + * into a persisted, replayable phase, plus the live-only `compaction.*` + * observables (`CompactionStarted` / `CompactionBlocked` / + * `CompactionCancelled` / `CompactionCompleted`). * - * The Model is intentionally phase-only — `{ phase }` (initial `idle`). The + * The state is intentionally phase-only — `{ phase }` (initial `idle`). The * richer per-compaction data is NOT resume state: `instruction` is only needed * by the live worker (which does not survive a restart) and by telemetry, so it * rides the `begin` payload (and is persisted on the record for audit) but is - * not stored in the Model; result numbers are consumed live by the - * `compaction.completed` signal and their durable effect (the summary message + * not stored in the state; result numbers are consumed live by the + * `CompactionCompleted` signal and their durable effect (the summary message * plus compaction metrics) already lives in the context history. The live * `complete` payload is empty to match the v1 wire shape; legacy logs may still - * carry result numbers, and `apply` accepts and ignores them while collapsing - * to `idle`. Each `apply` returns the same reference on a no-op so the wire's - * reference-equality gate stays quiet; it carries no non-determinism. + * carry result numbers, and the replay schema parse accepts and strips them + * while the fold collapses to `idle`. Each fold keeps the same reference on a + * no-op so the state's reference-equality stays quiet; it carries no + * non-determinism. The durable classes are the wire-protocol record + * vocabulary: their `serialize()` output is the on-disk record (flat payload, + * epoch-ms `time`), byte-compatible with the retired op encoding. * * The runtime orchestration — `ActiveCompaction`, its `AbortController`, and - * the in-flight worker promise — stays OUT of the Model (live-only service + * the in-flight worker promise — stays OUT of the state (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.hooks.onDidRestore` hook. + * `dispatcher.hooks.onDidRestore` hook. * - * 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. The `full_compaction.*` record shapes are registered in - * `PersistedOpMap` (below) because the records still - * ride the per-agent `wire.jsonl` journal restored by `IWireService`. + * The `compaction.*` observables are transient: `CompactionStarted` is emitted + * from the `FullCompactionBegin` fold via `ctx.emit` (live only, reading the + * event payload like the retired `toEvent`); the rest are dispatched directly + * by the service. Replay never emits them. */ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ + import { z } from 'zod'; -import { defineModel } from '#/wire/model'; +import { Event2 } from '#/app/event/event2'; +import { defineState } from '#/state/state'; -import type { CompactionBeginData, CompactionResult } from './types'; +import type { CompactionBeginData, CompactionResult, CompactionSource } from './types'; -export interface CompactionStartedEvent { - readonly type: 'compaction.started'; - readonly trigger: 'manual' | 'auto'; - readonly instruction?: string; +export type CompactionPhase = 'idle' | 'running' | 'cancelled' | 'completed'; + +export interface CompactionState { + readonly phase: CompactionPhase; } -export interface CompactionBlockedEvent { - readonly type: 'compaction.blocked'; - readonly turnId?: number; +const fullCompactionBeginSchema = z.custom(); + +export class FullCompactionBegin extends Event2> { + static override readonly type = 'full_compaction.begin'; + static override readonly durable = true; + static override readonly schema = fullCompactionBeginSchema; } +export interface FullCompactionBegin extends z.infer {} + +const fullCompactionCancelSchema = z.object({}); -export interface CompactionCancelledEvent { - readonly type: 'compaction.cancelled'; +export class FullCompactionCancel extends Event2> { + static override readonly type = 'full_compaction.cancel'; + static override readonly durable = true; + static override readonly schema = fullCompactionCancelSchema; } +export interface FullCompactionCancel extends z.infer {} -export interface CompactionCompletedEvent { - readonly type: 'compaction.completed'; - readonly result: CompactionResult; +const fullCompactionCompleteSchema = z.object({}); + +export class FullCompactionComplete extends Event2> { + static override readonly type = 'full_compaction.complete'; + static override readonly durable = true; + static override readonly schema = fullCompactionCompleteSchema; } +export interface FullCompactionComplete extends z.infer {} -export type CompactionPhase = 'idle' | 'running' | 'cancelled' | 'completed'; +export interface CompactionStartedPayload { + readonly trigger: CompactionSource; + readonly instruction?: string; +} -export interface CompactionState { - readonly phase: CompactionPhase; +export class CompactionStarted extends Event2 { + static override readonly type = 'compaction.started'; + static override readonly observable = true; +} +export interface CompactionStarted extends CompactionStartedPayload {} + +export interface CompactionBlockedPayload { + readonly turnId?: number; +} + +export class CompactionBlocked extends Event2 { + static override readonly type = 'compaction.blocked'; + static override readonly observable = true; } +export interface CompactionBlocked extends CompactionBlockedPayload {} -export const CompactionModel = defineModel('fullCompaction', () => ({ - phase: 'idle', -})); - -declare module '#/app/event/eventBus' { - interface DomainEventMap { - 'compaction.started': CompactionStartedEvent; - 'compaction.blocked': CompactionBlockedEvent; - 'compaction.cancelled': CompactionCancelledEvent; - 'compaction.completed': CompactionCompletedEvent; - } +export class CompactionCancelled extends Event2> { + static override readonly type = 'compaction.cancelled'; + static override readonly observable = true; +} + +export interface CompactionCompletedPayload { + readonly result: CompactionResult; } -declare module '#/wire/types' { - interface PersistedOpMap { - 'full_compaction.begin': typeof fullCompactionBegin; - 'full_compaction.cancel': typeof fullCompactionCancel; - 'full_compaction.complete': typeof fullCompactionComplete; - } +export class CompactionCompleted extends Event2 { + static override readonly type = 'compaction.completed'; + static override readonly observable = true; } +export interface CompactionCompleted extends CompactionCompletedPayload {} -export const fullCompactionBegin = CompactionModel.defineOp('full_compaction.begin', { - schema: z.custom(), - apply: (s) => (s.phase === 'running' ? s : { phase: 'running' }), - toEvent: (p) => ({ - type: 'compaction.started' as const, - trigger: p.source, - instruction: p.instruction, - }), -}); - -export const fullCompactionCancel = CompactionModel.defineOp('full_compaction.cancel', { - schema: z.object({}), - apply: (s) => (s.phase === 'idle' ? s : { phase: 'idle' }), -}); - -export const fullCompactionComplete = CompactionModel.defineOp('full_compaction.complete', { - schema: z.object({}), - apply: (s) => (s.phase === 'idle' ? s : { phase: 'idle' }), -}); +export const fullCompactionKey = defineState( + 'fullCompaction', + (): CompactionState => ({ phase: 'idle' }), +).replayable({ schema: z.custom() }) + .on(FullCompactionBegin, (s, e, ctx) => { + if (s.phase !== 'running') { + s.phase = 'running'; + } + ctx.emit(new CompactionStarted({ trigger: e.source, instruction: e.instruction })); + }) + .on(FullCompactionCancel, (s) => { + if (s.phase !== 'idle') { + s.phase = 'idle'; + } + }) + .on(FullCompactionComplete, (s) => { + if (s.phase !== 'idle') { + s.phase = 'idle'; + } + }); diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 1f356cc19df..96dd6587d77 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -23,7 +23,7 @@ import { Service } from "#/_base/di/service"; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; -import { defineState } from '#/_base/state/stateRegistry'; +import { defineState } from '#/state/state'; import { renderPrompt } from "#/_base/utils/render-prompt"; import { estimateTokensForMessage } from "#/kosong/contract/tokens"; import { buildCompactionSummaryText, isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; @@ -34,6 +34,8 @@ import { IAgentLLMRequesterService, type AgentLLMRequestFinish } from '#/agent/l import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry'; import { IAgentLoopService, type LoopErrorContext } from '#/agent/loop/loop'; +import { TurnStarted } from '#/agent/loop/turnEvents'; +import { TurnEnded } from '#/agent/loop/turnOps'; import { isAbortError } from '#/_base/utils/abort'; import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile'; import { IAgentStateService } from '#/agent/state/agentState'; @@ -55,7 +57,8 @@ import { IEventBus } from '#/app/event/eventBus'; import type { CompactionFailedEvent, CompactionFinishedEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2, isCodedError, isError2, toKimiErrorPayload, unwrapErrorCause } from "#/errors"; -import { IWireService } from '#/wire/wire'; +import { AgentErrorEvent } from '#/agent/mcp/mcpEvents'; +import { IEventDispatcher } from '#/state/eventDispatcher'; import compactionInstructionTemplate from './compaction-instruction.md?raw'; import { IAgentFullCompactionService, @@ -67,10 +70,13 @@ import { type CompactionStrategy, } from './strategy'; import { - CompactionModel, - fullCompactionBegin, - fullCompactionCancel, - fullCompactionComplete, + CompactionBlocked, + CompactionCancelled, + CompactionCompleted, + fullCompactionKey, + FullCompactionBegin, + FullCompactionCancel, + FullCompactionComplete, } from './compactionOps'; import { type CompactionBeginData, @@ -156,33 +162,34 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom @IAgentToolSelectService private readonly toolSelect: IAgentToolSelectService, @ISessionTodoService private readonly todo: ISessionTodoService, @ITelemetryService private readonly telemetry: ITelemetryService, - @IWireService private readonly wire: IWireService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IEventBus private readonly eventBus: IEventBus, @ILogService private readonly log: ILogService, @IAgentLoopService private readonly loopService: IAgentLoopService, @IAgentStateService private readonly states: IAgentStateService, ) { super(); - this.states.register(fullCompactionCompactionCountInTurnKey); - this.states.register(fullCompactionObservedMaxContextTokensByModelKey); - this.states.register(fullCompactionLastCompactedTokenCountKey); - this.states.register(fullCompactionConsecutiveOverflowCompactionsKey); - this.states.register(fullCompactionActiveTurnIdKey); + this.states.contributeState(fullCompactionKey); + this.states.contributeState(fullCompactionCompactionCountInTurnKey); + this.states.contributeState(fullCompactionObservedMaxContextTokensByModelKey); + this.states.contributeState(fullCompactionLastCompactedTokenCountKey); + this.states.contributeState(fullCompactionConsecutiveOverflowCompactionsKey); + this.states.contributeState(fullCompactionActiveTurnIdKey); this.strategy = new RuntimeCompactionStrategy( () => this.resolveModelContextWithEffectiveMax(), (message) => this.tokenCounting.estimateMessage(message), ); this._register( - this.wire.hooks.onDidRestore.register('full-compaction', async (_ctx, next) => { + this.dispatcher.hooks.onDidRestore.register('full-compaction', async (_ctx, next) => { this.normalizeAfterReplay(); await next(); }), ); this._register( - this.eventBus.subscribe('turn.started', () => this.resetForTurn()), + this.eventBus.subscribe(TurnStarted, () => this.resetForTurn()), ); this._register( - this.eventBus.subscribe('turn.ended', () => { + this.eventBus.subscribe(TurnEnded, () => { this.activeTurnId = undefined; }), ); @@ -349,7 +356,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom ); } try { - this.wire.dispatch(fullCompactionBegin(data)); + void this.dispatcher.dispatch(new FullCompactionBegin(data)); const active = this.createActiveCompaction( data.source, @@ -439,25 +446,25 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom private cancelActive(active: ActiveCompaction): boolean { if (this._compacting !== active) return false; - this.wire.dispatch(fullCompactionCancel({})); + void this.dispatcher.dispatch(new FullCompactionCancel({})); this._compacting = null; if (!active.abortController.signal.aborted) { active.abortController.abort(); } - this.eventBus.publish({ type: 'compaction.cancelled' }); + void this.dispatcher.dispatch(new CompactionCancelled({})); return true; } private markCompleted(active: ActiveCompaction): boolean { if (this._compacting !== active) return false; - this.wire.dispatch(fullCompactionComplete({})); + void this.dispatcher.dispatch(new FullCompactionComplete({})); this._compacting = null; return true; } private normalizeAfterReplay(): void { - if (this.wire.getModel(CompactionModel).phase !== 'running') return; - this.wire.dispatch(fullCompactionCancel({})); + if (this.states.get(fullCompactionKey).phase !== 'running') return; + void this.dispatcher.dispatch(new FullCompactionCancel({})); } private resetForTurn(): void { @@ -542,7 +549,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom if (active === null) return; active.blockedByTurn = true; this.propagateBlockingAbort(active, signal); - this.eventBus.publish({ type: 'compaction.blocked', turnId }); + void this.dispatcher.dispatch(new CompactionBlocked({ turnId })); try { await active.promise; } catch (error) { @@ -590,7 +597,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom } const { contextSummary: _contextSummary, ...eventResult } = result; void _contextSummary; - this.eventBus.publish({ type: 'compaction.completed', result: eventResult }); + void this.dispatcher.dispatch(new CompactionCompleted({ result: eventResult })); return result; } catch (error) { if (active.abortController.signal.aborted || isAbortError(error)) { @@ -604,10 +611,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom if (blockedByTurn) { throw error; } - this.eventBus.publish({ - type: 'error', - ...toKimiErrorPayload(error), - }); + void this.dispatcher.dispatch(new AgentErrorEvent(toKimiErrorPayload(error))); throw error; } finally { try { diff --git a/packages/agent-core-v2/src/agent/goal/goalOps.ts b/packages/agent-core-v2/src/agent/goal/goalOps.ts index e623d27aad7..454e06c1b59 100644 --- a/packages/agent-core-v2/src/agent/goal/goalOps.ts +++ b/packages/agent-core-v2/src/agent/goal/goalOps.ts @@ -1,34 +1,39 @@ /** - * `goal` domain — wire Model (`GoalModel`) and the `goal.create` - * (`createGoal`) / `goal.update` (`updateGoal`) / `goal.clear` (`clearGoal`) - * Ops for the per-agent goal lifecycle. + * `goal` domain — the `goalKey` state, the durable `goal.create` + * (`GoalCreate`) / `goal.update` (`GoalUpdate`) / `goal.clear` (`GoalClear`) / + * `forked` (`GoalForked`) events for the per-agent goal lifecycle, and the + * live-only `goal.updated` observable (`GoalUpdated`). * - * Declares the current goal as `GoalState | null` (initial `null`); `GoalState` - * holds the persistent, replayable fields — identity, objective, status, - * `turnsUsed` / `tokensUsed`, the accumulated `wallClockMs`, the current - * active interval's epoch-ms `wallClockResumedAt`, `budgetLimits`, and - * `terminalReason`. The persistence contract charges an active interval from - * its persisted create/resume anchor through the first recovery clock read, - * then folds that interval into `wallClockMs` while recovery pauses the goal. - * This intentionally includes unobservable crash downtime: a monotonic clock + * The state holds `GoalState | null` (initial `null`); `GoalState` holds the + * persistent, replayable fields — identity, objective, status, `turnsUsed` / + * `tokensUsed`, the accumulated `wallClockMs`, the current active interval's + * epoch-ms `wallClockResumedAt`, `budgetLimits`, and `terminalReason`. The + * persistence contract charges an active interval from its persisted + * create/resume anchor through the first recovery clock read, then folds that + * interval into `wallClockMs` while recovery pauses the goal. This + * intentionally includes unobservable crash downtime: a monotonic clock * cannot span processes, while learning the crash instant would require * periodic durable writes. System-clock rollback is clamped to zero. The * 1.4 -> 1.5 compatibility transform (also applied before sealing * envelope-less logs) derives missing create/resume/checkpoint anchors from - * those records' existing epoch-ms `time` stamps. The - * non-deterministic values stay OUT of `apply`: `goalId` and the wall-clock - * anchor/totals are computed by the live service and carried in Op payloads. - * 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.restore` rebuilds the Model silently and the - * service's `wire.hooks.onDidRestore` + * those records' existing epoch-ms `time` stamps. The durable classes are the + * wire-protocol record vocabulary: their `serialize()` output is the on-disk + * record (flat payload, epoch-ms `time`), byte-compatible with the retired op + * encoding. The non-deterministic values stay OUT of the folds: `goalId` and + * the wall-clock anchor/totals are computed by the live service and carried + * in event payloads. Each fold keeps the same reference when nothing changes + * so the state's reference-equality stays quiet. The `GoalUpdated` fact is + * dispatched live by the service (observable, never on replay); restore + * rebuilds the state silently and the service's `dispatcher.hooks.onDidRestore` * forces a replayed `active` goal back to `paused`. */ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ + import { z } from 'zod'; -import { defineModel } from '#/wire/model'; +import { Event2 } from '#/app/event/event2'; +import { defineState } from '#/state/state'; import type { GoalBudgetLimits, @@ -52,8 +57,6 @@ export interface GoalState { export type GoalModelState = GoalState | null; -export const GoalModel = defineModel('goal', () => null); - const GoalStatusSchema = z.enum(['active', 'paused', 'blocked', 'complete']); const GoalActorSchema = z.enum(['user', 'model', 'runtime', 'system']); @@ -66,104 +69,115 @@ const GoalBudgetLimitsSchema = z }) .strict(); -declare module '#/app/event/eventBus' { - interface DomainEventMap { - 'goal.updated': { - snapshot: GoalSnapshot | null; - change?: GoalChange; - }; - } +const goalCreateSchema = z + .object({ + goalId: z.string(), + objective: z.string(), + completionCriterion: z.string().optional(), + wallClockResumedAt: z.number().finite().nonnegative().optional(), + status: GoalStatusSchema.optional(), + actor: GoalActorSchema.optional(), + budgetLimits: GoalBudgetLimitsSchema.optional(), + }) + .strip(); + +export class GoalCreate extends Event2> { + static override readonly type = 'goal.create'; + static override readonly durable = true; + static override readonly schema = goalCreateSchema; } +export interface GoalCreate extends z.infer {} -declare module '#/wire/types' { - interface PersistedOpMap { - 'goal.create': typeof createGoal; - 'goal.update': typeof updateGoal; - 'goal.clear': typeof clearGoal; - forked: typeof forkGoal; - } +const goalUpdateSchema = z + .object({ + goalId: z.string().optional(), + status: GoalStatusSchema.optional(), + reason: z.string().optional(), + turnsUsed: z.number().finite().nonnegative().optional(), + tokensUsed: z.number().finite().nonnegative().optional(), + wallClockMs: z.number().finite().nonnegative().optional(), + wallClockResumedAt: z.number().finite().nonnegative().optional(), + budgetLimits: GoalBudgetLimitsSchema.optional(), + actor: GoalActorSchema.optional(), + }) + .strip(); + +export class GoalUpdate extends Event2> { + static override readonly type = 'goal.update'; + static override readonly durable = true; + static override readonly schema = goalUpdateSchema; } +export interface GoalUpdate extends z.infer {} -export const createGoal = GoalModel.defineOp('goal.create', { - schema: z - .object({ - goalId: z.string(), - objective: z.string(), - completionCriterion: z.string().optional(), - wallClockResumedAt: z.number().finite().nonnegative().optional(), - status: GoalStatusSchema.optional(), - actor: GoalActorSchema.optional(), - budgetLimits: GoalBudgetLimitsSchema.optional(), - }) - .strip(), - apply: (_s, p) => ({ - goalId: p.goalId, - objective: p.objective, - completionCriterion: p.completionCriterion, - status: 'active', +const goalClearSchema = z.object({}); + +export class GoalClear extends Event2> { + static override readonly type = 'goal.clear'; + static override readonly durable = true; + static override readonly schema = goalClearSchema; +} +export interface GoalClear extends z.infer {} + +const goalForkedSchema = z.object({}); + +export class GoalForked extends Event2> { + static override readonly type = 'forked'; + static override readonly durable = true; + static override readonly schema = goalForkedSchema; +} +export interface GoalForked extends z.infer {} + +export interface GoalUpdatedPayload { + snapshot: GoalSnapshot | null; + change?: GoalChange; +} + +export class GoalUpdated extends Event2 { + static override readonly type = 'goal.updated'; + static override readonly observable = true; +} +export interface GoalUpdated extends GoalUpdatedPayload {} + +export const goalKey = defineState('goal', (): GoalModelState => null).replayable({ + schema: z.custom(), +}) + .on(GoalCreate, (_s, e) => ({ + goalId: e.goalId, + objective: e.objective, + completionCriterion: e.completionCriterion, + status: 'active' as const, turnsUsed: 0, tokensUsed: 0, wallClockMs: 0, - wallClockResumedAt: p.wallClockResumedAt, + wallClockResumedAt: e.wallClockResumedAt, budgetLimits: {}, - }), -}); - -export const updateGoal = GoalModel.defineOp('goal.update', { - schema: z - .object({ - goalId: z.string().optional(), - status: GoalStatusSchema.optional(), - reason: z.string().optional(), - turnsUsed: z.number().finite().nonnegative().optional(), - tokensUsed: z.number().finite().nonnegative().optional(), - wallClockMs: z.number().finite().nonnegative().optional(), - wallClockResumedAt: z.number().finite().nonnegative().optional(), - budgetLimits: GoalBudgetLimitsSchema.optional(), - actor: GoalActorSchema.optional(), - }) - .strip(), - apply: (s, p) => { - if (s === null) return null; - let next: GoalState | undefined; - if (p.status !== undefined && p.status !== s.status) { - next = { - ...(next ?? s), - status: p.status, - terminalReason: p.status === 'active' ? undefined : p.reason, - wallClockResumedAt: - p.status === 'active' ? p.wallClockResumedAt : undefined, - }; + })) + .on(GoalUpdate, (s, e) => { + if (s === null) return; + if (e.status !== undefined && e.status !== s.status) { + s.status = e.status; + s.terminalReason = e.status === 'active' ? undefined : e.reason; + s.wallClockResumedAt = e.status === 'active' ? e.wallClockResumedAt : undefined; } - if (p.turnsUsed !== undefined && p.turnsUsed !== s.turnsUsed) { - next = { ...(next ?? s), turnsUsed: p.turnsUsed }; + if (e.turnsUsed !== undefined && e.turnsUsed !== s.turnsUsed) { + s.turnsUsed = e.turnsUsed; } - if (p.tokensUsed !== undefined && p.tokensUsed !== s.tokensUsed) { - next = { ...(next ?? s), tokensUsed: p.tokensUsed }; + if (e.tokensUsed !== undefined && e.tokensUsed !== s.tokensUsed) { + s.tokensUsed = e.tokensUsed; } - if (p.wallClockMs !== undefined && p.wallClockMs !== s.wallClockMs) { - next = { ...(next ?? s), wallClockMs: p.wallClockMs }; + if (e.wallClockMs !== undefined && e.wallClockMs !== s.wallClockMs) { + s.wallClockMs = e.wallClockMs; } if ( - p.wallClockResumedAt !== undefined && - (p.status ?? s.status) === 'active' && - p.wallClockResumedAt !== s.wallClockResumedAt + e.wallClockResumedAt !== undefined && + (e.status ?? s.status) === 'active' && + e.wallClockResumedAt !== s.wallClockResumedAt ) { - next = { ...(next ?? s), wallClockResumedAt: p.wallClockResumedAt }; + s.wallClockResumedAt = e.wallClockResumedAt; } - if (p.budgetLimits !== undefined && p.budgetLimits !== s.budgetLimits) { - next = { ...(next ?? s), budgetLimits: p.budgetLimits }; + if (e.budgetLimits !== undefined && e.budgetLimits !== s.budgetLimits) { + s.budgetLimits = e.budgetLimits; } - return next ?? s; - }, -}); - -export const clearGoal = GoalModel.defineOp('goal.clear', { - schema: z.object({}), - apply: () => null, -}); - -export const forkGoal = GoalModel.defineOp('forked', { - schema: z.object({}), - apply: () => null, -}); + }) + .on(GoalClear, () => null) + .on(GoalForked, () => null); diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 375bf08cc54..27ff9ceb1a2 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -1,17 +1,18 @@ /** * `goal` domain — `IAgentGoalService` implementation. * - * Owns the main-agent goal lifecycle; persists the goal in the `wire` - * `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.hooks.onDidRestore`. The accumulated - * `wallClockMs` lives in the Model (set from each Op payload, never by - * `Date.now()` inside `apply`); the active interval's epoch-ms + * Owns the main-agent goal lifecycle; persists the goal in the `goalKey` + * state (`GoalState | null`) through the durable `GoalCreate` / `GoalUpdate` / + * `GoalClear` events (`dispatcher.dispatch`), reads it through + * `dispatcher.getState`, dispatches the live-only `GoalUpdated` observable + * through the same dispatcher, and forces a replayed `active` + * goal back to `paused` via `dispatcher.hooks.onDidRestore`. The accumulated + * `wallClockMs` lives in the state (set from each event payload, never by + * `Date.now()` inside a fold); the active interval's epoch-ms * `wallClockResumedAt` anchor is * persisted at create/resume boundaries so recovery can settle crash-spanning - * elapsed time without periodic writes. A `forked` wire Op clears the Model - * at a fork boundary. Injects reminders through + * elapsed time without periodic writes. A `forked` journal record (written at + * a fork boundary) clears the state. Injects reminders through * `contextInjector`, drives continuation turns by enqueueing `newTurn` * `StepRequest`s onto `loop` (the continuation message materializes when the * loop pops it), accounts live @@ -40,14 +41,18 @@ import { randomUUID } from 'node:crypto'; -import type { TurnEndedEvent, TurnStartedEvent } from '#/agent/loop/turnEvents'; +import { z } from 'zod'; + +import { TurnStarted } from '#/agent/loop/turnEvents'; +import { TurnEnded } from '#/agent/loop/turnOps'; import { Disposable, MutableDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; + import { abortError } from '#/_base/utils/abort'; import { isPlainRecord } from '#/_base/utils/canonical-args'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { ContextAppendMessage } from '#/agent/contextMemory/contextEvents'; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; import { GoalInjection } from '#/agent/goal/injection/goalInjection'; import { @@ -78,13 +83,21 @@ import { toKimiErrorPayload, type KimiErrorPayload, } from '#/errors'; -import { IWireService } from '#/wire/wire'; -import { defineModel } from '#/wire/model'; import { IEventBus } from '#/app/event/eventBus'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { defineState } from '#/state/state'; import { IAgentGoalService, type GoalReasonInput, type ResumeGoalInput } from './goal'; import { IGoalDeadlineScheduler } from './goalDeadlineScheduler'; -import { clearGoal, createGoal, GoalModel, updateGoal, type GoalState } from './goalOps'; +import { + GoalClear, + GoalCreate, + GoalForked, + goalKey, + GoalUpdate, + GoalUpdated, + type GoalState, +} from './goalOps'; import type { CreateGoalInput, GoalActor, @@ -198,24 +211,25 @@ interface ResumeContinuation { readonly goalId: string; } -const GoalForkNoticeModel = defineModel( +export const goalForkNoticeKey = defineState( 'goalForkNotice', - () => ({ goalPresent: false, reminderPending: false }), - { - 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, - }, - }, -); + (): GoalForkNoticeState => ({ goalPresent: false, reminderPending: false }), +).replayable({ schema: z.custom() }) + .on(GoalCreate, (s) => { + s.goalPresent = true; + }) + .on(GoalClear, (s) => { + s.goalPresent = false; + }) + .on(GoalForked, (s) => { + s.reminderPending = s.goalPresent || s.reminderPending; + s.goalPresent = false; + }) + .on(ContextAppendMessage, (s, e) => { + if (s.reminderPending && isGoalForkClearedReminder(e.message)) { + s.reminderPending = false; + } + }); function isGoalForkClearedReminder(message: ContextMessage | undefined): boolean { const origin = message?.origin; @@ -223,7 +237,7 @@ function isGoalForkClearedReminder(message: ContextMessage | undefined): boolean return origin?.kind === 'system_trigger' && origin.name === GOAL_FORK_CLEARED_REMINDER_NAME; } -function isGoalContinuationOrigin(origin: TurnStartedEvent['origin']): boolean { +function isGoalContinuationOrigin(origin: TurnStarted['origin']): boolean { return origin.kind === 'system_trigger' && origin.name === 'goal_continuation'; } @@ -284,7 +298,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private pendingContinuation?: PendingContinuation; constructor( - @IWireService private readonly wire: IWireService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IEventBus private readonly eventBus: IEventBus, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @ITelemetryService private readonly telemetry: ITelemetryService, @@ -300,18 +314,20 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { @IAgentStateService private readonly states: IAgentStateService, ) { super(); - this.states.register(goalLiveTurnIdKey); - this.states.register(goalGoalDrivenTurnsKey); - this.states.register(goalCountedGoalTurnsKey); - this.states.register(goalGoalStarterTurnsKey); - this.states.register(goalGoalOutcomeToolResultTurnsKey); - this.states.register(goalGoalOutcomeContinuationTurnsKey); - this.states.register(goalBudgetGraceTurnsKey); - this.states.register(goalPendingContinuationGoalsKey); - this.states.register(goalGoalTurnTargetsKey); - this.states.register(goalExhaustedTurnBudgetGoalsKey); - this.states.register(goalLiveWallClockStartedAtKey); - this.states.register(goalResumeContinuationKey); + this.states.contributeState(goalKey); + this.states.contributeState(goalForkNoticeKey); + this.states.contributeState(goalLiveTurnIdKey); + this.states.contributeState(goalGoalDrivenTurnsKey); + this.states.contributeState(goalCountedGoalTurnsKey); + this.states.contributeState(goalGoalStarterTurnsKey); + this.states.contributeState(goalGoalOutcomeToolResultTurnsKey); + this.states.contributeState(goalGoalOutcomeContinuationTurnsKey); + this.states.contributeState(goalBudgetGraceTurnsKey); + this.states.contributeState(goalPendingContinuationGoalsKey); + this.states.contributeState(goalGoalTurnTargetsKey); + this.states.contributeState(goalExhaustedTurnBudgetGoalsKey); + this.states.contributeState(goalLiveWallClockStartedAtKey); + this.states.contributeState(goalResumeContinuationKey); if (!this.isSupportedAgent) return; this._register( new GoalInjection( @@ -322,13 +338,13 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { ), ); this._register( - this.wire.hooks.onDidRestore.register('goal', async (_ctx, next) => { + this.dispatcher.hooks.onDidRestore.register('goal', async (_ctx, next) => { this.normalizeAfterReplay(); await next(); }), ); this._register( - this.eventBus.subscribe('turn.started', (e) => { + this.eventBus.subscribe(TurnStarted, (e) => { this.handleTurnLaunched(e.turnId, e.origin); }), ); @@ -399,7 +415,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { }), ); this._register( - this.eventBus.subscribe('turn.ended', (e) => { + this.eventBus.subscribe(TurnEnded, (e) => { const goalId = this.goalTurnTarget(e.turnId); void this.handleTurnEnded(e.turnId, { reason: e.reason, error: e.error }).catch((error) => this.settleGoalAfterContinuationFailure(error, goalId), @@ -482,7 +498,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } private get goalState(): GoalState | null { - return this.wire.getModel(GoalModel) as GoalState | null; + return this.states.get(goalKey); } getGoal(): GoalToolResult { @@ -501,8 +517,8 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { const objective = this.validateObjective(input.objective); this.prepareForGoalCreation(input.replace === true); const wallClockResumedAt = Date.now(); - this.wire.dispatch( - createGoal({ + void this.dispatcher.dispatch( + new GoalCreate({ goalId: randomUUID(), objective, completionCriterion: normalizeCompletionCriterion(input.completionCriterion), @@ -605,7 +621,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { this.assertSupportedAgent(); const state = this.requireState(); const budgetLimits = { ...state.budgetLimits, ...input.budgetLimits }; - this.wire.dispatch(updateGoal({ budgetLimits })); + void this.dispatcher.dispatch(new GoalUpdate({ budgetLimits })); const next = this.requireState(); this.emitGoalUpdated(this.toSnapshot(next)); this.telemetry.track2('goal_budget_set', { @@ -666,7 +682,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private dispatchCompletion(state: GoalState, reason: string | undefined, actor: GoalActor): void { const wallClockMs = this.settleWallClock(state); - this.wire.dispatch(updateGoal({ status: 'complete', reason, wallClockMs, actor })); + void this.dispatcher.dispatch(new GoalUpdate({ status: 'complete', reason, wallClockMs, actor })); } private emitCompletion( @@ -698,7 +714,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { const state = this.goalState; if (state === null || state.status !== 'active' || !matchesGoal(state, goalId)) return null; const tokensUsed = state.tokensUsed + Math.max(0, tokenDelta); - this.wire.dispatch(updateGoal({ tokensUsed })); + void this.dispatcher.dispatch(new GoalUpdate({ tokensUsed })); const next = this.requireState(); return this.blockIfBudgetReached(next) ?? this.toSnapshot(next); } @@ -712,14 +728,14 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { const state = this.goalState; if (state === null || state.status !== 'active' || !matchesGoal(state, goalId)) return null; const turnsUsed = state.turnsUsed + 1; - this.wire.dispatch(updateGoal({ turnsUsed })); + void this.dispatcher.dispatch(new GoalUpdate({ turnsUsed })); const next = this.requireState(); this.emitGoalUpdated(this.toSnapshot(next)); this.telemetry.track2('goal_continued', { turns_used: next.turnsUsed }); return this.toSnapshot(next); } - private handleTurnLaunched(turnId: number, origin: TurnStartedEvent['origin']): void { + private handleTurnLaunched(turnId: number, origin: TurnStarted['origin']): void { this.liveTurnId = turnId; this.goalTurnTargets.delete(turnId); this.exhaustedTurnBudgetGoals.delete(turnId); @@ -831,7 +847,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private async handleTurnEnded( turnId: number, - result: Pick, + result: Pick, ): Promise { const { goalId, lifecycleGoalId, starterTurn } = this.clearTurnTracking(turnId); const resumeContinuation = this.resumeContinuation; @@ -888,7 +904,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } private async settleAbnormalTurn( - result: Pick, + result: Pick, goalId: string, ): Promise { if (!this.isActiveGoal(goalId)) return false; @@ -1006,8 +1022,8 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { if (state.status !== 'active') return; const reason = 'Paused after agent resume'; - this.wire.dispatch( - updateGoal({ + void this.dispatcher.dispatch( + new GoalUpdate({ status: 'paused', reason, wallClockMs: this.settleWallClock(state), @@ -1018,7 +1034,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } private appendForkClearedReminder(): void { - if (!this.wire.getModel(GoalForkNoticeModel).reminderPending) return; + if (!this.states.get(goalForkNoticeKey).reminderPending) return; this.reminders.appendSystemReminder(GOAL_FORK_CLEARED_REMINDER, { kind: 'injection', variant: GOAL_FORK_CLEARED_REMINDER_NAME, @@ -1034,7 +1050,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { this.cancelPendingContinuation(opts.preserveLiveContinuation === true); this.wallClockDeadline.clear(); this.liveWallClockStartedAt = undefined; - this.wire.dispatch(clearGoal({})); + void this.dispatcher.dispatch(new GoalClear({})); if (opts.emit !== false) this.emitGoalUpdated(null); if (opts.track !== false) this.telemetry.track2('goal_cleared', { actor }); } @@ -1062,8 +1078,8 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { this.wallClockDeadline.clear(); this.liveWallClockStartedAt = undefined; } - this.wire.dispatch( - updateGoal({ status, reason, wallClockMs, wallClockResumedAt, actor }), + void this.dispatcher.dispatch( + new GoalUpdate({ status, reason, wallClockMs, wallClockResumedAt, actor }), ); const next = this.requireState(); if (status === 'active') this.adoptStarterTurn(actor); @@ -1093,7 +1109,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } private emitGoalUpdated(snapshot: GoalSnapshot | null, change?: GoalChange): void { - this.eventBus.publish({ type: 'goal.updated', snapshot, change }); + void this.dispatcher.dispatch(new GoalUpdated({ snapshot, change })); } private settleWallClock(state: GoalState): number { @@ -1278,7 +1294,7 @@ function isTerminalUpdateGoalResult( return status === 'complete' || status === 'blocked'; } -function isMaxStepsTurnFailure(result: Pick): boolean { +function isMaxStepsTurnFailure(result: Pick): boolean { return ( result.reason === 'failed' && normalizeGoalErrorPayload(result.error).code === LoopErrors.codes.LOOP_MAX_STEPS_EXCEEDED diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts index ae12da6f0e1..2c6de9d07bc 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts @@ -1,35 +1,41 @@ /** - * `interruptionReminder` domain — legacy wire compatibility tombstone. + * `interruptionReminder` domain — legacy journal compatibility tombstone. * - * Retains the historical `interruptionReminder.recorded` Op as a no-op so old - * Agent journals replay without unknown-record diagnostics. New interruption - * reminders append at the cancellation event point and write no domain-owned - * delivery state. Scope-agnostic. + * Retains the historical `interruptionReminder.recorded` durable event as a + * no-op fold on a `null` state so old Agent journals replay without + * unknown-record diagnostics. New interruption reminders append at the + * cancellation event point and write no domain-owned delivery state. + * Scope-agnostic. */ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ + import { z } from 'zod'; -import { defineModel } from '#/wire/model'; +import { Event2 } from '#/app/event/event2'; +import { defineState } from '#/state/state'; export const INTERRUPTION_REMINDER_VARIANT = 'interruption'; export type InterruptionReminderState = null; -export const InterruptionReminderModel = defineModel( - 'interruptionReminder', - () => null, -); +const interruptionReminderRecordedSchema = z.object({ + turnId: z.number().int().nonnegative(), +}); -declare module '#/wire/types' { - interface PersistedOpMap { - 'interruptionReminder.recorded': typeof interruptionReminderRecorded; - } +export class InterruptionReminderRecorded extends Event2< + z.infer +> { + static override readonly type = 'interruptionReminder.recorded'; + static override readonly durable = true; + static override readonly schema = interruptionReminderRecordedSchema; } +export interface InterruptionReminderRecorded + extends z.infer {} -export const interruptionReminderRecorded = InterruptionReminderModel.defineOp( - 'interruptionReminder.recorded', - { - schema: z.object({ turnId: z.number().int().nonnegative() }), - apply: (state) => state, - }, -); +export const interruptionReminderKey = defineState( + 'interruptionReminder', + (): InterruptionReminderState => null, +) + .replayable({ schema: z.custom() }) + .on(InterruptionReminderRecorded, () => {}); diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts index 4fa1f48aa03..1466db7ab56 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts @@ -12,11 +12,13 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; +import { TurnEnded } from '#/agent/loop/turnOps'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IAgentStateService } from '#/agent/state/agentState'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentInterruptionReminderService } from './interruptionReminder'; -import { INTERRUPTION_REMINDER_VARIANT } from './interruptionReminderOps'; +import { INTERRUPTION_REMINDER_VARIANT, interruptionReminderKey } from './interruptionReminderOps'; const INTERRUPTION_REMINDER = [ 'The previous turn was interrupted by the user before completion;', @@ -34,10 +36,12 @@ export class AgentInterruptionReminderService @IEventBus eventBus: IEventBus, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, + @IAgentStateService agentState: IAgentStateService, ) { super(); + agentState.contributeState(interruptionReminderKey); this._register( - eventBus.subscribe('turn.ended', (event) => { + eventBus.subscribe(TurnEnded, (event) => { if (event.reason !== 'cancelled' || event.interruptReason !== 'user_cancelled') return; const origin = lastComparableMessage(this.context.get())?.origin; if (origin?.kind === 'injection' && origin.variant === INTERRUPTION_REMINDER_VARIANT) return; diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts index 4975c7063d5..a8f6e215553 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts @@ -1,14 +1,19 @@ /** - * `llmRequester` domain — durable request-trace wire Model and Ops. + * `llmRequester` domain — durable request-trace state and events. * - * Defines `llm.tools_snapshot` snapshots and `llm.request` outbound request - * traces, with replay restoring only the snapshot de-dup cursor. + * Defines the `llm.tools_snapshot` snapshot event and the `llm.request` + * outbound request trace event; the `llmRequestTraceKey` state folds only + * the snapshot de-dup cursor (`llm.request` records restore as journal facts + * with no state). Scope-agnostic. */ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ + import { z } from 'zod'; +import { Event2 } from '#/app/event/event2'; import type { ThinkingEffort } from '#/kosong/contract/provider'; -import { defineModel } from '#/wire/model'; +import { defineState } from '#/state/state'; export interface LlmRequestToolSchema { readonly name: string; @@ -20,56 +25,61 @@ export interface LlmRequestTraceState { readonly seenToolsHashes: readonly string[]; } -export const LlmRequestTraceModel = defineModel( - 'llm.requestTrace', - () => ({ seenToolsHashes: [] }), -); - const llmToolEntrySchema = z.object({ name: z.string(), description: z.string(), parameters: z.record(z.string(), z.unknown()), }); -declare module '#/wire/types' { - interface PersistedOpMap { - 'llm.tools_snapshot': typeof llmToolsSnapshot; - 'llm.request': typeof llmRequest; - } +const llmToolsSnapshotSchema = z.object({ + hash: z.string(), + tools: z.array(llmToolEntrySchema).readonly(), +}); + +export class LlmToolsSnapshot extends Event2> { + static override readonly type = 'llm.tools_snapshot'; + static override readonly durable = true; + static override readonly schema = llmToolsSnapshotSchema; } +export interface LlmToolsSnapshot extends z.infer {} -export const llmToolsSnapshot = LlmRequestTraceModel.defineOp('llm.tools_snapshot', { - schema: z.object({ - hash: z.string(), - tools: z.array(llmToolEntrySchema).readonly(), - }), - apply: (s, p) => { - if (s.seenToolsHashes.includes(p.hash)) return s; - return { seenToolsHashes: [...s.seenToolsHashes, p.hash] }; - }, +const llmRequestSchema = z.object({ + kind: z.enum(['loop', 'compaction']), + provider: z.string(), + model: z.string(), + modelAlias: z.string().optional(), + thinkingEffort: z.custom().optional(), + thinkingKeep: z.string().optional(), + temperature: z.number().optional(), + topP: z.number().optional(), + maxTokens: z.number().optional(), + betaApi: z.boolean().optional(), + toolSelect: z.boolean(), + systemPromptHash: z.string(), + systemPrompt: z.string().optional(), + toolsHash: z.string(), + messageCount: z.number(), + turnStep: z.string().optional(), + attempt: z.string().optional(), + projection: z.enum(['strict', 'media-degraded', 'media-stripped']).optional(), + droppedCount: z.number().optional(), }); -export const llmRequest = LlmRequestTraceModel.defineOp('llm.request', { - schema: z.object({ - kind: z.enum(['loop', 'compaction']), - provider: z.string(), - model: z.string(), - modelAlias: z.string().optional(), - thinkingEffort: z.custom().optional(), - thinkingKeep: z.string().optional(), - temperature: z.number().optional(), - topP: z.number().optional(), - maxTokens: z.number().optional(), - betaApi: z.boolean().optional(), - toolSelect: z.boolean(), - systemPromptHash: z.string(), - systemPrompt: z.string().optional(), - toolsHash: z.string(), - messageCount: z.number(), - turnStep: z.string().optional(), - attempt: z.string().optional(), - projection: z.enum(['strict', 'media-degraded', 'media-stripped']).optional(), - droppedCount: z.number().optional(), - }), - apply: (s) => s, -}); +export type LlmRequestPayload = z.infer; + +export class LlmRequest extends Event2 { + static override readonly type = 'llm.request'; + static override readonly durable = true; + static override readonly schema = llmRequestSchema; +} +export interface LlmRequest extends LlmRequestPayload {} + +export const llmRequestTraceKey = defineState( + 'llm.requestTrace', + (): LlmRequestTraceState => ({ seenToolsHashes: [] }), +).replayable({ schema: z.custom() }) + .on(LlmToolsSnapshot, (s, e) => { + if (s.seenToolsHashes.includes(e.hash)) return; + s.seenToolsHashes = [...s.seenToolsHashes, e.hash]; + }) + .on(LlmRequest, () => {}); diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 665dca6850e..ed0a182ea6c 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -24,8 +24,8 @@ * `AgentLLMRequestFinish` on the `finish` event, logs the request lifecycle * (config deduplicated by content, request/response/failure lines, plus * per-request fields) through `log`, publishes advisory model-capability - * warnings through `eventBus`, records durable request-trace Ops - * through `wire`, reports each request's `x-trace-id` to its caller, and + * warnings through the `WarningIssued` event, records durable request-trace + * events through the event dispatcher, reports each request's `x-trace-id` to its caller, and * reports provider failures through `telemetry`. The mutable request state * (`lastConfigLogSignature`, `turnConfigs`, `mediaDegradedTurns`, * `mediaStrippedTurns`, `emittedThinkingEffortWarnings`) is registered into @@ -36,7 +36,7 @@ import { createHash } from 'node:crypto'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; +import { defineState } from '#/state/state'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentContextProjectorService, @@ -50,7 +50,6 @@ import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; import { IAgentMediaResolverService } from '#/agent/media/mediaResolver'; import { IAgentUsageService } from '#/agent/usage/usage'; import { IConfigService } from '#/app/config/config'; -import { IEventBus } from '#/app/event/eventBus'; import { APIRequestTooLargeError, APIStatusError, @@ -80,8 +79,8 @@ import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; import type { Protocol } from '#/kosong/protocol/protocol'; import type { ApiErrorEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IWireService } from '#/wire/wire'; -import type { PayloadOf } from '#/wire/types'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { WarningIssued } from '#/agent/profile/profileOps'; import { IAgentLLMRequesterService, @@ -99,9 +98,10 @@ import { type ToolCallIdResponseNormalizer, } from './toolCallIdNormalizer'; import { - LlmRequestTraceModel, - llmRequest, - llmToolsSnapshot, + LlmRequest, + llmRequestTraceKey, + LlmToolsSnapshot, + type LlmRequestPayload, type LlmRequestToolSchema, } from './llmRequestOps'; import { isAbortError } from '#/_base/utils/abort'; @@ -189,15 +189,15 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { @IModelCatalog private readonly modelCatalog: IModelCatalog, @ILogService private readonly log: ILogService, @ITelemetryService private readonly telemetry: ITelemetryService, - @IWireService private readonly wire: IWireService, - @IEventBus private readonly eventBus: IEventBus, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IAgentStateService private readonly states: IAgentStateService, ) { - this.states.register(llmRequesterLastConfigLogSignatureKey); - this.states.register(llmRequesterTurnConfigsKey); - this.states.register(llmRequesterMediaDegradedTurnsKey); - this.states.register(llmRequesterMediaStrippedTurnsKey); - this.states.register(llmRequesterEmittedThinkingEffortWarningsKey); + this.states.contributeState(llmRequestTraceKey); + this.states.contributeState(llmRequesterLastConfigLogSignatureKey); + this.states.contributeState(llmRequesterTurnConfigsKey); + this.states.contributeState(llmRequesterMediaDegradedTurnsKey); + this.states.contributeState(llmRequesterMediaStrippedTurnsKey); + this.states.contributeState(llmRequesterEmittedThinkingEffortWarningsKey); } private get lastConfigLogSignature(): string | undefined { @@ -579,7 +579,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { } catch { } try { - this.eventBus.publish({ type: 'warning', code, message }); + void this.dispatcher.dispatch(new WarningIssued({ code, message })); } catch { } } @@ -702,8 +702,8 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { const wireTools = providerVisibleTools(input.tools); const tools = toolSignature(wireTools); const toolsHash = fingerprint(JSON.stringify(tools)); - if (!this.wire.getModel(LlmRequestTraceModel).seenToolsHashes.includes(toolsHash)) { - this.wire.dispatch(llmToolsSnapshot({ hash: toolsHash, tools })); + if (!this.states.get(llmRequestTraceKey).seenToolsHashes.includes(toolsHash)) { + void this.dispatcher.dispatch(new LlmToolsSnapshot({ hash: toolsHash, tools })); } const systemPromptHash = fingerprint(input.systemPrompt); @@ -711,7 +711,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { const thinkingConfig = this.config.get(THINKING_SECTION); const modelConfig = input.modelAlias === undefined ? undefined : this.modelService.get(input.modelAlias); - const payload: PayloadOf = { + const payload: LlmRequestPayload = { kind: requestKindForRecord(fields), provider: input.protocol, model: input.modelName, @@ -739,7 +739,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { projection: projectionField(fields), droppedCount: numberField(fields, 'droppedCount'), }; - this.wire.dispatch(llmRequest(payload)); + void this.dispatcher.dispatch(new LlmRequest(payload)); } private logResponse( @@ -817,7 +817,7 @@ function toolSignature(tools: readonly Tool[]): readonly LlmRequestToolSchema[] return tools.map(({ name, description, parameters }) => ({ name, description, parameters })); } -function requestKindForRecord(fields: AgentLLMRequestLogFields): PayloadOf['kind'] { +function requestKindForRecord(fields: AgentLLMRequestLogFields): LlmRequestPayload['kind'] { if (fields['kind'] === 'compaction') return 'compaction'; if (fields['requestKind'] === 'full_compaction') return 'compaction'; return 'loop'; diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index cea09f583c4..c0eef7f1352 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -17,9 +17,11 @@ * dispatched to the registered error handlers (first match wins); a handler * that claims and catches the error has already enqueued the turn's * continuation itself, so the loop only learns caught-or-not, while an - * unclaimed or uncaught error fails the turn. Emits `turn.*` / delta - * events through `event`, persists loop events through `contextMemory`, and - * reads the step budget from `config`. The plain-data loop state + * unclaimed or uncaught error fails the turn. Dispatches the durable + * `turn.*` events and the transient `turn.*` / delta observables through + * `state` (`IEventDispatcher`), persists loop events through + * `contextMemory`, and reads the step budget from `config`. The plain-data + * loop state * (`nextReservedTurnId`, `lastRequestTraceId`, `disposing`) is registered * into `agentState` (`IAgentStateService`) and read/written through it; * `pendingTurns` and `activeTurnJob` stay plain fields because a `TurnJob` @@ -36,14 +38,14 @@ import { createControlledPromise } from '@antfu/utils'; import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; +import { defineState } from '#/state/state'; import { abortError, isAbortError, isUserCancellation, userCancellationReason } from '#/_base/utils/abort'; import { toErrorMessage } from '#/_base/errors/errorMessage'; import { IAgentLLMRequesterService, type AgentLLMRequestFinish } from '#/agent/llmRequester/llmRequester'; import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IConfigService } from '#/app/config/config'; -import { IEventBus } from '#/app/event/eventBus'; +import { AgentErrorEvent } from '#/agent/mcp/mcpEvents'; import { type FinishReason } from '#/kosong/contract/provider'; import { mergeInPlace, type ContentPart, type StreamedMessagePart } from '#/kosong/contract/message'; import { type TokenUsage } from '#/kosong/contract/usage'; @@ -60,7 +62,7 @@ import type { TurnStartedEvent as TurnStartedTelemetryEvent, } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IWireService } from '#/wire/wire'; +import { IEventDispatcher } from '#/state/eventDispatcher'; import { LOOP_CONTROL_SECTION, type LoopControl } from './configSection'; import { createMaxStepsExceededError, @@ -85,8 +87,19 @@ import { type TurnSeed, } from './stepRequest'; import { StepRequestQueue, type StepRequestBatch } from './stepRequestQueue'; -import { isDisplayablePromptOrigin, projectTurnPrompt, type TurnInterruptReason } from './turnEvents'; -import { cancelTurn, endTurn, promptTurn, TurnModel } from './turnOps'; +import { + AssistantDelta, + isDisplayablePromptOrigin, + ThinkingDelta, + ToolCallDelta, + turnPromptText, + TurnStarted, + TurnStepCompleted, + TurnStepInterrupted, + TurnStepStarted, + type TurnInterruptReason, +} from './turnEvents'; +import { TurnCancel, TurnEnded, turnKey, TurnPrompt } from './turnOps'; export type LoopInterruptReason = 'aborted' | 'max_steps' | 'error'; @@ -122,18 +135,18 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService, - @IEventBus private readonly eventBus: IEventBus, @IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService, @IConfigService private readonly config: IConfigService, - @IWireService private readonly wire: IWireService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, @IAgentStateService private readonly states: IAgentStateService, ) { super(); - this.states.register(loopNextReservedTurnIdKey); - this.states.register(loopLastRequestTraceIdKey); - this.states.register(loopDisposingKey); + this.states.contributeState(turnKey); + this.states.contributeState(loopNextReservedTurnIdKey); + this.states.contributeState(loopLastRequestTraceIdKey); + this.states.contributeState(loopDisposingKey); } private get nextReservedTurnId(): number | undefined { @@ -295,8 +308,8 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { const job = this.activeTurnJob; if (job === undefined || (turnId !== undefined && job.turn.id !== turnId)) return false; if (job.controller.signal.aborted) return true; - this.wire.dispatch( - cancelTurn({ turnId: job.turn.id, target: 'active', reason: cancelReasonFor(cancellation) }), + void this.dispatcher.dispatch( + new TurnCancel({ turnId: job.turn.id, target: 'active', reason: cancelReasonFor(cancellation) }), ); job.controller.abort(cancellation); return true; @@ -307,7 +320,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (index < 0) return false; const [job] = this.pendingTurns.splice(index, 1); if (job === undefined || job.turn.state !== 'queued') return false; - this.wire.dispatch(cancelTurn({ turnId, target: 'queued', reason: cancelReasonFor(cancellation) })); + void this.dispatcher.dispatch(new TurnCancel({ turnId, target: 'queued', reason: cancelReasonFor(cancellation) })); for (const step of job.steps.values()) step.cancel(cancellation); job.controller.abort(cancellation); job.turn.state = 'cancelled'; @@ -373,7 +386,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } private reserveTurnId(): number { - const modelNextId = this.wire.getModel(TurnModel).nextTurnId; + const modelNextId = this.states.get(turnKey).nextTurnId; const id = Math.max(modelNextId, this.nextReservedTurnId ?? modelNextId); this.nextReservedTurnId = id + 1; return id; @@ -467,20 +480,16 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private startTurn(job: TurnJob): void { const origin = job.seed.origin; - this.wire.dispatch(promptTurn({ input: job.seed.input, origin })); + void this.dispatcher.dispatch(new TurnPrompt({ input: job.seed.input, origin })); job.turn.state = 'running'; this.activeTurnJob = job; - const projection = isDisplayablePromptOrigin(origin) - ? projectTurnPrompt(job.seed.input, origin) - : undefined; - this.eventBus.publish({ - type: 'turn.started', - turnId: job.turn.id, - origin, - prompt: projection?.text, - promptAttachments: projection?.attachments, - promptId: job.seed.promptId, - }); + void this.dispatcher.dispatch( + new TurnStarted({ + turnId: job.turn.id, + origin, + prompt: isDisplayablePromptOrigin(origin) ? turnPromptText(job.seed.input, origin) : undefined, + }), + ); void this.runTurn(job.turn, job.ready).then(job.result.resolve, job.result.reject); } @@ -526,16 +535,10 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { const interruptReason = result.type === 'completed' ? undefined : interruptReasonFor(result); const durationMs = Date.now() - startedAt; - this.wire.dispatch(endTurn({ turnId: turn.id, reason: result.type, error, durationMs })); - this.eventBus.publish({ - type: 'turn.ended', - turnId: turn.id, - reason: result.type, - error, - durationMs, - interruptReason, - }); - if (error !== undefined) this.eventBus.publish({ type: 'error', ...error }); + void this.dispatcher.dispatch( + new TurnEnded({ turnId: turn.id, reason: result.type, error, durationMs, interruptReason }), + ); + if (error !== undefined) void this.dispatcher.dispatch(new AgentErrorEvent(error)); if (interruptReason !== undefined) { const interrupted: TurnInterruptedEvent = { turn_id: turn.id, @@ -878,7 +881,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { onStarted: ((step: number) => void) | undefined, ): () => void { signal.throwIfAborted(); - this.eventBus.publish({ type: 'turn.step.started', turnId, step: currentStep, stepId: stepUuid }); + void this.dispatcher.dispatch(new TurnStepStarted({ turnId, step: currentStep, stepId: stepUuid })); this.context.appendLoopEvent({ type: 'step.begin', uuid: stepUuid, @@ -1050,22 +1053,23 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { finishReason: string, response: AgentLLMRequestFinish, ): void { - this.eventBus.publish({ - type: 'turn.step.completed', - turnId, - step, - stepId, - usage, - finishReason, - llmFirstTokenLatencyMs: response.timing?.firstTokenLatencyMs, - llmStreamDurationMs: response.timing?.streamDurationMs, - llmRequestBuildMs: response.timing?.requestBuildMs, - llmServerFirstTokenMs: response.timing?.serverFirstTokenMs, - llmServerDecodeMs: response.timing?.serverDecodeMs, - llmClientConsumeMs: response.timing?.clientConsumeMs, - providerFinishReason: response.providerFinishReason, - rawFinishReason: response.rawFinishReason, - }); + void this.dispatcher.dispatch( + new TurnStepCompleted({ + turnId, + step, + stepId, + usage, + finishReason, + llmFirstTokenLatencyMs: response.timing?.firstTokenLatencyMs, + llmStreamDurationMs: response.timing?.streamDurationMs, + llmRequestBuildMs: response.timing?.requestBuildMs, + llmServerFirstTokenMs: response.timing?.serverFirstTokenMs, + llmServerDecodeMs: response.timing?.serverDecodeMs, + llmClientConsumeMs: response.timing?.clientConsumeMs, + providerFinishReason: response.providerFinishReason, + rawFinishReason: response.rawFinishReason, + }), + ); } private emitStepInterrupted( @@ -1075,13 +1079,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { message?: string, ): void { if (activeStep === undefined) return; - this.eventBus.publish({ - type: 'turn.step.interrupted', - turnId, - step: activeStep, - reason, - message, - }); + void this.dispatcher.dispatch( + new TurnStepInterrupted({ + turnId, + step: activeStep, + reason, + message, + }), + ); } private createStreamPartHandler( @@ -1104,12 +1109,12 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { case 'text': onResponseEvent(); accumulate(part); - this.eventBus.publish({ type: 'assistant.delta', turnId, delta: part.text }); + void this.dispatcher.dispatch(new AssistantDelta({ turnId, delta: part.text })); return; case 'think': onResponseEvent(); accumulate(part); - this.eventBus.publish({ type: 'thinking.delta', turnId, delta: part.think }); + void this.dispatcher.dispatch(new ThinkingDelta({ turnId, delta: part.think })); return; case 'image_url': case 'audio_url': @@ -1119,13 +1124,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { onResponseEvent(); forceContentPartBoundary = true; callsByIndex.set(part._streamIndex, { id: part.id, name: part.name }); - this.eventBus.publish({ - type: 'tool.call.delta', - turnId, - toolCallId: part.id, - name: part.name, - argumentsPart: part.arguments ?? undefined, - }); + void this.dispatcher.dispatch( + new ToolCallDelta({ + turnId, + toolCallId: part.id, + name: part.name, + argumentsPart: part.arguments ?? undefined, + }), + ); return; } case 'tool_call_part': { @@ -1133,13 +1139,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { const toolCall = callsByIndex.get(part.index); if (toolCall === undefined) return; onResponseEvent(); - this.eventBus.publish({ - type: 'tool.call.delta', - turnId, - toolCallId: toolCall.id, - name: toolCall.name, - argumentsPart: part.argumentsPart, - }); + void this.dispatcher.dispatch( + new ToolCallDelta({ + turnId, + toolCallId: toolCall.id, + name: toolCall.name, + argumentsPart: part.argumentsPart, + }), + ); return; } default: { diff --git a/packages/agent-core-v2/src/agent/loop/turnEvents.ts b/packages/agent-core-v2/src/agent/loop/turnEvents.ts index 73243d1b927..52df446f309 100644 --- a/packages/agent-core-v2/src/agent/loop/turnEvents.ts +++ b/packages/agent-core-v2/src/agent/loop/turnEvents.ts @@ -1,7 +1,9 @@ /** - * `loop` domain — the `turn.*` / delta event payloads published through - * `IEventBus` as a turn runs. These are the loop's share of the agent event - * stream; consumers subscribe by `type`. + * `loop` domain — the transient observable `turn.*` / delta `Event2` classes + * published through the event dispatcher as a turn runs. These are the loop's + * share of the agent event stream; consumers subscribe by class (or by `type` + * string). The durable `turn.ended` fact (`TurnEnded`) lives with the + * `turnKey` state in `turnOps`. * `turn.started` additionally carries the text extracted from the turn's * input parts (absent when the turn opened with no text part): consumers * that render the user's prompt must take it from there, because the context @@ -9,25 +11,17 @@ * prompt rides the event only for displayable user origins * ({@link isDisplayablePromptOrigin}) — a system-triggered turn (goal * continuation, subagent run, cron…) has internal steering text as its input, - * which must never surface in transcripts. An upload's daemon-ref media part - * is self-contained (`daemonFileRefFromPart`): its kind comes from the part - * type and its file id from the reference, so the projection needs no - * tag+ref pairing — the referenced media rides as - * {@link TurnStartedEvent.promptAttachments}. When the turn's prompt bundles + * which must never surface in transcripts. When the turn's prompt bundles * skill activations, their rendered blocks (prepended to the content, one * text part per skill) are excluded from the extracted text. - * `turn.started` also echoes the prompt record id as - * {@link TurnStartedEvent.promptId} when the turn was opened by a prompt - * submission, so submitters can bind their own bookkeeping (e.g. staged - * uploads) to the exact turn that consumed them; turns opened any other way - * (retry, goal continuation, …) leave it absent. */ -import type { KimiErrorPayload } from '#/_base/errors/serialize'; +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ + import type { PromptOrigin } from '#/agent/contextMemory/types'; +import { Event2 } from '#/app/event/event2'; import type { FinishReason } from '#/kosong/contract/provider'; import type { ContentPart, TextPart } from '#/kosong/contract/message'; -import { daemonFileRefFromPart } from '#/agent/media/mediaRef'; import type { TokenUsage } from '#/kosong/contract/usage'; export type TurnEndReason = 'completed' | 'cancelled' | 'failed' | 'blocked'; @@ -40,56 +34,29 @@ export type TurnInterruptReason = | 'filtered' | 'blocked'; -/** - * One daemon-referenced upload carried by the turn-opening input. - */ -export interface TurnPromptAttachment { - readonly kind: 'image' | 'video'; - readonly fileId: string; -} - -export interface TurnStartedEvent { - readonly type: 'turn.started'; +export interface TurnStartedPayload { readonly turnId: number; readonly origin: PromptOrigin; readonly prompt?: string; - readonly promptAttachments?: readonly TurnPromptAttachment[]; - readonly promptId?: string; } -/** - * The displayable projection of the turn-opening input: the prompt text, - * plus one entry per daemon-referenced upload. - */ -export interface TurnPromptProjection { - readonly text?: string; - readonly attachments?: readonly TurnPromptAttachment[]; +export class TurnStarted extends Event2 { + static override readonly type = 'turn.started'; + static override readonly observable = true; } +export interface TurnStarted extends TurnStartedPayload {} -export function projectTurnPrompt( +export function turnPromptText( input: readonly ContentPart[], origin?: PromptOrigin, -): TurnPromptProjection { +): string | undefined { const bundledBlocks = origin?.kind === 'user' ? (origin.skillActivations?.length ?? 0) : 0; const text = input .filter((part): part is TextPart => part.type === 'text') .slice(bundledBlocks) .map((part) => part.text) .join(''); - const media = input.flatMap((part) => { - const daemonPart = daemonFileRefFromPart(part); - return daemonPart === undefined ? [] : [daemonPart]; - }); - return { - text: text.length > 0 ? text : undefined, - attachments: - media.length === 0 - ? undefined - : media.map((entry) => ({ - kind: entry.kind, - fileId: entry.ref.fileId, - })), - }; + return text.length > 0 ? text : undefined; } export function isDisplayablePromptOrigin(origin: PromptOrigin): boolean { @@ -100,24 +67,19 @@ export function isDisplayablePromptOrigin(origin: PromptOrigin): boolean { ); } -export interface TurnEndedEvent { - readonly type: 'turn.ended'; - readonly turnId: number; - readonly reason: TurnEndReason; - readonly error?: KimiErrorPayload; - readonly durationMs?: number; - readonly interruptReason?: TurnInterruptReason; -} - -export interface TurnStepStartedEvent { - readonly type: 'turn.step.started'; +export interface TurnStepStartedPayload { readonly turnId: number; readonly step: number; readonly stepId?: string; } -export interface TurnStepCompletedEvent { - readonly type: 'turn.step.completed'; +export class TurnStepStarted extends Event2 { + static override readonly type = 'turn.step.started'; + static override readonly observable = true; +} +export interface TurnStepStarted extends TurnStepStartedPayload {} + +export interface TurnStepCompletedPayload { readonly turnId: number; readonly step: number; readonly stepId?: string; @@ -133,8 +95,13 @@ export interface TurnStepCompletedEvent { readonly rawFinishReason?: string; } -export interface TurnStepInterruptedEvent { - readonly type: 'turn.step.interrupted'; +export class TurnStepCompleted extends Event2 { + static override readonly type = 'turn.step.completed'; + static override readonly observable = true; +} +export interface TurnStepCompleted extends TurnStepCompletedPayload {} + +export interface TurnStepInterruptedPayload { readonly turnId: number; readonly step: number; readonly stepId?: string; @@ -142,35 +109,43 @@ export interface TurnStepInterruptedEvent { readonly message?: string; } -export interface AssistantDeltaEvent { - readonly type: 'assistant.delta'; +export class TurnStepInterrupted extends Event2 { + static override readonly type = 'turn.step.interrupted'; + static override readonly observable = true; +} +export interface TurnStepInterrupted extends TurnStepInterruptedPayload {} + +export interface AssistantDeltaPayload { readonly turnId: number; readonly delta: string; } -export interface ThinkingDeltaEvent { - readonly type: 'thinking.delta'; +export class AssistantDelta extends Event2 { + static override readonly type = 'assistant.delta'; + static override readonly observable = true; +} +export interface AssistantDelta extends AssistantDeltaPayload {} + +export interface ThinkingDeltaPayload { readonly turnId: number; readonly delta: string; } -export interface ToolCallDeltaEvent { - readonly type: 'tool.call.delta'; +export class ThinkingDelta extends Event2 { + static override readonly type = 'thinking.delta'; + static override readonly observable = true; +} +export interface ThinkingDelta extends ThinkingDeltaPayload {} + +export interface ToolCallDeltaPayload { readonly turnId: number; readonly toolCallId: string; readonly name?: string; readonly argumentsPart?: string; } -declare module '#/app/event/eventBus' { - interface DomainEventMap { - 'turn.started': TurnStartedEvent; - 'turn.ended': TurnEndedEvent; - 'turn.step.started': TurnStepStartedEvent; - 'turn.step.completed': TurnStepCompletedEvent; - 'turn.step.interrupted': TurnStepInterruptedEvent; - 'assistant.delta': AssistantDeltaEvent; - 'thinking.delta': ThinkingDeltaEvent; - 'tool.call.delta': ToolCallDeltaEvent; - } +export class ToolCallDelta extends Event2 { + static override readonly type = 'tool.call.delta'; + static override readonly observable = true; } +export interface ToolCallDelta extends ToolCallDeltaPayload {} diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index 7c033c03b05..5677858654c 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -1,20 +1,36 @@ /** - * `loop` domain — persists and restores monotonically increasing turn + * `loop` domain — the `turnKey` state and the durable `turn.prompt` + * (`TurnPrompt`) / `turn.steer` (`TurnSteer`) / `turn.cancel` (`TurnCancel`) / + * `turn.ended` (`TurnEnded`) events behind monotonically increasing turn * identity. * - * Owns the next available turn id, including cancelled queued reservations and - * legacy loop-event observations. Also persists the terminal `turn.ended` - * record (reason / error / durationMs) so downstream history rebuilds and + * The state owns the next available turn id, including cancelled queued + * reservations and legacy loop-event observations (the + * `ContextAppendLoopEvent` fold), plus the terminal `lastEnded` outcome + * (reason / error / durationMs) so downstream history rebuilds and * cold-resumed read models (e.g. the activity view) can recover how the last - * turn ended. Consumed by the Agent-scope `loopService`. + * turn ended. The durable classes are the wire-protocol record vocabulary: + * their `serialize()` output is the on-disk record (flat payload, epoch-ms + * `time`), byte-compatible with the retired op encoding. `TurnEnded` merges + * the retired op with the same-named bus fact: it is durable AND observable, + * and carries the bus-only `interruptReason` alongside the persisted fields — + * its `serialize()` override emits exactly the op's record shape, so the + * journal stays byte-identical and replay never republishes. Consumed by the + * Agent-scope `loopService`. */ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ + import { z } from 'zod'; -import { defineModel } from '#/wire/model'; import type { KimiErrorPayload } from '#/_base/errors/serialize'; -import type { ContentPart } from '#/kosong/contract/message'; +import { ContextAppendLoopEvent } from '#/agent/contextMemory/contextEvents'; import type { PromptOrigin } from '#/agent/contextMemory/types'; +import { Event2, type SerializedEvent2 } from '#/app/event/event2'; +import type { ContentPart } from '#/kosong/contract/message'; +import { defineState } from '#/state/state'; + +import type { TurnInterruptReason } from './turnEvents'; export interface TurnModelState { readonly nextTurnId: number; @@ -26,78 +42,104 @@ export interface TurnModelState { }; } -export const TurnModel = defineModel( - 'turn', - () => ({ nextTurnId: 0, cancelledTurnIds: [] }), - { - reducers: { - 'context.append_loop_event': (state, { event }) => { - if (event.type === 'tool.result' || event.turnId === undefined) { - return state; - } - - const turnId = Number.parseInt(event.turnId, 10); - if (!Number.isInteger(turnId)) return state; - let next = state; - if (turnId >= state.nextTurnId) next = advanceTurnClock(state, turnId + 1); - if (next.lastEnded !== undefined && turnId > next.lastEnded.turnId) { - next = { ...next, lastEnded: undefined }; - } - return next; - }, - }, - }, -); - const turnInputShape = { input: z.custom(), origin: z.custom(), }; -declare module '#/wire/types' { - interface PersistedOpMap { - 'turn.prompt': typeof promptTurn; - 'turn.steer': typeof steerTurn; - 'turn.cancel': typeof cancelTurn; - 'turn.ended': typeof endTurn; - } +const turnPromptSchema = z.object(turnInputShape); + +export class TurnPrompt extends Event2> { + static override readonly type = 'turn.prompt'; + static override readonly durable = true; + static override readonly schema = turnPromptSchema; } +export interface TurnPrompt extends z.infer {} -export const promptTurn = TurnModel.defineOp('turn.prompt', { - schema: z.object(turnInputShape), - apply: (s) => advanceTurnClock(s, s.nextTurnId + 1), -}); +const turnSteerSchema = z.object(turnInputShape); + +export class TurnSteer extends Event2> { + static override readonly type = 'turn.steer'; + static override readonly durable = true; + static override readonly schema = turnSteerSchema; +} +export interface TurnSteer extends z.infer {} -export const steerTurn = TurnModel.defineOp('turn.steer', { - schema: z.object(turnInputShape), - apply: (s) => s, +const turnCancelSchema = z.object({ + turnId: z.number().optional(), + target: z.enum(['active', 'queued']).optional(), + reason: z.enum(['user_cancelled', 'aborted']).optional(), }); -export const cancelTurn = TurnModel.defineOp('turn.cancel', { - schema: z.object({ - turnId: z.number().optional(), - target: z.enum(['active', 'queued']).optional(), - reason: z.enum(['user_cancelled', 'aborted']).optional(), - }), - apply: (s, { turnId, target }) => { - if (target === undefined || turnId === undefined) return s; - if (turnId < s.nextTurnId) return s; - return advanceTurnClock(s, s.nextTurnId, [...s.cancelledTurnIds, turnId]); - }, +export class TurnCancel extends Event2> { + static override readonly type = 'turn.cancel'; + static override readonly durable = true; + static override readonly schema = turnCancelSchema; +} +export interface TurnCancel extends z.infer {} + +const turnEndedSchema = z.object({ + turnId: z.number(), + reason: z.enum(['completed', 'cancelled', 'failed', 'blocked']), + error: z.custom().optional(), + durationMs: z.number().optional(), }); -export const endTurn = TurnModel.defineOp('turn.ended', { - schema: z.object({ - turnId: z.number(), - reason: z.enum(['completed', 'cancelled', 'failed', 'blocked']), - error: z.custom().optional(), - durationMs: z.number().optional(), - }), - apply: (s, { turnId, reason, durationMs }) => ({ +export interface TurnEndedPayload { + readonly turnId: number; + readonly reason: 'completed' | 'cancelled' | 'failed' | 'blocked'; + readonly error?: KimiErrorPayload; + readonly durationMs?: number; + readonly interruptReason?: TurnInterruptReason; +} + +export class TurnEnded extends Event2 { + static override readonly type = 'turn.ended'; + static override readonly durable = true; + static override readonly observable = true; + static override readonly schema = turnEndedSchema; + + override serialize(): SerializedEvent2 { + const record: Record = { + type: this.type, + turnId: this.turnId, + reason: this.reason, + }; + if (this.error !== undefined) record['error'] = this.error; + if (this.durationMs !== undefined) record['durationMs'] = this.durationMs; + record['time'] = this.time; + return record as SerializedEvent2; + } +} +export interface TurnEnded extends TurnEndedPayload {} + +export const turnKey = defineState( + 'turn', + (): TurnModelState => ({ nextTurnId: 0, cancelledTurnIds: [] }), +).replayable({ schema: z.custom() }) + .on(ContextAppendLoopEvent, (s, e) => { + const { event } = e; + if (event.type === 'tool.result' || event.turnId === undefined) return; + const turnId = Number.parseInt(event.turnId, 10); + if (!Number.isInteger(turnId)) return; + let next: TurnModelState = s; + if (turnId >= next.nextTurnId) next = advanceTurnClock(next, turnId + 1); + if (next.lastEnded !== undefined && turnId > next.lastEnded.turnId) { + next = { ...next, lastEnded: undefined }; + } + if (next !== s) return next; + }) + .on(TurnPrompt, (s) => advanceTurnClock(s, s.nextTurnId + 1)) + .on(TurnSteer, () => {}) + .on(TurnCancel, (s, e) => { + if (e.target === undefined || e.turnId === undefined) return; + if (e.turnId < s.nextTurnId) return; + return advanceTurnClock(s, s.nextTurnId, [...s.cancelledTurnIds, e.turnId]); + }) + .on(TurnEnded, (s, e) => ({ ...s, - lastEnded: { turnId, reason, durationMs }, - }), -}); + lastEnded: { turnId: e.turnId, reason: e.reason, durationMs: e.durationMs }, + })); function advanceTurnClock( state: TurnModelState, diff --git a/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts b/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts index cd2583129b4..6a47bba2c30 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts @@ -1,14 +1,20 @@ /** - * `mcp` domain — MCP tool-discovery wire state. + * `mcp` domain — MCP tool-discovery state. * * Restores the per-agent de-dup cursor for durable MCP discovery records, - * keyed by `${serverName}\n${hash}` entries already present in this log. + * keyed by `${serverName}\n${hash}` entries already present in this log. The + * durable `mcp.tools_discovered` event (`McpToolsDiscovered`) carries the + * discovered tool snapshot; only the cursor is folded into state. + * Scope-agnostic. */ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ + import { z } from 'zod'; -import { defineModel } from '#/wire/model'; +import { Event2 } from '#/app/event/event2'; import type { MCPToolDefinition } from '#/mcpCore/types'; +import { defineState } from '#/state/state'; export interface McpToolCollision { readonly qualified: string; @@ -22,10 +28,6 @@ export interface McpDiscoveryState { readonly seen: readonly string[]; } -export const McpDiscoveryModel = defineModel('mcp.discovery', () => ({ - seen: [], -})); - const mcpToolCollisionSchema = z.object({ qualified: z.string(), toolName: z.string(), @@ -35,23 +37,25 @@ const mcpToolCollisionSchema = z.object({ ]), }); -declare module '#/wire/types' { - interface PersistedOpMap { - 'mcp.tools_discovered': typeof mcpToolsDiscovered; - } -} +const mcpToolsDiscoveredSchema = z.object({ + serverName: z.string(), + hash: z.string(), + tools: z.custom(), + enabledNames: z.array(z.string()).readonly(), + collisions: z.array(mcpToolCollisionSchema).readonly().optional(), +}); -export const mcpToolsDiscovered = McpDiscoveryModel.defineOp('mcp.tools_discovered', { - schema: z.object({ - serverName: z.string(), - hash: z.string(), - tools: z.custom(), - enabledNames: z.array(z.string()).readonly(), - collisions: z.array(mcpToolCollisionSchema).readonly().optional(), - }), - apply: (s, p) => { - const key = `${p.serverName}\n${p.hash}`; - if (s.seen.includes(key)) return s; - return { seen: [...s.seen, key] }; - }, +export class McpToolsDiscovered extends Event2> { + static override readonly type = 'mcp.tools_discovered'; + static override readonly durable = true; + static override readonly schema = mcpToolsDiscoveredSchema; +} +export interface McpToolsDiscovered extends z.infer {} + +export const mcpDiscoveryKey = defineState('mcp.discovery', (): McpDiscoveryState => ({ seen: [] })) + .replayable({ schema: z.custom() }) + .on(McpToolsDiscovered, (s, e) => { + const key = `${e.serverName}\n${e.hash}`; + if (s.seen.includes(key)) return; + s.seen = [...s.seen, key]; }); diff --git a/packages/agent-core-v2/src/agent/mcp/mcpEvents.ts b/packages/agent-core-v2/src/agent/mcp/mcpEvents.ts new file mode 100644 index 00000000000..638b97fe76d --- /dev/null +++ b/packages/agent-core-v2/src/agent/mcp/mcpEvents.ts @@ -0,0 +1,50 @@ +/** + * `mcp` domain — the transient observable `mcp.server.status` / + * `tool.list.updated` `Event2` classes published through the event dispatcher + * as workspace MCP servers change state, plus the shared agent-wide `error` + * observable (`AgentErrorEvent`, a `KimiErrorPayload` on the bus) whose type + * string is contract-fixed for downstream consumers; this domain only owns + * the declaration, any agent service may dispatch it. + */ + +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ + +import type { KimiErrorPayload } from '#/_base/errors/serialize'; +import { Event2 } from '#/app/event/event2'; + +export interface McpServerStatusPayload { + readonly name: string; + readonly transport: 'stdio' | 'http' | 'sse'; + readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth' | 'removed'; + readonly toolCount: number; + readonly error?: string; +} + +export interface McpServerStatusEventPayload { + readonly server: McpServerStatusPayload; +} + +export class McpServerStatus extends Event2 { + static override readonly type = 'mcp.server.status'; + static override readonly observable = true; +} +export interface McpServerStatus extends McpServerStatusEventPayload {} + +export type ToolListUpdatedReason = 'mcp.connected' | 'mcp.disconnected' | 'mcp.failed'; + +export interface ToolListUpdatedPayload { + readonly reason: ToolListUpdatedReason; + readonly serverName: string; +} + +export class ToolListUpdated extends Event2 { + static override readonly type = 'tool.list.updated'; + static override readonly observable = true; +} +export interface ToolListUpdated extends ToolListUpdatedPayload {} + +export class AgentErrorEvent extends Event2 { + static override readonly type = 'error'; + static override readonly observable = true; +} +export interface AgentErrorEvent extends KimiErrorPayload {} diff --git a/packages/agent-core-v2/src/agent/mcp/mcpService.ts b/packages/agent-core-v2/src/agent/mcp/mcpService.ts index 8d9d79c4183..99f14ab08c8 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpService.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpService.ts @@ -10,7 +10,7 @@ * as `removed`, swaps in the OAuth tool for * `needs-auth` servers, journals tool discoveries on the wire (queued until * restore finishes), and publishes `mcp.server.status` / `tool.list.updated` - * events. Only the session's baseline servers take part + * / collision `error` observables through `state` (`IEventDispatcher`). Only the session's baseline servers take part * (`ISessionMcpHandle.isBaselineServer`, checked on every replayed and * live status change): a server that appears mid-session — a plugin * install or a config edit — is ignored here, so its tools, status events, @@ -30,16 +30,14 @@ import { createHash } from 'node:crypto'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; +import { defineState } from '#/state/state'; import type { Tool as KosongTool } from '#/kosong/contract/tool'; import { type IDisposable } from "#/_base/di/lifecycle"; import { Service } from "#/_base/di/service"; -import type { KimiErrorPayload } from '#/_base/errors/serialize'; import { ErrorCodes, makeErrorPayload } from "#/errors"; import { abortable } from '#/_base/utils/abort'; import { IAgentStateService } from '#/agent/state/agentState'; -import { IEventBus } from '#/app/event/eventBus'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { sessionMediaOriginalsDir } from '#/agent/media/image-originals'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -53,45 +51,13 @@ import type { McpServerEntry } from '#/mcpCore/connection-manager'; import { IAgentMcpService } from './mcp'; import { qualifyMcpToolName } from '#/mcpCore/tool-naming'; import type { MCPClient, MCPToolDefinition } from '#/mcpCore/types'; -import { IWireService } from '#/wire/wire'; +import { IEventDispatcher } from '#/state/eventDispatcher'; import { - McpDiscoveryModel, - mcpToolsDiscovered, + mcpDiscoveryKey, + McpToolsDiscovered, type McpToolCollision, } from './mcpDiscoveryOps'; - -export interface ErrorEvent extends KimiErrorPayload { - readonly type: 'error'; -} - -export interface McpServerStatusPayload { - readonly name: string; - readonly transport: 'stdio' | 'http' | 'sse'; - readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth' | 'removed'; - readonly toolCount: number; - readonly error?: string; -} - -export interface McpServerStatusEvent { - readonly type: 'mcp.server.status'; - readonly server: McpServerStatusPayload; -} - -export type ToolListUpdatedReason = 'mcp.connected' | 'mcp.disconnected' | 'mcp.failed'; - -export interface ToolListUpdatedEvent { - readonly type: 'tool.list.updated'; - readonly reason: ToolListUpdatedReason; - readonly serverName: string; -} - -declare module '#/app/event/eventBus' { - interface DomainEventMap { - 'mcp.server.status': McpServerStatusEvent; - 'tool.list.updated': ToolListUpdatedEvent; - error: ErrorEvent; - } -} +import { AgentErrorEvent, McpServerStatus, ToolListUpdated } from './mcpEvents'; interface McpToolRegistration { readonly disposable: IDisposable; @@ -116,16 +82,16 @@ export class AgentMcpService extends Service implements IAgentMcpService { @ISessionMcpHandle private readonly mcpHandle: ISessionMcpHandle, @ISessionContext private readonly sessionContext: ISessionContext, @IAgentToolRegistryService private readonly registry: IAgentToolRegistryService, - @IEventBus private readonly eventBus: IEventBus, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @IAgentLoopService loop: IAgentLoopService, - @IWireService private readonly wire: IWireService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentStateService private readonly states: IAgentStateService, ) { super(); - this.states.register(mcpMcpToolsByServerKey); - this.states.register(mcpDiscoveryWritesReadyKey); + this.states.contributeState(mcpDiscoveryKey); + this.states.contributeState(mcpMcpToolsByServerKey); + this.states.contributeState(mcpDiscoveryWritesReadyKey); this.attachMcpTools(); loop.hooks.onWillBeginStep.register('mcp', async (ctx, next) => { await this.waitForInitialLoad(ctx.signal); @@ -137,7 +103,7 @@ export class AgentMcpService extends Service implements IAgentMcpService { }), ); this._register( - this.wire.hooks.onDidRestore.register('mcp', async (_ctx, next) => { + this.dispatcher.hooks.onDidRestore.register('mcp', async (_ctx, next) => { this.flushPendingDiscoveries(); await next(); }), @@ -227,16 +193,17 @@ export class AgentMcpService extends Service implements IAgentMcpService { private handleMcpServerStatusChange(entry: McpServerEntry): void { if (!this.mcpHandle.isBaselineServer(entry.name)) return; - this.eventBus.publish({ - type: 'mcp.server.status', - server: { - name: entry.name, - transport: entry.transport, - status: entry.status, - toolCount: entry.toolCount, - error: entry.error, - }, - }); + void this.dispatcher.dispatch( + new McpServerStatus({ + server: { + name: entry.name, + transport: entry.transport, + status: entry.status, + toolCount: entry.toolCount, + error: entry.error, + }, + }), + ); if (entry.status === 'connected') { this.registerConnectedMcpServer(entry); return; @@ -251,11 +218,12 @@ export class AgentMcpService extends Service implements IAgentMcpService { if (entry.status === 'disabled') { const removed = this.unregisterMcpServer(entry.name); if (removed) { - this.eventBus.publish({ - type: 'tool.list.updated', - reason: 'mcp.disconnected', - serverName: entry.name, - }); + void this.dispatcher.dispatch( + new ToolListUpdated({ + reason: 'mcp.disconnected', + serverName: entry.name, + }), + ); } } } @@ -271,11 +239,12 @@ export class AgentMcpService extends Service implements IAgentMcpService { ); this.emitMcpToolCollisions(entry.name, result.collisions); this.recordDiscovery(entry.name, resolved.rawTools, resolved.enabledNames, result.collisions); - this.eventBus.publish({ - type: 'tool.list.updated', - reason: 'mcp.connected', - serverName: entry.name, - }); + void this.dispatcher.dispatch( + new ToolListUpdated({ + reason: 'mcp.connected', + serverName: entry.name, + }), + ); } private registerNeedsAuthMcpServer(entry: McpServerEntry): void { @@ -292,11 +261,12 @@ export class AgentMcpService extends Service implements IAgentMcpService { const disposable = this._register(this.registry.register(tool, { source: 'mcp' })); this.mcpTools.set(tool.name, { disposable, serverName: entry.name }); this.mcpToolsByServer.set(entry.name, [tool.name]); - this.eventBus.publish({ - type: 'tool.list.updated', - reason: 'mcp.connected', - serverName: entry.name, - }); + void this.dispatcher.dispatch( + new ToolListUpdated({ + reason: 'mcp.connected', + serverName: entry.name, + }), + ); } private registerMcpServer( @@ -377,9 +347,9 @@ export class AgentMcpService extends Service implements IAgentMcpService { .update(JSON.stringify({ tools: rawTools, enabledNames: enabledNamesSnapshot, collisions })) .digest('hex'); const key = `${serverName}\n${hash}`; - if (this.wire.getModel(McpDiscoveryModel).seen.includes(key)) return; - this.wire.dispatch( - mcpToolsDiscovered({ + if (this.states.get(mcpDiscoveryKey).seen.includes(key)) return; + void this.dispatcher.dispatch( + new McpToolsDiscovered({ serverName, hash, tools: rawTools, @@ -415,16 +385,17 @@ export class AgentMcpService extends Service implements IAgentMcpService { : `"${collision.toolName}" -> ${collision.qualified} (collides with server "${collision.collidesWith.serverName}")`, ) .join('; '); - this.eventBus.publish({ - type: 'error', - ...makeErrorPayload( - ErrorCodes.MCP_TOOL_NAME_COLLISION, - `MCP server "${serverName}" registered ${collisions.length} tool name` + - `${collisions.length === 1 ? '' : 's'} ` + - `that collide with existing qualified names; the losing tools were dropped: ${summary}`, - { details: { serverName, collisions: collisions as readonly unknown[] } }, + void this.dispatcher.dispatch( + new AgentErrorEvent( + makeErrorPayload( + ErrorCodes.MCP_TOOL_NAME_COLLISION, + `MCP server "${serverName}" registered ${collisions.length} tool name` + + `${collisions.length === 1 ? '' : 's'} ` + + `that collide with existing qualified names; the losing tools were dropped: ${summary}`, + { details: { serverName, collisions: collisions as readonly unknown[] } }, + ), ), - }); + ); } } diff --git a/packages/agent-core-v2/src/agent/media/mediaResolverService.ts b/packages/agent-core-v2/src/agent/media/mediaResolverService.ts index a1267082d9d..e297fd64730 100644 --- a/packages/agent-core-v2/src/agent/media/mediaResolverService.ts +++ b/packages/agent-core-v2/src/agent/media/mediaResolverService.ts @@ -64,7 +64,7 @@ import { createHash } from 'node:crypto'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; +import { defineState } from '#/state/state'; import { IAgentStateService } from '#/agent/state/agentState'; import { IFileService } from '#/app/file/fileService'; import { LifecycleScope } from '#/app/scopes'; @@ -125,7 +125,7 @@ export class AgentMediaResolverService implements IAgentMediaResolverService { @IAgentStateService private readonly states: IAgentStateService, @ISessionMediaStore private readonly mediaStore: ISessionMediaStore, ) { - this.states.register(mediaResolvedKey); + this.states.contributeState(mediaResolvedKey); } private get resolved(): Map { diff --git a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts index a7fb1d13945..15f7df2dbac 100644 --- a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts +++ b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts @@ -30,9 +30,10 @@ import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { Service } from '#/_base/di/service'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; +import { defineState } from '#/state/state'; import { IAgentStateService } from '#/agent/state/agentState'; import { IEventBus } from '#/app/event/eventBus'; +import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { type ModelRequester } from '#/kosong/model/modelRequester'; @@ -68,9 +69,9 @@ export class AgentMediaToolsRegistrar extends Service implements IAgentMediaTool @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, ) { super(); - this.states.register(mediaRegisteredKeyKey); + this.states.contributeState(mediaRegisteredKeyKey); this.refresh(); - this._register(eventBus.subscribe('agent.status.updated', () => this.refresh())); + this._register(eventBus.subscribe(AgentStatusUpdated, () => this.refresh())); this._register(this.runtime.onDidChange(() => this.refresh())); this._register(toDisposable(() => this.registration?.dispose())); } diff --git a/packages/agent-core-v2/src/agent/media/videoResolverService.ts b/packages/agent-core-v2/src/agent/media/videoResolverService.ts index dc63a96ba18..c6d9abaef5c 100644 --- a/packages/agent-core-v2/src/agent/media/videoResolverService.ts +++ b/packages/agent-core-v2/src/agent/media/videoResolverService.ts @@ -1,12 +1,240 @@ /** - * `media` domain — deprecated alias of the request-time media resolver - * implementation (`mediaResolverService`). + * `media` domain — `IAgentVideoResolverService` implementation. * - * Kept under the historical names so existing call sites read unchanged. New - * code should import `AgentMediaResolverService` / `mediaResolvedKey` from - * `mediaResolverService` directly. Only the class is re-exported here: - * re-exporting `mediaResolvedKey` too would make the package root's - * `export *` of both modules ambiguous and silently drop the name. + * Resolves each `kimi-file://` video reference in the projected wire messages + * to a provider-acceptable part right before the request leaves for the wire. + * Reads the uploaded bytes through the `file` domain (`IFileService`), uploads + * them through the bound model's `ModelRequester.uploadVideo` (wrapped for + * `video_upload` telemetry through `createVideoUploader`), and persists the + * `(file, provider) → llmFileId` mapping through the `blobStore` + * access-pattern store so the upload happens once across a turn's steps, + * retries, and media-recovery reprojections. Falls back to an inline base64 + * `video_url` (protocols that carry it) or a `