From cb0750eef38137749aa4b1daface7739f2a22d57 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 24 Jul 2026 09:42:54 +0800 Subject: [PATCH 01/10] refactor(agent-core-v2): instantiate all registered services eagerly at scope creation - add instantiateAll to scope creation: every service registered for a scope tier is constructed when the scope is created, following the static dependency graph; a failing constructor fails scope creation - drop the hand-maintained eager-resolution lists: igniteEagerServices in AgentLifecycleService, the force-instantiated session services in SessionLifecycleService, and the kosong config bridge get in bootstrap - update DI docs and agent-core-dev skill guidance for the new semantics - adjust affected tests (registry hygiene, timer draining, listener ordering) and add scope-tree coverage for eager instantiation --- .agents/skills/agent-core-dev/implement.md | 10 ++- .changeset/eager-scope-instantiation.md | 5 ++ packages/agent-core-v2/docs/di.md | 11 +-- packages/agent-core-v2/src/_base/di/scope.ts | 34 +++++++++ .../src/agent/media/mediaToolsRegistrar.ts | 5 +- .../src/agent/plugin/agentPluginService.ts | 10 +-- .../toolRegistry/builtinToolsRegistrar.ts | 4 +- .../src/app/bootstrap/bootstrap.ts | 5 -- .../sessionLifecycleService.ts | 15 ++-- .../agentLifecycle/agentLifecycleService.ts | 59 ---------------- .../test/_base/di/scope-tree.test.ts | 50 ++++++++++++++ .../fullCompaction/fullCompaction.test.ts | 20 ++++-- .../test/agent/plan/plan.test.ts | 6 ++ .../test/agent/stepRetry/stepRetry.test.ts | 26 ++++++- .../app/bootstrap/bootstrapService.test.ts | 24 +++---- .../sessionAgentProfileCatalog.test.ts | 69 +++++++++++++++++-- .../sessionSkillCatalog/skillCatalog.test.ts | 34 +++++++-- 17 files changed, 267 insertions(+), 120 deletions(-) create mode 100644 .changeset/eager-scope-instantiation.md diff --git a/.agents/skills/agent-core-dev/implement.md b/.agents/skills/agent-core-dev/implement.md index 77a845773f..ac6d0906a0 100644 --- a/.agents/skills/agent-core-dev/implement.md +++ b/.agents/skills/agent-core-dev/implement.md @@ -127,16 +127,20 @@ export class WSBroadcastService extends Disposable implements IWSBroadcastServic ## §5 Eager vs delayed instantiation ```ts -// Eager: constructed when the scope is created +// Eager (default): constructed when the scope is created — registration alone +// (via module import) is enough, nobody has to `get()` it first registerScopedService(LifecycleScope.App, ILogService, LogService, InstantiationType.Eager, 'log'); -// Delayed: constructed on first get +// Delayed: scope creation materializes only a Proxy; the real constructor +// still defers to first property access registerScopedService(LifecycleScope.App, IScopeRegistry, ScopeRegistry, InstantiationType.Delayed, 'gateway'); ``` +When a scope (App / Session / Agent) is created, the container instantiates **every** service registered for that tier: the dependency graph is statically known from the `@IX` constructor metadata, so construction automatically follows dependency order, and cycles still throw `CyclicDependencyError`. A failing constructor fails the whole scope creation. + A `Delayed` service returns a **Proxy** that constructs the real instance on first property access. Listeners registered on its `onDid…` / `onWill…` events before construction are not lost — the container records them and replays the subscriptions once the instance exists. -> Rule of thumb: `Eager` for dependency-free, frequently-used, or "early side effect" services (e.g. `ILogService`); default to `Delayed` otherwise. +> Rule of thumb: keep the default `Eager` for everything; mark `Delayed` only when construction is genuinely expensive and you want it deferred (a legacy escape hatch — only a handful of services use it). ## §6 Using a service inside a plain function (`invokeFunction`) diff --git a/.changeset/eager-scope-instantiation.md b/.changeset/eager-scope-instantiation.md new file mode 100644 index 0000000000..bef351871d --- /dev/null +++ b/.changeset/eager-scope-instantiation.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Instantiate every registered service eagerly at scope creation on the experimental engine, following the dependency graph automatically, and drop the hand-maintained lists that resolved side-effect services one by one at startup. diff --git a/packages/agent-core-v2/docs/di.md b/packages/agent-core-v2/docs/di.md index 100978088d..1aeacdff0f 100644 --- a/packages/agent-core-v2/docs/di.md +++ b/packages/agent-core-v2/docs/di.md @@ -88,7 +88,7 @@ export * from './greetService'; // import 这一行即触发上面的 register export * from './greet/index'; ``` -于是「import 这个包」=「加载全部注册」。**没有中心装配文件**:绑定散落在各自域的实现文件里,靠 import 副作用收集。 +于是「import 这个包」=「加载全部注册」。**没有中心装配文件**:绑定散落在各自域的实现文件里,靠 import 副作用收集。而 Scope 创建时会把注册在该层的服务全部实例化(见场景 5),所以「import 即注册」就等于「注册即构造」——不需要在任何地方先 `get()` 一次来触发。 至此,任何人都能 `accessor.get(IGreeter)` 拿到这个全局唯一的服务。 @@ -216,13 +216,16 @@ export class FlagService extends Disposable implements IFlagService { 这一步引入:**`InstantiationType.Eager` vs `Delayed`**。 ```ts -// Eager(默认):第一次被解析(作为依赖或被 get)时同步构造,返回真实实例 +// Eager(默认):所在 Scope 创建时同步构造——import 触发注册,注册即被实例化, +// 不需要任何人事先 get registerScopedService(LifecycleScope.App, ILogService, LogService, InstantiationType.Eager, 'log'); -// Delayed:第一次被解析时先返回一个 Proxy,真实构造推迟到空闲时 +// Delayed:Scope 创建时只物化一个 Proxy,真实构造推迟到空闲时 registerScopedService(LifecycleScope.App, IScopeRegistry, ScopeRegistry, InstantiationType.Delayed, 'gateway'); ``` +每个 Scope(App / Session / Agent)创建时,容器会把注册在这一层的服务**全部**实例化:依赖图是静态已知的(`@IX` 记录在构造函数上),所以构造顺序自动按依赖关系拓扑展开,循环依赖照旧抛 `CyclicDependencyError`。任何一个服务构造失败,整个 Scope 创建失败。 + Delayed 服务返回的是一个 **Proxy**:真实构造推迟到空闲回调,或在首次访问其属性 / 调用其方法时立即发生。即便还没构造好,别人提前订阅它的 `onDid…` / `onWill…` 事件也不会丢——容器会先记下监听器,实例真正出来后再回放订阅。 > 经验:默认即 `Eager`,绝大多数服务无需关心。只有「构造很贵、想推迟到空闲时」的服务才显式标 `Delayed`(遗留逃生舱,见场景 9.4)。 @@ -308,7 +311,7 @@ export class ScopeRegistry implements IScopeRegistry { - `instantiation.createChild(collection)` 造一个子容器,它的父指针指向当前容器——于是子容器能向上解析到 App 的服务(场景 3 的可见性规则)。 - 给外部暴露时,用 `invokeFunction` 把子容器包成 `ServicesAccessor`(场景 6)。 -> 更高层通常直接用 [`Scope.createChild(kind, id)`](../src/_base/di/scope.ts)(它帮你做了「筛描述符 + 建子容器」);只有需要手动控制 `ServiceCollection` 时才像上面这样写。 +> 更高层通常直接用 [`Scope.createChild(kind, id)`](../src/_base/di/scope.ts)(它帮你做了「筛描述符 + 建子容器 + eager 实例化整层服务」);只有需要手动控制 `ServiceCollection` 时才像上面这样写——注意手动 `createChild` 不会自动实例化,这一层的 `Eager` 服务会退回到首次 `get` 才构造。 --- diff --git a/packages/agent-core-v2/src/_base/di/scope.ts b/packages/agent-core-v2/src/_base/di/scope.ts index e69ad91e36..023d01036e 100644 --- a/packages/agent-core-v2/src/_base/di/scope.ts +++ b/packages/agent-core-v2/src/_base/di/scope.ts @@ -89,6 +89,22 @@ function buildCollection(kind: LifecycleScope, extra?: ScopeSeed): ServiceCollec return collection; } +// Eagerly instantiate every service registered for this scope: importing a +// service module is enough — registration plus scope creation construct it, +// nobody has to `get()` it first. Iteration order is irrelevant because each +// `get` runs a DFS over the static dependency graph, so dependencies are +// always constructed before their dependents and cycles still throw +// `CyclicDependencyError`. `Delayed` descriptors only materialize their lazy +// proxy — the real constructor still defers to first property access. +function instantiateAll( + instantiation: IInstantiationService, + collection: ServiceCollection, +): void { + collection.forEach((id) => { + instantiation.invokeFunction((accessor) => accessor.get(id)); + }); +} + export function createScopedChildHandle( parent: IInstantiationService, kind: LifecycleScope, @@ -97,6 +113,12 @@ export function createScopedChildHandle( ): IScopeHandle { const collection = buildCollection(kind, options.extra); const child = parent.createChild(collection); + try { + instantiateAll(child, collection); + } catch (error) { + child.dispose(); + throw error; + } const accessor: ServicesAccessor = { get: (serviceId: ServiceIdentifier): T => child.invokeFunction((a) => a.get(serviceId)), @@ -127,6 +149,12 @@ export class Scope implements IDisposable { const kind = LifecycleScope.App; const collection = buildCollection(kind, options.extra); const instantiation = new InstantiationService(collection, true); + try { + instantiateAll(instantiation, collection); + } catch (error) { + instantiation.dispose(); + throw error; + } return new Scope(options.id ?? 'app', kind, instantiation); } @@ -148,6 +176,12 @@ export class Scope implements IDisposable { } const collection = buildCollection(kind, options.extra); const childInstantiation = this.instantiation.createChild(collection); + try { + instantiateAll(childInstantiation, collection); + } catch (error) { + childInstantiation.dispose(); + throw error; + } const child = new Scope(id, kind, childInstantiation, this); this.children.set(id, child); return child; diff --git a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts index 30848ad7a4..c8556a9142 100644 --- a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts +++ b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts @@ -17,9 +17,8 @@ * converts `video_url` takes the inline fallback when no upload hook * exists. * - * `AgentLifecycleService.create` force-instantiates this service right after - * the builtin-tools registrar, before any `opts.binding` bind runs, so the - * first `agent.status.updated` is always observed. + * Agent scope creation instantiates this service before any `opts.binding` + * bind runs, so the first `agent.status.updated` is always observed. */ import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index e972e92857..a36ddcde7b 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -4,8 +4,8 @@ * Renders session-start skills from `plugin` and `sessionSkillCatalog`, injects * them through `contextInjector` and `systemReminder`, and uses `contextMemory` * to neutralize stale guidance. Main-agent-only (v1 parity): the service - * self-gates on `agentId === 'main'`, and the agent bootstrap force-instantiates - * it (`igniteEagerServices`) so other agents construct it as a no-op. Resolves + * self-gates on `agentId === 'main'`; Agent scope creation instantiates it for + * every agent, so other agents construct it as a no-op. Resolves * session prompt context through `sessionContext` and reports missing skills * through `log`. Bound at Agent scope. */ @@ -49,9 +49,9 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic ) { super(); // Plugin session-start guidance is main-agent-only (v1 parity: - // `pluginSessionStarts: type === 'main' ? … : undefined`). The bootstrap - // force-instantiates this Delayed service for every agent - // (`igniteEagerServices`); non-main agents no-op. + // `pluginSessionStarts: type === 'main' ? … : undefined`). Agent scope + // creation instantiates this service for every agent; non-main agents + // no-op. if (scopeContext.agentId !== MAIN_AGENT_ID) return; this._register( injector.register( diff --git a/packages/agent-core-v2/src/agent/toolRegistry/builtinToolsRegistrar.ts b/packages/agent-core-v2/src/agent/toolRegistry/builtinToolsRegistrar.ts index 60b999d876..eea747232d 100644 --- a/packages/agent-core-v2/src/agent/toolRegistry/builtinToolsRegistrar.ts +++ b/packages/agent-core-v2/src/agent/toolRegistry/builtinToolsRegistrar.ts @@ -17,8 +17,8 @@ * has finished its own constructor and downstream `@IAgentToolRegistryService` * resolutions hit the cached instance instead of re-entering construction. * - * `AgentLifecycleService.create` force-instantiates this service on Agent scope - * creation so builtin tools land in the registry before the first turn. + * Agent scope creation eagerly instantiates this service, so builtin tools + * land in the registry before the first turn. */ import { Disposable } from '#/_base/di/lifecycle'; diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index 7bb520fbd0..9c54118cff 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -27,7 +27,6 @@ import { import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { IKosongConfigService } from '#/app/kosongConfig/kosongConfig'; export interface IBootstrapOptions { readonly homeDir: string; @@ -121,10 +120,6 @@ export function bootstrap(input: BootstrapInput = {}, extraSeeds: ScopeSeed = [] const app = createAppScope({ extra: [...bootstrapSeed(input), ...storageSeed(options), ...skillSeed(), ...extraSeeds], }); - // Instantiate the kosong persistence bridge eagerly: kosong's registries - // only become `ready` once the bridge has hydrated them from config, and - // Eager registration alone never constructs a service. - app.accessor.get(IKosongConfigService); return { app }; } diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index a84c196701..5ff74e2883 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -29,10 +29,11 @@ * rejects for a fatal explicit-source error, exactly the case that should * fail fast, and on that failure the half-materialized handle is disposed * instead of poisoning the session cache (the skill catalog, by contrast, is - * kicked fire-and-forget). The session-level eager services whose - * subscriptions must exist before the first agent / turn (external hooks, - * cron, the secondary-model startup warning) are force-instantiated at the - * same point. + * kicked fire-and-forget). The session-level services whose subscriptions + * must exist before the first agent / turn (external hooks, cron, the + * secondary-model startup warning) are constructed with the scope itself — + * Session scope creation eagerly instantiates every service registered at + * this tier. */ import { randomUUID } from 'node:crypto'; @@ -77,10 +78,7 @@ import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/ import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; import { ISessionMcpService } from '#/session/mcp/sessionMcp'; import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata'; -import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks'; import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; -import { ISessionCronService } from '#/session/cron/sessionCronService'; -import { ISessionSecondaryModelWarningService } from '#/session/subagent/secondaryModelWarning'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; @@ -222,9 +220,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec void handle.accessor.get(ISessionSkillCatalog).ready; await handle.accessor.get(ISessionAgentProfileCatalog).ready; await handle.accessor.get(ISessionMcpService).ensureMcpReady(opts.mcpServers); - handle.accessor.get(ISessionExternalHooksService); - handle.accessor.get(ISessionCronService); - handle.accessor.get(ISessionSecondaryModelWarningService); } catch (error) { handle.dispose(); throw error; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 14ac95eb10..070ab10f68 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -37,34 +37,17 @@ import { IEventBus } from '#/app/event/eventBus'; import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSection'; import { PermissionModeConfiguredModel } from '#/agent/permissionMode/permissionModeOps'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; -import { IAgentToolDedupeService } from '#/agent/toolDedupe/toolDedupe'; import { IAgentTaskService } from '#/agent/task/task'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionMcpService } from '#/session/mcp/sessionMcp'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentLoopService } from '#/agent/loop/loop'; -import { IAgentActivityView } from '#/agent/activityView/activityView'; import { IAgentProfileService } from '#/agent/profile/profile'; import { abortError } from '#/_base/utils/abort'; -import { IAgentLoopContinuationService } from '#/agent/loop/loopContinuation'; -import { IAgentStepRetryService } from '#/agent/stepRetry/stepRetry'; -import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; -import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import { IAgentPermissionGate } from '#/agent/permissionGate/permissionGate'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; -import { IAgentGoalService } from '#/agent/goal/goal'; -import { IAgentPlanService } from '#/agent/plan/plan'; -import { IAgentUserToolService } from '#/agent/userTool/userTool'; -import { IAgentBuiltinToolsRegistrar } from '#/agent/toolRegistry/builtinToolsRegistrar'; -import { IAgentMediaToolsRegistrar } from '#/agent/media/mediaTools'; -import { IImageConfigBridge } from '#/agent/media/imageConfigBridge'; -import { IAgentMcpService } from '#/agent/mcp/mcp'; -import { IAgentExternalHooksService } from '#/agent/externalHooks/externalHooks'; -import { IAgentPluginService } from '#/agent/plugin/agentPlugin'; import { ISessionInteractionService } from '#/session/interaction/interaction'; import { IWireService } from '#/wire/wire'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -205,7 +188,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle labels: opts.labels, }); this.onDidCreateEmitter.fire(handle); - this.igniteEagerServices(handle); await mcpReady; await wire.restore(); await this.bindBootstrap(handle, opts); @@ -222,47 +204,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle } } - // Force-instantiate the agent-scope observer/registrar services before the - // first turn: each exists only for its constructor side effects (registering - // built-in tools, subscribing hooks), so nothing injects them directly. - // `InstantiationType.Eager` does NOT auto-instantiate in this DI — it only - // skips the lazy proxy at resolve time — so they must be resolved here or - // their registrations (built-in tools, loop error handlers, MCP tools) would - // never happen. - private igniteEagerServices(handle: IAgentScopeHandle): void { - handle.accessor.get(IAgentBuiltinToolsRegistrar); - handle.accessor.get(IAgentMediaToolsRegistrar); - handle.accessor.get(IImageConfigBridge); - handle.accessor.get(IAgentToolDedupeService); - handle.accessor.get(IAgentExternalHooksService); - // The permission gate exists only to subscribe `onBeforeExecuteTool` from - // its constructor; the veto-event refactor removed the ordering-driven - // force-injections that used to pull it up, so it must be resolved here or - // tool execution would run without policy adjudication. - handle.accessor.get(IAgentPermissionGate); - handle.accessor.get(IAgentMcpService); - // Agent plugin service: registers main-agent-only plugin session-start - // guidance before the first turn (self-gates to a no-op for other agents). - handle.accessor.get(IAgentPluginService); - // Tool-select services: precompute tool selection and the announcements - // derived from it before the first turn. - handle.accessor.get(IAgentToolSelectService); - handle.accessor.get(IAgentToolSelectAnnouncementsService); - handle.accessor.get(IAgentStepRetryService); - handle.accessor.get(IAgentLoopContinuationService); - handle.accessor.get(IAgentContextMemoryService); - handle.accessor.get(IAgentContextInjectorService); - handle.accessor.get(IAgentGoalService); - handle.accessor.get(IAgentPlanService); - handle.accessor.get(IAgentTaskService); - handle.accessor.get(IAgentUserToolService); - handle.accessor.get(IAgentFullCompactionService); - // The activity view publishes `agent.activity.updated` from its constructor - // subscriptions; without an explicit resolve nothing injects it and the - // wire would never see the projection. - handle.accessor.get(IAgentActivityView); - } - private async bindBootstrap( handle: IAgentScopeHandle, opts: CreateAgentOptions, diff --git a/packages/agent-core-v2/test/_base/di/scope-tree.test.ts b/packages/agent-core-v2/test/_base/di/scope-tree.test.ts index 9f0bbb2217..f67c8f857d 100644 --- a/packages/agent-core-v2/test/_base/di/scope-tree.test.ts +++ b/packages/agent-core-v2/test/_base/di/scope-tree.test.ts @@ -182,4 +182,54 @@ describe('Scope tree', () => { expect(() => session.createChild(LifecycleScope.Agent, 'a1')).toThrow(/disposed/); app.dispose(); }); + + it('constructs every registered service at scope creation, in dependency order, without any get()', () => { + const events: string[] = []; + interface ITagged { + tag: string; + } + const IDep = createDecorator('tree-eager-dep'); + const ITop = createDecorator('tree-eager-top'); + _clearScopedRegistryForTests(); + class Dep implements ITagged { + tag = 'dep'; + constructor() { + events.push('dep'); + } + } + class Top implements ITagged { + tag = 'top'; + constructor(@IDep public readonly dep: ITagged) { + events.push('top'); + } + } + // Register the dependent BEFORE its dependency: construction order must + // come from the static dependency graph, not from registration order. + registerScopedService(LifecycleScope.Session, ITop, Top, InstantiationType.Eager); + registerScopedService(LifecycleScope.Session, IDep, Dep, InstantiationType.Eager); + + const app = createAppScope(); + app.createChild(LifecycleScope.Session, 's1'); + expect(events).toEqual(['dep', 'top']); + app.dispose(); + }); + + it('fails scope creation when a registered service constructor throws', () => { + interface IBoom { + tag: 'boom'; + } + const IBoom = createDecorator('tree-eager-boom'); + _clearScopedRegistryForTests(); + class Boom implements IBoom { + tag = 'boom' as const; + constructor() { + throw new Error('boom'); + } + } + registerScopedService(LifecycleScope.Session, IBoom, Boom, InstantiationType.Eager); + + const app = createAppScope(); + expect(() => app.createChild(LifecycleScope.Session, 's1')).toThrow(/boom/); + app.dispose(); + }); }); diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index d74d73dbd7..6160bf672e 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -36,7 +36,8 @@ import { MASTER_ENV } from '#/app/flag/flagService'; import { estimateTokensForMessages } from '#/kosong/contract/tokens'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } from '../../harness'; -import { appServices, createCommandRunner, execEnvServices, hostEnvironmentServices, sessionServices, testAgent } from '../../harness'; +import { agentService, appServices, createCommandRunner, execEnvServices, hostEnvironmentServices, sessionServices, testAgent } from '../../harness'; +import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements'; import { IAgentFullCompactionService, IModelOAuthTokens, @@ -1905,12 +1906,19 @@ describe('FullCompaction', () => { it('does not trigger auto compaction from a deferred loaded MCP schema', async () => { vi.stubEnv(MASTER_ENV, '1'); - const ctx = testAgent({ - initialConfig: { - providers: {}, - loopControl: { reservedContextSize: 0 }, + const ctx = testAgent( + // Scope creation eagerly constructs every registered agent-scope service, + // so the tool-select announcements service now runs in this harness. The + // loadable-tools reminder it would inject for the MCP tool registered + // below is unrelated to this test's assertions, so stub it out. + agentService(IAgentToolSelectAnnouncementsService, { _serviceBrand: undefined }), + { + initialConfig: { + providers: {}, + loopControl: { reservedContextSize: 0 }, + }, }, - }); + ); const parameters = { type: 'object', properties: { diff --git a/packages/agent-core-v2/test/agent/plan/plan.test.ts b/packages/agent-core-v2/test/agent/plan/plan.test.ts index 5bb2ab5c87..121b74cee9 100644 --- a/packages/agent-core-v2/test/agent/plan/plan.test.ts +++ b/packages/agent-core-v2/test/agent/plan/plan.test.ts @@ -2,6 +2,12 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; +// Imported first on purpose: scope creation now eagerly instantiates every +// registered service in registry (module evaluation) order, and +// `onBeforeExecuteTool` veto listeners fire in construction order. The plan +// guard must register before the permission gate (reached via `#/index` in +// the harness) so plan-file writes are allowed before deny rules adjudicate. +import '#/agent/plan/planService'; import type { ToolCall } from '#/kosong/contract/message'; import { dirname, isAbsolute, join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts b/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts index 6f3b27c203..7aee0f980a 100644 --- a/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts +++ b/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts @@ -13,6 +13,9 @@ import { ContinuationStepRequest } from '#/agent/loop/stepRequest'; import { createTestAgent, llmGenerateServices, type TestAgentContext } from '../../harness'; +// Captured before any `vi.useFakeTimers()` call, so this is always the real clock. +const realSetTimeout = globalThis.setTimeout; + describe('stepRetry plugin', () => { let ctx: TestAgentContext; @@ -34,7 +37,28 @@ describe('stepRetry plugin', () => { const loop = ctx.get(IAgentLoopService); loop.enqueue(new ContinuationStepRequest()); const resultPromise = loop.run({ turnId, signal }); - await vi.runAllTimersAsync(); + // Scope creation now eagerly instantiates every registered service, which + // adds real-async hops to the step pipeline. `runAllTimersAsync` can then + // return while the retry chain is parked on such a hop — before the next + // backoff timer has been scheduled — leaving that timer unfired forever. + // Keep draining, with a real-time yield between passes so the chain can + // schedule the next timer, until the turn settles. + let settled = false; + void resultPromise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + for (let i = 0; i < 100; i += 1) { + if (settled) break; + await vi.runAllTimersAsync(); + if (!settled) { + await new Promise((resolve) => realSetTimeout(resolve, 1)); + } + } return resultPromise; } diff --git a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts index b63104f28d..3291bd73f8 100644 --- a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts +++ b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts @@ -2,8 +2,9 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { InstantiationType } from '#/_base/di/extensions'; import type { ServiceIdentifier } from '#/_base/di/instantiation'; -import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope'; import { createScopedTestHost } from '#/_base/di/test'; +import { ILogService } from '#/_base/log/log'; import { IBootstrapService, bootstrap, @@ -11,16 +12,16 @@ import { resolveBootstrapOptions, } from '#/app/bootstrap/bootstrap'; import { BootstrapService } from '#/app/bootstrap/bootstrapService'; -import { IKosongConfigService } from '#/app/kosongConfig/kosongConfig'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { stubLog } from '../../_base/log/stubs'; + describe('BootstrapService (scoped)', () => { beforeEach(() => { - // No `_clearScopedRegistryForTests()` here: the registry is process-wide, - // and wiping it would break other suites sharing this worker. - // Re-registering is enough — later registrations win in the scope - // collection. + // Scope creation eagerly instantiates every registered service of the + // tier, so keep the registry minimal: only what this file needs. + _clearScopedRegistryForTests(); registerScopedService( LifecycleScope.App, IBootstrapService, @@ -58,14 +59,11 @@ describe('resolveBootstrapOptions', () => { describe('bootstrap() storage seeding', () => { it('seeds IFileSystemStorageService as a FileStorageService instance', () => { - // `bootstrap()` eagerly instantiates the kosong persistence bridge; stub - // it out so this test stays focused on the storage seed instead of - // pulling the whole config/kosong graph into the module imports. + // Scope creation eagerly instantiates everything in the collection, + // including the seeded `ISkillDiscovery` (FileSkillDiscovery), whose + // constructor needs `ILogService` — seed a stub for it. const { app } = bootstrap({ homeDir: '/tmp/kimi-home' }, [ - [ - IKosongConfigService as ServiceIdentifier, - { _serviceBrand: undefined, ready: Promise.resolve() }, - ], + [ILogService as ServiceIdentifier, stubLog()], ]); try { const storage = app.accessor.get(IFileSystemStorageService); diff --git a/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts b/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts index 3f6584158a..8a963c1308 100644 --- a/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts @@ -10,21 +10,46 @@ import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createScopedTestHost, stubPair } from '#/_base/di/test'; -import { LifecycleScope } from '#/_base/di/scope'; +import { + LifecycleScope, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; -import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { + DEFAULT_AGENT_PROFILE_NAME, + IAgentProfileCatalogService, +} from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { AgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalogService'; import { IAgentCatalogRuntimeOptions } from '#/app/agentFileCatalog/agentCatalogRuntimeOptions'; import { EXTRA_AGENT_DIRS_SECTION } from '#/app/agentFileCatalog/configSection'; -import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource'; +import { + IUserFileAgentSource, + UserFileAgentSource, +} from '#/app/agentFileCatalog/userFileAgentSource'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import '#/index'; -import { IExplicitFileAgentSource } from '#/session/sessionAgentProfileCatalog/explicitFileAgentSource'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { + ExplicitFileAgentSource, + IExplicitFileAgentSource, +} from '#/session/sessionAgentProfileCatalog/explicitFileAgentSource'; +import { + ExtraFileAgentSource, + IExtraFileAgentSource, +} from '#/session/sessionAgentProfileCatalog/extraFileAgentSource'; +import { + IProjectFileAgentSource, + ProjectFileAgentSource, +} from '#/session/sessionAgentProfileCatalog/projectFileAgentSource'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +import { SessionAgentProfileCatalogService } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { stubBootstrap } from '../../app/bootstrap/stubs'; @@ -182,6 +207,40 @@ function waitForEvent(event: Event): Promise { } describe('SessionAgentProfileCatalogService', () => { + beforeEach(() => { + // Scope creation now eagerly instantiates every service registered for + // that tier. `import '#/index'` fills the registry with the whole + // product graph (much of which has unstubbed dependencies), so clear it + // and re-register only the real services this suite constructs; their + // other dependencies are seeded as stubs by `makeSession`. Builtin agent + // profile contributions accumulate in a separate module-level list at + // import time and are unaffected by the clear. + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.App, + IAgentProfileCatalogService, + AgentProfileCatalogService, + ); + registerScopedService(LifecycleScope.App, IUserFileAgentSource, UserFileAgentSource); + registerScopedService(LifecycleScope.App, IHostFileSystem, HostFileSystem); + registerScopedService( + LifecycleScope.Session, + ISessionAgentProfileCatalog, + SessionAgentProfileCatalogService, + ); + registerScopedService( + LifecycleScope.Session, + IExplicitFileAgentSource, + ExplicitFileAgentSource, + ); + registerScopedService(LifecycleScope.Session, IExtraFileAgentSource, ExtraFileAgentSource); + registerScopedService( + LifecycleScope.Session, + IProjectFileAgentSource, + ProjectFileAgentSource, + ); + }); + it('lists builtin profiles when no agent directories exist', async () => { await withFixture(async (fixture) => { const { host, session } = makeSession(fixture); diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts index 104d2727d2..1fe1b37211 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts @@ -11,13 +11,18 @@ import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { createScopedTestHost, stubPair } from '#/_base/di/test'; -import { LifecycleScope } from '#/_base/di/scope'; +import { + _clearScopedRegistryForTests, + LifecycleScope, + registerScopedService, +} from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IPluginService } from '#/app/plugin/plugin'; +import { PluginService } from '#/app/plugin/pluginService'; import type { ReloadSummary } from '#/app/plugin/types'; import { IProviderService } from '#/kosong/provider/provider'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -27,11 +32,16 @@ import { MERGE_ALL_AVAILABLE_SKILLS_SECTION, } from '#/app/skillCatalog/configSection'; import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; -import '#/index'; +import { BuiltinSkillSource, IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; +import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery'; import type { SkillContribution } from '#/app/skillCatalog/skillSource'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; -import { IPluginSkillSource } from '#/session/sessionSkillCatalog/pluginSkillSource'; +import { SessionSkillCatalogService } from '#/session/sessionSkillCatalog/skillCatalogService'; +import { ExplicitFileSkillSource, IExplicitFileSkillSource } from '#/session/sessionSkillCatalog/explicitFileSkillSource'; +import { ExtraFileSkillSource, IExtraFileSkillSource } from '#/session/sessionSkillCatalog/extraFileSkillSource'; +import { IWorkspaceFileSkillSource, WorkspaceFileSkillSource } from '#/session/sessionSkillCatalog/workspaceFileSkillSource'; +import { IPluginSkillSource, PluginSkillSource } from '#/session/sessionSkillCatalog/pluginSkillSource'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import type { SkillRoot } from '#/app/skillCatalog/types'; @@ -196,6 +206,22 @@ async function withSkillCatalogWorkspace( } describe('SessionSkillCatalogService', () => { + beforeEach(() => { + // Scope creation now eagerly instantiates every service registered for + // that scope tier, so keep the scoped registry limited to the catalog + // chain these tests exercise; every other dependency arrives as a seeded + // stub via `createScopedTestHost`. + _clearScopedRegistryForTests(); + registerScopedService(LifecycleScope.App, IBuiltinSkillSource, BuiltinSkillSource); + registerScopedService(LifecycleScope.App, IUserFileSkillSource, UserFileSkillSource); + registerScopedService(LifecycleScope.App, IPluginService, PluginService); + registerScopedService(LifecycleScope.Session, ISessionSkillCatalog, SessionSkillCatalogService); + registerScopedService(LifecycleScope.Session, IExplicitFileSkillSource, ExplicitFileSkillSource); + registerScopedService(LifecycleScope.Session, IExtraFileSkillSource, ExtraFileSkillSource); + registerScopedService(LifecycleScope.Session, IWorkspaceFileSkillSource, WorkspaceFileSkillSource); + registerScopedService(LifecycleScope.Session, IPluginSkillSource, PluginSkillSource); + }); + it('merges global and project skills; project wins on name collision', async () => { const store = new InMemorySkillDiscovery(); store.setUserSkills([ From 1cbb8dbdc8554a4adee2828bc4f28381b0c0c2ae Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 24 Jul 2026 10:09:14 +0800 Subject: [PATCH 02/10] feat(agent-core-v2): add per-scope keyed state container (state domain) - add _base StateRegistry: typed StateKey/defineState descriptors with register/get/set, per-key onDidChange and global onDidChangeAny events, and BugIndicatingError on duplicate or unregistered key access - bind thin per-scope services at each tier: IStateService (App), ISessionStateService (Session), IAgentStateService (Agent), so scoped plain-data state lives in one observable container that dies with the scope - register the state domain in the layer check script and export the new services from the package index - add StateRegistry unit tests and scoped resolution tests --- .../scripts/check-domain-layers.mjs | 6 + .../src/_base/state/stateRegistry.ts | 94 +++++++++++ .../src/agent/state/agentState.ts | 20 +++ .../src/agent/state/agentStateService.ts | 25 +++ packages/agent-core-v2/src/app/state/state.ts | 20 +++ .../src/app/state/stateService.ts | 19 +++ packages/agent-core-v2/src/index.ts | 7 + .../src/session/state/sessionState.ts | 20 +++ .../src/session/state/sessionStateService.ts | 25 +++ .../test/_base/state/stateRegistry.test.ts | 149 ++++++++++++++++++ 10 files changed, 385 insertions(+) create mode 100644 packages/agent-core-v2/src/_base/state/stateRegistry.ts create mode 100644 packages/agent-core-v2/src/agent/state/agentState.ts create mode 100644 packages/agent-core-v2/src/agent/state/agentStateService.ts create mode 100644 packages/agent-core-v2/src/app/state/state.ts create mode 100644 packages/agent-core-v2/src/app/state/stateService.ts create mode 100644 packages/agent-core-v2/src/session/state/sessionState.ts create mode 100644 packages/agent-core-v2/src/session/state/sessionStateService.ts create mode 100644 packages/agent-core-v2/test/_base/state/stateRegistry.test.ts diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 5bc5f5580e..000e95598e 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -102,6 +102,12 @@ const DOMAIN_LAYER = new Map([ // Depends only on `_base`; sits in L1 beside the other program-control // layer substrates. ['task', 1], + // `state` is the per-scope keyed state container (`IStateService` / + // `ISessionStateService` / `IAgentStateService`, one per scope tier under + // `app/state`, `session/state`, `agent/state` — all resolve to this domain). + // It wraps the `_base` `StateRegistry` and depends on nothing else, so any + // domain may hold its plain-data state through it; sits in L1 beside `event`. + ['state', 1], // persistence/ and os/ — the two-level scopes. `interface` holds contracts // (same layer as the old domains they replace); `backends` holds // implementations that may depend on cross-domain services at various layers. diff --git a/packages/agent-core-v2/src/_base/state/stateRegistry.ts b/packages/agent-core-v2/src/_base/state/stateRegistry.ts new file mode 100644 index 0000000000..d9e09b1b2b --- /dev/null +++ b/packages/agent-core-v2/src/_base/state/stateRegistry.ts @@ -0,0 +1,94 @@ +/** + * `state` domain (L0) — scope-agnostic keyed state container primitives. + * + * Owns the typed `StateKey` / `defineState(name, initial)` descriptor (the + * state counterpart of wire's `defineModel`), the `IStateRegistry` base + * interface shared by the per-scope state services (`IStateService` / + * `ISessionStateService` / `IAgentStateService`), 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, `entries()` as a + * debugging snapshot). Misuse (duplicate registration, reading or writing an + * unregistered key) is a caller bug and raises `BugIndicatingError`. + * + * Values are stored as-is — the container does not freeze or clone, so + * replacing the whole value via `set` is the recommended update style; + * mutating a held `Map` / `Set` in place bypasses change notification. + * Persistence and replay are out of scope here: durable, replayable state + * belongs to wire Models. Scope-agnostic. + */ + +import { Disposable } from '../di/lifecycle'; +import { BugIndicatingError } from '../errors/errors'; +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 }; +} + +export interface StateChange { + readonly key: string; + readonly value: unknown; +} + +export interface IStateRegistry { + register(key: StateKey): void; + has(key: StateKey): boolean; + get(key: StateKey): T; + set(key: StateKey, value: T): void; + onDidChange(key: StateKey): Event; + readonly onDidChangeAny: Event; + entries(): readonly [string, unknown][]; +} + +export class StateRegistry extends Disposable implements IStateRegistry { + private readonly values = new Map(); + private readonly keyEmitters = new Map>(); + private readonly anyEmitter = this._register(new Emitter()); + readonly onDidChangeAny: Event = this.anyEmitter.event; + + register(key: StateKey): void { + if (this.values.has(key.name)) { + throw new BugIndicatingError(`state key '${key.name}' is already registered`); + } + this.values.set(key.name, key.initial()); + } + + has(key: StateKey): boolean { + return this.values.has(key.name); + } + + get(key: StateKey): T { + if (!this.values.has(key.name)) { + throw new BugIndicatingError(`state key '${key.name}' is not registered`); + } + return this.values.get(key.name) as T; + } + + set(key: StateKey, value: T): void { + if (!this.values.has(key.name)) { + throw new BugIndicatingError(`state key '${key.name}' is not registered`); + } + this.values.set(key.name, value); + this.keyEmitters.get(key.name)?.fire(value); + this.anyEmitter.fire({ key: key.name, value }); + } + + onDidChange(key: StateKey): Event { + let emitter = this.keyEmitters.get(key.name); + if (emitter === undefined) { + emitter = this._register(new Emitter()); + this.keyEmitters.set(key.name, emitter); + } + return emitter.event as Event; + } + + entries(): readonly [string, unknown][] { + return Array.from(this.values.entries()); + } +} diff --git a/packages/agent-core-v2/src/agent/state/agentState.ts b/packages/agent-core-v2/src/agent/state/agentState.ts new file mode 100644 index 0000000000..e0d513f549 --- /dev/null +++ b/packages/agent-core-v2/src/agent/state/agentState.ts @@ -0,0 +1,20 @@ +/** + * `state` domain (L1) — Agent-scope keyed state container contract. + * + * Defines `IAgentStateService`, the Agent-scope state service: Agent-tier + * services declare their plain-data state as typed keys (`defineState` from + * `_base`) and read/write them through this container, so per-agent shared + * state lives in one observable place and dies with the agent. Shares the + * `IStateRegistry` method set with its App/Session counterparts. Bound at + * Agent scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IStateRegistry } from '#/_base/state/stateRegistry'; + +export interface IAgentStateService extends IStateRegistry { + readonly _serviceBrand: undefined; +} + +export const IAgentStateService: ServiceIdentifier = + createDecorator('agentStateService'); diff --git a/packages/agent-core-v2/src/agent/state/agentStateService.ts b/packages/agent-core-v2/src/agent/state/agentStateService.ts new file mode 100644 index 0000000000..eccb69ddec --- /dev/null +++ b/packages/agent-core-v2/src/agent/state/agentStateService.ts @@ -0,0 +1,25 @@ +/** + * `state` domain (L1) — `IAgentStateService` implementation. + * + * Thin per-scope binding over the `_base` `StateRegistry`; the container owns + * construction and disposal, so registered state dies with the scope. Bound + * at Agent scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { StateRegistry } from '#/_base/state/stateRegistry'; + +import { IAgentStateService } from './agentState'; + +export class AgentStateService extends StateRegistry implements IAgentStateService { + declare readonly _serviceBrand: undefined; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentStateService, + AgentStateService, + InstantiationType.Eager, + 'state', +); diff --git a/packages/agent-core-v2/src/app/state/state.ts b/packages/agent-core-v2/src/app/state/state.ts new file mode 100644 index 0000000000..36ff553bce --- /dev/null +++ b/packages/agent-core-v2/src/app/state/state.ts @@ -0,0 +1,20 @@ +/** + * `state` domain (L1) — App-scope keyed state container contract. + * + * Defines `IStateService`, the App-scope state service: App-tier services + * declare their plain-data state as typed keys (`defineState` from `_base`) + * and read/write them through this container, so process-wide shared state + * lives in one observable place instead of scattering across private fields. + * Shares the `IStateRegistry` method set with its Session/Agent counterparts. + * Bound at App scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IStateRegistry } from '#/_base/state/stateRegistry'; + +export interface IStateService extends IStateRegistry { + readonly _serviceBrand: undefined; +} + +export const IStateService: ServiceIdentifier = + createDecorator('stateService'); diff --git a/packages/agent-core-v2/src/app/state/stateService.ts b/packages/agent-core-v2/src/app/state/stateService.ts new file mode 100644 index 0000000000..6719465d33 --- /dev/null +++ b/packages/agent-core-v2/src/app/state/stateService.ts @@ -0,0 +1,19 @@ +/** + * `state` domain (L1) — `IStateService` implementation. + * + * Thin per-scope binding over the `_base` `StateRegistry`; the container owns + * construction and disposal, so registered state dies with the scope. Bound + * at App scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { StateRegistry } from '#/_base/state/stateRegistry'; + +import { IStateService } from './state'; + +export class StateService extends StateRegistry implements IStateService { + declare readonly _serviceBrand: undefined; +} + +registerScopedService(LifecycleScope.App, IStateService, StateService, InstantiationType.Eager, 'state'); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index e11416f137..1a76e63919 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -60,6 +60,13 @@ import '#/app/event/eventBusService'; import '#/app/event/eventService'; export { IEventBus, type DomainEvent } from '#/app/event/eventBus'; export { IEventService, type DomainEvent as GlobalEvent } from '#/app/event/event'; +export * from '#/_base/state/stateRegistry'; +export * from '#/app/state/state'; +import '#/app/state/stateService'; +export * from '#/session/state/sessionState'; +import '#/session/state/sessionStateService'; +export * from '#/agent/state/agentState'; +import '#/agent/state/agentStateService'; export * from '#/kosong/contract/capability'; export * from '#/kosong/contract/errors'; export * from '#/kosong/contract/message'; diff --git a/packages/agent-core-v2/src/session/state/sessionState.ts b/packages/agent-core-v2/src/session/state/sessionState.ts new file mode 100644 index 0000000000..33d0ddffcc --- /dev/null +++ b/packages/agent-core-v2/src/session/state/sessionState.ts @@ -0,0 +1,20 @@ +/** + * `state` domain (L1) — Session-scope keyed state container contract. + * + * Defines `ISessionStateService`, the Session-scope state service: + * Session-tier services declare their plain-data state as typed keys + * (`defineState` from `_base`) and read/write them through this container, so + * per-session shared state lives in one observable place and dies with the + * session. Shares the `IStateRegistry` method set with its App/Agent + * counterparts. Bound at Session scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IStateRegistry } from '#/_base/state/stateRegistry'; + +export interface ISessionStateService extends IStateRegistry { + readonly _serviceBrand: undefined; +} + +export const ISessionStateService: ServiceIdentifier = + createDecorator('sessionStateService'); diff --git a/packages/agent-core-v2/src/session/state/sessionStateService.ts b/packages/agent-core-v2/src/session/state/sessionStateService.ts new file mode 100644 index 0000000000..6e16f6b310 --- /dev/null +++ b/packages/agent-core-v2/src/session/state/sessionStateService.ts @@ -0,0 +1,25 @@ +/** + * `state` domain (L1) — `ISessionStateService` implementation. + * + * Thin per-scope binding over the `_base` `StateRegistry`; the container owns + * construction and disposal, so registered state dies with the scope. Bound + * at Session scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { StateRegistry } from '#/_base/state/stateRegistry'; + +import { ISessionStateService } from './sessionState'; + +export class SessionStateService extends StateRegistry implements ISessionStateService { + declare readonly _serviceBrand: undefined; +} + +registerScopedService( + LifecycleScope.Session, + ISessionStateService, + SessionStateService, + InstantiationType.Eager, + 'state', +); diff --git a/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts b/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts new file mode 100644 index 0000000000..380da619c4 --- /dev/null +++ b/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { + LifecycleScope, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { createScopedTestHost, type ScopedTestHost } from '#/_base/di/test'; +import { BugIndicatingError } from '#/_base/errors/errors'; +import { defineState, StateRegistry, type StateChange } from '#/_base/state/stateRegistry'; +import { IStateService } from '#/app/state/state'; +import { StateService } from '#/app/state/stateService'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { AgentStateService } from '#/agent/state/agentStateService'; + +describe('StateRegistry', () => { + const countKey = defineState('test.count', () => 0); + const nameKey = defineState('test.name', () => 'anonymous'); + + it('returns the initial value after register', () => { + const registry = new StateRegistry(); + registry.register(countKey); + expect(registry.get(countKey)).toBe(0); + }); + + it('reads back the value written by set', () => { + const registry = new StateRegistry(); + registry.register(countKey); + registry.set(countKey, 42); + expect(registry.get(countKey)).toBe(42); + }); + + it('reports registered keys through has and entries', () => { + const registry = new StateRegistry(); + expect(registry.has(countKey)).toBe(false); + registry.register(countKey); + registry.register(nameKey); + expect(registry.has(countKey)).toBe(true); + expect(registry.entries()).toEqual([ + ['test.count', 0], + ['test.name', 'anonymous'], + ]); + }); + + it('rejects duplicate registration', () => { + const registry = new StateRegistry(); + registry.register(countKey); + expect(() => registry.register(countKey)).toThrow(BugIndicatingError); + }); + + it('rejects get and set on an unregistered key', () => { + const registry = new StateRegistry(); + expect(() => registry.get(countKey)).toThrow(BugIndicatingError); + expect(() => registry.set(countKey, 1)).toThrow(BugIndicatingError); + }); + + it('notifies onDidChange only for the key that was set', () => { + const registry = new StateRegistry(); + registry.register(countKey); + registry.register(nameKey); + const seen: number[] = []; + registry.onDidChange(countKey)((value) => seen.push(value)); + registry.set(nameKey, 'bob'); + expect(seen).toEqual([]); + registry.set(countKey, 7); + expect(seen).toEqual([7]); + }); + + it('notifies onDidChangeAny for every set', () => { + const registry = new StateRegistry(); + registry.register(countKey); + registry.register(nameKey); + const seen: StateChange[] = []; + registry.onDidChangeAny((change) => seen.push(change)); + registry.set(countKey, 1); + registry.set(nameKey, 'alice'); + expect(seen).toEqual([ + { key: 'test.count', value: 1 }, + { key: 'test.name', value: 'alice' }, + ]); + }); + + it('silences change events after dispose', () => { + const registry = new StateRegistry(); + registry.register(countKey); + const seen: StateChange[] = []; + registry.onDidChangeAny((change) => seen.push(change)); + registry.dispose(); + registry.set(countKey, 1); + expect(seen).toEqual([]); + expect(registry.get(countKey)).toBe(1); + }); +}); + +describe('state services (scoped)', () => { + let host: ScopedTestHost; + + beforeEach(() => { + _clearScopedRegistryForTests(); + registerScopedService(LifecycleScope.App, IStateService, StateService, InstantiationType.Eager, 'state'); + registerScopedService( + LifecycleScope.Session, + ISessionStateService, + SessionStateService, + InstantiationType.Eager, + 'state', + ); + registerScopedService( + LifecycleScope.Agent, + IAgentStateService, + AgentStateService, + InstantiationType.Eager, + 'state', + ); + host = createScopedTestHost(); + }); + + afterEach(() => host.dispose()); + + it('resolves a distinct state service per scope tier', () => { + const appState = host.app.accessor.get(IStateService); + const session = host.child(LifecycleScope.Session, 's1'); + const sessionState = session.accessor.get(ISessionStateService); + const agent = host.childOf(session, LifecycleScope.Agent, 'main'); + const agentState = agent.accessor.get(IAgentStateService); + expect(appState).not.toBe(sessionState); + expect(sessionState).not.toBe(agentState); + }); + + it('keeps registered state invisible to sibling scope tiers', () => { + const sessionKey = defineState('test.sessionOnly', () => 'seed'); + const session = host.child(LifecycleScope.Session, 's1'); + const sessionState = session.accessor.get(ISessionStateService); + sessionState.register(sessionKey); + sessionState.set(sessionKey, 'live'); + expect(sessionState.get(sessionKey)).toBe('live'); + const agent = host.childOf(session, LifecycleScope.Agent, 'main'); + expect(agent.accessor.get(IAgentStateService).has(sessionKey)).toBe(false); + expect(host.app.accessor.get(IStateService).has(sessionKey)).toBe(false); + }); + + it('resolves the same instance within one scope', () => { + const session = host.child(LifecycleScope.Session, 's1'); + expect(session.accessor.get(ISessionStateService)).toBe(session.accessor.get(ISessionStateService)); + }); +}); From 64135c1c5c78596322ad1439537851c22c308f2f Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 24 Jul 2026 10:33:54 +0800 Subject: [PATCH 03/10] refactor(agent-core-v2): move session-scope service state into ISessionStateService - add StateRegistry.snapshot() with JSON-safe serialization for Map/Set/Date/circular values - migrate plain-data fields of 13 session-scope services (cron, interaction, sessionActivity, agentProfileCatalog, fs, fsWatch, log, metadata, skillCatalog, toolPolicy, workspaceCommand, workspaceContext) to defineState keys registered in sessionState - register SessionStateService in affected tests and add test/state/stubs.ts helper --- .../src/_base/state/stateRegistry.ts | 43 +++++++++++ .../session/cron/sessionCronServiceImpl.ts | 59 +++++++++++++-- .../session/interaction/interactionService.ts | 39 +++++++++- .../sessionActivity/sessionActivityService.ts | 35 ++++++++- .../sessionAgentProfileCatalogService.ts | 37 ++++++++-- .../src/session/sessionFs/fsService.ts | 39 +++++++++- .../src/session/sessionFs/fsWatchService.ts | 73 +++++++++++++++++-- .../session/sessionLog/sessionLogService.ts | 31 ++++++-- .../sessionMetadata/sessionMetadataService.ts | 20 ++++- .../skillCatalogService.ts | 39 ++++++++-- .../sessionToolPolicyService.ts | 21 +++++- .../workspaceCommandService.ts | 19 ++++- .../workspaceContextService.ts | 41 +++++++++-- .../sessionLifecycle/sessionLifecycle.test.ts | 9 +++ .../test/session/approval/approval.test.ts | 3 + .../session/interaction/interaction.test.ts | 3 + .../test/session/question/question.test.ts | 3 + .../sessionActivityService.test.ts | 3 + .../sessionAgentProfileCatalog.test.ts | 10 +++ .../test/session/sessionFs/fsService.test.ts | 9 +++ .../session/sessionFs/fsWatchService.test.ts | 3 + .../sessionLog/sessionLogService.test.ts | 10 +++ .../sessionMetadata/sessionMetadata.test.ts | 17 ++++- .../sessionSkillCatalog/skillCatalog.test.ts | 10 +++ .../workspaceCommand/workspaceCommand.test.ts | 2 + packages/agent-core-v2/test/state/stubs.ts | 18 +++++ 26 files changed, 540 insertions(+), 56 deletions(-) create mode 100644 packages/agent-core-v2/test/state/stubs.ts diff --git a/packages/agent-core-v2/src/_base/state/stateRegistry.ts b/packages/agent-core-v2/src/_base/state/stateRegistry.ts index d9e09b1b2b..b6c2bed84a 100644 --- a/packages/agent-core-v2/src/_base/state/stateRegistry.ts +++ b/packages/agent-core-v2/src/_base/state/stateRegistry.ts @@ -44,6 +44,7 @@ export interface IStateRegistry { onDidChange(key: StateKey): Event; readonly onDidChangeAny: Event; entries(): readonly [string, unknown][]; + snapshot(): Record; } export class StateRegistry extends Disposable implements IStateRegistry { @@ -91,4 +92,46 @@ export class StateRegistry extends Disposable implements IStateRegistry { entries(): readonly [string, unknown][] { return Array.from(this.values.entries()); } + + snapshot(): Record { + const out: Record = {}; + for (const [key, value] of this.values) { + out[key] = toJsonSafe(value, new WeakSet()); + } + return out; + } +} + +function toJsonSafe(value: unknown, seen: WeakSet): unknown { + if (value === undefined || value === null) return null; + if (typeof value === 'function') return '(function)'; + if (typeof value === 'bigint') return value.toString(); + if (typeof value !== 'object') return value; + if (seen.has(value)) return '(circular)'; + seen.add(value); + try { + if (value instanceof Date) return value.toJSON(); + if (Array.isArray(value)) return value.map((item) => toJsonSafe(item, seen)); + if (value instanceof Map) { + const entries = [...value.entries()]; + const objectKeys = entries.every(([key]) => ['string', 'number'].includes(typeof key)); + if (objectKeys) { + return Object.fromEntries( + entries.map(([key, item]) => [key, toJsonSafe(item, seen)] as const), + ); + } + return entries.map(([key, item]) => [toJsonSafe(key, seen), toJsonSafe(item, seen)]); + } + if (value instanceof Set) { + return [...value.values()].map((item) => toJsonSafe(item, seen)); + } + const out: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (typeof item === 'function') continue; + out[key] = toJsonSafe(item, seen); + } + return out; + } finally { + seen.delete(value); + } } diff --git a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts index 58240710f7..d3698a0a3c 100644 --- a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts +++ b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts @@ -11,7 +11,10 @@ * through `IAgentPromptService` when a task fires, and registers the cron * tools (`CronCreate` / `CronList` / `CronDelete`) into the main agent's * `IAgentToolRegistryService` once `IAgentLifecycleService` signals - * `onDidCreateMain`. Bound at Session scope. + * `onDidCreateMain`. The plain-data state (`tasks`, `parsedCache`, + * `lastSeenAt`, `seededFromStore`, `inFlight`, `started`) is registered into + * `sessionState` (`ISessionStateService`) and read/written through it. Bound + * at Session scope. */ import { ulid } from 'ulid'; @@ -23,6 +26,7 @@ import { Disposable, toDisposable } from '#/_base/di/lifecycle'; import { InstantiationType } from '#/_base/di/extensions'; import { IInstantiationService } from '#/_base/di/instantiation'; import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/_base/state/stateRegistry'; import { IntervalTimer } from '#/_base/utils/timer'; import { IConfigService } from '#/app/config/config'; @@ -36,6 +40,7 @@ import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; import { renderCronFireXml } from '#/app/cron/format'; import { jitteredNextCronRunMs, oneShotJitteredNextCronRunMs } from '#/app/cron/jitter'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionStateService } from '#/session/state/sessionState'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentPromptService } from '#/agent/prompt/prompt'; @@ -57,6 +62,16 @@ export const CRON_FIRED = 'cron_fired' as const; export const CRON_MISSED = 'cron_missed' as const; export const CRON_DELETED = 'cron_deleted' as const; +export const cronTasksKey = defineState>('cron.tasks', () => new Map()); +export const cronParsedCacheKey = defineState>( + 'cron.parsedCache', + () => new Map(), +); +export const cronLastSeenAtKey = defineState>('cron.lastSeenAt', () => new Map()); +export const cronSeededFromStoreKey = defineState>('cron.seededFromStore', () => new Set()); +export const cronInFlightKey = defineState>('cron.inFlight', () => new Set()); +export const cronStartedKey = defineState('cron.started', () => false); + const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; const DEFAULT_POLL_INTERVAL_MS = 1_000; const MAX_COALESCE_ITERATIONS = 10_000; @@ -66,21 +81,16 @@ const MAX_ID_ATTEMPTS = 8; export class SessionCronServiceImpl extends Disposable implements ISessionCronService { declare readonly _serviceBrand: undefined; - private readonly tasks = new Map(); - private readonly parsedCache = new Map(); - private readonly lastSeenAt = new Map(); - private readonly seededFromStore = new Set(); - private readonly inFlight = new Set(); private readonly timer = this._register(new IntervalTimer({ unref: true })); private readonly persistQueues = new Map>(); private clocks: ClockSources = SYSTEM_CLOCKS; readonly isEnabled: boolean = true; - private started = false; private sigusr1Handler: NodeJS.SignalsListener | null = null; constructor( + @ISessionStateService private readonly states: ISessionStateService, @ISessionContext private readonly ctx: ISessionContext, @ICronTaskPersistence private readonly store: ICronTaskPersistence, @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @@ -89,6 +99,13 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe ) { super(); + this.states.register(cronTasksKey); + this.states.register(cronParsedCacheKey); + this.states.register(cronLastSeenAtKey); + this.states.register(cronSeededFromStoreKey); + this.states.register(cronInFlightKey); + this.states.register(cronStartedKey); + this._register( this.agentLifecycle.onDidCreate((handle) => { if (handle.id !== 'main') return; @@ -108,6 +125,34 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe ); } + private get tasks(): Map { + return this.states.get(cronTasksKey); + } + + private get parsedCache(): Map { + return this.states.get(cronParsedCacheKey); + } + + private get lastSeenAt(): Map { + return this.states.get(cronLastSeenAtKey); + } + + private get seededFromStore(): Set { + return this.states.get(cronSeededFromStoreKey); + } + + private get inFlight(): Set { + return this.states.get(cronInFlightKey); + } + + private get started(): boolean { + return this.states.get(cronStartedKey); + } + + private set started(value: boolean) { + this.states.set(cronStartedKey, value); + } + private bindMainAgent(handle: IAgentScopeHandle): void { const wire = handle.accessor.get(IWireService); this._register( diff --git a/packages/agent-core-v2/src/session/interaction/interactionService.ts b/packages/agent-core-v2/src/session/interaction/interactionService.ts index d2f7c514e0..2245d4e380 100644 --- a/packages/agent-core-v2/src/session/interaction/interactionService.ts +++ b/packages/agent-core-v2/src/session/interaction/interactionService.ts @@ -6,7 +6,9 @@ * request/resolution is also journaled as a persisted `interaction.request` / * `interaction.resolved` Op on the ORIGIN agent's wire (`origin.agentId ?? * 'main'`), so the journal can rebuild interaction entities on a cold - * transcript fold. `IAgentLifecycleService` is resolved lazily at dispatch + * transcript fold. The plain-data state (`pending`, `recentlyResolved`, + * `nextId`) is registered into `sessionState` (`ISessionStateService`) and + * read/written through it. `IAgentLifecycleService` is resolved lazily at dispatch * time (via `IInstantiationService.invokeFunction`) because the lifecycle * service already depends on this kernel for turn-end cancellation — a * constructor edge would close a DI cycle. Direct construction without a @@ -20,8 +22,10 @@ import { InstantiationType } from '#/_base/di/extensions'; import { IInstantiationService } from '#/_base/di/instantiation'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/_base/state/stateRegistry'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionStateService } from '#/session/state/sessionState'; import { IWireService } from '#/wire/wire'; import { @@ -44,21 +48,48 @@ const RECENTLY_RESOLVED_TTL_MS = 60_000; const RECENTLY_RESOLVED_MAX = 256; const MAIN_AGENT_ID = 'main'; +export const interactionPendingKey = defineState>( + 'interaction.pending', + () => new Map(), +); +export const interactionRecentlyResolvedKey = defineState>( + 'interaction.recentlyResolved', + () => new Map(), +); +export const interactionNextIdKey = defineState('interaction.nextId', () => 0); + export class SessionInteractionService extends Disposable implements ISessionInteractionService { declare readonly _serviceBrand: undefined; - private readonly pending = new Map(); - private readonly recentlyResolved = new Map(); private readonly _onDidChangePending = this._register(new Emitter()); readonly onDidChangePending: Event = this._onDidChangePending.event; private readonly _onDidResolve = this._register(new Emitter()); readonly onDidResolve: Event = this._onDidResolve.event; - private nextId = 0; constructor( + @ISessionStateService private readonly states: ISessionStateService, @IInstantiationService private readonly instantiation?: IInstantiationService, ) { super(); + this.states.register(interactionPendingKey); + this.states.register(interactionRecentlyResolvedKey); + this.states.register(interactionNextIdKey); + } + + private get pending(): Map { + return this.states.get(interactionPendingKey); + } + + private get recentlyResolved(): Map { + return this.states.get(interactionRecentlyResolvedKey); + } + + private get nextId(): number { + return this.states.get(interactionNextIdKey); + } + + private set nextId(value: number) { + this.states.set(interactionNextIdKey, value); } cancelPendingForTurn(turnId: number): void { diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts index 67e7a379ef..3e38f03b39 100644 --- a/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts @@ -6,18 +6,23 @@ * attach, `agent.activity.updated` over each agent's `event` bus afterwards) * — together with the pending-interaction set from `interaction` into the * session-level aggregate, and fires `onDidChange` with the domain cause - * only when the aggregate tuple actually changes. Bound at Session scope. + * only when the aggregate tuple actually changes. The plain-data state + * (`folds`, `current`) is registered into `sessionState` + * (`ISessionStateService`) and read/written through it. Bound at Session + * scope. */ import { InstantiationType } from '#/_base/di/extensions'; import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService, type IAgentScopeHandle } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; +import { defineState } from '#/_base/state/stateRegistry'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentActivityView, type AgentActivityState } from '#/agent/activityView/activityView'; import type { TurnEndReason } from '#/agent/loop/turnEvents'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionInteractionService, type Interaction } from '#/session/interaction/interaction'; +import { ISessionStateService } from '#/session/state/sessionState'; import { ISessionActivityView, @@ -34,21 +39,33 @@ interface AgentWorkFold { lastTurnReason?: SessionTurnOutcome; } +export const sessionActivityFoldsKey = defineState>( + 'sessionActivity.folds', + () => new Map(), +); +export const sessionActivityCurrentKey = defineState('sessionActivity.current', () => ({ + busy: false, + mainTurnActive: false, + pendingInteraction: 'none', + lastTurnReason: undefined, +})); + export class SessionActivityView extends Disposable implements ISessionActivityView { declare readonly _serviceBrand: undefined; private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; - private readonly folds = new Map(); private readonly agentSubscriptions = new Map(); - private current: SessionActivityState; constructor( + @ISessionStateService private readonly states: ISessionStateService, @IAgentLifecycleService private readonly agents: IAgentLifecycleService, @ISessionInteractionService private readonly interactions: ISessionInteractionService, ) { super(); + this.states.register(sessionActivityFoldsKey); + this.states.register(sessionActivityCurrentKey); for (const handle of this.agents.list()) this.attachAgent(handle); this.current = this.aggregate(); this._register( @@ -73,6 +90,18 @@ export class SessionActivityView extends Disposable implements ISessionActivityV ); } + private get folds(): Map { + return this.states.get(sessionActivityFoldsKey); + } + + private get current(): SessionActivityState { + return this.states.get(sessionActivityCurrentKey); + } + + private set current(value: SessionActivityState) { + this.states.set(sessionActivityCurrentKey, value); + } + state(): SessionActivityState { return this.current; } diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts index 049a880d91..1261bab68e 100644 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts @@ -17,7 +17,9 @@ * fixed, and `loadAll` merges whatever loaded even when a fatal source * rejects mid-pass. The swallowed handler on `ready` keeps an un-awaited * rejection from crashing the process, and event-driven reloads get the - * same warning treatment. + * same warning treatment. The plain-data state (`contributions`, `merged`) + * is registered into `sessionState` (`ISessionStateService`) and + * read/written through it. * Bound at Session scope. */ @@ -27,6 +29,7 @@ import { Emitter, type Event } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { isError2 } from '#/_base/errors/errors'; +import { defineState } from '#/_base/state/stateRegistry'; import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService, @@ -37,12 +40,21 @@ import type { IAgentProfileSource, } from '#/app/agentFileCatalog/agentProfileSource'; import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource'; +import { ISessionStateService } from '#/session/state/sessionState'; import { IExplicitFileAgentSource } from './explicitFileAgentSource'; import { IExtraFileAgentSource } from './extraFileAgentSource'; import { IProjectFileAgentSource } from './projectFileAgentSource'; import { ISessionAgentProfileCatalog } from './sessionAgentProfileCatalog'; +export const agentProfileCatalogContributionsKey = defineState< + Map +>('sessionAgentProfileCatalog.contributions', () => new Map()); +export const agentProfileCatalogMergedKey = defineState>( + 'sessionAgentProfileCatalog.merged', + () => new Map(), +); + export class SessionAgentProfileCatalogService extends Disposable implements ISessionAgentProfileCatalog @@ -50,17 +62,13 @@ export class SessionAgentProfileCatalogService declare readonly _serviceBrand: undefined; private readonly sources: readonly IAgentProfileSource[]; - private readonly contributions = new Map< - string, - { readonly c: AgentProfileContribution; readonly priority: number } - >(); private readonly sourceLoadTails = new Map>(); - private merged = new Map(); private readyPromise: Promise; private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; constructor( + @ISessionStateService private readonly states: ISessionStateService, @IAgentProfileCatalogService private readonly builtin: IAgentProfileCatalogService, @IUserFileAgentSource user: IUserFileAgentSource, @IExtraFileAgentSource extra: IExtraFileAgentSource, @@ -69,6 +77,8 @@ export class SessionAgentProfileCatalogService @ILogService private readonly log: ILogService, ) { super(); + this.states.register(agentProfileCatalogContributionsKey); + this.states.register(agentProfileCatalogMergedKey); this.sources = [user, extra, project, explicit].toSorted( (a, b) => a.priority - b.priority, ); @@ -88,6 +98,21 @@ export class SessionAgentProfileCatalogService void this.readyPromise.catch(() => undefined); } + private get contributions(): Map< + string, + { readonly c: AgentProfileContribution; readonly priority: number } + > { + return this.states.get(agentProfileCatalogContributionsKey); + } + + private get merged(): Map { + return this.states.get(agentProfileCatalogMergedKey); + } + + private set merged(value: Map) { + this.states.set(agentProfileCatalogMergedKey, value); + } + get ready(): Promise { return this.readyPromise; } diff --git a/packages/agent-core-v2/src/session/sessionFs/fsService.ts b/packages/agent-core-v2/src/session/sessionFs/fsService.ts index f4fec21bd2..3d7e706833 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsService.ts @@ -13,6 +13,8 @@ * check first, then re-verifies the candidate through `IHostFileSystem.realpath` * (resolving the longest existing prefix, so not-yet-created paths still work): * a symlink inside the workspace must not steer fs actions to files outside it. + * The plain-data state (`rgResolution`, `realRootsCache`) is registered into + * `sessionState` (`ISessionStateService`) and read/written through it. */ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'; @@ -62,6 +64,7 @@ import ignore, { type Ignore } from 'ignore'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/_base/state/stateRegistry'; import { buildEtag, countLines, @@ -75,6 +78,7 @@ import { IGitService } from '#/app/git/git'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IHostFileSystem, type HostDirEntry, type HostFileStat } from '#/os/interface/hostFileSystem'; import { ISessionProcessRunner } from '#/session/process/processRunner'; +import { ISessionStateService } from '#/session/state/sessionState'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { type FsDownloadResolved, type FsPathResolved, ISessionFsService } from './fs'; @@ -100,19 +104,38 @@ const FS_READ_MAX_BYTES = 10 * 1024 * 1024; const HIDDEN_NAME_RE = /^\./; const MACOS_NOISE = new Set(['.DS_Store', '.AppleDouble', '.LSOverride']); +export const sessionFsRgResolutionKey = defineState( + 'sessionFs.rgResolution', + () => undefined, +); +export const sessionFsRealRootsCacheKey = defineState< + { readonly key: string; readonly roots: readonly string[] } | undefined +>('sessionFs.realRootsCache', () => undefined); + export class SessionFsService implements ISessionFsService { declare readonly _serviceBrand: undefined; private readonly gitignoreCache = new Map(); - private rgResolution: RgResolution | null | undefined = undefined; constructor( + @ISessionStateService private readonly states: ISessionStateService, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IHostFileSystem private readonly hostFs: IHostFileSystem, @ISessionProcessRunner private readonly runner: ISessionProcessRunner, @ITelemetryService private readonly telemetry: ITelemetryService, @IGitService private readonly git: IGitService, - ) {} + ) { + this.states.register(sessionFsRgResolutionKey); + this.states.register(sessionFsRealRootsCacheKey); + } + + private get rgResolution(): RgResolution | null | undefined { + return this.states.get(sessionFsRgResolutionKey); + } + + private set rgResolution(value: RgResolution | null | undefined) { + this.states.set(sessionFsRgResolutionKey, value); + } private absOf(rel: string): string { return rel === '' || rel === '.' ? this.workspace.workDir : join(this.workspace.workDir, rel); @@ -693,7 +716,17 @@ export class SessionFsService implements ISessionFsService { return this.rgResolution; } - private realRootsCache: { readonly key: string; readonly roots: readonly string[] } | undefined; + private get realRootsCache(): + | { readonly key: string; readonly roots: readonly string[] } + | undefined { + return this.states.get(sessionFsRealRootsCacheKey); + } + + private set realRootsCache( + value: { readonly key: string; readonly roots: readonly string[] } | undefined, + ) { + this.states.set(sessionFsRealRootsCacheKey, value); + } private async realRoots(): Promise { const dirs = [this.workspace.workDir, ...this.workspace.additionalDirs]; diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts index a440595bd3..2af0bb3c3c 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts @@ -5,9 +5,11 @@ * events to the caller-declared subtree and to non-`.gitignore`d paths, * debounces them into fixed windows and re-exposes them as workspace-relative * `FsChangeEvent`s. The os watcher is started lazily on the first non-empty - * subscription and stopped when the subscription set becomes empty. Path - * confinement is lexical (`ISessionWorkspaceContext.isWithin`), matching - * `sessionFs`. + * subscription and stopped when the subscription set becomes empty. The + * plain-data state (`watched`, `pending`, `rawCount`, `truncated`, + * `gitignoreLoaded`) is registered into `sessionState` (`ISessionStateService`) + * and read/written through it. Path confinement is lexical + * (`ISessionWorkspaceContext.isWithin`), matching `sessionFs`. */ import { isAbsolute, join, relative, sep } from 'node:path'; @@ -18,6 +20,7 @@ import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/_base/state/stateRegistry'; import { ErrorCodes, Error2 } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { @@ -25,6 +28,7 @@ import { type IHostFsWatchHandle, IHostFsWatchService, } from '#/os/interface/hostFsWatch'; +import { ISessionStateService } from '#/session/state/sessionState'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import type { FsChangeEntry, FsChangeEvent } from './fsWatch'; @@ -40,20 +44,28 @@ function readPositiveIntEnv(name: string, fallback: number): number { return Number.isFinite(n) && n > 0 ? n : fallback; } +export const sessionFsWatchWatchedKey = defineState>( + 'sessionFsWatch.watched', + () => new Set(), +); +export const sessionFsWatchPendingKey = defineState('sessionFsWatch.pending', () => []); +export const sessionFsWatchRawCountKey = defineState('sessionFsWatch.rawCount', () => 0); +export const sessionFsWatchTruncatedKey = defineState('sessionFsWatch.truncated', () => false); +export const sessionFsWatchGitignoreLoadedKey = defineState( + 'sessionFsWatch.gitignoreLoaded', + () => false, +); + export class SessionFsWatchService extends Disposable implements ISessionFsWatchService { declare readonly _serviceBrand: undefined; private readonly emitter = this._register(new Emitter()); readonly onDidChangeFiles: Event = this.emitter.event; - private watched = new Set(); private handle: IHostFsWatchHandle | undefined; private handleSub: IDisposable | undefined; private debounceTimer: NodeJS.Timeout | undefined; - private pending: FsChangeEntry[] = []; - private rawCount = 0; - private truncated = false; private readonly debounceMs = readPositiveIntEnv( 'KIMI_CODE_FS_WATCH_DEBOUNCE_MS', @@ -65,14 +77,59 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch ); private readonly matcher: Ignore = ignore().add('.git/'); - private gitignoreLoaded = false; constructor( + @ISessionStateService private readonly states: ISessionStateService, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IHostFsWatchService private readonly hostFsWatch: IHostFsWatchService, @IHostFileSystem private readonly hostFs: IHostFileSystem, ) { super(); + this.states.register(sessionFsWatchWatchedKey); + this.states.register(sessionFsWatchPendingKey); + this.states.register(sessionFsWatchRawCountKey); + this.states.register(sessionFsWatchTruncatedKey); + this.states.register(sessionFsWatchGitignoreLoadedKey); + } + + private get watched(): Set { + return this.states.get(sessionFsWatchWatchedKey); + } + + private set watched(value: Set) { + this.states.set(sessionFsWatchWatchedKey, value); + } + + private get pending(): FsChangeEntry[] { + return this.states.get(sessionFsWatchPendingKey); + } + + private set pending(value: FsChangeEntry[]) { + this.states.set(sessionFsWatchPendingKey, value); + } + + private get rawCount(): number { + return this.states.get(sessionFsWatchRawCountKey); + } + + private set rawCount(value: number) { + this.states.set(sessionFsWatchRawCountKey, value); + } + + private get truncated(): boolean { + return this.states.get(sessionFsWatchTruncatedKey); + } + + private set truncated(value: boolean) { + this.states.set(sessionFsWatchTruncatedKey, value); + } + + private get gitignoreLoaded(): boolean { + return this.states.get(sessionFsWatchGitignoreLoadedKey); + } + + private set gitignoreLoaded(value: boolean) { + this.states.set(sessionFsWatchGitignoreLoadedKey, value); } get watchedPaths(): readonly string[] { diff --git a/packages/agent-core-v2/src/session/sessionLog/sessionLogService.ts b/packages/agent-core-v2/src/session/sessionLog/sessionLogService.ts index 2b38f461c1..946f641a15 100644 --- a/packages/agent-core-v2/src/session/sessionLog/sessionLogService.ts +++ b/packages/agent-core-v2/src/session/sessionLog/sessionLogService.ts @@ -6,24 +6,43 @@ * path already identifies the session). Registered to the single `ILogService` * token at Session scope, so every Session/Agent consumer injecting * `@ILogService` lands here (Agent has no own binding and falls back to this). - * Flushes synchronously when the Session scope is disposed. + * Flushes synchronously when the Session scope is disposed. The plain-data + * state (`rootLevel`) is registered into `sessionState` + * (`ISessionStateService`) and read/written through it. */ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/_base/state/stateRegistry'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionStateService } from '#/session/state/sessionState'; import { ILogService, type LogLevel } from '#/_base/log/log'; import { createFileLogWriter, type FileLogWriter } from '#/_base/log/fileLog'; import { ILogOptions, resolveSessionLogPath } from '#/_base/log/logConfig'; import { BoundLogger, type LogLevelState } from '#/_base/log/logService'; +export const sessionLogRootLevelKey = defineState('sessionLog.rootLevel', () => ({ + level: 'info', +})); + +/** + * Registers the root level into `sessionState` and hands the stored object to + * the `BoundLogger` base, so base and subclass share one `LogLevelState`. + * Runs inside the `super(...)` arguments, where `this` is not yet available. + */ +function seedRootLevel(states: ISessionStateService, level: LogLevel): LogLevelState { + states.register(sessionLogRootLevelKey); + states.set(sessionLogRootLevelKey, { level }); + return states.get(sessionLogRootLevelKey); +} + export class SessionLogService extends BoundLogger implements ILogService { declare readonly _serviceBrand: undefined; private readonly sink: FileLogWriter; - private readonly rootLevel: LogLevelState; constructor( + @ISessionStateService private readonly states: ISessionStateService, @ILogOptions options: ILogOptions, @ISessionContext session: ISessionContext, ) { @@ -33,10 +52,12 @@ export class SessionLogService extends BoundLogger implements ILogService { files: options.sessionFiles, format: { omitContextKeys: ['sessionId'] }, }); - const rootLevel: LogLevelState = { level: options.level }; - super(sink, rootLevel, { sessionId: session.sessionId }); + super(sink, seedRootLevel(states, options.level), { sessionId: session.sessionId }); this.sink = sink; - this.rootLevel = rootLevel; + } + + private get rootLevel(): LogLevelState { + return this.states.get(sessionLogRootLevelKey); } get level(): LogLevel { diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 239abe87b8..208c1df43c 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -5,6 +5,8 @@ * access-pattern store (`IAtomicDocumentStore`), rooted at the `metaScope` * namespace from `sessionContext`. Loads the existing document on * construction (creating it on first run), and logs through `log`. The + * plain-data state (`data`) is registered into `sessionState` + * (`ISessionStateService`) and read/written through it. The * document always carries the `agents` / `custom` maps that v1's * `Session.resume()` reads unconditionally — seeded at creation, backfilled * and persisted on load for documents written before the seeding existed @@ -29,10 +31,12 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; +import { defineState } from '#/_base/state/stateRegistry'; import { IFlagService } from '#/app/flag/flag'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IQueryStore } from '#/persistence/interface/queryStore'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionStateService } from '#/session/state/sessionState'; import { ISessionMetadata, @@ -47,6 +51,11 @@ const META_KEY = 'state.json'; const SESSION_COLLECTION = 'session'; const READ_MODEL_FLAG = 'persistence_minidb_readmodel'; +export const sessionMetadataDataKey = defineState( + 'sessionMetadata.data', + () => undefined, +); + export class SessionMetadata extends Disposable implements ISessionMetadata { declare readonly _serviceBrand: undefined; readonly ready: Promise; @@ -57,9 +66,9 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { ); private readonly scope: string; private updateQueue: Promise = Promise.resolve(); - private data!: SessionMeta; constructor( + @ISessionStateService private readonly states: ISessionStateService, @ISessionContext private readonly ctx: ISessionContext, @IAtomicDocumentStore private readonly store: IAtomicDocumentStore, @ILogService private readonly log: ILogService, @@ -67,11 +76,20 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { @IFlagService private readonly flags: IFlagService, ) { super(); + this.states.register(sessionMetadataDataKey); this.scope = ctx.metaScope; this.onDidChangeMetadata = this._onDidChangeMetadata.event; this.ready = this.load(); } + private get data(): SessionMeta { + return this.states.get(sessionMetadataDataKey) as SessionMeta; + } + + private set data(value: SessionMeta) { + this.states.set(sessionMetadataDataKey, value); + } + async read(): Promise { await this.ready; return this.data; diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts index 5054c8b6dd..aaf533138d 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts @@ -3,19 +3,23 @@ * * Merges builtin, user, explicit, extra, workspace, and plugin skill sources * by priority, serializing refreshes for each source. Exposes the merged read - * view and accepts ad-hoc contributions through `ISkillCatalogSink`. Bound at - * Session scope. + * view and accepts ad-hoc contributions through `ISkillCatalogSink`. The + * plain-data state (`contributions`, `merged`) is registered into + * `sessionState` (`ISessionStateService`) and read/written through it. Bound + * at Session scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { InstantiationType } from '#/_base/di/extensions'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/_base/state/stateRegistry'; import { IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import type { ISkillSource, SkillContribution } from '#/app/skillCatalog/skillSource'; import type { SkillCatalog } from '#/app/skillCatalog/types'; import { IUserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; +import { ISessionStateService } from '#/session/state/sessionState'; import { IPluginSkillSource } from './pluginSkillSource'; import { IExtraFileSkillSource } from './extraFileSkillSource'; @@ -23,6 +27,14 @@ import { IExplicitFileSkillSource } from './explicitFileSkillSource'; import { ISessionSkillCatalog, type ISkillCatalogSink } from './skillCatalog'; import { IWorkspaceFileSkillSource } from './workspaceFileSkillSource'; +export const skillCatalogContributionsKey = defineState< + Map +>('sessionSkillCatalog.contributions', () => new Map()); +export const skillCatalogMergedKey = defineState( + 'sessionSkillCatalog.merged', + () => new InMemorySkillCatalog(), +); + export class SessionSkillCatalogService extends Disposable implements ISessionSkillCatalog, ISkillCatalogSink @@ -30,17 +42,13 @@ export class SessionSkillCatalogService declare readonly _serviceBrand: undefined; private readonly sources: readonly ISkillSource[]; - private readonly contributions = new Map< - string, - { readonly c: SkillContribution; readonly priority: number } - >(); private readonly sourceLoadTails = new Map>(); - private merged = new InMemorySkillCatalog(); readonly ready: Promise; private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; constructor( + @ISessionStateService private readonly states: ISessionStateService, @IBuiltinSkillSource builtin: IBuiltinSkillSource, @IUserFileSkillSource user: IUserFileSkillSource, @IExplicitFileSkillSource explicit: IExplicitFileSkillSource, @@ -49,6 +57,8 @@ export class SessionSkillCatalogService @IPluginSkillSource plugin: IPluginSkillSource, ) { super(); + this.states.register(skillCatalogContributionsKey); + this.states.register(skillCatalogMergedKey); this.sources = [builtin, user, explicit, extra, workspace, plugin].toSorted((a, b) => a.priority - b.priority); for (const s of this.sources) { if (s.onDidChange) this._register(s.onDidChange(() => { void this.reloadSource(s.id); })); @@ -56,6 +66,21 @@ export class SessionSkillCatalogService this.ready = this.loadAll(); } + private get contributions(): Map< + string, + { readonly c: SkillContribution; readonly priority: number } + > { + return this.states.get(skillCatalogContributionsKey); + } + + private get merged(): InMemorySkillCatalog { + return this.states.get(skillCatalogMergedKey); + } + + private set merged(value: InMemorySkillCatalog) { + this.states.set(skillCatalogMergedKey, value); + } + get catalog(): SkillCatalog { return this.merged; } diff --git a/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts b/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts index 1848669c92..5f0e0eaaa7 100644 --- a/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts +++ b/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts @@ -3,15 +3,19 @@ * * Stores the client-managed denylist as one atomic document below the session * scope and serializes replacements. A successful replacement awaits all - * registered Agent prompt refreshes before returning. Bound at Session scope. + * registered Agent prompt refreshes before returning. The plain-data state + * (`state`) is registered into `sessionState` (`ISessionStateService`) and + * read/written through it. Bound at Session scope. */ import { InstantiationType } from '#/_base/di/extensions'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { AsyncEmitter, type Event } from '#/_base/event'; +import { defineState } from '#/_base/state/stateRegistry'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionStateService } from '#/session/state/sessionState'; import { ISessionToolPolicy, @@ -22,6 +26,10 @@ interface SessionToolPolicyState { readonly disabledTools: readonly string[]; } +export const sessionToolPolicyStateKey = defineState('sessionToolPolicy.state', () => ({ + disabledTools: [], +})); + const STATE_KEY = 'state.json'; export class SessionToolPolicyService extends Disposable implements ISessionToolPolicy { @@ -34,18 +42,27 @@ export class SessionToolPolicyService extends Disposable implements ISessionTool ); private readonly scope: string; private updateQueue: Promise = Promise.resolve(); - private state: SessionToolPolicyState = { disabledTools: [] }; constructor( + @ISessionStateService private readonly states: ISessionStateService, @ISessionContext sessionContext: ISessionContext, @IAtomicDocumentStore private readonly store: IAtomicDocumentStore, ) { super(); + this.states.register(sessionToolPolicyStateKey); this.scope = sessionContext.scope('tool-policy'); this.onDidChange = this.changeEmitter.event; this.ready = this.load(); } + private get state(): SessionToolPolicyState { + return this.states.get(sessionToolPolicyStateKey); + } + + private set state(value: SessionToolPolicyState) { + this.states.set(sessionToolPolicyStateKey, value); + } + disabledTools(): readonly string[] { return this.state.disabledTools; } diff --git a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts index 62f16836bd..ad5c8d817b 100644 --- a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts +++ b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts @@ -4,16 +4,21 @@ * Coordinates session-level workspace mutations: resolves and persists * project-local config through `projectLocalConfig`, updates * `workspaceContext`, and mirrors command output into the main agent through - * `agentLifecycle` and `contextMemory`. Bound at Session scope. + * `agentLifecycle` and `contextMemory`. The plain-data state + * (`pendingMainInjections`) is registered into `sessionState` + * (`ISessionStateService`) and read/written through it. Bound at Session + * scope. */ import { InstantiationType } from '#/_base/di/extensions'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/_base/state/stateRegistry'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionStateService } from '#/session/state/sessionState'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { @@ -22,21 +27,27 @@ import { type WorkspaceAdditionalDirsResult, } from './workspaceCommand'; +export const workspaceCommandPendingMainInjectionsKey = defineState( + 'workspaceCommand.pendingMainInjections', + () => [], +); + export class SessionWorkspaceCommandService extends Disposable implements ISessionWorkspaceCommandService { declare readonly _serviceBrand: undefined; - private readonly pendingMainInjections: ContextMessage[] = []; private mutationQueue: Promise = Promise.resolve(); constructor( + @ISessionStateService private readonly states: ISessionStateService, @IProjectLocalConfigService private readonly localConfig: IProjectLocalConfigService, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IAgentLifecycleService private readonly agents: IAgentLifecycleService, ) { super(); + this.states.register(workspaceCommandPendingMainInjectionsKey); this._register( this.agents.onDidCreate((handle) => { if (handle.id !== MAIN_AGENT_ID) return; @@ -47,6 +58,10 @@ export class SessionWorkspaceCommandService ); } + private get pendingMainInjections(): ContextMessage[] { + return this.states.get(workspaceCommandPendingMainInjectionsKey); + } + async addAdditionalDir(input: AddAdditionalDirInput): Promise { return this.enqueueMutation(() => this.applyAddAdditionalDir(input)); } diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts index 1b82e492a1..1a44caf0dd 100644 --- a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts +++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts @@ -2,25 +2,54 @@ * `workspaceContext` domain (L1) — `ISessionWorkspaceContext` implementation. * * Holds the session work directory and additional dirs, resolves relative - * paths, and checks whether a path falls within the workspace. Bound at - * Session scope. + * paths, and checks whether a path falls within the workspace. The plain-data + * state (`workDir`, `additionalDirs`) is registered into `sessionState` + * (`ISessionStateService`) and read/written through it. Bound at Session + * scope. */ import { isAbsolute, relative, resolve } from 'node:path'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/_base/state/stateRegistry'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionStateService } from '#/session/state/sessionState'; import { ISessionWorkspaceContext, type PathAccessOperation } from './workspaceContext'; +export const workspaceContextWorkDirKey = defineState('workspaceContext.workDir', () => ''); +export const workspaceContextAdditionalDirsKey = defineState( + 'workspaceContext.additionalDirs', + () => [], +); + export class SessionWorkspaceContextService implements ISessionWorkspaceContext { declare readonly _serviceBrand: undefined; - private _workDir: string; - private _additionalDirs: string[] = []; - constructor(@ISessionContext ctx: ISessionContext) { - this._workDir = resolve(ctx.cwd); + constructor( + @ISessionStateService private readonly states: ISessionStateService, + @ISessionContext ctx: ISessionContext, + ) { + this.states.register(workspaceContextWorkDirKey); + this.states.register(workspaceContextAdditionalDirsKey); + this.setWorkDir(ctx.cwd); + } + + private get _workDir(): string { + return this.states.get(workspaceContextWorkDirKey); + } + + private set _workDir(value: string) { + this.states.set(workspaceContextWorkDirKey, value); + } + + private get _additionalDirs(): string[] { + return this.states.get(workspaceContextAdditionalDirsKey); + } + + private set _additionalDirs(value: string[]) { + this.states.set(workspaceContextAdditionalDirsKey, value); } get workDir(): string { diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index c044f62c6e..702ea6ef70 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -44,6 +44,8 @@ import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStor import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -982,6 +984,13 @@ describe('SessionLifecycleService', () => { describe('additional dirs', () => { beforeEach(() => { + registerScopedService( + LifecycleScope.Session, + ISessionStateService, + SessionStateService, + InstantiationType.Eager, + 'state', + ); registerScopedService( LifecycleScope.Session, ISessionWorkspaceContext, diff --git a/packages/agent-core-v2/test/session/approval/approval.test.ts b/packages/agent-core-v2/test/session/approval/approval.test.ts index 60fc01e30a..938369e25c 100644 --- a/packages/agent-core-v2/test/session/approval/approval.test.ts +++ b/packages/agent-core-v2/test/session/approval/approval.test.ts @@ -9,6 +9,8 @@ import { type ApprovalRequest, ISessionApprovalService } from '#/session/approva import { SessionApprovalService } from '#/session/approval/approvalService'; import { ISessionInteractionService } from '#/session/interaction/interaction'; import { SessionInteractionService } from '#/session/interaction/interactionService'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; const display: ToolInputDisplay = { kind: 'command', command: 'bash' }; @@ -30,6 +32,7 @@ describe('SessionApprovalService', () => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); ix.stub(IEventBus, noopEventBus); + ix.set(ISessionStateService, new SessionStateService()); ix.set(ISessionInteractionService, new SyncDescriptor(SessionInteractionService)); ix.set(ISessionApprovalService, new SyncDescriptor(SessionApprovalService)); }); diff --git a/packages/agent-core-v2/test/session/interaction/interaction.test.ts b/packages/agent-core-v2/test/session/interaction/interaction.test.ts index d8eecc88b7..e6d710da2b 100644 --- a/packages/agent-core-v2/test/session/interaction/interaction.test.ts +++ b/packages/agent-core-v2/test/session/interaction/interaction.test.ts @@ -17,6 +17,8 @@ import { interactionResolved, } from '#/session/interaction/interactionOps'; import { SessionInteractionService } from '#/session/interaction/interactionService'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; import { IWireService } from '#/wire/wire'; import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; @@ -65,6 +67,7 @@ describe('SessionInteractionService', () => { _serviceBrand: undefined, get: (id: string) => agents.get(id)?.handle, } as unknown as IAgentLifecycleService); + ix.set(ISessionStateService, new SessionStateService()); ix.set(ISessionInteractionService, new SyncDescriptor(SessionInteractionService)); }); afterEach(() => disposables.dispose()); diff --git a/packages/agent-core-v2/test/session/question/question.test.ts b/packages/agent-core-v2/test/session/question/question.test.ts index 867554a8bd..0ccb71a2bf 100644 --- a/packages/agent-core-v2/test/session/question/question.test.ts +++ b/packages/agent-core-v2/test/session/question/question.test.ts @@ -14,6 +14,8 @@ import { ISessionInteractionService } from '#/session/interaction/interaction'; import { SessionInteractionService } from '#/session/interaction/interactionService'; import { type QuestionRequest, ISessionQuestionService } from '#/session/question/question'; import { SessionQuestionService } from '#/session/question/questionService'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; const noopEventBus: IEventBus = { _serviceBrand: undefined, @@ -41,6 +43,7 @@ describe('ISessionQuestionService (Session scope facade over the interaction ker beforeEach(() => { _clearScopedRegistryForTests(); + registerScopedService(LifecycleScope.Session, ISessionStateService, SessionStateService, InstantiationType.Eager, 'state'); registerScopedService(LifecycleScope.Session, ISessionInteractionService, SessionInteractionService, InstantiationType.Delayed, 'interaction'); registerScopedService(LifecycleScope.Session, ISessionQuestionService, SessionQuestionService, InstantiationType.Delayed, 'question'); diff --git a/packages/agent-core-v2/test/session/sessionActivity/sessionActivityService.test.ts b/packages/agent-core-v2/test/session/sessionActivity/sessionActivityService.test.ts index 84aec6e21f..3c5905033f 100644 --- a/packages/agent-core-v2/test/session/sessionActivity/sessionActivityService.test.ts +++ b/packages/agent-core-v2/test/session/sessionActivity/sessionActivityService.test.ts @@ -21,6 +21,8 @@ import { type SessionActivityChangedEvent, } from '#/session/sessionActivity/sessionActivity'; import { SessionActivityView } from '#/session/sessionActivity/sessionActivityService'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; class FakeBus implements IEventBus { declare readonly _serviceBrand: undefined; @@ -142,6 +144,7 @@ describe('ISessionActivityView (Session scope aggregate of agent activity + inte beforeEach(() => { _clearScopedRegistryForTests(); + registerScopedService(LifecycleScope.Session, ISessionStateService, SessionStateService, InstantiationType.Eager, 'state'); registerScopedService(LifecycleScope.Session, ISessionInteractionService, SessionInteractionService, InstantiationType.Delayed, 'interaction'); registerScopedService(LifecycleScope.Session, IAgentLifecycleService, FakeAgentLifecycle, InstantiationType.Delayed, 'agentLifecycle'); registerScopedService(LifecycleScope.Session, ISessionActivityView, SessionActivityView, InstantiationType.Delayed, 'sessionActivity'); diff --git a/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts b/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts index 8a963c1308..c53bbc7e3f 100644 --- a/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts @@ -13,6 +13,7 @@ import { join } from 'pathe'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, _clearScopedRegistryForTests, @@ -50,6 +51,8 @@ import { } from '#/session/sessionAgentProfileCatalog/projectFileAgentSource'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { SessionAgentProfileCatalogService } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { stubBootstrap } from '../../app/bootstrap/stubs'; @@ -216,6 +219,13 @@ describe('SessionAgentProfileCatalogService', () => { // profile contributions accumulate in a separate module-level list at // import time and are unaffected by the clear. _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.Session, + ISessionStateService, + SessionStateService, + InstantiationType.Eager, + 'state', + ); registerScopedService( LifecycleScope.App, IAgentProfileCatalogService, diff --git a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts index 32de5cef24..58e18f566b 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts @@ -16,6 +16,8 @@ import { type HostDirEntry, IHostFileSystem } from '#/os/interface/hostFileSyste import { ISessionFsService } from '#/session/sessionFs/fs'; import { SessionFsService } from '#/session/sessionFs/fsService'; import { ISessionProcessRunner, type IProcess } from '#/session/process/processRunner'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; import { ITelemetryService, type TelemetryProperties } from '#/app/telemetry/telemetry'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -296,6 +298,13 @@ function telemetryStub(events: Array<{ event: string; properties: Record { _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.Session, + ISessionStateService, + SessionStateService, + InstantiationType.Eager, + 'state', + ); registerScopedService( LifecycleScope.Session, ISessionFsService, diff --git a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts index 08868dd713..1889c456ce 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts @@ -16,6 +16,8 @@ import { type IHostFsWatchHandle, IHostFsWatchService, } from '#/os/interface/hostFsWatch'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import type { FsChangeEvent } from '#/session/sessionFs/fsWatch'; @@ -104,6 +106,7 @@ function makeSession(gitignore?: string): Harness { const watch = fakeHostFsWatch(); const host = createScopedTestHost(); const session = host.child(LifecycleScope.Session, 's1', [ + stubPair(ISessionStateService, new SessionStateService()), stubPair(ISessionWorkspaceContext, stubWorkspace()), stubPair(IHostFsWatchService, watch.service), stubPair(IHostFileSystem, fakeHostFs(gitignore)), diff --git a/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts b/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts index c64ab3d755..a63f74d2c5 100644 --- a/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts +++ b/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts @@ -20,12 +20,21 @@ import { import { AppLogService } from '#/_base/log/logService'; import { SessionLogService } from '#/session/sessionLog/sessionLogService'; import { makeSessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; let homeDir: string; let sessionDir: string; beforeEach(async () => { _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.Session, + ISessionStateService, + SessionStateService, + InstantiationType.Eager, + 'state', + ); registerScopedService( LifecycleScope.Session, ILogService, @@ -132,6 +141,7 @@ describe('SessionLogService', () => { describe('ILogService cross-scope resolution', () => { beforeEach(() => { _clearScopedRegistryForTests(); + registerScopedService(LifecycleScope.Session, ISessionStateService, SessionStateService, InstantiationType.Eager, 'state'); registerScopedService(LifecycleScope.App, ILogService, AppLogService, InstantiationType.Delayed, 'log'); registerScopedService(LifecycleScope.Session, ILogService, SessionLogService, InstantiationType.Delayed, 'log'); }); diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index b98e6b7c80..1a3048bfa9 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -2,12 +2,15 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; import { TestInstantiationService } from '#/_base/di/test'; import { IFlagService } from '#/app/flag/flag'; import { ILogService } from '#/_base/log/log'; import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { SessionMetadata } from '#/session/sessionMetadata/sessionMetadataService'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; @@ -20,6 +23,15 @@ import { stubQueryStore } from '../../persistence/interface/stubs'; const META_SCOPE = 'sessions/wd_test/s1/session-meta'; +// A re-constructed SessionMetadata stands for a new session lifetime: it gets +// its own state registry, so the shared `sessionMetadata.data` key registers +// cleanly instead of colliding with the first instance's registration. +function createFreshMetadata(ix: TestInstantiationService): SessionMetadata { + return ix + .createChild(new ServiceCollection([ISessionStateService, new SessionStateService()])) + .createInstance(SessionMetadata); +} + function makeContext(): ISessionContext { return makeSessionContext({ sessionId: 's1', @@ -42,6 +54,7 @@ describe('SessionMetadata', () => { ix.stub(ISessionContext, makeContext()); ix.stub(IQueryStore, stubQueryStore()); ix.stub(IFlagService, stubFlag(false)); + ix.set(ISessionStateService, new SyncDescriptor(SessionStateService)); ix.set(IFileSystemStorageService, new SyncDescriptor(InMemoryStorageService)); ix.set(IAtomicDocumentStore, new SyncDescriptor(JsonAtomicDocumentStore)); ix.set(ISessionMetadata, new SyncDescriptor(SessionMetadata)); @@ -84,7 +97,7 @@ describe('SessionMetadata', () => { const meta = ix.get(ISessionMetadata); await meta.update({ title: 'persisted' }); - const fresh = ix.createInstance(SessionMetadata); + const fresh = createFreshMetadata(ix); expect(await fresh.read()).toMatchObject({ id: 's1', title: 'persisted' }); }); @@ -105,7 +118,7 @@ describe('SessionMetadata', () => { // The heal is persisted: a fresh instance reads the maps from disk, and // updatedAt is untouched so session listings keep their order. - const fresh = ix.createInstance(SessionMetadata); + const fresh = createFreshMetadata(ix); const healed = await fresh.read(); expect(healed.agents).toEqual({}); expect(healed.custom).toEqual({}); diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts index 1fe1b37211..7b8830516b 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts @@ -14,6 +14,7 @@ import { join } from 'pathe'; import { beforeEach, describe, expect, it } from 'vitest'; import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { InstantiationType } from '#/_base/di/extensions'; import { _clearScopedRegistryForTests, LifecycleScope, @@ -42,6 +43,8 @@ import { ExplicitFileSkillSource, IExplicitFileSkillSource } from '#/session/ses import { ExtraFileSkillSource, IExtraFileSkillSource } from '#/session/sessionSkillCatalog/extraFileSkillSource'; import { IWorkspaceFileSkillSource, WorkspaceFileSkillSource } from '#/session/sessionSkillCatalog/workspaceFileSkillSource'; import { IPluginSkillSource, PluginSkillSource } from '#/session/sessionSkillCatalog/pluginSkillSource'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import type { SkillRoot } from '#/app/skillCatalog/types'; @@ -212,6 +215,13 @@ describe('SessionSkillCatalogService', () => { // chain these tests exercise; every other dependency arrives as a seeded // stub via `createScopedTestHost`. _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.Session, + ISessionStateService, + SessionStateService, + InstantiationType.Eager, + 'state', + ); registerScopedService(LifecycleScope.App, IBuiltinSkillSource, BuiltinSkillSource); registerScopedService(LifecycleScope.App, IUserFileSkillSource, UserFileSkillSource); registerScopedService(LifecycleScope.App, IPluginService, PluginService); diff --git a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts index 4167e70c83..dd64ee7c14 100644 --- a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts +++ b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts @@ -28,6 +28,7 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; import { stubContextMemory, type StubContextMemory } from '../../agent/contextMemory/stubs'; +import { registerStateServices } from '../../state/stubs'; const WORK_DIR = '/repo/work'; const EXTRA_DIR = `${WORK_DIR}/extra`; @@ -248,6 +249,7 @@ describe('SessionWorkspaceCommandService', () => { ix = createServices(disposables, { additionalServices: (reg) => { + registerStateServices(reg); reg.defineInstance(ISessionContext, ctx); reg.define(ISessionWorkspaceContext, SessionWorkspaceContextService); reg.defineInstance(IBootstrapService, bootstrapStub()); diff --git a/packages/agent-core-v2/test/state/stubs.ts b/packages/agent-core-v2/test/state/stubs.ts new file mode 100644 index 0000000000..646c20f4b5 --- /dev/null +++ b/packages/agent-core-v2/test/state/stubs.ts @@ -0,0 +1,18 @@ +/** + * Test doubles for the `state` domain: registers real `StateRegistry` + * instances for the three per-scope state service tokens. + */ + +import type { ServiceRegistration } from '#/_base/di/test'; +import { AgentStateService } from '#/agent/state/agentStateService'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { StateService } from '#/app/state/stateService'; +import { IStateService } from '#/app/state/state'; +import { SessionStateService } from '#/session/state/sessionStateService'; +import { ISessionStateService } from '#/session/state/sessionState'; + +export function registerStateServices(reg: ServiceRegistration): void { + reg.defineInstance(IStateService, new StateService()); + reg.defineInstance(ISessionStateService, new SessionStateService()); + reg.defineInstance(IAgentStateService, new AgentStateService()); +} From b530cbcc65d04a8207fd50a2754f85132eb0cfb2 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 24 Jul 2026 11:15:10 +0800 Subject: [PATCH 04/10] feat(kimi-inspect): rework chat layout with session pane and tabbed right dock - add SessionPane column next to the sidebar: Services tab (pending interactions + session Service panels) and State tab polling ISessionStateService.snapshot() every second, rendered as a live diff tree - merge the transcript audit panel and the agent inspector into one RightPanel with Audit/Agent tabs that keep panel state across switches - extract InteractionsCard from Inspector into its own component - collapse multiline strings in StateTree into a compact hover-preview button with a viewport-clamped fixed popup - test: cover StateRegistry.snapshot() conversions (Map/Set/circular); pass a session state service to SessionInteractionService in kap-server test fakes - update the AGENTS.md project map for the new layout --- AGENTS.md | 2 +- apps/kimi-inspect/src/App.tsx | 27 ++- apps/kimi-inspect/src/components/ChatView.tsx | 225 +++++++++--------- .../kimi-inspect/src/components/Inspector.tsx | 216 +---------------- .../src/components/InteractionsCard.tsx | 178 ++++++++++++++ .../src/components/RightPanel.tsx | 72 ++++++ .../src/components/SessionPane.tsx | 169 +++++++++++++ .../src/components/audit/AuditPanel.tsx | 8 +- .../src/components/audit/StateTree.test.tsx | 13 + .../src/components/audit/StateTree.tsx | 85 ++++++- .../src/_base/state/stateRegistry.ts | 7 +- .../test/_base/state/stateRegistry.test.ts | 43 ++++ .../test/services/transcript.test.ts | 30 ++- .../test/sessionEventBroadcaster.test.ts | 8 +- 14 files changed, 740 insertions(+), 343 deletions(-) create mode 100644 apps/kimi-inspect/src/components/InteractionsCard.tsx create mode 100644 apps/kimi-inspect/src/components/RightPanel.tsx create mode 100644 apps/kimi-inspect/src/components/SessionPane.tsx diff --git a/AGENTS.md b/AGENTS.md index 02c44a9b1e..06828d3a98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). - `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). Debug against the two engines via the root `pnpm dev:v1` / `pnpm dev:v2` backend scripts — the dev Sidebar shows the active backend and switches it at runtime. See `apps/kimi-web/AGENTS.md`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. -- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace, the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies), and App Services (`src/components/AppServicesView.tsx` — the app-scope Service reflection, full width; session/agent scopes stay in the Chat view's right `Inspector`, whose agent tab also carries a Plan lookup card — `PlanCard` in `src/components/Inspector.tsx` — querying `GET /sessions/{id}/transcript/plan` (one tool_call_id, or every plan of the agent) via `src/transcript/api.ts`'s `fetchTranscriptPlan`). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. Session-level coarse status is the one exception: `src/activity/` holds a second `/api/v1/ws` client (`GlobalEventsWs`) that subscribes to nothing and consumes the server-pushed global facts — `event.session.work_changed` updates a per-session activity map (`SessionActivityHub` + subscribe/version store, seeded on connect/reconnect from `GET /api/v1/sessions`), while `event.session.created` / `session.meta.updated` invalidate the `['sessions']` query; the `Sidebar` session rows render `running` / `approval` / `question` / `failed` badges from it via `useSessionActivities`. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory: full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library); `/api/v1/ws` is an incremental channel (`transcript.ops`, grade `block` — the cheapest grade that still carries whole-state frame upserts, dropping per-token `append` frames; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: a dedicated `subscribe_v2` control frame carries the per-agent grades and the `transcript_since` cursor, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). The Transcript audit panel (`src/components/audit/`, always docked right of the chat) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload. +- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace, the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies), and App Services (`src/components/AppServicesView.tsx` — the app-scope Service reflection, full width; the Agent scope stays in the Chat view's right dock under the `Agent` tab (`Inspector`: agent switcher + a Plan lookup card — `PlanCard` in `src/components/Inspector.tsx` — querying `GET /sessions/{id}/transcript/plan` (one tool_call_id, or every plan of the agent) via `src/transcript/api.ts`'s `fetchTranscriptPlan` — plus the agent Service panels), while the Session scope has its own column right next to the session-list sidebar (`src/components/SessionPane.tsx`) with two tabs: Services (the pending-interactions card — `src/components/InteractionsCard.tsx` — plus the session Service panels) and State (every key a Session Service registered into the session-state container, read on demand via `ISessionStateService.snapshot()`)). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. Session-level coarse status is the one exception: `src/activity/` holds a second `/api/v1/ws` client (`GlobalEventsWs`) that subscribes to nothing and consumes the server-pushed global facts — `event.session.work_changed` updates a per-session activity map (`SessionActivityHub` + subscribe/version store, seeded on connect/reconnect from `GET /api/v1/sessions`), while `event.session.created` / `session.meta.updated` invalidate the `['sessions']` query; the `Sidebar` session rows render `running` / `approval` / `question` / `failed` badges from it via `useSessionActivities`. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory: full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library); `/api/v1/ws` is an incremental channel (`transcript.ops`, grade `block` — the cheapest grade that still carries whole-state frame upserts, dropping per-token `append` frames; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: a dedicated `subscribe_v2` control frame carries the per-agent grades and the `transcript_since` cursor, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). The Transcript audit panel (`src/components/audit/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx index bc0ff4f7a8..07ed0703b0 100644 --- a/apps/kimi-inspect/src/App.tsx +++ b/apps/kimi-inspect/src/App.tsx @@ -3,22 +3,26 @@ * push anymore: the v2 socket (`/api/v2/ws`) that fed the core/session/agent * event streams was removed server-side, so Service panels and the pending * interactions card fetch on demand and the sidebar polls. - * Layout: header / icon rail / view. The `chat` view is the classic trio - * (left sidebar with workspaces + sessions, chat, inspector); the `models` - * view is the full-width model catalog; the `services` view is the - * full-width app-scope Service reflection (`AppServicesView`). + * Layout: header / icon rail / view. The `chat` view is a strip of the + * left sidebar (workspaces + sessions), the session pane (session Services + * / State tabs), the chat column, and the right dock (`RightPanel`) merging + * the transcript audit and the agent inspector under Audit / Agent tabs; + * the `models` view is the full-width model catalog; the `services` view is + * the full-width app-scope Service reflection (`AppServicesView`). */ import { useEffect, useState } from 'react'; import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; +import type { AuditTrail } from './audit/trail'; import { AppServicesView } from './components/AppServicesView'; import { ChatView } from './components/ChatView'; -import { Inspector } from './components/Inspector'; import { ModelCatalogView } from './components/ModelCatalogView'; import { NavRail, type AppView } from './components/NavRail'; +import { RightPanel } from './components/RightPanel'; import { ServerSwitcher } from './components/ServerSwitcher'; +import { SessionPane } from './components/SessionPane'; import { Sidebar } from './components/Sidebar'; import { useConnection } from './connection'; import { errorMessage } from './ui'; @@ -30,6 +34,8 @@ export function App() { const [view, setView] = useState('chat'); const [ready, setReady] = useState(false); const [resumeError, setResumeError] = useState(null); + /** Audit trail of the chat view's transcript channel, rendered in the right dock. */ + const [trail, setTrail] = useState(null); // Resume (materialize) the session on the server when it is selected, so // session / agent scoped Services become reachable. @@ -86,18 +92,25 @@ export function App() { ) : ( <> + {resumeError !== null ? (
Failed to open session: {errorMessage(resumeError)}
) : ( - + )} - )} diff --git a/apps/kimi-inspect/src/components/ChatView.tsx b/apps/kimi-inspect/src/components/ChatView.tsx index d4beeb4d57..90153e01c2 100644 --- a/apps/kimi-inspect/src/components/ChatView.tsx +++ b/apps/kimi-inspect/src/components/ChatView.tsx @@ -68,7 +68,6 @@ import { } from '../transcript/store'; import { TranscriptWs } from '../transcript/ws'; import { ActionButton, Badge, ErrorLine, JsonView, relTime } from '../ui'; -import { AuditPanel } from './audit/AuditPanel'; const noopSubscribe = () => () => {}; @@ -326,10 +325,13 @@ export function ChatView({ sessionId, agentId, ready, + onTrailChange, }: { sessionId: string | null; agentId: string; ready: boolean; + /** Hands the audit trail of the current channel up to the app shell (the audit panel lives in the right dock, not inside this view). */ + onTrailChange?: (trail: AuditTrail | null) => void; }) { const { klient, baseUrl, config } = useConnection(); const [input, setInput] = useState(''); @@ -355,6 +357,12 @@ export function ChatView({ ); const items = state.items; + // The audit panel is rendered by the app shell's right dock; report the + // trail (null while no channel exists) so it can subscribe to it there. + useEffect(() => { + onTrailChange?.(trail); + }, [onTrailChange, trail]); + useLayoutEffect(() => { const el = scrollRef.current; if (el === null) return; @@ -485,122 +493,119 @@ export function ChatView({ return ( -
-
-
- {sessionId} - agent: {agentId} - {running ? turn running : idle} - {state.pendingInteractions.size > 0 ? ( - {state.pendingInteractions.size} pending - ) : null} -
- -
- {state.hasMoreOlder ? ( -
- - {loadingOlder ? 'Loading earlier turns…' : ''} - +
+
+ {sessionId} + agent: {agentId} + {running ? turn running : idle} + {state.pendingInteractions.size > 0 ? ( + {state.pendingInteractions.size} pending + ) : null} +
+ +
+ {state.hasMoreOlder ? ( +
+ + {loadingOlder ? 'Loading earlier turns…' : ''} + +
+ ) : null} + {olderError !== null ? ( +
+ +
+ { + setOlderError(null); + void loadOlder(); + }} + > + Retry loading earlier turns +
- ) : null} - {olderError !== null ? ( -
- -
- { - setOlderError(null); - void loadOlder(); - }} - > - Retry loading earlier turns - -
+
+ ) : null} + {loadError !== null ? ( +
+ +
+ Failed to load the transcript — the server may be too old to expose the transcript + API.
- ) : null} - {loadError !== null ? ( -
- -
- Failed to load the transcript — the server may be too old to expose the transcript - API. +
+ ) : null} + {items.length === 0 && loadError === null ? ( +
+ {loaded ? 'Empty transcript — send a prompt below.' : 'Loading transcript…'} +
+ ) : null} + {latestTodo !== undefined && latestTodo.items.length > 0 ? ( +
+
todo (latest)
+ {latestTodo.items.map((entry, i) => ( +
+ + {entry.status === 'done' ? '✔' : entry.status === 'in_progress' ? '◐' : '□'} + + + {entry.title} +
-
- ) : null} - {items.length === 0 && loadError === null ? ( -
- {loaded ? 'Empty transcript — send a prompt below.' : 'Loading transcript…'} -
- ) : null} - {latestTodo !== undefined && latestTodo.items.length > 0 ? ( -
-
todo (latest)
- {latestTodo.items.map((entry, i) => ( -
- - {entry.status === 'done' ? '✔' : entry.status === 'in_progress' ? '◐' : '□'} - - - {entry.title} - -
- ))} -
- ) : null} - {items.map((item) => ( - // Native virtual screen: the browser skips layout/paint for - // off-screen items and remembers their last rendered size - // (`auto` in contain-intrinsic-size), so long transcripts stay - // cheap without a windowing library. -
- -
- ))} - {unanchoredInteractions.map((interaction) => ( - - ))} -
- -
- {sendError !== null ? ( -
- -
- ) : null} -
-