diff --git a/.agents/skills/agent-core-dev/SKILL.md b/.agents/skills/agent-core-dev/SKILL.md index b35ea3a0335..887296a6420 100644 --- a/.agents/skills/agent-core-dev/SKILL.md +++ b/.agents/skills/agent-core-dev/SKILL.md @@ -67,4 +67,4 @@ Invariants that hold across every stage. Each is expanded in the stage file note 9. Throw coded errors; register codes centrally; branch on `code` across the wire, never `instanceof`. (errors.md) 10. Gate unreleased behavior behind a flag contributed via `registerFlagDefinition` and resolved through `IFlagService.enabled(id)`; no ad-hoc env toggles. (flags.md) 11. Tests resolve the SUT by interface; shared stubs live under `test/`, never `src/`. (test.md) -12. Config is the preference registry: only preferences that are persistable, schema'd, and user/operator-facing go in `IConfigService`. Domain-specific config (including env-only operational toggles) goes through `registerSection` + `envOverlay`. Facts → `IBootstrapService`, and host invocation arguments (CLI flags, host identity headers, prompt identity) → `BootstrapInput.args` / `IBootstrapService.args` — never new per-domain runtime-options services; domain runtime state (cron/flags/model) never goes onto `IBootstrapService`; session state → Session scope; constants → code. Business domains never call `IBootstrapService.getEnv()` directly. (config.md) +12. Config is the preference registry: only preferences that are persistable, schema'd, and user/operator-facing go in `IConfigService`. Domain-specific config (including env-only operational toggles) goes through `registerConfigSection` + `envOverlay`. Facts → `IBootstrapService`, and host invocation arguments (CLI flags, host identity headers, prompt identity) → `BootstrapInput.args` / `IBootstrapService.args` — never new per-domain runtime-options services; domain runtime state (cron/flags/model) never goes onto `IBootstrapService`; session state → Session scope; constants → code. Business domains never call `IBootstrapService.getEnv()` directly. (config.md) diff --git a/.agents/skills/agent-core-dev/align.md b/.agents/skills/agent-core-dev/align.md index 1186c039ef9..24def7f9932 100644 --- a/.agents/skills/agent-core-dev/align.md +++ b/.agents/skills/agent-core-dev/align.md @@ -157,7 +157,8 @@ import { InstantiationType, registerSingleton } from '../../di'; registerSingleton(IXxxService, XxxService, InstantiationType.Delayed); // v2 -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; registerScopedService(LifecycleScope.Session, IXxxService, XxxService, ScopeActivation.OnDemand, 'xxx'); ``` diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 6529924c117..90e1dc10242 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -2,7 +2,7 @@ How the `config` domain works and how a domain owns its configuration section. Covers the section-registry model, the App vs Session split, the TOML on-disk format, and the recipe for adding or migrating a config section. -The `config` domain is a thin registry + loader: it does **not** know the shape of any individual section. Each domain owns the schema (and, where needed, the TOML transform) for the config it consumes, registers the section into `IConfigRegistry`, and reads it through `IConfigService`. There is no whole-config object passed around. +The `config` domain is a thin registry + loader: it does **not** know the shape of any individual section. Each domain owns the schema (and, where needed, the TOML transform) for the config it consumes, contributes the section (statically at module load via `registerConfigSection`, or at runtime as a `ConfigSectionContribution` collection record), and reads it through `IConfigService`. There is no whole-config object passed around. ## What belongs in Config @@ -93,13 +93,15 @@ pass `ConfigTarget.Memory` for a per-run override that is never written to disk. ## Layout -- `src/config/config.ts` — `IConfigRegistry` / `IConfigService` tokens, `ConfigSection`, `ConfigEffectiveOverlay`, event types. -- `src/config/configService.ts` — `ConfigRegistry` + `ConfigService` impl; self-registers at App scope. -- `src/config/toml.ts` — generic snake_case ↔ camelCase machinery plus the registry-aware `transformTomlData` / `applySectionToToml` entry points. Per-domain normalization lives in the section owner's `configSection.ts` (registered as `fromToml` / `toToml`); this module stays free of any other domain's semantics. -- `src/profile/thinking.ts` (owner domain, not `config`) — the `resolveThinkingEffort` helper; uses the authoritative `ThinkingConfig` from `configSection.ts`. -- `src/config/configPure.ts` — `isPlainObject`, `deepMerge`, `omitUndefined`, `describeUnknownError`. +- `src/app/config/config.ts` — `IConfigRegistry` / `IConfigService` tokens, `ConfigSection`, `ConfigEffectiveOverlay`, event types. +- `src/app/config/configService.ts` — `ConfigRegistry` + `ConfigService` impl; self-registers at App scope. The registry is also the fold of the `ConfigSectionContribution` collection: it drains the module-level contributions at construction, then refolds incrementally (`added` → `registerSection`, `removed` → `unregisterSection`). +- `src/app/config/configSectionContributions.ts` — the `ConfigSectionContribution` collection token (the runtime channel: a unit contributes with `this.provide(ConfigSectionContribution, …)`) plus the module-level `registerConfigSection` collector (the static channel, import = register). +- `src/app/config/configOverlayContributions.ts` — the module-level `registerConfigOverlay` collector for `ConfigEffectiveOverlay`s (drained at construction like the sections). +- `src/app/config/toml.ts` — generic snake_case ↔ camelCase machinery plus the registry-aware `transformTomlData` / `applySectionToToml` entry points. Per-domain normalization lives in the section owner's `configSection.ts` (registered as `fromToml` / `toToml`); this module stays free of any other domain's semantics. +- `src/kosong/model/thinking.ts` (owner domain, not `config`) — the `resolveThinkingEffort` helper and the authoritative `ThinkingConfig` type (the `thinking` section itself registers from `src/app/kosongConfig/configSection.ts`). +- `src/app/config/configPure.ts` — `isPlainObject`, `deepMerge`, `omitUndefined`, `describeUnknownError`. -A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/flag/flag.ts` for `experimental`, `src/loop/configSection.ts` for `loopControl`). Exception: kosong-owned sections (`providers`, `models`, `thinking`) — kosong is a pure, persistence-free abstraction layer that defines only the types (`src/kosong/{provider,model}`); the section constants, the zod schemas (re-derived from those types and compile-time pinned via `AssertExact, Type>>`, see `_base/utils/typeEquality.ts`), the registrations, env bindings, and TOML transforms all live in the persistence wrapper `src/app/kosongConfig/configSection.ts`. (`modelCatalog` and `secondaryModel` have no kosong-side type at all — their sections are fully self-contained in `app/kosongConfig`, types derived from the schemas.) A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the wrapper too (`src/app/kosongConfig/envOverlay.ts`; the `[secondary_model]` derived-entry synthesis in `secondaryModelOverlay.ts`) and is registered via `IConfigRegistry.registerEffectiveOverlay`. The two-way sync between config sections and kosong's in-memory registries is owned by `IKosongConfigService` (`src/app/kosongConfig/kosongConfigService.ts`). +A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/app/flag/flag.ts` for `experimental`, `src/agent/loop/configSection.ts` for `loopControl`). Exception: kosong-owned sections (`providers`, `models`, `thinking`) — kosong is a pure, persistence-free abstraction layer that defines only the types (`src/kosong/{provider,model}`); the section constants, the zod schemas (re-derived from those types and compile-time pinned via `AssertExact, Type>>`, see `_base/utils/typeEquality.ts`), the registrations, env bindings, and TOML transforms all live in the persistence wrapper `src/app/kosongConfig/configSection.ts`. (`modelCatalog` and `secondaryModel` have no kosong-side type at all — their sections are fully self-contained in `app/kosongConfig`, types derived from the schemas.) A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the wrapper too (`src/app/kosongConfig/envOverlay.ts`; the `[secondary_model]` derived-entry synthesis in `secondaryModelOverlay.ts`) and is registered via module-level `registerConfigOverlay`. The two-way sync between config sections and kosong's in-memory registries is owned by `IKosongConfigService` (`src/app/kosongConfig/kosongConfigService.ts`). ## Scope @@ -117,9 +119,14 @@ A config section is identified by a camelCase domain key (`'providers'`, `'think - `fromToml?: ConfigFromToml` — read-path transform (snake_case file value → in-memory shape). Defaults to a plain key-casing pass; owners register one when the on-disk shape needs custom normalization (record key preservation, nested object conversion, array entries, key renames, reshapes). - `toToml?: ConfigToToml` — write-path transform (in-memory value → snake_case file value). Defaults to a plain camelCase→snake_case key mapping. +Two contribution channels: + +- **Static (import = register)** — the owning domain calls `registerConfigSection(domain, schema, options)` at the top level of its `configSection.ts`; `ConfigRegistry` drains the collected contributions when it is constructed. Every in-repo section uses this channel. +- **Runtime (collection record)** — a unit contributes `this.provide(ConfigSectionContribution, { domain, schema, options })` (e.g. a feature assembled through `IFeatureManager`); the `ConfigRegistry` fold registers the section when the record lands and unregisters it when the record is withdrawn (provider disposed). User TOML values survive a withdrawal — they just stop being validated and effective. + Ownership rules: -- **One owner per section.** `registerSection` throws if a domain is registered twice. +- **One owner per section.** `registerSection` throws if a domain is registered twice — the static channel fails fast when `ConfigRegistry` drains it; a conflicting runtime record is reported through `onUnexpectedError` and the first registration wins (the fold is an event path and never throws). - **The domain that consumes a config owns its schema.** This is what keeps `config` from depending on its consumers: `config` must not import `externalHooks` / `permissionRules` / `provider` / `kosong` / etc. for a section's schema. If a schema needs a domain's types, the schema lives in that domain. - **Demand-driven.** Do not register sections for config that no domain reads yet; a section appears (with its schema in the owning domain) only when a consumer appears. @@ -131,7 +138,7 @@ Declare the bindings with `envBindings(schema, { … })` — the field names are type-checked against the schema (no magic strings), and nested schemas recurse: ```ts -registerSection('thinking', ThinkingConfigSchema, { +registerConfigSection('thinking', ThinkingConfigSchema, { env: envBindings(ThinkingConfigSchema, { effort: 'KIMI_MODEL_THINKING_EFFORT', }), @@ -139,7 +146,7 @@ registerSection('thinking', ThinkingConfigSchema, { // nested / record section — outer key is a runtime constant, inner fields are // checked against the value schema: -registerSection('providers', ProvidersSectionSchema, { +registerConfigSection('providers', ProvidersSectionSchema, { env: envBindings(ProvidersSectionSchema, { [ENV_MODEL_PROVIDER_KEY]: envBindings(ProviderConfigSchema, { apiKey: 'KIMI_MODEL_API_KEY', @@ -184,21 +191,27 @@ never write their own env-merge logic. export const MySectionSchema = z.object({ /* ... */ }); export type MySection = z.infer; ``` -2. In the domain's service constructor, inject `IConfigRegistry` and register: +2. Register it at the top level of the same module (import = register): + ```ts + // src//configSection.ts + import { registerConfigSection } from '#/app/config/configSectionContributions'; + + registerConfigSection(MY_SECTION, MySectionSchema, { defaultValue: {} }); + ``` + `ConfigRegistry` drains module-level contributions when it is constructed, so the section exists before any consumer resolves `IConfigService` — no owning Service needs to be constructed first. Make sure `src/index.ts` imports the leaf so the top-level call runs. +3. (Runtime variant) a dynamically loaded unit (e.g. one assembled through `IFeatureManager`) contributes the section as a collection record instead: ```ts - constructor(@IConfigRegistry registry: IConfigRegistry) { - registry.registerSection(MY_SECTION, MySectionSchema, { defaultValue: {} }); - } + this.provide(ConfigSectionContribution, { domain: MY_SECTION, schema: MySectionSchema, options: { defaultValue: {} } }); ``` - Pick a service whose scope matches when the config is first needed. Registering from an Agent-scope service is fine — see "Late registration". -3. Read it anywhere via `IConfigService`: + The `ConfigRegistry` fold registers it incrementally and unregisters it when the unit is retracted (user TOML values survive) — see "Late registration". +4. Read it anywhere via `IConfigService`: ```ts constructor(@IConfigService private readonly config: IConfigService) {} // ... const value = this.config.get(MY_SECTION); ``` -4. React to edits by subscribing `IConfigService.onDidChange` and filtering on `e.domain === MY_SECTION` (see `FlagService`). -5. Write it only through `IConfigService.set(domain, patch)` (merge) or `.replace(domain, value)` (wholesale). Never write `config.toml` directly. +5. React to edits by subscribing `IConfigService.onDidChange` and filtering on `e.domain === MY_SECTION` (see `FlagService`). +6. Write it only through `IConfigService.set(domain, patch)` (merge) or `.replace(domain, value)` (wholesale). Never write `config.toml` directly. ## Reads vs writes @@ -222,11 +235,11 @@ So `configure(...)` never overwrites the local file. Treat `config.toml` as the ## Late registration -`ConfigService` loads in its constructor (first `get(IConfigService)`). Domain services that register sections may be constructed later (especially Agent-scope services). To keep validation and defaults correct: +`ConfigService` loads in its constructor (first `get(IConfigService)`). Static sections are drained before that, but a runtime-contributed section (a `ConfigSectionContribution` record) can register at any later moment. To keep validation and defaults correct: -- `IConfigRegistry` emits `onDidRegisterSection` whenever a section is registered. -- `ConfigService` subscribes and, on registration, re-validates the already-loaded raw value for that domain, applies the default if the raw value is absent, re-runs the env overlay, and fires `onDidChange` if the effective value changed. -- Before a section is registered, `get(domain)` returns the raw (transformed, unvalidated) value; consumers that need validated values should read after the owning service is constructed, or react to `onDidChange`. +- `IConfigRegistry` emits `onDidRegisterSection` whenever a section is registered (and `onDidUnregisterSection` when a runtime record is withdrawn). +- `ConfigService` subscribes and, on registration, re-validates the already-loaded raw value for that domain, applies the default if the raw value is absent, re-runs the env overlay, and fires `onDidChange` if the effective value changed. On unregistration it devalidates the domain — `get(domain)` falls back to the raw value. +- Before a section is registered, `get(domain)` returns the raw (transformed, unvalidated) value; consumers that need validated values should read after the section lands, or react to `onDidChange`. This means registration order is never a correctness concern — you do not need an eager bootstrap. @@ -263,11 +276,11 @@ registerSection(MY_SECTION, MySectionSchema, { ### `KIMI_MODEL_*` env overlay -When `KIMI_MODEL_NAME` is set, the `kosongConfig` wrapper's `kimiModelEnvOverlay` (`src/app/kosongConfig/envOverlay.ts`) injects a reserved model alias (`__kimi_env_model__`) into `effective`, points `defaultModel` at it, and merges the request `modelOverrides`; the reserved provider (`__kimi_env__`) comes from the `providers` section env bindings. The overlay is registered via `IConfigRegistry.registerEffectiveOverlay` and applied **only to `effective`**, never to `rawSnake`, so it is never persisted. Its `strip` (plus the providers section `stripEnv`) is the final guard so a caller that read `effective` (with the overlay) cannot write the reserved entries or the shell API key back to disk. `config` itself only runs registered overlays — it does not know the `KIMI_MODEL_*` semantics. +When `KIMI_MODEL_NAME` is set, the `kosongConfig` wrapper's `kimiModelEnvOverlay` (`src/app/kosongConfig/envOverlay.ts`) injects a reserved model alias (`__kimi_env_model__`) into `effective`, points `defaultModel` at it, and merges the request `modelOverrides`; the reserved provider (`__kimi_env__`) comes from the `providers` section env bindings. The overlay is registered via module-level `registerConfigOverlay` and applied **only to `effective`**, never to `rawSnake`, so it is never persisted. Its `strip` (plus the providers section `stripEnv`) is the final guard so a caller that read `effective` (with the overlay) cannot write the reserved entries or the shell API key back to disk. `config` itself only runs registered overlays — it does not know the `KIMI_MODEL_*` semantics. ## Owner-owned sections -`config` holds no monolithic config schema and no whole-config object. Every section is owned by the domain that consumes it: the schema (and any `fromToml` / `toToml` normalization and `stripEnv`) lives in that domain's `configSection.ts`, and the domain registers it via `IConfigRegistry.registerSection`. Cross-section env behavior (e.g. `KIMI_MODEL_*`) lives in an owner-registered `ConfigEffectiveOverlay`. To add a section, follow "Add a config section" above in the owning domain — never add schema or normalization to `config` itself. +`config` holds no monolithic config schema and no whole-config object. Every section is owned by the domain that consumes it: the schema (and any `fromToml` / `toToml` normalization and `stripEnv`) lives in that domain's `configSection.ts`, and the domain contributes it via module-level `registerConfigSection` (or a runtime `ConfigSectionContribution` record). Cross-section env behavior (e.g. `KIMI_MODEL_*`) lives in an owner-registered `ConfigEffectiveOverlay` (module-level `registerConfigOverlay`). To add a section, follow "Add a config section" above in the owning domain — never add schema or normalization to `config` itself. ## Ownership map (generated) @@ -287,13 +300,13 @@ The authoritative, always-current list of registered sections — rendered in th ## Red lines (this topic) -- One owner per section; `registerSection` throws on duplicate domains. +- One owner per section: a duplicate static registration throws when `ConfigRegistry` drains it; a conflicting runtime record is logged (`onUnexpectedError`) and the first registration wins. - `config` never imports the domains that consume it — keep section schemas in the owning domain. - Config is the **preference registry**: register only values that are preferences, persistable, schema'd, and user/operator-facing. Facts → `IBootstrapService`; session state → Session scope; constants → code. - Business domains read `config.get(...)` or structured `IBootstrapService` facts; never call `IBootstrapService.getEnv()` directly — only `config` reads the raw env bag to build overlays. -- Keep `IBootstrapService` domain-agnostic: host invocation arguments (CLI flags, host identity headers, prompt identity) go into `BootstrapInput.args` / `IBootstrapService.args` — never into new per-domain runtime-options services; domain runtime state (cron, flags, model params, …) never goes onto `IBootstrapService` at all. Domain-specific config goes through `registerSection` + `envBindings`, read via `config.get(...)`. +- Keep `IBootstrapService` domain-agnostic: host invocation arguments (CLI flags, host identity headers, prompt identity) go into `BootstrapInput.args` / `IBootstrapService.args` — never into new per-domain runtime-options services; domain runtime state (cron, flags, model params, …) never goes onto `IBootstrapService` at all. Domain-specific config goes through `registerConfigSection` + `envBindings`, read via `config.get(...)`. - Do not pass a whole config bag via options; read each section through `IConfigService`. There is no `KimiConfig` object — config is a registry of owner-owned sections. - `config.toml` is snake_case on disk, camelCase in memory — never write camelCase keys to disk, and never write to `config.toml` except through `IConfigService.set/replace`. - Reading config / calling `configure(...)` / switching model at runtime must not rewrite `config.toml`; runtime state lives in memory and the session wireRecord, not the file. - Never persist env overlays (`__kimi_env__` / `__kimi_env_model__` / shell API key / experimental env); overlays live only in `effective` / `Memory`. -- Registering from an Agent-scope service is fine — the late-registration mechanism keeps validation correct; do not add an eager bootstrap. +- Runtime contribution (a `ConfigSectionContribution` record from a unit at any scope) is fine — the late-registration mechanism keeps validation correct; the static channel needs no eager bootstrap (import = register, drained at `ConfigRegistry` construction). diff --git a/.agents/skills/agent-core-dev/design.md b/.agents/skills/agent-core-dev/design.md index bf94e46c731..c6acd0cbbda 100644 --- a/.agents/skills/agent-core-dev/design.md +++ b/.agents/skills/agent-core-dev/design.md @@ -130,6 +130,8 @@ The three mechanisms above are also where a domain accepts new behavior without | Step into an operation in order / veto | a **hook** (`onWill`/`onDid`, `OrderedHookSlot`) | the owning scope | | Swap a backend (File ↔ DB ↔ S3) | a **Store / Storage token** at the byte layer (see persistence.md) | `App` (composition root) | +The standard shape of a "registry / catalog the domain queries" row is an L3 contribution point: the target domain owns a `collection` token, contributors call `this.provide(token, record)` from a unit, and a fold service in the target domain injects the `CollectionView` (incremental `onDidChange`; provider death withdraws the record). The four in-repo seams are `ConfigSectionContribution` → `ConfigRegistry`, `AgentToolContribution` → `AgentToolActivationService`, `AgentProfileContribution` → `IAgentProfileRegistry`, and `WireModelContribution` → `WireService` (file-level pointers: `packages/agent-core-v2/AGENTS.md` §Units and contribution points). + Closed-for-modification means: the domain's own file is not where new scenarios branch. If a new scenario forces an edit here, an extension point is missing or misplaced. ## 5. Dependency direction diff --git a/.agents/skills/agent-core-dev/flags.md b/.agents/skills/agent-core-dev/flags.md index 61607389717..e824699ca68 100644 --- a/.agents/skills/agent-core-dev/flags.md +++ b/.agents/skills/agent-core-dev/flags.md @@ -6,11 +6,11 @@ Gate not-yet-public features behind `IFlagService.enabled(id)`, per the reposito ## Layout -- `src/flag/flagRegistry.ts` — `IFlagRegistry` token + `FlagDefinitionInput` / `FlagId` / `FlagSurface` types + `registerFlagDefinition` / `getContributedFlags` (import-time contribution queue). -- `src/flag/flagRegistryService.ts` — `FlagRegistryService` impl; in-memory catalog seeded from import-time contributions; App scope. -- `src/flag/flag.ts` — `IFlagService` token + resolver types (`ExperimentalFlagMap`, `ExperimentalFlagConfig`, `ExperimentalFlagSource`, `ExperimentalFeatureState`) + `ExperimentalConfigSchema` / `ExperimentalConfig` (zod). -- `src/flag/flagService.ts` — `FlagService` impl + `MASTER_ENV` (`KIMI_CODE_EXPERIMENTAL_FLAG`) + `EXPERIMENTAL_SECTION` (`experimental`); reads definitions from `IFlagRegistry`; self-registers at App scope. -- `src/flag/index.ts` — **removed (no barrel)**; `src/index.ts` imports the `flag` leafs precisely instead (e.g. `import './flag/flagService'`). +- `src/app/flag/flagRegistry.ts` — `IFlagRegistry` token + `FlagDefinitionInput` / `FlagId` / `FlagSurface` types + `registerFlagDefinition` / `getContributedFlags` (import-time contribution queue). +- `src/app/flag/flagRegistryService.ts` — `FlagRegistryService` impl; in-memory catalog seeded from import-time contributions; App scope. +- `src/app/flag/flag.ts` — `IFlagService` token + resolver types (`ExperimentalFlagMap`, `ExperimentalFlagConfig`, `ExperimentalFlagSource`, `ExperimentalFeatureState`) + `EXPERIMENTAL_SECTION` (`experimental`) / `ExperimentalConfigSchema` (zod) + the module-level `registerConfigSection(EXPERIMENTAL_SECTION, …)` call that owns the section. +- `src/app/flag/flagService.ts` — `FlagService` impl + `MASTER_ENV` (`KIMI_CODE_EXPERIMENTAL_FLAG`); reads definitions from `IFlagRegistry` and overrides from `IConfigService`; self-registers at App scope. +- `src/app/flag/index.ts` — **removed (no barrel)**; `src/index.ts` imports the `flag` leafs precisely instead (e.g. `import './app/flag/flagService'`). - `src//flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/agent/toolSelect/flag.ts`). The directory already names the domain, so the file is just `flag.ts`. ## Public surface @@ -33,9 +33,9 @@ Highest wins; env is read live on every call (nothing cached): ## Config integration -- `FlagService` registers the `[experimental]` section into `IConfigRegistry` at construction (`registerSection('experimental', ExperimentalConfigSchema)`) and reads overrides from `IConfigService`. +- The flag domain owns the `[experimental]` section: `src/app/flag/flag.ts` registers it at module load via `registerConfigSection(EXPERIMENTAL_SECTION, ExperimentalConfigSchema, { fromToml, toToml })` (import = register, drained by `ConfigRegistry` at construction); `FlagService` reads overrides from `IConfigService`. - It subscribes `IConfigService.onDidChange` and refreshes overrides whenever the `experimental` domain changes, so config edits apply live. -- `IConfigRegistry.registerSection` throws if a domain is registered twice — `experimental` is owned exclusively by `FlagService`. +- `ConfigRegistry.registerSection` throws if a domain is registered twice — `experimental` is owned exclusively by the flag domain. - `setConfigOverrides(overrides)` is an imperative escape hatch for tests and hosts without an `IConfigService`; hosts on `IConfigService` should set the `[experimental]` section instead. Config shape: @@ -54,7 +54,7 @@ Declare the definition in the owning domain's `flag.ts` and call `registerFlagDe `src//flag.ts`: ```ts -import { type FlagDefinitionInput, registerFlagDefinition } from '#/flag'; +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; export const myFeatureFlag: FlagDefinitionInput = { id: 'my_feature', diff --git a/.agents/skills/agent-core-dev/implement.md b/.agents/skills/agent-core-dev/implement.md index 1f860ab902c..9e436280581 100644 --- a/.agents/skills/agent-core-dev/implement.md +++ b/.agents/skills/agent-core-dev/implement.md @@ -31,7 +31,8 @@ export const IGreeter: ServiceIdentifier = createDecorator(' ```ts // greet/greetService.ts -import { LifecycleScope, registerScopedService, ScopeActivation } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { registerScopedService, ScopeActivation } from '#/_base/di/scope'; import { IGreeter } from './greet'; export class Greeter implements IGreeter { @@ -127,7 +128,8 @@ export class WSBroadcastService extends Disposable implements IWSBroadcastServic - Extend `Disposable`, collect any `IDisposable` with `this._register(d)` (event subscriptions, `toDisposable(fn)`, etc.). - The container calls `dispose()` automatically when the service is torn down; child resources release in turn. -- Disposal order is deterministic (orient.md): child scopes first, then reverse construction order within a scope. +- Disposal order is deterministic (orient.md): child scopes first; within a scope the Ledger (`src/_base/lifecycle/`) tears entries down in strict reverse registration order, serially — `Disposable` / `DisposableStore` delegate to it. +- Extend `Service` (from `#/_base/di/service`) instead when the unit needs capability calls on `this` (`provide` / `effect` / `on` / `get` / `ref`) — e.g. contributing a record to a `collection` token. `Service` extends `Disposable` (so `_register` is unchanged) and adds the two-phase construction protocol: `provide` / `on` / `effect` calls inside the constructor are buffered and flushed by the kernel after `Reflect.construct`; `get` / `ref` throw inside the constructor — dependencies stay constructor parameters. A manually `new`ed `Service` has no capabilities: every capability call throws. ## §5 Scope activation @@ -160,7 +162,7 @@ registerScopedService( ); ``` -`ScopeActivation.OnScopeCreated` is the default fourth argument. Scope creation constructs every registration using this mode, after constructing its dependencies. If any constructor fails, scope creation fails. Use it for ordinary services and for constructor side effects that must exist when the scope becomes ready. +`ScopeActivation.OnScopeCreated` is the default fourth argument. Scope creation activates every registration using this mode, after constructing its dependencies. An eager constructor failure no longer fails scope creation: the unit lands in sticky `Failed` — scope creation succeeds, resolving the unit rethrows its error, and an explicit `update()` reloads it (see the bootstrap note below). Use it for ordinary services and for constructor side effects that must exist when the scope becomes ready. `ScopeActivation.OnDemand` stores the descriptor without constructing the service. The first `get()` constructs and caches the real instance directly; later `get()` calls return that same instance. Use it only when construction should wait until the service is actually requested. @@ -168,6 +170,8 @@ Both modes use the same dependency graph and reject cycles with `CyclicDependenc The complete registration signature is `registerScopedService(scope, id, ctor, activation = ScopeActivation.OnScopeCreated, domain?)`: activation is the fourth argument and domain is the fifth. +**Bootstrap shares the dynamic provide path.** Scope creation (`Scope.createApp` / `Scope.createChild` / `createScopedChildHandle` in `src/_base/di/scope.ts`) submits the scope kind's entire `registerScopedService` batch as ONE cascade transaction via `provideAll`: every token registers before the activation wave runs, so **registration order never matters**, and untracked transitive `createInstance` resolutions succeed inside the batch. A seed occupying a token (the `extra` tuple in `ScopeOptions`) overrides the static registration for that token. `activateScopeServices` is gone — there is no separate static activation path. + ## §6 Using a service inside a plain function (`invokeFunction`) When you do not want a new class and just need a service once, or when you expose a `ServicesAccessor` to the outside: @@ -198,7 +202,7 @@ class TurnRunner { const runner = instantiation.createInstance(TurnRunner, 'hello', 1); ``` -Static params come first (you pass them), service params follow (the container fills them), then `Reflect.construct` builds the instance. This object is **not** placed in any scope's singleton cache — every call is a fresh instance. +Static params come first (you pass them), service params follow (the container fills them), then `Reflect.construct` builds the instance. This object is **not** placed in any scope's singleton cache — every call is a fresh instance — and it is not tracked as a cascade unit either: `createInstance` products are cascade-exempt leaves that no cascade tears down or rebuilds; their owner disposes them. > This is why service params must follow static params **for `createInstance`**: the container sorts by the parameter positions recorded via `@IX`. `_serviceBrand` lets the compiler tell the two kinds apart. Scoped services built by `registerScopedService` follow a different convention (`@IX` params first, optional static params after) — see service-authoring.md §constructor-conventions. @@ -234,7 +238,7 @@ Key points: - `instantiation.createChild(collection)` builds a child container whose parent pointer is the current container — so the child resolves upward to `App` services (the visibility rule). - Expose the child to the outside by wrapping it in a `ServicesAccessor` via `invokeFunction` (§6). -> Higher-level code usually calls `Scope.createChild(kind, id)` (it does the "filter descriptors + build child" for you). Drop to the manual `ServiceCollection` form only when you need explicit control. +> Higher-level code usually calls `Scope.createChild(kind, id)` (it does the "filter descriptors + build child" for you, then submits the whole batch through `provideAll` as one cascade transaction — see §5). Drop to the manual `ServiceCollection` form only when you need explicit control; to change bindings on an already-created container, prefer `provide` / `unprovide` / `update` over rebuilding a collection. Before the static batch lands, the scope-creation point runs the kernel's `ScopeUnits` fold (`_base/di/scopeUnits.ts` — materializes the recipes contributed to `ScopeUnits(kind)` as per-scope units) and then the `ScopeOptions.assemble` hook — the session domain uses the hook to construct its seed-adapter units (`session/sessionSeed/sessionSeedAdapters.ts`) so their provided tokens exist before the session services activate. ## §9 Cyclic dependencies (forbidden — refactor) @@ -276,6 +280,8 @@ Both `ScopeActivation.OnScopeCreated` and `ScopeActivation.OnDemand` construct t | `Disposable` / `DisposableStore` / `IDisposable` | §4 | resource management and disposal | | `Scope` / `LifecycleScope` | §3, §8 | the lifetime tree | | `ScopeActivation` | §3, §5 | choose scope-created or first-`get()` construction | +| `Service` (`_base/di/service`) | §4 | unit base class — `this.provide/effect/on/get/ref` capabilities, two-phase construction | +| `collection(name)` / `CollectionView` (`_base/di/collection`) | §4 | contribution-point token + the fold's live view (provider death withdraws the record) | | `SyncDescriptor` | (tests / low-level) | package a constructor + static args into a pending descriptor | > Legacy export (not used in v2, just recognize it): `refineServiceDecorator` is a VS Code leftover DI helper. v2 src/test has zero references; always use `registerScopedService`. diff --git a/.agents/skills/agent-core-dev/orient.md b/.agents/skills/agent-core-dev/orient.md index 7370aa63576..9644fbea960 100644 --- a/.agents/skills/agent-core-dev/orient.md +++ b/.agents/skills/agent-core-dev/orient.md @@ -17,24 +17,26 @@ Classes talk only to interfaces and never care how an implementation is construc Lifetimes form a tree, from longest to shortest: ```text -App (0) process-wide, single global instance - └── Workspace (1) one workspace handler (a materialized workspace root) - └── Session (2) one session - └── Agent (3) one agent +App process-wide, single global instance + └── Workspace one workspace handler (a materialized workspace root) + └── Session one session + └── Agent one agent ``` ```ts +// src/app/scopes.ts — the business layer declares the tiers and their order; +// the DI kernel only knows opaque string kinds plus the declared topology. export enum LifecycleScope { - App = 0, - Workspace = 1, - Session = 2, - Agent = 3, + App = 'app', + Workspace = 'workspace', + Session = 'session', + Agent = 'agent', } ``` -- A larger number = shorter life = closer to a leaf. +- Later in the topology = shorter life = closer to a leaf. - "Singleton" means **one per scope**: `ILogService` is global once; each `Session` scope has its own `ISessionMetadata`. -- `kind` strictly increases along the parent→child direction. +- `kind` must advance along the declared topology in the parent→child direction. ### Visibility rule @@ -47,7 +49,15 @@ A child scope sees its ancestors; a parent never sees its children. Resolution w ### Disposal order -Deterministic: **child scopes die first; within one scope, instances dispose in reverse construction order** (last constructed, first disposed). Business code declares which tier it lives in and never disposes by hand. +Deterministic: **child scopes die first; within one scope, teardown runs in strict reverse registration order, one entry at a time.** The mechanism is the Ledger (`src/_base/lifecycle/`): ordered effect bookkeeping, dual-track (sync + async disposers), serial reverse-order teardown (never parallel), with the teardown reason (`'scope-close' | 'cascade' | 'unload'`) passed through to every disposer. `Disposable` / `DisposableStore` (`src/_base/di/lifecycle.ts`) delegate to it — "reverse construction order" is a Ledger property, not a container convention. Business code declares which tier it lives in and never disposes by hand. + +## Dynamic DI: units and cascades + +Registration is not the end of the story. Every unit a container tracks — static registrations and runtime `provide`s alike — lives in a small state machine owned by the scope's cascade engine (`src/_base/di/cascadeEngine.ts`, one per scope container, orchestrating tree-wide). Vocabulary you will meet in errors, tests, and the debug surface: + +- **Unit states** — `Pending → Activating → Active`, plus `Unloading` during teardown and a sticky `Failed`. A construction failure parks the unit in `Failed` with no auto-retry: resolving it rethrows its error; an explicit `update()` reloads it. +- **Waiting area** — a unit whose declared dependencies are missing sits `Pending` and auto-activates when they arrive, including cross-scope wake-up when an ancestor gains the token. An `ondemand` unit counts as available: consumers pull it transitively at materialization. +- **Cascade transaction** — every `provide` / `unprovide` / `update` runs as one tree-wide transaction: contagion set from the persistent dependency graph (instance edges, child→parent across scopes) → abort hook → global reverse-topo teardown → apply the change → waiting-area recheck fixpoint → history ring. Static bootstrap shares this path: scope creation submits the kind's whole registration batch as one `provideAll`, so registration order never matters. ## Import boundaries diff --git a/.agents/skills/agent-core-dev/server-align.md b/.agents/skills/agent-core-dev/server-align.md index ce896cc298c..6907a710a22 100644 --- a/.agents/skills/agent-core-dev/server-align.md +++ b/.agents/skills/agent-core-dev/server-align.md @@ -109,7 +109,8 @@ export const IAgentPromptService: ServiceIdentifier = ```ts // promptService.ts — impl delegates to the native v2 Service -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; constructor(@IAgentPromptService private readonly prompt: IAgentPromptService /*, ... */) {} // submit() builds v2-native input, calls the native Service, projects the result diff --git a/.agents/skills/agent-core-dev/service-authoring.md b/.agents/skills/agent-core-dev/service-authoring.md index dd4ba7e3557..5484f48edca 100644 --- a/.agents/skills/agent-core-dev/service-authoring.md +++ b/.agents/skills/agent-core-dev/service-authoring.md @@ -137,7 +137,8 @@ Holds the concrete class(es) and the top-level registration. A typical impl: * … collaborators as roles ("logs through `log`") … Bound at App scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/log'; import { type Greeting, IGreeter } from './greet'; @@ -163,6 +164,8 @@ What belongs here: - **Helper classes / functions** used only by this impl (e.g. a built-in writer, an `extractError` helper) — co-located in the same file. - **Top-level `registerScopedService(...)`** — one per Service the file owns; importing the impl file runs the registration. +Base class: extend `Service` (from `#/_base/di/service`) when the unit needs capability calls on `this` — `provide` / `effect` / `on` / `get` / `ref` (e.g. contributing a record to a `collection` token). `Service` extends `Disposable`, so `_register` keeps working; constructor-time `provide` / `on` / `effect` calls are buffered and flushed by the kernel after construction, while `get` / `ref` throw inside the constructor (dependencies stay constructor parameters). Otherwise extend `Disposable` — both are full DI units; a service whose own members collide with the `Service` vocabulary (`name` / `state` / `config` / `get`) must stay on `Disposable` (leave a NOTE comment saying so). + ## Constructor conventions - Declare every dependency with `@IX` on a constructor parameter. @@ -317,7 +320,8 @@ export const IGreeter: ServiceIdentifier = createDecorator(' ```ts // greet/greetService.ts -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { type Greeting, IGreeter } from './greet'; export class Greeter implements IGreeter { diff --git a/.agents/skills/agent-core-dev/test.md b/.agents/skills/agent-core-dev/test.md index d70945fb91d..96e817a3226 100644 --- a/.agents/skills/agent-core-dev/test.md +++ b/.agents/skills/agent-core-dev/test.md @@ -79,8 +79,8 @@ Reach for this only when *which layer a service lives in* is itself the thing be ```ts import { beforeEach, describe, expect, it } from 'vitest'; +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, @@ -224,6 +224,14 @@ Do **not** add the system-under-test itself to the store. `TestInstantiationServ Scope-host tests call `host.dispose()` in `afterEach` (or at the end of the `it`). Route teardown through the store so ordering is deterministic and nothing leaks when a test fails mid-way. +## Cascade: asserting unit state + +The cascade engine's test vocabulary lives in two files: `test/_base/di/cascade.test.ts` (the mechanism matrix, including cross-scope orchestration) and `test/_base/di/provide.test.ts` (provide/unprovide semantics). + +- **Assert unit states, not internals.** Every container exposes its engine as `container.cascade`: `stateOf(IX)` → `'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed'`; `failureOf(IX)` → the sticky error of a `Failed` unit; `pendingSnapshot()` → the waiting-area contents. +- **The waiting area parks units with unregistered dependencies** — a unit whose declared deps are missing stays `Pending` (no throw), so a test must seed the full dependency chain. Example: a root→agent chain with no session container must seed the session-scope dependency explicitly — `ix.set(ISessionStateService, new SessionStateService())` in `test/session/agentLifecycle/agentLifecycle.test.ts` — or the dependent unit never activates. +- **Eager activation failure is sticky `Failed`, not a scope-creation throw.** Assert state + rethrow: `expect(ix.cascade.stateOf(IX)).toBe('Failed')`, then `expect(() => ix.invokeFunction((a) => a.get(IX))).toThrow(…)`. Do not expect scope/host creation itself to throw for a failing eager constructor. + ## Assertions and naming - One behavior per `it`; describe observable behavior (`child shadows parent registration`), not implementation (`calls _getOrCreateServiceInstance`). diff --git a/AGENTS.md b/AGENTS.md index da1a73f2842..1ba77bf7654 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,9 +17,9 @@ 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`). - the browser web UI: **its source no longer lives in this repo.** It is developed in the code-app repo (`apps/web`) and shipped as the committed, prebuilt bundle `apps/kimi-code/dist-web` (gitignored, force-added), synced from code-app with `KIMI_CODE_REPO= pnpm run sync:web` — sync and commit the bundle in the same change whenever the web UI should ship differently. `apps/kimi-code/scripts/check-web-assets.mjs` guards packaging against a missing bundle. To hack on the web UI against this repo's server, run `pnpm dev:server` here and point code-app's `pnpm dev:web` at it via `KIMI_SERVER_URL`. - `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 transcript chat, and per-scope Service panels. See `apps/kimi-inspect/AGENTS.md`. +- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session transcript chat, per-scope Service panels, and the DI unit inspection view. See `apps/kimi-inspect/AGENTS.md`. - `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. See `packages/agent-core/AGENTS.md`. -- `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent`; there is no App-level session lifecycle facade — callers compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler. See `packages/agent-core-v2/AGENTS.md` and use the `agent-core-dev` skill (`.agents/skills/agent-core-dev/SKILL.md`) when developing here. +- `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`app/scopes.ts`) — plus the L3 unit layer (`Service`/`Fiber` units, collection contribution points, the Feature seam in `src/features/`); there is no App-level session lifecycle facade — callers compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler. See `packages/agent-core-v2/AGENTS.md` and use the `agent-core-dev` skill (`.agents/skills/agent-core-dev/SKILL.md`) when developing here. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. - `packages/kaos`: the execution environment and file/process abstractions. diff --git a/apps/kimi-inspect/AGENTS.md b/apps/kimi-inspect/AGENTS.md index 2de845c78dd..23e74caf956 100644 --- a/apps/kimi-inspect/AGENTS.md +++ b/apps/kimi-inspect/AGENTS.md @@ -10,6 +10,7 @@ A left icon rail (`src/components/NavRail.tsx`) switches top-level views: - **Global message search** (`src/components/SearchView.tsx`) — cross-session full-text search over `POST /api/v1/search`, cursor-paged via a manual Load more; an exact-match checkbox maps to the API's `mode: 'literal'` substring search, which ignores sort and orders newest-first; a `live`/`index` badge on the results shows which server route served them (in-memory session transcript vs the persisted index). - **Model Catalog** (`src/components/ModelCatalogView.tsx`) — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies. 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`. - **App Services** (`src/components/AppServicesView.tsx`) — the app-scope Service reflection, full width, joined by the **Workspace Services** view (`src/components/WorkspaceServicesView.tsx`) — the workspace-scope counterpart with a left sidebar directory browser (`src/components/WorkspaceDirBrowser.tsx` — server-side fs browsing over the App-scope `IHostFolderBrowser`, marking entries that are registered workspaces with their `IWorkspaceTrust` trust state, and registering a picked folder on demand via `IWorkspaceService.createOrTouch`), its proxies riding the `/workspace/:id` route, which materializes the handler on demand via `IWorkspaceLifecycleService.handlerFor`. +- **DI view** (`src/components/DiInspectionView.tsx`) — the engine's Service × Effect × DI debug surface over the App-scope `IDebugLedgerService` / `IDebugGraphService` / `IDebugCascadeService`: the unit tree = ledger tree with unprovide / update / dispose triggers, the dependency DAG as a hand-rolled SVG, the cascade history, and the waiting area; the four panels poll on a short interval and refresh eagerly off the global `event.di.unit_changed` WS frame via `src/activity/di.ts`, which invalidates the `['di']` react-query prefix. The **Agent scope** stays in the Chat view's right dock (`src/components/RightPanel.tsx`) across two tabs: diff --git a/apps/kimi-inspect/README.md b/apps/kimi-inspect/README.md new file mode 100644 index 00000000000..3c90d456236 --- /dev/null +++ b/apps/kimi-inspect/README.md @@ -0,0 +1,58 @@ +# kimi-inspect + +Web inspector for the kap-server `/api/v1/debug` RPC surface — a read/trigger +window into a running Kimi Code engine (workspaces, sessions, agents, and the +scoped DI registry). + +## Run + +1. Start a kap-server with the debug surface mounted (repo dev scripts do this + for you): `pnpm dev:v1` / `pnpm dev:v2` from the repo root pass + `--debug-endpoints` on a loopback bind; the surface inherits the global + bearer auth. +2. `pnpm --filter @moonshot-ai/kimi-inspect dev` — the Vite dev server proxies + `/api` to the server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) + and auto-discovers running instances + (`~/.kimi-code/server/instances`); switch servers from the header dropdown. + +A connection failure shows a blocking "Debug surface unavailable" screen — +there is no fallback data source. + +## Views (left icon rail) + +- **Chat workspace** — session list (activity badges from the global-events WS) + plus a transcript-driven per-session chat; the right dock hosts the + Agent-scope Service panels, a plan lookup card, and the transcript audit + panel. The Session scope has its own column (pending interactions + session + Service panels, and a State tab). +- **Search** — cross-session full-text search over `POST /api/v1/search` + (cursor-paged; exact-match maps to the API's `literal` mode; a `live`/`index` + badge shows which server route served the results). +- **Model Catalog** — every provider with its models; expanding one opens the + model inspector (config layers + resolved runtime view with per-value + provenance). +- **App / Workspace Services** — the full Service reflection over the App + scope, and over each Workspace scope (picked via the directory browser; + workspace handlers materialize on demand). +- **DI** — the engine's Service × Effect × DI debug surface, four panels fed + by the App-scope debug Services (`IDebugLedgerService` / `IDebugGraphService` + / `IDebugCascadeService`) and refreshed eagerly off the `event.di.unit_changed` + WS frame: + - **Unit tree** — scope → unit → ledger entries (label, five-state + `Pending / Activating / Active / Unloading / Failed`, uid, `pinned` flag, + unit error object), with **unprovide / update / dispose** triggers. + - **Graph** — the dependency DAG (instance edges across scopes + collection + edges). + - **Cascade** — the cascade transaction history ring (changes, contagion set, + torn-down / rebuilt / failed, abort wait, duration). + - **Pending** — the waiting area: units parked on unsatisfied dependencies + with their missing-token sets. + +## Notes for maintainers + +- The channel layer (`src/channel/`) is a VS Code-style `ProxyChannel`: + `GET /api/v1/debug/channels` enumerates every scoped Service — there is no + whitelist; new Services appear automatically. +- There is no Service-event push channel besides the global events listed + above; panels fetch/refresh on demand (react-query, 15 s poll) plus the + `event.di.unit_changed` invalidation for the DI view. diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx index 2624dcf54fd..a3ce38d58e5 100644 --- a/apps/kimi-inspect/src/App.tsx +++ b/apps/kimi-inspect/src/App.tsx @@ -12,8 +12,10 @@ * `workspace` view is the workspace-scope counterpart * (`WorkspaceServicesView`, with a workspace picker on top); the * `bash` view is the full-width `IBashParserService` playground - * (`BashParserView`); the `search` view is the full-width global message - * search (`SearchView`) whose hits navigate back into the chat timeline. + * (`BashParserView`); the `di` view is the engine's Service × Effect × DI + * debug surface (`DiInspectionView`); the `search` view is the full-width + * global message search (`SearchView`) whose hits navigate back into the + * chat timeline. */ import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; @@ -24,6 +26,7 @@ import type { AuditTrail } from './audit/trail'; import { AppServicesView } from './components/AppServicesView'; import { BashParserView } from './components/BashParserView'; import { ChatView, type ChatJump } from './components/ChatView'; +import { DiInspectionView } from './components/DiInspectionView'; import { ModelCatalogView } from './components/ModelCatalogView'; import { NavRail, type AppView } from './components/NavRail'; import { RightPanel } from './components/RightPanel'; @@ -62,7 +65,10 @@ export function App() { .get(sessionId) .then((summary) => { if (summary === undefined) throw new Error(`session ${sessionId} does not exist`); - return klient.workspace(summary.workspaceId).service(ISessionLifecycleService).resume(sessionId); + return klient + .workspace(summary.workspaceId) + .service(ISessionLifecycleService) + .resume(sessionId); }) .then(() => { if (!cancelled) setReady(true); @@ -118,6 +124,8 @@ export function App() { ) : view === 'bash' ? ( + ) : view === 'di' ? ( + ) : view === 'models' ? ( { diff --git a/apps/kimi-inspect/src/activity/di.ts b/apps/kimi-inspect/src/activity/di.ts new file mode 100644 index 00000000000..ed5b7e3f007 --- /dev/null +++ b/apps/kimi-inspect/src/activity/di.ts @@ -0,0 +1,53 @@ +/** + * DI debug feed — a dedicated global-events socket for the DI view. The + * session activity hub (`useSessionActivities`) lives with the chat Sidebar, + * which unmounts when the DI view is active, so the DI view owns its own + * `GlobalEventsWs` (only one of the two is ever connected at a time). + * + * Every `event.di.unit_changed` frame invalidates the `['di']` query prefix + * (the same pattern as `event.session.created` invalidating `['sessions']`), + * so all DI panels refetch on unit transitions instead of waiting out their + * poll interval. Bursts (a cascade flipping many units at once) are coalesced + * with a short trailing throttle; a reconnect invalidates immediately, since + * live transitions were missed while the socket was down. + */ + +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; + +import { useConnection } from '../connection'; +import { GlobalEventsWs } from './ws'; + +const INVALIDATE_THROTTLE_MS = 250; + +export function useDiQueryInvalidation(): void { + const { baseUrl, config } = useConnection(); + const queryClient = useQueryClient(); + const token = config.token.trim(); + + useEffect(() => { + let timer: ReturnType | undefined; + const invalidate = () => { + if (timer !== undefined) return; + timer = setTimeout(() => { + timer = undefined; + void queryClient.invalidateQueries({ queryKey: ['di'] }); + }, INVALIDATE_THROTTLE_MS); + }; + const ws = new GlobalEventsWs({ + url: baseUrl, + token: token === '' ? undefined : token, + handlers: { + onWorkChanged: () => {}, + onSessionCreated: () => {}, + onMetaUpdated: () => {}, + onDiUnitChanged: invalidate, + onReconnected: invalidate, + }, + }); + return () => { + if (timer !== undefined) clearTimeout(timer); + ws.close(); + }; + }, [baseUrl, token, queryClient]); +} diff --git a/apps/kimi-inspect/src/activity/ws.ts b/apps/kimi-inspect/src/activity/ws.ts index 6e9b77af301..c7ff61089f6 100644 --- a/apps/kimi-inspect/src/activity/ws.ts +++ b/apps/kimi-inspect/src/activity/ws.ts @@ -11,7 +11,11 @@ * - `event.session.work_changed` → `{busy, main_turn_active, * pending_interaction, last_turn_reason}` for one session; * - `event.session.created` / `session.meta.updated` → list-level signals - * (a session appeared / retitled), forwarded for list invalidation. + * (a session appeared / retitled), forwarded for list invalidation; + * - `event.di.unit_changed` → one DI unit state transition of the engine's + * scope tree (the debug-surface feed), forwarded for `['di']` + * invalidation. Global like the rest: it carries the `__global__` + * session watermark and fans out to every connection. * * Session/agent-grained events never arrive here (they stay subscribe-gated * server-side); the transcript chat channel has its own socket. Global @@ -35,6 +39,18 @@ export interface SessionWorkFacts { readonly lastTurnReason?: SessionTurnOutcome | undefined; } +export type DiUnitState = 'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed'; + +/** Wire payload of the `event.di.unit_changed` global event. */ +export interface DiUnitChangedPayload { + /** Scope path of the container owning the unit (`app` / `app/workspace:` / …). */ + readonly scope: string; + readonly token: string; + readonly state: DiUnitState; + /** Serialized sticky failure, present only on a Failed transition. */ + readonly error?: string | undefined; +} + export interface GlobalEventsWsHandlers { /** Coarse work-fact tuple for one session changed. */ onWorkChanged: (sessionId: string, facts: SessionWorkFacts) => void; @@ -42,6 +58,8 @@ export interface GlobalEventsWsHandlers { onSessionCreated: (sessionId: string) => void; /** A session's title/patch changed (list-level signal). */ onMetaUpdated: (sessionId: string) => void; + /** A DI unit of the engine's scope tree changed state (debug feed). */ + onDiUnitChanged?: ((payload: DiUnitChangedPayload) => void) | undefined; /** Socket established (initial connect and every reconnect) — the consumer * answers with a REST re-seed, since live facts are missed while down. */ onReconnected: () => void; @@ -165,6 +183,11 @@ export class GlobalEventsWs { this.handlers.onMetaUpdated(sessionId); return; } + case 'event.di.unit_changed': { + const payload = parseDiUnitChangedPayload(frame.payload); + if (payload !== undefined) this.handlers.onDiUnitChanged?.(payload); + return; + } case 'ping': { const nonce = (frame.payload as { nonce?: unknown } | undefined)?.nonce; this.send({ type: 'pong', payload: { nonce } }); @@ -212,6 +235,28 @@ function parseWorkFacts(payload: unknown): SessionWorkFacts | undefined { }; } +const DI_UNIT_STATES: ReadonlySet = new Set([ + 'Pending', + 'Activating', + 'Active', + 'Unloading', + 'Failed', +]); + +function parseDiUnitChangedPayload(payload: unknown): DiUnitChangedPayload | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const p = payload as Record; + if (typeof p['scope'] !== 'string' || typeof p['token'] !== 'string') return undefined; + const state = p['state']; + if (typeof state !== 'string' || !DI_UNIT_STATES.has(state)) return undefined; + return { + scope: p['scope'], + token: p['token'], + state: state as DiUnitState, + error: typeof p['error'] === 'string' ? p['error'] : undefined, + }; +} + /** Derive the `/api/v1/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */ function toWsUrl(base: string): string { const url = new URL(base); diff --git a/apps/kimi-inspect/src/components/DiInspectionView.tsx b/apps/kimi-inspect/src/components/DiInspectionView.tsx new file mode 100644 index 00000000000..bd1f78476e4 --- /dev/null +++ b/apps/kimi-inspect/src/components/DiInspectionView.tsx @@ -0,0 +1,610 @@ +/** + * DI view — the engine's "Service × Effect × DI" debug surface (the App-scope + * `debug` domain, exposed like every other Service over `/api/v1/debug`): + * + * - Units: the unit tree = ledger tree (`IDebugLedgerService.tree`) — one + * collapsible card per scope container, its units with the five-state + * cascade badge and the unprovide / update / dispose triggers + * (`IDebugCascadeService`), its ledger entries as a nested subtree; + * - Deps: the persistent dependency DAG (`IDebugGraphService.graph`) as + * root-based Miller columns (`di/DiGraphPanel.tsx`) — column 0 lists the + * roots (services nothing consumes), each click opens the next column + * with that service's direct dependencies; rows carry path-scoped + * relation bars (one per path ancestor with a direct edge) and a + * path-root background highlight; + * - Cascade: the cross-scope cascade history rings + * (`IDebugCascadeService.history`), newest first; + * - Pending: the waiting area + sticky failures per scope + * (`IDebugCascadeService.pending`), with an `update` retry per failure. + * + * All four panels poll on a short interval and refresh eagerly when the + * global `event.di.unit_changed` WS frame fires (`useDiQueryInvalidation` + * invalidates the `['di']` query prefix). + */ + +import type { UnitState } from '@moonshot-ai/agent-core-v2/_base/di/cascadeEngine'; +import type { LedgerEntryInfo } from '@moonshot-ai/agent-core-v2/_base/lifecycle/ledger'; +import { + IDebugCascadeService, + type DebugCascadeEntry, + type DebugPendingGroup, +} from '@moonshot-ai/agent-core-v2/debug/debugCascade'; +import { IDebugGraphService, type DebugGraph } from '@moonshot-ai/agent-core-v2/debug/debugGraph'; +import { + IDebugLedgerService, + type DebugLedgerNode, + type DebugUnit, +} from '@moonshot-ai/agent-core-v2/debug/debugLedger'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState, type ReactNode } from 'react'; + +import { useDiQueryInvalidation } from '../activity/di'; +import type { InspectClient } from '../channel'; +import { useConnection } from '../connection'; +import { ActionButton, Badge, ErrorLine } from '../ui'; +import { DiGraphPanel } from './di/DiGraphPanel'; + +type DiPanel = 'units' | 'graph' | 'cascade' | 'pending'; + +const PANELS: readonly { id: DiPanel; title: string }[] = [ + { id: 'units', title: 'Units' }, + { id: 'graph', title: 'Deps' }, + { id: 'cascade', title: 'Cascade' }, + { id: 'pending', title: 'Pending' }, +]; + +const REFETCH_INTERVAL_MS = 3000; + +export function DiInspectionView() { + useDiQueryInvalidation(); + const [panel, setPanel] = useState('units'); + return ( +
+ +
+ {panel === 'units' ? ( + + ) : panel === 'graph' ? ( + + ) : panel === 'cascade' ? ( + + ) : ( + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Shared data plumbing +// --------------------------------------------------------------------------- + +function useDiQuery(panel: string, fetcher: (klient: InspectClient) => Promise) { + const { klient } = useConnection(); + return useQuery({ + queryKey: ['di', klient.baseUrl, panel], + queryFn: () => fetcher(klient), + refetchInterval: REFETCH_INTERVAL_MS, + }); +} + +/** The loading / error gate shared by every panel. Returns null when data is ready. */ +function panelGate(query: { isError: boolean; error: unknown; data: unknown }): ReactNode | null { + if (query.isError) return ; + if (query.data === undefined) { + return
loading…
; + } + return null; +} + +type DiTriggerAction = 'unprovide' | 'update' | 'dispose'; + +interface DiTrigger { + /** `${action}:${scopePath}:${token}` of the in-flight trigger, if any. */ + readonly busy: string | null; + readonly error: unknown; + readonly run: (action: DiTriggerAction, scopePath: string, token: string) => Promise; +} + +function triggerKey(action: DiTriggerAction, scopePath: string, token: string): string { + return `${action}:${scopePath}:${token}`; +} + +/** + * The destructive unit triggers (`IDebugCascadeService.unprovide` / `update` + * / `dispose`), confirm-gated like the ServiceCard danger actions; a settled + * trigger invalidates the `['di']` prefix so every panel converges. + */ +function useDiTrigger(): DiTrigger { + const { klient } = useConnection(); + const queryClient = useQueryClient(); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + + const run = async (action: DiTriggerAction, scopePath: string, token: string): Promise => { + if (!window.confirm(`${action} '${token}' @ ${scopePath}?`)) return; + setBusy(triggerKey(action, scopePath, token)); + setError(null); + try { + const svc = klient.core(IDebugCascadeService); + if (action === 'unprovide') await svc.unprovide(scopePath, token); + else if (action === 'update') await svc.update(scopePath, token); + else await svc.dispose(scopePath, token); + await queryClient.invalidateQueries({ queryKey: ['di'] }); + } catch (error) { + setError(error); + } finally { + setBusy(null); + } + }; + + return { busy, error, run }; +} + +function truncate(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +// --------------------------------------------------------------------------- +// Units panel — the unit tree = ledger tree +// --------------------------------------------------------------------------- + +const STATE_TONES: Record = { + Pending: 'neutral', + Activating: 'sky', + Active: 'green', + Unloading: 'amber', + Failed: 'red', +}; + +function UnitStateBadge({ state }: { state?: UnitState }) { + if (state === undefined) return unseen; + return {state}; +} + +function UnitsPanel() { + const query = useDiQuery('units', (klient) => klient.core(IDebugLedgerService).tree()); + const trigger = useDiTrigger(); + const gate = panelGate(query); + if (gate !== null) return gate; + const tree = query.data as DebugLedgerNode; + return ( +
+ {trigger.error !== null ? ( +
+ +
+ ) : null} + +
+ ); +} + +function ScopeNodeCard({ + node, + depth, + trigger, +}: { + node: DebugLedgerNode; + depth: number; + trigger: DiTrigger; +}) { + const [open, setOpen] = useState(depth < 2); + const counts = new Map(); + for (const unit of node.units) { + if (unit.state === undefined) continue; + counts.set(unit.state, (counts.get(unit.state) ?? 0) + 1); + } + return ( +
+
+
{ + setOpen((v) => !v); + }} + > + {open ? '▾' : '▸'} + {node.label} + + {node.path} + + + {node.units.length} units + {[...counts.entries()].map(([state, count]) => ( + + {count} {state} + + ))} + +
+ {open ? ( +
+ {node.units.length === 0 ? ( +
no units
+ ) : ( + node.units.map((unit) => ( + + )) + )} + +
+ ) : null} +
+ {open + ? node.children.map((child) => ( + + )) + : null} +
+ ); +} + +function UnitRow({ + scopePath, + unit, + trigger, +}: { + scopePath: string; + unit: DebugUnit; + trigger: DiTrigger; +}) { + const [errorOpen, setErrorOpen] = useState(false); + const actions: readonly DiTriggerAction[] = ['unprovide', 'update', 'dispose']; + return ( +
+
+ + {unit.token} + + + + + {unit.inFlight === true ? in-flight : null} + #{unit.uid} + {unit.error !== undefined ? ( + + ) : null} + + {actions.map((action) => ( + void trigger.run(action, scopePath, unit.token)} + > + {trigger.busy === triggerKey(action, scopePath, unit.token) ? '…' : action} + + ))} + +
+ {errorOpen && unit.error !== undefined ? ( +
+          {unit.error}
+        
+ ) : null} +
+ ); +} + +const LEDGER_KIND_TONES: Record = { + disposer: 'neutral', + effect: 'sky', + ledger: 'violet', +}; + +function LedgerSection({ entries }: { entries: readonly LedgerEntryInfo[] }) { + const [open, setOpen] = useState(false); + if (entries.length === 0) return null; + return ( +
+ + {open ? ( +
+ {entries.map((entry, i) => ( + + ))} +
+ ) : null} +
+ ); +} + +function LedgerEntryRow({ entry }: { entry: LedgerEntryInfo }) { + return ( +
+ + {entry.label} + + + {entry.kind} + + {entry.children !== undefined && entry.children.length > 0 ? ( +
+ {entry.children.map((child, i) => ( + + ))} +
+ ) : null} +
+ ); +} + +// --------------------------------------------------------------------------- +// Graph panel — the dependency DAG; rendering lives in di/DiGraphPanel.tsx +// --------------------------------------------------------------------------- + +function GraphPanel() { + const query = useDiQuery('graph', (klient) => klient.core(IDebugGraphService).graph()); + const gate = panelGate(query); + if (gate !== null) return gate; + return ; +} + +// --------------------------------------------------------------------------- +// Cascade panel — the cross-scope cascade history rings, newest first +// --------------------------------------------------------------------------- + +function CascadePanel() { + const query = useDiQuery('cascade', (klient) => klient.core(IDebugCascadeService).history()); + const gate = panelGate(query); + if (gate !== null) return gate; + // seq is a per-engine ring sequence; cross-scope ordering is approximate. + const entries = (query.data as DebugCascadeEntry[]).toReversed(); + if (entries.length === 0) { + return
no cascade transactions yet
; + } + return ( +
+ {entries.map((entry, i) => ( + + ))} +
+ ); +} + +function CascadeEntryCard({ entry }: { entry: DebugCascadeEntry }) { + const [open, setOpen] = useState(false); + const expandable = + entry.failed.length > 0 || entry.affected.length > 0 || entry.tornDown.length > 0; + return ( +
+
{ + setOpen((v) => !v); + } + : undefined + } + > + #{entry.seq} + + {truncate(entry.scopePath, 40)} + + {entry.abortWaited ? abort waited : null} + {entry.abortTimedOut ? abort timed out : null} + {entry.failed.length > 0 ? {entry.failed.length} failed : null} + + {entry.reason} + + {entry.durationMs}ms + {expandable ? ( + {open ? '▾' : '▸'} + ) : null} +
+
+
+ {entry.changes.map((change, i) => ( + + {change.action} {truncate(change.token, 48)} + + ))} +
+
+ affected {entry.affected.length} · torn down {entry.tornDown.length} · rebuilt{' '} + {entry.rebuilt.length} · failed {entry.failed.length} +
+ {open ? ( +
+ {entry.affected.length > 0 ? ( + + ) : null} + {entry.tornDown.length > 0 ? ( + + ) : null} + {entry.failed.length > 0 ? ( + + ) : null} +
+ ) : null} +
+
+ ); +} + +function TokenList({ + label, + tokens, + danger, +}: { + label: string; + tokens: readonly string[]; + danger?: boolean; +}) { + return ( +
+
+ {label} +
+
+ {tokens.map((token, i) => ( + + {truncate(token, 64)} + + ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Pending panel — the waiting area + sticky failures per scope +// --------------------------------------------------------------------------- + +function PendingPanel() { + const query = useDiQuery('pending', (klient) => klient.core(IDebugCascadeService).pending()); + const trigger = useDiTrigger(); + const gate = panelGate(query); + if (gate !== null) return gate; + const groups = query.data as DebugPendingGroup[]; + return ( +
+ {trigger.error !== null ? ( +
+ +
+ ) : null} + {groups.length === 0 ? ( +
no waiting or failed units
+ ) : ( + groups.map((group) => ( +
+
+ {group.scopePath} +
+
+ {group.waiting.length > 0 ? ( +
+
+ waiting ({group.waiting.length}) +
+ {group.waiting.map((unit) => ( +
+ + {unit.token} + + missing: + + {unit.missing.map((dep, i) => ( + + {truncate(dep, 48)} + + ))} + +
+ ))} +
+ ) : null} + {group.failed.length > 0 ? ( +
+
+ failed ({group.failed.length}) +
+ {group.failed.map((unit) => ( +
+
+ + {unit.token} + + + void trigger.run('update', group.scopePath, unit.token)} + > + {trigger.busy === triggerKey('update', group.scopePath, unit.token) + ? '…' + : 'retry update'} + + +
+ {unit.error !== undefined ? ( +
+                          {unit.error}
+                        
+ ) : null} +
+ ))} +
+ ) : null} +
+
+ )) + )} +
+ ); +} diff --git a/apps/kimi-inspect/src/components/NavRail.tsx b/apps/kimi-inspect/src/components/NavRail.tsx index edcad9e00ab..c06a646d897 100644 --- a/apps/kimi-inspect/src/components/NavRail.tsx +++ b/apps/kimi-inspect/src/components/NavRail.tsx @@ -6,7 +6,7 @@ import type { ReactNode } from 'react'; -export type AppView = 'chat' | 'search' | 'models' | 'services' | 'workspace' | 'bash'; +export type AppView = 'chat' | 'search' | 'models' | 'services' | 'workspace' | 'bash' | 'di'; interface ViewDef { readonly id: AppView; @@ -87,6 +87,19 @@ const VIEWS: readonly ViewDef[] = [ ), }, + { + id: 'di', + title: 'DI', + icon: ( + + + + + + + + ), + }, ]; export function NavRail({ diff --git a/apps/kimi-inspect/src/components/di/DiGraphPanel.tsx b/apps/kimi-inspect/src/components/di/DiGraphPanel.tsx new file mode 100644 index 00000000000..d5614f029ba --- /dev/null +++ b/apps/kimi-inspect/src/components/di/DiGraphPanel.tsx @@ -0,0 +1,374 @@ +/** + * DI Deps panel — the persistent dependency DAG (`IDebugGraphService.graph`) + * as Finder-style Miller columns with path-scoped relation markers. Edges + * point consumer → dependency, so "X depends on Y" ⟺ an edge + * `from: X → to: Y`: + * + * - column 0 lists the roots: services that never appear as an edge's `to` + * (nothing consumes them, isolated nodes included), token-sorted, + * constrained by the scope filter; each root row carries the level-0 + * color bar; + * - clicking a row in column N truncates the expansion path there and + * opens column N+1 with that service's direct dependencies (along `to`, + * unfiltered — cross-scope rows get a scopePath subtitle); + * - relation bars are path-scoped: a row's left edge carries one 3px bar + * per path ancestor that depends on it DIRECTLY (edge ancestor → node), + * ordered by ancestor depth — the direct parent always contributes one, + * and a grandparent with a skip-level edge adds another. Every color is + * the fixed per-LEVEL identity color (`levelColor(depth)`), so a bar + * reads directly as "depended on by the service N levels up", identical + * in every path; column headers carry the parent level's color chip so + * bars map back to ancestors; + * - path highlight: rows covered by the closure (reachable along `to`) of + * the path's root get a faint background in the level-0 color; + * - rows without dependencies are dimmed and carry no chevron; a row + * already in the current path is marked ↩ (cycle guard — columns only + * ever open on click, so there is no auto-recursion). + * + * Pure React + Tailwind, no graph or layout library. + */ +import type { UnitState } from '@moonshot-ai/agent-core-v2/_base/di/cascadeEngine'; +import type { DebugGraph, DebugGraphNode } from '@moonshot-ai/agent-core-v2/debug/debugGraph'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +const STATE_COLORS: Record = { + Pending: '#a3a3a3', + Activating: '#38bdf8', + Active: '#34d399', + Unloading: '#fbbf24', + Failed: '#f87171', + none: '#525252', +}; + +/** + * Fixed identity color per path depth (level): `levelColor(d)` is the same + * for every service at depth d in every path, so a row's color bars read + * directly as "depended on by the service at level 0 / 1 / 2 …". + */ +const LEVEL_PALETTE = [ + '#f87171', + '#fb923c', + '#fbbf24', + '#a3e635', + '#34d399', + '#22d3ee', + '#38bdf8', + '#818cf8', + '#c084fc', + '#f472b6', +]; + +function levelColor(depth: number): string { + return LEVEL_PALETTE[depth % LEVEL_PALETTE.length] ?? '#525252'; +} + +function scopeDepth(path: string): number { + return path.split('/').length; +} + +function compareScopePaths(a: string, b: string): number { + return scopeDepth(a) - scopeDepth(b) || a.localeCompare(b); +} + +function byToken(a: DebugGraphNode, b: DebugGraphNode): number { + return a.token.localeCompare(b.token); +} + +function fallbackNode(id: string): DebugGraphNode { + return { id, token: id, scopePath: '' }; +} + +interface DepsColumn { + readonly title: string; + /** + * The scope the column's rows belong to by default ('' = unfiltered root + * column): rows from any other scope get a scopePath subtitle, as do all + * rows when the column spans more than one scope. + */ + readonly homeScope: string; + readonly items: readonly DebugGraphNode[]; +} + +export function DiGraphPanel({ graph }: { graph: DebugGraph }) { + const [scopeFilter, setScopeFilter] = useState(''); + /** Selected row id per column — path[i] is the row expanded into column i+1. */ + const [path, setPath] = useState([]); + const scrollRef = useRef(null); + + const allScopes = useMemo( + () => [...new Set(graph.nodes.map((n) => n.scopePath))].toSorted(compareScopePaths), + [graph], + ); + + const nodesById = useMemo(() => new Map(graph.nodes.map((n) => [n.id, n])), [graph]); + + /** Direct dependencies per consumer id (edge `to` endpoints), deduped. */ + const depsOf = useMemo(() => { + const acc = new Map>(); + for (const e of graph.edges) { + const set = acc.get(e.from) ?? new Set(); + set.add(e.to); + acc.set(e.from, set); + } + return acc; + }, [graph]); + + /** Services nothing consumes (never an edge's `to`), scope-filtered. */ + const roots = useMemo(() => { + const consumed = new Set(graph.edges.map((e) => e.to)); + const pool = + scopeFilter === '' ? graph.nodes : graph.nodes.filter((n) => n.scopePath === scopeFilter); + return pool.filter((n) => !consumed.has(n.id)).toSorted(byToken); + }, [graph, scopeFilter]); + + // Walk the path, truncating it at the first entry that is no longer + // selectable (scope filter change or data refresh), so rendering and + // follow-up clicks always work from a valid prefix. + const { columns, usedPath } = useMemo(() => { + const columns: DepsColumn[] = []; + const usedPath: string[] = []; + columns.push({ + title: `roots · ${scopeFilter === '' ? 'all scopes' : scopeFilter}`, + homeScope: scopeFilter, + items: roots, + }); + let items = roots; + for (const id of path) { + if (!items.some((n) => n.id === id)) break; + usedPath.push(id); + const deps = [...(depsOf.get(id) ?? [])] + .map((depId) => nodesById.get(depId) ?? fallbackNode(depId)) + .toSorted(byToken); + if (deps.length === 0) break; + const parent = nodesById.get(id); + columns.push({ + title: parent?.token ?? id, + homeScope: parent?.scopePath ?? '', + items: deps, + }); + items = deps; + } + return { columns, usedPath }; + }, [scopeFilter, path, roots, depsOf, nodesById]); + + // Keep the deepest column in view as the path grows. + useEffect(() => { + const el = scrollRef.current; + if (el !== null) el.scrollLeft = el.scrollWidth; + }, [columns.length]); + + const select = (columnIndex: number, id: string) => { + setPath([...usedPath.slice(0, columnIndex), id]); + }; + + // Path context: the root the current path starts at (column 0 only holds + // roots, so this is always one — guarded anyway). + const pathRootId = usedPath[0]; + + // Path-highlight: nodes reachable (along `to`) from the path's root, + // tinted in the fixed level-0 color. Only the path root's closure is + // needed — computed lazily per selection. + const pathTint = pathRootId === undefined ? undefined : `${levelColor(0)}1a`; + const pathClosure = useMemo(() => { + if (pathRootId === undefined) return undefined; + const seen = new Set(); + const stack = [pathRootId]; + while (stack.length > 0) { + const id = stack.pop() ?? ''; + if (seen.has(id)) continue; + seen.add(id); + for (const dep of depsOf.get(id) ?? []) stack.push(dep); + } + return seen; + }, [pathRootId, depsOf]); + + return ( +
+
+ + + {(Object.keys(STATE_COLORS) as (UnitState | 'none')[]).map((state) => ( + + + {state === 'none' ? 'unseen' : state} + + ))} + +
+ {graph.nodes.length === 0 ? ( +
no nodes
+ ) : ( +
+ {columns.map((column, columnIndex) => ( + + ))} +
+ )} +
+ ); +} + +function DepsColumnView({ + column, + columnIndex, + usedPath, + depsOf, + nodesById, + pathTint, + pathClosure, + onSelect, +}: { + column: DepsColumn; + columnIndex: number; + usedPath: readonly string[]; + depsOf: ReadonlyMap>; + nodesById: ReadonlyMap; + pathTint?: string; + pathClosure?: ReadonlySet; + onSelect: (columnIndex: number, id: string) => void; +}) { + const mixedScopes = new Set(column.items.map((n) => n.scopePath)).size > 1; + const selectedId = usedPath[columnIndex]; + return ( +
+
+ {columnIndex > 0 ? ( + + ) : null} + + {column.title} + + {column.items.length} +
+
+ {column.items.length === 0 ? ( +
+ {columnIndex === 0 ? 'no roots' : 'no nodes'} +
+ ) : ( + column.items.map((node) => { + const hasDeps = (depsOf.get(node.id)?.size ?? 0) > 0; + const selected = node.id === selectedId; + const inPath = usedPath.slice(0, columnIndex).includes(node.id); + const showScope = + mixedScopes || (column.homeScope !== '' && node.scopePath !== column.homeScope); + // Path-scoped relation bars: column 0 rows carry the level-0 + // color; expanded columns carry one bar per path ancestor with a + // DIRECT edge to this node, each in that ancestor's level color. + const bars: { color: string; label: string }[] = []; + if (columnIndex === 0) { + bars.push({ color: levelColor(0), label: node.token }); + } else { + usedPath.slice(0, columnIndex).forEach((ancestorId, depth) => { + if (depsOf.get(ancestorId)?.has(node.id) === true) { + bars.push({ + color: levelColor(depth), + label: nodesById.get(ancestorId)?.token ?? ancestorId, + }); + } + }); + } + const inPathTree = !selected && pathClosure?.has(node.id) === true; + const details = [ + `state: ${node.state ?? 'unseen'}`, + node.uid !== undefined ? `#${node.uid}` : undefined, + inPath ? 'already in path' : undefined, + columnIndex > 0 && bars.length > 0 + ? `direct dependency of: ${bars.map((b) => b.label).join(', ')}` + : undefined, + ] + .filter((s) => s !== undefined) + .join('\n'); + return ( + + ); + }) + )} +
+
+ ); +} diff --git a/apps/kimi-inspect/src/panels.ts b/apps/kimi-inspect/src/panels.ts index bfb1fc80d20..51e66304a4e 100644 --- a/apps/kimi-inspect/src/panels.ts +++ b/apps/kimi-inspect/src/panels.ts @@ -21,7 +21,7 @@ import { IAgentGoalService } from '@moonshot-ai/agent-core-v2/agent/goal/goal'; import { IAgentMcpService } from '@moonshot-ai/agent-core-v2/agent/mcp/mcp'; import { IAgentPermissionModeService } from '@moonshot-ai/agent-core-v2/agent/permissionMode/permissionMode'; import { IAgentPermissionRulesService } from '@moonshot-ai/agent-core-v2/agent/permissionRules/permissionRules'; -import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/agent/plan/plan'; +import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; import { IAgentSwarmService } from '@moonshot-ai/agent-core-v2/agent/swarm/swarm'; diff --git a/flake.nix b/flake.nix index 33df4666e03..a102e68b908 100644 --- a/flake.nix +++ b/flake.nix @@ -162,7 +162,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-52qERxzBsr7sYsKFq22k6ODNHY2uL8meRWAkvxo01no="; + hash = "sha256-P450+LKDYkRyk7OZ2mSOX0/RwtbivwR5ZksN8FM6+TU="; }; nativeBuildInputs = [ diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index ec40fc8ffdc..392b6cad49e 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -4,7 +4,28 @@ ## Scopes -Four `LifecycleScope` tiers — `App` (0) / `Workspace` (1) / `Session` (2) / `Agent` (3) (`src/_base/di/scope.ts`). The `workspace/` domain owns the Workspace tier: the App-scope `workspaceLifecycle` holds the live handler registry (one handler per workspaceId, create-or-get + join, never closed), and each handler's `sessionLifecycle` owns the session lifecycle (create/resume/fork/close/delete) as its child scopes. Workspace-scope services (`workspaceSkillCatalog` / `workspaceAgentProfileLoader` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy` / `workspaceTrust`) hold the handler-shared resources — loaded once at handler materialization, then refreshed by fs watch — and sessions consume them through session-domain seed contracts with change events (`session/mcp`, `session/workspaceInfo`, `session/sessionSkillCatalog` data, …). `workspaceMcp` is pure connection orchestration over the scope-agnostic `mcpCore` layer; the effective server set is owned by `workspaceMcpConfig` (mcp.json files + plugin contributions, fs-watch refreshed), and MCP persistence — the `[mcp]` config section plus OAuth credentials — lives in `app/mcpConfig`, the same wrapper shape as `kosongConfig` over kosong. `workspaceDirs` is backed by `.kimi-code/local.toml`; `workspaceToolPolicy` is the os-level tool veto. A session created with `CreateSessionOptions.mcpServers` additionally gets ephemeral per-session MCP servers: `workspaceMcp.sessionOverlay` builds a session-owned manager for them (never persisted, invisible to the handler's other sessions, not gated by `workspaceTrust`), the session's `ISessionMcpHandle` seed carries a `session/mcp` `MergedMcpConnectionView` over the shared manager and the overlay (an ephemeral name shadows a workspace server for that session), and `sessionLifecycle` shuts the overlay down when the session handle disposes (backstopped by the lifecycle service's own dispose for teardown paths that bypass the handle wrapper). Agent profiles follow the Contribution / Registry / Catalog extension point instead of a workspace catalog: the `workspaceAgentProfileLoader` domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit runtime files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) register `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, tagged with the handler's `workspaceId` (the registry dedups per source id; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles), and each Session-scope `sessionAgentProfileCatalog` projects the registry into the merged read view directly (name-level dedup + the builtin-override rule in the projection) — its seed carries only the workspace key. `workspaceTrust` records the per-workspace trust marker (persisted under the home, keyed by `encodeWorkDirKey(root)`); while untrusted, `workspaceMcpConfig` skips the project-level MCP config files (`.mcp.json`, `.kimi-code/mcp.json`). The trust state flips through kap-server's `GET|POST /workspaces/{id}/trust` + `POST /workspaces/{id}/untrust` routes. The old App-level session-lifecycle facade and `ISessionMcpService` / `ISessionFsService` are gone — compose `sessionIndex` → `workspaceLifecycle.handlerFor` → the handler instead. +Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (string-valued, declared in `src/app/scopes.ts` — the DI kernel in `src/_base/di/scope.ts` only knows opaque `ScopeKind` strings plus the order installed by `setScopeTopology`). The `workspace/` domain owns the Workspace tier: the App-scope `workspaceLifecycle` holds the live handler registry (one handler per workspaceId, create-or-get + join, never closed), and each handler's `sessionLifecycle` owns the session lifecycle (create/resume/fork/close/delete) as its child scopes. Workspace-scope services (`workspaceSkillCatalog` / `workspaceAgentProfileLoader` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy` / `workspaceTrust`) hold the handler-shared resources — loaded once at handler materialization, then refreshed by fs watch — and sessions consume them through session-domain seed contracts with change events (`session/mcp`, `session/workspaceInfo`, `session/sessionSkillCatalog` data, …), projected by five seed-adapter units (`src/session/sessionSeed/sessionSeedAdapters.ts`): each adapter `@ref`-observes its workspace upstream, live-reads through getters, re-fires `onDidChange` when the backing generation switches, and provides the seed token synchronously through the session scope's `ScopeOptions.assemble` hook before session services activate (a host without the workspace layer keeps the scope's default `extra` registration; the inline seeds stay plain `extra`). `workspaceMcp` is pure connection orchestration over the scope-agnostic `mcpCore` layer; the effective server set is owned by `workspaceMcpConfig` (mcp.json files + plugin contributions, fs-watch refreshed), and MCP persistence — the `[mcp]` config section plus OAuth credentials — lives in `app/mcpConfig`, the same wrapper shape as `kosongConfig` over kosong. `workspaceDirs` is backed by `.kimi-code/local.toml`; `workspaceToolPolicy` is the os-level tool veto. A session created with `CreateSessionOptions.mcpServers` additionally gets ephemeral per-session MCP servers: `workspaceMcp.sessionOverlay` builds a session-owned manager for them (never persisted, invisible to the handler's other sessions, not gated by `workspaceTrust`), the session's `ISessionMcpHandle` seed carries a `session/mcp` `MergedMcpConnectionView` over the shared manager and the overlay (an ephemeral name shadows a workspace server for that session), and `sessionLifecycle` shuts the overlay down when the session handle disposes (backstopped by the lifecycle service's own dispose for teardown paths that bypass the handle wrapper). Agent profiles follow the Contribution / Registry / Catalog extension point instead of a workspace catalog: the `workspaceAgentProfileLoader` domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit runtime files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) contribute `AgentProfileContribution` records to the collection via `this.provide`, tagged with the handler's `workspaceId`; the App-scope `IAgentProfileRegistry` is a fold over that collection (same-(sourceId, workspaceKey) later records shadow earlier ones, provider death withdraws; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles through an owned helper unit), and each Session-scope `sessionAgentProfileCatalog` projects the registry into the merged read view directly (name-level dedup + the builtin-override rule in the projection) — its seed carries only the workspace key. `workspaceTrust` records the per-workspace trust marker (persisted under the home, keyed by `encodeWorkDirKey(root)`); while untrusted, `workspaceMcpConfig` skips the project-level MCP config files (`.mcp.json`, `.kimi-code/mcp.json`). The trust state flips through kap-server's `GET|POST /workspaces/{id}/trust` + `POST /workspaces/{id}/untrust` routes. The old App-level session-lifecycle facade and `ISessionMcpService` / `ISessionFsService` are gone — compose `sessionIndex` → `workspaceLifecycle.handlerFor` → the handler instead. + +## Units and contribution points (L3) + +The DI kernel (`src/_base/di/`) owns the unit layer on top of the scoped registry: + +- `service.ts` — `Service`: the unit base class (extends `Disposable`). Capabilities live on `this` (`provide` / `effect` / `on` / `get` / `ref`, plus `name` / `state` / `config`). Two-phase construction: inside the ctor `provide`/`on`/`effect` buffer (writes only — `get`/`ref` throw, dependencies are constructor parameters); the kernel binds the runtime after `Reflect.construct` and flushes in writing order; a manually `new`ed instance throws on every capability call. Services whose own members collide with the `Service` vocabulary keep `extends Disposable` with a NOTE comment — still full DI units (cascade/ledger do not require `Service`). +- `fiber.ts` — the `Fiber` capability interface (not a DI token), `FiberHandle` (thenable / `state` / `uid` / `update` / `dispose`), `ServiceRecipe` (class / arrow function / `{apply}`), the `FiberState` five-state machine, and `ScopeUnits(kind)` — the materialization collection token, one per scope kind. +- `collection.ts` — `collection(name)` contribution tokens. Contribute with `this.provide(token, value)`; a fold declares the token as a constructor parameter and receives a `CollectionView` (`items` / `records` / incremental `onDidChange`). Records are visible to the provider's ancestors and descendants (never sibling subtrees); provider death withdraws. Collection edges enter the graph for introspection but never join a cascade contagion set. +- `scopeUnits.ts` — the kernel fold: every scope-creation point (`createScopedChildHandle` / `Scope.createApp` / `Scope.createChild`) runs `watchScopeUnits(container, kind)` before eager activation, materializing each visible `ScopeUnits(kind)` record's recipe as a unit inside the new scope (disposal hangs on the record provider's book — provider death tears the materialized units down across the tree). `ScopeOptions.assemble` runs at the same point (the session seed adapters use it). +- `instantiation.ts` — the `@ref(IX)` decorator factory (`LiveRef`: `current` live read + `onDidChange` availability event; observation creates no binding and no graph edge) and `ScopeActivation`. +- `src/app/feature/` — `IFeatureManager` (App scope): runtime unit assembly (`provideUnit` / `unprovideUnit` / `updateUnit`) and introspection (`units()` / `onDidChangeUnits`); managed units hang on the manager's own book. External package management stays with `IPluginService`. The `features` assembly (`src/features/featureAssemblyService.ts`) drains the module-level feature table through it. + +The four contribution seams (token → fold): config sections — `ConfigSectionContribution` → `ConfigRegistry` fold (`src/app/config/`; module-level `registerConfigSection` stays the static built-in channel drained at construction, a withdrawn runtime record unregisters the section while user TOML values survive); agent tools — `AgentToolContribution` → `AgentToolActivationService` fold (built-in records provided once at App scope by `builtinToolAssemblyService`; `registerAgentToolService` stays the static channel: Agent-scope DI `OnDemand` registration + module table); agent profiles — `AgentProfileContribution` → `IAgentProfileRegistry` fold (see Scopes); wire vocabulary — `WireModelContribution` → `WireService` fold (a record bundles `models` / `ops` / `crossReducers` / `checkpointedModels`; the built-in layer is the module tables drained at fold time — `defineOp` / `defineModel` / `defineCheckpointedModel` stay the static channel — and replaying a withdrawn domain's history lands on the unknown-op skip-and-count path). A fifth seam: executable commands — `CommandContribution` → `IAgentCommandService` fold (`src/agent/command/`; a contributed command runs engine-side — `run(ctx)` gets `ctx.get` resolving through the agent container, valid only during the synchronous part of `run`; name-level dedup, last record wins; surfaced over RPC as `agentRPCService.listCommands` / `runCommand`). + +`src/features/` — built-in capabilities authored as self-contained Feature units (`plan` is the first, extracted from `agent/plan` + `agent/tools/plan`). A `Feature` (`src/features/feature.ts`) is an App-scope unit recipe with a `static override readonly name` and `contribute*` helpers composing the seams: `contributeService(scope, id, ctor)` / `contributeAgentService` (per-scope materialization via `ScopeUnits` — provider death retracts everywhere, 连坐), `contributeTool` (per-agent `OnDemand` registration + the `AgentToolContribution` record), `contributeProfiles`, `contributeConfig`, `contributeCommand`, plus `onDispose`. Feature modules self-register at import (`registerFeature`, `src/features/featureRegistry.ts`); the App-scope `IFeatureAssemblyService` drains the table through `IFeatureManager.provideUnit`, so every feature is a named, introspectable, retractable managed unit. Built-in features keep user-facing static contracts — config sections, agent profiles, wire vocabulary — on the static import=register channels (the config/state manifest generators read static tables / call sites; wire records must stay replayable); the Feature unit carries the runtime capabilities (services, tools, commands). The string form of the unit `on(...)` capability (`this.on('turn.ended', …)`) is backed by the production `FiberEventResolver` registered in `src/app/event/fiberEventResolver.ts`, resolving against the scope's `IEventBus`. + +## Ledger and cascade (L0/L2) + +- `src/_base/lifecycle/` — the Ledger (L0): ordered, dual-track (sync / async disposable) effect bookkeeping with strict reverse-order serial teardown and reason passthrough (`'scope-close' | 'cascade' | 'unload'`). Scopes, containers, and units all anchor side effects here; `Disposable` / `DisposableStore` (`_base/di/lifecycle.ts`) delegate to it. +- `cascadeEngine.ts` — one engine per scope container with tree-wide orchestration (L2): `provide` / `unprovide` / `update` run as transactions (contagion set from the persistent dependency graph — instance edges may point child → parent across scopes → abort hook → global reverse-topo teardown → apply → waiting-area recheck to a fixpoint → history ring). Units are five-state (`Pending / Activating / Active / Unloading / Failed`): construction failure is sticky `Failed` (no auto-retry; `update()` reloads; resolving a Failed unit rethrows its error); units with unsatisfiable declared dependencies park in the waiting area and auto-activate when the deps arrive, across scopes. An `ondemand` unit counts as available — consumers pull it transitively at materialization. +- Static and dynamic share one provide path: scope creation (`createScopedChildHandle` / `Scope.createApp` / `Scope.createChild`) submits the kind's whole `registerScopedService` batch as ONE cascade transaction via `provideAll` — every token registers before the activation wave, so registration order never matters (untracked transitive `createInstance` resolutions succeed inside the batch); a seed occupying a token overrides the static registration. `activateScopeServices` is gone — eager activation failure is a sticky `Failed` unit, not a scope-creation error. ## Examples @@ -70,6 +91,7 @@ Per-domain references live in `docs/`. - [`docs/flag.md`](docs/flag.md) — Read **before gating behavior behind a feature flag**: declaring a flag in its owning domain and registering it at import time via `registerFlagDefinition`, checking `IFlagService.enabled(id)`, wiring the `[experimental]` config section, or deciding whether a flag is App-scope vs. per-session. - [`docs/errors.md`](docs/errors.md) — Read **before raising errors from a domain**: defining a co-located `XxxError`, registering a code in `ErrorCodes`/`ERROR_INFO`, translating external errors (provider/HTTP, fs, MCP) at the boundary, or (de)serializing errors across RPC/SDK with `toErrorPayload`/`fromErrorPayload`. - [`docs/di-testing.md`](docs/di-testing.md) — Read **before writing or touching any DI/Scope test**: picking the right harness (`InstantiationService` vs `TestInstantiationService` vs `createScopedTestHost`), declaring deps with `@IService`, stubbing collaborators, and teardown via `DisposableStore`. +- [`docs/features.md`](docs/features.md) — Read **before adding or extracting a built-in feature** (`src/features//`): the `Feature` base class, the `contribute*` seams, the static-vs-feature channel rules, and the assembly/retraction lifecycle. - [`docs/config-manifest.toml`](docs/config-manifest.toml) — Generated list of every registered config section, in the on-disk `config.toml` shape (owner, scope, defaults, env bindings, schema fields). Do not edit by hand; regenerate with `pnpm gen:config-manifest` after adding or removing a `registerConfigSection` call — `test/app/config/configManifest.test.ts` enforces freshness. - [`docs/wire-manifest.d.ts`](docs/wire-manifest.d.ts) — Generated declaration file listing every registered wire record type as a payload interface (model, persist policy, `toEvent`, cross-reducers in the doc comment; payload fields in real TS type syntax), plus a `WirePayloadMap`. Do not edit by hand; regenerate with `pnpm gen:wire-manifest` after adding or removing a `defineOp` call — `test/wire/wireManifest.test.ts` enforces freshness and checks the file parses. - [`docs/state-manifest.d.ts`](docs/state-manifest.d.ts) — Generated declaration file listing every state key registered into `IAppStateService` / `IWorkspaceStateService` / `ISessionStateService` / `IAgentStateService`, as `AppStateSnapshot` / `WorkspaceStateSnapshot` / `SessionStateSnapshot` / `AgentStateSnapshot` interfaces (keys grouped by defining file), plus the `AppStateKey` / `WorkspaceStateKey` / `SessionStateKey` / `AgentStateKey` unions. Self-contained: every value type is expanded fully inline with each named type marked by a `/* TypeName — source/file.ts */` comment (recursion stops with a `recursive` marker) — no imports, no helper declarations. Do not edit by hand; regenerate with `pnpm gen:state-manifest` after adding or removing a `states.register(...)` call — `test/state/stateManifest.test.ts` enforces freshness and checks the file parses. diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index f7ea27b4ddf..a6387283992 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -13,7 +13,7 @@ # builtinProductSkills src/app/skillCatalog/configSection.ts # cron src/app/cron/configSection.ts # defaultPermissionMode src/agent/permissionMode/configSection.ts -# defaultPlanMode src/agent/plan/configSection.ts +# defaultPlanMode src/features/plan/configSection.ts # experimental src/app/flag/flag.ts # extraAgentDirs src/workspace/workspaceAgentProfileLoader/configSection.ts # extraSkillDirs src/app/skillCatalog/configSection.ts @@ -102,7 +102,7 @@ manual_tick = false # ########################################################################## # defaultPlanMode (config.toml: default_plan_mode) -# owner: src/agent/plan/configSection.ts +# owner: src/features/plan/configSection.ts # scope: core # ########################################################################## diff --git a/packages/agent-core-v2/docs/di-testing.md b/packages/agent-core-v2/docs/di-testing.md index 023e8c219ae..4b304b9b2cd 100644 --- a/packages/agent-core-v2/docs/di-testing.md +++ b/packages/agent-core-v2/docs/di-testing.md @@ -123,8 +123,8 @@ Reference: ```ts import { beforeEach, describe, expect, it } from 'vitest'; +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, _clearScopedRegistryForTests, registerScopedService, ScopeActivation, diff --git a/packages/agent-core-v2/docs/di.md b/packages/agent-core-v2/docs/di.md index 15be4b8474a..dad69a2591c 100644 --- a/packages/agent-core-v2/docs/di.md +++ b/packages/agent-core-v2/docs/di.md @@ -47,7 +47,8 @@ export const IGreeter: ServiceIdentifier = createDecorator(' ```ts // greet/greetService.ts -import { LifecycleScope, registerScopedService, ScopeActivation } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { registerScopedService, ScopeActivation } from '#/_base/di/scope'; import { IGreeter } from './greet'; export class Greeter implements IGreeter { @@ -144,15 +145,16 @@ const meta = accessor.get(ISessionMetadata); // 类型是 ISessionMetadata ### 3.1 四层,按寿命从长到短 ```ts +// src/app/scopes.ts(业务层声明;内核只认识字符串 kind 与拓扑序) export enum LifecycleScope { - App = 0, // 进程级,全局一份 - Workspace = 1, // 一个工作区 handler(与 Session 一对多) - Session = 2, // 一次会话 - Agent = 3, // 一个 agent + App = 'app', // 进程级,全局一份 + Workspace = 'workspace', // 一个工作区 handler(与 Session 一对多) + Session = 'session', // 一次会话 + Agent = 'agent', // 一个 agent } ``` -数值越大,寿命越短、越靠叶子。注册时把 `scope` 换成对应层即可: +拓扑里越靠后,寿命越短、越靠叶子。注册时把 `scope` 换成对应层即可: ```ts registerScopedService( diff --git a/packages/agent-core-v2/docs/features.md b/packages/agent-core-v2/docs/features.md new file mode 100644 index 00000000000..43c330885de --- /dev/null +++ b/packages/agent-core-v2/docs/features.md @@ -0,0 +1,106 @@ +# Features — self-contained built-in capabilities + +A **Feature** is a built-in capability (plan mode, and later mcp, …) authored as ONE +self-contained unit under `src/features//`. The Feature unit is the single place +that declares everything the capability contributes to the engine; retracting the unit +withdraws all of it across the scope tree (连坐). + +`plan` is the reference implementation: `src/features/plan/` (extracted from +`agent/plan` + `agent/tools/plan`). + +## The base class + +```ts +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +export class PlanFeature extends Feature { + static override readonly name = 'plan'; // stable unit name (the assembly keys by it) + + constructor() { + super(); + this.contributeAgentService(IAgentPlanService, AgentPlanService); + this.contributeTool(IEnterPlanModeTool, EnterPlanModeTool, { name: 'EnterPlanMode', domain: 'plan' }); + this.contributeTool(IExitPlanModeTool, ExitPlanModeTool, { name: 'ExitPlanMode', domain: 'plan' }); + this.onDispose(() => { /* cleanup */ }); + } +} + +registerFeature(PlanFeature); // import = register +``` + +`Feature extends Service`, so every contribution runs through the normal two-phase +construction protocol (declare contributions in the constructor; they are buffered and +flushed by the kernel). The helpers are thin compositions over the existing seams: + +| Helper | Composition | Semantics | +|---|---|---| +| `contribute(token, value)` | `this.provide(token, value)` | raw collection record | +| `contributeService(scope, id, ctor, opts?)` | `ScopeUnits(scope)` function recipe | one live unit per present AND future scope of that kind; retracted everywhere when the feature dies | +| `contributeAgentService(id, ctor, opts?)` | `contributeService(LifecycleScope.Agent, …)` | the common case | +| `contributeTool(id, ctor, options)` | per-agent `OnDemand` registration + `AgentToolContribution` record | the tool ctor keeps full `@IXxx` DI; the activation fold filters by name before constructing | +| `contributeProfiles(profiles, opts?)` | `AgentProfileContribution` record | `sourceId` defaults to `feature:` | +| `contributeConfig(domain, schema, options?)` | `ConfigSectionContribution` record | see the static-channel rule below before using | +| `contributeCommand({ name, description?, run })` | `CommandContribution` record | runs engine-side; `ctx.get(id)` resolves through the agent container and is valid only during the synchronous part of `run` (resolve up front, then `await`) | +| `onDispose(fn)` | `this._register(toDisposable(fn))` | cleanup on retraction | + +## Assembly lifecycle + +1. A feature module calls `registerFeature(Recipe)` at its top level + (`src/features/featureRegistry.ts` holds the module table). +2. `src/index.ts` imports the feature leaf (`import '#/features/plan/planFeature';`), + so importing the package registers it. +3. At App-scope creation the `IFeatureAssemblyService` + (`src/features/featureAssemblyService.ts`) drains the table and assembles each + recipe through `IFeatureManager.provideUnit` — the same provide path as static + scope batches. Every feature is named, introspectable (`IFeatureManager.units()`, + visible in the kimi-inspect DI view), and individually retractable + (`unprovideUnit(name)` / `updateUnit(name, config)`). +4. Per-scope materialization goes through the kernel's `ScopeUnits` fold: a service a + feature contributes at Agent scope appears in every existing and future Agent scope, + bound by the same cascade rules as a static registration. + +## Static channels vs Feature channels (the rule for built-in features) + +Some contribution kinds must stay on the **static import=register channels** even when +they belong to a feature: + +- **Config sections** (`registerConfigSection`) — the config manifest generator + (`scripts/gen-config-manifest.mts`) drains the module-level table and statically scans + for call sites; a runtime-only contribution would vanish from + `docs/config-manifest.toml`. +- **Agent profiles** contributed via `registerAgentProfile` — same static-table + reasoning. +- **Wire vocabulary** (`defineOp` / `defineModel` / `defineCheckpointedModel`) — wire + records must remain replayable even if the feature unit is retracted. + +The Feature unit carries the **runtime capabilities**: services, tools, commands, hook +subscriptions. `PlanFeature` is the example: `configSection.ts` and `profile/plan.ts` +keep their static registrations; the service and the two tools go through the Feature. + +## Events and hooks inside a feature + +- Agent-scope services a feature contributes can use the string form of the unit `on` + capability — `this.on('turn.ended', …)` — backed by the production `FiberEventResolver` + (`src/app/event/fiberEventResolver.ts`), which resolves the event against the scope's + `IEventBus` (attaching lazily if the bus is not materialized yet). Constructor + injection of `@IEventBus` + `subscribe` remains the fully explicit equivalent. +- Tool-call guards (e.g. the plan-mode write veto) subscribe to + `IAgentToolExecutorService.onBeforeExecuteTool` inside the contributed Agent-scope + service — see `src/features/plan/planService.ts` for the canonical veto-listener + pattern. + +## Adding a new feature + +1. `src/features//` — domain files follow the usual conventions (header comments, + one service per file pair, `.md?raw` assets move with the feature). +2. `Feature.ts` — the Feature subclass + `registerFeature(...)`. +3. `src/index.ts` — precise leaf imports/exports; no barrel. +4. Tests in `test/features//`; for the assembly mechanics mirror + `test/features/feature.test.ts` (scoped host, `registerFeature` before + `createScopedTestHost`). +5. If the feature registers agent-state keys, `scripts/gen-state-manifest.mts` resolves + the scope of `.register(key)` call sites under `src/features/**` from the receiver's + `I{App,Workspace,Session,Agent}StateService` type — register through a member typed + as the scope's state service. Regenerate the manifests + (`pnpm gen:config-manifest && pnpm gen:wire-manifest && pnpm gen:state-manifest`). diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 01994365bce..0dcbe846654 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -94,7 +94,7 @@ // media.registeredKey src/agent/media/mediaToolsRegistrar.ts // media.resolved src/agent/media/videoResolverService.ts // permissionMode.lastMode src/agent/permissionMode/injection/permissionModeInjection.ts -// plan.wasActive src/agent/plan/injection/planModeInjection.ts +// plan.wasActive src/features/plan/injection/planModeInjection.ts // profile.activeToolNamesOverlay src/agent/profile/profileService.ts // profile.agentsMdWarning src/agent/profile/profileService.ts // profile.emittedPluginBudgetWarnings src/agent/profile/profileService.ts @@ -1124,8 +1124,6 @@ export interface AgentStateSnapshot { }>; // src/agent/permissionMode/injection/permissionModeInjection.ts 'permissionMode.lastMode': 'manual' | 'yolo' | 'auto' | undefined; - // src/agent/plan/injection/planModeInjection.ts - 'plan.wasActive': boolean; // src/agent/profile/profileService.ts 'profile.activeToolNamesOverlay': readonly string[] | undefined; 'profile.agentsMdWarning': string | undefined; @@ -1210,6 +1208,8 @@ export interface AgentStateSnapshot { inputCacheCreation: number; } | undefined; 'usage.currentTurnId': number | undefined; + // src/features/plan/injection/planModeInjection.ts + 'plan.wasActive': boolean; } export type AgentStateKey = keyof AgentStateSnapshot; diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 5aac50f61e9..aaf5e4a414f 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -47,10 +47,10 @@ // permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts // permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts // permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts -// plan_mode.cancel plan persisted src/agent/plan/planOps.ts -// plan_mode.enter plan persisted src/agent/plan/planOps.ts -// plan_mode.exit plan persisted src/agent/plan/planOps.ts -// plan.revision plan persisted src/agent/plan/planOps.ts +// plan_mode.cancel plan persisted src/features/plan/planOps.ts +// plan_mode.enter plan persisted src/features/plan/planOps.ts +// plan_mode.exit plan persisted src/features/plan/planOps.ts +// plan.revision plan persisted src/features/plan/planOps.ts // profile.bind profile persisted src/agent/profile/profileOps.ts // skill.activate skill transient src/agent/skill/skillOps.ts // swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts @@ -410,7 +410,7 @@ interface PermissionSetModePayload { /** * model: plan · persisted · toEvent - * owner: src/agent/plan/planOps.ts + * owner: src/features/plan/planOps.ts */ interface PlanModeCancelPayload { _name: 'plan_mode.cancel'; @@ -419,7 +419,7 @@ interface PlanModeCancelPayload { /** * model: plan · persisted · toEvent - * owner: src/agent/plan/planOps.ts + * owner: src/features/plan/planOps.ts */ interface PlanModeEnterPayload { _name: 'plan_mode.enter'; @@ -428,7 +428,7 @@ interface PlanModeEnterPayload { /** * model: plan · persisted · toEvent - * owner: src/agent/plan/planOps.ts + * owner: src/features/plan/planOps.ts */ interface PlanModeExitPayload { _name: 'plan_mode.exit'; @@ -437,7 +437,7 @@ interface PlanModeExitPayload { /** * model: plan · persisted · toEvent - * owner: src/agent/plan/planOps.ts + * owner: src/features/plan/planOps.ts */ interface PlanRevisionPayload { _name: 'plan.revision'; diff --git a/packages/agent-core-v2/package.json b/packages/agent-core-v2/package.json index 03fcea8bfe8..ea85c28ee2f 100644 --- a/packages/agent-core-v2/package.json +++ b/packages/agent-core-v2/package.json @@ -52,10 +52,7 @@ "gen:wire-manifest": "tsx --import ../../build/register-raw-text-loader.mjs scripts/gen-wire-manifest.mts", "gen:state-manifest": "tsx scripts/gen-state-manifest.mts", "lint:imports": "node scripts/check-import-boundaries.mjs", - "clean": "rm -rf dist", - "dep-graph:analyze": "tsx scripts/dep-graph/cli.ts", - "dep-graph:dev": "vite --config scripts/dep-graph/vite.config.ts", - "dep-graph:lint": "tsx scripts/dep-graph/lint.ts" + "clean": "rm -rf dist" }, "dependencies": { "@antfu/utils": "^9.3.0", @@ -90,23 +87,15 @@ "zod": "^4.3.6" }, "devDependencies": { - "@dagrejs/dagre": "^1.1.4", "@types/js-yaml": "^4.0.9", "@types/picomatch": "^4.0.3", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", "@types/retry": "0.12.0", "@types/sinon": "^21.0.1", "@types/tar": "^7.0.87", "@types/yauzl": "^2.10.3", "@types/yazl": "^2.4.6", - "@vitejs/plugin-react": "^4.4.1", - "@xyflow/react": "^12.4.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", "sinon": "^22.0.0", "ts-morph": "^28.0.0", - "tsx": "^4.21.0", - "vite": "^6.3.3" + "tsx": "^4.21.0" } } diff --git a/packages/agent-core-v2/scripts/check-import-boundaries.mjs b/packages/agent-core-v2/scripts/check-import-boundaries.mjs index 903afa1fe9f..5239f29dee9 100644 --- a/packages/agent-core-v2/scripts/check-import-boundaries.mjs +++ b/packages/agent-core-v2/scripts/check-import-boundaries.mjs @@ -16,6 +16,8 @@ * - purity: `contract` imports no other domain (only `_base` helpers) * and no external package at all (no SDKs, not even types); * `protocol` imports only `_base` + `contract` and no wire SDK. + * All pure layers may additionally import the DI vocabulary modules + * in `KOSONG_ALLOWED_VOCABULARY` (`app/scopes`). * - `provider/bases/` sub-boundary: base implementation files must not * import the registries (`protocolBase`, `protocolAdapterRegistry`), * `providerDefinition`, or any `*.contrib.ts` module. The @@ -74,13 +76,24 @@ const KOSONG_LAYER = new Map([ /** * Kosong is a pure provider/model abstraction layer: NO kosong subdomain may * import another v2 domain outside kosong itself — only `_base` utilities - * are allowed. (`protocol` additionally sees `kosong/contract`, handled by - * the internal-layer rule above.) Config persistence, OAuth tokens, events, + * are allowed, plus the DI vocabulary modules in + * `KOSONG_ALLOWED_VOCABULARY` (`app/scopes`: the `LifecycleScope` tier names + * every self-registering Service needs). (`protocol` additionally sees + * `kosong/contract`, handled by the internal-layer rule above.) Config + * persistence, OAuth tokens, events, * and discovery orchestration all live in the upper `app/kosongConfig` * wrapper — kosong must never reach up to them. */ const KOSONG_BASE_ONLY_SUBDOMAINS = new Set(['contract', 'protocol', 'provider', 'model']); +/** + * Non-`_base` modules the pure kosong layers may still import, keyed by + * extensionless `src/`-relative path. `app/scopes` is DI vocabulary (the + * scope tier names + topology declaration), not app orchestration, so a + * kosong Service may read its registration tier from it. + */ +const KOSONG_ALLOWED_VOCABULARY = new Set(['app/scopes']); + /** * Wire SDK packages the pure kosong layers must never import — not even * types. `contract` in fact imports no external package at all; this list @@ -292,12 +305,15 @@ export function checkSource(source, absFile) { } // Rule 2c: outside the kosong subtree, kosong code may only depend on - // `_base` utilities (`protocol` additionally sees `kosong/contract`, + // `_base` utilities plus the DI vocabulary in KOSONG_ALLOWED_VOCABULARY + // (`protocol` additionally sees `kosong/contract`, // handled by Rule 2b above). This is what keeps kosong a pure // abstraction layer with no upward dependencies. if (KOSONG_BASE_ONLY_SUBDOMAINS.has(sourceKosong.sub)) { const targetDomain = targetDomainOf(targetAbs); - if (targetDomain !== '_base') { + const targetRel = relative(SRC_ROOT, targetAbs).split(/[\\/]/).join('/'); + const targetStripped = targetRel.endsWith('.ts') ? targetRel.slice(0, -'.ts'.length) : targetRel; + if (targetDomain !== '_base' && !KOSONG_ALLOWED_VOCABULARY.has(targetStripped)) { violations.push({ file: absFile, line, diff --git a/packages/agent-core-v2/scripts/dep-graph.mjs b/packages/agent-core-v2/scripts/dep-graph.mjs deleted file mode 100644 index 1c34a21b2e6..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph.mjs +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env node -/** - * Dump the agent-core-v2 Service dependency graph. - * - * Walks every `src//*Service.ts` impl file, and for each registered - * service extracts: - * - its `LifecycleScope` (from the `registerScopedService(...)` call), - * - its constructor DI dependencies (the `@IToken` parameter decorators). - * - * Output is grouped by domain so the whole graph can be reviewed in one pass. - * - * Run: `node scripts/dep-graph.mjs`. - */ - -import { readFileSync, readdirSync, statSync } from 'node:fs'; -import { dirname, join, relative } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const SRC_ROOT = join(__dirname, '..', 'src'); - -const SCOPE_DIRS = new Set(['app', 'session', 'agent']); - -/** Resolve a `src/`-relative file path to its domain, skipping the scope tier. */ -function domainOf(rel) { - const segments = rel.split(/[\\/]/); - return SCOPE_DIRS.has(segments[0]) ? segments[1] : segments[0]; -} - -function walk(dir) { - const out = []; - for (const entry of readdirSync(dir)) { - const abs = join(dir, entry); - const st = statSync(abs); - if (st.isDirectory()) out.push(...walk(abs)); - else if (entry.endsWith('.ts') && entry !== 'index.ts') out.push(abs); - } - return out; -} - -/** - * Extract services from one impl file. - * @returns {Array<{impl:string, token:string, scope:string, deps:string[]}>} - */ -function extract(source) { - const services = []; - - // Map impl class -> ctor deps (via @IToken decorators in the constructor). - const classRe = /export\s+class\s+(\w+)\s*(?:extends\s+\w+\s*)?(?:implements\s+[\w,\s]+)?\s*\{/g; - let cls; - const classDeps = new Map(); - while ((cls = classRe.exec(source)) !== null) { - const impl = cls[1]; - const start = cls.index; - // Find the constructor belonging to this class (before the next top-level class). - const nextClass = classRe.exec(source); - classRe.lastIndex = cls.index + 1; // allow re-match - const slice = source.slice(start, nextClass ? nextClass.index : source.length); - if (nextClass) classRe.lastIndex = nextClass.index; - const ctorMatch = /constructor\s*\(([^)]*)\)/.exec(slice); - const deps = []; - if (ctorMatch) { - const decRe = /@(I[A-Za-z]\w*)\s+(?:(?:private|protected|public|readonly)\s+)*_?\w+\s*:/g; - let d; - while ((d = decRe.exec(ctorMatch[1])) !== null) deps.push(d[1]); - } - classDeps.set(impl, deps); - } - - // Pair each registerScopedService call with scope + token + impl. - const regRe = - /registerScopedService\(\s*LifecycleScope\.(\w+)\s*,\s*(I[A-Za-z]\w*)\s*,\s*(\w+)\s*,/g; - let r; - while ((r = regRe.exec(source)) !== null) { - const [, scope, token, impl] = r; - services.push({ - impl, - token, - scope, - deps: classDeps.get(impl) ?? [], - }); - } - return services; -} - -function main() { - const files = walk(SRC_ROOT); - /** @type {Map>} */ - const byDomain = new Map(); - for (const f of files) { - const domain = domainOf(relative(SRC_ROOT, f)); - const services = extract(readFileSync(f, 'utf8')); - if (!byDomain.has(domain)) byDomain.set(domain, []); - byDomain.get(domain).push(...services); - } - - const domains = [...byDomain.keys()].sort(); - let total = 0; - for (const domain of domains) { - const services = byDomain.get(domain).sort((a, b) => a.token.localeCompare(b.token)); - console.log(`\n## ${domain}`); - for (const s of services) { - total++; - const deps = s.deps.length > 0 ? s.deps.join(', ') : '—'; - console.log(`- ${s.token} [${s.scope}] → ${deps}`); - } - } - console.log(`\n${total} services across ${domains.length} domains.`); - return 0; -} - -process.exit(main()); diff --git a/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts b/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts deleted file mode 100644 index 2d35c79edbf..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts +++ /dev/null @@ -1,836 +0,0 @@ -/** - * Static analyzer for the `agent-core-v2` service graph. - * - * Discovers services registered via `registerScopedService(...)` and, for each - * impl class, records four kinds of edges to other services: - * - * - `ctor` — constructor DI (`@IToken` param decorators) - * - `accessor` — runtime lookups (`.get(IToken)`) - * - `publish`/`subscribe` — `IEventService` usage from a class field - * - `signal`/`append`/`on` — `IAgentRecordService` usage from a class field - * - * Deliberately parse-only (no type checker) so the whole tree runs in ~1s. - * We rely on the codebase convention that constructor DI params carry an - * explicit type annotation matching the injected token — that's how we know - * which field holds an event bus without asking the type checker. - */ - -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { - type CallExpression, - type ClassDeclaration, - type InterfaceDeclaration, - type Node, - type ParameterDeclaration, - Project, - type SourceFile, - SyntaxKind, -} from 'ts-morph'; - -import type { Edge, EdgeKind, EdgeRef, Graph, ServiceNode, ServiceScope } from './types'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export const PKG_ROOT = resolve(__dirname, '..', '..', '..'); -export const REPO_ROOT = resolve(PKG_ROOT, '..', '..'); -export const SRC_ROOT = join(PKG_ROOT, 'src'); -export const SNAPSHOT_PATH = join(PKG_ROOT, '.local', 'dep-graph.json'); - -const EVENT_BUS_TOKENS = new Set(['IEventService', 'IAgentRecordService']); - -const EVENT_METHOD_KIND: Record = { - publish: 'publish', - subscribe: 'subscribe', - append: 'emit', - signal: 'emit', - on: 'on', -}; - -const SCOPE_ORDER: ServiceScope[] = ['App', 'Session', 'Agent']; -const SCOPE_LEVEL: Record = { App: 0, Session: 1, Agent: 2 }; - -const FRAMEWORK_BINDINGS: readonly { token: string; scope: ServiceScope; impl: string }[] = [ - { token: 'IInstantiationService', scope: 'App', impl: 'InstantiationService' }, - { token: 'IKaos', scope: 'App', impl: 'Kaos' }, - { token: 'ILogOptions', scope: 'App', impl: 'LogOptions' }, - { token: 'IBootstrapOptions', scope: 'App', impl: 'BootstrapOptions' }, - { token: 'ISessionContext', scope: 'Session', impl: 'SessionContext' }, - { token: 'IAgentScopeContext', scope: 'Agent', impl: 'AgentScopeContext' }, -]; - -const PRODUCTION_OVERRIDES: readonly { token: string; scope: ServiceScope; impl: string }[] = [ - { token: 'IFileSystemStorageService', scope: 'App', impl: 'FileStorageService' }, - { token: 'ISkillDiscovery', scope: 'App', impl: 'FileSkillDiscovery' }, -]; - -export function nodeId(scope: ServiceScope, token: string): string { - return `${scope}::${token}`; -} - -type Bindings = Map>; - -function resolveFromScope( - bindings: Bindings, - token: string, - sourceScope: ServiceScope, -): ServiceNode | undefined { - const scopeMap = bindings.get(token); - if (!scopeMap) return undefined; - const sourceLevel = SCOPE_LEVEL[sourceScope]; - for (let lvl = sourceLevel; lvl >= 0; lvl--) { - const s = SCOPE_ORDER[lvl]; - const hit = scopeMap.get(s); - if (hit) return hit; - } - return undefined; -} - -interface EdgeAccumulator { - services: ServiceNode[]; - edges: Map; - bindings: Bindings; - unknownRefs: Set; -} - -function relFromRepo(absPath: string): string { - return relative(REPO_ROOT, absPath).replaceAll('\\', '/'); -} - -function edgeKey(fromId: string, toId: string, kind: EdgeKind): string { - return `${fromId}|${toId}|${kind}`; -} - -function pushEdge( - acc: EdgeAccumulator, - fromId: string, - source: ServiceNode, - token: string, - kind: EdgeKind, - ref: EdgeRef, - overrideScope?: ServiceScope, -): void { - const target = resolveFromScope(acc.bindings, token, overrideScope ?? source.scope); - - let toId: string; - let extra: Pick; - if (target) { - toId = target.id; - extra = {}; - } else { - const scopeMap = acc.bindings.get(token); - const actualScope = scopeMap ? innermostScope(scopeMap) : undefined; - if (actualScope !== undefined) { - toId = `scopeMismatch::${token}`; - extra = { scopeMismatch: true as const, actualScope }; - } else { - toId = `unresolved::${token}`; - extra = { unresolved: true as const }; - } - } - - const key = edgeKey(fromId, toId, kind); - const existing = acc.edges.get(key); - if (existing) { - if (!existing.refs.some((r) => sameRef(r, ref))) { - existing.refs.push(ref); - } - return; - } - const edge: Edge = { - from: fromId, - to: toId, - token, - kind, - refs: [ref], - ...extra, - }; - acc.edges.set(key, edge); - if (extra.unresolved) acc.unknownRefs.add(token); -} - -function innermostScope(scopeMap: Map): ServiceScope | undefined { - let best: ServiceScope | undefined; - let bestLevel = -1; - for (const s of scopeMap.keys()) { - const lvl = SCOPE_LEVEL[s]; - if (lvl > bestLevel) { - bestLevel = lvl; - best = s; - } - } - return best; -} - -function sameRef(a: EdgeRef, b: EdgeRef): boolean { - return ( - a.file === b.file && - a.line === b.line && - (a.fromMethod ?? '') === (b.fromMethod ?? '') && - (a.toMethod ?? '') === (b.toMethod ?? '') - ); -} - -function collectInterfaces(sourceFiles: SourceFile[]): Map { - const out = new Map(); - for (const file of sourceFiles) { - for (const iface of file.getInterfaces()) { - const name = iface.getName(); - if (!name) continue; - out.set(name, iface); - } - } - return out; -} - -function collectInterfaceMembers(iface: InterfaceDeclaration): string[] { - const names = new Set(); - for (const member of iface.getMembers()) { - const kind = member.getKind(); - if (kind === SyntaxKind.MethodSignature) { - const name = member.asKindOrThrow(SyntaxKind.MethodSignature).getName(); - names.add(name); - } else if (kind === SyntaxKind.PropertySignature) { - const name = member.asKindOrThrow(SyntaxKind.PropertySignature).getName(); - if (name === '_serviceBrand') continue; - names.add(name); - } - } - return [...names].sort(); -} - -function readRegistration( - call: CallExpression, -): { token: string; impl: string; scope: ServiceScope; domain: string; line: number } | undefined { - const args = call.getArguments(); - if (args.length < 3) return undefined; - - const scopeArg = args[0]; - const tokenArg = args[1]; - const implArg = args[2]; - const domainArg = args[4]; - - if (scopeArg.getKind() !== SyntaxKind.PropertyAccessExpression) return undefined; - const scopeText = scopeArg.getText(); - const scope = scopeText.split('.').at(-1); - if (scope !== 'App' && scope !== 'Session' && scope !== 'Agent') return undefined; - - if (tokenArg.getKind() !== SyntaxKind.Identifier) return undefined; - if (implArg.getKind() !== SyntaxKind.Identifier) return undefined; - - let domain = 'unknown'; - if (domainArg?.getKind() === SyntaxKind.StringLiteral) { - domain = domainArg.getText().slice(1, -1); - } - - return { - token: tokenArg.getText(), - impl: implArg.getText(), - scope, - domain, - line: call.getStartLineNumber(), - }; -} - -function domainOf(absPath: string): string { - const rel = relative(SRC_ROOT, absPath).replaceAll('\\', '/'); - return rel.split('/')[0] ?? 'unknown'; -} - -function collectServices(sourceFiles: SourceFile[]): { - services: ServiceNode[]; - implClasses: Map; - bindings: Bindings; -} { - const services: ServiceNode[] = []; - const implClasses = new Map(); - const bindings: Bindings = new Map(); - - for (const file of sourceFiles) { - for (const cls of file.getClasses()) { - const name = cls.getName(); - if (name) implClasses.set(name, cls); - } - } - - for (const file of sourceFiles) { - for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) { - const expr = call.getExpression(); - if (expr.getText() !== 'registerScopedService') continue; - const reg = readRegistration(call); - if (!reg) continue; - const domain = reg.domain !== 'unknown' ? reg.domain : domainOf(file.getFilePath()); - const node: ServiceNode = { - id: nodeId(reg.scope, reg.token), - token: reg.token, - impl: reg.impl, - scope: reg.scope, - domain, - file: relFromRepo(file.getFilePath()), - line: reg.line, - }; - services.push(node); - let scopeMap = bindings.get(reg.token); - if (!scopeMap) { - scopeMap = new Map(); - bindings.set(reg.token, scopeMap); - } - if (!scopeMap.has(reg.scope)) scopeMap.set(reg.scope, node); - } - } - - return { services, implClasses, bindings }; -} - -function readCtor(cls: ClassDeclaration): { - ctorDeps: { token: string; line: number }[]; - injectedFields: Map; -} { - const ctorDeps: { token: string; line: number }[] = []; - const injectedFields = new Map(); - - const ctors = cls.getConstructors(); - if (ctors.length === 0) return { ctorDeps, injectedFields }; - const ctor = ctors[0]; - - for (const param of ctor.getParameters()) { - const decorators = param.getDecorators(); - let paramToken: string | undefined; - for (const dec of decorators) { - const decName = dec.getName(); - if (!decName.startsWith('I')) continue; - ctorDeps.push({ token: decName, line: dec.getStartLineNumber() }); - paramToken = decName; - } - if (paramToken === undefined) continue; - const fieldName = fieldNameOf(param); - if (fieldName) injectedFields.set(fieldName, paramToken); - } - - return { ctorDeps, injectedFields }; -} - -function fieldNameOf(param: ParameterDeclaration): string | undefined { - const modifiers = param.getModifiers().map((m) => m.getText()); - if (modifiers.some((m) => m === 'private' || m === 'protected' || m === 'public')) { - return param.getName(); - } - return undefined; -} - -function enclosingMethodName(node: Node): string | undefined { - let cur: Node | undefined = node.getParent(); - while (cur) { - const kind = cur.getKind(); - if (kind === SyntaxKind.MethodDeclaration) { - const m = cur.asKindOrThrow(SyntaxKind.MethodDeclaration); - return m.getName(); - } - if (kind === SyntaxKind.Constructor) return ''; - if (kind === SyntaxKind.GetAccessor) { - const g = cur.asKindOrThrow(SyntaxKind.GetAccessor); - return `get ${g.getName()}`; - } - if (kind === SyntaxKind.SetAccessor) { - const s = cur.asKindOrThrow(SyntaxKind.SetAccessor); - return `set ${s.getName()}`; - } - if (kind === SyntaxKind.PropertyDeclaration) { - const p = cur.asKindOrThrow(SyntaxKind.PropertyDeclaration); - return ``; - } - if (kind === SyntaxKind.ClassDeclaration) return undefined; - cur = cur.getParent(); - } - return undefined; -} - -function chainedMethodName(getCall: CallExpression): string | undefined { - const parent = getCall.getParent(); - if (!parent || parent.getKind() !== SyntaxKind.PropertyAccessExpression) return undefined; - const pae = parent.asKindOrThrow(SyntaxKind.PropertyAccessExpression); - if (pae.getExpression() !== getCall) return undefined; - const grandparent = pae.getParent(); - if (!grandparent || grandparent.getKind() !== SyntaxKind.CallExpression) return undefined; - const outer = grandparent.asKindOrThrow(SyntaxKind.CallExpression); - if (outer.getExpression() !== pae) return undefined; - return pae.getName(); -} - -const HANDLE_ALIAS_SCOPE: Record = { - IAppScopeHandle: 'App', - ISessionScopeHandle: 'Session', - IAgentScopeHandle: 'Agent', -}; - -const FUNCTION_LIKE_KINDS = new Set([ - SyntaxKind.MethodDeclaration, - SyntaxKind.FunctionDeclaration, - SyntaxKind.ArrowFunction, - SyntaxKind.FunctionExpression, - SyntaxKind.Constructor, - SyntaxKind.GetAccessor, - SyntaxKind.SetAccessor, -]); - -function stripTypeWrappers(text: string): string { - let t = text.trim(); - t = t.replace(/\s*\|\s*(undefined|null)\s*/g, '').trim(); - const promise = /^Promise\s*<\s*(.+?)\s*>$/.exec(t); - if (promise) t = promise[1].trim(); - t = t.replace(/\[\]\s*$/, '').trim(); - t = t.replace(/^readonly\s+/, '').trim(); - return t; -} - -function handleScopeFromTypeText(text: string | undefined): ServiceScope | undefined { - if (text === undefined) return undefined; - const t = stripTypeWrappers(text); - const alias = HANDLE_ALIAS_SCOPE[t]; - if (alias !== undefined) return alias; - const generic = /^IScopeHandle\s*<\s*LifecycleScope\.(App|Session|Agent)\s*>$/.exec(t); - if (generic) return generic[1] as ServiceScope; - return undefined; -} - -function enclosingFunction(node: Node): Node | undefined { - let cur: Node | undefined = node.getParent(); - while (cur) { - if (FUNCTION_LIKE_KINDS.has(cur.getKind())) return cur; - cur = cur.getParent(); - } - return undefined; -} - -function getParams(fn: Node): ParameterDeclaration[] { - return (fn as unknown as { getParameters(): ParameterDeclaration[] }).getParameters(); -} - -function isAccessorReceiver(node: Node): boolean { - if (node.getKind() !== SyntaxKind.PropertyAccessExpression) return false; - return node.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName() === 'accessor'; -} - -function collectInterfaceMethodReturns( - interfacesByName: Map, -): Map> { - const out = new Map>(); - for (const [name, iface] of interfacesByName) { - const methods = new Map(); - for (const member of iface.getMembers()) { - if (member.getKind() === SyntaxKind.MethodSignature) { - const m = member.asKindOrThrow(SyntaxKind.MethodSignature); - const rt = m.getReturnTypeNode()?.getText(); - if (rt) methods.set(m.getName(), rt); - } - } - out.set(name, methods); - } - return out; -} - -function inferExprTypeText( - expr: Node, - cls: ClassDeclaration, - ifaceMethods: Map>, - fn: Node, - depth = 0, -): string | undefined { - if (depth > 6) return undefined; - const kind = expr.getKind(); - - if (kind === SyntaxKind.AwaitExpression) { - const inner = (expr as unknown as { getExpression(): Node }).getExpression(); - return inferExprTypeText(inner, cls, ifaceMethods, fn, depth + 1); - } - - if (kind === SyntaxKind.AsExpression || kind === SyntaxKind.NonNullExpression) { - const inner = (expr as unknown as { getExpression(): Node }).getExpression(); - return inferExprTypeText(inner, cls, ifaceMethods, fn, depth + 1); - } - - if (kind === SyntaxKind.CallExpression) { - const call = expr.asKindOrThrow(SyntaxKind.CallExpression); - const callee = call.getExpression(); - if (callee.getKind() !== SyntaxKind.PropertyAccessExpression) return undefined; - const pae = callee.asKindOrThrow(SyntaxKind.PropertyAccessExpression); - const methodName = pae.getName(); - const base = pae.getExpression(); - - if (methodName === 'get' && isAccessorReceiver(base)) { - const first = call.getArguments()[0]; - if (first && first.getKind() === SyntaxKind.Identifier) return first.getText(); - return undefined; - } - - if (base.getKind() === SyntaxKind.ThisKeyword) { - return cls.getMethod(methodName)?.getReturnTypeNode()?.getText(); - } - - const baseType = inferExprTypeText(base, cls, ifaceMethods, fn, depth + 1); - if (baseType === undefined) return undefined; - return ifaceMethods.get(stripTypeWrappers(baseType))?.get(methodName); - } - - if (kind === SyntaxKind.Identifier) { - return resolveIdentifierTypeText(expr, cls, ifaceMethods, fn, depth + 1); - } - - if (kind === SyntaxKind.PropertyAccessExpression) { - const pae = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression); - if (pae.getExpression().getKind() === SyntaxKind.ThisKeyword) { - return thisFieldTypeText(cls, pae.getName()); - } - return undefined; - } - - if (kind === SyntaxKind.BinaryExpression) { - const bin = expr.asKindOrThrow(SyntaxKind.BinaryExpression); - if (bin.getOperatorToken().getKind() === SyntaxKind.QuestionQuestionToken) { - return ( - inferExprTypeText(bin.getLeft(), cls, ifaceMethods, fn, depth + 1) ?? - inferExprTypeText(bin.getRight(), cls, ifaceMethods, fn, depth + 1) - ); - } - return undefined; - } - - if (kind === SyntaxKind.ConditionalExpression) { - const cond = expr.asKindOrThrow(SyntaxKind.ConditionalExpression); - return ( - inferExprTypeText(cond.getWhenTrue(), cls, ifaceMethods, fn, depth + 1) ?? - inferExprTypeText(cond.getWhenFalse(), cls, ifaceMethods, fn, depth + 1) - ); - } - - return undefined; -} - -function thisFieldTypeText(cls: ClassDeclaration, fieldName: string): string | undefined { - const ctor = cls.getConstructors()[0]; - if (ctor) { - for (const p of ctor.getParameters()) { - if (p.getName() !== fieldName) continue; - const t = p.getTypeNode()?.getText(); - if (t) return t; - } - } - return cls.getProperty(fieldName)?.getTypeNode()?.getText(); -} - -function resolveIdentifierTypeText( - id: Node, - cls: ClassDeclaration, - ifaceMethods: Map>, - fn: Node, - depth: number, -): string | undefined { - const name = id.getText(); - - for (const p of getParams(fn)) { - if (p.getName() === name) { - const t = p.getTypeNode()?.getText(); - if (t) return t; - } - } - - const decls = fn.getDescendantsOfKind(SyntaxKind.VariableDeclaration); - for (const decl of decls) { - if (decl.getName() !== name) continue; - if (decl.getStart() > id.getStart()) continue; - const annotated = decl.getTypeNode()?.getText(); - if (annotated) return annotated; - const init = decl.getInitializer(); - if (init) { - const inferred = inferExprTypeText(init, cls, ifaceMethods, fn, depth + 1); - if (inferred) return inferred; - } - } - return undefined; -} - -function inferAccessorScope( - getCall: CallExpression, - cls: ClassDeclaration, - ifaceMethods: Map>, -): ServiceScope | undefined { - const getExpr = getCall.getExpression(); - if (getExpr.getKind() !== SyntaxKind.PropertyAccessExpression) return undefined; - const receiver = getExpr.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getExpression(); - if (!isAccessorReceiver(receiver)) return undefined; - const obj = receiver.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getExpression(); - const fn = enclosingFunction(getCall); - if (fn === undefined) return undefined; - return handleScopeFromTypeText(inferExprTypeText(obj, cls, ifaceMethods, fn)); -} - -function collectRuntimeEdges( - cls: ClassDeclaration, - source: ServiceNode, - injectedFields: Map, - acc: EdgeAccumulator, - ifaceMethods: Map>, -): void { - const filePath = relFromRepo(cls.getSourceFile().getFilePath()); - - for (const call of cls.getDescendantsOfKind(SyntaxKind.CallExpression)) { - const callee = call.getExpression(); - if (callee.getKind() !== SyntaxKind.PropertyAccessExpression) continue; - const pae = callee.asKindOrThrow(SyntaxKind.PropertyAccessExpression); - const methodName = pae.getName(); - const line = call.getStartLineNumber(); - const fromMethod = enclosingMethodName(call); - const baseRef: EdgeRef = { file: filePath, line }; - if (fromMethod !== undefined) baseRef.fromMethod = fromMethod; - - if (methodName === 'get') { - const args = call.getArguments(); - if (args.length === 0) continue; - const first = args[0]; - if (first.getKind() !== SyntaxKind.Identifier) continue; - const tokenName = first.getText(); - if (!tokenName.startsWith('I')) continue; - if (tokenName === source.token) continue; - const toMethod = chainedMethodName(call); - const ref: EdgeRef = { ...baseRef }; - if (toMethod !== undefined) ref.toMethod = toMethod; - const accessorScope = inferAccessorScope(call, cls, ifaceMethods); - pushEdge(acc, source.id, source, tokenName, 'accessor', ref, accessorScope); - continue; - } - - const receiver = pae.getExpression(); - let fieldName: string | undefined; - if (receiver.getKind() === SyntaxKind.PropertyAccessExpression) { - const inner = receiver.asKindOrThrow(SyntaxKind.PropertyAccessExpression); - if (inner.getExpression().getKind() === SyntaxKind.ThisKeyword) { - fieldName = inner.getName(); - } - } else if (receiver.getKind() === SyntaxKind.Identifier) { - fieldName = receiver.getText(); - } - if (fieldName === undefined) continue; - - const fieldToken = injectedFields.get(fieldName); - if (fieldToken === undefined) continue; - if (fieldToken === source.token) continue; - - if (EVENT_BUS_TOKENS.has(fieldToken)) { - const eventKind = EVENT_METHOD_KIND[methodName]; - if (eventKind === undefined) continue; - pushEdge(acc, source.id, source, fieldToken, eventKind, baseRef); - continue; - } - - const ref: EdgeRef = { ...baseRef, toMethod: methodName }; - pushEdge(acc, source.id, source, fieldToken, 'ctor', ref); - } -} - -export function analyze(options: { srcRoot?: string; generatedAt?: string } = {}): Graph { - const srcRoot = options.srcRoot ?? SRC_ROOT; - const project = new Project({ - tsConfigFilePath: undefined, - skipAddingFilesFromTsConfig: true, - skipFileDependencyResolution: true, - skipLoadingLibFiles: true, - compilerOptions: { - allowJs: false, - noResolve: true, - experimentalDecorators: true, - }, - }); - - const globPattern = `${srcRoot.replaceAll('\\', '/')}/**/*.ts`; - project.addSourceFilesAtPaths(globPattern); - - const sourceFiles = project.getSourceFiles(); - - const { services, implClasses, bindings } = collectServices(sourceFiles); - const interfacesByName = collectInterfaces(sourceFiles); - const ifaceMethods = collectInterfaceMethodReturns(interfacesByName); - - const frameworkNodes: ServiceNode[] = FRAMEWORK_BINDINGS.map((b) => ({ - id: nodeId(b.scope, b.token), - token: b.token, - impl: b.impl, - scope: b.scope, - domain: 'framework', - file: 'packages/agent-core-v2/src/_base', - line: 0, - })); - for (const node of frameworkNodes) { - services.push(node); - let scopeMap = bindings.get(node.token); - if (!scopeMap) { - scopeMap = new Map(); - bindings.set(node.token, scopeMap); - } - if (!scopeMap.has(node.scope)) scopeMap.set(node.scope, node); - } - - for (const override of PRODUCTION_OVERRIDES) { - const id = nodeId(override.scope, override.token); - const cls = implClasses.get(override.impl); - const file = cls ? relFromRepo(cls.getSourceFile().getFilePath()) : SRC_ROOT; - const domain = cls ? domainOf(cls.getSourceFile().getFilePath()) : 'unknown'; - const line = cls ? cls.getStartLineNumber() : 0; - const node: ServiceNode = { - id, - token: override.token, - impl: override.impl, - scope: override.scope, - domain, - file, - line, - }; - const existingIndex = services.findIndex((s) => s.id === id); - if (existingIndex >= 0) { - services[existingIndex] = node; - } else { - services.push(node); - } - let scopeMap = bindings.get(override.token); - if (!scopeMap) { - scopeMap = new Map(); - bindings.set(override.token, scopeMap); - } - scopeMap.set(override.scope, node); - } - - const acc: EdgeAccumulator = { - services, - edges: new Map(), - bindings, - unknownRefs: new Set(), - }; - - for (const svc of services) { - const iface = interfacesByName.get(svc.token); - if (!iface) continue; - const members = collectInterfaceMembers(iface); - if (members.length > 0) svc.publicMembers = members; - } - - for (const svc of services) { - const cls = implClasses.get(svc.impl); - if (!cls) continue; - const { ctorDeps, injectedFields } = readCtor(cls); - const filePath = relFromRepo(cls.getSourceFile().getFilePath()); - for (const dep of ctorDeps) { - if (dep.token === svc.token) continue; - pushEdge(acc, svc.id, svc, dep.token, 'ctor', { file: filePath, line: dep.line }); - } - collectRuntimeEdges(cls, svc, injectedFields, acc, ifaceMethods); - } - - const nodeById = new Map(services.map((s) => [s.id, s])); - const unresolvedReferrers = new Map>(); - for (const edge of acc.edges.values()) { - if (!edge.unresolved) continue; - let scopes = unresolvedReferrers.get(edge.token); - if (!scopes) { - scopes = new Set(); - unresolvedReferrers.set(edge.token, scopes); - } - const source = nodeById.get(edge.from); - if (source) scopes.add(source.scope); - } - for (const [token, scopes] of unresolvedReferrers) { - let scope: ServiceScope = 'App'; - let minLevel = Number.POSITIVE_INFINITY; - for (const s of scopes) { - const lvl = SCOPE_LEVEL[s]; - if (lvl < minLevel) { - minLevel = lvl; - scope = s; - } - } - const node: ServiceNode = { - id: `unresolved::${token}`, - token, - impl: token, - scope, - domain: 'unresolved', - file: '', - line: 0, - unresolved: true, - }; - const iface = interfacesByName.get(token); - if (iface) { - const members = collectInterfaceMembers(iface); - if (members.length > 0) node.publicMembers = members; - } - services.push(node); - } - - const mismatchTokens = new Map(); - for (const edge of acc.edges.values()) { - if (!edge.scopeMismatch || edge.actualScope === undefined) continue; - if (!mismatchTokens.has(edge.token)) mismatchTokens.set(edge.token, edge.actualScope); - } - for (const [token, scope] of mismatchTokens) { - const registered = acc.bindings.get(token)?.get(scope); - const node: ServiceNode = { - id: `scopeMismatch::${token}`, - token, - impl: token, - scope, - domain: registered?.domain ?? 'unknown', - file: '', - line: 0, - scopeMismatch: true, - }; - const iface = interfacesByName.get(token); - if (iface) { - const members = collectInterfaceMembers(iface); - if (members.length > 0) node.publicMembers = members; - } - services.push(node); - } - - return { - generatedAt: options.generatedAt ?? new Date(0).toISOString(), - services: services.sort( - (a, b) => - a.domain.localeCompare(b.domain) || - a.impl.localeCompare(b.impl) || - a.scope.localeCompare(b.scope), - ), - edges: [...acc.edges.values()].sort( - (a, b) => - a.from.localeCompare(b.from) || a.kind.localeCompare(b.kind) || a.to.localeCompare(b.to), - ), - unknownTokens: [...acc.unknownRefs].sort(), - }; -} - -export function readHeadSha(): string | undefined { - try { - const head = readFileSync(join(REPO_ROOT, '.git', 'HEAD'), 'utf8').trim(); - if (head.startsWith('ref: ')) { - const ref = head.slice(5); - return readFileSync(join(REPO_ROOT, '.git', ref), 'utf8').trim(); - } - return head; - } catch { - return undefined; - } -} - -export function writeSnapshot(graph: Graph, path: string = SNAPSHOT_PATH): void { - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify(graph, null, 2)}\n`); -} - -export function summarize(graph: Graph): string { - const byKind = new Map(); - for (const e of graph.edges) byKind.set(e.kind, (byKind.get(e.kind) ?? 0) + 1); - const kindSummary = [...byKind.entries()] - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([k, n]) => `${k}=${n}`) - .join(' '); - return `services=${graph.services.length} edges=${graph.edges.length} ${kindSummary}`; -} diff --git a/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts b/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts deleted file mode 100644 index c83fe400a37..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Shape of the dependency-graph data emitted by the analyzer and consumed by - * the web viewer. Kept dependency-free so the same file can be imported from - * Node (analyzer, Vite plugin) and the browser (React app). - */ - -export type ServiceScope = 'App' | 'Session' | 'Agent'; - -export type EdgeKind = - | 'ctor' - | 'accessor' - | 'publish' - | 'subscribe' - | 'emit' - | 'on'; - -export interface ServiceNode { - id: string; - token: string; - impl: string; - scope: ServiceScope; - domain: string; - file: string; - line: number; - publicMembers?: string[]; - unresolved?: true; - scopeMismatch?: true; -} - -export interface EdgeRef { - file: string; - line: number; - fromMethod?: string; - toMethod?: string; -} - -export interface Edge { - from: string; - to: string; - token: string; - kind: EdgeKind; - unresolved?: true; - scopeMismatch?: true; - actualScope?: ServiceScope; - refs: EdgeRef[]; -} - -export interface Graph { - generatedAt: string; - services: ServiceNode[]; - edges: Edge[]; - unknownTokens: string[]; -} diff --git a/packages/agent-core-v2/scripts/dep-graph/cli.ts b/packages/agent-core-v2/scripts/dep-graph/cli.ts deleted file mode 100644 index 486feeecf97..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/cli.ts +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env -S npx tsx -/** - * One-shot analyzer entry point. Writes the current `Graph` snapshot to - * `.local/dep-graph.json` (git-ignored) so it can be diffed, committed to a - * scratch review branch, or piped to another tool without running the dev - * server. - * - * pnpm dep-graph:analyze - * - * The dev server (`pnpm dep-graph:dev`) writes the same file continuously - * while running — this CLI is for CI, hooks, or offline inspection. - */ - -import { SNAPSHOT_PATH, analyze, readHeadSha, summarize, writeSnapshot } from './analyzer/analyze'; - -const graph = analyze({ generatedAt: readHeadSha() ?? new Date().toISOString() }); -writeSnapshot(graph); -console.log(`wrote ${SNAPSHOT_PATH}\n ${summarize(graph)}`); -if (graph.unknownTokens.length > 0) { - console.log(` unknownTokens=${graph.unknownTokens.length}: ${graph.unknownTokens.join(', ')}`); -} diff --git a/packages/agent-core-v2/scripts/dep-graph/lint.ts b/packages/agent-core-v2/scripts/dep-graph/lint.ts deleted file mode 100644 index 1304f043352..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/lint.ts +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env -S npx tsx -/** - * Scope-rule lint over the analyzed dep graph. - * - * The analyzer resolves each edge's target token to a concrete impl by - * walking the source's scope tree (source scope → App). If no visible - * binding exists, the edge is marked `unresolved` — meaning at runtime the - * DI container would fail to satisfy the dependency from the source's - * scope. That's exactly the scope-rule violation we want to lint against: - * - * Scope tree: App > Session > Agent (App outermost / longest-lived) - * - * - `ctor` edge unresolved → **error**: container will crash on - * instantiation. - * - `accessor` edge unresolved → **warning**: only fails at `.get()`-time, - * and calls made under an active inner - * scope may resolve correctly if the - * accessor was passed in from that inner - * scope. Still worth flagging as an - * implicit dependency on runtime nesting. - * - Resolved edges are legal by construction — the analyzer only resolves - * if a binding is visible from the source scope. - * - `publish` / `subscribe` / `emit` / `on` route through the event bus - * token, which is itself ctor-injected; the ctor edge already carries - * the check. - * - * Usage: - * pnpm dep-graph:lint # errors → exit 1 - * pnpm dep-graph:lint --warn # also fail on warnings - */ - -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { join } from 'node:path'; - -import { SNAPSHOT_PATH, SRC_ROOT, analyze } from './analyzer/analyze'; -import type { Edge, Graph, ServiceNode } from './analyzer/types'; - -interface Violation { - severity: 'error' | 'warning'; - edge: Edge; - from: ServiceNode; -} - -function loadGraph(): Graph { - if (existsSync(SNAPSHOT_PATH)) { - const snapMtime = statSync(SNAPSHOT_PATH).mtimeMs; - const srcMtime = latestMtime(SRC_ROOT); - if (snapMtime >= srcMtime) { - return JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf8')) as Graph; - } - } - return analyze({ generatedAt: 'lint' }); -} - -function latestMtime(dir: string): number { - let latest = 0; - const walk = (d: string): void => { - for (const entry of readdirSync(d, { withFileTypes: true })) { - const abs = join(d, entry.name); - if (entry.isDirectory()) walk(abs); - else if (entry.name.endsWith('.ts')) { - const m = statSync(abs).mtimeMs; - if (m > latest) latest = m; - } - } - }; - walk(dir); - return latest; -} - -function lint(graph: Graph): Violation[] { - const byId = new Map(); - for (const s of graph.services) byId.set(s.id, s); - - const violations: Violation[] = []; - for (const edge of graph.edges) { - if (!edge.unresolved) continue; - const from = byId.get(edge.from); - if (!from) continue; - if (edge.kind === 'ctor') { - violations.push({ severity: 'error', edge, from }); - } else if (edge.kind === 'accessor') { - violations.push({ severity: 'warning', edge, from }); - } - } - return violations; -} - -function main(): number { - const failOnWarn = process.argv.includes('--warn'); - const graph = loadGraph(); - const violations = lint(graph); - - const errors = violations.filter((v) => v.severity === 'error'); - const warnings = violations.filter((v) => v.severity === 'warning'); - - const report = (v: Violation): void => { - console.log( - ` [${v.severity.toUpperCase()} ${v.from.scope}→?] ${v.from.impl} (${v.from.token}) --${v.edge.kind}--> ${v.edge.token} (no binding visible from ${v.from.scope})`, - ); - for (const ref of v.edge.refs) { - console.log(` ${ref.file}:${ref.line}`); - } - }; - - if (errors.length > 0) { - console.log( - `\n${errors.length} scope-rule ERROR(s) — ctor edge cannot be resolved from source scope:`, - ); - for (const v of errors) report(v); - } - if (warnings.length > 0) { - console.log( - `\n${warnings.length} scope-rule warning(s) — accessor edge cannot be resolved from source scope (only safe if the accessor is passed in from an inner scope):`, - ); - for (const v of warnings) report(v); - } - - const summary = `\ndep-graph:lint — services=${graph.services.length} edges=${graph.edges.length} errors=${errors.length} warnings=${warnings.length}`; - console.log(summary); - - if (errors.length > 0) return 1; - if (failOnWarn && warnings.length > 0) return 1; - return 0; -} - -process.exit(main()); diff --git a/packages/agent-core-v2/scripts/dep-graph/plugin/virtual-dep-graph.ts b/packages/agent-core-v2/scripts/dep-graph/plugin/virtual-dep-graph.ts deleted file mode 100644 index a79582b3aeb..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/plugin/virtual-dep-graph.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Vite plugin — exposes `virtual:dep-graph` as a module whose default export - * is the current analyzer output, and continuously mirrors the same output to - * `.local/dep-graph.json` on disk while the dev server runs. On any change - * under `src/**\/*.ts` we re-run the analyzer, rewrite the snapshot, and - * invalidate the virtual module so the React Flow view refreshes via HMR. - * - * The plugin runs only in the dev server process; nothing about it ships - * with the package (`dist/` is untouched — see `tsdown.config.ts`, which - * only bundles `src/index.ts`). - */ - -import { relative } from 'node:path'; - -import chokidar, { type FSWatcher } from 'chokidar'; -import type { Plugin, ViteDevServer } from 'vite'; - -import { - SNAPSHOT_PATH, - SRC_ROOT, - analyze, - readHeadSha, - summarize, - writeSnapshot, -} from '../analyzer/analyze'; -import type { Graph } from '../analyzer/types'; - -const VIRTUAL_ID = 'virtual:dep-graph'; -const RESOLVED_ID = `\0${VIRTUAL_ID}`; - -const DEBOUNCE_MS = 200; - -function tag(): string { - return readHeadSha() ?? new Date().toISOString(); -} - -function isSrcFile(file: string): boolean { - const rel = relative(SRC_ROOT, file); - return !rel.startsWith('..') && (file.endsWith('.ts') || file.endsWith('.tsx')); -} - -interface PluginOptions { - writeSnapshotFile?: boolean; -} - -function fingerprint(g: Graph): string { - return JSON.stringify({ - services: g.services, - edges: g.edges, - unknownTokens: g.unknownTokens, - }); -} - -export function depGraphPlugin(options: PluginOptions = {}): Plugin { - const shouldWrite = options.writeSnapshotFile ?? true; - let cached: Graph | undefined; - let cachedFingerprint: string | undefined; - let server: ViteDevServer | undefined; - let debounceTimer: ReturnType | undefined; - let watcher: FSWatcher | undefined; - - function analyzeNow(reason: string): boolean { - const started = Date.now(); - const next = analyze({ generatedAt: tag() }); - const nextFingerprint = fingerprint(next); - const changed = nextFingerprint !== cachedFingerprint; - if (changed) { - cached = next; - cachedFingerprint = nextFingerprint; - if (shouldWrite) writeSnapshot(next); - } - const took = Date.now() - started; - const suffix = changed - ? shouldWrite - ? ` (wrote ${relative(process.cwd(), SNAPSHOT_PATH)})` - : '' - : ' (no change)'; - console.log(`[dep-graph] ${reason} → ${summarize(next)}${suffix} in ${took}ms`); - return changed; - } - - function scheduleRefresh(reason: string): void { - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - debounceTimer = undefined; - if (analyzeNow(reason)) invalidate(); - }, DEBOUNCE_MS); - } - - function invalidate(): void { - if (!server) return; - const mod = server.moduleGraph.getModuleById(RESOLVED_ID); - if (mod) { - server.moduleGraph.invalidateModule(mod); - server.ws.send({ type: 'full-reload', path: '*' }); - } - } - - return { - name: 'agent-core-v2:dep-graph', - buildStart() { - if (!cached) analyzeNow('startup'); - }, - configureServer(dev) { - server = dev; - watcher = chokidar.watch(SRC_ROOT, { - ignoreInitial: true, - ignored: (path, stats) => { - if (!stats) return false; - if (stats.isDirectory()) return false; - return !path.endsWith('.ts'); - }, - }); - watcher.on('ready', () => { - console.log(`[dep-graph] watching ${relative(process.cwd(), SRC_ROOT)}`); - }); - for (const evt of ['add', 'change', 'unlink'] as const) { - watcher.on(evt, (file: string) => { - if (!isSrcFile(file)) return; - scheduleRefresh(`${evt} ${relative(SRC_ROOT, file)}`); - }); - } - }, - async closeBundle() { - if (debounceTimer) clearTimeout(debounceTimer); - await watcher?.close(); - }, - resolveId(id) { - if (id === VIRTUAL_ID) return RESOLVED_ID; - return undefined; - }, - load(id) { - if (id !== RESOLVED_ID) return undefined; - if (!cached) analyzeNow('load'); - return `export default ${JSON.stringify(cached)};`; - }, - }; -} diff --git a/packages/agent-core-v2/scripts/dep-graph/vite.config.ts b/packages/agent-core-v2/scripts/dep-graph/vite.config.ts deleted file mode 100644 index 5076f1bf884..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/vite.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; - -import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; - -import { depGraphPlugin } from './plugin/virtual-dep-graph'; - -const here = dirname(fileURLToPath(import.meta.url)); - -export default defineConfig({ - root: resolve(here, 'web'), - cacheDir: resolve(here, '.vite'), - clearScreen: false, - server: { - host: '127.0.0.1', - port: 5187, - strictPort: false, - }, - plugins: [react(), depGraphPlugin()], - build: { - outDir: resolve(here, '.local', 'web-dist'), - emptyOutDir: true, - }, -}); diff --git a/packages/agent-core-v2/scripts/dep-graph/web/index.html b/packages/agent-core-v2/scripts/dep-graph/web/index.html deleted file mode 100644 index 624b67d438a..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/web/index.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - agent-core-v2 · dep graph - - - -
- - - diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/App.tsx b/packages/agent-core-v2/scripts/dep-graph/web/src/App.tsx deleted file mode 100644 index 8ed48fa3816..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/App.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import graph from 'virtual:dep-graph'; - -import type { EdgeKind, ServiceScope } from '../../analyzer/types'; -import { Filters, type FilterState } from './Filters'; -import { GraphView } from './GraphView'; -import { readQueryParams } from './query-params'; -import { EDGE_KINDS } from './style'; -import { collectTagCounts, loadTags, saveTags, tagsEqual, type TagMap } from './tags'; - -const ALL_SCOPES: ServiceScope[] = ['App', 'Session', 'Agent']; - -export function App(): JSX.Element { - const queryParams = useMemo(() => readQueryParams(window.location.search), []); - - const domains = useMemo( - () => [...new Set(graph.services.map((s) => s.domain))].sort((a, b) => a.localeCompare(b)), - [], - ); - - const [filters, setFilters] = useState(() => { - const visibleDomains = queryParams.domains ? new Set(queryParams.domains) : undefined; - return { - scopes: queryParams.scopes - ? new Set(queryParams.scopes) - : new Set(ALL_SCOPES), - kinds: queryParams.kinds - ? new Set(queryParams.kinds) - : new Set(EDGE_KINDS), - hiddenDomains: visibleDomains - ? new Set(domains.filter((d) => !visibleDomains.has(d))) - : new Set(), - search: queryParams.search ?? '', - hideOrphans: queryParams.hideOrphans ?? false, - groupByScope: queryParams.groupByScope ?? false, - activeTags: new Set(), - }; - }); - - const [selectedId, setSelectedId] = useState(() => - queryParams.focus && graph.services.some((s) => s.id === queryParams.focus) - ? queryParams.focus - : undefined, - ); - - const [tags, setTags] = useState(() => loadTags()); - useEffect(() => { - saveTags(tags); - }, [tags]); - - const tagCounts = useMemo(() => collectTagCounts(tags), [tags]); - - const handleEditTags = useCallback((nodeId: string, next: string[]) => { - setTags((prev) => { - if (tagsEqual(prev, nodeId, next)) return prev; - const updated = { ...prev }; - if (next.length === 0) delete updated[nodeId]; - else updated[nodeId] = next; - return updated; - }); - }, []); - - return ( -
- -
- -
-
- ); -} diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/Filters.tsx b/packages/agent-core-v2/scripts/dep-graph/web/src/Filters.tsx deleted file mode 100644 index 9abc8a21f17..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/Filters.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import type { EdgeKind, Graph, ServiceScope } from '../../analyzer/types'; -import { EDGE_KINDS, EDGE_STYLE, SCOPE_STYLE } from './style'; -import { tagColor, type TagCount } from './tags'; - -export interface FilterState { - scopes: Set; - kinds: Set; - hiddenDomains: Set; - search: string; - hideOrphans: boolean; - groupByScope: boolean; - activeTags: Set; -} - -interface FiltersProps { - graph: Graph; - domains: string[]; - tagCounts: TagCount[]; - state: FilterState; - onChange: (next: FilterState) => void; -} - -const SCOPES: ServiceScope[] = ['App', 'Session', 'Agent']; - -export function Filters({ - graph, - domains, - tagCounts, - state, - onChange, -}: FiltersProps): JSX.Element { - function toggle(set: Set, key: T): Set { - const next = new Set(set); - if (next.has(key)) next.delete(key); - else next.add(key); - return next; - } - - const edgeCounts = countByKind(graph); - const scopeCounts = countByScope(graph); - const domainCounts = countByDomain(graph); - - return ( - - ); -} - -function Section({ title, children }: { title: string; children: React.ReactNode }): JSX.Element { - return ( -
-
- {title} -
- {children} -
- ); -} - -interface CheckRowProps { - label: string; - count?: number; - checked: boolean; - color?: string; - dashed?: boolean; - onToggle: () => void; -} - -function CheckRow({ label, count, checked, color, dashed, onToggle }: CheckRowProps): JSX.Element { - return ( - - ); -} - -const btnStyle: React.CSSProperties = { - flex: 1, - padding: '3px 8px', - background: '#21262d', - color: '#e6edf3', - border: '1px solid #30363d', - borderRadius: 4, - cursor: 'pointer', - fontSize: 11, -}; - -function countByKind(graph: Graph): Record { - const out: Record = {}; - for (const e of graph.edges) out[e.kind] = (out[e.kind] ?? 0) + 1; - return out; -} - -function countByScope(graph: Graph): Record { - const out: Record = {}; - for (const s of graph.services) out[s.scope] = (out[s.scope] ?? 0) + 1; - return out; -} - -function countByDomain(graph: Graph): Record { - const out: Record = {}; - for (const s of graph.services) out[s.domain] = (out[s.domain] ?? 0) + 1; - return out; -} diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx b/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx deleted file mode 100644 index 9aaee155a91..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx +++ /dev/null @@ -1,1047 +0,0 @@ -import { - Background, - BackgroundVariant, - Controls, - Handle, - MiniMap, - type Node, - type NodeProps, - Position, - ReactFlow, - type Edge as RFEdge, - type Viewport, -} from '@xyflow/react'; -import '@xyflow/react/dist/style.css'; -import { Fragment, useMemo, useState } from 'react'; - -import type { Edge, EdgeKind, EdgeRef, Graph, ServiceNode } from '../../analyzer/types'; -import type { FilterState } from './Filters'; -import { layoutDagre } from './layout-dagre'; -import { - EDGE_STYLE, - SCOPE_MISMATCH_COLOR, - SCOPE_STYLE, - UNRESOLVED_COLOR, -} from './style'; -import { tagColor, type TagMap } from './tags'; - -const NODE_WIDTH = 300; -const HEADER_HEIGHT = 68; -const PORT_ROW_HEIGHT = 18; -const PORTS_PAD_TOP = 4; -const TAGS_ROW_HEIGHT = 20; - -interface ServicePortsInfo { - inPorts: string[]; - outPorts: string[]; - connectedIn: Set; -} - -interface GraphViewProps { - graph: Graph; - filters: FilterState; - selectedId?: string; - onSelect: (id?: string) => void; - tags: TagMap; - onEditTags: (nodeId: string, tags: string[]) => void; -} - -interface ServiceNodeData extends Record { - service: ServiceNode; - selected: boolean; - matched: boolean; - dim: boolean; - ports: ServicePortsInfo; - tags: string[]; -} - -const EVENT_KINDS: Set = new Set(['publish', 'subscribe', 'emit', 'on']); - -function effectiveToMethod(kind: EdgeKind, refTo: string | undefined): string | undefined { - if (refTo !== undefined) return refTo; - if (EVENT_KINDS.has(kind)) return kind; - return undefined; -} - -function computeServicePorts( - services: ServiceNode[], - edges: Edge[], -): Map { - const acc = new Map< - string, - { in: Set; out: Set; connectedIn: Set } - >(); - for (const s of services) { - const bucket = { - in: new Set(), - out: new Set(), - connectedIn: new Set(), - }; - if (s.publicMembers) { - for (const name of s.publicMembers) bucket.in.add(name); - } - acc.set(s.id, bucket); - } - for (const e of edges) { - const src = acc.get(e.from); - const dst = acc.get(e.to); - for (const ref of e.refs) { - const toMethod = effectiveToMethod(e.kind, ref.toMethod); - if (ref.fromMethod !== undefined && src) src.out.add(ref.fromMethod); - if (toMethod !== undefined && dst) { - dst.in.add(toMethod); - dst.connectedIn.add(toMethod); - } - } - } - const result = new Map(); - for (const [id, sets] of acc) { - result.set(id, { - inPorts: [...sets.in].sort(), - outPorts: [...sets.out].sort(), - connectedIn: sets.connectedIn, - }); - } - return result; -} - -function nodeHeight(ports: ServicePortsInfo, hasTags: boolean): number { - const rows = Math.max(ports.inPorts.length, ports.outPorts.length); - const base = rows === 0 ? HEADER_HEIGHT : HEADER_HEIGHT + PORTS_PAD_TOP + rows * PORT_ROW_HEIGHT + PORTS_PAD_TOP; - return hasTags ? base + TAGS_ROW_HEIGHT : base; -} - -function ServiceNodeView({ data }: NodeProps>): JSX.Element { - const { service, selected, matched, dim, ports, tags } = data; - const bg = SCOPE_STYLE[service.scope].color; - const rowCount = Math.max(ports.inPorts.length, ports.outPorts.length); - const isUnresolved = service.unresolved === true; - const isScopeMismatch = service.scopeMismatch === true; - const specialBorder = isUnresolved || isScopeMismatch; - const borderColor = selected - ? '#ffdf5d' - : matched - ? '#79c0ff' - : isUnresolved - ? UNRESOLVED_COLOR - : isScopeMismatch - ? SCOPE_MISMATCH_COLOR - : 'rgba(0,0,0,0.4)'; - const borderWidth = selected || matched || specialBorder ? 2 : 1; - const borderStyle = specialBorder && !selected && !matched ? 'dashed' : 'solid'; - const glow = selected - ? '0 0 0 3px rgba(255,223,93,0.25)' - : matched - ? '0 0 0 3px rgba(121,192,255,0.25)' - : 'none'; - return ( -
- - - -
-
- - {SCOPE_STYLE[service.scope].badge} - - - {service.impl} - -
-
- {isUnresolved - ? 'no implementation registered' - : isScopeMismatch - ? `registered at ${service.scope} · cross-scope ref` - : service.token} -
-
{service.domain}
-
- - {tags.length > 0 && } - - {rowCount > 0 && ( -
- {Array.from({ length: rowCount }, (_, i) => { - const out = ports.outPorts[i]; - const inn = ports.inPorts[i]; - return ( -
- {out !== undefined && ( - - )} - {inn !== undefined && ( - - )} -
- - {out ?? ''} - - - {inn ?? ''} - -
-
- ); - })} -
- )} -
- ); -} - -function BandLabelView({ data }: NodeProps>): JSX.Element { - const { scope, width } = data; - return ( -
- {scope} -
- ); -} - -const nodeTypes = { service: ServiceNodeView, band: BandLabelView }; - -function TagChips({ tags }: { tags: string[] }): JSX.Element { - return ( -
- {tags.map((tag) => ( - - ))} -
- ); -} - -interface TagChipProps { - tag: string; - onRemove?: () => void; -} - -function TagChip({ tag, onRemove }: TagChipProps): JSX.Element { - const { color, bg } = tagColor(tag); - return ( - - - {tag} - - {onRemove && ( - - )} - - ); -} - -interface TagEditorProps { - tags: string[]; - allTags: string[]; - onChange: (next: string[]) => void; -} - -function TagEditor({ tags, allTags, onChange }: TagEditorProps): JSX.Element { - const [draft, setDraft] = useState(''); - const listId = 'tag-suggestions'; - - function commit(raw: string): void { - const tag = raw.trim(); - if (!tag || tags.includes(tag)) { - setDraft(''); - return; - } - onChange([...tags, tag]); - setDraft(''); - } - - return ( -
-
- tags -
-
- {tags.length === 0 ? ( - no tags - ) : ( - tags.map((tag) => ( - onChange(tags.filter((t) => t !== tag))} - /> - )) - )} -
-
- setDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - commit(draft); - } - }} - style={{ - flex: 1, - minWidth: 0, - padding: '4px 7px', - background: '#0e1116', - color: '#e6edf3', - border: '1px solid #30363d', - borderRadius: 4, - fontSize: 11, - }} - /> - - - {allTags - .filter((t) => !tags.includes(t)) - .map((t) => ( - -
-
- ); -} - -const VIEWPORT_STORAGE_KEY = 'agent-core-v2:dep-graph:viewport'; - -function loadViewport(): Viewport | undefined { - try { - const raw = sessionStorage.getItem(VIEWPORT_STORAGE_KEY); - if (raw === null) return undefined; - const parsed = JSON.parse(raw) as Partial | null; - if ( - parsed === null || - typeof parsed.x !== 'number' || - typeof parsed.y !== 'number' || - typeof parsed.zoom !== 'number' - ) { - return undefined; - } - return { x: parsed.x, y: parsed.y, zoom: parsed.zoom }; - } catch { - return undefined; - } -} - -function saveViewport(v: Viewport): void { - try { - sessionStorage.setItem(VIEWPORT_STORAGE_KEY, JSON.stringify(v)); - } catch { - } -} - -function passesFilter( - service: ServiceNode, - filters: FilterState, - connected: Set, -): boolean { - if (!filters.scopes.has(service.scope)) return false; - if (filters.hiddenDomains.has(service.domain)) return false; - if (filters.hideOrphans && !connected.has(service.id)) return false; - return true; -} - -function matchesSearch(service: ServiceNode, query: string): boolean { - const members = service.publicMembers ? ` ${service.publicMembers.join(' ')}` : ''; - const hay = `${service.token} ${service.impl} ${service.domain}${members}`.toLowerCase(); - return hay.includes(query); -} - -export function GraphView({ - graph, - filters, - selectedId, - onSelect, - tags, - onEditTags, -}: GraphViewProps): JSX.Element { - const initialViewport = useMemo(() => loadViewport(), []); - - const { nodes, edges, selectedService, selectedEdges } = useMemo(() => { - const survivingEdges: Edge[] = graph.edges.filter((e) => filters.kinds.has(e.kind)); - - const connected = new Set(); - for (const e of survivingEdges) { - connected.add(e.from); - connected.add(e.to); - } - - const visibleServices = graph.services.filter((s) => - passesFilter(s, filters, connected), - ); - const visibleIds = new Set(visibleServices.map((s) => s.id)); - - const finalEdges = survivingEdges.filter( - (e) => visibleIds.has(e.from) && visibleIds.has(e.to), - ); - - const ports = computeServicePorts(visibleServices, finalEdges); - - const searchQuery = filters.search.trim().toLowerCase(); - const matched = new Set(); - if (searchQuery) { - for (const s of visibleServices) { - if (matchesSearch(s, searchQuery)) matched.add(s.id); - } - } - - const tagMatched = new Set(); - if (filters.activeTags.size > 0) { - for (const s of visibleServices) { - const st = tags[s.id]; - if (st && st.some((t) => filters.activeTags.has(t))) tagMatched.add(s.id); - } - } - - const focused = new Set(); - const seedFocus = (id: string): void => { - focused.add(id); - for (const e of finalEdges) { - if (e.from === id) focused.add(e.to); - if (e.to === id) focused.add(e.from); - } - }; - if (selectedId !== undefined) seedFocus(selectedId); - for (const id of matched) seedFocus(id); - for (const id of tagMatched) seedFocus(id); - - const focusActive = - selectedId !== undefined || matched.size > 0 || tagMatched.size > 0; - - const layout = layoutDagre(visibleServices, finalEdges, { - groupByScope: filters.groupByScope, - nodeSize: (id) => { - const p = ports.get(id) ?? { - inPorts: [], - outPorts: [], - connectedIn: new Set(), - }; - const hasTags = (tags[id]?.length ?? 0) > 0; - return { width: NODE_WIDTH, height: nodeHeight(p, hasTags) }; - }, - }); - const pos = layout.positions; - - const rfNodes: Node[] = visibleServices.map( - (service): Node => ({ - id: service.id, - type: 'service', - position: pos.get(service.id) ?? { x: 0, y: 0 }, - data: { - service, - selected: service.id === selectedId, - matched: matched.has(service.id), - dim: focusActive && !focused.has(service.id), - ports: ports.get(service.id) ?? { - inPorts: [], - outPorts: [], - connectedIn: new Set(), - }, - tags: tags[service.id] ?? [], - }, - }), - ); - - if (layout.bands) { - const ys = [...pos.values()].map((p) => p.y); - const minY = ys.length > 0 ? Math.min(...ys) : 0; - for (const band of layout.bands) { - rfNodes.push({ - id: `band::${band.scope}`, - type: 'band', - position: { x: band.x, y: minY - 40 }, - data: { scope: band.scope, width: Math.max(band.width, 120) }, - draggable: false, - selectable: false, - focusable: false, - }); - } - } - - const rfEdges: RFEdge[] = []; - for (const e of finalEdges) { - const style = EDGE_STYLE[e.kind]; - const isHighlighted = focusActive && focused.has(e.from) && focused.has(e.to); - const pairs = new Map< - string, - { fromMethod: string | undefined; toMethod: string | undefined } - >(); - for (const ref of e.refs) { - const toMethod = effectiveToMethod(e.kind, ref.toMethod); - const key = `${ref.fromMethod ?? ''}|${toMethod ?? ''}`; - if (!pairs.has(key)) pairs.set(key, { fromMethod: ref.fromMethod, toMethod }); - } - for (const [key, pair] of pairs) { - const sourceHandle = pair.fromMethod ? `out:${pair.fromMethod}` : 'default-source'; - const targetHandle = pair.toMethod ? `in:${pair.toMethod}` : 'default-target'; - rfEdges.push({ - id: `${e.from}::${e.kind}::${e.to}::${key}`, - source: e.from, - target: e.to, - sourceHandle, - targetHandle, - style: { - stroke: style.color, - strokeWidth: isHighlighted ? 2.2 : 1.2, - strokeDasharray: style.dashed ? '4 3' : undefined, - opacity: focusActive ? (isHighlighted ? 1 : 0.1) : 0.75, - }, - animated: false, - }); - } - } - - const selectedService = selectedId - ? graph.services.find((s) => s.id === selectedId) - : undefined; - const selectedEdges = selectedId - ? finalEdges.filter((e) => e.from === selectedId || e.to === selectedId) - : []; - - return { nodes: rfNodes, edges: rfEdges, selectedService, selectedEdges }; - }, [graph, filters, selectedId, tags]); - - return ( - <> - saveViewport(viewport)} - minZoom={0.1} - maxZoom={1.6} - onNodeClick={(_, node) => { - if (node.id.startsWith('band::')) return; - onSelect(node.id); - }} - onPaneClick={() => onSelect(undefined)} - proOptions={{ hideAttribution: true }} - > - - { - if (n.id.startsWith('band::')) return 'transparent'; - const service = (n.data as ServiceNodeData | undefined)?.service; - if (!service) return '#7d8590'; - return service.unresolved - ? UNRESOLVED_COLOR - : service.scopeMismatch - ? SCOPE_MISMATCH_COLOR - : SCOPE_STYLE[service.scope].color; - }} - /> - - - {selectedService && ( - onSelect(undefined)} - tags={tags} - onEditTags={onEditTags} - /> - )} - - ); -} - -interface ServicePanelProps { - service: ServiceNode; - graph: Graph; - edges: Edge[]; - onClose: () => void; - tags: TagMap; - onEditTags: (nodeId: string, tags: string[]) => void; -} - -function ServicePanel({ - service, - graph, - edges, - onClose, - tags, - onEditTags, -}: ServicePanelProps): JSX.Element { - const outgoing = edges.filter((e) => e.from === service.id); - const incoming = edges.filter((e) => e.to === service.id && e.from !== service.id); - const byId = new Map(graph.services.map((s) => [s.id, s])); - const nodeTags = tags[service.id] ?? []; - const allTags = useMemo( - () => [...new Set(Object.values(tags).flat())].sort(), - [tags], - ); - return ( -
-
-
-
{service.impl}
- {service.unresolved ? ( -
- No implementation registered -
- ) : service.scopeMismatch ? ( -
- Registered at {service.scope} — not visible from the caller's scope -
- ) : ( -
{service.token}
- )} -
- {service.scope} · {service.domain} -
- {!service.unresolved && !service.scopeMismatch && ( -
- {service.file}:{service.line} -
- )} -
- -
- - { - onEditTags(service.id, next); - }} - /> - - - -
- ); -} - -interface EdgeListProps { - title: string; - edges: Edge[]; - direction: 'in' | 'out'; - byId: Map; -} - -interface EdgeGroup { - edge: Edge; - peerLabel: string; - peerToken?: string; - methodRefs: EdgeRef[]; - unattributedCount: number; -} - -function buildEdgeGroups( - edges: Edge[], - direction: 'in' | 'out', - byId: Map, -): EdgeGroup[] { - return edges.map((e) => { - const peerId = direction === 'out' ? e.to : e.from; - const peer = byId.get(peerId); - const peerLabel = peer ? peer.impl : peerId; - const peerToken = peer?.token; - const methodRefs = e.refs.filter( - (r) => r.toMethod !== undefined || r.fromMethod !== undefined, - ); - const unattributedCount = e.refs.length - methodRefs.length; - return { edge: e, peerLabel, peerToken, methodRefs, unattributedCount }; - }); -} - -function EdgeList({ title, edges, direction, byId }: EdgeListProps): JSX.Element { - const groups = buildEdgeGroups(edges, direction, byId); - const selfIsFrom = direction === 'out'; - return ( -
-
- {title} -
- {groups.length === 0 ? ( -
- ) : ( - - - - - - - - - - - - - - - - - {groups.map((g) => { - const kindStyle = EDGE_STYLE[g.edge.kind]; - const kindCell = ( -
- - {g.edge.kind} -
- ); - const peerCell = ( -
- {g.peerLabel} -
- ); - const groupKey = `${g.edge.from}::${g.edge.kind}::${g.edge.to}`; - if (g.methodRefs.length === 0) { - return ( - - - - - - ); - } - return ( - - {g.methodRefs.map((r, i) => { - const isFirst = i === 0; - return ( - - {isFirst && ( - <> - - - - )} - - - - ); - })} - - ); - })} - -
kindpeerfrom → toline
{kindCell}{peerCell} - — ×{g.edge.refs.length} -
- {kindCell} - - {peerCell} - - - {r.fromMethod ?? '?'} - - - - {r.toMethod ?? '?'} - - :{r.line}
- )} -
- ); -} - -const tableStyle: React.CSSProperties = { - width: '100%', - borderCollapse: 'collapse', - tableLayout: 'fixed', - fontFamily: - 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace', - fontSize: 10.5, -}; - -const thStyle: React.CSSProperties = { - textAlign: 'left', - fontWeight: 600, - color: '#7d8590', - fontSize: 9, - textTransform: 'uppercase', - letterSpacing: 0.5, - padding: '3px 6px', - borderBottom: '1px solid #30363d', -}; - -const tdStyle: React.CSSProperties = { - padding: '3px 6px', - verticalAlign: 'top', -}; - -const tdCallStyle: React.CSSProperties = { - ...tdStyle, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', -}; - -const tdLineStyle: React.CSSProperties = { - ...tdStyle, - textAlign: 'right', - color: '#6e7681', - whiteSpace: 'nowrap', -}; - -const cellClipStyle: React.CSSProperties = { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', -}; - -const groupBorderStyle: React.CSSProperties = { - borderTop: '1px solid #21262d', -}; diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/layout-dagre.ts b/packages/agent-core-v2/scripts/dep-graph/web/src/layout-dagre.ts deleted file mode 100644 index b034e06b699..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/layout-dagre.ts +++ /dev/null @@ -1,134 +0,0 @@ -import Dagre from '@dagrejs/dagre'; - -import type { Edge, ServiceNode, ServiceScope } from '../../analyzer/types'; - -const NODE_WIDTH = 220; -const NODE_HEIGHT = 48; - -const BAND_GAP = 120; - -export interface LayoutOptions { - direction?: 'LR' | 'RL' | 'TB' | 'BT'; - ranksep?: number; - nodesep?: number; - groupByScope?: boolean; - nodeSize?: (id: string) => { width: number; height: number }; -} - -export interface ScopeBand { - scope: ServiceScope; - x: number; - y: number; - width: number; - height: number; -} - -export interface LayoutResult { - positions: Map; - width: number; - height: number; - bands?: ScopeBand[]; -} - -const BAND_ORDER: ServiceScope[] = ['App', 'Session', 'Agent']; - -export function layoutDagre( - services: ServiceNode[], - edges: Edge[], - options: LayoutOptions = {}, -): LayoutResult { - if (options.groupByScope) return layoutByScope(services, edges, options); - return runDagre(services, edges, options); -} - -function layoutByScope( - services: ServiceNode[], - edges: Edge[], - options: LayoutOptions, -): LayoutResult { - const byScope = new Map(); - for (const s of services) { - const arr = byScope.get(s.scope); - if (arr) arr.push(s); - else byScope.set(s.scope, [s]); - } - - const positions = new Map(); - const bands: ScopeBand[] = []; - let xCursor = 0; - let totalHeight = 0; - - for (const scope of BAND_ORDER) { - const scoped = byScope.get(scope); - if (!scoped || scoped.length === 0) continue; - const scopedIds = new Set(scoped.map((s) => s.id)); - const scopedEdges = edges.filter((e) => scopedIds.has(e.from) && scopedIds.has(e.to)); - const sub = runDagre(scoped, scopedEdges, options); - for (const [id, pos] of sub.positions) { - positions.set(id, { x: pos.x + xCursor, y: pos.y }); - } - bands.push({ scope, x: xCursor, y: 0, width: sub.width, height: sub.height }); - xCursor += sub.width + BAND_GAP; - if (sub.height > totalHeight) totalHeight = sub.height; - } - - return { - positions, - width: Math.max(0, xCursor - BAND_GAP), - height: totalHeight, - bands, - }; -} - -function runDagre( - services: ServiceNode[], - edges: Edge[], - options: LayoutOptions, -): LayoutResult { - const g = new Dagre.graphlib.Graph({ multigraph: true }); - g.setGraph({ - rankdir: options.direction ?? 'RL', - ranksep: options.ranksep ?? 90, - nodesep: options.nodesep ?? 20, - edgesep: 10, - marginx: 20, - marginy: 20, - }); - g.setDefaultEdgeLabel(() => ({})); - - const degree = new Map(); - for (const s of services) degree.set(s.id, 0); - for (const e of edges) { - if (!degree.has(e.from) || !degree.has(e.to)) continue; - degree.set(e.from, (degree.get(e.from) ?? 0) + 1); - degree.set(e.to, (degree.get(e.to) ?? 0) + 1); - } - - const known = new Set(); - for (const s of services) { - const isolated = (degree.get(s.id) ?? 0) === 0; - const size = options.nodeSize?.(s.id) ?? { width: NODE_WIDTH, height: NODE_HEIGHT }; - g.setNode(s.id, { - width: size.width, - height: size.height, - ...(isolated ? { rank: 'max' } : {}), - }); - known.add(s.id); - } - for (const e of edges) { - if (!known.has(e.from) || !known.has(e.to)) continue; - g.setEdge(e.from, e.to, {}, e.kind); - } - - Dagre.layout(g); - - const positions = new Map(); - for (const s of services) { - const n = g.node(s.id); - if (!n) continue; - const size = options.nodeSize?.(s.id) ?? { width: NODE_WIDTH, height: NODE_HEIGHT }; - positions.set(s.id, { x: n.x - size.width / 2, y: n.y - size.height / 2 }); - } - const { width = 0, height = 0 } = g.graph(); - return { positions, width, height }; -} diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/main.tsx b/packages/agent-core-v2/scripts/dep-graph/web/src/main.tsx deleted file mode 100644 index 2d599437b89..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/main.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; - -import { App } from './App'; - -const el = document.getElementById('root'); -if (!el) throw new Error('missing #root'); - -createRoot(el).render( - - - , -); diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/query-params.ts b/packages/agent-core-v2/scripts/dep-graph/web/src/query-params.ts deleted file mode 100644 index f63052f1618..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/query-params.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * URL query-string reader for the dep-graph viewer. Lets a link deep-link into - * a specific slice of the graph — e.g. `?domain=session,sessionMetadata` shows - * only those domains, `?scope=Session&kind=ctor` narrows to ctor edges at - * Session scope, and `?focus=Session::IMyService` pre-selects a node. - * - * The mapping is one-way on load: the URL seeds the initial filter state and - * subsequent UI interaction does NOT write back to the URL. Parsed values are - * validated against the known scope/kind vocabularies; unknown tokens are - * dropped rather than crashing the viewer. - */ -import type { EdgeKind, ServiceScope } from '../../analyzer/types'; -import { EDGE_KINDS } from './style'; - -const ALL_SCOPES: readonly ServiceScope[] = ['App', 'Session', 'Agent']; - -export interface QueryParams { - domains?: string[]; - scopes?: ServiceScope[]; - kinds?: EdgeKind[]; - search?: string; - hideOrphans?: boolean; - groupByScope?: boolean; - focus?: string; -} - -export function readQueryParams(search: string): QueryParams { - const params = new URLSearchParams(search); - const out: QueryParams = {}; - - const domains = parseList(params.get('domain')); - if (domains !== undefined) out.domains = domains; - - const scopes = filterValid(parseList(params.get('scope')), isScope); - if (scopes !== undefined) out.scopes = scopes; - - const kinds = filterValid(parseList(params.get('kind')), isKind); - if (kinds !== undefined) out.kinds = kinds; - - const searchValue = params.get('search'); - if (searchValue !== null && searchValue !== '') out.search = searchValue; - - if (params.has('hideOrphans')) out.hideOrphans = parseBool(params.get('hideOrphans')); - if (params.has('groupByScope')) out.groupByScope = parseBool(params.get('groupByScope')); - - const focus = params.get('focus'); - if (focus !== null && focus !== '') out.focus = focus; - - return out; -} - -function parseList(raw: string | null): string[] | undefined { - if (raw === null) return undefined; - const items = [ - ...new Set(raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0)), - ]; - return items.length > 0 ? items : undefined; -} - -function filterValid( - items: string[] | undefined, - guard: (s: string) => s is T, -): T[] | undefined { - if (items === undefined) return undefined; - const valid = items.filter(guard); - return valid.length > 0 ? valid : undefined; -} - -function isScope(s: string): s is ServiceScope { - return (ALL_SCOPES as readonly string[]).includes(s); -} - -function isKind(s: string): s is EdgeKind { - return (EDGE_KINDS as readonly string[]).includes(s); -} - -function parseBool(raw: string | null): boolean { - if (raw === null || raw === '') return true; - return !/^(false|0|no|off)$/i.test(raw.trim()); -} diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/style.ts b/packages/agent-core-v2/scripts/dep-graph/web/src/style.ts deleted file mode 100644 index cba384cfeb2..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/style.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Colors + labels for edge kinds. Central so the legend and the React Flow - * edges stay in sync. - */ -import type { EdgeKind, ServiceScope } from '../../analyzer/types'; - -export const EDGE_STYLE: Record< - EdgeKind, - { color: string; label: string; dashed: boolean } -> = { - ctor: { color: '#7d8590', label: 'ctor', dashed: false }, - accessor: { color: '#d29922', label: 'accessor', dashed: false }, - publish: { color: '#39c5cf', label: 'publish', dashed: true }, - subscribe: { color: '#79c0ff', label: 'subscribe', dashed: true }, - emit: { color: '#f778ba', label: 'emit', dashed: true }, - on: { color: '#c297f5', label: 'on', dashed: true }, -}; - -export const SCOPE_STYLE: Record = { - App: { color: '#2f5fa8', badge: 'App' }, - Session: { color: '#7f4bb5', badge: 'Ses' }, - Agent: { color: '#2f8a4d', badge: 'Agt' }, -}; - -export const SCOPE_MISMATCH_COLOR = '#f0883e'; -export const UNRESOLVED_COLOR = '#f85149'; - -export const EDGE_KINDS: EdgeKind[] = ['ctor', 'accessor', 'publish', 'subscribe', 'emit', 'on']; diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/tags.ts b/packages/agent-core-v2/scripts/dep-graph/web/src/tags.ts deleted file mode 100644 index 4a680a40e7b..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/tags.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Per-node tag model for the dep-graph viewer. Tags are user-authored labels - * stuck onto service nodes so the graph can be grouped / focused by concerns - * that the analyzer doesn't know about (team ownership, migration phase, - * review status, …). They live entirely in the browser: persisted to - * `localStorage` and keyed by `ServiceNode.id`, which is stable across - * analyzer runs (`${scope}::${token}`). - */ - -/** `ServiceNode.id` → tag list. Order is preserved as entered. */ -export type TagMap = Record; - -const TAGS_STORAGE_KEY = 'agent-core-v2:dep-graph:tags'; - -export function loadTags(): TagMap { - try { - const raw = localStorage.getItem(TAGS_STORAGE_KEY); - if (raw === null) return {}; - const parsed = JSON.parse(raw) as unknown; - if (!isTagMap(parsed)) return {}; - return parsed; - } catch { - return {}; - } -} - -export function saveTags(tags: TagMap): void { - try { - localStorage.setItem(TAGS_STORAGE_KEY, JSON.stringify(tags)); - } catch { - } -} - -function isTagMap(value: unknown): value is TagMap { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - for (const v of Object.values(value)) { - if (!Array.isArray(v) || v.some((t) => typeof t !== 'string')) return false; - } - return true; -} - -export interface TagCount { - tag: string; - count: number; -} - -export function collectTagCounts(tags: TagMap): TagCount[] { - const counts = new Map(); - for (const list of Object.values(tags)) { - for (const tag of list) counts.set(tag, (counts.get(tag) ?? 0) + 1); - } - return [...counts] - .map(([tag, count]) => ({ tag, count })) - .sort((a, b) => a.tag.localeCompare(b.tag)); -} - -export function tagsEqual(tags: TagMap, nodeId: string, next: string[]): boolean { - const cur = tags[nodeId]; - if (next.length === 0) return !(nodeId in tags); - return cur !== undefined && cur.length === next.length && cur.every((t, i) => t === next[i]); -} - -export function tagColor(tag: string): { color: string; bg: string } { - const hue = ((hashString(tag) % 360) + 360) % 360; - return { - color: `hsl(${hue}, 65%, 72%)`, - bg: `hsla(${hue}, 55%, 45%, 0.2)`, - }; -} - -function hashString(s: string): number { - let h = 5381; - for (let i = 0; i < s.length; i++) h = (h * 33) ^ (s.codePointAt(i) ?? 0); - return h; -} diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/virtual-dep-graph.d.ts b/packages/agent-core-v2/scripts/dep-graph/web/src/virtual-dep-graph.d.ts deleted file mode 100644 index 39fa3633abc..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/virtual-dep-graph.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/// - -declare module 'virtual:dep-graph' { - import type { Graph } from '../../analyzer/types'; - const graph: Graph; - export default graph; -} diff --git a/packages/agent-core-v2/scripts/dep-graph/web/tsconfig.json b/packages/agent-core-v2/scripts/dep-graph/web/tsconfig.json deleted file mode 100644 index dd2da011087..00000000000 --- a/packages/agent-core-v2/scripts/dep-graph/web/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "resolveJsonModule": true, - "isolatedModules": true, - "types": ["vite/client"] - }, - "include": ["src/**/*", "../analyzer/types.ts"] -} diff --git a/packages/agent-core-v2/scripts/gen-state-manifest.mts b/packages/agent-core-v2/scripts/gen-state-manifest.mts index 7fdb4802920..b4e8d7acc33 100644 --- a/packages/agent-core-v2/scripts/gen-state-manifest.mts +++ b/packages/agent-core-v2/scripts/gen-state-manifest.mts @@ -7,12 +7,15 @@ * Pure static pass (state keys are registered inside DI scope constructors, so * there is no process-level registry to drain the way `gen-wire-manifest` * does): - * 1. A ts-morph scan of `src/{app,workspace,session,agent}/**` collects - * every top-level `defineState('name', ...)` key constant. + * 1. A ts-morph scan of `src/{app,workspace,session,agent,features}/**` + * collects every top-level `defineState('name', ...)` key constant. * 2. Every `.register(key)` call site resolves its argument back to a key * constant (following imports); the key joins the scope of the * registering file (`src/app/**` → App, `src/workspace/**` → Workspace, - * `src/session/**` → Session, `src/agent/**` → Agent). + * `src/session/**` → Session, `src/agent/**` → Agent). Files under + * `src/features/**` register into whichever scope their services are + * materialized in, so the scope is resolved from the register-call + * receiver's type (`IAgentStateService` → Agent, …). * A key that is defined but never registered is excluded. * * The output is a self-contained `.d.ts`: each key's value type is the @@ -41,9 +44,12 @@ import { SyntaxKind, ts, type Identifier, + type PropertyAccessExpression, type Signature, + type SourceFile, type Symbol as MorphSymbol, type Type as MorphType, + type TypeChecker, type VariableDeclaration, } from 'ts-morph'; @@ -107,6 +113,35 @@ function scopeDirOf(file: string): ScopeDir | undefined { return SCOPES.some((scope) => scope.dir === first) ? (first as ScopeDir) : undefined; } +function isFeaturesFile(file: string): boolean { + return relative(SRC, file).split(/[\\/]/)[0] === 'features'; +} + +/** Feature files register into the scope of their materialized services — resolve it from the register-call receiver's state-service type. */ +const FEATURES_RECEIVER_SCOPE: Readonly> = { + IAppStateService: 'app', + IWorkspaceStateService: 'workspace', + ISessionStateService: 'session', + IAgentStateService: 'agent', +}; + +function featuresRegisterScope( + expression: PropertyAccessExpression, + checker: TypeChecker, + sf: SourceFile, +): ScopeDir { + const typeName = checker.getTypeAtLocation(expression.getExpression()).getSymbol()?.getName(); + const scope = typeName === undefined ? undefined : FEATURES_RECEIVER_SCOPE[typeName]; + if (scope === undefined) { + throw new Error( + `[gen-state-manifest] cannot resolve the state-service scope of '${expression.getText()}' ` + + `in ${srcRelative(sf.getFilePath())} — register through an ` + + 'I{App,Workspace,Session,Agent}StateService-typed member.', + ); + } + return scope; +} + /** Package-root-relative posix path (used in index/comment columns). */ function srcRelative(file: string): string { return relative(PKG, file).split('\\').join('/'); @@ -140,7 +175,8 @@ function stableSymbolKey(key: string): string { function collectKeyDefs(project: Project): Map { const defs = new Map(); for (const sf of project.getSourceFiles()) { - if (scopeDirOf(sf.getFilePath()) === undefined) continue; + const filePath = sf.getFilePath(); + if (scopeDirOf(filePath) === undefined && !isFeaturesFile(filePath)) continue; for (const statement of sf.getVariableStatements()) { for (const declaration of statement.getDeclarations()) { const initializer = declaration.getInitializer(); @@ -181,11 +217,13 @@ function collectRegistrations( project: Project, defs: ReadonlyMap, ): Registration[] { + const checker = project.getTypeChecker(); const registrations: Registration[] = []; const seen = new Set(); for (const sf of project.getSourceFiles()) { - const scope = scopeDirOf(sf.getFilePath()); - if (scope === undefined) continue; + const fileScope = scopeDirOf(sf.getFilePath()); + const featuresFile = isFeaturesFile(sf.getFilePath()); + if (fileScope === undefined && !featuresFile) continue; for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) { const expression = call.getExpression(); if (!Node.isPropertyAccessExpression(expression) || expression.getName() !== 'register') { @@ -196,6 +234,7 @@ function collectRegistrations( if (args.length !== 1 || arg === undefined || !Node.isIdentifier(arg)) continue; const def = resolveKeyDef(arg, defs); if (def === undefined) continue; + const scope = fileScope ?? featuresRegisterScope(expression, checker, sf); if (!def.exported) { throw new Error( `[gen-state-manifest] state key '${def.keyName}' (${srcRelative(def.file)}) is ` + diff --git a/packages/agent-core-v2/src/_base/contribution/registry.ts b/packages/agent-core-v2/src/_base/contribution/registry.ts index f86ff7934fd..5db87eb20de 100644 --- a/packages/agent-core-v2/src/_base/contribution/registry.ts +++ b/packages/agent-core-v2/src/_base/contribution/registry.ts @@ -27,6 +27,7 @@ export interface RegisterContributionOptions { readonly priority?: number; } +// NOTE: stays Disposable — its own 'get' collides with the Fiber export class ContributionRegistry extends Disposable { private readonly registrations = new Map>(); private readonly onDidChangeEmitter = this._register(new Emitter()); diff --git a/packages/agent-core-v2/src/_base/di/cascadeEngine.ts b/packages/agent-core-v2/src/_base/di/cascadeEngine.ts index 34d7d9d6bed..472100d1ff4 100644 --- a/packages/agent-core-v2/src/_base/di/cascadeEngine.ts +++ b/packages/agent-core-v2/src/_base/di/cascadeEngine.ts @@ -29,6 +29,7 @@ */ import { onUnexpectedError } from '../errors/unexpectedError'; +import { Emitter, type Event } from '../event'; import { isPromiseLike } from '../lifecycle/disposer'; import type { SyncDescriptor } from './descriptors'; import { @@ -43,7 +44,6 @@ export type UnitState = 'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Fai export type CascadeAction = 'provide' | 'unprovide' | 'update'; -/** Eager units activate as soon as their dependencies are satisfied; on-demand units wait for their first resolution (but cascade-torn units always rebuild). */ export type UnitActivation = 'eager' | 'ondemand'; export interface CascadeChange { @@ -51,10 +51,9 @@ export interface CascadeChange { // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly token: ServiceIdentifier; readonly descriptor?: SyncDescriptor; - /** Pre-materialized value for a `provide` change (mutually exclusive with `descriptor`). */ readonly instance?: unknown; - readonly pinned?: boolean; readonly activation?: UnitActivation; + readonly config?: unknown; readonly reason: string; } @@ -71,40 +70,40 @@ export interface CascadeHistoryEntry { readonly durationMs: number; } +export interface UnitSnapshot { + readonly token: string; + readonly state: UnitState; + readonly error?: string; + readonly everActive: boolean; + readonly inFlight: boolean; +} + +export interface UnitStateChange { + readonly token: string; + readonly state: UnitState; + readonly error?: string; +} + export interface CascadeEngineOptions { - /** - * Abort hook (§4.5): invoked at transaction step ② with the contagion set. - * A returned promise is awaited up to `abortWaitMs` (best-effort), then the - * cascade proceeds anyway (forced teardown). - */ onWillCascade?: ( affected: readonly ScopedToken[], reason: string, ) => void | Promise; - /** Bounded wait for in-flight work to abort (default 5000ms). */ readonly abortWaitMs?: number; - /** Suspended-resolution timeout (default 30000ms). */ readonly resolveTimeoutMs?: number; - /** History ring capacity (default 200). */ readonly historyCapacity?: number; readonly now?: () => number; } -/** Container operations one engine drives for its own scope's units. */ export interface CascadeHost { - /** Registered in this container or an ancestor. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any isRegistered(token: ServiceIdentifier): boolean; - /** The container owning this token in this container's chain, if any. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any ownerScopeOf(token: ServiceIdentifier): object | undefined; - /** Has a live materialized instance in this container. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any isMaterialized(token: ServiceIdentifier): boolean; - /** Create + cache the instance (throws on construction failure). */ // eslint-disable-next-line @typescript-eslint/no-explicit-any materialize(token: ServiceIdentifier): unknown; - /** Tear the live instance down and reset the entry to its recipe. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any retire(token: ServiceIdentifier): void | Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -112,33 +111,28 @@ export interface CascadeHost { // eslint-disable-next-line @typescript-eslint/no-explicit-any token: ServiceIdentifier, descriptor: SyncDescriptor, - pinned: boolean | undefined, + config: unknown, ): number; - /** Register a pre-materialized instance (a new generation). */ // eslint-disable-next-line @typescript-eslint/no-explicit-any applyProvideInstance( // eslint-disable-next-line @typescript-eslint/no-explicit-any token: ServiceIdentifier, instance: unknown, - pinned: boolean | undefined, + config: unknown, ): number; // eslint-disable-next-line @typescript-eslint/no-explicit-any applyUnprovide(token: ServiceIdentifier): void; - /** The unit's recipe: the pending descriptor, or the retained one of a live instance. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any recipeOf(token: ServiceIdentifier): SyncDescriptor | undefined; - /** Constructor-declared (instance-edge) dependencies of a recipe. */ dependenciesOf( recipe: SyncDescriptor, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Array>; } -/** Structural handle a scoped token's `scope` provides to the engine. */ export interface CascadeScopeHandle { readonly cascade: CascadeEngine; readonly cascadeDisposed: boolean; - /** Distance from the tree root (root = 0); parents always sort shallower. */ readonly cascadeDepth: number; } @@ -146,7 +140,6 @@ interface UnitRecord { state: UnitState; error?: unknown; activation: UnitActivation; - /** True once the unit has had a live instance (torn-down units always rebuild). */ everActive: boolean; } @@ -161,29 +154,36 @@ const DEFAULT_ABORT_WAIT_MS = 5000; const DEFAULT_RESOLVE_TIMEOUT_MS = 30000; const DEFAULT_HISTORY_CAPACITY = 200; -/** - * Tree-wide cascade runtime, owned by the root container and shared by every - * engine of the scope tree: the persistent graph, the serialized request - * queue, the in-flight contagion set of the running transaction, and the - * settle waiters for suspended resolutions. - */ export class CascadeTree { readonly graph: DependencyGraph; readonly queue: QueuedRequest[] = []; - /** Every live engine of the tree (engines register at construction). */ readonly engines = new Set(); running = false; - /** The scope orchestrating the running transaction (for label rendering). */ orchestrator: object | undefined; private readonly _inFlight = new PairIndex(); private _settleWaiters: Array<() => void> = []; private readonly _scopeSeq = new Map(); private _nextScopeSeq = 0; + private readonly _onDidAddEngine = new Emitter(); + readonly onDidAddEngine: Event = this._onDidAddEngine.event; + private readonly _onDidRemoveEngine = new Emitter(); + readonly onDidRemoveEngine: Event = this._onDidRemoveEngine.event; constructor(graph: DependencyGraph) { this.graph = graph; } + addEngine(engine: CascadeEngine): void { + this.engines.add(engine); + this._onDidAddEngine.fire(engine); + } + + removeEngine(engine: CascadeEngine): void { + if (this.engines.delete(engine)) { + this._onDidRemoveEngine.fire(engine); + } + } + inFlightSet(ref: ScopedToken, on: boolean): void { if (on) { this._inFlight.set(ref.scope, ref.token, true); @@ -202,7 +202,6 @@ export class CascadeTree { } } - /** Stable per-scope sequence used to render cross-scope labels (`#n:token`). */ seqOf(scope: object): number { let seq = this._scopeSeq.get(scope); if (seq === undefined) { @@ -230,7 +229,6 @@ export class CascadeEngine { ServiceIdentifier, UnitRecord >(); - /** missing dependency token → waiting unit tokens (§5.5 wake intersection). */ private readonly _pendingIndex = new Map< // eslint-disable-next-line @typescript-eslint/no-explicit-any ServiceIdentifier, @@ -240,6 +238,10 @@ export class CascadeEngine { private readonly _history: CascadeHistoryEntry[] = []; private _historySeq = 0; private _disposed = false; + private readonly _onDidChangeUnitState = new Emitter(); + readonly onDidChangeUnitState: Event = this._onDidChangeUnitState.event; + private readonly _onDidCascade = new Emitter(); + readonly onDidCascade: Event = this._onDidCascade.event; constructor( private readonly _host: CascadeHost, @@ -247,28 +249,34 @@ export class CascadeEngine { private readonly _tree: CascadeTree, private _options: CascadeEngineOptions = {}, ) { - this._tree.engines.add(this); + this._tree.addEngine(this); } - /** Merge new options (tests configure hooks/timeouts per scenario). */ configure(options: CascadeEngineOptions): void { this._options = { ...this._options, ...options }; } - /** State of an engine-tracked unit; undefined for tokens the engine never saw. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - unitState(token: ServiceIdentifier): UnitState | undefined { + stateOf(token: ServiceIdentifier): UnitState | undefined { return this._units.get(token)?.state; } - /** The sticky failure of a Failed unit. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - unitFailure(token: ServiceIdentifier): unknown { + activationOf(token: ServiceIdentifier): UnitActivation | undefined { + return this._units.get(token)?.activation; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + materializable(token: ServiceIdentifier): boolean { + return this._host.recipeOf(token) !== undefined; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + failureOf(token: ServiceIdentifier): unknown { const unit = this._units.get(token); return unit?.state === 'Failed' ? unit.error : undefined; } - /** True while the scoped token sits inside the running transaction's contagion set. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any isInFlight(token: ServiceIdentifier): boolean { const owner = this._host.ownerScopeOf(token) ?? this._scope; @@ -279,7 +287,6 @@ export class CascadeEngine { return this._history; } - /** Waiting-area snapshot for introspection: waiting unit → missing tokens. */ pendingSnapshot(): ReadonlyMap { const snapshot = new Map(); for (const [token, unit] of this._units) { @@ -292,12 +299,20 @@ export class CascadeEngine { return snapshot; } - /** - * Queue a change on the tree. Queued requests merge (deduped by - * scope+token) into the next transaction. The returned promise settles when - * the transaction that applied the change completes; with the sync fast - * path the change is already applied when `submit` returns. - */ + unitsSnapshot(): UnitSnapshot[] { + const snapshot: UnitSnapshot[] = []; + for (const [token, unit] of this._units) { + snapshot.push({ + token: token.toString(), + state: unit.state, + error: unit.error === undefined ? undefined : serializeError(unit.error), + everActive: unit.everActive, + inFlight: this.isInFlight(token), + }); + } + return snapshot; + } + submit(change: CascadeChange): Promise { if (this._disposed) { return Promise.resolve(); @@ -308,7 +323,18 @@ export class CascadeEngine { }); } - /** Explicit reload of a unit (D5): a replace-self transaction. */ + submitAll(changes: readonly CascadeChange[]): Promise { + if (this._disposed || changes.length === 0) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + for (const change of changes) { + this._tree.queue.push({ engine: this, change, resolve, reject }); + } + this._pump(); + }); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any update(token: ServiceIdentifier, reason?: string): Promise { return this.submit({ @@ -318,7 +344,6 @@ export class CascadeEngine { }); } - /** Settles when no transaction is running and the tree queue is empty. */ whenIdle(): Promise { if (!this._tree.running && this._tree.queue.length === 0) { return Promise.resolve(); @@ -330,11 +355,6 @@ export class CascadeEngine { }); } - /** - * Async resolution path (§4.3): a token inside the running transaction's - * contagion set suspends until the transaction completes (then resolves); - * anything else resolves immediately. Times out with `CascadeConflictError`. - */ resolveWhenAvailable( // eslint-disable-next-line @typescript-eslint/no-explicit-any token: ServiceIdentifier, @@ -367,24 +387,20 @@ export class CascadeEngine { }); } - /** Container-side notification: a unit was materialized outside activation. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any observedMaterialization(token: ServiceIdentifier): void { const unit = this._units.get(token); if (unit !== undefined && unit.state === 'Pending') { - unit.state = 'Active'; unit.everActive = true; - unit.error = undefined; + this._setUnitState(token, unit, 'Active', undefined); } } dispose(): void { this._disposed = true; - this._tree.engines.delete(this); + this._tree.removeEngine(this); this._units.clear(); this._pendingIndex.clear(); - // Only this engine's queued requests are withdrawn; the tree queue keeps - // serving the other scopes. const remaining: QueuedRequest[] = []; for (const request of this._tree.queue) { if (request.engine === this) { @@ -395,16 +411,11 @@ export class CascadeEngine { } this._tree.queue.length = 0; this._tree.queue.push(...remaining); + this._onDidChangeUnitState.dispose(); + this._onDidCascade.dispose(); } - // ------------------------------------------------------ orchestrator ops - /** - * Orchestrator-driven: tear down one of THIS engine's live units - * (Active → Unloading → Pending). Idempotently skipped when this engine's - * scope died mid-transaction. `parkAsPending` is false for the unprovide - * target itself (removal is not a state). - */ _teardownForCascade( // eslint-disable-next-line @typescript-eslint/no-explicit-any token: ServiceIdentifier, @@ -414,18 +425,15 @@ export class CascadeEngine { if (this._disposed || !this._host.isMaterialized(token)) { return undefined; } - this._unitFor(token).state = 'Unloading'; + this._setUnitState(token, this._unitFor(token), 'Unloading', undefined); const out = this._host.retire(token); tornDown.push(this._label({ scope: this._scope, token })); if (parkAsPending) { - // Dependents and replaced units go back to the waiting area with their - // recipe retained; they were live, so they always rebuild. this._markPending(token, undefined, true); } return out; } - /** Orchestrator-driven: recheck THIS engine's waiting area (transaction ⑤). */ _recheckForCascade(rebuilt: string[], failed: string[]): void { if (this._disposed) { return; @@ -433,7 +441,6 @@ export class CascadeEngine { this._recheckPending(rebuilt, failed); } - /** Orchestrator-driven: apply THIS engine's own change (transaction ④). */ _applyChangeForCascade(change: CascadeChange): void { if (this._disposed) { return; @@ -441,16 +448,13 @@ export class CascadeEngine { switch (change.action) { case 'provide': if (change.descriptor !== undefined) { - this._host.applyProvide(change.token, change.descriptor, change.pinned); + this._host.applyProvide(change.token, change.descriptor, change.config); this._markPending(change.token, change.activation ?? 'eager', false); } else { - // Pre-materialized instance: a new generation that is already - // live — no activation to schedule, dependents rebuild below. - this._host.applyProvideInstance(change.token, change.instance, change.pinned); + this._host.applyProvideInstance(change.token, change.instance, change.config); const unit = this._unitFor(change.token); - unit.state = 'Active'; unit.everActive = true; - unit.error = undefined; + this._setUnitState(change.token, unit, 'Active', undefined); } break; case 'unprovide': @@ -458,13 +462,11 @@ export class CascadeEngine { this._units.delete(change.token); break; case 'update': - // Reload of a live-or-failed unit: it rebuilds like a torn one. this._markPending(change.token, undefined, true); break; } } - // ------------------------------------------------------------------ queue private _pump(): void { if (this._tree.running) { @@ -487,7 +489,6 @@ export class CascadeEngine { this._tree.fireSettleWaiters(); this._pump(); }; - // The first request's engine orchestrates the merged transaction. const orchestrator = batch[0]!.engine; try { const out = orchestrator._transact(batch); @@ -504,13 +505,11 @@ export class CascadeEngine { } } - // ------------------------------------------------------------ transaction private _transact(batch: QueuedRequest[]): void | Promise { const changes = mergeBatch(batch); const started = this._options.now?.() ?? Date.now(); const reason = changes.map(({ change }) => change.reason).join('; '); - // ① contagion set from the tree-global graph (includes the changed tokens). const affected = this._tree.graph.affectedSet( changes.map(({ engine, change }) => ({ scope: engine._scope, token: change.token })), ); @@ -531,7 +530,6 @@ export class CascadeEngine { throw error; } if (isPromiseLike(out)) { - // The contagion set stays in flight until the transaction settles. return Promise.resolve(out).then(clear, (error: unknown) => { clear(); throw error; @@ -540,7 +538,6 @@ export class CascadeEngine { clear(); return undefined; }; - // ② WillCascade broadcast → abort hook (bounded wait, best-effort). const wait = this._waitForAbort(affected, reason); if (isPromiseLike(wait)) { return Promise.resolve(wait).then(complete); @@ -571,7 +568,6 @@ export class CascadeEngine { Promise.resolve(out).then( () => ({ waited: true, timedOut: false }), (error: unknown) => { - // Best-effort (§4.5): an async abort failure is logged, never a veto. onUnexpectedError(error); return { waited: true, timedOut: false }; }, @@ -593,8 +589,6 @@ export class CascadeEngine { const rebuilt: string[] = []; const failed: string[] = []; - // ③ tear the contagion set down in global reverse topological order, - // serially; each scope's engine executes its own units. const teardownOrder = this._tree.graph.reverseTopoOrder(affected); let index = 0; const step = (): void | Promise => { @@ -603,9 +597,8 @@ export class CascadeEngine { index += 1; const owner = engineOf(ref); if (owner === undefined || owner._disposed) { - continue; // descendant scope died mid-transaction: skip idempotently + continue; } - // The unprovide target itself is removed in ④, not parked as Pending. const removed = changes.some( ({ engine, change }) => engine === owner && change.token === ref.token && change.action === 'unprovide', @@ -619,14 +612,9 @@ export class CascadeEngine { }; const after = (): void => { - // ④ apply each change in its own scope. for (const { engine, change } of changes) { engine._applyChangeForCascade(change); } - // ⑤ recheck the waiting area across the tree: every live engine, in - // shallow-first order (dependencies only point upward, so depth order - // is a valid global topological order across scopes), iterated to a - // fixpoint — activating one unit may satisfy the next. const enginesInOrder = [...this._tree.engines] .filter((engine) => !engine._disposed) .sort((a, b) => a._scope.cascadeDepth - b._scope.cascadeDepth); @@ -643,7 +631,6 @@ export class CascadeEngine { break; } } - // ⑥ history ring (orchestrator-local). this._pushHistory({ seq: ++this._historySeq, reason, @@ -669,7 +656,6 @@ export class CascadeEngine { return undefined; } - // ------------------------------------------------------------------ units // eslint-disable-next-line @typescript-eslint/no-explicit-any private _unitFor(token: ServiceIdentifier): UnitRecord { @@ -677,11 +663,30 @@ export class CascadeEngine { if (unit === undefined) { unit = { state: 'Pending', activation: 'eager', everActive: false }; this._units.set(token, unit); + this._onDidChangeUnitState.fire({ token: token.toString(), state: 'Pending' }); } return unit; } - /** Back to the waiting area; `everActive` marks a cascade-torn unit (always rebuilds). */ + private _setUnitState( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + token: ServiceIdentifier, + unit: UnitRecord, + state: UnitState, + error: unknown, + ): void { + const changed = unit.state !== state; + unit.state = state; + unit.error = error; + if (changed) { + this._onDidChangeUnitState.fire({ + token: token.toString(), + state, + error: error === undefined ? undefined : serializeError(error), + }); + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any private _markPending( token: ServiceIdentifier, @@ -689,8 +694,7 @@ export class CascadeEngine { everActive?: boolean, ): void { const unit = this._unitFor(token); - unit.state = 'Pending'; - unit.error = undefined; + this._setUnitState(token, unit, 'Pending', undefined); if (activation !== undefined) { unit.activation = activation; } @@ -699,12 +703,6 @@ export class CascadeEngine { } } - /** - * ⑤ for one engine: rebuild the missing-token index from scratch (cheap: - * one sweep of the waiting area), then activate every satisfied unit in - * topological order, iterating to a fixpoint. Satisfaction consults the - * dependency's OWNING engine across scopes (D9). - */ private _recheckPending(rebuilt: string[], failed: string[]): void { for (;;) { this._pendingIndex.clear(); @@ -714,8 +712,6 @@ export class CascadeEngine { if (unit.state !== 'Pending') continue; const missing = this._missingDeps(token); if (missing.length === 0) { - // On-demand units that were never live wait for their first - // resolution instead of auto-activating. if (unit.everActive || unit.activation === 'eager') { satisfied.push(token); } @@ -739,15 +735,13 @@ export class CascadeEngine { for (const ref of ordered) { this._activate(ref.token, rebuilt, failed); } - // Materialization pulls descriptor dependencies transitively; sweep the - // units that became materialized as a side effect. for (const [token, unit] of this._units) { if ( (unit.state === 'Pending' || unit.state === 'Activating') && this._host.isMaterialized(token) ) { - unit.state = 'Active'; unit.everActive = true; + this._setUnitState(token, unit, 'Active', undefined); } } } @@ -756,28 +750,22 @@ export class CascadeEngine { // eslint-disable-next-line @typescript-eslint/no-explicit-any private _activate(token: ServiceIdentifier, rebuilt: string[], failed: string[]): void { const unit = this._unitFor(token); - unit.state = 'Activating'; - unit.error = undefined; + this._setUnitState(token, unit, 'Activating', undefined); try { this._host.materialize(token); - unit.state = 'Active'; unit.everActive = true; + this._setUnitState(token, unit, 'Active', undefined); rebuilt.push(this._label({ scope: this._scope, token })); } catch (error) { - // D5: Failed is sticky — no automatic retry; explicit update() reloads. - unit.state = 'Failed'; - unit.error = error; + this._setUnitState(token, unit, 'Failed', error); failed.push(this._label({ scope: this._scope, token })); } } - /** Missing dependencies of a Pending unit (empty = ready to activate). */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private _missingDeps(token: ServiceIdentifier): Array> { const recipe = this._host.recipeOf(token); if (recipe === undefined) { - // No recipe to rebuild from (e.g. a foreign-seeded instance that was - // torn down): the unit can never become satisfied on its own. return [token]; } return this._host @@ -790,15 +778,20 @@ export class CascadeEngine { if (!this._host.isRegistered(dep)) { return false; } - // The dependency's state is owned by the engine of the scope that - // registered it (an ancestor's engine for a cross-scope injection). const owner = this._host.ownerScopeOf(dep); const engine = owner === undefined ? undefined : engineOf({ scope: owner, token: dep }); - const state = engine?.unitState(dep); - return state === undefined || state === 'Active'; + const state = engine?.stateOf(dep); + if (state === undefined || state === 'Active') { + return true; + } + return ( + state === 'Pending' && + engine !== undefined && + engine.activationOf(dep) === 'ondemand' && + engine.materializable(dep) + ); } - /** Render a scoped token for history: plain for the orchestrator's scope, `#n:token` for others. */ private _label(ref: ScopedToken): string { if (ref.scope === this._tree.orchestrator) { return ref.token.toString(); @@ -806,7 +799,6 @@ export class CascadeEngine { return `#${this._tree.seqOf(ref.scope)}:${ref.token.toString()}`; } - /** Merge-queue dedupe key for this engine's scope. */ _mergeKey(): string { return String(this._tree.seqOf(this._scope)); } @@ -817,15 +809,18 @@ export class CascadeEngine { if (this._history.length > capacity) { this._history.splice(0, this._history.length - capacity); } + this._onDidCascade.fire(entry); } } -/** Resolve a scoped token to its scope's engine (structural handle). */ +function serializeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function engineOf(ref: ScopedToken): CascadeEngine | undefined { return (ref.scope as Partial).cascade; } -/** Queued requests merge: one change per scope+token (latest wins), order preserved. */ function mergeBatch(batch: QueuedRequest[]): QueuedRequest[] { const byKey = new Map(); for (const request of batch) { diff --git a/packages/agent-core-v2/src/_base/di/collection.ts b/packages/agent-core-v2/src/_base/di/collection.ts new file mode 100644 index 00000000000..4f69dedbba9 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/collection.ts @@ -0,0 +1,240 @@ +/** + * `di` domain — collection tokens, live views, and the tree-global record + * store (L3, D12). + * + * A contribution point is a `collection(name)` token; contributing is + * `this.provide(token, value)` — no registry API. Records physically live + * under the provider's scope and are visible to the provider's ancestors AND + * descendants (never to sibling subtrees): capabilities flow upward, and a + * fold at any tier also sees what its own subtree contributed. Every record + * carries the provider unit's name and scope path so folds can group/filter + * by source. Record lifetime hangs on the provider's book — provider death + * withdraws the record (and scope death tears the provider's book). + * + * A fold service declares the token as a constructor parameter and receives + * a `CollectionView`: `items`/`records` are computed live, `onDidChange` + * delivers incremental `{added, removed}` payloads. Collection edges are + * recorded in the persistent graph for introspection but never join a + * cascade contagion set — a fold refolds incrementally instead of being + * rebuilt. + */ + +import { Emitter, type Event } from '../event'; +import type { Ledger } from '../lifecycle/ledger'; +import { storeCustomDependency, type ServiceIdentifier } from './instantiation'; + +export interface CollectionToken { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (target: any, key: string | symbol | undefined, index: number): void; + + readonly name: string; + + readonly __t?: T; + + toString(): string; +} + +const _collectionTokens = new Map>(); +const _collectionTokenSet = new WeakSet(); + +export function collection(name: string): CollectionToken { + const existing = _collectionTokens.get(name); + if (existing !== undefined) { + return existing as CollectionToken; + } + const token = function collectionDecorator( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + target: any, + _key: string | symbol | undefined, + index: number, + ): void { + if (arguments.length !== 3) { + throw new Error('@CollectionToken-decorator can only be used to decorate a parameter'); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + storeCustomDependency(token as unknown as ServiceIdentifier, 'collection', target, index); + } as unknown as CollectionToken; + Object.defineProperty(token, 'toString', { + value: () => `collection:${name}`, + enumerable: false, + }); + Object.defineProperty(token, 'name', { value: name, enumerable: false, configurable: true }); + _collectionTokens.set(name, token as CollectionToken); + _collectionTokenSet.add(token); + return token; +} + +export function isCollectionToken(thing: unknown): thing is CollectionToken { + return typeof thing === 'function' && _collectionTokenSet.has(thing); +} + +export interface CollectionRecord { + readonly value: T; + readonly providerName: string; + readonly scopePath: string; +} + +export interface CollectionChange { + readonly added: readonly T[]; + readonly removed: readonly T[]; +} + +export interface CollectionView { + readonly items: readonly T[]; + readonly records: readonly CollectionRecord[]; + readonly onDidChange: Event>; +} + +interface StoredRecord { + readonly id: number; + readonly value: unknown; + readonly providerName: string; + readonly scopePath: string; + readonly provider: object; + readonly providerBook: Ledger; +} + +export type { StoredRecord }; + +export class CollectionStore { + private readonly _records = new Map< + CollectionToken, + Map + >(); + private readonly _views = new Set>(); + private _nextId = 0; + + constructor(private readonly _parentOf: (container: object) => object | undefined) {} + + addRecord( + token: CollectionToken, + provider: object, + providerName: string, + scopePath: string, + providerBook: Ledger, + value: T, + ): () => void { + let records = this._records.get(token as CollectionToken); + if (records === undefined) { + records = new Map(); + this._records.set(token as CollectionToken, records); + } + const record: StoredRecord = { + id: ++this._nextId, + value, + providerName, + scopePath, + provider, + providerBook, + }; + records.set(record.id, record); + const fire = (view: CollectionViewImpl, kind: 'added' | 'removed'): void => { + if (view.consumer === provider || this._isRelated(view.consumer, provider)) { + view._fireDelta(kind, [record]); + } + }; + for (const view of this._views) { + if (view.token === (token as unknown as CollectionToken)) { + fire(view, 'added'); + } + } + return () => { + if (!records.delete(record.id)) { + return; + } + for (const view of this._views) { + if (view.token === (token as unknown as CollectionToken)) { + fire(view, 'removed'); + } + } + }; + } + + createView(token: CollectionToken, consumer: object): CollectionViewImpl { + const view = new CollectionViewImpl(this, token, consumer); + this._views.add(view as unknown as CollectionViewImpl); + return view; + } + + dropView(view: CollectionViewImpl): void { + this._views.delete(view); + } + + recordsFor(token: CollectionToken, consumer: object): CollectionRecord[] { + const records = this._records.get(token as CollectionToken); + if (records === undefined) { + return []; + } + const out: CollectionRecord[] = []; + for (const record of records.values()) { + if (record.provider === consumer || this._isRelated(consumer, record.provider)) { + out.push({ + value: record.value as T, + providerName: record.providerName, + scopePath: record.scopePath, + }); + } + } + return out; + } + + storedRecordsFor( + token: CollectionToken, + consumer: object, + ): readonly StoredRecord[] { + const records = this._records.get(token); + if (records === undefined) { + return []; + } + const out: StoredRecord[] = []; + for (const record of records.values()) { + if (record.provider === consumer || this._isRelated(consumer, record.provider)) { + out.push(record); + } + } + return out; + } + + private _isRelated(consumer: object, provider: object): boolean { + for (let c: object | undefined = consumer; c !== undefined; c = this._parentOf(c)) { + if (c === provider) return true; + } + for (let p: object | undefined = provider; p !== undefined; p = this._parentOf(p)) { + if (p === consumer) return true; + } + return false; + } +} + +export class CollectionViewImpl implements CollectionView { + private readonly _onDidChange = new Emitter>(); + readonly onDidChange: Event> = this._onDidChange.event; + + constructor( + private readonly _store: CollectionStore, + readonly token: CollectionToken, + readonly consumer: object, + ) {} + + get records(): CollectionRecord[] { + return this._store.recordsFor(this.token, this.consumer); + } + + get items(): readonly T[] { + return this.records.map((record) => record.value); + } + + _fireDelta(kind: 'added' | 'removed', records: readonly StoredRecord[]): void { + const values = records.map((record) => record.value as T); + this._onDidChange.fire( + kind === 'added' + ? { added: values, removed: [] } + : { added: [], removed: values }, + ); + } + + dispose(): void { + this._store.dropView(this as unknown as CollectionViewImpl); + this._onDidChange.dispose(); + } +} diff --git a/packages/agent-core-v2/src/_base/di/dependencyGraph.ts b/packages/agent-core-v2/src/_base/di/dependencyGraph.ts index b52c1439138..bfc936e9d3c 100644 --- a/packages/agent-core-v2/src/_base/di/dependencyGraph.ts +++ b/packages/agent-core-v2/src/_base/di/dependencyGraph.ts @@ -15,9 +15,7 @@ import type { ServiceIdentifier } from './instantiation'; -/** A token as seen from the tree: the owning container plus the identifier. */ export interface ScopedToken { - /** The container whose collection owns the registration. */ readonly scope: object; // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly token: ServiceIdentifier; @@ -31,7 +29,6 @@ export interface DependencyEdge { readonly kind: DependencyEdgeKind; } -/** Nested scope → token maps, so scoped tokens stay structural (no interning). */ export class PairIndex { private readonly _map = new Map { } export class DependencyGraph { - /** live instance → its scoped token */ private readonly _refByInstance = new Map(); - /** scoped token → live instance */ private readonly _instanceByRef = new PairIndex(); - /** consumer instance → (dependency scoped token → edge kind) */ private readonly _out = new Map>(); - /** dependency scoped token → (consumer instance → edge kind) */ private readonly _in = new PairIndex>(); - /** Register a materialized service instance so its edges can be tracked. */ addInstance( instance: object, scope: object, @@ -98,7 +90,6 @@ export class DependencyGraph { this._instanceByRef.set(scope, token, instance); } - /** Drop a consumer: its outbound edges and token mapping (inbound edges stay). */ removeInstance(instance: object): void { const ref = this._refByInstance.get(instance); if (ref !== undefined) { @@ -136,12 +127,6 @@ export class DependencyGraph { inbound.set(consumerInstance, kind); } - /** - * The contagion set: the changed scoped tokens plus every scoped token whose - * live instance transitively depends on them through instance edges — - * computed across the whole tree (dependents always live in the changed - * scope's subtree, since edges point child → parent). - */ affectedSet(changed: Iterable): ScopedToken[] { const seen = new PairIndex(); const queue: ScopedToken[] = []; @@ -169,7 +154,6 @@ export class DependencyGraph { return affected; } - /** Dependencies-first order over the given scoped-token subset (instance edges). */ topoOrder(tokens: Iterable): ScopedToken[] { const subset = new PairIndex(); for (const ref of tokens) { @@ -191,12 +175,10 @@ export class DependencyGraph { return ordered; } - /** Dependents-first order (teardown order) over the given scoped-token subset. */ reverseTopoOrder(tokens: Iterable): ScopedToken[] { return this.topoOrder(tokens).toReversed(); } - /** Instance-edge cycle check over the live graph; returns the cycle path or null. */ findCycle(label: (ref: ScopedToken) => string): string[] | null { const state = new PairIndex<'visiting' | 'done'>(); const path: ScopedToken[] = []; @@ -228,7 +210,6 @@ export class DependencyGraph { return null; } - /** Introspection: every live edge (both kinds), scoped on both ends. */ edges(): DependencyEdge[] { const edges: DependencyEdge[] = []; for (const [consumerInstance, out] of this._out) { @@ -248,7 +229,6 @@ export class DependencyGraph { this._in.clear(); } - /** In-subset instance-edge dependencies of a scoped token's live instance. */ private _dependenciesOf( ref: ScopedToken, subset: PairIndex | undefined, diff --git a/packages/agent-core-v2/src/_base/di/errors.ts b/packages/agent-core-v2/src/_base/di/errors.ts index d1de9d4153d..e95353c312f 100644 --- a/packages/agent-core-v2/src/_base/di/errors.ts +++ b/packages/agent-core-v2/src/_base/di/errors.ts @@ -25,12 +25,6 @@ export class CyclicDependencyError extends Error { } } -/** - * Raised when a resolution hits a token inside an in-flight cascade - * transaction's contagion set. The async resolution path suspends instead - * (see `CascadeEngine.resolveWhenAvailable`); the sync path cannot suspend, - * so it fails fast with this error. - */ export class CascadeConflictError extends Error { constructor( readonly token: string, diff --git a/packages/agent-core-v2/src/_base/di/fiber.ts b/packages/agent-core-v2/src/_base/di/fiber.ts new file mode 100644 index 00000000000..6c7220b0d77 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/fiber.ts @@ -0,0 +1,766 @@ +/** + * `di` domain — the L3 unit layer: the `Fiber` capability contract, unit + * recipes, and the construction protocol that binds them to a container. + * + * A unit recipe comes in three shapes — a class extending `Service` + * (`service.ts`), a function `(fiber, config) => cleanup`, or an object with + * `apply(fiber, config)` — carrying optional statics (`name` / `inject` / + * `Config`; `Config` is a standard-schema that must validate + * synchronously). A materialized unit receives a `Fiber` facade exposing the + * five capabilities: `provide` (token-bound units, anonymous sub-units, and + * collection records), `effect` (ledger-anchored side effects), `on` (event + * subscriptions), `get` (declared-dependency resolution) and `ref` (live + * references). Every capability returns a `FiberHandle` — a thenable that + * settles once the unit is active, and carries `update` / `dispose`. + * + * `FiberRuntime` never touches the container directly: it delegates to a + * `FiberHost` (implemented by the instantiation service) and anchors every + * teardown into the unit's `Ledger`, so provider death withdraws everything + * the unit provided. `get` is restricted to the recipe's declared + * dependencies (constructor parameters for class recipes, the `inject` + * static for function/object recipes). + * + * The construction protocol bridges class recipes and the container: the + * container pushes a `ConstructionFrame`, the `Service` base buffers + * capability calls made inside the constructor as `BufferedOp`s (answered + * with `PendingFiberHandle`s), and `bindServiceUnit` flushes the buffer + * against the freshly bound runtime once construction finishes — 构造期只写 + * 不读. `ScopeUnits(kind)` mints the per-scope-kind materialization + * collection token folded by `scopeUnits.ts`. + */ + +import type { IDisposable } from './lifecycle'; +import type { Emitter } from '../event'; +import { isPromiseLike, type EffectBody } from '../lifecycle/disposer'; +import { Ledger, type LedgerEntry } from '../lifecycle/ledger'; +import { + collection, + isCollectionToken, + type CollectionToken, + type CollectionView, +} from './collection'; +import { SyncDescriptor } from './descriptors'; +import { + isServiceIdentifier, + ScopeActivation, + _util, + type LiveRef, + type ServiceIdentifier, +} from './instantiation'; + +export enum FiberState { + Pending = 0, + Activating = 1, + Active = 2, + Unloading = 3, + Failed = 4, +} + +export interface ConfigSchema { + readonly '~standard': { + readonly validate: (value: unknown) => unknown; + }; +} + +export interface RecipeStatics { + readonly name?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly inject?: readonly ServiceIdentifier[]; + readonly Config?: ConfigSchema; +} + +export type ServiceClassRecipe = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (new (...args: any[]) => unknown) & RecipeStatics; + +export type ServiceFunctionRecipe = (( + fiber: Fiber, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config?: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +) => any) & + RecipeStatics; + +export type ServiceObjectRecipe = { + apply( + fiber: Fiber, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config?: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): any; +} & RecipeStatics; + +export type ServiceRecipe = + | ServiceClassRecipe + | ServiceFunctionRecipe + | ServiceObjectRecipe; + +export interface FiberProvideOptions { + readonly config?: unknown; + readonly activation?: ScopeActivation; +} + +export interface Fiber { + readonly name: string; + readonly state: FiberState; + readonly config: unknown; + + provide( + id: ServiceIdentifier, + recipe: ServiceClassRecipe, + opts?: FiberProvideOptions, + ): FiberHandle; + provide(id: ServiceIdentifier, instance: T): FiberHandle; + provide(recipe: ServiceRecipe, opts?: FiberProvideOptions): FiberHandle; + provide(token: CollectionToken, value: T): FiberHandle; + + effect(body: EffectBody, label?: string): FiberHandle; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string | Emitter, handler: (e: any) => void): FiberHandle; + + get(id: ServiceIdentifier): T; + ref(id: ServiceIdentifier): LiveRef; +} + +export interface FiberHandle extends PromiseLike> { + readonly name: string; + readonly state: FiberState; + readonly uid: number; + update(config?: unknown): Promise; + dispose(): Promise; +} + +export class FiberProtocolError extends Error { + constructor(detail: string) { + super(detail); + this.name = 'FiberProtocolError'; + } +} + +export class ServiceRecipeError extends Error { + constructor(detail: string) { + super(detail); + this.name = 'ServiceRecipeError'; + } +} + +export interface ConstructionFrame { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly ctor: new (...args: any[]) => any; + readonly config: unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly token: ServiceIdentifier | undefined; + readonly host: FiberHost; +} + +const _constructionStack: ConstructionFrame[] = []; + +export function pushConstructionFrame(frame: ConstructionFrame): void { + _constructionStack.push(frame); +} + +export function popConstructionFrame(): void { + _constructionStack.pop(); +} + +export function currentConstruction(): ConstructionFrame | undefined { + return _constructionStack.at(-1); +} + +export const SERVICE_MARK = Symbol('serviceUnit'); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isServiceRecipe(ctor: any): ctor is ServiceClassRecipe { + return typeof ctor === 'function' && ctor.prototype?.[SERVICE_MARK] === true; +} + +export function isClassRecipe(recipe: unknown): recipe is ServiceClassRecipe { + return ( + typeof recipe === 'function' && + Object.prototype.hasOwnProperty.call(recipe, 'prototype') + ); +} + +export type BufferedOp = (runtime: Fiber) => void; + +export interface UnitInternals { + readonly unitBook: Ledger; + takeUnitBuffer(): BufferedOp[] | null; + setUnitRuntime(runtime: Fiber): void; +} + +export interface FiberHost { + mintUid(): number; + provideToken( + id: ServiceIdentifier, + descriptor: SyncDescriptor, + options: { + readonly activation: 'eager' | 'ondemand'; + readonly config?: unknown; + }, + ): TokenProvideCore; + provideTokenInstance(id: ServiceIdentifier, instance: T): TokenProvideCore; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tokenState(id: ServiceIdentifier): string | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateToken(id: ServiceIdentifier, config: unknown, hasConfig: boolean): Promise; + resolveTokenWhenAvailable(id: ServiceIdentifier): Promise; + resolveInstance(id: ServiceIdentifier): T; + materializedInstance(id: ServiceIdentifier): T | undefined; + liveRef(id: ServiceIdentifier): LiveRef; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + recordInstanceEdge(node: object | undefined, id: ServiceIdentifier): void; + collectionView(token: CollectionToken): CollectionView; + addCollectionRecord( + token: CollectionToken, + providerName: string, + providerBook: Ledger, + value: T, + ): () => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructService(ctor: new (...args: any[]) => T, config: unknown): T; +} + +export interface TokenProvideCore { + uid(): number; + dispose(): Promise; + release(): void; +} + +export type FiberEventResolver = ( + host: FiberHost, + event: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handler: (e: any) => void, +) => IDisposable; + +let _eventResolver: FiberEventResolver | undefined; + +export function setFiberEventResolver(resolver: FiberEventResolver | undefined): void { + _eventResolver = resolver; +} + +export function bindServiceUnit(instance: UnitInternals & IDisposable, frame: ConstructionFrame): void { + const buffer = instance.takeUnitBuffer(); + if (buffer === null) { + return; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ctor = (instance as any).constructor as ServiceClassRecipe; + const runtime = new FiberRuntime( + frame.host, + instance.unitBook, + recipeName(ctor), + frame.config, + frame.token, + new Set(_util.getInstanceDependencies(ctor as unknown as _util.DI_TARGET_OBJ).map((d) => d.id)), + instance, + ); + instance.setUnitRuntime(runtime); + try { + for (const op of buffer) { + op(runtime); + } + } catch (error) { + instance.dispose(); + throw error; + } +} + +function recipeName(recipe: RecipeStatics & { name?: string }): string { + return recipe.name ?? 'anonymous'; +} + +function validateConfig(schema: ConfigSchema | undefined, config: unknown, name: string): unknown { + if (schema === undefined) { + return config; + } + const out = schema['~standard'].validate(config); + if (isPromiseLike(out)) { + throw new ServiceRecipeError(`config schema of unit '${name}' must validate synchronously`); + } + const result = out as { + readonly value?: unknown; + readonly issues?: ReadonlyArray<{ readonly message: string }>; + }; + if (result.issues !== undefined && result.issues.length > 0) { + throw new ServiceRecipeError( + `invalid config for unit '${name}': ${result.issues.map((issue) => issue.message).join('; ')}`, + ); + } + return 'value' in result ? result.value : config; +} + +function mapUnitState(state: string | undefined): FiberState { + if (state === undefined) { + return FiberState.Active; + } + switch (state) { + case 'Pending': + return FiberState.Pending; + case 'Activating': + return FiberState.Activating; + case 'Unloading': + return FiberState.Unloading; + case 'Failed': + return FiberState.Failed; + default: + return FiberState.Active; + } +} + +export class FiberRuntime implements Fiber { + constructor( + private readonly _host: FiberHost, + private readonly _book: Ledger, + readonly name: string, + readonly config: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _token: ServiceIdentifier | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _declared: ReadonlySet>, + private readonly _edgeNode: object | undefined, + ) {} + + get state(): FiberState { + if (this._token !== undefined) { + return mapUnitState(this._host.tokenState(this._token)); + } + return FiberState.Active; + } + + provide( + id: ServiceIdentifier, + recipe: ServiceClassRecipe, + opts?: FiberProvideOptions, + ): FiberHandle; + provide(id: ServiceIdentifier, instance: T): FiberHandle; + provide(recipe: ServiceRecipe, opts?: FiberProvideOptions): FiberHandle; + provide(token: CollectionToken, value: T): FiberHandle; + provide( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + first: ServiceIdentifier | ServiceRecipe | CollectionToken, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + second?: any, + third?: FiberProvideOptions, + ): FiberHandle { + if (isCollectionToken(first)) { + return this._provideRecord(first, second); + } + if (isServiceIdentifier(first)) { + if (isClassRecipe(second)) { + return this._provideToken(first, second, third); + } + if ( + typeof second === 'function' || + (second !== null && typeof second === 'object' && typeof second.apply === 'function') + ) { + throw new ServiceRecipeError( + `token-bound provide of '${String(first)}' requires a class recipe or an instance (function/object recipes are only valid for the anonymous form)`, + ); + } + return this._provideTokenInstance(first, second); + } + return this._provideAnonymous(first as ServiceRecipe, second as FiberProvideOptions | undefined); + } + + effect(body: EffectBody, label?: string): FiberHandle { + const entry = this._book.effect(body, label ?? `effect:${this.name}`); + return new BasicFiberHandle({ + name: label ?? `effect:${this.name}`, + uid: this._host.mintUid(), + state: () => (entry.disposed ? FiberState.Unloading : FiberState.Active), + update: async () => { + await entry.dispose(); + if (this._book.isActive) { + this._book.effect(body, label ?? `effect:${this.name}`); + } + }, + dispose: async () => { + await entry.dispose(); + }, + whenActive: () => Promise.resolve(), + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string | Emitter, handler: (e: any) => void): FiberHandle { + let subscription: IDisposable; + if (typeof event === 'string') { + if (_eventResolver === undefined) { + throw new FiberProtocolError( + `no event resolver registered — cannot subscribe to '${event}'`, + ); + } + subscription = _eventResolver(this._host, event, handler); + } else if (typeof event?.event === 'function') { + subscription = event.event(handler); + } else { + throw new FiberProtocolError(`unsupported event source for unit '${this.name}'`); + } + const label = typeof event === 'string' ? `on:${event}` : `on:${event.constructor?.name ?? 'emitter'}`; + const entry = this._book.register(() => { + subscription.dispose(); + }, label); + return new BasicFiberHandle({ + name: label, + uid: this._host.mintUid(), + state: () => (entry.disposed ? FiberState.Unloading : FiberState.Active), + update: async () => {}, + dispose: async () => { + await entry.dispose(); + }, + whenActive: () => Promise.resolve(), + }); + } + + get(id: ServiceIdentifier): T { + if (!this._declared.has(id)) { + throw new FiberProtocolError( + `unit '${this.name}' resolves undeclared dependency '${String(id)}' — declare it as a constructor parameter (class recipe) or in the inject static (function recipe)`, + ); + } + this._host.recordInstanceEdge(this._edgeNode, id); + return this._host.resolveInstance(id); + } + + ref(id: ServiceIdentifier): LiveRef { + return this._host.liveRef(id); + } + + private _provideToken( + id: ServiceIdentifier, + recipe: ServiceClassRecipe, + opts: FiberProvideOptions | undefined, + ): FiberHandle { + if (!isClassRecipe(recipe)) { + throw new ServiceRecipeError( + `token-bound provide of '${String(id)}' requires a class recipe (function/object recipes are only valid for the anonymous form)`, + ); + } + const name = recipe.name ?? String(id); + const config = validateConfig(recipe.Config, opts?.config, name); + const core = this._host.provideToken( + id, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new SyncDescriptor(recipe as new (...args: any[]) => T), + { + activation: opts?.activation === ScopeActivation.OnDemand ? 'ondemand' : 'eager', + config, + }, + ); + const entry = this._book.register(() => core.dispose(), `provide:${String(id)}`); + return new BasicFiberHandle({ + name, + uid: undefined, + uidOf: () => core.uid(), + state: () => mapUnitState(this._host.tokenState(id)), + update: (next) => { + const hasConfig = next !== undefined; + return this._host.updateToken( + id, + hasConfig ? validateConfig(recipe.Config, next, name) : undefined, + hasConfig, + ); + }, + dispose: async () => { + await entry.dispose(); + }, + whenActive: () => this._host.resolveTokenWhenAvailable(id).then(() => undefined), + }); + } + + private _provideTokenInstance(id: ServiceIdentifier, instance: T): FiberHandle { + const core = this._host.provideTokenInstance(id, instance); + const entry = this._book.register(() => core.dispose(), `provide:${String(id)}`); + return new BasicFiberHandle({ + name: String(id), + uid: undefined, + uidOf: () => core.uid(), + state: () => mapUnitState(this._host.tokenState(id)), + update: () => this._host.updateToken(id, undefined, false), + dispose: async () => { + await entry.dispose(); + }, + whenActive: () => this._host.resolveTokenWhenAvailable(id).then(() => undefined), + }); + } + + private _provideAnonymous(recipe: ServiceRecipe, opts: FiberProvideOptions | undefined): FiberHandle { + if (isClassRecipe(recipe)) { + return this._provideAnonymousClass(recipe, opts); + } + return this._provideFunction(recipe, opts); + } + + private _provideAnonymousClass(recipe: ServiceClassRecipe, opts: FiberProvideOptions | undefined): FiberHandle { + const name = recipeName(recipe); + let config = validateConfig(recipe.Config, opts?.config, name); + let state = FiberState.Activating; + let failure: unknown; + let instance: unknown; + let entry: LedgerEntry | undefined; + const construct = (nextConfig: unknown): void => { + state = FiberState.Activating; + instance = this._host.constructService(recipe, nextConfig); + entry = this._book.register(() => { + state = FiberState.Unloading; + (instance as Partial).dispose?.(); + }, `provide:${name}`); + state = FiberState.Active; + }; + try { + construct(config); + } catch (error) { + state = FiberState.Failed; + failure = error; + throw error; + } + for (const dependency of _util.getInstanceDependencies(recipe as unknown as _util.DI_TARGET_OBJ)) { + this._host.recordInstanceEdge(this._edgeNode, dependency.id); + } + return new BasicFiberHandle({ + name, + uid: this._host.mintUid(), + state: () => state, + update: async (next) => { + await entry?.dispose(); + entry = undefined; + try { + if (next !== undefined) { + config = validateConfig(recipe.Config, next, name); + } + construct(config); + } catch (error) { + state = FiberState.Failed; + failure = error; + throw error; + } + }, + dispose: async () => { + await entry?.dispose(); + entry = undefined; + }, + whenActive: () => (failure !== undefined ? Promise.reject(failure) : Promise.resolve()), + }); + } + + private _provideFunction(recipe: ServiceFunctionRecipe | ServiceObjectRecipe, opts: FiberProvideOptions | undefined): FiberHandle { + const name = recipeName(recipe); + const config = validateConfig(recipe.Config, opts?.config, name); + const book = new Ledger(`unit:${name}`); + const anchor = this._book.register((reason) => book.teardown(reason), `provide:${name}`); + const facade = new FiberRuntime( + this._host, + book, + name, + config, + undefined, + new Set(recipe.inject ?? []), + this._edgeNode, + ); + const run = (): void => { + let out: unknown; + if (typeof recipe === 'function') { + out = recipe(facade, config); + } else { + out = recipe.apply(facade, config); + } + book.effect((() => out) as EffectBody, `effect:${name}`); + }; + try { + run(); + } catch (error) { + void book.teardown('unload'); + anchor.release(); + throw error; + } + return new BasicFiberHandle({ + name, + uid: this._host.mintUid(), + state: () => (anchor.disposed ? FiberState.Unloading : FiberState.Active), + update: async () => { + await book.clear('unload'); + try { + run(); + } catch (error) { + void book.teardown('unload'); + anchor.release(); + throw error; + } + }, + dispose: async () => { + await anchor.dispose(); + }, + whenActive: () => Promise.resolve(), + }); + } + + private _provideRecord(token: CollectionToken, value: T): FiberHandle { + const remove = this._host.addCollectionRecord(token, this.name, this._book, value); + const entry = this._book.register(() => { + remove(); + }, `provide:${token.name}`); + return new BasicFiberHandle({ + name: `${this.name}→${token.name}`, + uid: this._host.mintUid(), + state: () => (entry.disposed ? FiberState.Unloading : FiberState.Active), + update: async () => {}, + dispose: async () => { + await entry.dispose(); + }, + whenActive: () => Promise.resolve(), + }); + } +} + +interface BasicHandleParts { + readonly name: string; + readonly uid?: number; + readonly uidOf?: () => number; + readonly state: () => FiberState; + readonly update: (config: unknown) => Promise; + readonly dispose: () => Promise; + readonly whenActive: () => Promise; +} + +class BasicFiberHandle implements FiberHandle { + constructor(private readonly _parts: BasicHandleParts) {} + + get name(): string { + return this._parts.name; + } + + get state(): FiberState { + return this._parts.state(); + } + + get uid(): number { + if (this._parts.uidOf !== undefined) { + return this._parts.uidOf(); + } + return this._parts.uid!; + } + + update(config?: unknown): Promise { + return this._parts.update(config); + } + + dispose(): Promise { + return this._parts.dispose(); + } + + // eslint-disable-next-line eslint-plugin-unicorn(no-thenable) + then, TResult2 = never>( + onfulfilled?: ((value: FiberHandle) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + return thenSettle(this._parts.whenActive().then(() => settledView(this)), onfulfilled, onrejected); + } +} + +interface SettledHandleView { + readonly name: string; + readonly state: FiberState; + readonly uid: number; + update(config?: unknown): Promise; + dispose(): Promise; +} + +function settledView(handle: FiberHandle): SettledHandleView { + return { + get name() { + return handle.name; + }, + get state() { + return handle.state; + }, + get uid() { + return handle.uid; + }, + update: (config?: unknown) => handle.update(config), + dispose: () => handle.dispose(), + }; +} + +function thenSettle( + settled: Promise, + onfulfilled: ((value: FiberHandle) => TResult1 | PromiseLike) | null | undefined, + onrejected: ((reason: unknown) => TResult2 | PromiseLike) | null | undefined, +): PromiseLike { + return settled.then( + onfulfilled as unknown as (value: SettledHandleView) => TResult1 | PromiseLike, + onrejected, + ); +} + +export class PendingFiberHandle implements FiberHandle { + private _real: FiberHandle | undefined; + private _disposed = false; + + constructor(private readonly _pendingName: string) {} + + attach(real: FiberHandle): void { + if (this._disposed) { + void real.dispose(); + return; + } + this._real = real; + } + + get name(): string { + return this._real?.name ?? this._pendingName; + } + + get state(): FiberState { + return this._real?.state ?? FiberState.Activating; + } + + get uid(): number { + if (this._real === undefined) { + throw new FiberProtocolError( + `handle '${this._pendingName}' has not been flushed yet (construction protocol)`, + ); + } + return this._real.uid; + } + + update(config?: unknown): Promise { + if (this._real === undefined) { + return Promise.reject( + new FiberProtocolError(`handle '${this._pendingName}' has not been flushed yet`), + ); + } + return this._real.update(config); + } + + async dispose(): Promise { + this._disposed = true; + await this._real?.dispose(); + } + + // eslint-disable-next-line eslint-plugin-unicorn(no-thenable) — FiberHandle is a thenable by design + then, TResult2 = never>( + onfulfilled?: ((value: FiberHandle) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + if (this._real !== undefined) { + return this._real.then(onfulfilled, onrejected); + } + return thenSettle(Promise.resolve(settledView(this)), onfulfilled, onrejected); + } +} + +const _scopeUnitsTokens = new Map>(); + +export function ScopeUnits(kind: string): CollectionToken { + let token = _scopeUnitsTokens.get(kind); + if (token === undefined) { + token = collection(`scope-units:${kind}`); + _scopeUnitsTokens.set(kind, token); + } + return token; +} + +export type { EffectBody }; diff --git a/packages/agent-core-v2/src/_base/di/instantiation.ts b/packages/agent-core-v2/src/_base/di/instantiation.ts index 5e9e41cc632..f059ea7a5fc 100644 --- a/packages/agent-core-v2/src/_base/di/instantiation.ts +++ b/packages/agent-core-v2/src/_base/di/instantiation.ts @@ -4,9 +4,12 @@ import type { SyncDescriptor, SyncDescriptor0 } from './descriptors'; import type { CascadeEngine } from './cascadeEngine'; +import type { Event } from '../event'; import type { DisposableStore, IDisposable } from './lifecycle'; import type { ServiceCollection } from './serviceCollection'; +export type DependencyKind = 'instance' | 'collection' | 'ref'; + // eslint-disable-next-line @typescript-eslint/no-namespace export namespace _util { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -14,19 +17,33 @@ export namespace _util { export const DI_TARGET = '$di$target'; export const DI_DEPENDENCIES = '$di$dependencies'; + export interface ServiceDependency { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly id: ServiceIdentifier; + readonly index: number; + readonly kind: DependencyKind; + } + export function getServiceDependencies( ctor: DI_TARGET_OBJ, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): { id: ServiceIdentifier; index: number }[] { + ): ServiceDependency[] { return ctor[DI_DEPENDENCIES] || []; } + export function getInstanceDependencies( + ctor: DI_TARGET_OBJ, + ): ServiceDependency[] { + return getServiceDependencies(ctor).filter( + (dependency) => dependency.kind === 'instance', + ); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type export interface DI_TARGET_OBJ extends Function { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type [DI_TARGET]: Function; // eslint-disable-next-line @typescript-eslint/no-explicit-any - [DI_DEPENDENCIES]: { id: ServiceIdentifier; index: number }[]; + [DI_DEPENDENCIES]: { id: ServiceIdentifier; index: number; kind: DependencyKind }[]; } } @@ -57,16 +74,28 @@ function storeServiceDependency( // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type target: Function, index: number, + kind: DependencyKind = 'instance', ): void { const t = target as _util.DI_TARGET_OBJ; if (t[_util.DI_TARGET] === target) { - t[_util.DI_DEPENDENCIES].push({ id, index }); + t[_util.DI_DEPENDENCIES].push({ id, index, kind }); } else { - t[_util.DI_DEPENDENCIES] = [{ id, index }]; + t[_util.DI_DEPENDENCIES] = [{ id, index, kind }]; t[_util.DI_TARGET] = target; } } +export function storeCustomDependency( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + id: ServiceIdentifier, + kind: DependencyKind, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + target: any, + index: number, +): void { + storeServiceDependency(id, target, index, kind); +} + export function createDecorator(name: string): ServiceIdentifier { const existing = _util.serviceIds.get(name); if (existing) { @@ -95,37 +124,64 @@ export function createDecorator(name: string): ServiceIdentifier { writable: false, configurable: false, }); + Object.defineProperty(id, SERVICE_IDENTIFIER_MARK, { value: true, enumerable: false }); _util.serviceIds.set(name, id); return id; } +const SERVICE_IDENTIFIER_MARK = Symbol('serviceIdentifier'); + +export function isServiceIdentifier(thing: unknown): thing is ServiceIdentifier { + return ( + typeof thing === 'function' && + (thing as unknown as Record)[SERVICE_IDENTIFIER_MARK] === true + ); +} + export function refineServiceDecorator( serviceIdentifier: ServiceIdentifier, ): ServiceIdentifier { return serviceIdentifier as ServiceIdentifier; } +export enum ScopeActivation { + OnScopeCreated = 0, + OnDemand = 1, +} + +export interface LiveRef { + readonly current: T | undefined; + readonly onDidChange: Event; +} + +export function ref( + id: ServiceIdentifier, +): (target: object, key: string | symbol | undefined, index: number) => void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function refDecorator(target: any, _key: string | symbol | undefined, index: number): void { + if (arguments.length !== 3) { + throw new Error('@ref-decorator can only be used to decorate a parameter'); + } + storeServiceDependency(id, target, index, 'ref'); + }; +} + export interface ServicesAccessor { get(id: ServiceIdentifier): T; } export interface ProvideOptions { - /** Cascade-line metadata (L4): a pinned unit never joins a cascade. */ - readonly pinned?: boolean; - /** - * `eager` (default): the unit activates as soon as its dependencies are - * satisfied. `ondemand`: it materializes at first resolution (a cascade-torn - * unit always rebuilds regardless). - */ readonly activation?: 'eager' | 'ondemand'; + readonly config?: unknown; +} + +export interface ProvideAllEntry { + readonly id: ServiceIdentifier; + readonly descriptor: SyncDescriptor; + readonly options?: ProvideOptions; } -/** - * Handle to one `provide` registration: it is an entry in the provider's - * ledger, so disposing the handle unprovides the token. (Grows into the full - * FiberHandle — thenable / state / update — in Phase 3.) - */ export interface ProvideHandle extends IDisposable { readonly uid: number; } @@ -133,7 +189,6 @@ export interface ProvideHandle extends IDisposable { export interface IInstantiationService { readonly _serviceBrand: undefined; - /** Cascade engine (L2): per-container facade over the tree-wide orchestrated transactions. */ readonly cascade: CascadeEngine; invokeFunction( @@ -153,16 +208,12 @@ export interface IInstantiationService { ...args: GetLeadingNonServiceArgs> ): R; createChild(services: ServiceCollection, store?: DisposableStore): IInstantiationService; - /** - * Register (or replace) a token at runtime. Replacing retires the previous - * materialized instance before the new generation becomes visible. - */ provide( id: ServiceIdentifier, instanceOrDescriptor: T | SyncDescriptor, options?: ProvideOptions, ): ProvideHandle; - /** Remove a token, retiring its materialized instance. No-op when absent. */ + provideAll(entries: ReadonlyArray): void; unprovide(id: ServiceIdentifier): void; dispose(): void; } diff --git a/packages/agent-core-v2/src/_base/di/instantiationService.ts b/packages/agent-core-v2/src/_base/di/instantiationService.ts index 23503170673..5a4004291dd 100644 --- a/packages/agent-core-v2/src/_base/di/instantiationService.ts +++ b/packages/agent-core-v2/src/_base/di/instantiationService.ts @@ -4,21 +4,41 @@ import { SyncDescriptor } from './descriptors'; import { CascadeEngine, CascadeTree, type CascadeChange, type CascadeHost } from './cascadeEngine'; +import { + CollectionStore, + type CollectionToken, + type CollectionView, + type CollectionViewImpl, +} from './collection'; import { DependencyGraph } from './dependencyGraph'; import { CascadeConflictError, CyclicDependencyError } from './errors'; +import { + bindServiceUnit, + isServiceRecipe, + pushConstructionFrame, + popConstructionFrame, + type ConstructionFrame, + type FiberHost, + type TokenProvideCore, + type UnitInternals, +} from './fiber'; import { Graph } from './graph'; import { IInstantiationService as IInstantiationServiceDecorator, _util, type IInstantiationService, + type LiveRef, + type ProvideAllEntry, type ProvideHandle, type ProvideOptions, type ServiceIdentifier, type ServicesAccessor, } from './instantiation'; -import { isDisposable, type DisposableStore } from './lifecycle'; +import { isDisposable, type DisposableStore, type IDisposable } from './lifecycle'; import { onUnexpectedError } from '../errors/unexpectedError'; +import { Emitter } from '../event'; import { Ledger, type LedgerEntry } from '../lifecycle/ledger'; +import type { Disposer } from '../lifecycle/disposer'; import { ServiceCollection } from './serviceCollection'; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -116,26 +136,24 @@ export class InstantiationService implements IInstantiationService { protected readonly _ledger = new Ledger('InstantiationService'); - /** Tree-global persistent dependency graph (shared by the whole scope tree). */ get dependencyGraph(): DependencyGraph { return this._tree.graph; } private readonly _tree: CascadeTree; - /** Cascade engine (L2): one per container; tree-wide orchestrated transactions. */ readonly cascade: CascadeEngine; private _parentLedgerEntry: LedgerEntry | undefined; - /** Materialized instance → its ledger entry (for individual retirement). */ private readonly _instanceEntries = new Map(); - /** Token → the ledger entry of its latest provide (generation-guarded). */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private readonly _provideEntries = new Map, LedgerEntry>(); + private readonly _provideEntries = new Map< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ServiceIdentifier, + { readonly entry: LedgerEntry; readonly core: TokenProvideCore } + >(); - /** Set while the cascade engine itself resolves — bypasses the in-flight guard. */ private _cascadeResolving = false; protected readonly _children = new Set(); @@ -146,6 +164,18 @@ export class InstantiationService implements IInstantiationService { // eslint-disable-next-line @typescript-eslint/no-explicit-any private readonly _activeInstantiations = new Set>(); + private readonly _collectionStore: CollectionStore; + + private readonly _collectionViews = new Map< + CollectionToken, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + CollectionViewImpl + >(); + + debugLabel: string | undefined; + + private _fiberHost: FiberHost | undefined; + private _disposed = false; constructor( @@ -158,6 +188,9 @@ export class InstantiationService implements IInstantiationService { this._globalGraph = _enableTracing ? parent?._globalGraph ?? new Graph(e => e) : undefined; this._services.set(IInstantiationServiceDecorator, this); this._tree = parent?._tree ?? new CascadeTree(new DependencyGraph()); + this._collectionStore = + parent?._collectionStore ?? + new CollectionStore((container) => (container as InstantiationService)._parent); const host: CascadeHost = { isRegistered: (token) => this._getServiceInstanceOrDescriptor(token) !== undefined, ownerScopeOf: (token) => this._ownerOf(token), @@ -177,12 +210,12 @@ export class InstantiationService implements IInstantiationService { } }, retire: (token) => this._retireUnit(token), - applyProvide: (token, descriptor, pinned) => { - this._services.set(token, descriptor, { pinned }); + applyProvide: (token, descriptor, config) => { + this._services.set(token, descriptor, { config }); return this._services.uidOf(token)!; }, - applyProvideInstance: (token, instance, pinned) => { - this._services.set(token, instance, { pinned }); + applyProvideInstance: (token, instance, config) => { + this._services.set(token, instance, { config }); return this._services.uidOf(token)!; }, applyUnprovide: (token) => { @@ -194,22 +227,19 @@ export class InstantiationService implements IInstantiationService { return entry.value instanceof SyncDescriptor ? entry.value : entry.recipe; }, dependenciesOf: (recipe) => - _util.getServiceDependencies(recipe.ctor).map((dependency) => dependency.id), + _util.getInstanceDependencies(recipe.ctor).map((dependency) => dependency.id), }; this.cascade = new CascadeEngine(host, this, this._tree); } - /** Structural handle for the cascade engine's scoped tokens. */ get cascadeDisposed(): boolean { return this._disposed; } - /** Distance from the tree root (root = 0). */ get cascadeDepth(): number { return (this._parent?.cascadeDepth ?? -1) + 1; } - /** The container owning a token in this container's resolution chain. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private _ownerOf(id: ServiceIdentifier): InstantiationService | undefined { if (this._services.has(id)) { @@ -253,39 +283,96 @@ export class InstantiationService implements IInstantiationService { options?: ProvideOptions, ): ProvideHandle { this._assertNotDisposed(); + const core = this._provideCore(id, instanceOrDescriptor, options); + const entry = this._ledger.register(() => { + void core.dispose(); + }, `provide:${String(id)}`); + this._provideEntries.set(id, { entry, core }); + return { + get uid(): number { + return core.uid(); + }, + dispose: () => { + void entry.dispose(); + }, + }; + } + + provideAll(entries: ReadonlyArray): void { + this._assertNotDisposed(); + const changes: CascadeChange[] = []; + for (const { id, descriptor, options } of entries) { + this._releaseProvideEntry(id); + changes.push({ + action: 'provide', + token: id, + descriptor, + activation: options?.activation, + config: options?.config, + reason: `provide ${String(id)}`, + }); + } + this.cascade.submitAll(changes).catch(onUnexpectedError); + for (const { id } of entries) { + const uid = this._services.uidOf(id); + if (uid === undefined) { + continue; + } + const entry = this._ledger.register(() => { + if (this._services.uidOf(id) === uid) { + void this._unprovideCore(id); + } + }, `provide:${String(id)}`); + this._provideEntries.set(id, { + entry, + core: { + uid: () => uid, + dispose: () => { + if (this._services.uidOf(id) === uid) { + return this._unprovideCore(id); + } + return Promise.resolve(); + }, + release: () => {}, + }, + }); + } + } + + private _provideCore( + id: ServiceIdentifier, + instanceOrDescriptor: T | SyncDescriptor, + options?: ProvideOptions, + ): TokenProvideCore { this._releaseProvideEntry(id); if ( !(instanceOrDescriptor instanceof SyncDescriptor) && this._services.get(id) === instanceOrDescriptor ) { - // Re-affirming the very instance already materialized under this token: - // refresh the registration, no retirement, no cascade. - this._services.set(id, instanceOrDescriptor, { pinned: options?.pinned }); + this._services.set(id, instanceOrDescriptor); const uid = this._services.uidOf(id)!; - const entry = this._ledger.register(() => { - if (this._services.uidOf(id) === uid) { - this.unprovide(id); - } - }, `provide:${String(id)}`); - this._provideEntries.set(id, entry); return { - uid, + uid: () => uid, dispose: () => { - void entry.dispose(); + if (this._services.uidOf(id) === uid) { + return this._unprovideCore(id); + } + return Promise.resolve(); }, + release: () => {}, }; } - // Everything else — a recipe or a replacing instance — is one cascade - // transaction, so live dependents are torn down and rebuilt (D1/D4). const beforeUid = this._services.uidOf(id); let appliedUid: number | undefined; - const noteApplied = (): void => { - const uid = this._services.uidOf(id); - if (uid !== undefined && uid !== beforeUid) { - appliedUid = uid; + const availability = this._services.onDidChange(id, ({ newUid }) => { + if (newUid !== undefined && newUid !== beforeUid) { + appliedUid ??= newUid; } + }); + const release = (): void => { + availability.dispose(); }; const change: CascadeChange = instanceOrDescriptor instanceof SyncDescriptor @@ -293,29 +380,20 @@ export class InstantiationService implements IInstantiationService { action: 'provide', token: id, descriptor: instanceOrDescriptor, - pinned: options?.pinned, activation: options?.activation, + config: options?.config, reason: `provide ${String(id)}`, } : { action: 'provide', token: id, instance: instanceOrDescriptor, - pinned: options?.pinned, + config: options?.config, reason: `provide ${String(id)}`, }; - noteApplied(); - this.cascade.submit(change).then(noteApplied, onUnexpectedError); - noteApplied(); // the sync fast path has already applied the change - const entry = this._ledger.register(() => { - // Generation guard: only unprovide the generation this entry provided. - if (appliedUid !== undefined && this._services.uidOf(id) === appliedUid) { - this.unprovide(id); - } - }, `provide:${String(id)}`); - this._provideEntries.set(id, entry); + this.cascade.submit(change).catch(onUnexpectedError); return { - get uid(): number { + uid: () => { if (appliedUid === undefined) { throw new Error( `provide of '${String(id)}' has not been applied yet (cascade in flight)`, @@ -324,34 +402,45 @@ export class InstantiationService implements IInstantiationService { return appliedUid; }, dispose: () => { - void entry.dispose(); + release(); + if (appliedUid !== undefined && this._services.uidOf(id) === appliedUid) { + return this._unprovideCore(id); + } + return Promise.resolve(); }, + release, }; } - unprovide(id: ServiceIdentifier): void { + private _unprovideCore(id: ServiceIdentifier): Promise { if (this._disposed) { - return; + return Promise.resolve(); } this._releaseProvideEntry(id); if (this._services.get(id) === undefined) { - return; + return Promise.resolve(); } - this.cascade + return this.cascade .submit({ action: 'unprovide', token: id, reason: `unprovide ${String(id)}` }) - .catch(onUnexpectedError); + .catch((error: unknown) => { + onUnexpectedError(error); + }); + } + + unprovide(id: ServiceIdentifier): void { + void this._unprovideCore(id); } // eslint-disable-next-line @typescript-eslint/no-explicit-any private _releaseProvideEntry(id: ServiceIdentifier): void { - const entry = this._provideEntries.get(id); - if (entry !== undefined) { + const prev = this._provideEntries.get(id); + if (prev !== undefined) { this._provideEntries.delete(id); - entry.release(); + prev.entry.release(); + prev.core.release(); } } - /** Retire the live instance of a token and reset its entry to the recipe. */ private _retireUnit(id: ServiceIdentifier): void | Promise { const instance = this._services.get(id); if (instance === undefined || instance instanceof SyncDescriptor) { @@ -366,6 +455,173 @@ export class InstantiationService implements IInstantiationService { return entry.dispose(); } + private _nextUnitUid = 0; + + get fiberHost(): FiberHost { + return this._getFiberHost(); + } + + get collectionStore(): CollectionStore { + return this._collectionStore; + } + + get ledger(): Ledger { + return this._ledger; + } + + get cascadeTree(): CascadeTree { + return this._tree; + } + + get children(): readonly InstantiationService[] { + return [...this._children]; + } + + servicesSnapshot(): { token: string; uid: number }[] { + const snapshot: { token: string; uid: number }[] = []; + this._services.forEach((id) => { + snapshot.push({ + token: id.toString(), + uid: this._services.uidOf(id)!, + }); + }); + return snapshot; + } + + findIdentifier(tokenString: string): ServiceIdentifier | undefined { + let found: ServiceIdentifier | undefined; + this._services.forEach((id) => { + if (found === undefined && id.toString() === tokenString) { + found = id; + } + }); + return found; + } + + anchorKernelEntry(disposer: Disposer, label: string): LedgerEntry { + return this._ledger.register(disposer, label); + } + + private _getFiberHost(): FiberHost { + this._fiberHost ??= { + mintUid: () => ++this._root()._nextUnitUid, + provideToken: (id, descriptor, options) => this._provideCore(id, descriptor, options), + provideTokenInstance: (id: ServiceIdentifier, instance: T) => + this._provideCore(id, instance, undefined), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tokenState: (id: ServiceIdentifier) => { + const owner = this._ownerOf(id) ?? this; + return owner.cascade.stateOf(id); + }, + updateToken: async (id, config, hasConfig) => { + const owner = this._ownerOf(id); + if (owner === undefined) { + throw new Error(`update of unregistered token '${String(id)}'`); + } + if (hasConfig) { + owner._services.setConfig(id, config); + } + await owner.cascade.update(id); + }, + resolveTokenWhenAvailable: (id: ServiceIdentifier): Promise => + this.cascade.resolveWhenAvailable(id), + resolveInstance: (id: ServiceIdentifier): T => + this.invokeFunction((accessor) => accessor.get(id)), + materializedInstance: (id: ServiceIdentifier): T | undefined => + this._materializedInstanceOf(id), + liveRef: (id: ServiceIdentifier): LiveRef => this._liveRef(id), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + recordInstanceEdge: (node: object | undefined, id: ServiceIdentifier) => { + if (node === undefined) { + return; + } + const owner = this._ownerOf(id); + if (owner !== undefined) { + this.dependencyGraph.addEdge(node, { scope: owner, token: id }, 'instance'); + } + }, + collectionView: (token: CollectionToken): CollectionView => + this._collectionView(token), + addCollectionRecord: ( + token: CollectionToken, + providerName: string, + providerBook: Ledger, + value: T, + ) => + this._collectionStore.addRecord( + token, + this, + providerName, + this._scopePath(), + providerBook, + value, + ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructService: (ctor: new (...args: any[]) => T, config: unknown): T => { + return this._createInstance(ctor, [], Trace.traceCreation(this._enableTracing, ctor), { + config, + }); + }, + }; + return this._fiberHost; + } + + private _materializedInstanceOf(id: ServiceIdentifier): T | undefined { + const value = this._services.get(id); + if (value !== undefined) { + return value instanceof SyncDescriptor ? undefined : (value as T); + } + return this._parent?._materializedInstanceOf(id); + } + + private _liveRef(id: ServiceIdentifier): LiveRef { + const change = new Emitter(); + const chain: InstantiationService[] = [this]; + for (let c = this._parent; c !== undefined; c = c._parent) { + chain.push(c); + } + const subscriptions = chain.map((container) => + container._services.onDidChange(id, () => { + change.fire(); + }), + ); + this._ledger.register(() => { + for (const subscription of subscriptions) { + subscription.dispose(); + } + change.dispose(); + }, `ref:${String(id)}`); + const current = (): T | undefined => this._materializedInstanceOf(id); + const ref: LiveRef = { + get current(): T | undefined { + return current(); + }, + onDidChange: change.event, + }; + return ref; + } + + private _collectionView(token: CollectionToken): CollectionView { + let view = this._collectionViews.get(token as CollectionToken); + if (view === undefined) { + view = this._collectionStore.createView(token, this); + this._collectionViews.set(token as CollectionToken, view); + } + return view as CollectionViewImpl; + } + + private _scopePath(): string { + const labels: string[] = [this.debugLabel ?? `#${this._tree.seqOf(this)}`]; + for ( + let c: InstantiationService | undefined = this._parent; + c !== undefined; + c = c._parent + ) { + labels.unshift(c.debugLabel ?? `#${this._tree.seqOf(c)}`); + } + return labels.join('/'); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any createInstance(descriptor: SyncDescriptor, ...rest: any[]): T; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -421,9 +677,6 @@ export class InstantiationService implements IInstantiationService { this._disposed = true; try { - // Children first (forward creation order): their services may depend on - // this container's instances, so they must die before them. Each child - // releases its ledger entry, so the ledger teardown below skips them. for (const child of Array.from(this._children)) { child.dispose(); } @@ -431,6 +684,10 @@ export class InstantiationService implements IInstantiationService { void this._ledger.teardown('scope-close'); this._services.dispose(); this.cascade.dispose(); + for (const view of this._collectionViews.values()) { + view.dispose(); + } + this._collectionViews.clear(); } finally { this._children.clear(); this._parentLedgerEntry?.release(); @@ -442,10 +699,23 @@ export class InstantiationService implements IInstantiationService { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - private _createInstance(ctor: any, args: unknown[], _trace: Trace): T { + private _createInstance(ctor: any, args: unknown[], _trace: Trace, unit?: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + id?: ServiceIdentifier; + config?: unknown; + }): T { const serviceDependencies = _util.getServiceDependencies(ctor).toSorted((a, b) => a.index - b.index); const serviceArgs: unknown[] = []; for (const dependency of serviceDependencies) { + const kind = dependency.kind ?? 'instance'; + if (kind === 'collection') { + serviceArgs.push(this._collectionView(dependency.id as unknown as CollectionToken)); + continue; + } + if (kind === 'ref') { + serviceArgs.push(this._liveRef(dependency.id)); + continue; + } const service = this._getOrCreateServiceInstance(dependency.id, _trace); if (!service) { this._throwIfStrict( @@ -472,22 +742,38 @@ export class InstantiationService implements IInstantiationService { } } - return Reflect.construct(ctor, args.concat(serviceArgs)); + const finalArgs = args.concat(serviceArgs); + if (!isServiceRecipe(ctor)) { + return Reflect.construct(ctor, finalArgs); + } + const frame: ConstructionFrame = { + ctor, + config: unit?.config, + token: unit?.id, + host: this._getFiberHost(), + }; + pushConstructionFrame(frame); + let instance: T; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + instance = Reflect.construct(ctor as new (...args: any[]) => T, finalArgs); + } finally { + popConstructionFrame(); + } + bindServiceUnit(instance as UnitInternals & IDisposable, frame); + return instance; } protected _getOrCreateServiceInstance(id: ServiceIdentifier, _trace: Trace): T { if (!this._cascadeResolving) { if (this.cascade.isInFlight(id)) { - // The sync resolution path cannot suspend; the async path - // (cascade.resolveWhenAvailable) waits for the transaction instead. throw new CascadeConflictError( String(id), 'token is inside an in-flight cascade transaction', ); } - const failure = this.cascade.unitFailure(id); + const failure = this.cascade.failureOf(id); if (failure !== undefined) { - // D5: Failed is sticky — resolving a failed unit rethrows its error. throw failure as Error; } } @@ -549,7 +835,7 @@ export class InstantiationService implements IInstantiationService { throw new CyclicDependencyError(graph); } - for (const dependency of _util.getServiceDependencies(item.desc.ctor)) { + for (const dependency of _util.getInstanceDependencies(item.desc.ctor)) { const instanceOrDesc = this._getServiceInstanceOrDescriptor(dependency.id); if (!instanceOrDesc) { this._throwIfStrict( @@ -630,13 +916,24 @@ export class InstantiationService implements IInstantiationService { const root = this._root(); root._inProgress.push(id); try { - const result = this._createInstance(ctor, args.slice(), _trace); - // Persistent tree-global graph: record the instance and its - // constructor-injection (instance) edges, both ends scope-tagged; the - // ledger entry removes them again at teardown. Edges point child → - // parent (a dependency's owner is always this container or an ancestor). + const result = this._createInstance(ctor, args.slice(), _trace, { + id, + config: this._services.configOf(id), + }); this.dependencyGraph.addInstance(result as object, this, id); for (const dependency of _util.getServiceDependencies(ctor)) { + const kind = dependency.kind ?? 'instance'; + if (kind === 'collection') { + this.dependencyGraph.addEdge( + result as object, + { scope: this, token: dependency.id }, + 'collection', + ); + continue; + } + if (kind === 'ref') { + continue; + } const owner = this._ownerOf(dependency.id); if (owner !== undefined) { this.dependencyGraph.addEdge( @@ -650,8 +947,6 @@ export class InstantiationService implements IInstantiationService { this._instanceEntries.delete(result); this.dependencyGraph.removeInstance(result as object); if (isDisposable(result)) { - // Propagate a (runtime) async disposer so cascade teardown can - // await it serially; statically `dispose()` is typed void. const out = result.dispose() as unknown as void | Promise; return out; } @@ -670,8 +965,6 @@ export class InstantiationService implements IInstantiationService { private _setCreatedServiceInstance(id: ServiceIdentifier, instance: T): void { if (this._services.get(id) instanceof SyncDescriptor) { - // Keeps the recipe on the entry so a cascade teardown can unmaterialize - // back to it (and rebuild later). this._services.materialize(id, instance); } else if (this._parent) { this._parent._setCreatedServiceInstance(id, instance); diff --git a/packages/agent-core-v2/src/_base/di/lifecycle.ts b/packages/agent-core-v2/src/_base/di/lifecycle.ts index 0593c3c8290..5ae86e22e3c 100644 --- a/packages/agent-core-v2/src/_base/di/lifecycle.ts +++ b/packages/agent-core-v2/src/_base/di/lifecycle.ts @@ -233,6 +233,10 @@ export class DisposableStore implements IDisposable { trackDisposable(this); } + get ledger(): Ledger { + return this._ledger; + } + add(d: T): T { if ((d as unknown as DisposableStore) === this) { throw new Error('Cannot register a disposable on itself!'); diff --git a/packages/agent-core-v2/src/_base/di/scope.ts b/packages/agent-core-v2/src/_base/di/scope.ts index 347293e7e0d..6d4d7272f3e 100644 --- a/packages/agent-core-v2/src/_base/di/scope.ts +++ b/packages/agent-core-v2/src/_base/di/scope.ts @@ -1,31 +1,48 @@ /** - * `di` domain — DI Scope tree (`Scope`, `LifecycleScope`) and scoped service registry. + * `di` domain — DI Scope tree (`Scope`) and scoped service registry. * * Scoped services are resolved when their scope is created by default; * registrations that defer construction until first resolution use `OnDemand`. + * + * The kernel only knows the scope tree and the `ScopeKind` partial order. + * The tier set is a business concept: the host bootstrap declares it through + * `setScopeTopology` (see `src/app/scopes.ts`). */ +import { BugIndicatingError } from '../errors/errors'; import { SyncDescriptor } from './descriptors'; +import { ScopeActivation, type ProvideAllEntry } from './instantiation'; import type { ServiceIdentifier, ServicesAccessor, IInstantiationService } from './instantiation'; import { InstantiationService } from './instantiationService'; import { DisposableStore, type IDisposable } from './lifecycle'; import { Ledger, type LedgerEntry } from '../lifecycle/ledger'; import { ServiceCollection } from './serviceCollection'; +import { watchScopeUnits } from './scopeUnits'; -export enum LifecycleScope { - App = 0, - Workspace = 1, - Session = 2, - Agent = 3, -} +export { ScopeActivation }; + +export type ScopeKind = string; + +let _scopeTopology: readonly string[] | undefined; -export enum ScopeActivation { - OnScopeCreated = 0, - OnDemand = 1, +export function setScopeTopology(kinds: readonly string[]): void { + if (_scopeTopology === undefined) { + _scopeTopology = [...kinds]; + return; + } + if ( + _scopeTopology.length === kinds.length && + _scopeTopology.every((kind, index) => kind === kinds[index]) + ) { + return; + } + throw new BugIndicatingError( + `scope topology already declared as [${_scopeTopology.join(', ')}]; cannot redeclare as [${kinds.join(', ')}]`, + ); } export interface ScopedEntry { - readonly scope: LifecycleScope; + readonly scope: ScopeKind; readonly id: ServiceIdentifier; readonly descriptor: SyncDescriptor; readonly domain: string; @@ -35,7 +52,7 @@ export interface ScopedEntry { const _scopedRegistry: ScopedEntry[] = []; export function registerScopedService( - scope: LifecycleScope, + scope: ScopeKind, id: ServiceIdentifier, // eslint-disable-next-line @typescript-eslint/no-explicit-any ctor: new (...args: any[]) => T, @@ -52,7 +69,7 @@ export function registerScopedService( }); } -export function getScopedServiceDescriptors(scope: LifecycleScope): ReadonlyArray { +export function getScopedServiceDescriptors(scope: ScopeKind): ReadonlyArray { return _scopedRegistry.filter((entry) => entry.scope === scope); } @@ -68,27 +85,23 @@ export type ScopeSeed = ReadonlyArray< export interface ScopeOptions { readonly id?: string; readonly extra?: ScopeSeed; + readonly assemble?: (container: InstantiationService) => void; } -export interface IScopeHandle { +export interface IScopeHandle { readonly id: string; readonly kind: K; readonly accessor: ServicesAccessor; dispose(): void; } -export type IAppScopeHandle = IScopeHandle; -export type IWorkspaceScopeHandle = IScopeHandle; -export type ISessionScopeHandle = IScopeHandle; -export type IAgentScopeHandle = IScopeHandle; +export type IAppScopeHandle = IScopeHandle<'app'>; +export type IWorkspaceScopeHandle = IScopeHandle<'workspace'>; +export type ISessionScopeHandle = IScopeHandle<'session'>; +export type IAgentScopeHandle = IScopeHandle<'agent'>; -function buildCollection(kind: LifecycleScope, extra?: ScopeSeed): ServiceCollection { +function buildCollection(extra?: ScopeSeed): ServiceCollection { const collection = new ServiceCollection(); - for (const entry of _scopedRegistry) { - if (entry.scope === kind) { - collection.set(entry.id, entry.descriptor); - } - } if (extra) { for (const [id, value] of extra) { collection.set(id, value); @@ -97,33 +110,40 @@ function buildCollection(kind: LifecycleScope, extra?: ScopeSeed): ServiceCollec return collection; } -function activateScopeServices( +function provideScopeServices( instantiation: IInstantiationService, - kind: LifecycleScope, + kind: ScopeKind, collection: ServiceCollection, ): void { + const entries: ProvideAllEntry[] = []; for (const entry of _scopedRegistry) { - if ( - entry.scope !== kind || - entry.activation !== ScopeActivation.OnScopeCreated || - collection.get(entry.id) !== entry.descriptor - ) { + if (entry.scope !== kind || collection.get(entry.id) !== undefined) { continue; } - instantiation.invokeFunction((accessor) => accessor.get(entry.id)); + entries.push({ + id: entry.id, + descriptor: entry.descriptor, + options: { + activation: entry.activation === ScopeActivation.OnDemand ? 'ondemand' : 'eager', + }, + }); } + instantiation.provideAll(entries); } export function createScopedChildHandle( parent: IInstantiationService, - kind: LifecycleScope, + kind: ScopeKind, id: string, options: ScopeOptions = {}, ): IScopeHandle { - const collection = buildCollection(kind, options.extra); + const collection = buildCollection(options.extra); const child = parent.createChild(collection); + (child as InstantiationService).debugLabel = id; try { - activateScopeServices(child, kind, collection); + watchScopeUnits(child as InstantiationService, kind); + options.assemble?.(child as InstantiationService); + provideScopeServices(child, kind, collection); } catch (error) { child.dispose(); throw error; @@ -146,12 +166,10 @@ export class Scope implements IDisposable { private constructor( readonly id: string, - readonly kind: LifecycleScope, + readonly kind: ScopeKind, readonly instantiation: IInstantiationService, private readonly _parent?: Scope, ) { - // Registration order is reversed at teardown: children (registered later) - // go first, then the store, then the instantiation container. this._ledger = new Ledger(`scope:${id}`); this._ledger.register(() => { this.instantiation.dispose(); @@ -165,12 +183,19 @@ export class Scope implements IDisposable { }; } + get ledger(): Ledger { + return this._ledger; + } + static createApp(options: ScopeOptions = {}): Scope { - const kind = LifecycleScope.App; - const collection = buildCollection(kind, options.extra); + const kind: ScopeKind = 'app'; + const collection = buildCollection(options.extra); const instantiation = new InstantiationService(collection, true); + instantiation.debugLabel = options.id ?? 'app'; try { - activateScopeServices(instantiation, kind, collection); + watchScopeUnits(instantiation, kind); + options.assemble?.(instantiation); + provideScopeServices(instantiation, kind, collection); } catch (error) { instantiation.dispose(); throw error; @@ -184,20 +209,27 @@ export class Scope implements IDisposable { } } - createChild(kind: LifecycleScope, id: string, options: ScopeOptions = {}): Scope { + createChild(kind: ScopeKind, id: string, options: ScopeOptions = {}): Scope { this._assertNotDisposed(); - if (kind <= this.kind) { - throw new Error( - `child scope kind ${LifecycleScope[kind]}(${kind}) must be greater than parent kind ${LifecycleScope[this.kind]}(${this.kind})`, - ); + if (_scopeTopology !== undefined) { + const parentIndex = _scopeTopology.indexOf(this.kind); + const childIndex = _scopeTopology.indexOf(kind); + if (parentIndex === -1 || childIndex === -1 || childIndex <= parentIndex) { + throw new Error( + `child scope kind '${kind}' must be greater than parent kind '${this.kind}' in the declared scope topology`, + ); + } } if (this.children.has(id)) { throw new Error(`Scope '${this.id}' already has a child with id '${id}'`); } - const collection = buildCollection(kind, options.extra); + const collection = buildCollection(options.extra); const childInstantiation = this.instantiation.createChild(collection); + (childInstantiation as InstantiationService).debugLabel = id; try { - activateScopeServices(childInstantiation, kind, collection); + watchScopeUnits(childInstantiation as InstantiationService, kind); + options.assemble?.(childInstantiation as InstantiationService); + provideScopeServices(childInstantiation, kind, collection); } catch (error) { childInstantiation.dispose(); throw error; diff --git a/packages/agent-core-v2/src/_base/di/scopeUnits.ts b/packages/agent-core-v2/src/_base/di/scopeUnits.ts new file mode 100644 index 00000000000..7fd6688a4d9 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/scopeUnits.ts @@ -0,0 +1,136 @@ +/** + * `di` domain — the kernel-side `ScopeUnits(kind)` fold (L3, D11/G2). + * + * `ScopeUnits(kind)` is the materialization collection token the kernel mints + * per scope kind. When a scope of that kind is created, this fold watches the + * new scope's live view of the token and materializes every record's recipe + * as a unit INSIDE that scope (cross-scope materialization): a feature + * contributed once at App scope becomes one live unit per Session/Agent + * scope, automatically. + * + * Lifetime rules (per §5.6): + * - the materialized unit's disposal hangs on the RECORD PROVIDER's book — + * disposing the provider retracts the record and tears the materialized + * units down across the tree (连坐); + * - a target scope's natural death tears its materialized units down with it + * (the fold ledger is anchored into the scope's container ledger); both + * anchors are idempotent, so a provider dying mid-teardown is a no-op; + * - records visible at creation are materialized immediately; the view's + * incremental changes reconcile the set by record identity. + * + * A materialized unit's own `this.provide(...)` registrations are ordinary + * token provides in the target scope — they join the graph and cascades as + * usual. The materialized unit itself carries no token identity, so its own + * constructor dependencies do not independently join cascades (feature + * recipes are dependency-free assemblies by convention, per the Plan + * sample); its provided tokens fully participate. + */ + +import { onUnexpectedError } from '../errors/unexpectedError'; +import type { IDisposable } from './lifecycle'; +import { Ledger } from '../lifecycle/ledger'; +import type { StoredRecord } from './collection'; +import { + FiberRuntime, + isClassRecipe, + ScopeUnits, + type EffectBody, + type ServiceRecipe, +} from './fiber'; +import type { InstantiationService } from './instantiationService'; +import type { ScopeKind } from './scope'; + +export function watchScopeUnits(container: InstantiationService, kind: ScopeKind): void { + if (container.cascadeDisposed) { + return; + } + const token = ScopeUnits(kind); + const host = container.fiberHost; + const view = host.collectionView(token); + const foldLedger = new Ledger(`scope-units:${kind}`); + container.anchorKernelEntry((reason) => foldLedger.teardown(reason), `scope-units:${kind}`); + + const materialized = new Map void>(); + + const materialize = (record: StoredRecord): void => { + const recipe = record.value as ServiceRecipe; + const name = record.providerName; + const unitLedger = new Ledger(`scope-units:${kind}:${name}`); + try { + if (isClassRecipe(recipe)) { + const instance = host.constructService(recipe, undefined) as Partial; + unitLedger.register(() => { + instance.dispose?.(); + }, `unit:${name}`); + } else { + const facade = new FiberRuntime( + host, + unitLedger, + name, + undefined, + undefined, + new Set(recipe.inject ?? []), + undefined, + ); + const out = + typeof recipe === 'function' + ? recipe(facade, undefined) + : recipe.apply(facade, undefined); + unitLedger.effect((() => out) as EffectBody, `effect:${name}`); + } + } catch (error) { + void unitLedger.teardown('unload'); + onUnexpectedError(error); + return; + } + + let retracted = false; + const retract = (): void => { + if (retracted) { + return; + } + retracted = true; + materialized.delete(record.id); + void unitLedger.teardown('unload'); + }; + if (!record.providerBook.isActive) { + retract(); + return; + } + record.providerBook.register(() => { + retract(); + }, `scope-units:${kind}`); + foldLedger.register(() => { + retract(); + }, `record:${name}`); + materialized.set(record.id, retract); + }; + + const reconcile = (): void => { + if (!foldLedger.isActive) { + return; + } + const records = container.collectionStore.storedRecordsFor(token, container); + const seen = new Set(); + for (const record of records) { + seen.add(record.id); + if (!materialized.has(record.id)) { + materialize(record); + } + } + // Snapshot: `retract()` deletes its own entry from `materialized`. + for (const [id, retract] of Array.from(materialized)) { + if (!seen.has(id)) { + retract(); + } + } + }; + + const subscription = view.onDidChange(() => { + reconcile(); + }); + foldLedger.register(() => { + subscription.dispose(); + }, 'view-subscription'); + reconcile(); +} diff --git a/packages/agent-core-v2/src/_base/di/service.ts b/packages/agent-core-v2/src/_base/di/service.ts new file mode 100644 index 00000000000..c7c30e74c78 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/service.ts @@ -0,0 +1,166 @@ +/** + * `di` domain — the `Service` base class for L3 unit recipes. + * + * Extending `Service` turns a class into a unit recipe with the five `Fiber` + * capabilities (`this.provide` / `effect` / `on` / `get` / `ref`). The class + * follows the two-phase construction protocol: inside the constructor — when + * the container builds the instance under a matching `ConstructionFrame` — + * capability calls do not run immediately; they are buffered as + * `BufferedOp`s and answered with `PendingFiberHandle`s, then flushed + * against the real `FiberRuntime` by `bindServiceUnit` right after + * construction (`fiber.ts`). Reads (`get` / `ref`) are forbidden during this + * phase — declare dependencies as constructor parameters instead (构造期只写 + * 不读). A `Service` created by manual `new` never gets a bound runtime, and + * its capability calls throw `FiberProtocolError`. + * + * The `SERVICE_MARK` prototype marker (set below) lets the container + * recognize `Service`-derived class recipes and drive them through this + * protocol; services whose members collide with the `Service` vocabulary + * keep `extends Disposable` and use the function/object recipe forms + * instead. + */ + +import type { Emitter } from '../event'; +import type { EffectBody } from '../lifecycle/disposer'; +import type { Ledger } from '../lifecycle/ledger'; +import type { CollectionToken } from './collection'; +import { + currentConstruction, + FiberProtocolError, + FiberState, + PendingFiberHandle, + SERVICE_MARK, + type BufferedOp, + type Fiber, + type FiberHandle, + type FiberProvideOptions, + type FiberRuntime, + type RecipeStatics, + type ServiceClassRecipe, + type ServiceRecipe, + type UnitInternals, +} from './fiber'; +import type { ServiceIdentifier, LiveRef } from './instantiation'; +import { Disposable } from './lifecycle'; + +export abstract class Service extends Disposable implements Fiber, UnitInternals { + private __unitBuffer: BufferedOp[] | null; + private __unitRuntime: FiberRuntime | undefined; + + readonly name: string; + readonly state: FiberState = FiberState.Active; + readonly config: unknown; + + constructor() { + super(); + const frame = currentConstruction(); + if ( + frame !== undefined && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + frame.ctor === (new.target as unknown as new (...args: any[]) => any) + ) { + this.__unitBuffer = []; + this.config = frame.config; + } else { + this.__unitBuffer = null; + this.config = undefined; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.name = (this.constructor as any).name || 'anonymous'; + } + + provide( + id: ServiceIdentifier, + recipe: ServiceClassRecipe, + opts?: FiberProvideOptions, + ): FiberHandle; + provide(id: ServiceIdentifier, instance: T): FiberHandle; + provide(recipe: ServiceRecipe, opts?: FiberProvideOptions): FiberHandle; + provide(token: CollectionToken, value: T): FiberHandle; + provide( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + first: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + second?: any, + third?: FiberProvideOptions, + ): FiberHandle { + if (this.__unitBuffer !== null) { + const pending = new PendingFiberHandle(this._pendingName(first)); + this.__unitBuffer.push((runtime) => { + pending.attach(runtime.provide(first, second, third)); + }); + return pending; + } + return this._runtime().provide(first, second, third); + } + + effect(body: EffectBody, label?: string): FiberHandle { + if (this.__unitBuffer !== null) { + const pending = new PendingFiberHandle(label ?? `effect:${this.name}`); + this.__unitBuffer.push((runtime) => { + pending.attach(runtime.effect(body, label)); + }); + return pending; + } + return this._runtime().effect(body, label); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string | Emitter, handler: (e: any) => void): FiberHandle { + const label = typeof event === 'string' ? `on:${event}` : 'on:emitter'; + if (this.__unitBuffer !== null) { + const pending = new PendingFiberHandle(label); + this.__unitBuffer.push((runtime) => { + pending.attach(runtime.on(event, handler)); + }); + return pending; + } + return this._runtime().on(event, handler); + } + + get(id: ServiceIdentifier): T { + return this._runtime().get(id); + } + + ref(id: ServiceIdentifier): LiveRef { + return this._runtime().ref(id); + } + + get unitBook(): Ledger { + return this._store.ledger; + } + + takeUnitBuffer(): BufferedOp[] | null { + const buffer = this.__unitBuffer; + this.__unitBuffer = null; + return buffer; + } + + setUnitRuntime(runtime: FiberRuntime): void { + this.__unitRuntime = runtime; + } + + private _runtime(): FiberRuntime { + if (this.__unitRuntime === undefined) { + if (this.__unitBuffer !== null) { + throw new FiberProtocolError( + `unit '${this.name}': get/ref/provide reads are not available during construction — declare dependencies as constructor parameters (构造期只写不读)`, + ); + } + throw new FiberProtocolError( + `unit '${this.name}': no unit runtime is bound — a Service created by manual \`new\` has no capabilities; construct it through the container`, + ); + } + return this.__unitRuntime; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _pendingName(first: any): string { + if (typeof first === 'function') { + return (first as RecipeStatics).name ?? String(first); + } + return this.name; + } +} + +(Service.prototype as unknown as Record)[SERVICE_MARK] = true; diff --git a/packages/agent-core-v2/src/_base/di/serviceCollection.ts b/packages/agent-core-v2/src/_base/di/serviceCollection.ts index 2f81b203bd6..b553fbf6331 100644 --- a/packages/agent-core-v2/src/_base/di/serviceCollection.ts +++ b/packages/agent-core-v2/src/_base/di/serviceCollection.ts @@ -15,8 +15,7 @@ import type { IDisposable } from './lifecycle'; export interface ServiceCollectionEntry { readonly value: T | SyncDescriptor; readonly uid: number; - readonly pinned: boolean; - /** The recipe a materialized instance was created from (kept for rebuilds). */ + readonly config?: unknown; readonly recipe?: SyncDescriptor; } @@ -47,24 +46,19 @@ export class ServiceCollection { set( id: ServiceIdentifier, instanceOrDescriptor: T | SyncDescriptor, - options?: { readonly pinned?: boolean }, + options?: { readonly config?: unknown }, ): T | SyncDescriptor | undefined { const prev = this._entries.get(id); const uid = ++this._nextUid; this._entries.set(id, { value: instanceOrDescriptor, uid, - pinned: options?.pinned ?? prev?.pinned ?? false, + config: options?.config, }); this._emitterFor(id).fire({ oldUid: prev?.uid, newUid: uid }); return prev?.value as T | SyncDescriptor | undefined; } - /** - * Swap a descriptor entry for its materialized instance, keeping the uid, - * pinned flag, and the recipe (so the entry can be unmaterialized back to - * the recipe when the instance is torn down). Not a new generation. - */ materialize(id: ServiceIdentifier, instance: T): void { const prev = this._entries.get(id); if (prev === undefined || !(prev.value instanceof SyncDescriptor)) { @@ -73,12 +67,11 @@ export class ServiceCollection { this._entries.set(id, { value: instance, uid: prev.uid, - pinned: prev.pinned, + config: prev.config, recipe: prev.value, }); } - /** Swap a materialized entry back to its recipe (instance torn down). */ unmaterialize(id: ServiceIdentifier): void { const prev = this._entries.get(id); if (prev === undefined || prev.recipe === undefined) { @@ -87,10 +80,18 @@ export class ServiceCollection { this._entries.set(id, { value: prev.recipe, uid: prev.uid, - pinned: prev.pinned, + config: prev.config, }); } + setConfig(id: ServiceIdentifier, config: unknown): void { + const prev = this._entries.get(id); + if (prev === undefined) { + return; + } + this._entries.set(id, { ...prev, config }); + } + delete(id: ServiceIdentifier): T | SyncDescriptor | undefined { const prev = this._entries.get(id); if (prev === undefined) { @@ -112,11 +113,10 @@ export class ServiceCollection { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - isPinned(id: ServiceIdentifier): boolean { - return this._entries.get(id)?.pinned ?? false; + configOf(id: ServiceIdentifier): unknown { + return this._entries.get(id)?.config; } - /** Fired when the token's availability changes (set → new uid, delete → undefined). */ onDidChange( id: ServiceIdentifier, listener: (change: AvailabilityChange) => void, diff --git a/packages/agent-core-v2/src/_base/di/test.ts b/packages/agent-core-v2/src/_base/di/test.ts index 641fd111408..11b332889d4 100644 --- a/packages/agent-core-v2/src/_base/di/test.ts +++ b/packages/agent-core-v2/src/_base/di/test.ts @@ -13,12 +13,12 @@ export type { } from './testInstantiationService'; import { type ServiceIdentifier } from './instantiation'; -import { createAppScope, LifecycleScope, Scope, type ScopeSeed } from './scope'; +import { createAppScope, Scope, type ScopeKind, type ScopeSeed } from './scope'; export interface ScopedTestHost { readonly app: Scope; - child(kind: LifecycleScope, id: string, stubs?: ScopeSeed): Scope; - childOf(parent: Scope, kind: LifecycleScope, id: string, stubs?: ScopeSeed): Scope; + child(kind: ScopeKind, id: string, stubs?: ScopeSeed): Scope; + childOf(parent: Scope, kind: ScopeKind, id: string, stubs?: ScopeSeed): Scope; dispose(): void; } diff --git a/packages/agent-core-v2/src/_base/di/testInstantiationService.ts b/packages/agent-core-v2/src/_base/di/testInstantiationService.ts index 258db38e024..f7fb86aa6f0 100644 --- a/packages/agent-core-v2/src/_base/di/testInstantiationService.ts +++ b/packages/agent-core-v2/src/_base/di/testInstantiationService.ts @@ -53,9 +53,6 @@ export class TestInstantiationService extends InstantiationService implements ID id: ServiceIdentifier, instanceOrDescriptor: T | SyncDescriptor, ): T | SyncDescriptor | undefined { - // Routed through provide so test overrides get production semantics: - // a replaced materialized instance is retired, a new generation starts. - // Descriptors stay lazy (constructed at first resolution), as before. const prev = this._serviceCollection.get(id); this.provide(id, instanceOrDescriptor, { activation: 'ondemand' }); return prev; diff --git a/packages/agent-core-v2/src/_base/lifecycle/disposer.ts b/packages/agent-core-v2/src/_base/lifecycle/disposer.ts index 1278dd77d23..a40ca66188d 100644 --- a/packages/agent-core-v2/src/_base/lifecycle/disposer.ts +++ b/packages/agent-core-v2/src/_base/lifecycle/disposer.ts @@ -12,15 +12,6 @@ export type TeardownReason = 'scope-close' | 'cascade' | 'unload'; export type Disposer = (reason: TeardownReason) => void | Promise; -/** - * The four return forms accepted from an effect body: - * - `void` — nothing to roll back (the entry still exists for introspection); - * - `Disposer` — a single rollback action; - * - `Promise` — an asynchronously produced rollback action; - * - sync / async iterator of `Disposer`s — each yielded value is one rollback - * action; if iteration throws partway, the already-yielded disposers are - * rolled back in reverse before the error propagates. - */ export type EffectResult = | void | Disposer diff --git a/packages/agent-core-v2/src/_base/lifecycle/ledger.ts b/packages/agent-core-v2/src/_base/lifecycle/ledger.ts index 3dd4846a54d..ea79a628d05 100644 --- a/packages/agent-core-v2/src/_base/lifecycle/ledger.ts +++ b/packages/agent-core-v2/src/_base/lifecycle/ledger.ts @@ -30,14 +30,10 @@ export interface LedgerEntryInfo { readonly children?: readonly LedgerEntryInfo[]; } -/** Handle to one ledger entry: remove it, or remove-and-run it. */ export interface LedgerEntry { readonly label: string; - /** True once the entry has been disposed, released, or torn down. */ readonly disposed: boolean; - /** Remove the entry and run its disposer (guarded). Idempotent. */ dispose(reason?: TeardownReason): void | Promise; - /** Remove the entry without running its disposer. Idempotent. */ release(): void; } @@ -47,12 +43,10 @@ interface EntryRecord { stack?: string; active: boolean; run: Disposer; - /** Set for child-ledger entries, for introspection. */ ledger?: Ledger; } export class Ledger { - /** Dev-mode toggle: capture the registration stack on every entry. */ static captureStacks = false; private _state: LedgerState = 'active'; @@ -74,7 +68,6 @@ export class Ledger { return this._state === 'disposed'; } - /** Number of live entries. */ get size(): number { return this._records.reduce((count, record) => count + (record.active ? 1 : 0), 0); } @@ -115,15 +108,12 @@ export class Ledger { }); } if (isSyncIterable(out)) { - // Drives the iterator immediately; a mid-iteration throw rolls back the - // already-yielded disposers before rethrowing (construction failure). const run = driveSyncEffect(out); return this._push({ label, kind: 'effect', active: true, run }); } return this._push({ label, kind: 'effect', active: true, run: () => {} }); } - /** A child ledger is itself one entry of this ledger. */ createChild(label: string = 'ledger'): Ledger { this._assertActive('createChild'); const child = new Ledger(label); @@ -137,13 +127,6 @@ export class Ledger { return child; } - /** - * Tear down every entry in strict reverse registration order, awaiting each - * one serially. Idempotent: a second call while disposing returns the - * in-flight promise; after disposal it is a no-op. Returns `undefined` when - * every entry completed synchronously (state is then synchronously - * `disposed`), otherwise a promise that settles once teardown completes. - */ teardown(reason: TeardownReason = 'scope-close'): void | Promise { if (this._state !== 'active') { return this._teardownPromise; @@ -161,13 +144,11 @@ export class Ledger { return undefined; } - /** Tear down all current entries but keep the ledger active. */ clear(reason: TeardownReason = 'scope-close'): void | Promise { this._assertActive('clear'); return drainRecords(this._records, reason); } - /** Introspection snapshot of the live entries (child ledgers recurse). */ entries(): LedgerEntryInfo[] { const infos: LedgerEntryInfo[] = []; for (const record of this._records) { @@ -213,7 +194,6 @@ export class Ledger { } } - /** Called by a child ledger when it tears itself down. */ private _detachFromParent(): void { this._parentEntry?.release(); this._parentEntry = undefined; @@ -226,7 +206,6 @@ export class Ledger { } } -/** Run one entry's disposer, logging (with label) instead of throwing. */ function runGuarded(record: EntryRecord, reason: TeardownReason): void | Promise { let out: void | Promise; try { @@ -251,10 +230,6 @@ function tagged(error: unknown, label: string): unknown { return new Error(`[ledger:${label}] ${String(error)}`); } -/** - * Tear down a record list from the tail, serially. Sync fast path: when no - * entry returns a promise, the whole drain completes within the tick. - */ function drainRecords(records: EntryRecord[], reason: TeardownReason): void | Promise { let index = records.length; const step = (): void | Promise => { @@ -274,7 +249,6 @@ function drainRecords(records: EntryRecord[], reason: TeardownReason): void | Pr return step(); } -/** Run a fixed disposer list in reverse, serially, guarding each entry. */ function runDisposersReverse( disposers: readonly Disposer[], reason: TeardownReason, @@ -308,11 +282,6 @@ function collect(step: IteratorResult, disposers: Disposer[]): } } -/** - * Drive a sync effect iterator to completion now. On a mid-iteration throw, - * the already-yielded disposers are rolled back in reverse (sync fast path; - * an async rollback continues in the background) and the error is rethrown. - */ function driveSyncEffect(iterable: Iterable): Disposer { const disposers: Disposer[] = []; const iterator = iterable[Symbol.iterator](); @@ -333,7 +302,6 @@ function driveSyncEffect(iterable: Iterable): Disposer { return (reason) => runDisposersReverse(disposers, reason); } -/** Async counterpart of {@link driveSyncEffect}; resolves to the composite disposer. */ async function driveAsyncEffect(iterable: AsyncIterable): Promise { const disposers: Disposer[] = []; const iterator = iterable[Symbol.asyncIterator](); diff --git a/packages/agent-core-v2/src/_base/log/logConfig.ts b/packages/agent-core-v2/src/_base/log/logConfig.ts index 312968be759..6be4bd0a4d8 100644 --- a/packages/agent-core-v2/src/_base/log/logConfig.ts +++ b/packages/agent-core-v2/src/_base/log/logConfig.ts @@ -60,7 +60,9 @@ export function resolveLoggingConfig(input: ResolveLoggingInput): LoggingConfig } export function logSeed(config: LoggingConfig): ScopeSeed { - return [[ILogOptions as ServiceIdentifier, config satisfies ILogOptions]]; + return [ + [ILogOptions as ServiceIdentifier, config satisfies ILogOptions], + ]; } function parseLevel(value: string | undefined): LogLevel | undefined { diff --git a/packages/agent-core-v2/src/_base/log/logService.ts b/packages/agent-core-v2/src/_base/log/logService.ts index b816697b2e4..f403edee42d 100644 --- a/packages/agent-core-v2/src/_base/log/logService.ts +++ b/packages/agent-core-v2/src/_base/log/logService.ts @@ -3,14 +3,15 @@ * * `BoundLogger` filters entries by level, extracts the payload into ctx/error, * merges bound context, and writes to a plain `ILogWriter`. It extends - * `Disposable` so scope implementations can flush synchronously when their + * `Service` so scope implementations can flush synchronously when their * scope is disposed. `AppLogService` is the App-scope binding of the single * `ILogService` token: it owns the global rotating file sink and reads its * level from `ILogOptions`. */ -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { type ILogger, @@ -78,7 +79,7 @@ export interface LogLevelState { level: LogLevel; } -export class BoundLogger extends Disposable implements ILogger { +export class BoundLogger extends Service implements ILogger { constructor( protected readonly writer: ILogWriter, private readonly levelState: LogLevelState, diff --git a/packages/agent-core-v2/src/_base/state/stateRegistry.ts b/packages/agent-core-v2/src/_base/state/stateRegistry.ts index c12da7f4b0d..190e8f0b284 100644 --- a/packages/agent-core-v2/src/_base/state/stateRegistry.ts +++ b/packages/agent-core-v2/src/_base/state/stateRegistry.ts @@ -66,6 +66,7 @@ export interface IStateRegistry { inspect(): StateInspection; } +// NOTE: stays Disposable — its own 'get' collides with the Fiber export class StateRegistry extends Disposable implements IStateRegistry { private readonly values = new Map(); private readonly keyEmitters = new Map>(); diff --git a/packages/agent-core-v2/src/agent/activityView/activityViewService.ts b/packages/agent-core-v2/src/agent/activityView/activityViewService.ts index bc73d64d457..7b6dab3fdd3 100644 --- a/packages/agent-core-v2/src/agent/activityView/activityViewService.ts +++ b/packages/agent-core-v2/src/agent/activityView/activityViewService.ts @@ -19,7 +19,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentLoopService } from '#/agent/loop/loop'; @@ -69,6 +70,7 @@ export const activityViewCurrentKey = defineState('activityV background: [], })); +// NOTE: stays Disposable — its own 'state' collides with the Fiber export class AgentActivityView extends Disposable implements IAgentActivityView { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts index 76d2a59d055..0076f856020 100644 --- a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -63,7 +63,8 @@ import { basename, dirname, isAbsolute, join, normalize } from 'pathe'; import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IBashParserService } from '#/app/bashParser/bashParser'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; diff --git a/packages/agent-core-v2/src/agent/blob/agentBlobServiceImpl.ts b/packages/agent-core-v2/src/agent/blob/agentBlobServiceImpl.ts index 499affe89a6..d3e13057cfb 100644 --- a/packages/agent-core-v2/src/agent/blob/agentBlobServiceImpl.ts +++ b/packages/agent-core-v2/src/agent/blob/agentBlobServiceImpl.ts @@ -9,7 +9,8 @@ import { createHash } from 'node:crypto'; import type { ContentPart } from '#/kosong/contract/message'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IBlobStore } from '#/persistence/interface/blobStore'; import { diff --git a/packages/agent-core-v2/src/agent/command/agentCommand.ts b/packages/agent-core-v2/src/agent/command/agentCommand.ts new file mode 100644 index 00000000000..5fe6db25fdf --- /dev/null +++ b/packages/agent-core-v2/src/agent/command/agentCommand.ts @@ -0,0 +1,27 @@ +/** + * `command` domain — the `IAgentCommandService` contract. + * + * The agent-scope registry over the `CommandContribution` collection: lists + * the contributed executable commands (name-level dedup, last record wins, + * `source` = provider unit name) and runs one by name with an args string. + * Bound at Agent scope. + */ + +import type { Event } from '#/_base/event'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface AgentCommandInfo { + readonly name: string; + readonly description?: string; + readonly source: string; +} + +export interface IAgentCommandService { + readonly _serviceBrand: undefined; + readonly onDidChange: Event; + list(): readonly AgentCommandInfo[]; + run(name: string, args?: string): Promise; +} + +export const IAgentCommandService: ServiceIdentifier = + createDecorator('agentCommandService'); diff --git a/packages/agent-core-v2/src/agent/command/agentCommandService.ts b/packages/agent-core-v2/src/agent/command/agentCommandService.ts new file mode 100644 index 00000000000..27379d570d3 --- /dev/null +++ b/packages/agent-core-v2/src/agent/command/agentCommandService.ts @@ -0,0 +1,78 @@ +/** + * `command` domain — `IAgentCommandService` implementation. + * + * The fold over the `CommandContribution` collection (`command`): `list()` + * dedupes the live records by name (a later record shadows an earlier one of + * the same name), and `run` invokes the contribution's callback inside an + * `invokeFunction` so its `ctx.get` resolves through the agent container. + * Unknown names fail with a coded `REQUEST_INVALID` error. Bound at Agent + * scope; constructed on demand — nothing pushes to a command registry, every + * consumer pulls. + */ + +import { Emitter, type Event } from '#/_base/event'; +import { type CollectionRecord, type CollectionView } from '#/_base/di/collection'; +import { + IInstantiationService, + type ServiceIdentifier, +} from '#/_base/di/instantiation'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Error2, ErrorCodes } from '#/errors'; + +import { IAgentCommandService, type AgentCommandInfo } from './agentCommand'; +import { CommandContribution } from './commandContribution'; + +export class AgentCommandService extends Service implements IAgentCommandService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @CommandContribution private readonly contributions: CollectionView, + ) { + super(); + this._register(this.contributions.onDidChange(() => this._onDidChange.fire())); + } + + list(): readonly AgentCommandInfo[] { + const byName = new Map(); + for (const record of this.contributions.records) { + byName.set(record.value.name, { + name: record.value.name, + description: record.value.description, + source: record.providerName, + }); + } + return [...byName.values()]; + } + + async run(name: string, args = ''): Promise { + const record = this.find(name); + if (record === undefined) { + throw new Error2(ErrorCodes.REQUEST_INVALID, `Unknown command "${name}"`); + } + await this.instantiationService.invokeFunction((accessor) => + record.value.run({ args, get: (id: ServiceIdentifier): T => accessor.get(id) }), + ); + } + + private find(name: string): CollectionRecord | undefined { + let found: CollectionRecord | undefined; + for (const record of this.contributions.records) { + if (record.value.name === name) found = record; + } + return found; + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentCommandService, + AgentCommandService, + ScopeActivation.OnDemand, + 'command', +); diff --git a/packages/agent-core-v2/src/agent/command/commandContribution.ts b/packages/agent-core-v2/src/agent/command/commandContribution.ts new file mode 100644 index 00000000000..73a63da3cd8 --- /dev/null +++ b/packages/agent-core-v2/src/agent/command/commandContribution.ts @@ -0,0 +1,27 @@ +/** + * `command` domain — the `CommandContribution` collection token and payload. + * + * An executable command a Feature (or any unit) contributes into the + * agent-scope registry (`IAgentCommandService`) — unlike plugin commands, + * which are prompt templates, a contributed command runs engine-side with DI + * access. `run` receives a `CommandRunContext` whose `get` resolves services + * from the target agent's container; the records carry the provider unit's + * name as `source`, and a record is withdrawn when its provider dies. No + * scoped state — pure payload + token. + */ + +import { collection } from '#/_base/di/collection'; +import type { ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface CommandRunContext { + readonly args: string; + get(id: ServiceIdentifier): T; +} + +export interface CommandContribution { + readonly name: string; + readonly description?: string; + readonly run: (ctx: CommandRunContext) => void | Promise; +} + +export const CommandContribution = collection('command'); diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 5db0487c967..0bb8cc08550 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -12,8 +12,10 @@ * functions, not plain data). Bound at Agent scope. */ -import { Disposable, toDisposable } from "#/_base/di/lifecycle"; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { toDisposable } from "#/_base/di/lifecycle"; +import { Service } from "#/_base/di/service"; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; @@ -41,7 +43,7 @@ export const contextInjectorIsNewTurnKey = defineState( () => true, ); -export class AgentContextInjectorService extends Disposable implements IAgentContextInjectorService { +export class AgentContextInjectorService extends Service implements IAgentContextInjectorService { declare readonly _serviceBrand: undefined; private readonly entries = new Set(); diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index dd05fb1ee39..fb5e00e7e48 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -11,7 +11,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; import { @@ -53,6 +54,7 @@ declare module '#/app/event/eventBus' { } } +// NOTE: stays Disposable — its own 'get' collides with the Fiber export class AgentContextMemoryService extends Disposable implements IAgentContextMemoryService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts index b69bf20851f..5cc73599973 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts @@ -3,7 +3,10 @@ * wire-Model factory. * * Defines the undo anchor vocabulary and registers conversation-time Models - * for undo validation. Scope-agnostic. + * for undo validation. `CHECKPOINTED_MODELS` stays the undo domain's read + * path; the `WireModelContribution` fold also drains it into the built-in + * layer so the checkpointed list is part of the folded wire vocabulary. + * Scope-agnostic. */ import { defineModel, type ModelDef } from '#/wire/model'; diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts index 937a5839db6..6d152f13845 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts @@ -6,8 +6,10 @@ */ import { createDecorator } from '#/_base/di/instantiation'; -import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { BugIndicatingError } from '#/errors'; export interface AgentConversationUndoParticipant { @@ -28,7 +30,7 @@ export const IAgentConversationUndoParticipantRegistry = ); class AgentConversationUndoParticipantRegistry - extends Disposable + extends Service implements IAgentConversationUndoParticipantRegistry { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts index 88227ba4e09..ca92b6205c3 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts @@ -26,7 +26,8 @@ */ import { createHash } from 'node:crypto'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { defineState } from '#/_base/state/stateRegistry'; import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender'; diff --git a/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts b/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts index f7aa2b2e2a4..dda9554408e 100644 --- a/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts +++ b/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts @@ -16,7 +16,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IAgentContextInjectorService, diff --git a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts index 7c3c30704b5..56ddf2aa0fd 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts @@ -22,8 +22,9 @@ */ import { IInstantiationService } from '#/_base/di/instantiation'; -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { isPlainRecord } from '#/_base/utils/canonical-args'; import { IAgentStateService } from '#/agent/state/agentState'; @@ -78,7 +79,7 @@ export const externalHooksStopHookContinuationUsedKey = defineState( () => false, ); -export class AgentExternalHooksService extends Disposable implements IAgentExternalHooksService { +export class AgentExternalHooksService extends Service implements IAgentExternalHooksService { declare readonly _serviceBrand: undefined; constructor( diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 6e6f328f5d1..825187ade89 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -17,9 +17,10 @@ * runs. */ -import { Disposable } from "#/_base/di/lifecycle"; +import { Service } from "#/_base/di/service"; import { IInstantiationService } from '#/_base/di/instantiation'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { defineState } from '#/_base/state/stateRegistry'; import { renderPrompt } from "#/_base/utils/render-prompt"; @@ -134,7 +135,7 @@ export const fullCompactionActiveTurnIdKey = defineState( () => undefined as number | undefined, ); -export class AgentFullCompactionService extends Disposable implements IAgentFullCompactionService { +export class AgentFullCompactionService extends Service implements IAgentFullCompactionService { declare readonly _serviceBrand: undefined; readonly hooks: IAgentFullCompactionService['hooks'] = { onWillCompact: new OrderedHookSlot(), diff --git a/packages/agent-core-v2/src/agent/goal/goalDeadlineSchedulerService.ts b/packages/agent-core-v2/src/agent/goal/goalDeadlineSchedulerService.ts index 9d74a2334f4..8e63f1199e0 100644 --- a/packages/agent-core-v2/src/agent/goal/goalDeadlineSchedulerService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalDeadlineSchedulerService.ts @@ -6,7 +6,8 @@ */ import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IGoalDeadlineScheduler } from './goalDeadlineScheduler'; diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 0b3633e2947..ae56369e307 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -42,7 +42,8 @@ import { randomUUID } from 'node:crypto'; import type { TurnEndedEvent, TurnStartedEvent } from '#/agent/loop/turnEvents'; import { Disposable, MutableDisposable, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { abortError } from '#/_base/utils/abort'; import { isPlainRecord } from '#/_base/utils/canonical-args'; @@ -276,6 +277,7 @@ export const goalResumeContinuationKey = defineState undefined as ResumeContinuation | undefined, ); +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class AgentGoalService extends Disposable implements IAgentGoalService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts b/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts index 040f75a082c..bc39fd2607d 100644 --- a/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts +++ b/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts @@ -1,5 +1,5 @@ import type { GoalSnapshot } from '#/agent/goal/types'; -import { Disposable } from "#/_base/di/lifecycle"; +import { Service } from "#/_base/di/service"; import { renderPrompt } from "#/_base/utils/render-prompt"; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import GOAL_ACTIVE_REMINDER from './goal-active-reminder.md?raw'; @@ -10,7 +10,7 @@ export interface GoalInjectionOptions { readonly getGoal: () => GoalSnapshot | null; } -export class GoalInjection extends Disposable { +export class GoalInjection extends Service { constructor( private readonly options: GoalInjectionOptions, @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts index 1974cd741bb..e3dadecb07e 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts @@ -7,8 +7,9 @@ * left pending by an interrupted restore. Bound at Agent scope. */ -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; @@ -28,7 +29,7 @@ const INTERRUPTION_REMINDER = [ ].join(' '); export class AgentInterruptionReminderService - extends Disposable + extends Service implements IAgentInterruptionReminderService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 2bd32c35204..92339a83df9 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -30,7 +30,8 @@ */ import { createHash } from 'node:crypto'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { diff --git a/packages/agent-core-v2/src/agent/loop/loopContinuationService.ts b/packages/agent-core-v2/src/agent/loop/loopContinuationService.ts index 8d05bf05381..8564f317cdf 100644 --- a/packages/agent-core-v2/src/agent/loop/loopContinuationService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopContinuationService.ts @@ -13,15 +13,16 @@ * registers before the first turn runs. */ -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentLoopContinuationService } from './loopContinuation'; import { IAgentLoopService } from './loop'; import { ContinuationStepRequest } from './stepRequest'; export class AgentLoopContinuationService - extends Disposable + extends Service implements IAgentLoopContinuationService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 68d2e9cd844..a6940a27b7f 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -34,7 +34,8 @@ import { randomUUID } from 'node:crypto'; import { createControlledPromise } from '@antfu/utils'; import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { abortError, isAbortError, isUserCancellation, userCancellationReason } from '#/_base/utils/abort'; import { toErrorMessage } from '#/_base/errors/errorMessage'; @@ -99,6 +100,7 @@ export const loopLastRequestTraceIdKey = defineState( ); export const loopDisposingKey = defineState('loop.disposing', () => false); +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class AgentLoopService extends Disposable implements IAgentLoopService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/mcp/mcpService.ts b/packages/agent-core-v2/src/agent/mcp/mcpService.ts index 2815e286f80..d6957ad3359 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpService.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpService.ts @@ -19,12 +19,13 @@ */ import { createHash } from 'node:crypto'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import type { Tool as KosongTool } from '#/kosong/contract/tool'; -import { Disposable, type IDisposable } from "#/_base/di/lifecycle"; +import { type IDisposable } from "#/_base/di/lifecycle"; +import { Service } from "#/_base/di/service"; import type { KimiErrorPayload } from '#/_base/errors/serialize'; import { ErrorCodes, makeErrorPayload } from "#/errors"; import { abortable } from '#/_base/utils/abort'; @@ -97,7 +98,7 @@ export const mcpDiscoveryWritesReadyKey = defineState( () => false, ); -export class AgentMcpService extends Disposable implements IAgentMcpService { +export class AgentMcpService extends Service implements IAgentMcpService { declare readonly _serviceBrand: undefined; private readonly mcpTools = new Map(); private readonly pendingDiscoveries: Array<() => void> = []; diff --git a/packages/agent-core-v2/src/agent/media/imageConfigBridge.ts b/packages/agent-core-v2/src/agent/media/imageConfigBridge.ts index dde7955f056..4b930b3e230 100644 --- a/packages/agent-core-v2/src/agent/media/imageConfigBridge.ts +++ b/packages/agent-core-v2/src/agent/media/imageConfigBridge.ts @@ -19,7 +19,8 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IConfigService } from '#/app/config/config'; import { setConfiguredMaxImageEdgePx, @@ -35,6 +36,7 @@ export interface IImageConfigBridge { export const IImageConfigBridge: ServiceIdentifier = createDecorator('imageConfigBridge'); +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class ImageConfigBridge extends Disposable implements IImageConfigBridge { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts index e0e843fe157..df70906c632 100644 --- a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts +++ b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts @@ -26,8 +26,10 @@ * bind runs, so the first `agent.status.updated` is always observed. */ -import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IAgentStateService } from '#/agent/state/agentState'; import { IEventBus } from '#/app/event/eventBus'; @@ -50,7 +52,7 @@ export const mediaRegisteredKeyKey = defineState( () => undefined as string | undefined, ); -export class AgentMediaToolsRegistrar extends Disposable implements IAgentMediaToolsRegistrar { +export class AgentMediaToolsRegistrar extends Service implements IAgentMediaToolsRegistrar { declare readonly _serviceBrand: undefined; private registration: IDisposable | undefined; diff --git a/packages/agent-core-v2/src/agent/media/videoResolverService.ts b/packages/agent-core-v2/src/agent/media/videoResolverService.ts index e9274d7b2e4..1aafe624d5f 100644 --- a/packages/agent-core-v2/src/agent/media/videoResolverService.ts +++ b/packages/agent-core-v2/src/agent/media/videoResolverService.ts @@ -24,8 +24,8 @@ */ import { createHash } from 'node:crypto'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IAgentStateService } from '#/agent/state/agentState'; import { IFileService } from '#/app/file/fileService'; diff --git a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts index 58263c0003d..99e12990bd8 100644 --- a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts +++ b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts @@ -11,8 +11,9 @@ * risk. Bound at Agent scope. */ -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentPermissionPolicyService } from '#/agent/permissionPolicy/permissionPolicy'; import type { PermissionData } from '#/agent/permissionPolicy/types'; @@ -28,7 +29,7 @@ import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentPermissionGate } from './permissionGate'; -export class AgentPermissionGate extends Disposable implements IAgentPermissionGate { +export class AgentPermissionGate extends Service implements IAgentPermissionGate { declare readonly _serviceBrand: undefined; constructor( @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, diff --git a/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts b/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts index b9a672a1b37..ab415d49aad 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts @@ -10,7 +10,7 @@ * `agentState` (`IAgentStateService`) and read/written through it. */ -import { Disposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { defineState } from '#/_base/state/stateRegistry'; import { IAgentContextInjectorService, @@ -29,7 +29,7 @@ export const permissionModeLastModeKey = defineState () => undefined as PermissionMode | undefined, ); -export class PermissionModeInjection extends Disposable { +export class PermissionModeInjection extends Service { constructor( private readonly permissionMode: Pick, @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts index 8dd72e955d1..b556bfd9e49 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts @@ -11,8 +11,9 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { IInstantiationService } from '#/_base/di/instantiation'; -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; import { PermissionModeInjection } from '#/agent/permissionMode/injection/permissionModeInjection'; import { IWireService } from '#/wire/wire'; @@ -23,7 +24,7 @@ import { setMode, } from './permissionModeOps'; -export class AgentPermissionModeService extends Disposable implements IAgentPermissionModeService { +export class AgentPermissionModeService extends Service implements IAgentPermissionModeService { declare readonly _serviceBrand: undefined; private readonly _onDidChangeMode = this._register(new Emitter()); diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts index ec7690daac6..c687fa19c4a 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts @@ -8,7 +8,7 @@ */ import { IInstantiationService } from "#/_base/di/instantiation"; -import { Disposable } from "#/_base/di/lifecycle"; +import { Service } from "#/_base/di/service"; import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; import { AutoModeApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/auto-mode-approve'; import { AutoModeAskUserQuestionDenyPermissionPolicyService } from '#/agent/permissionPolicy/policies/auto-mode-ask-user-question-deny'; @@ -27,10 +27,11 @@ import { type PermissionPolicyEvaluation, } from './permissionPolicy'; import type { PermissionPolicy } from "./types"; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; export class AgentPermissionPolicyService - extends Disposable + extends Service implements IAgentPermissionPolicyService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts index dcc4586bd83..d60f18ffe43 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts @@ -8,7 +8,9 @@ * consumers read the getters instead. Bound at Agent scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IWireService } from '#/wire/wire'; import { diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index 947a3055533..be043868a7c 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -10,8 +10,9 @@ * through `log`. Bound at Agent scope. */ -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { escapeXmlAttr } from '#/_base/utils/xml-escape'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; @@ -31,7 +32,7 @@ const SESSION_START_INJECTION_VARIANT = 'plugin_session_start'; const MAIN_AGENT_ID = 'main'; -export class AgentPluginService extends Disposable implements IAgentPluginService { +export class AgentPluginService extends Service implements IAgentPluginService { declare readonly _serviceBrand: undefined; constructor( diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index d84a60a8c5c..aa214aca13e 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -76,7 +76,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/kosong/contract/capability'; import { type SamplingOptions, type ThinkingEffort } from '#/kosong/contract/provider'; @@ -209,6 +210,7 @@ export const profileEmittedPluginBudgetWarningsKey = defineState>( () => new Set(), ); +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class AgentProfileService extends Disposable implements IAgentProfileService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 3f3dd91d100..efd40bac1c6 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -13,7 +13,8 @@ */ import { IInstantiationService } from '#/_base/di/instantiation'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { extractImageCompressionCaptions } from '#/agent/media/image-compress'; import { userCancellationReason } from '#/_base/utils/abort'; diff --git a/packages/agent-core-v2/src/agent/replayBuilder/types.ts b/packages/agent-core-v2/src/agent/replayBuilder/types.ts index 04f0bc1d073..8d4b6b49a99 100644 --- a/packages/agent-core-v2/src/agent/replayBuilder/types.ts +++ b/packages/agent-core-v2/src/agent/replayBuilder/types.ts @@ -5,7 +5,7 @@ import type { AgentContextData, ContextMessage } from '#/agent/contextMemory/typ import type { GoalChange, GoalSnapshot } from '#/agent/goal/types'; import type { PermissionApprovalResultRecord } from '#/agent/permissionRules/permissionRules'; import type { PermissionData, PermissionMode } from '#/agent/permissionPolicy/types'; -import type { PlanData } from '#/agent/plan/plan'; +import type { PlanData } from '#/features/plan/plan'; import type { ToolInfo } from '#/tool/toolContract'; import type { SessionSummary } from '#/agent/rpc/core-api'; import type { UsageStatus } from '#/agent/usage/usage'; diff --git a/packages/agent-core-v2/src/agent/rpc/core-api.ts b/packages/agent-core-v2/src/agent/rpc/core-api.ts index 4dd6391bd90..b28ecebbeb2 100644 --- a/packages/agent-core-v2/src/agent/rpc/core-api.ts +++ b/packages/agent-core-v2/src/agent/rpc/core-api.ts @@ -10,6 +10,7 @@ */ import type { AgentContextData } from '#/agent/contextMemory/types'; +import type { AgentCommandInfo } from '#/agent/command/agentCommand'; import type { GoalBudgetLimits, GoalBudgetReport, @@ -211,6 +212,11 @@ export interface ActivatePluginCommandPayload { readonly args?: string | undefined; } +export interface RunCommandPayload { + readonly name: string; + readonly args?: string | undefined; +} + export interface McpServerInfo { readonly name: string; readonly transport: 'stdio' | 'http' | 'sse'; @@ -303,6 +309,8 @@ export interface AgentAPI { cancelCompaction: (payload: EmptyPayload) => void; activateSkill: (payload: ActivateSkillPayload) => PromptLaunchResult | undefined; activatePluginCommand: (payload: ActivatePluginCommandPayload) => void; + listCommands: (payload: EmptyPayload) => readonly AgentCommandInfo[]; + runCommand: (payload: RunCommandPayload) => Promise; getContext: (payload: EmptyPayload) => AgentContextData; getTools: (payload: EmptyPayload) => readonly ToolInfo[]; } diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index cdee4020cc5..f3d089ab76f 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; @@ -13,6 +13,7 @@ import { IAgentLifecycleService, MAIN_AGENT_ID, } from '#/session/agentLifecycle/agentLifecycle'; +import { IAgentCommandService } from '#/agent/command/agentCommand'; import { expandCommandArguments } from '#/app/plugin/commands'; import { IPluginService } from '#/app/plugin/plugin'; import { ProfileError } from '#/agent/profile/profile'; @@ -32,6 +33,7 @@ import type { EmptyPayload, PromptLaunchResult, PromptPayload, + RunCommandPayload, SetPermissionPayload, SteerPayload, UndoHistoryPayload, @@ -82,6 +84,7 @@ export class AgentRPCService implements IAgentRPCService { @ISessionContext private readonly sessionContext: ISessionContext, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, + @IAgentCommandService private readonly commands: IAgentCommandService, ) { } async prompt(payload: PromptPayload): Promise { @@ -228,6 +231,14 @@ export class AgentRPCService implements IAgentRPCService { }; } + listCommands(_payload: EmptyPayload) { + return this.commands.list(); + } + + async runCommand(payload: RunCommandPayload): Promise { + return this.commands.run(payload.name, payload.args); + } + getTools(_payload: EmptyPayload) { return this.toolRegistry.list().map((tool) => ({ name: tool.name, diff --git a/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts b/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts index 3512eb8b3d0..df90eb2cf05 100644 --- a/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts +++ b/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts @@ -21,7 +21,9 @@ * stays an instance field (per-command `AbortController`s, not plain data). */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { userCancellationReason } from '#/_base/utils/abort'; import { escapeXml } from '#/_base/utils/xml-escape'; diff --git a/packages/agent-core-v2/src/agent/skill/skillService.ts b/packages/agent-core-v2/src/agent/skill/skillService.ts index 89b2139e0b9..2e056b10437 100644 --- a/packages/agent-core-v2/src/agent/skill/skillService.ts +++ b/packages/agent-core-v2/src/agent/skill/skillService.ts @@ -12,14 +12,15 @@ */ import { randomUUID } from 'node:crypto'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { ContentPart } from '#/kosong/contract/message'; import type { ContextMessage, SkillActivationOrigin } from '#/agent/contextMemory/types'; import { renderUserSlashSkillPrompt } from './prompt'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { Disposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { ErrorCodes, Error2 } from '#/errors'; import { isUserActivatableSkillType, type SkillDefinition } from '#/app/skillCatalog/types'; import { IAgentPromptService } from '#/agent/prompt/prompt'; @@ -30,7 +31,7 @@ import { IAgentSkillService, type SkillActivationInput } from './skill'; import { skillActivate } from './skillOps'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; -export class AgentSkillService extends Disposable implements IAgentSkillService { +export class AgentSkillService extends Service implements IAgentSkillService { declare readonly _serviceBrand: undefined; constructor( diff --git a/packages/agent-core-v2/src/agent/state/agentStateService.ts b/packages/agent-core-v2/src/agent/state/agentStateService.ts index 75a177aafd3..682e4386049 100644 --- a/packages/agent-core-v2/src/agent/state/agentStateService.ts +++ b/packages/agent-core-v2/src/agent/state/agentStateService.ts @@ -8,7 +8,9 @@ * injects). Bound at Agent scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { StateRegistry } from '#/_base/state/stateRegistry'; import { ISessionStateService } from '#/session/state/sessionState'; diff --git a/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts b/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts index 70cb2aa6fec..990f1daecbd 100644 --- a/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts +++ b/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts @@ -16,7 +16,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { DEFAULT_MAX_RETRY_ATTEMPTS, @@ -67,6 +68,7 @@ export const stepRetryFailedAttemptsKey = defineState( () => 0, ); +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class AgentStepRetryService extends Disposable implements IAgentStepRetryService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/swarm/swarmService.ts b/packages/agent-core-v2/src/agent/swarm/swarmService.ts index 901dd963cb1..fde33429be5 100644 --- a/packages/agent-core-v2/src/agent/swarm/swarmService.ts +++ b/packages/agent-core-v2/src/agent/swarm/swarmService.ts @@ -18,8 +18,9 @@ * reason. */ -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; @@ -32,7 +33,7 @@ import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw'; import { IAgentSwarmService, type SwarmModeTrigger } from './swarm'; import { swarmEnter, swarmExit, SwarmModel } from './swarmOps'; -export class AgentSwarmService extends Disposable implements IAgentSwarmService { +export class AgentSwarmService extends Service implements IAgentSwarmService { declare readonly _serviceBrand: undefined; constructor( diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts b/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts index 1c037efafa3..317fa17a9c6 100644 --- a/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts +++ b/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts @@ -1,11 +1,12 @@ -import { Disposable } from "#/_base/di/lifecycle"; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from "#/_base/di/service"; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; import { IAgentSystemReminderService } from './systemReminder'; -export class AgentSystemReminderService extends Disposable implements IAgentSystemReminderService { +export class AgentSystemReminderService extends Service implements IAgentSystemReminderService { declare readonly _serviceBrand: undefined; constructor( diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 2336e728b4e..8a733d88339 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -40,8 +40,8 @@ import { randomBytes } from 'node:crypto'; import { join } from 'pathe'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { ContentPart } from '#/kosong/contract/message'; @@ -249,6 +249,7 @@ export const taskActiveTaskReminderPendingKey = defineState( () => false, ); +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class AgentTaskService extends Disposable implements IAgentTaskService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingService.ts b/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingService.ts index c80cfa39fdd..4feb38200b3 100644 --- a/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingService.ts +++ b/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingService.ts @@ -20,7 +20,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IConfigService } from '#/app/config/config'; import { ContextModel } from '#/agent/contextMemory/contextOps'; import type { ContextMessage } from '#/agent/contextMemory/types'; diff --git a/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts b/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts index c54cdf44e31..07a911037e9 100644 --- a/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts +++ b/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts @@ -1,13 +1,16 @@ /** * `toolActivation` domain — `IAgentToolActivationService` contract. * - * Owns the activation pass that turns the module-level `registerAgentToolService` - * contributions (`toolRegistry`, L3) into entries of the per-agent runtime - * registry: a contribution activates only when its `when` predicate holds, - * the workspace os-level veto (`sessionToolPolicyGate`) does not disable it, - * and its declared `name` is allowed by the bound Profile's tool policy - * (`profile`, L4). One activation pass runs after restore and profile - * binding, so an Agent's tools reflect the Profile before the first turn. Bound at Agent scope. + * Owns the fold that turns the `AgentToolContribution` collection records + * (`toolRegistry`, L3 — built-in ones provided once by the App-scope + * assembly, dynamic ones provided by live units) into entries of the + * per-agent runtime registry: a record activates only when its `when` + * predicate holds, the workspace os-level veto (`sessionToolPolicyGate`) + * does not disable it, and its declared `name` is allowed by the bound + * Profile's tool policy (`profile`, L4); a withdrawn record unregisters its + * tool again. One full activation pass runs after restore and profile + * binding, so an Agent's tools reflect the Profile before the first turn. + * Bound at Agent scope. */ import { createDecorator } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts index fdeb583c5dc..886f646fcc2 100644 --- a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts +++ b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts @@ -1,49 +1,62 @@ /** * `toolActivation` domain — `IAgentToolActivationService` implementation. * - * Iterates the `toolRegistry` contribution table and, for each entry allowed - * by the workspace os-level veto (the seeded `sessionToolPolicyGate`) AND - * the bound Profile's tool policy (`profile`), resolves the Agent-scope - * service through the container — nothing constructs the tool before this - * `accessor.get` — and registers the real instance into the runtime - * registry. + * The fold over the `AgentToolContribution` collection (`toolRegistry`, L3): + * folds `view.items` into the per-agent runtime registry — for each record + * allowed by the workspace os-level veto (the seeded `sessionToolPolicyGate`) + * AND the bound Profile's tool policy (`profile`), it resolves the + * Agent-scope service through the container — nothing constructs the tool + * before this `accessor.get` — and registers the real instance into the + * runtime registry. * - * Activation runs once explicitly (after restore and profile binding) and + * The fold is incremental: `view.onDidChange` re-folds deltas — an `added` + * record walks the same activation judgment, a `removed` record (provider + * unit disposed) withdraws the tool from the runtime registry through the + * registration handle kept per record. Re-folding never gates the fold + * itself: collection edges never join a cascade contagion set. + * + * One full pass also runs explicitly (after restore and profile binding) and * re-runs on every `agent.status.updated` event, so tools newly allowed by a - * runtime re-bind or - * `setActiveTools` are activated without a restart. Already-registered names - * are skipped, and nothing is ever unregistered here: restricting visibility - * remains the request-time tool policy's job. + * runtime re-bind or `setActiveTools` are activated without a restart. + * Already-registered names are skipped, and besides withdrawn records + * nothing is ever unregistered here: restricting visibility remains the + * request-time tool policy's job. * - * Resolving contributions lazily inside `activate()` — never from the - * constructor — keeps the historical cycle broken: some tools (SkillTool → - * `prompt` → `loop` → `toolRegistry`) transitively depend on the tool - * registry, which by activation time has long finished constructing. Bound - * at Agent scope; the lifecycle's explicit `activate()` is the only - * resolution path. + * Resolving contributions lazily inside `activate()` / the change + * subscription — never from this service's own constructor — keeps the + * historical cycle broken: some tools (SkillTool → `prompt` → `loop` → + * `toolRegistry`) transitively depend on the tool registry, which by + * activation time has long finished constructing. Bound at Agent scope; the + * lifecycle's explicit `activate()` is the only full-resolution path. */ -import { Disposable } from '#/_base/di/lifecycle'; +import { type CollectionView } from '#/_base/di/collection'; import { IInstantiationService } from '#/_base/di/instantiation'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentProfileService } from '#/agent/profile/profile'; import { isToolActive } from '#/agent/toolPolicy/evaluate'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; -import { getAgentToolContributions } from '#/agent/toolRegistry/toolContribution'; +import { AgentToolContribution } from '#/agent/toolRegistry/toolContribution'; import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { IAgentToolActivationService } from './toolActivation'; -export class AgentToolActivationService extends Disposable implements IAgentToolActivationService { +export class AgentToolActivationService extends Service implements IAgentToolActivationService { declare readonly _serviceBrand: undefined; + private readonly registrations = new Map(); + constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @IAgentProfileService private readonly profile: IAgentProfileService, @ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate, @IEventBus eventBus: IEventBus, + @AgentToolContribution private readonly contributions: CollectionView, ) { super(); this._register( @@ -51,29 +64,50 @@ export class AgentToolActivationService extends Disposable implements IAgentTool void this.activate(); }), ); + this._register( + this.contributions.onDidChange((change) => { + this.activateRecords(change.added); + for (const record of change.removed) { + this.deactivateRecord(record); + } + }), + ); } activate(): Promise { + this.activateRecords(this.contributions.items); + return Promise.resolve(); + } + + private activateRecords(records: readonly AgentToolContribution[]): void { + if (records.length === 0) return; const data = this.profile.data(); const policy = { tools: data.activeToolNames, disallowedTools: data.disallowedTools }; const workspaceVeto = { disallowedTools: this.toolPolicyGate.disabledTools }; this.instantiationService.invokeFunction((accessor) => { - for (const { id, options } of getAgentToolContributions()) { + for (const record of records) { + const { id, options } = record; const source = options.source ?? 'builtin'; if (this.toolRegistry.resolve(options.name) !== undefined) continue; if (!isToolActive(workspaceVeto, options.name, source)) continue; if (!isToolActive(policy, options.name, source)) continue; if (options.when !== undefined && !options.when(accessor)) continue; const tool = accessor.get(id); - this._register( - this.toolRegistry.register(tool, { - source: options.source, - disclosure: options.disclosure, - }), - ); + const registration = this.toolRegistry.register(tool, { + source: options.source, + disclosure: options.disclosure, + }); + this.registrations.set(record, registration); + this._register(registration); } }); - return Promise.resolve(); + } + + private deactivateRecord(record: AgentToolContribution): void { + const registration = this.registrations.get(record); + if (registration === undefined) return; + this.registrations.delete(record); + registration.dispose(); } } diff --git a/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts index 407e07b9987..354d0bfc6bf 100644 --- a/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts +++ b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts @@ -10,8 +10,9 @@ */ import { IInstantiationService } from '#/_base/di/instantiation'; -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { abortable, isUserCancellation } from '#/_base/utils/abort'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { @@ -58,7 +59,7 @@ declare module '#/app/event/eventBus' { } } -export class AgentToolApprovalService extends Disposable implements IAgentToolApprovalService { +export class AgentToolApprovalService extends Service implements IAgentToolApprovalService { declare readonly _serviceBrand: undefined; constructor( diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index 864c905c1ff..617336bd9a0 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -17,8 +17,9 @@ import { createHash } from 'node:crypto'; -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { canonicalTelemetryArgs } from '#/_base/utils/canonical-args'; import type { ToolCallDedupDetectedEvent, ToolCallRepeatEvent } from '#/app/telemetry/events'; @@ -141,7 +142,7 @@ export const toolDedupeActiveTurnIdKey = defineState( ); export const toolDedupeActiveStepKey = defineState('toolDedupe.activeStep', () => 0); -export class AgentToolDedupeService extends Disposable implements IAgentToolDedupeService { +export class AgentToolDedupeService extends Service implements IAgentToolDedupeService { declare readonly _serviceBrand: undefined; private readonly stepDeferreds = new Map>(); diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 17b6b8defba..8f568065097 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -15,7 +15,8 @@ */ import { toDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { AsyncEmitter, type Event } from '#/_base/event'; import { defineState } from '#/_base/state/stateRegistry'; import type { ContentPart, ToolCall } from '#/kosong/contract/message'; diff --git a/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts index 51dfc0acd84..ff7fb53c8d6 100644 --- a/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts +++ b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts @@ -12,7 +12,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentProfileService, ProfileError, ProfileErrors } from '#/agent/profile/profile'; import { TOOLS_SECTION, type ToolsConfig } from './configSection'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -25,6 +26,7 @@ import type { ToolSource } from '#/tool/toolContract'; import { isToolActiveComposed, type ToolActivationPolicy } from './evaluate'; import { IAgentToolPolicyService } from './toolPolicy'; +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class AgentToolPolicyService extends Disposable implements IAgentToolPolicyService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/toolRegistry/builtinToolAssemblyService.ts b/packages/agent-core-v2/src/agent/toolRegistry/builtinToolAssemblyService.ts new file mode 100644 index 00000000000..38f1de5522c --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolRegistry/builtinToolAssemblyService.ts @@ -0,0 +1,47 @@ +/** + * `toolRegistry` domain — the built-in tool assembly unit. + * + * The one bridge from the static contribution table into the collection + * world: constructed once at App-scope creation, it provides every + * module-level `registerAgentToolService` contribution (import = register) + * into the `AgentToolContribution` collection. Ancestor visibility lets + * every Agent scope's fold (`AgentToolActivationService`) see these + * records; withdrawing is not a built-in concept (the table is static), so + * the records live as long as this unit. The table itself stays the static + * data channel for the readers that only need names (profile typo + * warnings, agent-tool descriptions). Bound at App scope. + */ + +import { createDecorator } from '#/_base/di/instantiation'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import { AgentToolContribution, getAgentToolContributions } from './toolContribution'; + +export interface IBuiltinToolAssemblyService { + readonly _serviceBrand: undefined; +} + +export const IBuiltinToolAssemblyService = createDecorator( + 'builtinToolAssemblyService', +); + +export class BuiltinToolAssemblyService extends Service implements IBuiltinToolAssemblyService { + declare readonly _serviceBrand: undefined; + + constructor() { + super(); + for (const record of getAgentToolContributions()) { + this.provide(AgentToolContribution, record); + } + } +} + +registerScopedService( + LifecycleScope.App, + IBuiltinToolAssemblyService, + BuiltinToolAssemblyService, + ScopeActivation.OnScopeCreated, + 'toolRegistry', +); diff --git a/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts b/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts index d0a683a515a..2ccc8481ec9 100644 --- a/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts +++ b/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts @@ -10,11 +10,13 @@ * when their host capability is absent (e.g. `WebSearchTool` without a * configured provider), and the runtime registry always holds real instances, * never proxies. - * `AgentToolActivationService` consumes the table when - * an Agent is created: for each contribution whose `when` predicate holds and - * whose `name` the bound Profile's tool policy allows, it resolves the - * service through the container (`accessor.get`, triggering construction) and - * registers it into the per-agent runtime registry. The + * The App-scope built-in assembly (`builtinToolAssemblyService`) provides + * the table into the `AgentToolContribution` collection once at App-scope + * creation; the fold (`AgentToolActivationService`) consumes the collection + * view when an Agent is created: for each record whose `when` predicate + * holds and whose `name` the bound Profile's tool policy allows, it + * resolves the service through the container (`accessor.get`, triggering + * construction) and registers it into the per-agent runtime registry. The * declared `name` is what lets activation filter without instantiating. * * `registerAgentToolService` is deliberately not "builtin"-scoped: the same API is @@ -29,7 +31,9 @@ */ import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { collection } from '#/_base/di/collection'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { AgentTool, ToolDisclosure, @@ -56,6 +60,8 @@ export interface AgentToolContribution { readonly options: AgentToolContributionOptions; } +export const AgentToolContribution = collection('agent-tool'); + const _agentToolContributions: AgentToolContribution[] = []; export function registerAgentToolService( diff --git a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts index 9bd76277a4b..3ad9704bdd2 100644 --- a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts +++ b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts @@ -7,7 +7,8 @@ */ import { toDisposable, type IDisposable } from "#/_base/di/lifecycle"; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { ExecutableTool, ToolDisclosure, @@ -20,6 +21,8 @@ import { type ToolRegistrationOptions, } from './toolRegistry'; +import './builtinToolAssemblyService'; + interface ToolEntry { readonly tool: ExecutableTool; readonly source: ToolSource; diff --git a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts index d6460173659..54435cd2703 100644 --- a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts +++ b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts @@ -8,8 +8,8 @@ */ import { randomUUID } from 'node:crypto'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import type { ExecutableToolResult } from '#/tool/toolContract'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts index 9cbffa0239c..3338fbdc22c 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts @@ -11,8 +11,9 @@ * (`IAgentStateService`) and read/written through it. Bound at Agent scope. */ -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentStateService } from '#/agent/state/agentState'; @@ -28,7 +29,7 @@ export const toolSelectNeedsBoundaryInjectionKey = defineState( () => false, ); -export class AgentToolSelectAnnouncementsService extends Disposable implements IAgentToolSelectAnnouncementsService { +export class AgentToolSelectAnnouncementsService extends Service implements IAgentToolSelectAnnouncementsService { declare readonly _serviceBrand: undefined; constructor( diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts index e2cae477eed..fa4c3489eb9 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -11,8 +11,9 @@ * at Agent scope. */ -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IEventBus } from '#/app/event/eventBus'; import { IFlagService } from '#/app/flag/flag'; @@ -46,7 +47,7 @@ export const toolSelectPendingLoadedKey = defineState>( () => new Set(), ); -export class AgentToolSelectService extends Disposable implements IAgentToolSelectService { +export class AgentToolSelectService extends Service implements IAgentToolSelectService { declare readonly _serviceBrand: undefined; constructor( diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts index a05c5d46cea..b7d1a0b586a 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts @@ -95,11 +95,6 @@ export class AgentSwarmTool implements IAgentSwarmTool { declare readonly _serviceBrand: undefined; readonly name = 'AgentSwarm' as const; - /** - * The `model` choice only exists while the `secondary-model` experiment is - * on; off, the advertised schema drops it so the concept never enters the - * prompt. Read live per request (same as `description`). - */ get parameters(): Record { return this.flags.enabled(SECONDARY_MODEL_FLAG_ID) ? AGENT_SWARM_PARAMETERS diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index efcb7305b4c..ffefcdebb02 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -113,11 +113,6 @@ export class SubagentTool implements ISubagentTool { declare readonly _serviceBrand: undefined; readonly name: string = 'Agent'; - /** - * The `model` choice only exists while the `secondary-model` experiment is - * on; off, the advertised schema drops it so the concept never enters the - * prompt. Read live per request (same as `description`). - */ get parameters(): Record { return this.flags.enabled(SECONDARY_MODEL_FLAG_ID) ? SUBAGENT_TOOL_PARAMETERS diff --git a/packages/agent-core-v2/src/agent/tools/cron/cron-create/cronCreateTool.ts b/packages/agent-core-v2/src/agent/tools/cron/cron-create/cronCreateTool.ts index 1a945b6c6cf..0a844ef0b19 100644 --- a/packages/agent-core-v2/src/agent/tools/cron/cron-create/cronCreateTool.ts +++ b/packages/agent-core-v2/src/agent/tools/cron/cron-create/cronCreateTool.ts @@ -30,7 +30,9 @@ * expression parsing and timestamp formatting. Bound at Agent scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { ToolExecution } from '#/tool/toolContract'; import { toInputJsonSchema } from '#/tool/input-schema'; import { literalRulePattern } from '#/tool/rule-match'; diff --git a/packages/agent-core-v2/src/agent/tools/cron/cron-delete/cronDeleteTool.ts b/packages/agent-core-v2/src/agent/tools/cron/cron-delete/cronDeleteTool.ts index 0b19124b007..76c08f919af 100644 --- a/packages/agent-core-v2/src/agent/tools/cron/cron-delete/cronDeleteTool.ts +++ b/packages/agent-core-v2/src/agent/tools/cron/cron-delete/cronDeleteTool.ts @@ -41,7 +41,9 @@ * id. Bound at Agent scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { ToolExecution } from '#/tool/toolContract'; import { toInputJsonSchema } from '#/tool/input-schema'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; diff --git a/packages/agent-core-v2/src/agent/tools/cron/cron-list/cronListTool.ts b/packages/agent-core-v2/src/agent/tools/cron/cron-list/cronListTool.ts index d736707c085..85a19cbb14b 100644 --- a/packages/agent-core-v2/src/agent/tools/cron/cron-list/cronListTool.ts +++ b/packages/agent-core-v2/src/agent/tools/cron/cron-list/cronListTool.ts @@ -44,7 +44,9 @@ * for expression parsing and timestamp formatting. Bound at Agent scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { ToolExecution } from '#/tool/toolContract'; import { toInputJsonSchema } from '#/tool/input-schema'; import { ISessionCronService } from '#/session/cron/sessionCronService'; diff --git a/packages/agent-core-v2/src/agent/undo/undoService.ts b/packages/agent-core-v2/src/agent/undo/undoService.ts index 6cf930d3f74..e9223331809 100644 --- a/packages/agent-core-v2/src/agent/undo/undoService.ts +++ b/packages/agent-core-v2/src/agent/undo/undoService.ts @@ -7,8 +7,10 @@ * `eventBus`, `telemetry`, and `wire`. Bound at Agent scope. */ -import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants'; @@ -46,7 +48,7 @@ declare module '#/app/event/eventBus' { } export class AgentConversationUndoService - extends Disposable + extends Service implements IAgentConversationUndoService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/usage/usageService.ts b/packages/agent-core-v2/src/agent/usage/usageService.ts index 95b0c7aff7c..aa8342238d4 100644 --- a/packages/agent-core-v2/src/agent/usage/usageService.ts +++ b/packages/agent-core-v2/src/agent/usage/usageService.ts @@ -14,8 +14,9 @@ */ import { addUsage, type TokenUsage } from '#/kosong/contract/usage'; -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; import { defineState } from '#/_base/state/stateRegistry'; @@ -42,7 +43,7 @@ export const usageCurrentTurnKey = defineState( () => undefined as TokenUsage | undefined, ); -export class AgentUsageService extends Disposable implements IAgentUsageService { +export class AgentUsageService extends Service implements IAgentUsageService { declare readonly _serviceBrand: undefined; private readonly _onDidRecord = this._register(new Emitter()); diff --git a/packages/agent-core-v2/src/agent/userTool/userToolService.ts b/packages/agent-core-v2/src/agent/userTool/userToolService.ts index 60c6cf98a2a..9719a7b8d26 100644 --- a/packages/agent-core-v2/src/agent/userTool/userToolService.ts +++ b/packages/agent-core-v2/src/agent/userTool/userToolService.ts @@ -17,8 +17,10 @@ * Bound at Agent scope. */ -import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { abortable } from '#/_base/utils/abort'; import { IAgentProfileService } from '#/agent/profile/profile'; import type { @@ -40,7 +42,7 @@ interface UserToolExecutionRequest { readonly args: unknown; } -export class AgentUserToolService extends Disposable implements IAgentUserToolService { +export class AgentUserToolService extends Service implements IAgentUserToolService { declare readonly _serviceBrand: undefined; private readonly registrations = new Map(); diff --git a/packages/agent-core-v2/src/app/agentIdentity/agentIdentityService.ts b/packages/agent-core-v2/src/app/agentIdentity/agentIdentityService.ts index 7a87be577ce..1aa2340b850 100644 --- a/packages/agent-core-v2/src/app/agentIdentity/agentIdentityService.ts +++ b/packages/agent-core-v2/src/app/agentIdentity/agentIdentityService.ts @@ -11,7 +11,8 @@ * consumer would read. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { CoreErrors } from '#/_base/errors/codes'; import { Error2 } from '#/_base/errors/errors'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index 95231dea5e8..9f20446527a 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -29,12 +29,13 @@ * Profiles reach agents through the Contribution / Registry / Catalog * extension point: loaders (builtin code contributions via * `registerAgentProfile(...)`, plugin / user file scans at App scope, - * workspace / extra / explicit file scans at Workspace scope) register - * `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, - * keyed by source id; the Session-scope `ISessionAgentProfileCatalog` - * projects the registry into the merged, name-deduped read view that - * consumers (the `Agent` tool, the swarm scheduler, the per-agent profile - * binding) resolve profiles through. + * workspace / extra / explicit file scans at Workspace scope) contribute + * `AgentProfileContribution` records to the collection, keyed by source id; + * the App-scope `IAgentProfileRegistry` fold projects them into its read + * surface, and the Session-scope `ISessionAgentProfileCatalog` projects the + * registry into the merged, name-deduped read view that consumers (the + * `Agent` tool, the swarm scheduler, the per-agent profile binding) resolve + * profiles through. */ import type { ILogger } from '#/_base/log/log'; diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileContribution.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileContribution.ts index 755617bfa97..e64cc76ee06 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileContribution.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileContribution.ts @@ -3,11 +3,11 @@ * source priorities. * * `AgentProfileContribution` is the Contribution of the agent-profile - * extension point: the plain data structure a loader registers into the - * App-scope `IAgentProfileRegistry` under its source id. It is pure payload — - * the source id and priority are registration metadata passed to `register`, - * never part of the contribution. Name-level dedup is NOT done here or in the - * registry; it is the Session catalog's projection job. + * extension point: the plain data structure a loader contributes to the + * `AgentProfileContribution` collection under its source id. It is pure + * payload — the source id and priority are record metadata carried alongside + * it, never part of the contribution. Name-level dedup is NOT done here or in + * the registry fold; it is the Session catalog's projection job. * * `AGENT_PROFILE_SOURCE_PRIORITY` orders the sources for that projection * (higher wins name collisions), with one deliberate deviation from the skill @@ -16,6 +16,7 @@ * that must always win. */ +import { collection } from '#/_base/di/collection'; import type { AgentProfile } from './agentProfileCatalog'; export interface SkippedAgentFile { @@ -29,6 +30,15 @@ export interface AgentProfileContribution { readonly scannedRoots?: readonly string[]; } +export interface AgentProfileContributionRecord { + readonly sourceId: string; + readonly priority?: number; + readonly workspaceKey?: string; + readonly contribution: AgentProfileContribution; +} + +export const AgentProfileContribution = collection('agent-profile'); + export const AGENT_PROFILE_SOURCE_PRIORITY = { builtin: 0, plugin: 5, diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistry.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistry.ts index 36abb94b73f..d3ae51453da 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistry.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistry.ts @@ -2,26 +2,29 @@ * `agentProfileCatalog` domain — `IAgentProfileRegistry` contract. * * The Registry of the Contribution / Registry / Catalog extension-point - * pattern for agent profiles. A contribution is a plain data structure - * (`AgentProfileContribution`) offered by a loader; the registry stores at - * most one contribution per (`sourceId`, `workspaceKey`) pair — re-registering - * replaces the previous entry, which is the only dedup this layer performs. - * Name-level dedup, priority ordering, and the builtin-override rule are the - * Catalog's projection job (`ISessionAgentProfileCatalog`), never the - * registry's. + * pattern for agent profiles, surfaced as a fold over the + * `AgentProfileContribution` collection (D12): loaders contribute records + * with `this.provide(AgentProfileContribution, …)` — there is no register + * API — and the App-scope fold projects the live collection view into this + * read surface. The fold keeps at most one contribution per (`sourceId`, + * `workspaceKey`) pair — a later record for the same pair shadows the + * earlier one, the old re-register-replaces semantics — which is the only + * dedup this layer performs. Name-level dedup, priority ordering, and the + * builtin-override rule are the Catalog's projection job + * (`ISessionAgentProfileCatalog`), never the registry's. * - * Bound at App scope so contributors from ANY scope can register: App loaders - * (builtin / plugin / user) register global contributions (`workspaceKey` - * absent), while each Workspace-scope loader registers its workspace-local - * contribution tagged with the handler's `workspaceKey`, and multiple - * workspaces never collide. `register` returns a handle whose `dispose` - * unregisters — but only the entry it registered, so a stale handle can never - * evict a newer re-registration. Every mutation fires `onDidChange` with the - * affected (sourceId, workspaceKey) so session catalogs can re-project. + * Bound at App scope so records from ANY scope land in the projection: App + * loaders (builtin) contribute global records (`workspaceKey` absent), while + * each Workspace-scope loader contributes its workspace-local record tagged + * with the handler's `workspaceKey`, and multiple workspaces never collide. + * A record dies with its providing unit — a reload replaces it, a dead + * workspace handler withdraws its records — and withdrawing a shadowed + * record stays silent: a stale contribution can never evict the current + * one. Every projection change fires `onDidChange` with the affected + * (sourceId, workspaceKey) so session catalogs can re-project. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { IDisposable } from '#/_base/di/lifecycle'; import type { Event } from '#/_base/event'; import type { AgentProfileContribution } from './agentProfileContribution'; @@ -37,24 +40,11 @@ export interface AgentProfileRegistryChange { readonly workspaceKey?: string; } -export interface RegisterAgentProfileOptions { - readonly priority?: number; - readonly workspaceKey?: string; -} - export interface IAgentProfileRegistry { readonly _serviceBrand: undefined; readonly onDidChange: Event; - register( - sourceId: string, - contribution: AgentProfileContribution, - options?: RegisterAgentProfileOptions, - ): IDisposable; - - unregister(sourceId: string, workspaceKey?: string): void; - entries(): readonly AgentProfileRegistration[]; } diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistryService.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistryService.ts index 00cc0ad3d06..f719e1e4779 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistryService.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistryService.ts @@ -1,25 +1,33 @@ /** - * `agentProfileCatalog` domain — `IAgentProfileRegistry` impl. + * `agentProfileCatalog` domain — `IAgentProfileRegistry` impl: the fold of + * the agent-profile contribution point. * - * App-scope singleton backed by the generic `ContributionRegistry`: storage - * keys encode the (sourceId, workspaceKey) pair so a workspace-local source id - * (`workspace`, `extra`, `explicit`) coexists across handlers, while global - * sources (`builtin`, `plugin`, `user`) register once. The registry is pure - * storage — merging, name dedup, and override rules live in the Session-scope - * catalog projection. + * App-scope singleton projecting the live `AgentProfileContribution` + * collection view: storage keys encode the (sourceId, workspaceKey) pair so a + * workspace-local source id (`workspace`, `extra`, `explicit`) coexists + * across handlers, while global sources (`builtin`) appear once; a later + * record for the same pair shadows the earlier one (the old + * re-register-replaces semantics). The fold is pure storage — merging, name + * dedup, and override rules live in the Session-scope catalog projection. + * Change events reproduce the old registry's exactly: a pair fires only when + * its winning record actually changes, so a reload's record swap fires once + * while a shadowed record's withdrawal stays silent. */ -import { ContributionRegistry } from '#/_base/contribution/registry'; -import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; -import type { Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import type { AgentProfileContribution } from './agentProfileContribution'; +import { type CollectionChange, type CollectionView } from '#/_base/di/collection'; +import { Service } from '#/_base/di/service'; +import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { + AgentProfileContribution, + type AgentProfileContributionRecord, +} from './agentProfileContribution'; import type { AgentProfileRegistration, AgentProfileRegistryChange, IAgentProfileRegistry, - RegisterAgentProfileOptions, } from './agentProfileRegistry'; import { IAgentProfileRegistry as IAgentProfileRegistryDecorator } from './agentProfileRegistry'; @@ -33,42 +41,60 @@ function decodeKey(key: string): AgentProfileRegistryChange { } export class AgentProfileRegistryService - extends Disposable + extends Service implements IAgentProfileRegistry { declare readonly _serviceBrand: undefined; - private readonly registry = this._register( - new ContributionRegistry(), + private readonly onDidChangeEmitter = this._register( + new Emitter(), ); + readonly onDidChange: Event = this.onDidChangeEmitter.event; - readonly onDidChange: Event = (listener, thisArg, disposables) => - this.registry.onDidChange( - (key) => listener.call(thisArg, decodeKey(key)), - undefined, - disposables, + private folded: ReadonlyMap = new Map(); + + constructor( + @AgentProfileContribution + private readonly view: CollectionView, + ) { + super(); + this.refold(); + this._register( + this.view.onDidChange((change) => { + this.onViewChange(change); + }), ); + } - register( - sourceId: string, - contribution: AgentProfileContribution, - options?: RegisterAgentProfileOptions, - ): IDisposable { - const registration: AgentProfileRegistration = { - sourceId, - priority: options?.priority ?? 0, - workspaceKey: options?.workspaceKey, - contribution, - }; - return this.registry.register(encodeKey(sourceId, options?.workspaceKey), registration); + entries(): readonly AgentProfileRegistration[] { + return [...this.folded.values()].map((record) => ({ + sourceId: record.sourceId, + priority: record.priority ?? 0, + workspaceKey: record.workspaceKey, + contribution: record.contribution, + })); } - unregister(sourceId: string, workspaceKey?: string): void { - this.registry.unregister(encodeKey(sourceId, workspaceKey)); + private onViewChange(change: CollectionChange): void { + const previous = this.folded; + const affected = new Set(); + for (const record of [...change.removed, ...change.added]) { + affected.add(encodeKey(record.sourceId, record.workspaceKey)); + } + this.refold(); + for (const key of affected) { + if (previous.get(key) !== this.folded.get(key)) { + this.onDidChangeEmitter.fire(decodeKey(key)); + } + } } - entries(): readonly AgentProfileRegistration[] { - return this.registry.entries().map((entry) => entry.contribution); + private refold(): void { + const next = new Map(); + for (const record of this.view.records) { + next.set(encodeKey(record.value.sourceId, record.value.workspaceKey), record.value); + } + this.folded = next; } } diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoader.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoader.ts index cc370f2c983..92309c9f14a 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoader.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoader.ts @@ -2,7 +2,7 @@ * `agentProfileCatalog` domain — `IBuiltinAgentProfileLoader` contract. * * The builtin loader of the agent-profile extension point: owns the global - * `builtin` contribution (priority 0) in the App-scope `IAgentProfileRegistry` + * `builtin` record (priority 0) of the `AgentProfileContribution` collection * — the code-defined profiles accumulated at module load via * `registerAgentProfile(...)`. Also exposes the static `get` / `getDefault` / * `list` read view for loader-time consumers that need the builtin default diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoaderService.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoaderService.ts index 06ad5bd894c..81fb522ab15 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoaderService.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoaderService.ts @@ -2,8 +2,9 @@ * `agentProfileCatalog` domain — `IBuiltinAgentProfileLoader` implementation. * * Snapshots the module-level contributions (`registerAgentProfile`, the - * "import = register" pattern) on construction and registers them into - * `IAgentProfileRegistry`. Register-after-construction is not supported: like + * "import = register" pattern) on construction and contributes them to the + * `AgentProfileContribution` collection as the global `builtin` record. + * Register-after-construction is not supported: like * `IAgentToolRegistryService`, contributions are expected to accumulate at * import time before the container resolves the service. `getDefault()` * throws a `BugIndicatingError` when the builtin default profile is missing — a @@ -11,20 +12,27 @@ * scope. */ +import { IInstantiationService } from '#/_base/di/instantiation'; import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { BugIndicatingError } from '#/errors'; import type { AgentProfile } from './agentProfileCatalog'; import { DEFAULT_AGENT_PROFILE_NAME } from './agentProfileCatalog'; -import { AGENT_PROFILE_SOURCE_PRIORITY } from './agentProfileContribution'; -import { IAgentProfileRegistry } from './agentProfileRegistry'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + AgentProfileContribution, + type AgentProfileContributionRecord, +} from './agentProfileContribution'; import { BUILTIN_AGENT_PROFILE_SOURCE_ID, IBuiltinAgentProfileLoader, } from './builtinAgentProfileLoader'; import { getAgentProfileContributions } from './contribution'; +// NOTE: stays Disposable — its own 'get' collides with the Fiber export class BuiltinAgentProfileLoaderService extends Disposable implements IBuiltinAgentProfileLoader @@ -34,17 +42,17 @@ export class BuiltinAgentProfileLoaderService private readonly byName: Map; private readonly ordered: readonly AgentProfile[]; - constructor(@IAgentProfileRegistry registry: IAgentProfileRegistry) { + constructor(@IInstantiationService instantiationService: IInstantiationService) { super(); const contributions = getAgentProfileContributions(); this.ordered = [...contributions]; this.byName = new Map(this.ordered.map((def) => [def.name, def])); this._register( - registry.register( - BUILTIN_AGENT_PROFILE_SOURCE_ID, - { profiles: this.ordered }, - { priority: AGENT_PROFILE_SOURCE_PRIORITY.builtin }, - ), + instantiationService.createInstance(BuiltinAgentProfileContributionUnit, { + sourceId: BUILTIN_AGENT_PROFILE_SOURCE_ID, + priority: AGENT_PROFILE_SOURCE_PRIORITY.builtin, + contribution: { profiles: this.ordered }, + }), ); } @@ -67,6 +75,13 @@ export class BuiltinAgentProfileLoaderService } } +class BuiltinAgentProfileContributionUnit extends Service { + constructor(record: AgentProfileContributionRecord) { + super(); + this.provide(AgentProfileContribution, record); + } +} + registerScopedService( LifecycleScope.App, IBuiltinAgentProfileLoader, diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts index bee3c64b362..e0584de8ff0 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts @@ -2,7 +2,7 @@ * `agentProfileCatalog` domain — shared prompt helpers for builtin profiles. * * Keeps the base system-prompt template and the task-agent role prefix in the - * registry domain. + * agent-profile domain. * * All system-prompt rendering — the builtin template, `SYSTEM.md`, and agent * files — shares one `${var}` substitution pass over one variable table diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index 9b581c45345..ebffcea60c2 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -44,7 +44,8 @@ import type { } from './oauthProtocol'; import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2, ErrorCodes } from '#/errors'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; @@ -100,6 +101,7 @@ interface FlowState { resolvedAt: string | undefined; } +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class OAuthService extends Disposable implements IOAuthService { declare readonly _serviceBrand: undefined; private readonly flows = new Map(); diff --git a/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts b/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts index 3ddbe07639c..087ac27144c 100644 --- a/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts +++ b/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts @@ -26,8 +26,8 @@ import { kimiCodeBaseUrl, type BearerTokenProvider, } from '@moonshot-ai/kimi-code-oauth'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IOAuthService } from '#/app/auth/auth'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; diff --git a/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts b/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts index 3b669f1636d..1adede3e5a2 100644 --- a/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts +++ b/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts @@ -11,8 +11,8 @@ import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; import type { AuthSummary } from './authLegacy'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IOAuthService } from '#/app/auth/auth'; import { IModelService } from '#/kosong/model/model'; import { IProviderService } from '#/kosong/provider/provider'; diff --git a/packages/agent-core-v2/src/app/bashParser/bashParserService.ts b/packages/agent-core-v2/src/app/bashParser/bashParserService.ts index 54b2b8c8db7..52ac2b9699f 100644 --- a/packages/agent-core-v2/src/app/bashParser/bashParserService.ts +++ b/packages/agent-core-v2/src/app/bashParser/bashParserService.ts @@ -14,8 +14,8 @@ import { parse } from '@moonshot-ai/tree-sitter-bash'; import type { SyntaxNode } from '@moonshot-ai/tree-sitter-bash'; - -import { LifecycleScope, registerScopedService, ScopeActivation } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { registerScopedService, ScopeActivation } from '#/_base/di/scope'; import type { BashParseOptions, BashParseResult, BashSyntaxNode } from './bashParser'; import { IBashParserService } from './bashParser'; diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index 64ebf35a080..b80c1a00a02 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -34,38 +34,14 @@ import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageSe import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -/** - * Host invocation arguments — process-level overrides the embedding host - * states once at startup (mirrors VS Code's `NativeParsedArgs` carried on the - * environment service). Resolved from {@link HostArgsInput} and read via - * `IBootstrapService.args`. - */ export interface HostArgs { - /** - * Explicit agent definition files for this process (the CLI's - * `--agent-file`): loaded as the highest-priority `explicit` agent-profile - * source. Undefined means no explicit files. - */ readonly agentFiles?: readonly string[]; - /** - * Explicit skill directories for this process (v1's SDK `skillDirs`): when - * non-empty, default user / project skill discovery is skipped and these - * directories serve as the user skill source. - */ readonly skillDirs?: readonly string[]; - /** - * Host identity headers applied to outbound provider requests (User-Agent + - * `X-Msh-*`, built by the host through `createKimiDefaultHeaders`). - * Materialized to `{}` when the host passes none. - */ readonly requestHeaders: Readonly>; - /** Fills the `${product_name}` slot in the base system-prompt template. */ readonly displayName?: string; - /** Replaces the `${reply_style_guide}` block in the base system prompt. */ readonly replyStyleGuide?: string; } -/** {@link HostArgs} as accepted from the host: `requestHeaders` may be omitted. */ export interface HostArgsInput { readonly agentFiles?: readonly string[]; readonly skillDirs?: readonly string[]; @@ -119,7 +95,6 @@ export interface IBootstrapService { readonly homeDir: string; readonly configPath: string; readonly clientIdentity: KimiHostIdentity; - /** Host invocation arguments; see {@link HostArgs}. */ readonly args: HostArgs; readonly sessionsDir: string; readonly blobsDir: string; @@ -142,10 +117,7 @@ export interface BootstrapInput { readonly platform?: NodeJS.Platform; readonly arch?: string; readonly cwd?: string; - /** Required: every process names its host. There is deliberately no default - — a fabricated identity would silently misreport the host upstream. */ readonly clientIdentity: KimiHostIdentity; - /** Host invocation arguments; see {@link HostArgsInput}. */ readonly args?: HostArgsInput; } @@ -168,7 +140,12 @@ export function resolveBootstrapOptions(input: BootstrapInput): IBootstrapOption } export function bootstrapSeed(input: BootstrapInput): ScopeSeed { - return [[IBootstrapOptions as ServiceIdentifier, resolveBootstrapOptions(input)]]; + return [ + [ + IBootstrapOptions as ServiceIdentifier, + resolveBootstrapOptions(input), + ], + ]; } export interface BootstrapResult { diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts index 836512cafbc..711e67c1b44 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts @@ -12,8 +12,8 @@ import { basename, join, relative } from 'pathe'; import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IBootstrapOptions, diff --git a/packages/agent-core-v2/src/app/capability/capabilityService.ts b/packages/agent-core-v2/src/app/capability/capabilityService.ts index 2903f403ee1..8c42563ac41 100644 --- a/packages/agent-core-v2/src/app/capability/capabilityService.ts +++ b/packages/agent-core-v2/src/app/capability/capabilityService.ts @@ -11,7 +11,8 @@ import { homedir } from 'node:os'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { Error2 } from '#/errors'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts index c0d668b7d0f..24eca802983 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -170,8 +170,10 @@ export interface IConfigRegistry { readonly _serviceBrand: undefined; readonly onDidRegisterSection: Event; + readonly onDidUnregisterSection: Event; readonly onDidRegisterOverlay: Event; registerSection(domain: string, schema: ConfigSchema, options?: RegisterSectionOptions): void; + unregisterSection(domain: string): void; getSection(domain: string): ConfigSection | undefined; listSections(): readonly ConfigSection[]; registerEffectiveOverlay(overlay: ConfigEffectiveOverlay): void; @@ -250,16 +252,7 @@ export interface IConfigService { inspect(domain: string): ConfigInspectValue; getAll(): ResolvedConfig; set(domain: string, patch: unknown, target?: ConfigTarget): Promise; - /** - * Replace one domain wholesale; `undefined` (or `null`, the wire encoding - * of clear — JSON transports cannot carry `undefined`) removes the domain. - */ replace(domain: string, value: unknown, target?: ConfigTarget): Promise; - /** - * Replace several domains in ONE atomic write: a domain mapped to - * `undefined` (or `null`, see {@link replace}) is cleared, domains absent - * from `sections` are left untouched. - */ replaceSections( sections: Readonly>, target?: ConfigTarget, diff --git a/packages/agent-core-v2/src/app/config/configSectionContributions.ts b/packages/agent-core-v2/src/app/config/configSectionContributions.ts index 36ba47417d8..8dda4ecb225 100644 --- a/packages/agent-core-v2/src/app/config/configSectionContributions.ts +++ b/packages/agent-core-v2/src/app/config/configSectionContributions.ts @@ -10,6 +10,7 @@ * whether the consuming Service is instantiated. */ +import { collection } from '#/_base/di/collection'; import type { ConfigSchema, RegisterSectionOptions } from './config'; export interface ConfigSectionContribution { @@ -18,6 +19,8 @@ export interface ConfigSectionContribution { readonly options: RegisterSectionOptions; } +export const ConfigSectionContribution = collection('config-section'); + const _contributions: ConfigSectionContribution[] = []; export function registerConfigSection( diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 56ea4d19e08..9f2bda8c825 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -24,14 +24,22 @@ * reported as warning diagnostics (the deprecated value is NOT applied, and * the file is never rewritten); env-var renames declared via a binding's * `deprecatedEnv` still resolve as a fallback, likewise with a warning. - * Diagnostics changes are published through `onDidChangeDiagnostics`. Bound - * at App scope. + * Diagnostics changes are published through `onDidChangeDiagnostics`. + * `ConfigRegistry` is also the + * fold of the `ConfigSectionContribution` collection token (D12): records + * provided by live units register sections incrementally through the same + * path as the module drain (identical = silent, conflict = logged), and a + * withdrawn record unregisters its section — the domain falls back to + * unknown-section semantics, its TOML user values preserved but no longer + * validated/effective. Bound at App scope. */ +import { type CollectionView } from '#/_base/di/collection'; import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; -import { BugIndicatingError } from '#/errors'; +import { BugIndicatingError, onUnexpectedError } from '#/errors'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { ILogService } from '#/_base/log/log'; import { @@ -61,7 +69,10 @@ import { IConfigService, } from './config'; import { deepEqual, deepMerge, describeUnknownError, isPlainObject } from './configPure'; -import { getConfigSectionContributions } from './configSectionContributions'; +import { + ConfigSectionContribution, + getConfigSectionContributions, +} from './configSectionContributions'; import { getConfigOverlayContributions } from './configOverlayContributions'; import { collectKeyDeprecations } from './deprecations'; import { migrateThinkingEffortMaxToHigh } from './migrations'; @@ -176,24 +187,69 @@ function isSameSection( ); } -export class ConfigRegistry implements IConfigRegistry { +export class ConfigRegistry extends Disposable implements IConfigRegistry { declare readonly _serviceBrand: undefined; private readonly sections = new Map(); private readonly overlays: ConfigEffectiveOverlay[] = []; - private readonly _onDidRegisterSection = new Emitter(); + private readonly _onDidRegisterSection = this._register( + new Emitter(), + ); readonly onDidRegisterSection: Event = this._onDidRegisterSection.event; - private readonly _onDidRegisterOverlay = new Emitter(); + private readonly _onDidUnregisterSection = this._register( + new Emitter(), + ); + readonly onDidUnregisterSection: Event = + this._onDidUnregisterSection.event; + private readonly _onDidRegisterOverlay = this._register( + new Emitter(), + ); readonly onDidRegisterOverlay: Event = this._onDidRegisterOverlay.event; + private readonly foldDomains = new Set(); - constructor() { + constructor( + @ConfigSectionContribution view?: CollectionView, + ) { + super(); for (const c of getConfigSectionContributions()) { this.registerSection(c.domain, c.schema, c.options); } for (const overlay of getConfigOverlayContributions()) { this.registerEffectiveOverlay(overlay); } + if (view === undefined) return; + for (const item of view.items) { + this.addContribution(item); + } + this._register( + view.onDidChange((change) => { + for (const contribution of change.removed) { + this.removeContribution(contribution); + } + for (const contribution of change.added) { + this.addContribution(contribution); + } + }), + ); + } + + private addContribution(contribution: ConfigSectionContribution): void { + const before = this.sections.get(contribution.domain); + try { + this.registerSection(contribution.domain, contribution.schema, contribution.options); + } catch (error) { + onUnexpectedError(error); + return; + } + if (before === undefined && this.sections.get(contribution.domain) !== undefined) { + this.foldDomains.add(contribution.domain); + } + } + + private removeContribution(contribution: ConfigSectionContribution): void { + if (!this.foldDomains.delete(contribution.domain)) return; + this.unregisterSection(contribution.domain); } registerSection( @@ -229,6 +285,11 @@ export class ConfigRegistry implements IConfigRegistry { this._onDidRegisterSection.fire({ domain }); } + unregisterSection(domain: string): void { + if (!this.sections.delete(domain)) return; + this._onDidUnregisterSection.fire({ domain }); + } + getSection(domain: string): ConfigSection | undefined { return this.sections.get(domain); } @@ -261,6 +322,7 @@ export class ConfigRegistry implements IConfigRegistry { } } +// NOTE: stays Disposable — its own 'get' collides with the Fiber export class ConfigService extends Disposable implements IConfigService { declare readonly _serviceBrand: undefined; private readonly _onDidChangeConfiguration = this._register(new Emitter()); @@ -295,6 +357,7 @@ export class ConfigService extends Disposable implements IConfigService { super(); this.configKey = this.bootstrap.configKey; this._register(this.registry.onDidRegisterSection((e) => this.revalidateDomain(e.domain))); + this._register(this.registry.onDidUnregisterSection((e) => this.devalidateDomain(e.domain))); this._register(this.registry.onDidRegisterOverlay(() => this.reapplyOverlays())); const { configKey } = this; const { homeDir } = this.bootstrap; @@ -398,8 +461,6 @@ export class ConfigService extends Disposable implements IConfigService { target: ConfigTarget = ConfigTarget.User, ): Promise { await this.ready; - // `null` is the wire encoding of "clear this domain": JSON transports - // (klient memory/ipc, kap-server REST/WS) cannot carry `undefined`. const effectiveValue = value === null ? undefined : value; if (target === ConfigTarget.Memory) { if (effectiveValue === undefined) { @@ -446,7 +507,6 @@ export class ConfigService extends Disposable implements IConfigService { await this.enqueueStateTransition(async () => { const staged: ResolvedConfig = { ...this.raw }; for (const domain of domains) { - // Same `null`-means-clear encoding as `replace` (see above). const value = sections[domain] === null ? undefined : sections[domain]; const stripped = this.stripEnv(domain, value); if (stripped === undefined) { @@ -697,6 +757,26 @@ export class ConfigService extends Disposable implements IConfigService { this.emitDiagnosticsIfChanged(); } + private devalidateDomain(domain: string): void { + if (this.registry.getSection(domain) !== undefined) return; + + const snakeKey = camelToSnake(domain); + const rawSnakeValue = this.rawSnake[snakeKey]; + if (rawSnakeValue === undefined) { + delete this.raw[domain]; + delete this.validated[domain]; + delete this.effective[domain]; + } else { + const raw = transformTomlData({ [snakeKey]: rawSnakeValue }, this.registry)[domain]; + this.raw[domain] = raw; + this.validated[domain] = raw; + this.effective[domain] = raw; + } + + this.applyEnvOverlay(this.effective); + this.commit('reload', [domain]); + } + private async persist(domain: string): Promise { await this.persistDomains([domain]); } diff --git a/packages/agent-core-v2/src/app/cron/cron-expr.ts b/packages/agent-core-v2/src/app/cron/cron-expr.ts index bf50e875423..54863a7adaf 100644 --- a/packages/agent-core-v2/src/app/cron/cron-expr.ts +++ b/packages/agent-core-v2/src/app/cron/cron-expr.ts @@ -19,7 +19,6 @@ import { Error2, ErrorCodes } from '#/errors'; -/** A parsed cron expression. Opaque to callers — pass it back into {@link computeNextCronRun}. */ export interface ParsedCronExpression { readonly raw: string; readonly minutes: ReadonlySet; diff --git a/packages/agent-core-v2/src/app/cron/cronTaskPersistenceService.ts b/packages/agent-core-v2/src/app/cron/cronTaskPersistenceService.ts index 1f5d70f296a..ec776131b05 100644 --- a/packages/agent-core-v2/src/app/cron/cronTaskPersistenceService.ts +++ b/packages/agent-core-v2/src/app/cron/cronTaskPersistenceService.ts @@ -7,7 +7,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; @@ -40,6 +41,7 @@ export function isValidCronTask(obj: unknown): obj is CronTask { return true; } +// NOTE: stays Disposable — its own 'get' collides with the Fiber export class CronTaskPersistenceService extends Disposable implements ICronTaskPersistence { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/app/edit/fileEditService.ts b/packages/agent-core-v2/src/app/edit/fileEditService.ts index 89b6887daf3..ffaefebcaab 100644 --- a/packages/agent-core-v2/src/app/edit/fileEditService.ts +++ b/packages/agent-core-v2/src/app/edit/fileEditService.ts @@ -7,7 +7,9 @@ * `FileEditResult`; it owns no tool-facing message. Bound at App scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { unwrapErrorCause } from '#/_base/errors/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; diff --git a/packages/agent-core-v2/src/app/event/eventBusService.ts b/packages/agent-core-v2/src/app/event/eventBusService.ts index 6a6071b0596..959d31fdd2d 100644 --- a/packages/agent-core-v2/src/app/event/eventBusService.ts +++ b/packages/agent-core-v2/src/app/event/eventBusService.ts @@ -10,13 +10,15 @@ * created. */ -import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter } from '#/_base/event'; import { type DomainEvent, type DomainEventMap, IEventBus } from './eventBus'; -export class EventBusService extends Disposable implements IEventBus { +export class EventBusService extends Service implements IEventBus { declare readonly _serviceBrand: undefined; private readonly allEmitter = this._register(new Emitter()); diff --git a/packages/agent-core-v2/src/app/event/eventService.ts b/packages/agent-core-v2/src/app/event/eventService.ts index 9a9671b3cb3..beafdbc8ca9 100644 --- a/packages/agent-core-v2/src/app/event/eventService.ts +++ b/packages/agent-core-v2/src/app/event/eventService.ts @@ -5,13 +5,15 @@ * Bound at App scope. */ -import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; import { type DomainEvent, IEventService } from './event'; -export class EventService extends Disposable implements IEventService { +export class EventService extends Service implements IEventService { declare readonly _serviceBrand: undefined; private readonly emitter = this._register(new Emitter()); diff --git a/packages/agent-core-v2/src/app/event/fiberEventResolver.ts b/packages/agent-core-v2/src/app/event/fiberEventResolver.ts new file mode 100644 index 00000000000..d7bd32b4faa --- /dev/null +++ b/packages/agent-core-v2/src/app/event/fiberEventResolver.ts @@ -0,0 +1,36 @@ +/** + * `event` domain — the production `FiberEventResolver` backing the string + * form of the unit `on(...)` capability (`this.on('turn.ended', …)`). + * + * Resolves string event names against the unit scope's `IEventBus`: the + * subscription attaches as soon as the bus is materialized in the scope (or + * an ancestor), waits through a `liveRef` when it is not there yet, and + * detaches with the unit's book. Scopes without an `IEventBus` (App) simply + * never attach — per-agent domain events only exist under an Agent scope. + * Imported for the registration side effect. + */ + +import { setFiberEventResolver } from '#/_base/di/fiber'; +import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; + +import { type DomainEvent, type DomainEventMap, IEventBus } from './eventBus'; + +setFiberEventResolver((host, event, handler) => { + const busRef = host.liveRef(IEventBus); + let subscription: IDisposable | undefined; + const attach = (): void => { + if (subscription !== undefined) return; + const bus = busRef.current; + if (bus === undefined) return; + subscription = bus.subscribe( + event as keyof DomainEventMap, + handler as (e: DomainEvent) => void, + ); + }; + attach(); + const onChange = busRef.onDidChange(attach); + return toDisposable(() => { + onChange.dispose(); + subscription?.dispose(); + }); +}); diff --git a/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunnerService.ts b/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunnerService.ts index 2586df30d37..6bacea0fde0 100644 --- a/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunnerService.ts +++ b/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunnerService.ts @@ -15,7 +15,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; @@ -31,6 +32,7 @@ import { import { blockDecision, indexHooks, runMatchedHooks } from './runner'; import type { HookRunCallbacks } from './runner'; +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class ExternalHooksRunnerService extends Disposable implements IExternalHooksRunnerService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/app/feature/featureManager.ts b/packages/agent-core-v2/src/app/feature/featureManager.ts new file mode 100644 index 00000000000..6694d6bb3bc --- /dev/null +++ b/packages/agent-core-v2/src/app/feature/featureManager.ts @@ -0,0 +1,52 @@ +/** + * `feature` domain — `IFeatureManager`: dynamic unit assembly at App scope. + * + * The FeatureManager is the slim business-layer owner of "everything is a + * service" at runtime (§5.10 of the plan): it assembles feature recipes into + * live units through the SAME provide path the kernel uses statically + * (`this.provide`), tracks them for introspection (kimi-inspect), and + * retracts them on demand. Units it assembles hang on its own book — manager + * death retracts every managed unit. + * + * Deliberately NOT here (by design, Phase 3): + * - external package management (install / marketplace metadata) stays with + * `IPluginService` — "plugin" is the external world's word; the kernel and + * this manager only know recipes; + * - the enablement-set persistence for dynamic features lands with the + * per-domain flipping of Phase 5 (no external recipe sources exist yet); + * - per-domain flipping of built-in domains is Phase 5. + */ + +import type { Event } from '#/_base/event'; +import type { + FiberHandle, + FiberProvideOptions, + FiberState, + ServiceClassRecipe, + ServiceRecipe, +} from '#/_base/di/fiber'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ManagedUnitInfo { + readonly name: string; + readonly state: FiberState; + readonly uid: number | undefined; +} + +export interface IFeatureManager { + readonly _serviceBrand: undefined; + + provideUnit(recipe: ServiceRecipe, opts?: FiberProvideOptions): FiberHandle; + provideUnit( + id: ServiceIdentifier, + recipe: ServiceClassRecipe, + opts?: FiberProvideOptions, + ): FiberHandle; + unprovideUnit(name: string): Promise; + updateUnit(name: string, config?: unknown): Promise; + + units(): readonly ManagedUnitInfo[]; + readonly onDidChangeUnits: Event; +} + +export const IFeatureManager = createDecorator('featureManager'); diff --git a/packages/agent-core-v2/src/app/feature/featureManagerService.ts b/packages/agent-core-v2/src/app/feature/featureManagerService.ts new file mode 100644 index 00000000000..7e521c72011 --- /dev/null +++ b/packages/agent-core-v2/src/app/feature/featureManagerService.ts @@ -0,0 +1,108 @@ +/** + * `feature` domain — `FeatureManagerService`: the App-scope unit manager. + * + * See `featureManager.ts` for the domain contract. Implementation notes: + * managed units are assembled through this unit's own `this.provide`, so + * they anchor on its book (manager death retracts them all); the managed set + * is keyed by unit name — a second `provideUnit` of the same name replaces + * the previous handle (retract-then-assemble is the caller's cascade). + */ + +import { Emitter, type Event } from '#/_base/event'; +import type { + FiberHandle, + FiberProvideOptions, + ServiceClassRecipe, + ServiceRecipe, +} from '#/_base/di/fiber'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { isServiceIdentifier, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { Service } from '#/_base/di/service'; +import { + IFeatureManager, + type ManagedUnitInfo, +} from './featureManager'; + +export class FeatureManagerService extends Service implements IFeatureManager { + declare readonly _serviceBrand: undefined; + + private readonly _units = new Map(); + private readonly _onDidChangeUnits = new Emitter(); + readonly onDidChangeUnits: Event = this._onDidChangeUnits.event; + + constructor() { + super(); + this._register(this._onDidChangeUnits); + } + + provideUnit(recipe: ServiceRecipe, opts?: FiberProvideOptions): FiberHandle; + provideUnit( + id: ServiceIdentifier, + recipe: ServiceClassRecipe, + opts?: FiberProvideOptions, + ): FiberHandle; + provideUnit( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + first: ServiceRecipe | ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + second?: any, + third?: FiberProvideOptions, + ): FiberHandle { + const handle = isServiceIdentifier(first) + ? this.provide(first, second as ServiceClassRecipe, third) + : this.provide(first as ServiceRecipe, second as FiberProvideOptions | undefined); + const name = handle.name; + const previous = this._units.get(name); + if (previous !== undefined && previous !== handle) { + void previous.dispose(); + } + this._units.set(name, handle); + this._onDidChangeUnits.fire(); + return handle; + } + + async unprovideUnit(name: string): Promise { + const handle = this._units.get(name); + if (handle === undefined) { + return; + } + this._units.delete(name); + try { + await handle.dispose(); + } finally { + this._onDidChangeUnits.fire(); + } + } + + async updateUnit(name: string, config?: unknown): Promise { + const handle = this._units.get(name); + if (handle === undefined) { + throw new Error(`feature unit '${name}' is not managed by this FeatureManager`); + } + await handle.update(config); + this._onDidChangeUnits.fire(); + } + + units(): readonly ManagedUnitInfo[] { + const infos: ManagedUnitInfo[] = []; + for (const [name, handle] of this._units) { + let uid: number | undefined; + try { + uid = handle.uid; + } catch { + uid = undefined; + } + infos.push({ name, state: handle.state, uid }); + } + return infos; + } +} + +registerScopedService( + LifecycleScope.App, + IFeatureManager, + FeatureManagerService, + ScopeActivation.OnScopeCreated, + 'feature', +); diff --git a/packages/agent-core-v2/src/app/file/fileServiceImpl.ts b/packages/agent-core-v2/src/app/file/fileServiceImpl.ts index c63cc08e345..d3de270f0ed 100644 --- a/packages/agent-core-v2/src/app/file/fileServiceImpl.ts +++ b/packages/agent-core-v2/src/app/file/fileServiceImpl.ts @@ -14,8 +14,8 @@ import { randomUUID } from 'node:crypto'; import { Readable } from 'node:stream'; import type { FileMeta } from './fileService'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IBlobStore } from '#/persistence/interface/blobStore'; import { IFileService, diff --git a/packages/agent-core-v2/src/app/flag/flagRegistryService.ts b/packages/agent-core-v2/src/app/flag/flagRegistryService.ts index ae1dfdd3e90..7f48e814deb 100644 --- a/packages/agent-core-v2/src/app/flag/flagRegistryService.ts +++ b/packages/agent-core-v2/src/app/flag/flagRegistryService.ts @@ -7,7 +7,8 @@ */ import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { BugIndicatingError } from '#/errors'; import { @@ -17,6 +18,7 @@ import { IFlagRegistry, } from './flagRegistry'; +// NOTE: stays Disposable — its own 'get' collides with the Fiber export class FlagRegistryService extends Disposable implements IFlagRegistry { declare readonly _serviceBrand: undefined; private readonly byId = new Map(); diff --git a/packages/agent-core-v2/src/app/flag/flagService.ts b/packages/agent-core-v2/src/app/flag/flagService.ts index b6439f176b1..e2dcd1aa95f 100644 --- a/packages/agent-core-v2/src/app/flag/flagService.ts +++ b/packages/agent-core-v2/src/app/flag/flagService.ts @@ -7,7 +7,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { parseBooleanEnv } from '#/_base/utils/env'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; @@ -24,6 +25,7 @@ import { type FlagDefinitionInput, type FlagId, IFlagRegistry } from './flagRegi export const MASTER_ENV = 'KIMI_CODE_EXPERIMENTAL_FLAG'; +// NOTE: stays Disposable — its own 'state' and 'config' collide with the Fiber export class FlagService extends Disposable implements IFlagService { declare readonly _serviceBrand: undefined; readonly registry: IFlagRegistry; diff --git a/packages/agent-core-v2/src/app/gateway/gatewayService.ts b/packages/agent-core-v2/src/app/gateway/gatewayService.ts index dba32d345ed..fd6cedf50f7 100644 --- a/packages/agent-core-v2/src/app/gateway/gatewayService.ts +++ b/packages/agent-core-v2/src/app/gateway/gatewayService.ts @@ -9,9 +9,10 @@ * is a transport concern of the edge server, not of this module. */ +import { LifecycleScope } from '#/app/scopes'; + import { type IAgentScopeHandle, - LifecycleScope, ScopeActivation, registerScopedService, } from '#/_base/di/scope'; diff --git a/packages/agent-core-v2/src/app/git/gitService.ts b/packages/agent-core-v2/src/app/git/gitService.ts index 479f99e90cd..2ccb7714c1d 100644 --- a/packages/agent-core-v2/src/app/git/gitService.ts +++ b/packages/agent-core-v2/src/app/git/gitService.ts @@ -11,8 +11,8 @@ */ import type { FsDiffResponse, FsGitStatusResponse, FsPullRequest } from './git'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ErrorCodes, Error2 } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostProcessService } from '#/os/interface/hostProcess'; diff --git a/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowserService.ts b/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowserService.ts index 966e2d43bca..c262ca37f15 100644 --- a/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowserService.ts +++ b/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowserService.ts @@ -12,8 +12,8 @@ import { homedir } from 'node:os'; import { dirname, isAbsolute, join } from 'node:path'; import type { FsBrowseEntry, FsBrowseResponse, FsHomeResponse } from './hostFolderBrowser'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IWorkspaceService } from '#/app/workspace/workspace'; import { diff --git a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts index 1b61b8a26ba..76ffe7b5a40 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts @@ -46,8 +46,8 @@ import { type RefreshProviderHost, type RefreshResult, } from '@moonshot-ai/kimi-code-oauth'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2 } from '#/_base/errors/errors'; import { IOAuthService } from '#/app/auth/auth'; import { AuthErrors } from '#/app/auth/errors'; diff --git a/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts b/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts index d3b6bf9343f..c439ada551b 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts @@ -14,7 +14,8 @@ * on the full-headers path never touch it. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; diff --git a/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts b/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts index e3b965ee195..8c7a168141f 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts @@ -28,7 +28,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry'; @@ -48,6 +49,7 @@ import { const PERSIST_MAX_ATTEMPTS = 3; +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class KosongConfigService extends Disposable implements IKosongConfigService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts index b3587cadbb5..bf56aac7710 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts @@ -40,8 +40,8 @@ import { type CustomRegistrySource, type ManagedKimiConfigShape, } from '@moonshot-ai/kimi-code-oauth'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2 } from '#/_base/errors/errors'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IConfigService } from '#/app/config/config'; diff --git a/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts b/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts index 7105d2e3227..54a12878b74 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts @@ -6,7 +6,9 @@ * the port. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2 } from '#/_base/errors/errors'; import { IOAuthService } from '#/app/auth/auth'; diff --git a/packages/agent-core-v2/src/app/mcpConfig/oauthStore.ts b/packages/agent-core-v2/src/app/mcpConfig/oauthStore.ts index 218f2e02840..5a876841139 100644 --- a/packages/agent-core-v2/src/app/mcpConfig/oauthStore.ts +++ b/packages/agent-core-v2/src/app/mcpConfig/oauthStore.ts @@ -17,7 +17,8 @@ */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { McpOAuthStore } from '#/mcpCore/oauth/store'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; diff --git a/packages/agent-core-v2/src/app/plugin/pluginService.ts b/packages/agent-core-v2/src/app/plugin/pluginService.ts index ccc995e0dc0..9f3376452a3 100644 --- a/packages/agent-core-v2/src/app/plugin/pluginService.ts +++ b/packages/agent-core-v2/src/app/plugin/pluginService.ts @@ -12,9 +12,10 @@ import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; -import { Disposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { Emitter, type Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { BugIndicatingError, Error2, PluginErrors } from '#/errors'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IProviderService } from '#/kosong/provider/provider'; @@ -47,7 +48,7 @@ const KIMI_CODE_BASE_URL_ENV = 'KIMI_CODE_BASE_URL'; const KIMI_CODE_OAUTH_HOST_ENV = 'KIMI_CODE_OAUTH_HOST'; const KIMI_OAUTH_HOST_ENV = 'KIMI_OAUTH_HOST'; -export class PluginService extends Disposable implements IPluginService { +export class PluginService extends Service implements IPluginService { declare readonly _serviceBrand: undefined; private readonly homeDir: string; diff --git a/packages/agent-core-v2/src/app/scopes.ts b/packages/agent-core-v2/src/app/scopes.ts new file mode 100644 index 00000000000..3185913aa29 --- /dev/null +++ b/packages/agent-core-v2/src/app/scopes.ts @@ -0,0 +1,26 @@ +/** + * `app` domain — the business scope tier set and its topology declaration. + * + * The DI kernel (`_base/di/scope`) only knows the scope tree and opaque + * `ScopeKind` strings; the four tiers and their parent → child order are a + * business concept, declared here as a module side effect so importing the + * package (or this module) installs the topology. + */ + +import { setScopeTopology } from '#/_base/di/scope'; + +export enum LifecycleScope { + App = 'app', + Workspace = 'workspace', + Session = 'session', + Agent = 'agent', +} + +export const SCOPE_TOPOLOGY: readonly LifecycleScope[] = [ + LifecycleScope.App, + LifecycleScope.Workspace, + LifecycleScope.Session, + LifecycleScope.Agent, +]; + +setScopeTopology(SCOPE_TOPOLOGY); diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts index 18402736bdd..561d3c55859 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -8,8 +8,8 @@ */ import { join, resolve } from 'pathe'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { ISessionScopeHandle } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { resolveGlobalLogPath } from '#/_base/log/logConfig'; diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts index 8159e91dd00..36658493e46 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts @@ -24,7 +24,8 @@ */ import { Disposable, toDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { IntervalTimer } from '#/_base/utils/timer'; import { IFlagService } from '#/app/flag/flag'; diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index 40582755b65..3f428f4998e 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -39,7 +39,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { IntervalTimer } from '#/_base/utils/timer'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index 7843077a513..f44bc3deaab 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -11,11 +11,10 @@ import type { GoalSnapshot } from '#/agent/goal/types'; import type { SessionStatusResponse, UpdateSessionProfileRequest } from './sessionProtocol'; - +import { LifecycleScope } from '#/app/scopes'; import { type IAgentScopeHandle, type ISessionScopeHandle, - LifecycleScope, ScopeActivation, registerScopedService, } from '#/_base/di/scope'; @@ -27,7 +26,7 @@ import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting' import { IAgentGoalService } from '#/agent/goal/goal'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; -import { IAgentPlanService } from '#/agent/plan/plan'; +import { IAgentPlanService } from '#/features/plan/plan'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { IConfigService } from '#/app/config/config'; diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtinSkillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/builtinSkillSource.ts index c56ff7680a5..81d4ed444e4 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtinSkillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/builtinSkillSource.ts @@ -15,7 +15,8 @@ import { Emitter, type Event } from '#/_base/event'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IConfigService } from '#/app/config/config'; import { visibleBuiltinSkills } from './builtin/builtin'; diff --git a/packages/agent-core-v2/src/app/skillCatalog/inMemorySkillDiscovery.ts b/packages/agent-core-v2/src/app/skillCatalog/inMemorySkillDiscovery.ts index 175a94abf39..7d4edc8adb3 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/inMemorySkillDiscovery.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/inMemorySkillDiscovery.ts @@ -12,7 +12,9 @@ * App-scoped. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { SkillDiscoveryResult } from './skillDiscovery'; import { ISkillDiscovery } from './skillDiscovery'; diff --git a/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts index fafb0174837..308bba3de27 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts @@ -9,7 +9,8 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; @@ -28,6 +29,7 @@ export interface IUserFileSkillSource extends ISkillSource { export const IUserFileSkillSource: ServiceIdentifier = createDecorator('userFileSkillSource'); +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class UserFileSkillSource extends Disposable implements IUserFileSkillSource { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/app/state/appStateService.ts b/packages/agent-core-v2/src/app/state/appStateService.ts index aafc55e8bdd..e7ea6bfc081 100644 --- a/packages/agent-core-v2/src/app/state/appStateService.ts +++ b/packages/agent-core-v2/src/app/state/appStateService.ts @@ -7,7 +7,9 @@ * `inspectParent`. Bound at App scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { StateRegistry } from '#/_base/state/stateRegistry'; import { IAppStateService } from './appState'; diff --git a/packages/agent-core-v2/src/app/task/taskService.ts b/packages/agent-core-v2/src/app/task/taskService.ts index 7e0ff8bf981..08373e35817 100644 --- a/packages/agent-core-v2/src/app/task/taskService.ts +++ b/packages/agent-core-v2/src/app/task/taskService.ts @@ -7,8 +7,10 @@ */ import { Emitter, type Event } from '#/_base/event'; -import { Disposable, markAsDisposed, trackDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { markAsDisposed, trackDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { type ITaskHandle, @@ -159,7 +161,7 @@ class DeferHandle implements IDeferredHandle { } } -export class TaskService extends Disposable implements ITaskService { +export class TaskService extends Service implements ITaskService { declare readonly _serviceBrand: undefined; private _nextId = 0; diff --git a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts index a938f08f602..f2a30a4bcd5 100644 --- a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts +++ b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts @@ -6,7 +6,9 @@ * collaborators. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentTelemetryContextService, type AgentTelemetryContext, diff --git a/packages/agent-core-v2/src/app/telemetry/telemetryService.ts b/packages/agent-core-v2/src/app/telemetry/telemetryService.ts index e28a0b9364e..4425e44d4fa 100644 --- a/packages/agent-core-v2/src/app/telemetry/telemetryService.ts +++ b/packages/agent-core-v2/src/app/telemetry/telemetryService.ts @@ -8,7 +8,8 @@ */ import { type IDisposable, toDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { onUnexpectedError } from '#/_base/errors/unexpectedError'; import type { diff --git a/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts b/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts index 3750c03eac8..a5ba788e764 100644 --- a/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts +++ b/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts @@ -6,14 +6,6 @@ import { Error2 } from '#/_base/errors/errors'; import { WebErrors } from '../errors'; -/** - * How the returned content relates to the original response body. - * - * - `passthrough` — the body was already plain text / markdown and is - * returned verbatim, in full. - * - `extracted` — the body was an HTML page; only the main article text - * was extracted and returned. - */ export type UrlFetchKind = 'passthrough' | 'extracted'; export interface UrlFetchResult { diff --git a/packages/agent-core-v2/src/app/web/webService.ts b/packages/agent-core-v2/src/app/web/webService.ts index af458bda8ba..3dcb4581360 100644 --- a/packages/agent-core-v2/src/app/web/webService.ts +++ b/packages/agent-core-v2/src/app/web/webService.ts @@ -25,8 +25,8 @@ import { KIMI_CODE_PROVIDER_NAME, kimiCodeBaseUrl, } from '@moonshot-ai/kimi-code-oauth'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IOAuthService } from '#/app/auth/auth'; import { SERVICES_SECTION, type ServicesConfig } from '#/app/auth/configSection'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; diff --git a/packages/agent-core-v2/src/app/workspace/fileWorkspacePersistence.ts b/packages/agent-core-v2/src/app/workspace/fileWorkspacePersistence.ts index 746ed8ba931..01a2def20e0 100644 --- a/packages/agent-core-v2/src/app/workspace/fileWorkspacePersistence.ts +++ b/packages/agent-core-v2/src/app/workspace/fileWorkspacePersistence.ts @@ -10,7 +10,9 @@ * scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import type { Workspace } from './workspace'; diff --git a/packages/agent-core-v2/src/app/workspace/workspaceService.ts b/packages/agent-core-v2/src/app/workspace/workspaceService.ts index 45bd3e17780..a9e741ab963 100644 --- a/packages/agent-core-v2/src/app/workspace/workspaceService.ts +++ b/packages/agent-core-v2/src/app/workspace/workspaceService.ts @@ -53,8 +53,8 @@ */ import { basename, isAbsolute } from 'pathe'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { encodeWorkDirKey, workspaceRootKey } from '#/_base/utils/workdir-slug'; import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; diff --git a/packages/agent-core-v2/src/app/workspaceAliases/workspaceAliasesService.ts b/packages/agent-core-v2/src/app/workspaceAliases/workspaceAliasesService.ts index 2b8a06e954f..e4976317244 100644 --- a/packages/agent-core-v2/src/app/workspaceAliases/workspaceAliasesService.ts +++ b/packages/agent-core-v2/src/app/workspaceAliases/workspaceAliasesService.ts @@ -13,7 +13,9 @@ * or bucket is ever rewritten here. Bound at App scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IWorkspaceService } from '#/app/workspace/workspace'; import { collectAliasIds, diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts index 1ef4c158f8e..d9e082d4d26 100644 --- a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts @@ -17,12 +17,12 @@ */ import { IInstantiationService } from '#/_base/di/instantiation'; -import { Disposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope } from '#/app/scopes'; import { createScopedChildHandle, type IWorkspaceScopeHandle, - LifecycleScope, ScopeActivation, registerScopedService, } from '#/_base/di/scope'; @@ -46,7 +46,7 @@ import { type WorkspaceSessionRegistry, } from './workspaceLifecycle'; -export class WorkspaceLifecycleService extends Disposable implements IWorkspaceLifecycleService { +export class WorkspaceLifecycleService extends Service implements IWorkspaceLifecycleService { declare readonly _serviceBrand: undefined; private readonly live = new Map(); private readonly materializing = new Map>(); diff --git a/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts b/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts index 9e9c3492b75..fd9b9fca1e0 100644 --- a/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts +++ b/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts @@ -9,7 +9,9 @@ * archived sessions too. Bound at App scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { IWorkspaceAliases } from '#/app/workspaceAliases/workspaceAliases'; diff --git a/packages/agent-core-v2/src/debug/debugCascade.ts b/packages/agent-core-v2/src/debug/debugCascade.ts new file mode 100644 index 00000000000..0cac70b67e0 --- /dev/null +++ b/packages/agent-core-v2/src/debug/debugCascade.ts @@ -0,0 +1,81 @@ +/** + * `debug` domain — `IDebugCascadeService`: cascade history, waiting area, and + * triggers (L5 debug surface, plan §5.11). + * + * Public contract. `history()` folds every engine's history ring of the scope + * tree (each entry tagged with its orchestrating scope's path); `pending()` + * reports the waiting area and the sticky-failed units per scope. The + * triggers address a unit by `(scopePath, token)` and drive the kernel's + * public cascade entries: + * + * - `unprovide` — registration removal through the container's + * registry-level `unprovide` (the same entry business code calls); + * - `update` — restart: `cascade.update` without a config, or a config + * patch + reload through the fiber host when `config` is given; + * - `dispose` — the plan §5.11 `dispose(handle)` spelling: an awaited + * cascade `unprovide` submission. The kernel exposes no retire-only entry, + * so `dispose` and `unprovide` reach the same end state (registration + * removed, dependents cascaded to the waiting area); they differ only in + * the entry exercised. Both settle the cascade before returning. + * + * The service is also the producer of the `event.di.unit_changed` global + * event: while active it watches every engine of the tree (including + * late-joined scopes) and republishes unit state transitions on + * `IEventService`. Bound at App scope. All payloads are JSON-serializable + * wire data. + */ + +import type { CascadeAction, UnitState } from '#/_base/di/cascadeEngine'; +import { createDecorator } from '#/_base/di/instantiation'; + +export interface DebugCascadeEntry { + readonly scopePath: string; + readonly seq: number; + readonly reason: string; + readonly changes: ReadonlyArray<{ token: string; action: CascadeAction }>; + readonly affected: readonly string[]; + readonly tornDown: readonly string[]; + readonly rebuilt: readonly string[]; + readonly failed: readonly string[]; + readonly abortWaited: boolean; + readonly abortTimedOut: boolean; + readonly durationMs: number; +} + +export interface DebugPendingUnit { + readonly token: string; + readonly missing: string[]; +} + +export interface DebugFailedUnit { + readonly token: string; + readonly error?: string; +} + +export interface DebugPendingGroup { + readonly scopePath: string; + readonly waiting: DebugPendingUnit[]; + readonly failed: DebugFailedUnit[]; +} + +export const DI_UNIT_CHANGED_EVENT = 'event.di.unit_changed'; + +export interface DiUnitChangedPayload { + readonly scope: string; + readonly token: string; + readonly state: UnitState; + readonly error?: string; +} + +export interface IDebugCascadeService { + readonly _serviceBrand: undefined; + + history(): DebugCascadeEntry[]; + pending(): DebugPendingGroup[]; + unprovide(scopePath: string, token: string): Promise; + update(scopePath: string, token: string, config?: unknown): Promise; + dispose(scopePath: string, token: string): Promise; +} + +export const IDebugCascadeService = + createDecorator('debugCascadeService'); diff --git a/packages/agent-core-v2/src/debug/debugCascadeService.ts b/packages/agent-core-v2/src/debug/debugCascadeService.ts new file mode 100644 index 00000000000..e34e9c9bd3d --- /dev/null +++ b/packages/agent-core-v2/src/debug/debugCascadeService.ts @@ -0,0 +1,200 @@ +/** + * `debug` domain — `IDebugCascadeService` implementation. + * + * Read paths fold the kernel's debug accessors (`cascadeTree.engines` / + * `history` / `pendingSnapshot` / `unitsSnapshot`); the triggers only call the + * kernel's public entries (`unprovide` / `cascade.update` / `cascade.submit`) + * after resolving `(scopePath, token)` to a live container and identifier. + * Publishes `event.di.unit_changed` through `event` (`IEventService`) for + * every unit state transition of the tree. Bound at App scope, activated with + * the scope so the event feed is always on. + * + * NOTE: does not extend `Disposable` — the wire trigger `dispose(scopePath, + * token)` collides with `IDisposable.dispose`; the no-arg overload below is + * the framework teardown (the container retires this unit by calling + * `dispose()`), the two-arg overload is the trigger. + */ + +import type { CascadeEngine } from '#/_base/di/cascadeEngine'; +import { + IInstantiationService, + type ServiceIdentifier, +} from '#/_base/di/instantiation'; +import type { InstantiationService } from '#/_base/di/instantiationService'; +import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IEventService } from '#/app/event/event'; +import { LifecycleScope } from '#/app/scopes'; +import { Error2, ErrorCodes } from '#/errors'; + +import { + DI_UNIT_CHANGED_EVENT, + IDebugCascadeService, + type DebugCascadeEntry, + type DebugFailedUnit, + type DebugPendingGroup, + type DebugPendingUnit, + type DiUnitChangedPayload, +} from './debugCascade'; +import { + resolveScopeContainer, + scopePathOfEngine, + walkScopeContainers, +} from './scopeTree'; + +export class DebugCascadeService implements IDebugCascadeService { + declare readonly _serviceBrand: undefined; + + private readonly root: InstantiationService; + private readonly events: IEventService; + private readonly store = new DisposableStore(); + private readonly engineSubscriptions = new Map(); + private tornDown = false; + + constructor( + @IInstantiationService instantiation: IInstantiationService, + @IEventService events: IEventService, + ) { + this.root = instantiation as InstantiationService; + this.events = events; + const tree = this.root.cascadeTree; + for (const engine of tree.engines) { + this._watchEngine(engine); + } + this.store.add( + tree.onDidAddEngine((engine) => { + this._watchEngine(engine); + }), + ); + this.store.add( + tree.onDidRemoveEngine((engine) => { + this._unwatchEngine(engine); + }), + ); + } + + history(): DebugCascadeEntry[] { + const entries: DebugCascadeEntry[] = []; + for (const info of walkScopeContainers(this.root)) { + for (const entry of info.container.cascade.history()) { + entries.push({ scopePath: info.path, ...entry }); + } + } + return entries.toSorted( + (a, b) => a.seq - b.seq || a.scopePath.localeCompare(b.scopePath), + ); + } + + pending(): DebugPendingGroup[] { + const groups: DebugPendingGroup[] = []; + for (const info of walkScopeContainers(this.root)) { + const waiting: DebugPendingUnit[] = []; + for (const [token, missing] of info.container.cascade.pendingSnapshot()) { + waiting.push({ token, missing: [...missing] }); + } + const failed: DebugFailedUnit[] = info.container.cascade + .unitsSnapshot() + .filter((unit) => unit.state === 'Failed') + .map((unit) => ({ token: unit.token, error: unit.error })); + if (waiting.length > 0 || failed.length > 0) { + groups.push({ scopePath: info.path, waiting, failed }); + } + } + return groups; + } + + async unprovide(scopePath: string, token: string): Promise { + const { container, id } = this._resolve(scopePath, token); + container.unprovide(id); + await container.cascade.whenIdle(); + } + + async update(scopePath: string, token: string, config?: unknown): Promise { + const { container, id } = this._resolve(scopePath, token); + if (config === undefined) { + await container.cascade.update(id, `debug update ${token}`); + } else { + await container.fiberHost.updateToken(id, config, true); + } + } + + async dispose(scopePath: string, token: string): Promise; + dispose(): void; + async dispose(scopePath?: string, token?: string): Promise { + if (scopePath === undefined && token === undefined) { + if (!this.tornDown) { + this.tornDown = true; + this.store.dispose(); + for (const subscription of this.engineSubscriptions.values()) { + subscription.dispose(); + } + this.engineSubscriptions.clear(); + } + return; + } + if (scopePath === undefined || token === undefined) { + throw new Error2( + ErrorCodes.DEBUG_TOKEN_NOT_FOUND, + 'dispose requires both a scope path and a token', + ); + } + const { container, id } = this._resolve(scopePath, token); + await container.cascade.submit({ + action: 'unprovide', + token: id, + reason: `debug dispose ${token}`, + }); + } + + private _resolve( + scopePath: string, + token: string, + ): { container: InstantiationService; id: ServiceIdentifier } { + const container = resolveScopeContainer(this.root, scopePath); + if (container === undefined) { + throw new Error2( + ErrorCodes.DEBUG_SCOPE_NOT_FOUND, + `no DI container at scope path '${scopePath}'`, + ); + } + const id = container.findIdentifier(token); + if (id === undefined) { + throw new Error2( + ErrorCodes.DEBUG_TOKEN_NOT_FOUND, + `token '${token}' is not registered in container '${scopePath}'`, + ); + } + return { container, id }; + } + + private _watchEngine(engine: CascadeEngine): void { + if (this.engineSubscriptions.has(engine)) { + return; + } + this.engineSubscriptions.set( + engine, + engine.onDidChangeUnitState((change) => { + const payload: DiUnitChangedPayload = { + scope: scopePathOfEngine(this.root, engine) ?? '#unknown', + token: change.token, + state: change.state, + error: change.error, + }; + this.events.publish({ type: DI_UNIT_CHANGED_EVENT, payload }); + }), + ); + } + + private _unwatchEngine(engine: CascadeEngine): void { + this.engineSubscriptions.get(engine)?.dispose(); + this.engineSubscriptions.delete(engine); + } +} + +registerScopedService( + LifecycleScope.App, + IDebugCascadeService, + DebugCascadeService, + ScopeActivation.OnScopeCreated, + 'debug', +); diff --git a/packages/agent-core-v2/src/debug/debugGraph.ts b/packages/agent-core-v2/src/debug/debugGraph.ts new file mode 100644 index 00000000000..dcb23d15ca2 --- /dev/null +++ b/packages/agent-core-v2/src/debug/debugGraph.ts @@ -0,0 +1,41 @@ +/** + * `debug` domain — `IDebugGraphService`: the persistent dependency DAG (L5 + * debug surface, plan §5.11). + * + * Public contract. `graph()` renders the tree-global dependency graph: nodes + * are every registered token of every container (union the edge endpoints, so + * collection tokens that own no registration still appear), edges are the live + * instance edges (cross-tree) and collection edges, told apart by `kind`. + * Bound at App scope. All payloads are JSON-serializable wire data. + */ + +import type { UnitState } from '#/_base/di/cascadeEngine'; +import type { DependencyEdgeKind } from '#/_base/di/dependencyGraph'; +import { createDecorator } from '#/_base/di/instantiation'; + +export interface DebugGraphNode { + readonly id: string; + readonly token: string; + readonly scopePath: string; + readonly uid?: number; + readonly state?: UnitState; +} + +export interface DebugGraphEdge { + readonly from: string; + readonly to: string; + readonly kind: DependencyEdgeKind; +} + +export interface DebugGraph { + readonly nodes: DebugGraphNode[]; + readonly edges: DebugGraphEdge[]; +} + +export interface IDebugGraphService { + readonly _serviceBrand: undefined; + + graph(): DebugGraph; +} + +export const IDebugGraphService = createDecorator('debugGraphService'); diff --git a/packages/agent-core-v2/src/debug/debugGraphService.ts b/packages/agent-core-v2/src/debug/debugGraphService.ts new file mode 100644 index 00000000000..5b1f6af6a8d --- /dev/null +++ b/packages/agent-core-v2/src/debug/debugGraphService.ts @@ -0,0 +1,85 @@ +/** + * `debug` domain — `IDebugGraphService` implementation. + * + * Read-only introspection over the kernel's debug accessors (`children` / + * `servicesSnapshot` / `unitsSnapshot` / `dependencyGraph.edges`); no kernel + * state is mutated. Bound at App scope; the injected container is the tree + * root (the dependency graph is shared by the whole tree). + */ + +import { IInstantiationService } from '#/_base/di/instantiation'; +import type { InstantiationService } from '#/_base/di/instantiationService'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { + IDebugGraphService, + type DebugGraph, + type DebugGraphEdge, + type DebugGraphNode, +} from './debugGraph'; +import { walkScopeContainers } from './scopeTree'; + +export class DebugGraphService implements IDebugGraphService { + declare readonly _serviceBrand: undefined; + + private readonly root: InstantiationService; + + constructor(@IInstantiationService instantiation: IInstantiationService) { + this.root = instantiation as InstantiationService; + } + + graph(): DebugGraph { + const infos = walkScopeContainers(this.root); + const pathByContainer = new Map( + infos.map((info) => [info.container, info.path]), + ); + const nodes = new Map(); + for (const info of infos) { + const states = new Map( + info.container.cascade.unitsSnapshot().map((unit) => [unit.token, unit]), + ); + for (const registration of info.container.servicesSnapshot()) { + const id = nodeId(info.path, registration.token); + nodes.set(id, { + id, + token: registration.token, + scopePath: info.path, + uid: registration.uid, + state: states.get(registration.token)?.state, + }); + } + } + const pathOf = (scope: object): string => + pathByContainer.get(scope as InstantiationService) ?? + `#${this.root.cascadeTree.seqOf(scope)}`; + const edges: DebugGraphEdge[] = []; + const endpointNode = (path: string, token: string): string => { + const id = nodeId(path, token); + if (!nodes.has(id)) { + nodes.set(id, { id, token, scopePath: path }); + } + return id; + }; + for (const edge of this.root.dependencyGraph.edges()) { + edges.push({ + from: endpointNode(pathOf(edge.consumer.scope), edge.consumer.token.toString()), + to: endpointNode(pathOf(edge.dependency.scope), edge.dependency.token.toString()), + kind: edge.kind, + }); + } + return { nodes: [...nodes.values()], edges }; + } +} + +function nodeId(scopePath: string, token: string): string { + return `${scopePath}::${token}`; +} + +registerScopedService( + LifecycleScope.App, + IDebugGraphService, + DebugGraphService, + ScopeActivation.OnDemand, + 'debug', +); diff --git a/packages/agent-core-v2/src/debug/debugLedger.ts b/packages/agent-core-v2/src/debug/debugLedger.ts new file mode 100644 index 00000000000..bbcbd868126 --- /dev/null +++ b/packages/agent-core-v2/src/debug/debugLedger.ts @@ -0,0 +1,40 @@ +/** + * `debug` domain — `IDebugLedgerService`: the unit tree = ledger tree (L5 + * debug surface, plan §5.11). + * + * Public contract. `tree()` walks the whole container tree under the App + * root; every node carries the container's scope path and label, its units + * (the service registrations joined with the cascade engine's five-state + * unit snapshots) and its ledger entries verbatim (child ledgers already + * recurse). Bound at App scope. All payloads are JSON-serializable wire data. + */ + +import type { UnitState } from '#/_base/di/cascadeEngine'; +import { createDecorator } from '#/_base/di/instantiation'; +import type { LedgerEntryInfo } from '#/_base/lifecycle/ledger'; + +export interface DebugUnit { + readonly token: string; + readonly uid: number; + readonly state?: UnitState; + readonly error?: string; + readonly everActive?: boolean; + readonly inFlight?: boolean; +} + +export interface DebugLedgerNode { + readonly path: string; + readonly label: string; + readonly units: DebugUnit[]; + readonly ledger: LedgerEntryInfo[]; + readonly children: DebugLedgerNode[]; +} + +export interface IDebugLedgerService { + readonly _serviceBrand: undefined; + + tree(): DebugLedgerNode; +} + +export const IDebugLedgerService = + createDecorator('debugLedgerService'); diff --git a/packages/agent-core-v2/src/debug/debugLedgerService.ts b/packages/agent-core-v2/src/debug/debugLedgerService.ts new file mode 100644 index 00000000000..ad4b988c71e --- /dev/null +++ b/packages/agent-core-v2/src/debug/debugLedgerService.ts @@ -0,0 +1,64 @@ +/** + * `debug` domain — `IDebugLedgerService` implementation. + * + * Read-only introspection over the kernel's debug accessors (`children` / + * `servicesSnapshot` / `unitsSnapshot` / `ledger.entries`); no kernel state is + * mutated. Bound at App scope; the injected container is the tree root. + */ + +import { IInstantiationService } from '#/_base/di/instantiation'; +import type { InstantiationService } from '#/_base/di/instantiationService'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { IDebugLedgerService, type DebugLedgerNode, type DebugUnit } from './debugLedger'; +import { scopeContainerLabel } from './scopeTree'; + +export class DebugLedgerService implements IDebugLedgerService { + declare readonly _serviceBrand: undefined; + + private readonly root: InstantiationService; + + constructor(@IInstantiationService instantiation: IInstantiationService) { + this.root = instantiation as InstantiationService; + } + + tree(): DebugLedgerNode { + return this._node(this.root, scopeContainerLabel(this.root)); + } + + private _node(container: InstantiationService, path: string): DebugLedgerNode { + return { + path, + label: scopeContainerLabel(container), + units: joinUnits(container), + ledger: container.ledger.entries(), + children: container.children.map((child) => + this._node(child, `${path}/${scopeContainerLabel(child)}`), + ), + }; + } +} + +function joinUnits(container: InstantiationService): DebugUnit[] { + const states = new Map(container.cascade.unitsSnapshot().map((unit) => [unit.token, unit])); + return container.servicesSnapshot().map((registration) => { + const unit = states.get(registration.token); + return { + token: registration.token, + uid: registration.uid, + state: unit?.state, + error: unit?.error, + everActive: unit?.everActive, + inFlight: unit?.inFlight, + }; + }); +} + +registerScopedService( + LifecycleScope.App, + IDebugLedgerService, + DebugLedgerService, + ScopeActivation.OnDemand, + 'debug', +); diff --git a/packages/agent-core-v2/src/debug/errors.ts b/packages/agent-core-v2/src/debug/errors.ts new file mode 100644 index 00000000000..d5a1dd24a28 --- /dev/null +++ b/packages/agent-core-v2/src/debug/errors.ts @@ -0,0 +1,14 @@ +/** + * `debug` domain error codes. + */ + +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const DebugErrors = { + codes: { + DEBUG_SCOPE_NOT_FOUND: 'debug.scope_not_found', + DEBUG_TOKEN_NOT_FOUND: 'debug.token_not_found', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(DebugErrors); diff --git a/packages/agent-core-v2/src/debug/index.ts b/packages/agent-core-v2/src/debug/index.ts new file mode 100644 index 00000000000..6d40dd821bc --- /dev/null +++ b/packages/agent-core-v2/src/debug/index.ts @@ -0,0 +1,12 @@ +/** + * `debug` domain barrel — L5 debug surface (plan §5.11): ledger tree, + * dependency graph, and cascade history / waiting area / triggers. + */ + +export * from './debugLedger'; +export * from './debugGraph'; +export * from './debugCascade'; +export * from './errors'; +export * from './debugLedgerService'; +export * from './debugGraphService'; +export * from './debugCascadeService'; diff --git a/packages/agent-core-v2/src/debug/scopeTree.ts b/packages/agent-core-v2/src/debug/scopeTree.ts new file mode 100644 index 00000000000..d490564c47e --- /dev/null +++ b/packages/agent-core-v2/src/debug/scopeTree.ts @@ -0,0 +1,49 @@ +/** + * `debug` domain — container-tree traversal helpers shared by the debug + * services. + * + * Scope paths join `debugLabel` segments from the tree root + * (`app` / `app/workspace:` / …); an unlabeled container falls back to its + * tree sequence (`#n`). Resolution walks the live tree and compares whole + * paths, so labels never need to be separator-safe, and a path re-resolves to + * the same container for the process lifetime. + */ + +import type { CascadeEngine } from '#/_base/di/cascadeEngine'; +import type { InstantiationService } from '#/_base/di/instantiationService'; + +export interface ScopeContainerInfo { + readonly container: InstantiationService; + readonly path: string; + readonly label: string; +} + +export function scopeContainerLabel(container: InstantiationService): string { + return container.debugLabel ?? `#${container.cascadeTree.seqOf(container)}`; +} + +export function walkScopeContainers(root: InstantiationService): ScopeContainerInfo[] { + const out: ScopeContainerInfo[] = []; + const visit = (container: InstantiationService, path: string): void => { + out.push({ container, path, label: scopeContainerLabel(container) }); + for (const child of container.children) { + visit(child, `${path}/${scopeContainerLabel(child)}`); + } + }; + visit(root, scopeContainerLabel(root)); + return out; +} + +export function resolveScopeContainer( + root: InstantiationService, + path: string, +): InstantiationService | undefined { + return walkScopeContainers(root).find((info) => info.path === path)?.container; +} + +export function scopePathOfEngine( + root: InstantiationService, + engine: CascadeEngine, +): string | undefined { + return walkScopeContainers(root).find((info) => info.container.cascade === engine)?.path; +} diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index a18578f0cea..82cc6a58fdb 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -12,6 +12,7 @@ import { ProtocolErrors } from '#/kosong/protocol/errors'; import { ConfigErrors } from '#/app/config/errors'; import { CapabilityErrors } from '#/app/capability/errors'; import { CronErrors } from '#/app/cron/errors'; +import { DebugErrors } from '#/debug/errors'; import { FileErrors } from '#/app/file/fileService'; import { FsErrors } from '#/workspace/workspaceFs/internal/errors'; import { FullCompactionErrors } from '#/agent/fullCompaction/errors'; @@ -47,6 +48,7 @@ export { ProtocolErrors } from '#/kosong/protocol/errors'; export { ConfigErrors } from '#/app/config/errors'; export { CapabilityErrors } from '#/app/capability/errors'; export { CronErrors } from '#/app/cron/errors'; +export { DebugErrors } from '#/debug/errors'; export { FileErrors } from '#/app/file/fileService'; export { FsErrors } from '#/workspace/workspaceFs/internal/errors'; export { FullCompactionErrors } from '#/agent/fullCompaction/errors'; @@ -79,6 +81,7 @@ export const ErrorCodes = { ...ConfigErrors.codes, ...CapabilityErrors.codes, ...CronErrors.codes, + ...DebugErrors.codes, ...FileErrors.codes, ...FsErrors.codes, ...FullCompactionErrors.codes, diff --git a/packages/agent-core-v2/src/features/feature.ts b/packages/agent-core-v2/src/features/feature.ts new file mode 100644 index 00000000000..f3bd956dcd7 --- /dev/null +++ b/packages/agent-core-v2/src/features/feature.ts @@ -0,0 +1,114 @@ +/** + * `features` domain — the `Feature` base class: one self-contained built-in + * capability (plan, mcp, …) authored as a single App-scope unit recipe. + * + * A subclass declares its contributions inside its constructor through the + * `contribute*` helpers — thin compositions over the unit capabilities and + * the existing collection seams: config sections (`config`), per-scope + * service materialization (`ScopeUnits` — the kernel folds one live unit per + * present and future scope of that kind), agent tools (`toolRegistry`), and + * agent profiles (`agentProfileCatalog`). Everything a Feature provides hangs + * on its own book, so retracting the Feature unit withdraws every + * contribution across the scope tree (连坐). Recipes declare a stable + * `static readonly name`; the assembly keys managed units by it. + */ + +import { type CollectionToken } from '#/_base/di/collection'; +import { + ScopeUnits, + type Fiber, + type FiberHandle, + type FiberProvideOptions, + type ServiceClassRecipe, +} from '#/_base/di/fiber'; +import { ScopeActivation, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { toDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { + AgentProfileContribution, + AGENT_PROFILE_SOURCE_PRIORITY, +} from '#/app/agentProfileCatalog/agentProfileContribution'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import type { ConfigSchema, RegisterSectionOptions } from '#/app/config/config'; +import { ConfigSectionContribution } from '#/app/config/configSectionContributions'; +import { LifecycleScope } from '#/app/scopes'; +import { + CommandContribution, + type CommandContribution as CommandContributionPayload, +} from '#/agent/command/commandContribution'; +import { + AgentToolContribution, + type AgentToolContributionOptions, + type AgentToolCtor, + type AnyAgentTool, +} from '#/agent/toolRegistry/toolContribution'; + +export abstract class Feature extends Service { + contribute(token: CollectionToken, value: T): FiberHandle { + return this.provide(token, value); + } + + contributeConfig( + domain: string, + schema: ConfigSchema, + options: RegisterSectionOptions = {}, + ): FiberHandle { + return this.provide(ConfigSectionContribution, { + domain, + schema: schema as ConfigSchema, + options: options as RegisterSectionOptions, + }); + } + + contributeService( + scope: LifecycleScope, + id: ServiceIdentifier, + ctor: ServiceClassRecipe, + opts?: FiberProvideOptions, + ): FiberHandle { + return this.provide(ScopeUnits(scope), { + name: `${this.name}:${String(id)}`, + apply(fiber: Fiber): void { + fiber.provide(id, ctor, opts); + }, + }); + } + + contributeAgentService( + id: ServiceIdentifier, + ctor: ServiceClassRecipe, + opts?: FiberProvideOptions, + ): FiberHandle { + return this.contributeService(LifecycleScope.Agent, id, ctor, opts); + } + + contributeTool( + id: ServiceIdentifier, + ctor: AgentToolCtor, + options: AgentToolContributionOptions, + ): void { + this.contributeService(LifecycleScope.Agent, id, ctor, { + activation: ScopeActivation.OnDemand, + }); + this.provide(AgentToolContribution, { id, ctor, options }); + } + + contributeCommand(contribution: CommandContributionPayload): FiberHandle { + return this.provide(CommandContribution, contribution); + } + + contributeProfiles( + profiles: readonly AgentProfile[], + opts?: { readonly sourceId?: string; readonly priority?: number }, + ): FiberHandle { + return this.provide(AgentProfileContribution, { + sourceId: opts?.sourceId ?? `feature:${this.name}`, + priority: opts?.priority ?? AGENT_PROFILE_SOURCE_PRIORITY.builtin, + contribution: { profiles }, + }); + } + + onDispose(fn: () => void): void { + this._register(toDisposable(fn)); + } +} diff --git a/packages/agent-core-v2/src/features/featureAssembly.ts b/packages/agent-core-v2/src/features/featureAssembly.ts new file mode 100644 index 00000000000..50ef072d06e --- /dev/null +++ b/packages/agent-core-v2/src/features/featureAssembly.ts @@ -0,0 +1,17 @@ +/** + * `features` domain — the `IFeatureAssemblyService` contract. + * + * The assembly drains the module-level feature recipe table + * (`featureRegistry`) into managed units at App-scope creation; it owns no + * state of its own and exists so feature assembly runs through the same + * provide path as every other unit. Bound at App scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IFeatureAssemblyService { + readonly _serviceBrand: undefined; +} + +export const IFeatureAssemblyService: ServiceIdentifier = + createDecorator('featureAssemblyService'); diff --git a/packages/agent-core-v2/src/features/featureAssemblyService.ts b/packages/agent-core-v2/src/features/featureAssemblyService.ts new file mode 100644 index 00000000000..1b7b1f63483 --- /dev/null +++ b/packages/agent-core-v2/src/features/featureAssemblyService.ts @@ -0,0 +1,35 @@ +/** + * `features` domain — `IFeatureAssemblyService` implementation. + * + * Assembles every registered feature recipe through `feature` + * (`IFeatureManager`), so each built-in capability becomes a named, + * introspectable (`units()`), individually retractable managed unit hanging + * on the manager's book. Bound at App scope. + */ + +import { IFeatureManager } from '#/app/feature/featureManager'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; + +import { IFeatureAssemblyService } from './featureAssembly'; +import { getFeatureRecipes } from './featureRegistry'; + +export class FeatureAssemblyService extends Service implements IFeatureAssemblyService { + declare readonly _serviceBrand: undefined; + + constructor(@IFeatureManager featureManager: IFeatureManager) { + super(); + for (const recipe of getFeatureRecipes()) { + featureManager.provideUnit(recipe); + } + } +} + +registerScopedService( + LifecycleScope.App, + IFeatureAssemblyService, + FeatureAssemblyService, + ScopeActivation.OnScopeCreated, + 'features', +); diff --git a/packages/agent-core-v2/src/features/featureRegistry.ts b/packages/agent-core-v2/src/features/featureRegistry.ts new file mode 100644 index 00000000000..4a22b5af612 --- /dev/null +++ b/packages/agent-core-v2/src/features/featureRegistry.ts @@ -0,0 +1,24 @@ +/** + * `features` domain — the module-level feature recipe table ("import = + * register"). + * + * Each feature module calls `registerFeature(Recipe)` at its top level; the + * assembly drains the table once at App-scope creation. Pure data — no DI, no + * container — so feature modules stay importable in any bootstrap order. + */ + +import type { ServiceClassRecipe } from '#/_base/di/fiber'; + +const _featureRecipes: ServiceClassRecipe[] = []; + +export function registerFeature(recipe: ServiceClassRecipe): void { + _featureRecipes.push(recipe); +} + +export function getFeatureRecipes(): readonly ServiceClassRecipe[] { + return _featureRecipes; +} + +export function _clearFeatureRecipesForTests(): void { + _featureRecipes.length = 0; +} diff --git a/packages/agent-core-v2/src/agent/plan/configSection.ts b/packages/agent-core-v2/src/features/plan/configSection.ts similarity index 67% rename from packages/agent-core-v2/src/agent/plan/configSection.ts rename to packages/agent-core-v2/src/features/plan/configSection.ts index bfcab5262c1..1da8b802b53 100644 --- a/packages/agent-core-v2/src/agent/plan/configSection.ts +++ b/packages/agent-core-v2/src/features/plan/configSection.ts @@ -1,9 +1,13 @@ /** - * `plan` domain — `defaultPlanMode` config section. + * `plan` domain — registers the `defaultPlanMode` config section into + * `config`. * * Top-level boolean preference (`default_plan_mode` on disk, v1-compatible): * when `true`, every freshly created session starts in plan mode. Resumed / * forked sessions restore plan state from wire records and ignore this. + * Stays on the static import=register channel (not the Feature's runtime + * contribution) so the section remains statically discoverable — the config + * manifest generator drains the module-level table. Bound at App scope. */ import { z } from 'zod'; diff --git a/packages/agent-core-v2/src/agent/plan/exitPlanModeReview.ts b/packages/agent-core-v2/src/features/plan/exitPlanModeReview.ts similarity index 100% rename from packages/agent-core-v2/src/agent/plan/exitPlanModeReview.ts rename to packages/agent-core-v2/src/features/plan/exitPlanModeReview.ts diff --git a/packages/agent-core-v2/src/agent/plan/injection/plan-mode-exit-reminder.md b/packages/agent-core-v2/src/features/plan/injection/plan-mode-exit-reminder.md similarity index 100% rename from packages/agent-core-v2/src/agent/plan/injection/plan-mode-exit-reminder.md rename to packages/agent-core-v2/src/features/plan/injection/plan-mode-exit-reminder.md diff --git a/packages/agent-core-v2/src/agent/plan/injection/plan-mode-full-reminder.md b/packages/agent-core-v2/src/features/plan/injection/plan-mode-full-reminder.md similarity index 100% rename from packages/agent-core-v2/src/agent/plan/injection/plan-mode-full-reminder.md rename to packages/agent-core-v2/src/features/plan/injection/plan-mode-full-reminder.md diff --git a/packages/agent-core-v2/src/agent/plan/injection/plan-mode-inline-full-reminder.md b/packages/agent-core-v2/src/features/plan/injection/plan-mode-inline-full-reminder.md similarity index 100% rename from packages/agent-core-v2/src/agent/plan/injection/plan-mode-inline-full-reminder.md rename to packages/agent-core-v2/src/features/plan/injection/plan-mode-inline-full-reminder.md diff --git a/packages/agent-core-v2/src/agent/plan/injection/plan-mode-inline-reentry-reminder.md b/packages/agent-core-v2/src/features/plan/injection/plan-mode-inline-reentry-reminder.md similarity index 100% rename from packages/agent-core-v2/src/agent/plan/injection/plan-mode-inline-reentry-reminder.md rename to packages/agent-core-v2/src/features/plan/injection/plan-mode-inline-reentry-reminder.md diff --git a/packages/agent-core-v2/src/agent/plan/injection/plan-mode-inline-sparse-reminder.md b/packages/agent-core-v2/src/features/plan/injection/plan-mode-inline-sparse-reminder.md similarity index 100% rename from packages/agent-core-v2/src/agent/plan/injection/plan-mode-inline-sparse-reminder.md rename to packages/agent-core-v2/src/features/plan/injection/plan-mode-inline-sparse-reminder.md diff --git a/packages/agent-core-v2/src/agent/plan/injection/plan-mode-reentry-reminder.md b/packages/agent-core-v2/src/features/plan/injection/plan-mode-reentry-reminder.md similarity index 100% rename from packages/agent-core-v2/src/agent/plan/injection/plan-mode-reentry-reminder.md rename to packages/agent-core-v2/src/features/plan/injection/plan-mode-reentry-reminder.md diff --git a/packages/agent-core-v2/src/agent/plan/injection/plan-mode-sparse-reminder.md b/packages/agent-core-v2/src/features/plan/injection/plan-mode-sparse-reminder.md similarity index 100% rename from packages/agent-core-v2/src/agent/plan/injection/plan-mode-sparse-reminder.md rename to packages/agent-core-v2/src/features/plan/injection/plan-mode-sparse-reminder.md diff --git a/packages/agent-core-v2/src/agent/plan/injection/planModeInjection.ts b/packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts similarity index 95% rename from packages/agent-core-v2/src/agent/plan/injection/planModeInjection.ts rename to packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts index 454a5059dd5..951c99320a4 100644 --- a/packages/agent-core-v2/src/agent/plan/injection/planModeInjection.ts +++ b/packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts @@ -10,13 +10,13 @@ * (`IAgentStateService`) and read/written through it. */ -import { Disposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { defineState } from '#/_base/state/stateRegistry'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IAgentPlanService } from '#/agent/plan/plan'; -import type { PlanFilePath } from '#/agent/plan/plan'; +import { IAgentPlanService } from '#/features/plan/plan'; +import type { PlanFilePath } from '#/features/plan/plan'; import { IAgentStateService } from '#/agent/state/agentState'; import PLAN_MODE_EXIT_REMINDER from './plan-mode-exit-reminder.md?raw'; import PLAN_MODE_FULL_REMINDER from './plan-mode-full-reminder.md?raw'; @@ -32,7 +32,7 @@ const PLAN_MODE_INJECTION_VARIANT = 'plan_mode'; export const planWasActiveKey = defineState('plan.wasActive', () => false); -export class PlanModeInjection extends Disposable { +export class PlanModeInjection extends Service { constructor( @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, @IAgentPlanService private readonly plan: IAgentPlanService, diff --git a/packages/agent-core-v2/src/agent/plan/plan.ts b/packages/agent-core-v2/src/features/plan/plan.ts similarity index 100% rename from packages/agent-core-v2/src/agent/plan/plan.ts rename to packages/agent-core-v2/src/features/plan/plan.ts diff --git a/packages/agent-core-v2/src/features/plan/planFeature.ts b/packages/agent-core-v2/src/features/plan/planFeature.ts new file mode 100644 index 00000000000..f659ec8774a --- /dev/null +++ b/packages/agent-core-v2/src/features/plan/planFeature.ts @@ -0,0 +1,45 @@ +/** + * `plan` domain — `PlanFeature`: the plan-mode capability assembled as one + * App-scope Feature unit. + * + * Contributes the per-Agent `IAgentPlanService` and the `EnterPlanMode` / + * `ExitPlanMode` agent tools through the `features` base-class seams; + * retracting the unit withdraws all of them across the scope tree. The + * `defaultPlanMode` config section (`features/plan/configSection`), the + * `plan` agent profile (`features/plan/profile`), and the `plan_mode.*` / + * `plan.revision` wire vocabulary (`features/plan/planOps`) stay on their + * static import=register channels — user-facing contracts must remain + * statically discoverable (config manifest) and wire records replayable even + * when the feature unit is retracted. Registered into the feature table at + * import. + */ + +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import './configSection'; +import { IAgentPlanService } from './plan'; +import { AgentPlanService } from './planService'; +import { IEnterPlanModeTool } from './tools/enter-plan-mode/enter-plan-mode'; +import { EnterPlanModeTool } from './tools/enter-plan-mode/enterPlanModeTool'; +import { IExitPlanModeTool } from './tools/exit-plan-mode/exit-plan-mode'; +import { ExitPlanModeTool } from './tools/exit-plan-mode/exitPlanModeTool'; + +export class PlanFeature extends Feature { + static override readonly name = 'plan'; + + constructor() { + super(); + this.contributeAgentService(IAgentPlanService, AgentPlanService); + this.contributeTool(IEnterPlanModeTool, EnterPlanModeTool, { + name: 'EnterPlanMode', + domain: 'plan', + }); + this.contributeTool(IExitPlanModeTool, ExitPlanModeTool, { + name: 'ExitPlanMode', + domain: 'plan', + }); + } +} + +registerFeature(PlanFeature); diff --git a/packages/agent-core-v2/src/agent/plan/planOps.ts b/packages/agent-core-v2/src/features/plan/planOps.ts similarity index 100% rename from packages/agent-core-v2/src/agent/plan/planOps.ts rename to packages/agent-core-v2/src/features/plan/planOps.ts diff --git a/packages/agent-core-v2/src/agent/plan/planService.ts b/packages/agent-core-v2/src/features/plan/planService.ts similarity index 95% rename from packages/agent-core-v2/src/agent/plan/planService.ts rename to packages/agent-core-v2/src/features/plan/planService.ts index 5ca8ddb29d7..80aa641d4f7 100644 --- a/packages/agent-core-v2/src/agent/plan/planService.ts +++ b/packages/agent-core-v2/src/features/plan/planService.ts @@ -18,21 +18,22 @@ * CronDelete call is vetoed with a `toolApproval.formatDenyMessage`- * formatted reason, and an `ExitPlanMode` call outside `auto` mode defers * to a cold `waitUntil` factory running the `exitPlanModeReview` user - * review. Bound at Agent scope. + * review. Bound at Agent scope — contributed into every Agent scope by + * `PlanFeature` (`features/plan/planFeature`). */ import { createHash, randomUUID } from 'node:crypto'; import { dirname, join } from 'pathe'; -import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { unwrapErrorCause } from '#/_base/errors/errors'; import { Error2, ErrorCodes } from '#/errors'; import { generateHeroSlug } from '#/_base/utils/hero-slug'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import { PlanModeInjection } from '#/agent/plan/injection/planModeInjection'; +import { PlanModeInjection } from '#/features/plan/injection/planModeInjection'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; @@ -64,7 +65,7 @@ import { planRevision, } from './planOps'; -export class AgentPlanService extends Disposable implements IAgentPlanService { +export class AgentPlanService extends Service implements IAgentPlanService { declare readonly _serviceBrand: undefined; private readonly review: ExitPlanModeReview; @@ -299,11 +300,3 @@ function planModeWriteDeniedMessage(planFilePath: string | null): string { } export { AgentPlanService as Plan }; - -registerScopedService( - LifecycleScope.Agent, - IAgentPlanService, - AgentPlanService, - ScopeActivation.OnScopeCreated, - 'plan', -); diff --git a/packages/agent-core-v2/src/agent/plan/profile/plan.ts b/packages/agent-core-v2/src/features/plan/profile/plan.ts similarity index 100% rename from packages/agent-core-v2/src/agent/plan/profile/plan.ts rename to packages/agent-core-v2/src/features/plan/profile/plan.ts diff --git a/packages/agent-core-v2/src/agent/tools/plan/enter-plan-mode/enter-plan-mode.md b/packages/agent-core-v2/src/features/plan/tools/enter-plan-mode/enter-plan-mode.md similarity index 100% rename from packages/agent-core-v2/src/agent/tools/plan/enter-plan-mode/enter-plan-mode.md rename to packages/agent-core-v2/src/features/plan/tools/enter-plan-mode/enter-plan-mode.md diff --git a/packages/agent-core-v2/src/agent/tools/plan/enter-plan-mode/enter-plan-mode.ts b/packages/agent-core-v2/src/features/plan/tools/enter-plan-mode/enter-plan-mode.ts similarity index 94% rename from packages/agent-core-v2/src/agent/tools/plan/enter-plan-mode/enter-plan-mode.ts rename to packages/agent-core-v2/src/features/plan/tools/enter-plan-mode/enter-plan-mode.ts index 04de469bfc9..fcd299d40b7 100644 --- a/packages/agent-core-v2/src/agent/tools/plan/enter-plan-mode/enter-plan-mode.ts +++ b/packages/agent-core-v2/src/features/plan/tools/enter-plan-mode/enter-plan-mode.ts @@ -1,5 +1,5 @@ /** - * `tools` domain — `IEnterPlanModeTool` contract. + * `plan` domain — `IEnterPlanModeTool` contract. * * Public contract of the EnterPlanMode tool — the plan-mode entry tool the * LLM calls to enter plan mode directly: the (empty) input schema and the diff --git a/packages/agent-core-v2/src/agent/tools/plan/enter-plan-mode/enterPlanModeTool.ts b/packages/agent-core-v2/src/features/plan/tools/enter-plan-mode/enterPlanModeTool.ts similarity index 91% rename from packages/agent-core-v2/src/agent/tools/plan/enter-plan-mode/enterPlanModeTool.ts rename to packages/agent-core-v2/src/features/plan/tools/enter-plan-mode/enterPlanModeTool.ts index 0739a6b9f18..eaf1b1c3967 100644 --- a/packages/agent-core-v2/src/agent/tools/plan/enter-plan-mode/enterPlanModeTool.ts +++ b/packages/agent-core-v2/src/features/plan/tools/enter-plan-mode/enterPlanModeTool.ts @@ -1,5 +1,5 @@ /** - * `tools` domain — `IEnterPlanModeTool` implementation. + * `plan` domain — `IEnterPlanModeTool` implementation. * * Enters plan mode through the plan service (`plan`), reporting an error when * plan mode is already active, and tracks the `plan_enter_resolved` @@ -9,10 +9,9 @@ */ import type { ToolExecution } from '#/tool/toolContract'; -import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { toInputJsonSchema } from '#/tool/input-schema'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IAgentPlanService } from '#/agent/plan/plan'; +import { IAgentPlanService } from '#/features/plan/plan'; import DESCRIPTION from './enter-plan-mode.md?raw'; import { @@ -62,8 +61,6 @@ export class EnterPlanModeTool implements IEnterPlanModeTool { } } -registerAgentToolService(IEnterPlanModeTool, EnterPlanModeTool, { name: 'EnterPlanMode', domain: 'plan' }); - function enteredPlanModeMessage(planPath: string | null): string { if (planPath === null) { return [ diff --git a/packages/agent-core-v2/src/agent/tools/plan/exit-plan-mode/exit-plan-mode.md b/packages/agent-core-v2/src/features/plan/tools/exit-plan-mode/exit-plan-mode.md similarity index 100% rename from packages/agent-core-v2/src/agent/tools/plan/exit-plan-mode/exit-plan-mode.md rename to packages/agent-core-v2/src/features/plan/tools/exit-plan-mode/exit-plan-mode.md diff --git a/packages/agent-core-v2/src/agent/tools/plan/exit-plan-mode/exit-plan-mode.ts b/packages/agent-core-v2/src/features/plan/tools/exit-plan-mode/exit-plan-mode.ts similarity index 98% rename from packages/agent-core-v2/src/agent/tools/plan/exit-plan-mode/exit-plan-mode.ts rename to packages/agent-core-v2/src/features/plan/tools/exit-plan-mode/exit-plan-mode.ts index d7eb5baec8e..90f4ed0ff4c 100644 --- a/packages/agent-core-v2/src/agent/tools/plan/exit-plan-mode/exit-plan-mode.ts +++ b/packages/agent-core-v2/src/features/plan/tools/exit-plan-mode/exit-plan-mode.ts @@ -1,5 +1,5 @@ /** - * `tools` domain — `IExitPlanModeTool` contract. + * `plan` domain — `IExitPlanModeTool` contract. * * Public contract of the ExitPlanMode tool — the plan-mode exit tool the LLM * calls to surface a finalised plan to the user and exit plan mode: the input diff --git a/packages/agent-core-v2/src/agent/tools/plan/exit-plan-mode/exitPlanModeTool.ts b/packages/agent-core-v2/src/features/plan/tools/exit-plan-mode/exitPlanModeTool.ts similarity index 94% rename from packages/agent-core-v2/src/agent/tools/plan/exit-plan-mode/exitPlanModeTool.ts rename to packages/agent-core-v2/src/features/plan/tools/exit-plan-mode/exitPlanModeTool.ts index 2c221b280cc..3d3357420e0 100644 --- a/packages/agent-core-v2/src/agent/tools/plan/exit-plan-mode/exitPlanModeTool.ts +++ b/packages/agent-core-v2/src/features/plan/tools/exit-plan-mode/exitPlanModeTool.ts @@ -1,5 +1,5 @@ /** - * `tools` domain — `IExitPlanModeTool` implementation. + * `plan` domain — `IExitPlanModeTool` implementation. * * Reads the plan file tracked by the plan service (`plan`) and flips plan * mode off. Every submission — the moment the final content is read for the @@ -20,11 +20,10 @@ import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; import type { ExecutableToolResult, ToolExecution } from '#/tool/toolContract'; -import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { toInputJsonSchema } from '#/tool/input-schema'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IAgentPlanService } from '#/agent/plan/plan'; -import type { PlanData } from '#/agent/plan/plan'; +import { IAgentPlanService } from '#/features/plan/plan'; +import type { PlanData } from '#/features/plan/plan'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import DESCRIPTION from './exit-plan-mode.md?raw'; @@ -172,8 +171,6 @@ export class ExitPlanModeTool implements IExitPlanModeTool { } } -registerAgentToolService(IExitPlanModeTool, ExitPlanModeTool, { name: 'ExitPlanMode', domain: 'plan' }); - function formatAutoApprovedPlanForOutput(plan: string, path: string | undefined): string { const savedTo = path !== undefined ? `Plan saved to: ${path}\n\n` : ''; return `Plan mode deactivated. All tools are now available.\nNote: this plan was auto-approved without user review — the user has NOT explicitly approved it. Follow the user's original instructions on whether to proceed with execution; if they asked you to stop, wait, or only summarize after planning, do not start executing.\n${savedTo}## Plan (auto-approved, not user-reviewed):\n${plan}`; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 10529274495..9994bbf630b 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -10,7 +10,33 @@ export * from '#/_base/di/instantiation'; export * from '#/_base/di/instantiationService'; export * from '#/_base/di/lifecycle'; export * from '#/_base/di/scope'; +export * from './app/scopes'; export * from '#/_base/di/serviceCollection'; +export * from '#/_base/di/cascadeEngine'; +export * from '#/_base/di/dependencyGraph'; +export * from '#/_base/lifecycle/ledger'; +export { + collection, + isCollectionToken, + type CollectionChange, + type CollectionRecord, + type CollectionToken, + type CollectionView, +} from '#/_base/di/collection'; +export { + FiberProtocolError, + FiberState, + ScopeUnits, + ServiceRecipeError, + setFiberEventResolver, + type ConfigSchema, + type Fiber, + type FiberHandle, + type FiberProvideOptions, + type RecipeStatics, + type ServiceRecipe, +} from '#/_base/di/fiber'; +export { Service } from '#/_base/di/service'; export * from './errors'; export * from '#/_base/log/log'; @@ -20,6 +46,7 @@ export * from '#/_base/log/fileLog'; export * from '#/_base/log/logService'; export * from '#/wire/wire'; export * from '#/wire/wireService'; +export * from '#/wire/wireContribution'; export * from '#/wire/record'; export * from '#/wire/migration/migration'; export * from '#/session/sessionLog/sessionLogService'; @@ -64,6 +91,7 @@ import '#/app/task/taskService'; export { TaskService } from '#/app/task/taskService'; import '#/app/event/eventBusService'; import '#/app/event/eventService'; +import '#/app/event/fiberEventResolver'; export { IEventBus, type DomainEvent } from '#/app/event/eventBus'; export { IEventService, type DomainEvent as GlobalEvent } from '#/app/event/event'; export * from '#/_base/state/stateRegistry'; @@ -102,6 +130,7 @@ export * from '#/session/sessionToolPolicy/sessionToolPolicy'; export * from '#/session/sessionToolPolicy/sessionToolPolicyService'; export * from '#/app/config/config'; export * from '#/app/config/configService'; +export * from '#/app/config/configSectionContributions'; import '#/app/kosongConfig/configSection'; export * from '#/kosong/provider/provider'; export * from '#/kosong/provider/providerService'; @@ -189,6 +218,16 @@ export * from '#/app/capability/capability'; export * from '#/app/capability/capabilityService'; export * from '#/app/capability/errors'; export * from '#/app/capability/types'; +export * from '#/app/feature/featureManager'; +import '#/app/feature/featureManagerService'; +export * from '#/features/feature'; +export * from '#/features/featureAssembly'; +export * from '#/features/featureRegistry'; +import '#/features/featureAssemblyService'; +export * from '#/agent/command/agentCommand'; +export * from '#/agent/command/commandContribution'; +import '#/agent/command/agentCommandService'; +export * from '#/debug/index'; export * from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; export * from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService'; @@ -248,15 +287,16 @@ export * from '#/app/flag/flagService'; export * from '#/agent/activityView/activityView'; import '#/agent/activityView/activityViewService'; -import '#/agent/plan/profile/plan'; -export * from '#/agent/tools/plan/enter-plan-mode/enter-plan-mode'; -import '#/agent/tools/plan/enter-plan-mode/enterPlanModeTool'; -export * from '#/agent/tools/plan/exit-plan-mode/exit-plan-mode'; -import '#/agent/tools/plan/exit-plan-mode/exitPlanModeTool'; -import '#/agent/plan/configSection'; -export * from '#/agent/plan/plan'; -export * from '#/agent/plan/planOps'; -export * from '#/agent/plan/planService'; +import '#/features/plan/profile/plan'; +export * from '#/features/plan/tools/enter-plan-mode/enter-plan-mode'; +import '#/features/plan/tools/enter-plan-mode/enterPlanModeTool'; +export * from '#/features/plan/tools/exit-plan-mode/exit-plan-mode'; +import '#/features/plan/tools/exit-plan-mode/exitPlanModeTool'; +export * from '#/features/plan/configSection'; +export * from '#/features/plan/plan'; +export * from '#/features/plan/planOps'; +export * from '#/features/plan/planService'; +import '#/features/plan/planFeature'; export * from '#/agent/tools/goal/create-goal/create-goal'; import '#/agent/tools/goal/create-goal/createGoalTool'; export * from '#/agent/tools/goal/get-goal/get-goal'; @@ -608,8 +648,8 @@ import '#/agent/toolRegistry/toolRegistry'; import '#/agent/toolRegistry/toolRegistryService'; export { IAgentToolActivationService } from '#/agent/toolActivation/toolActivation'; export { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; -export { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; -export type { AgentToolContribution, AgentToolContributionOptions } from '#/agent/toolRegistry/toolContribution'; +export { registerAgentToolService, AgentToolContribution } from '#/agent/toolRegistry/toolContribution'; +export type { AgentToolContributionOptions } from '#/agent/toolRegistry/toolContribution'; export * from '#/agent/userTool/userTool'; export * from '#/agent/userTool/userToolOps'; export * from '#/agent/userTool/userToolService'; diff --git a/packages/agent-core-v2/src/kosong/model/catalogService.ts b/packages/agent-core-v2/src/kosong/model/catalogService.ts index e73f214f38a..d48875bc465 100644 --- a/packages/agent-core-v2/src/kosong/model/catalogService.ts +++ b/packages/agent-core-v2/src/kosong/model/catalogService.ts @@ -57,7 +57,8 @@ import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth'; import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2 } from '#/_base/errors/errors'; import type { ModelCapability } from '#/kosong/contract/capability'; import type { ProviderRequestAuth } from '#/kosong/contract/provider'; @@ -132,6 +133,7 @@ interface CatalogEntry { readonly trace: ResolutionTraceCollector; } +// NOTE: stays Disposable — its own 'get' collides with the Fiber export class ModelCatalog extends Disposable implements IModelCatalog { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/kosong/model/modelService.ts b/packages/agent-core-v2/src/kosong/model/modelService.ts index 9f86ddbc7a7..b676adafb0e 100644 --- a/packages/agent-core-v2/src/kosong/model/modelService.ts +++ b/packages/agent-core-v2/src/kosong/model/modelService.ts @@ -7,7 +7,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { AsyncEmitter, type Event, type IWaitUntil } from '#/_base/event'; import { deepEqual, diffRecords, isEmptyDiff } from '../recordDiff'; @@ -22,6 +23,7 @@ import { const NO_ABORT = new AbortController().signal; +// NOTE: stays Disposable — its own 'get' collides with the Fiber export class ModelService extends Disposable implements IModelService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts b/packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts index ceaecc2eb0a..8309c681505 100644 --- a/packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts +++ b/packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts @@ -24,7 +24,9 @@ * Bound at App scope, eager. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability'; import type { ModelCapability } from '#/kosong/contract/capability'; import { ChatProviderError } from '#/kosong/contract/errors'; diff --git a/packages/agent-core-v2/src/kosong/provider/providerService.ts b/packages/agent-core-v2/src/kosong/provider/providerService.ts index 8d15e84b0b3..fb6a8159058 100644 --- a/packages/agent-core-v2/src/kosong/provider/providerService.ts +++ b/packages/agent-core-v2/src/kosong/provider/providerService.ts @@ -7,7 +7,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { AsyncEmitter, type Event, type IWaitUntil } from '#/_base/event'; import { deepEqual, diffRecords, isEmptyDiff } from '../recordDiff'; @@ -22,6 +23,7 @@ import { const NO_ABORT = new AbortController().signal; +// NOTE: stays Disposable — its own 'get' collides with the Fiber export class ProviderService extends Disposable implements IProviderService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/mcpCore/types.ts b/packages/agent-core-v2/src/mcpCore/types.ts index 522cbc396b5..c38b7f4caba 100644 --- a/packages/agent-core-v2/src/mcpCore/types.ts +++ b/packages/agent-core-v2/src/mcpCore/types.ts @@ -8,11 +8,6 @@ import { ErrorCodes, Error2 } from '#/errors'; -/** - * Inline resource contents nested under an EmbeddedResource block. - * Exactly one of `text` or `blob` is populated, per the MCP schema's - * `TextResourceContents | BlobResourceContents` union. - */ export interface MCPEmbeddedResourceContents { uri: string; mimeType?: string; diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostClockService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostClockService.ts index 5b2f32ac083..3c979c01584 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostClockService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostClockService.ts @@ -5,7 +5,8 @@ * Node.js runtime. Bound at App scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IHostClock } from '#/os/interface/hostClock'; export class HostClockService implements IHostClock { diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts index ed4aad339e5..b44d74c8193 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts @@ -8,7 +8,9 @@ * returning stale zeros. Bound at App scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { BugIndicatingError } from '#/_base/errors/errors'; import { probeHostEnvironmentFromNode } from '#/_base/execEnv/environmentProbe'; import { applyLoginShellPathFromNode } from '#/_base/execEnv/loginShellPath'; diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts index 9b9e7634c24..6cb12dcb83b 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts @@ -17,8 +17,8 @@ import { stat as nodeStat, writeFile, } from 'node:fs/promises'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { decodeTextWithErrors, type TextDecodeErrors } from '#/_base/execEnv/decodeText'; import { type HostDirEntry, type HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts index cdb945bf15a..333f6314982 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts @@ -12,7 +12,8 @@ import { FSWatcher } from 'chokidar'; import type { IDisposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { onUnexpectedError } from '#/_base/errors/unexpectedError'; import { diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostProcessService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostProcessService.ts index 859f03c4291..8d3aad955d7 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostProcessService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostProcessService.ts @@ -11,7 +11,8 @@ import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' import type { Readable, Writable } from 'node:stream'; import { BufferedReadable } from '#/_base/execEnv/bufferedReadable'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { HostProcessError, diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostTerminalService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostTerminalService.ts index 455a743736e..b87b14bb70b 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostTerminalService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostTerminalService.ts @@ -12,12 +12,13 @@ import type { IPty } from 'node-pty'; -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IHostTerminalService, type TerminalProcess, type TerminalSpawnOptions } from '#/os/interface/terminal'; -export class HostTerminalService extends Disposable implements IHostTerminalService { +export class HostTerminalService extends Service implements IHostTerminalService { declare readonly _serviceBrand: undefined; private readonly processes = new Set(); diff --git a/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts b/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts index 747939b1c2b..666c75fd323 100644 --- a/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts @@ -59,7 +59,8 @@ import { type QueryOptions } from '@moonshot-ai/minidb'; import { ClusterDb } from '@moonshot-ai/minidb/cluster'; import { Disposable, toDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { @@ -107,6 +108,7 @@ export async function drainQueryStoreDisposals(): Promise { await Promise.all(pendingDisposals); } +// NOTE: stays Disposable — its own 'get' collides with the Fiber export class MiniDbQueryStore extends Disposable implements IQueryStore { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts index b9e2d44a49a..23e6b2e08ae 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts @@ -15,7 +15,8 @@ */ import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts index 38563f4795a..bc18136ba38 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts @@ -10,7 +10,8 @@ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Event } from '#/_base/event'; import { IFileSystemStorageService, StorageError, StorageErrors } from '#/persistence/interface/storage'; diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/blobStoreService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/blobStoreService.ts index 38eadfb5751..b99408f760e 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/blobStoreService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/blobStoreService.ts @@ -6,7 +6,9 @@ * scope strings to namespace their data. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IBlobStore, type BlobReadRange } from '#/persistence/interface/blobStore'; diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts index e89166ddf00..a7579c08520 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts @@ -12,8 +12,8 @@ import { dirname, isAbsolute, join, normalize, resolve } from 'pathe'; import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; import { z } from 'zod'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IProjectLocalConfigService, diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 794f6a364fe..6c7a0d6efe9 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -24,10 +24,10 @@ import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { Emitter } from '#/_base/event'; import { Error2, ErrorCodes } from '#/errors'; import { join } from 'pathe'; +import { LifecycleScope } from '#/app/scopes'; import { createScopedChildHandle, type IAgentScopeHandle, - LifecycleScope, ScopeActivation, registerScopedService, } from '#/_base/di/scope'; @@ -60,6 +60,7 @@ import { let nextAgentId = 0; +// NOTE: stays Disposable — its own 'get' and 'config' collide with the Fiber export class AgentLifecycleService extends Disposable implements IAgentLifecycleService { declare readonly _serviceBrand: undefined; private readonly handles = new Map(); diff --git a/packages/agent-core-v2/src/session/approval/approvalService.ts b/packages/agent-core-v2/src/session/approval/approvalService.ts index 3cc8813bb7f..27243377eb7 100644 --- a/packages/agent-core-v2/src/session/approval/approvalService.ts +++ b/packages/agent-core-v2/src/session/approval/approvalService.ts @@ -5,7 +5,9 @@ * pending state of its own (the kernel holds it). Bound at Session scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ISessionInteractionService } from '#/session/interaction/interaction'; import { diff --git a/packages/agent-core-v2/src/session/btw/btwService.ts b/packages/agent-core-v2/src/session/btw/btwService.ts index 4ee6362db52..0b81d2cd7cf 100644 --- a/packages/agent-core-v2/src/session/btw/btwService.ts +++ b/packages/agent-core-v2/src/session/btw/btwService.ts @@ -12,7 +12,9 @@ * forking a missing source throws. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; diff --git a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts index 94ee5566e70..74fb9c088ff 100644 --- a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts +++ b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts @@ -23,7 +23,8 @@ import type { ContentPart } from '#/kosong/contract/message'; import type { CronJobOrigin, CronMissedOrigin } from '#/agent/contextMemory/types'; import { Disposable, toDisposable } from '#/_base/di/lifecycle'; -import { type IAgentScopeHandle, LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { type IAgentScopeHandle, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IntervalTimer } from '#/_base/utils/timer'; @@ -77,6 +78,7 @@ const MAX_COALESCE_ITERATIONS = 10_000; const CRON_ID_REGEX: RegExp = /^(?:[0-9a-f]{8}|[0-9A-HJKMNP-TV-Z]{26})$/i; const MAX_ID_ATTEMPTS = 8; +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class SessionCronServiceImpl extends Disposable implements ISessionCronService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts index 53a390eb7cd..cd82ec7a672 100644 --- a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts @@ -23,8 +23,9 @@ * live in the runner. Bound at Session scope. */ -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IntervalTimer } from '#/_base/utils/timer'; import { IExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunner'; import type { Hooks } from '#/hooks'; @@ -53,7 +54,7 @@ type SessionStartHookSource = Exclude; const HEARTBEAT_INTERVAL_MS = 60_000; export class SessionExternalHooksService - extends Disposable + extends Service implements ISessionExternalHooksService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/session/interaction/interactionService.ts b/packages/agent-core-v2/src/session/interaction/interactionService.ts index 1671589838c..2984c85f8c0 100644 --- a/packages/agent-core-v2/src/session/interaction/interactionService.ts +++ b/packages/agent-core-v2/src/session/interaction/interactionService.ts @@ -18,8 +18,9 @@ import { Emitter, type Event } from '#/_base/event'; import { IInstantiationService } from '#/_base/di/instantiation'; -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -56,7 +57,7 @@ export const interactionRecentlyResolvedKey = defineState>( ); export const interactionNextIdKey = defineState('interaction.nextId', () => 0); -export class SessionInteractionService extends Disposable implements ISessionInteractionService { +export class SessionInteractionService extends Service implements ISessionInteractionService { declare readonly _serviceBrand: undefined; private readonly _onDidChangePending = this._register(new Emitter()); diff --git a/packages/agent-core-v2/src/session/process/processRunnerService.ts b/packages/agent-core-v2/src/session/process/processRunnerService.ts index 2f4ecb07d56..b25160f66f5 100644 --- a/packages/agent-core-v2/src/session/process/processRunnerService.ts +++ b/packages/agent-core-v2/src/session/process/processRunnerService.ts @@ -15,7 +15,9 @@ * contracts. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { BugIndicatingError } from '#/errors'; import { IHostProcessService } from '#/os/interface/hostProcess'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; diff --git a/packages/agent-core-v2/src/session/question/questionService.ts b/packages/agent-core-v2/src/session/question/questionService.ts index c10318aaf2e..f9648b6491e 100644 --- a/packages/agent-core-v2/src/session/question/questionService.ts +++ b/packages/agent-core-v2/src/session/question/questionService.ts @@ -5,7 +5,9 @@ * pending state of its own (the kernel holds it). Bound at Session scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ISessionInteractionService } from '#/session/interaction/interaction'; import { diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts index f95a0b77282..086d460387e 100644 --- a/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts @@ -13,8 +13,8 @@ */ import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, registerScopedService, type IAgentScopeHandle, @@ -54,6 +54,7 @@ export const sessionActivityCurrentKey = defineState('sess lastTurnReason: undefined, })); +// NOTE: stays Disposable — its own 'state' collides with the Fiber export class SessionActivityView extends Disposable implements ISessionActivityView { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts index b60579f16b6..3b1ce50e606 100644 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts @@ -19,7 +19,8 @@ import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { BugIndicatingError } from '#/errors'; import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; @@ -43,6 +44,7 @@ interface ProfileCandidate { readonly priority: number; } +// NOTE: stays Disposable — its own 'get' collides with the Fiber export class SessionAgentProfileCatalogService extends Disposable implements ISessionAgentProfileCatalog diff --git a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts index dfc2e597723..613a1d847c0 100644 --- a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts +++ b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts @@ -22,7 +22,9 @@ * `SESSION_INIT_FAILED`) so callers can tell "aborted" from "failed". */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { isAbortError, isUserCancellation, userCancellationReason } from '#/_base/utils/abort'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; diff --git a/packages/agent-core-v2/src/session/sessionLog/sessionLogService.ts b/packages/agent-core-v2/src/session/sessionLog/sessionLogService.ts index 647f2fde3fa..479e498d45d 100644 --- a/packages/agent-core-v2/src/session/sessionLog/sessionLogService.ts +++ b/packages/agent-core-v2/src/session/sessionLog/sessionLogService.ts @@ -9,7 +9,9 @@ * `sessionState` (`ISessionStateService`) and read/written through it. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionStateService } from '#/session/state/sessionState'; diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index b3120b5fe83..030be428b31 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -26,8 +26,9 @@ * loading an *existing* document (session resume) stays silent. */ -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; import { defineState } from '#/_base/state/stateRegistry'; @@ -53,7 +54,7 @@ export const sessionMetadataDataKey = defineState( () => undefined, ); -export class SessionMetadata extends Disposable implements ISessionMetadata { +export class SessionMetadata extends Service implements ISessionMetadata { declare readonly _serviceBrand: undefined; readonly ready: Promise; readonly onDidChangeMetadata: Event; diff --git a/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts b/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts new file mode 100644 index 00000000000..ae37a9da11f --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts @@ -0,0 +1,278 @@ +/** + * `sessionSeed` domain — the workspace → session seed adapter units. + * + * Each adapter projects one workspace-scoped resource service into its + * Session-scope pure-data injection contract (the seed tokens every session + * consumer resolves): the workspace's merged skill catalog, the AGENTS.md + * snapshot, the shared MCP connection handle, the additional-directory set, + * and the os-level tool veto. The projection object is built per upstream + * generation by the workspace service's own `sessionData()` / + * `sessionProvider()` / `sessionHandle()` / `sessionInfo()` / `sessionGate()` + * method; the adapter only owns the LIFETIME semantics the plain `extra` seed + * could not express: + * + * - live reads: the data object's getters delegate to the CURRENT backing + * projection, so an upstream rebuild (a new generation observed through + * `@ref`) never leaves consumers reading a stale closure; + * - change events: `onDidChange` is the adapter's own emitter — it forwards + * the backing projection's events and RE-FIRES when the backing view + * switches, telling consumers to re-pull; + * - hosts without a workspace layer (test hosts, harness agents): the + * observed upstream is absent and the adapter returns early, leaving the + * scope's default/extra registration (e.g. the Noop tool-policy gate) + * untouched. + * + * The units carry no DI token of their own: the session + * assembly point constructs them explicitly (`assembleSessionSeedAdapters`, + * the `assemble` hook of `createScopedChildHandle`) and anchors their + * disposal into the session container's ledger. Observation (`@ref`) is + * data-flow semantics — an upstream rebuild re-fires `onDidChange` instead + * of cascading this adapter down. A session created with ephemeral + * `mcpServers` passes its merged overlay handle as `sessionMcpHandle`: the + * MCP adapter is skipped and the overlay handle is provided directly (fixed + * at creation, like the pre-adapter inline seed). + */ + +import type { ServiceClassRecipe } from '#/_base/di/fiber'; +import { IInstantiationService, ref, type LiveRef } from '#/_base/di/instantiation'; +import type { InstantiationService } from '#/_base/di/instantiationService'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { Emitter } from '#/_base/event'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; +import { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; +import { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; +import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; + +export class SessionSkillCatalogDataAdapter extends Service { + constructor( + @IInstantiationService instantiation: IInstantiationService, + @ref(IWorkspaceSkillCatalog) upstream: LiveRef, + ) { + super(); + if (upstream.current === undefined) return; + const change = this._register(new Emitter()); + let backing = upstream.current.sessionData(); + let backingSubscription = backing.onDidChange((sourceId) => { + change.fire(sourceId); + }); + this._register({ + dispose: () => { + backingSubscription.dispose(); + }, + }); + this._register( + upstream.onDidChange(() => { + if (upstream.current !== undefined) { + backingSubscription.dispose(); + backing = upstream.current.sessionData(); + backingSubscription = backing.onDidChange((sourceId) => { + change.fire(sourceId); + }); + } + change.fire('catalog'); + }), + ); + const data: ISessionSkillCatalogData = { + _serviceBrand: undefined, + get ready() { + return backing.ready; + }, + get catalog() { + return backing.catalog; + }, + onDidChange: change.event, + }; + instantiation.provide(ISessionSkillCatalogData, data); + } +} + +export class SessionInstructionsProviderAdapter extends Service { + constructor( + @IInstantiationService instantiation: IInstantiationService, + @ref(IWorkspaceInstructionsService) upstream: LiveRef, + ) { + super(); + if (upstream.current === undefined) return; + const change = this._register(new Emitter()); + let backing = upstream.current.sessionProvider(); + let backingSubscription = backing.onDidChange(() => { + change.fire(); + }); + this._register({ + dispose: () => { + backingSubscription.dispose(); + }, + }); + this._register( + upstream.onDidChange(() => { + if (upstream.current !== undefined) { + backingSubscription.dispose(); + backing = upstream.current.sessionProvider(); + backingSubscription = backing.onDidChange(() => { + change.fire(); + }); + } + change.fire(); + }), + ); + const data: ISessionInstructionsProvider = { + _serviceBrand: undefined, + get ready() { + return backing.ready; + }, + get agentsMd() { + return backing.agentsMd; + }, + get agentsMdWarning() { + return backing.agentsMdWarning; + }, + get agentsMdPaths() { + return backing.agentsMdPaths; + }, + onDidChange: change.event, + }; + instantiation.provide(ISessionInstructionsProvider, data); + } +} + +export class SessionMcpHandleAdapter extends Service { + constructor( + @IInstantiationService instantiation: IInstantiationService, + @ref(IWorkspaceMcpService) upstream: LiveRef, + ) { + super(); + if (upstream.current === undefined) return; + let backing = upstream.current.sessionHandle(); + this._register( + upstream.onDidChange(() => { + if (upstream.current !== undefined) { + backing = upstream.current.sessionHandle(); + } + }), + ); + const handle: ISessionMcpHandle = { + _serviceBrand: undefined, + get ready() { + return backing.ready; + }, + get connectionManager() { + return backing.connectionManager; + }, + }; + instantiation.provide(ISessionMcpHandle, handle); + } +} + +export class SessionWorkspaceInfoAdapter extends Service { + constructor( + @IInstantiationService instantiation: IInstantiationService, + @ref(IWorkspaceDirs) upstream: LiveRef, + ) { + super(); + if (upstream.current === undefined) return; + const change = this._register(new Emitter()); + let backing = upstream.current.sessionInfo(); + let backingSubscription = backing.onDidChange(() => { + change.fire(); + }); + this._register({ + dispose: () => { + backingSubscription.dispose(); + }, + }); + this._register( + upstream.onDidChange(() => { + if (upstream.current !== undefined) { + backingSubscription.dispose(); + backing = upstream.current.sessionInfo(); + backingSubscription = backing.onDidChange(() => { + change.fire(); + }); + } + change.fire(); + }), + ); + const info: ISessionWorkspaceInfo = { + _serviceBrand: undefined, + get ready() { + return backing.ready; + }, + get additionalDirs() { + return backing.additionalDirs; + }, + onDidChange: change.event, + }; + instantiation.provide(ISessionWorkspaceInfo, info); + } +} + +export class SessionToolPolicyGateAdapter extends Service { + constructor( + @IInstantiationService instantiation: IInstantiationService, + @ref(IWorkspaceToolPolicy) upstream: LiveRef, + ) { + super(); + if (upstream.current === undefined) return; + const change = this._register(new Emitter()); + let backing = upstream.current.sessionGate(); + let backingSubscription = backing.onDidChange(() => { + change.fire(); + }); + this._register({ + dispose: () => { + backingSubscription.dispose(); + }, + }); + this._register( + upstream.onDidChange(() => { + if (upstream.current !== undefined) { + backingSubscription.dispose(); + backing = upstream.current.sessionGate(); + backingSubscription = backing.onDidChange(() => { + change.fire(); + }); + } + change.fire(); + }), + ); + const gate: ISessionToolPolicyGate = { + _serviceBrand: undefined, + get disabledTools() { + return backing.disabledTools; + }, + onDidChange: change.event, + }; + instantiation.provide(ISessionToolPolicyGate, gate); + } +} + +const SESSION_SEED_ADAPTERS: readonly ServiceClassRecipe[] = [ + SessionSkillCatalogDataAdapter, + SessionInstructionsProviderAdapter, + SessionMcpHandleAdapter, + SessionWorkspaceInfoAdapter, + SessionToolPolicyGateAdapter, +]; + +export function assembleSessionSeedAdapters( + container: InstantiationService, + sessionMcpHandle?: ISessionMcpHandle, +): void { + for (const recipe of SESSION_SEED_ADAPTERS) { + if (recipe === SessionMcpHandleAdapter && sessionMcpHandle !== undefined) { + container.provide(ISessionMcpHandle, sessionMcpHandle); + continue; + } + const adapter = container.fiberHost.constructService(recipe, undefined) as Partial; + container.anchorKernelEntry(() => { + adapter.dispose?.(); + }, `sessionSeed:${recipe.name}`); + } +} diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts index 1736c28cbec..18892e8d437 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts @@ -14,9 +14,10 @@ * Bound at Session scope. */ -import { Disposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { Emitter, type Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import type { SkillContribution } from '#/app/skillCatalog/skillSource'; @@ -35,7 +36,7 @@ export const skillCatalogMergedKey = defineState( ); export class SessionSkillCatalogService - extends Disposable + extends Service implements ISessionSkillCatalog, ISkillCatalogSink { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts b/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts index b9dc1ca5bba..6b17a4fe40a 100644 --- a/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts +++ b/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts @@ -9,7 +9,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { AsyncEmitter, type Event } from '#/_base/event'; import { defineState } from '#/_base/state/stateRegistry'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; @@ -31,6 +32,7 @@ export const sessionToolPolicyStateKey = defineState('se const STATE_KEY = 'state.json'; +// NOTE: stays Disposable — its own 'state' collides with the Fiber export class SessionToolPolicyService extends Disposable implements ISessionToolPolicy { declare readonly _serviceBrand: undefined; readonly ready: Promise; diff --git a/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts index 4788251e4c0..264632b8ba3 100644 --- a/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts +++ b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts @@ -9,7 +9,8 @@ */ import { Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ISessionToolPolicyGate } from './sessionToolPolicyGate'; diff --git a/packages/agent-core-v2/src/session/state/sessionStateService.ts b/packages/agent-core-v2/src/session/state/sessionStateService.ts index fba29346572..aaa26e55ea6 100644 --- a/packages/agent-core-v2/src/session/state/sessionStateService.ts +++ b/packages/agent-core-v2/src/session/state/sessionStateService.ts @@ -8,7 +8,9 @@ * injects). Bound at Session scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { StateRegistry } from '#/_base/state/stateRegistry'; import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index e98989adeed..38c743ac3a3 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -190,17 +190,6 @@ function resolvedCapabilities( } } -/** - * Strip the `model` property from a subagent collaboration tool's advertised - * JSON schema. While the `secondary-model` experiment is off the parameter is - * a silent no-op, so the schema the model sees (and the args validator - * compiled from the same advertised schema) drops it entirely — the - * secondary-model concept never enters the prompt, and a stray `model` - * argument is rejected instead of silently inheriting the caller's model. - * Returns the input unchanged when there is no `model` property; otherwise a - * shallow copy — the input is never mutated, so callers can keep both - * variants as shared constants. - */ export function stripSubagentModelParameter( parameters: Record, ): Record { diff --git a/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts b/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts index 4224212d81d..16e8f4250fb 100644 --- a/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts +++ b/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts @@ -17,9 +17,9 @@ */ import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; import { type IAgentScopeHandle, - LifecycleScope, ScopeActivation, registerScopedService, } from '#/_base/di/scope'; @@ -46,6 +46,7 @@ import { type SecondaryModelWarning, } from './secondaryModelWarning'; +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class SessionSecondaryModelWarningService extends Disposable implements ISessionSecondaryModelWarningService diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts index a77a1599b47..774cb05f4b3 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts @@ -9,11 +9,11 @@ * turn driving itself is delegated to a pure helper. Bound at Session scope. */ -import { Disposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { Error2, ErrorCodes } from '#/errors'; +import { LifecycleScope } from '#/app/scopes'; import { type IAgentScopeHandle, - LifecycleScope, ScopeActivation, registerScopedService, } from '#/_base/di/scope'; @@ -34,7 +34,7 @@ import { } from './subagent'; import { runAgentTurn } from './runAgentTurn'; -export class SessionSubagentService extends Disposable implements ISessionSubagentService { +export class SessionSubagentService extends Service implements ISessionSubagentService { declare readonly _serviceBrand: undefined; readonly hooks = createHooks(['onWillStartAgentTask']); diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index e3eb5475cdb..811cb21ee77 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -20,8 +20,8 @@ import type { TokenUsage } from '#/kosong/contract/usage'; import { IModelCatalog } from '#/kosong/model/catalog'; - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2, ErrorCodes } from '#/errors'; import { linkAbortSignal } from '#/_base/utils/abort'; import type { IAgentScopeHandle } from '#/_base/di/scope'; diff --git a/packages/agent-core-v2/src/session/terminal/terminalService.ts b/packages/agent-core-v2/src/session/terminal/terminalService.ts index 27911de0428..ec9556023be 100644 --- a/packages/agent-core-v2/src/session/terminal/terminalService.ts +++ b/packages/agent-core-v2/src/session/terminal/terminalService.ts @@ -11,7 +11,8 @@ import { randomUUID } from 'node:crypto'; import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { CreateTerminalRequest, Terminal, @@ -62,6 +63,7 @@ export interface ISessionTerminalService { export const ISessionTerminalService: ServiceIdentifier = createDecorator('sessionTerminalService'); +// NOTE: stays Disposable — its own 'get' collides with the Fiber export class SessionTerminalService extends Disposable implements ISessionTerminalService { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts index 3fde3e3a194..b5e38602ae9 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts @@ -9,10 +9,11 @@ * Session scope. */ -import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; import { type IAgentScopeHandle, - LifecycleScope, ScopeActivation, registerScopedService, } from '#/_base/di/scope'; @@ -32,7 +33,7 @@ import { TODO_LIST_REMINDER_VARIANT, todoListStaleReminder } from './todoListRem const MAIN_AGENT_ID = 'main'; -export class SessionTodoService extends Disposable implements ISessionTodoService { +export class SessionTodoService extends Service implements ISessionTodoService { declare readonly _serviceBrand: undefined; private readonly onDidChangeEmitter = this._register(new Emitter()); diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts index 4af45b48fcb..3336ac0fdb5 100644 --- a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts +++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts @@ -12,8 +12,9 @@ import { isAbsolute, relative, resolve } from 'node:path'; -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { ErrorCodes, Error2 } from '#/errors'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -28,7 +29,7 @@ export const workspaceContextAdditionalDirsKey = defineState( () => [], ); -export class SessionWorkspaceContextService extends Disposable implements ISessionWorkspaceContext { +export class SessionWorkspaceContextService extends Service implements ISessionWorkspaceContext { declare readonly _serviceBrand: undefined; constructor( diff --git a/packages/agent-core-v2/src/wire/errors.ts b/packages/agent-core-v2/src/wire/errors.ts index b40a0c73719..fa5f5249917 100644 --- a/packages/agent-core-v2/src/wire/errors.ts +++ b/packages/agent-core-v2/src/wire/errors.ts @@ -5,7 +5,9 @@ * Aggregates the wire domain's coded errors: `DuplicateOpError` and * `CycleError` stay co-located with their throw sites but extend * `WireError`; `wire.unknown_record` is constructed here for replay-time - * reporting of records whose Op type is absent from `OP_REGISTRY`. + * reporting of records whose Op type is absent from the wire runtime's + * folded op registry (unknown or withdrawn vocabulary — see + * `wireContribution.ts`). */ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; diff --git a/packages/agent-core-v2/src/wire/model.ts b/packages/agent-core-v2/src/wire/model.ts index db73244bf69..d672a70ce4c 100644 --- a/packages/agent-core-v2/src/wire/model.ts +++ b/packages/agent-core-v2/src/wire/model.ts @@ -29,6 +29,13 @@ * A primary Model may register cross-model reducers keyed by foreign op types: * the wire service runs them on both dispatch and restore, so v1-derived * restore effects can stay replayable without persisting extra records. + * + * `defineModel` also records every defined Model into `MODEL_REGISTRY`; + * together with `OP_REGISTRY`, `MODEL_CROSS_REDUCERS`, and + * `CHECKPOINTED_MODELS` these module tables are the static built-in channel + * ("import = register") that the `WireModelContribution` fold drains into the + * built-in layer whenever a `WireService` (re)folds its runtime lookups — + * registrations are append-only and never removed. * `DeepReadonly` recursively maps a state type to its deeply-readonly view * for the references returned by `getModel`: functions pass * through, `Map` / `Set` widen to `ReadonlyMap` / `ReadonlySet`, arrays and @@ -64,6 +71,9 @@ export interface ModelCrossReducerEntry { export const MODEL_CROSS_REDUCERS = new Map(); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const MODEL_REGISTRY: ModelDef[] = []; + export function defineModel( name: string, initial: () => S, @@ -89,6 +99,8 @@ export function defineModel( list.push({ model: def, reducer }); } } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + MODEL_REGISTRY.push(def as ModelDef); return def; } diff --git a/packages/agent-core-v2/src/wire/op.ts b/packages/agent-core-v2/src/wire/op.ts index 2dbe8472bb9..2ed858ca090 100644 --- a/packages/agent-core-v2/src/wire/op.ts +++ b/packages/agent-core-v2/src/wire/op.ts @@ -19,7 +19,11 @@ * payload type, stays assignable to the single `dispatch(...ops: Op[])` rest * parameter, while the precise payload type survives on `Op.payload` for the * Op's own caller. Registering a duplicate `type` throws `DuplicateOpError` so - * the global Op-type namespace stays unique. Scope-agnostic. + * the global Op-type namespace stays unique. `OP_REGISTRY` is never consulted + * at runtime directly: it is the static built-in channel ("import = register") + * that the `WireModelContribution` fold drains into the built-in layer (see + * `wireContribution.ts`); runtime lookups read the folded result. + * Scope-agnostic. */ import type { z } from 'zod'; diff --git a/packages/agent-core-v2/src/wire/wireContribution.ts b/packages/agent-core-v2/src/wire/wireContribution.ts new file mode 100644 index 00000000000..28cd5d3d8f1 --- /dev/null +++ b/packages/agent-core-v2/src/wire/wireContribution.ts @@ -0,0 +1,134 @@ +/** + * `wire` domain — the `WireModelContribution` collection token (D12), its + * per-domain record shape, and the fold that collapses the built-in layer + * plus live contribution records into the lookup structure the wire runtime + * consults. + * + * A unit contributes one bundle of wire vocabulary per domain with + * `this.provide(WireModelContribution, …)`: `models` (the `defineModel` + * products), `ops` (the `OpDescriptor`s), `crossReducers` (cross-model + * reducers keyed by foreign op type), and `checkpointedModels` (the + * `defineCheckpointedModel` products). The fold lives in `WireService` + * (Agent scope): it refolds from the built-in layer and the view's surviving + * records on every `onDidChange` — the collection edge enters the dependency + * graph for introspection but never rebuilds the service. A withdrawn record + * removes its vocabulary, so replaying that domain's historical wire records + * lands on the generic unknown-op path (skip + count): persisted facts stay + * readable when the contributing unit is long gone. + * + * The built-in layer is the module tables (`OP_REGISTRY`, `MODEL_REGISTRY`, + * `MODEL_CROSS_REDUCERS`, `CHECKPOINTED_MODELS`), drained at fold time: + * `defineOp` / `defineModel` / `defineCheckpointedModel` ("import = + * register") stay the static built-in data channel, every table is filled at + * module load — long before any scope constructs a `WireService` — and no op + * module is ever imported lazily, so draining at fold time is equivalent to + * the old live reads. (Routing the built-in layer through an App-scope + * assembly unit as just another collection record was considered and + * rejected: every bare-container `WireService` construction — unit tests + * included — would then have to materialize that assembly first. The sibling + * folds drain their module collectors at fold construction the same way.) + * + * Conflict semantics: `defineOp` keeps its module-load fail-fast + * (`DuplicateOpError`). The fold is an event path and never throws — a later + * record whose op type collides with an already-folded type is skipped and + * reported through `onUnexpectedError`, and the built-in layer always folds + * first so built-ins win every collision (a persistent conflict re-logs on + * each refold). Scope-agnostic. + */ + +import { collection } from '#/_base/di/collection'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import { + CHECKPOINTED_MODELS, + type Checkpointed, +} from '#/agent/contextMemory/conversationTime'; + +import { WireError, WireErrors } from './errors'; +import { + MODEL_CROSS_REDUCERS, + MODEL_REGISTRY, + type ModelCrossReducerEntry, + type ModelDef, +} from './model'; +import { OP_REGISTRY, type OpDescriptor } from './op'; + +export interface WireModelContributionRecord { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly models?: readonly ModelDef[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly ops?: readonly OpDescriptor[]; + readonly crossReducers?: ReadonlyMap; + readonly checkpointedModels?: readonly ModelDef>[]; +} + +export const WireModelContribution = collection('wire-model'); + +export interface FoldedWireRegistry { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly ops: ReadonlyMap>; + readonly crossReducers: ReadonlyMap; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly models: readonly ModelDef[]; + readonly checkpointedModels: readonly ModelDef>[]; +} + +export function builtinWireContribution(): WireModelContributionRecord { + return { + models: [...MODEL_REGISTRY], + ops: [...OP_REGISTRY.values()], + crossReducers: MODEL_CROSS_REDUCERS, + checkpointedModels: [...CHECKPOINTED_MODELS], + }; +} + +export function foldWireContributions( + records: readonly WireModelContributionRecord[], +): FoldedWireRegistry { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ops = new Map>(); + const crossReducers = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const models: ModelDef[] = []; + const checkpointedModels: ModelDef>[] = []; + for (const record of records) { + for (const op of record.ops ?? []) { + if (ops.has(op.type)) { + onUnexpectedError( + new WireError( + WireErrors.codes.WIRE_DUPLICATE_OP, + `Duplicate Op type contributed: '${op.type}'; keeping the already-folded registration`, + { details: { type: op.type } }, + ), + ); + continue; + } + ops.set(op.type, op); + } + for (const [opType, entries] of record.crossReducers ?? []) { + let list = crossReducers.get(opType); + if (list === undefined) { + list = []; + crossReducers.set(opType, list); + } + for (const entry of entries) { + const duplicate = list.some( + (existing) => existing.model === entry.model && existing.reducer === entry.reducer, + ); + if (!duplicate) { + list.push(entry); + } + } + } + for (const model of record.models ?? []) { + if (!models.includes(model)) { + models.push(model); + } + } + for (const model of record.checkpointedModels ?? []) { + if (!checkpointedModels.includes(model)) { + checkpointedModels.push(model); + } + } + } + return { ops, crossReducers, models, checkpointedModels }; +} diff --git a/packages/agent-core-v2/src/wire/wireService.ts b/packages/agent-core-v2/src/wire/wireService.ts index af2ee012398..47d527d52ae 100644 --- a/packages/agent-core-v2/src/wire/wireService.ts +++ b/packages/agent-core-v2/src/wire/wireService.ts @@ -7,14 +7,26 @@ * rewrites, blob dehydration and rehydration plus an ordered post-restore hook. * It is bound at Agent scope because the aggregate identity is the Agent * identity. + * + * The runtime lookups — the op table behind `restore`, the cross-reducer + * table behind `execute`, and the model / checkpointed-model lists — are the + * fold of the `WireModelContribution` collection (see `wireContribution.ts`): + * the built-in layer drained from the module tables plus every live + * contribution record, refolded on each view change; the collection edge + * never rebuilds the service. Replay tolerance is the fold's unload + * counterpart: a record whose op type is absent from the fold is skipped and + * counted, so a journal stays readable after the unit that contributed its + * vocabulary is withdrawn. */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { BugIndicatingError } from '#/_base/errors/errors'; import { onUnexpectedError } from '#/_base/errors/unexpectedError'; -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { type CollectionView } from '#/_base/di/collection'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; @@ -34,9 +46,7 @@ import { type WireMigration, } from './migration/migration'; import type { DeepReadonly, ModelDef, PartsTransformer } from './model'; -import { MODEL_CROSS_REDUCERS } from './model'; import type { Op } from './op'; -import { OP_REGISTRY } from './op'; import { AGENT_WIRE_RECORD_KEY, createWireMetadataRecord, @@ -46,6 +56,13 @@ import { wireRecordToPayload, type WireRecord, } from './record'; +import { + builtinWireContribution, + foldWireContributions, + WireModelContribution, + type FoldedWireRegistry, + type WireModelContributionRecord, +} from './wireContribution'; const MAX_DRAIN = 100; @@ -71,7 +88,7 @@ interface OpGroup { type RestorePhase = 'new' | 'restoring' | 'ready' | 'failed'; -export class WireService extends Disposable implements IWireService { +export class WireService extends Service implements IWireService { declare readonly _serviceBrand: undefined; readonly hooks: IWireService['hooks'] = { @@ -80,6 +97,7 @@ export class WireService extends Disposable implements IWireService { private readonly models = new Map, ModelInstance>(); private readonly wireScope: string; + private folded: FoldedWireRegistry; private restorePhase: RestorePhase = 'new'; private dispatching = false; @@ -92,10 +110,23 @@ export class WireService extends Disposable implements IWireService { @IAppendLogStore private readonly log: IAppendLogStore, @IAgentBlobService private readonly blobService: IAgentBlobService, @IEventBus private readonly eventBus: IEventBus, + @WireModelContribution view: CollectionView, ) { super(); this.wireScope = scopeContext.scope(); this._register(this.log.acquire(this.wireScope, AGENT_WIRE_RECORD_KEY)); + this.folded = this.foldContributions(view); + this._register( + view.onDidChange(() => { + this.folded = this.foldContributions(view); + }), + ); + } + + private foldContributions( + view: CollectionView, + ): FoldedWireRegistry { + return foldWireContributions([builtinWireContribution(), ...view.items]); } getModel(model: ModelDef): DeepReadonly { @@ -211,7 +242,7 @@ export class WireService extends Disposable implements IWireService { } private replayRecord(record: WireRecord, index: number): void { - const descriptor = OP_REGISTRY.get(record.type); + const descriptor = this.folded.ops.get(record.type); if (descriptor === undefined) { this.reportSkippedRecord(record.type, index); return; @@ -256,7 +287,7 @@ export class WireService extends Disposable implements IWireService { this.eventBus.publish(event as DomainEvent); } } - const crossReducers = MODEL_CROSS_REDUCERS.get(op.type); + const crossReducers = this.folded.crossReducers.get(op.type); if (crossReducers !== undefined) { for (const entry of crossReducers) { if (entry.model === op.descriptor.model) continue; diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 0ac6170e9d9..334d129bb89 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -21,7 +21,9 @@ * Every Session scope is also seeded with the handler's shared workspace * resources as pure-data read views (the injection contracts) — discovery, * watching and connecting all live on the Workspace-scope services; session - * consumers read the seeds and refresh off their change events. + * consumers read the seeds and refresh off their change events. The five + * workspace-projection seeds are provided by the seed-adapter units + * assembled with the scope (`assembleSessionSeedAdapters`), not by `extra`. * Materializes the session's initial metadata on * creation. Bound at Workspace scope. * Persisted sessions are discovered through the session-index read model. @@ -66,17 +68,17 @@ import { ulid } from 'ulid'; import { IInstantiationService } from '#/_base/di/instantiation'; import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; import { createScopedChildHandle, type ISessionScopeHandle, - LifecycleScope, ScopeActivation, registerScopedService, } from '#/_base/di/scope'; import { unwrapErrorCause } from '#/_base/errors/errors'; import { Emitter, type Event } from '#/_base/event'; -import { DEFAULT_PLAN_MODE_SECTION } from '#/agent/plan/configSection'; -import { IAgentPlanService } from '#/agent/plan/plan'; +import { DEFAULT_PLAN_MODE_SECTION } from '#/features/plan/configSection'; +import { IAgentPlanService } from '#/features/plan/plan'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { CRON_SESSION_TAG, type CronTask } from '#/app/cron/cronTask'; import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; @@ -97,12 +99,10 @@ import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; -import { sessionMcpHandleSeed } from '#/session/mcp/sessionMcpHandle'; import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; import { sessionAgentProfileCatalogSeed } from '#/session/sessionAgentProfileCatalog/agentProfileCatalogSeed'; -import { sessionInstructionsProviderSeed } from '#/session/sessionInstructions/instructionsProvider'; -import { sessionWorkspaceInfoSeed } from '#/session/workspaceInfo/workspaceInfo'; +import { assembleSessionSeedAdapters } from '#/session/sessionSeed/sessionSeedAdapters'; import { ISessionLifecycleHooks, sessionLifecycleHooksSeed, @@ -110,9 +110,7 @@ import { } from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionProcessRunner } from '#/session/process/processRunner'; -import { sessionSkillCatalogDataSeed } from '#/session/sessionSkillCatalog/skillCatalogData'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; -import { sessionToolPolicyGateSeed } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { IWireService } from '#/wire/wire'; import { AGENT_WIRE_RECORD_KEY, @@ -132,13 +130,10 @@ import { IWorkspaceAgentProfileLoader, } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; -import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; import { IWorkspaceMcpService, type ISessionMcpOverlay, } from '#/workspace/workspaceMcp/workspaceMcp'; -import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; -import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; import { agentScopeOf, sessionDirOf, sessionScopeOf } from './internal/addressing'; import { @@ -158,6 +153,7 @@ type MaterializeSessionOptions = Omit & { readonly sessionId: string; }; +// NOTE: stays Disposable — its own 'get' and 'config' collide with the Fiber export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { declare readonly _serviceBrand: undefined; private readonly sessions = new Map(); @@ -192,7 +188,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec @ICronTaskPersistence private readonly cronStore: ICronTaskPersistence, @IEventService private readonly event: IEventService, @ITelemetryService private readonly telemetry: ITelemetryService, - @IWorkspaceSkillCatalog private readonly skillCatalog: IWorkspaceSkillCatalog, @IWorkspaceAgentProfileLoader private readonly workspaceAgentProfileLoader: IWorkspaceAgentProfileLoader, @IExtraAgentProfileLoader @@ -203,10 +198,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private readonly userAgentProfileLoader: IUserAgentProfileLoader, @IPluginAgentProfileLoader private readonly pluginAgentProfileLoader: IPluginAgentProfileLoader, - @IWorkspaceInstructionsService private readonly instructions: IWorkspaceInstructionsService, @IWorkspaceMcpService private readonly mcp: IWorkspaceMcpService, @IWorkspaceDirs private readonly workspaceDirs: IWorkspaceDirs, - @IWorkspaceToolPolicy private readonly toolPolicy: IWorkspaceToolPolicy, @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, ) { super(); @@ -296,17 +289,13 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ...sessionContextSeed(ctx), ...sessionLifecycleHooksSeed(hooks), [ITelemetryService, this.telemetry.withContext({ sessionId: opts.sessionId })], - ...sessionSkillCatalogDataSeed(this.skillCatalog.sessionData()), ...sessionAgentProfileCatalogSeed({ _serviceBrand: undefined, workspaceKey: workspaceId, }), - ...sessionInstructionsProviderSeed(this.instructions.sessionProvider()), - ...sessionMcpHandleSeed(mcpOverlay?.handle ?? this.mcp.sessionHandle()), - ...sessionWorkspaceInfoSeed(this.workspaceDirs.sessionInfo()), - ...sessionToolPolicyGateSeed(this.toolPolicy.sessionGate()), [ISessionProcessRunner, this.processRunner], ], + assemble: (container) => assembleSessionSeedAdapters(container, mcpOverlay?.handle), }, ) as ISessionScopeHandle; const handle: ISessionScopeHandle = @@ -327,7 +316,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec try { await handle.accessor.get(ISessionMetadata).ready; await handle.accessor.get(ISessionToolPolicy).ready; - void this.skillCatalog.ready; await Promise.all([ this.workspaceAgentProfileLoader.ready, this.extraAgentProfileLoader.ready, diff --git a/packages/agent-core-v2/src/workspace/state/workspaceStateService.ts b/packages/agent-core-v2/src/workspace/state/workspaceStateService.ts index 5037d9b7661..d58a27208aa 100644 --- a/packages/agent-core-v2/src/workspace/state/workspaceStateService.ts +++ b/packages/agent-core-v2/src/workspace/state/workspaceStateService.ts @@ -8,7 +8,9 @@ * Bound at Workspace scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { StateRegistry } from '#/_base/state/stateRegistry'; import { IAppStateService } from '#/app/state/appState'; diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader.ts index baa6cfb2390..6519ddec929 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader.ts @@ -2,7 +2,7 @@ * `workspaceAgentProfileLoader` domain — `IExplicitAgentProfileLoader` contract. * * The explicit loader of the agent-profile extension point: owns the - * `explicit` contribution in the App-scope `IAgentProfileRegistry` — the + * `explicit` record of the `AgentProfileContribution` collection — the * runtime-selected agent files (`--agent-file`), tagged with this handler's `workspaceId`. * The loader is `fatal`: an invalid explicit file is an explicit user intent * that must not be silently dropped, so the rejection propagates into `ready` diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService.ts index 52701263227..59c0d52c269 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService.ts @@ -6,10 +6,11 @@ * Bound at Workspace scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; -import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; import { parseAgentFileText } from '#/workspace/workspaceAgentProfileLoader/internal/agentFile'; import { AgentProfileLoaderBase } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader'; import { agentProfileFromFile } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile'; @@ -41,9 +42,8 @@ export class ExplicitAgentProfileLoaderService @IHostFileSystem private readonly fs: IHostFileSystem, @ILogService log: ILogService, @IUserAgentProfileLoader private readonly user: IUserAgentProfileLoader, - @IAgentProfileRegistry registry: IAgentProfileRegistry, ) { - super(registry, log); + super(log); this.start(); } diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader.ts index d33914ed94e..3976578251c 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader.ts @@ -2,12 +2,12 @@ * `workspaceAgentProfileLoader` domain — `IExtraAgentProfileLoader` contract. * * The extra loader of the agent-profile extension point: owns the `extra` - * contribution in the App-scope `IAgentProfileRegistry` — the agent files + * record of the `AgentProfileContribution` collection — the agent files * discovered from the configured `extraAgentDirs`, tagged with this handler's * `workspaceId` (relative configured paths resolve against the workspace - * root, so the contribution is workspace-local even though the config section + * root, so the record is workspace-local even though the config section * is global). `ready` tracks the most recent discovery pass; `reload()` - * re-discovers and re-registers. Workspace-scoped. + * re-discovers and re-contributes. Workspace-scoped. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService.ts index 94d71ae2464..eb30ed8cf4f 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService.ts @@ -1,13 +1,15 @@ /** * `workspaceAgentProfileLoader` domain — `IExtraAgentProfileLoader` implementation. * - * Resolves the configured `extraAgentDirs` through `config`, `workspaceContext`, - * `bootstrap`, and `hostFs`, reporting skipped files through `log`. - * Reloads when the `extraAgentDirs` config section changes. Bound at - * Workspace scope. + * Resolves the configured `extraAgentDirs` through `configService`, + * `workspaceContext`, `bootstrap`, and `hostFs`, reporting skipped files + * through `log`. Reloads when the `extraAgentDirs` config section changes. + * Bound at Workspace scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { discoverAgentFiles } from '#/workspace/workspaceAgentProfileLoader/internal/agentFileDiscovery'; import { AgentProfileLoaderBase } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader'; @@ -22,7 +24,6 @@ import { type ExtraAgentDirsConfig, } from '#/workspace/workspaceAgentProfileLoader/configSection'; import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; -import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -40,17 +41,16 @@ export class ExtraAgentProfileLoaderService protected readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.extra; constructor( - @IConfigService private readonly config: IConfigService, + @IConfigService private readonly configService: IConfigService, @IWorkspaceContext private readonly workspace: IWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, @IHostFileSystem private readonly fs: IHostFileSystem, @ILogService log: ILogService, @IUserAgentProfileLoader private readonly user: IUserAgentProfileLoader, - @IAgentProfileRegistry registry: IAgentProfileRegistry, ) { - super(registry, log); + super(log); this._register( - this.config.onDidSectionChange((event) => { + this.configService.onDidSectionChange((event) => { if (event.domain === EXTRA_AGENT_DIRS_SECTION) { void this.reload().catch((error) => { this.log.warn(`agent profile loader "extra" reload failed: ${String(error)}`); @@ -66,8 +66,8 @@ export class ExtraAgentProfileLoaderService } protected async load(): Promise { - await this.config.ready; - const dirs = this.config.get(EXTRA_AGENT_DIRS_SECTION) ?? []; + await this.configService.ready; + const dirs = this.configService.get(EXTRA_AGENT_DIRS_SECTION) ?? []; return profilesFromDiscovery( await discoverAgentFiles( this.fs, diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader.ts index 4ca9f2cce84..1a4d4cde82e 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader.ts @@ -2,40 +2,38 @@ * `workspaceAgentProfileLoader` domain — `AgentProfileLoaderBase`, the shared * loader skeleton of the agent-profile extension point. * - * A loader owns one source id: it loads an `AgentProfileContribution` and - * registers it into the App-scope `IAgentProfileRegistry` under that id - * (workspace-local loaders additionally tag a `workspaceKey`). The first load + * A loader owns one source id: it loads an `AgentProfileContribution` payload + * and contributes it to the `AgentProfileContribution` collection under that + * id (workspace-local loaders additionally tag a `workspaceKey`); the + * App-scope registry fold picks the record up from there. The first load * starts when the subclass constructor calls {@link start} — after its own * fields are set, since `load()` is virtual. `ready` tracks the most recent * load pass; `reload()` replaces it, so a `fatal` failure does not wedge the * loader once the underlying problem is fixed. A rejecting `fatal` loader * (an invalid `--agent-file`) propagates into `ready` so session * materialization fails fast; a rejecting non-fatal loader degrades to a - * warning and keeps any previously registered contribution, so directory - * problems never poison the registry. Loads are serialized per loader — a - * refresh never overlaps the previous pass — and the swallowed handler on - * `ready` keeps an un-awaited rejection from crashing the process. + * warning and keeps any previously contributed record, so directory problems + * never poison the projection. Loads are serialized per loader — a refresh + * never overlaps the previous pass — and the swallowed handler on `ready` + * keeps an un-awaited rejection from crashing the process. The record hangs + * on the loader unit's book, so disposing the loader withdraws it. */ -import { Disposable, MutableDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { MutableDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import type { ILogService } from '#/_base/log/log'; -import type { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; +import { AgentProfileContribution } from '#/app/agentProfileCatalog/agentProfileContribution'; -import type { AgentProfileContribution } from '#/app/agentProfileCatalog/agentProfileContribution'; - -export abstract class AgentProfileLoaderBase extends Disposable { +export abstract class AgentProfileLoaderBase extends Service { protected abstract readonly sourceId: string; protected abstract readonly priority: number; protected readonly fatal: boolean = false; private readyPromise: Promise = Promise.resolve(); private tail: Promise = Promise.resolve(); - private readonly registrationHandle = this._register(new MutableDisposable()); + private readonly contributionHandle = this._register(new MutableDisposable()); - constructor( - protected readonly registry: IAgentProfileRegistry, - protected readonly log: ILogService, - ) { + constructor(protected readonly log: ILogService) { super(); } @@ -61,18 +59,21 @@ export abstract class AgentProfileLoaderBase extends Disposable { } private enqueue(): Promise { - const current = this.tail.catch(() => undefined).then(() => this.loadAndRegister()); + const current = this.tail.catch(() => undefined).then(() => this.loadAndContribute()); this.tail = current; return current; } - private async loadAndRegister(): Promise { + private async loadAndContribute(): Promise { try { const contribution = await this.load(); - this.registrationHandle.value = this.registry.register(this.sourceId, contribution, { + const handle = this.provide(AgentProfileContribution, { + sourceId: this.sourceId, priority: this.priority, workspaceKey: this.workspaceKey, + contribution, }); + this.contributionHandle.value = { dispose: () => void handle.dispose() }; } catch (error) { if (this.fatal) throw error; this.log.warn(`agent profile loader "${this.sourceId}" load failed: ${String(error)}`); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader.ts index 37b0d461ff0..0f86121b117 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader.ts @@ -2,10 +2,10 @@ * `workspaceAgentProfileLoader` domain — `IPluginAgentProfileLoader` contract. * * The plugin loader of the agent-profile extension point: owns the `plugin` - * contribution in the App-scope `IAgentProfileRegistry` — the agent files + * record of the `AgentProfileContribution` collection — the agent files * discovered from the enabled plugins' agent roots, tagged with this * handler's `workspaceId`. `ready` tracks the most recent discovery pass; - * `reload()` re-discovers and re-registers. Workspace-scoped. + * `reload()` re-discovers and re-contributes. Workspace-scoped. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService.ts index 18c0c819709..edb5af5fd35 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService.ts @@ -2,16 +2,17 @@ * `workspaceAgentProfileLoader` domain — `IPluginAgentProfileLoader` implementation. * * Discovers agent profiles contributed by enabled plugins (roots from the - * App-scope `plugins.pluginAgentRoots()`) and registers them via the shared + * App-scope `plugins.pluginAgentRoots()`) and contributes them via the shared * loader skeleton. Reloads when plugins reload; install / enable / remove - * mutations deliberately do not re-register — those take effect on the next + * mutations deliberately do not re-contribute — those take effect on the next * explicit reload. Bound at Workspace scope: agent-file discovery lives in * the workspace layer alongside every other source. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; -import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; import { IPluginService } from '#/app/plugin/plugin'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; @@ -40,10 +41,9 @@ export class PluginAgentProfileLoaderService @IHostFileSystem private readonly fs: IHostFileSystem, @ILogService log: ILogService, @IUserAgentProfileLoader private readonly user: IUserAgentProfileLoader, - @IAgentProfileRegistry registry: IAgentProfileRegistry, @IWorkspaceContext private readonly workspace: IWorkspaceContext, ) { - super(registry, log); + super(log); this._register( this.plugins.onDidReload(() => { void this.reload().catch((error) => { diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoader.ts index ffca276e583..8e5522a4849 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoader.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoader.ts @@ -2,13 +2,13 @@ * `workspaceAgentProfileLoader` domain — `IUserAgentProfileLoader` contract. * * The user loader of the agent-profile extension point: owns the `user` - * contribution in the App-scope `IAgentProfileRegistry` — the agent files + * record of the `AgentProfileContribution` collection — the agent files * discovered from the user agent roots under the os home, plus the * `/SYSTEM.md` prompt-override profile appended after them — tagged * with this handler's `workspaceId`. Also exposes the effective default * profile (the `SYSTEM.md` override when present, else the builtin default, * refreshed on each load pass) for backing `${base_prompt}`. `ready` tracks - * the most recent discovery pass; `reload()` re-discovers and re-registers. + * the most recent discovery pass; `reload()` re-discovers and re-contributes. * Workspace-scoped. */ diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService.ts index 7d60022001f..46566c1b7cf 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService.ts @@ -6,13 +6,14 @@ * prompt-override profile (synthesized against the builtin default from the * App builtin loader) after the scanned profiles so it wins same-name * collisions within this contribution. The user roots are global os - * directories, but per-workspace registration keeps every contribution - * flowing through the same workspace-tagged lane. Bound at Workspace scope. + * directories, but per-workspace contribution keeps every record flowing + * through the same workspace-tagged lane. Bound at Workspace scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; -import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; @@ -46,10 +47,9 @@ export class UserAgentProfileLoaderService @IHostFileSystem private readonly fs: IHostFileSystem, @ILogService log: ILogService, @IBuiltinAgentProfileLoader private readonly builtin: IBuiltinAgentProfileLoader, - @IAgentProfileRegistry registry: IAgentProfileRegistry, @IWorkspaceContext private readonly workspace: IWorkspaceContext, ) { - super(registry, log); + super(log); this.defaultProfile = builtin.getDefault(); this.start(); } diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader.ts index e414e792224..702c477f435 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader.ts @@ -2,11 +2,11 @@ * `workspaceAgentProfileLoader` domain — `IWorkspaceAgentProfileLoader` contract. * * The workspace loader of the agent-profile extension point: owns the - * `workspace` contribution in the App-scope `IAgentProfileRegistry` — the + * `workspace` record of the `AgentProfileContribution` collection — the * agent files discovered under this handler's project root, tagged with the * handler's `workspaceId` so concurrent handlers never collide and the * sessions of THIS workspace project exactly this entry. `ready` tracks the - * most recent discovery pass; `reload()` re-discovers and re-registers. + * most recent discovery pass; `reload()` re-discovers and re-contributes. * Workspace-scoped. */ diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService.ts index 145cc1dc804..b36f7268913 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService.ts @@ -3,15 +3,17 @@ * * Discovers the workspace's agent files (`.kimi-code/agents`, `.agents/agents` * under the project root, resolved through `workspaceContext` and `hostFs`) - * and registers them via the shared loader skeleton. `${base_prompt}` is + * and contributes them via the shared loader skeleton. `${base_prompt}` is * backed by the user loader's effective default profile. Watches the project * agent-root candidates through `hostFsWatch` (watched whether or not they * exist yet) and reloads debounced, so a project agent-file change - * re-registers this contribution only. Bound at Workspace scope: the scan is - * per handler and the registration dies with it. + * re-contributes this record only. Bound at Workspace scope: the scan is + * per handler and the record dies with it. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { TimeoutTimer } from '#/_base/utils/timer'; import { subtreeWatchFilter } from '#/_base/utils/paths'; @@ -24,7 +26,6 @@ import { import { profilesFromDiscovery } from './internal/agentProfileFromFile'; import { projectAgentRootCandidates, projectAgentRoots } from '#/workspace/workspaceAgentProfileLoader/internal/agentRoots'; import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; -import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; @@ -51,9 +52,8 @@ export class WorkspaceAgentProfileLoaderService @ILogService log: ILogService, @IUserAgentProfileLoader private readonly user: IUserAgentProfileLoader, @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, - @IAgentProfileRegistry registry: IAgentProfileRegistry, ) { - super(registry, log); + super(log); this.watchReady = this.watchProjectAgentRoots(); this.start(); } diff --git a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts index 8c79690b0c2..116cad362f4 100644 --- a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts @@ -17,9 +17,10 @@ * Workspace scope. */ -import { Disposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { Emitter, type Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { defineState } from '#/_base/state/stateRegistry'; import { TimeoutTimer } from '#/_base/utils/timer'; @@ -47,7 +48,7 @@ export const workspaceDirsEphemeralDirsKey = defineState( () => [], ); -export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { +export class WorkspaceDirsService extends Service implements IWorkspaceDirs { declare readonly _serviceBrand: undefined; private projectRoot: string; diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts index ac47a89cc42..e440efe07be 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts @@ -58,7 +58,8 @@ const FsWireErrorCode = { } as const; import ignore, { type Ignore } from 'ignore'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { decodeUtfText, detectTextEncoding, type UtfTextEncoding } from '#/_base/text/encoding'; import { buildEtag, diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts index 9b4a16f81a4..6882f41a654 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts @@ -17,9 +17,11 @@ import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import ignore, { type Ignore } from 'ignore'; -import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; +import { type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { Emitter, type Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ErrorCodes, Error2 } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { @@ -47,7 +49,7 @@ function readPositiveIntEnv(name: string, fallback: number): number { return Number.isFinite(n) && n > 0 ? n : fallback; } -export class WorkspaceFsWatchService extends Disposable implements IWorkspaceFsWatchService { +export class WorkspaceFsWatchService extends Service implements IWorkspaceFsWatchService { declare readonly _serviceBrand: undefined; private readonly subscriptions = new Set(); diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts index cebef9a28bb..c0ad3fcc658 100644 --- a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts @@ -6,7 +6,9 @@ * Bound at Workspace scope. */ -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { type FsDiffResponse, type FsGitStatusResponse, IGitService } from '#/app/git/git'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; diff --git a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts index 99cb505c74d..db0866784fc 100644 --- a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts @@ -17,9 +17,10 @@ * Workspace scope. */ -import { Disposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { Emitter, type Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { defineState } from '#/_base/state/stateRegistry'; import { TimeoutTimer } from '#/_base/utils/timer'; @@ -46,7 +47,7 @@ export const workspaceInstructionsCurrentKey = defineState = createDecorator('extraFileSkillSource'); +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class ExtraFileSkillSource extends Disposable implements IExtraFileSkillSource { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/pluginSkillSource.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/pluginSkillSource.ts index 30139ca69da..82092fe4649 100644 --- a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/pluginSkillSource.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/pluginSkillSource.ts @@ -11,7 +11,8 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import { PLUGIN_SKILL_SOURCE_ID, diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/rootFileSkillSource.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/rootFileSkillSource.ts index 17c400b7277..725ef367c25 100644 --- a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/rootFileSkillSource.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/rootFileSkillSource.ts @@ -12,7 +12,8 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import { Disposable, DisposableStore } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { TimeoutTimer } from '#/_base/utils/timer'; import { subtreeWatchFilter } from '#/_base/utils/paths'; import { IConfigService } from '#/app/config/config'; @@ -42,6 +43,7 @@ export interface IWorkspaceRootSkillSource extends ISkillSource { export const IWorkspaceRootSkillSource: ServiceIdentifier = createDecorator('workspaceRootSkillSource'); +// NOTE: stays Disposable — its own 'config' collides with the Fiber export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRootSkillSource { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts index 3ee0a160105..f60b19fbe42 100644 --- a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts @@ -14,9 +14,10 @@ * read/written through it. Bound at Workspace scope. */ -import { Disposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { Emitter, type Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; @@ -40,7 +41,7 @@ export const workspaceSkillCatalogMergedKey = defineState( () => new InMemorySkillCatalog(), ); -export class WorkspaceSkillCatalogService extends Disposable implements IWorkspaceSkillCatalog { +export class WorkspaceSkillCatalogService extends Service implements IWorkspaceSkillCatalog { declare readonly _serviceBrand: undefined; private readonly sources: readonly ISkillSource[]; diff --git a/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicyService.ts b/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicyService.ts index 6994837838b..b84ef320ca9 100644 --- a/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicyService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicyService.ts @@ -10,9 +10,10 @@ * the capability set here and fires `onDidChange`. Bound at Workspace scope. */ -import { Disposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { IWorkspaceContext, @@ -26,7 +27,7 @@ export function computeCapabilityDisabledTools(osBackendId: string): readonly st return []; } -export class WorkspaceToolPolicyService extends Disposable implements IWorkspaceToolPolicy { +export class WorkspaceToolPolicyService extends Service implements IWorkspaceToolPolicy { declare readonly _serviceBrand: undefined; private readonly disabled: readonly string[]; diff --git a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts index daadb27b468..03f0f9aed5f 100644 --- a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts @@ -17,7 +17,8 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter } from '#/_base/event'; import { defineState } from '#/_base/state/stateRegistry'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; @@ -39,6 +40,7 @@ export const workspaceTrustTrustedKey = defineState( () => false, ); +// NOTE: stays Disposable — its own 'get' collides with the Fiber export class WorkspaceTrustService extends Disposable implements IWorkspaceTrust { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/test/_base/di/cascade.test.ts b/packages/agent-core-v2/test/_base/di/cascade.test.ts index a24ea8e9e1f..99ba6a80dee 100644 --- a/packages/agent-core-v2/test/_base/di/cascade.test.ts +++ b/packages/agent-core-v2/test/_base/di/cascade.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it } from 'vitest'; +import type { + CascadeEngine, + CascadeHistoryEntry, + UnitStateChange, +} from '#/_base/di/cascadeEngine'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { CascadeConflictError } from '#/_base/di/errors'; import { createDecorator } from '#/_base/di/instantiation'; @@ -25,12 +30,10 @@ function ledgerOf(ix: InstantiationService): Ledger { return (ix as unknown as { _ledger: Ledger })._ledger; } -/** Flush all pending microtask chains (async teardown hops). */ function flushMicrotasks(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } -// ------------------------------------------------------------------ fixtures interface IRoot { label: string; @@ -52,7 +55,6 @@ interface IExtra { } const IExtra = createDecorator('cascade-extra'); -/** Shared per-test event log; fixtures push construct/dispose events. */ let events: string[] = []; class Root implements IRoot { @@ -111,14 +113,13 @@ describe('cascade engine — mechanism matrix', () => { it('1. provide X auto-activates dependents from Pending', () => { const ix = makeContainer(); events = []; - // Dependent provided before its dependency: goes to the waiting area. ix.provide(IMid, new SyncDescriptor(Mid)); - expect(ix.cascade.unitState(IMid)).toBe('Pending'); + expect(ix.cascade.stateOf(IMid)).toBe('Pending'); expect(events).toEqual([]); ix.provide(IRoot, new SyncDescriptor(Root)); - expect(ix.cascade.unitState(IRoot)).toBe('Active'); - expect(ix.cascade.unitState(IMid)).toBe('Active'); + expect(ix.cascade.stateOf(IRoot)).toBe('Active'); + expect(ix.cascade.stateOf(IMid)).toBe('Active'); expect(events).toEqual(['+root', '+mid']); ix.dispose(); }); @@ -133,11 +134,10 @@ describe('cascade engine — mechanism matrix', () => { ix.unprovide(IRoot); expect(events).toEqual(['-leaf', '-mid', '-root']); - expect(ix.cascade.unitState(IRoot)).toBeUndefined(); // removed, not a state - expect(ix.cascade.unitState(IMid)).toBe('Pending'); - expect(ix.cascade.unitState(ILeaf)).toBe('Pending'); + expect(ix.cascade.stateOf(IRoot)).toBeUndefined(); + expect(ix.cascade.stateOf(IMid)).toBe('Pending'); + expect(ix.cascade.stateOf(ILeaf)).toBe('Pending'); expect(() => ix.invokeFunction((a) => a.get(IRoot))).toThrow(/unknown service/); - // The waiting area retains recipes with the missing token indexed. expect(ix.cascade.pendingSnapshot().get('cascade-mid')).toEqual(['cascade-root']); void mid; void leaf; @@ -169,7 +169,6 @@ describe('cascade engine — mechanism matrix', () => { const firstMid = ix.invokeFunction((a) => a.get(IMid)); events = []; - // Replace the root recipe in one transaction. class Root2 implements IRoot { label = 'root2'; constructor() { @@ -182,20 +181,39 @@ describe('cascade engine — mechanism matrix', () => { const historyBefore = ix.cascade.history().length; ix.provide(IRoot, new SyncDescriptor(Root2)); - // One transaction covered teardown + rebuild; nothing lingered in Pending. expect(ix.cascade.history().length).toBe(historyBefore + 1); const entry = ix.cascade.history().at(-1)!; expect(entry.tornDown).toEqual(['cascade-leaf', 'cascade-mid', 'cascade-root']); expect(entry.rebuilt).toEqual(['cascade-root', 'cascade-mid', 'cascade-leaf']); expect(events).toEqual(['-leaf', '-mid', '-root', '+root2', '+mid', '+leaf']); - expect(ix.cascade.unitState(IMid)).toBe('Active'); - expect(ix.cascade.unitState(ILeaf)).toBe('Active'); + expect(ix.cascade.stateOf(IMid)).toBe('Active'); + expect(ix.cascade.stateOf(ILeaf)).toBe('Active'); const newMid = ix.invokeFunction((a) => a.get(IMid)); expect(newMid).not.toBe(firstMid); expect(newMid.root).toBeInstanceOf(Root2); ix.dispose(); }); + it('eager units treat an on-demand dependency as available and pull it transitively', () => { + const ix = makeContainer(); + events = []; + ix.provide(IMid, new SyncDescriptor(Mid)); + expect(ix.cascade.stateOf(IMid)).toBe('Pending'); + + ix.provide(IRoot, new SyncDescriptor(Root), { activation: 'ondemand' }); + expect(ix.cascade.stateOf(IMid)).toBe('Active'); + expect(ix.cascade.stateOf(IRoot)).toBe('Active'); + expect(events).toEqual(['+root', '+mid']); + + const ix2 = makeContainer(); + events = []; + ix2.provide(IExtra, new SyncDescriptor(Extra), { activation: 'ondemand' }); + expect(ix2.cascade.stateOf(IExtra)).toBe('Pending'); + expect(events).toEqual([]); + ix2.dispose(); + ix.dispose(); + }); + it('5/6. requests submitted during a cascade queue up and merge their contagion sets', async () => { const ix = makeContainer(); ix.provide(IRoot, new SyncDescriptor(Root)); @@ -207,7 +225,6 @@ describe('cascade engine — mechanism matrix', () => { onWillCascade: (affected) => { calls += 1; hookCalls.push(affected.map(String)); - // Park only the first transaction at the abort wait. return calls === 1 ? gate.promise : undefined; }, }); @@ -217,7 +234,6 @@ describe('cascade engine — mechanism matrix', () => { token: IRoot, reason: 'drop root', }); - // These two queue behind the in-flight transaction and merge into one. const second = ix.cascade.submit({ action: 'unprovide', token: IExtra, @@ -230,26 +246,21 @@ describe('cascade engine — mechanism matrix', () => { reason: 'add mid', }); - // Queued changes are not applied while the first transaction is in flight. expect(ix.cascade.isInFlight(IRoot)).toBe(true); - // A token outside the in-flight contagion set resolves normally. expect(ix.invokeFunction((a) => a.get(IExtra))).toBeInstanceOf(Extra); gate.resolve(); await Promise.all([first, second, third]); - // Three requests, two transactions: the queued two merged (one hook call each). expect(calls).toBe(2); - // (The first two history entries are the initial provides.) const history = ix.cascade.history().slice(-2); expect(history[0]!.changes).toEqual([{ token: 'cascade-root', action: 'unprovide' }]); expect(history[1]!.changes).toEqual([ { token: 'cascade-extra', action: 'unprovide' }, { token: 'cascade-mid', action: 'provide' }, ]); - expect(ix.cascade.unitState(IExtra)).toBeUndefined(); - // Mid's dependency is gone: it waits. - expect(ix.cascade.unitState(IMid)).toBe('Pending'); + expect(ix.cascade.stateOf(IExtra)).toBeUndefined(); + expect(ix.cascade.stateOf(IMid)).toBe('Pending'); ix.dispose(); }); @@ -266,28 +277,24 @@ describe('cascade engine — mechanism matrix', () => { } } ix.provide(IExtra, new SyncDescriptor(Flaky)); - expect(ix.cascade.unitState(IExtra)).toBe('Failed'); - // Resolving a failed unit rethrows the recorded error. + expect(ix.cascade.stateOf(IExtra)).toBe('Failed'); expect(() => ix.invokeFunction((a) => a.get(IExtra))).toThrow('ctor boom'); - // A dependent of a Failed unit waits. class NeedsExtra { constructor(@IExtra public readonly extra: IExtra) {} } const INeedsExtra = createDecorator('cascade-needs-extra'); ix.provide(INeedsExtra, new SyncDescriptor(NeedsExtra)); - expect(ix.cascade.unitState(INeedsExtra)).toBe('Pending'); + expect(ix.cascade.stateOf(INeedsExtra)).toBe('Pending'); - // Sticky: an unrelated transaction does not retry the failed unit. ix.provide(IRoot, new SyncDescriptor(Root)); - expect(ix.cascade.unitState(IExtra)).toBe('Failed'); + expect(ix.cascade.stateOf(IExtra)).toBe('Failed'); - // Explicit update() recovers it (and wakes its dependent). shouldThrow = false; events = []; return ix.cascade.update(IExtra).then(() => { - expect(ix.cascade.unitState(IExtra)).toBe('Active'); - expect(ix.cascade.unitState(INeedsExtra)).toBe('Active'); + expect(ix.cascade.stateOf(IExtra)).toBe('Active'); + expect(ix.cascade.stateOf(INeedsExtra)).toBe('Active'); expect(events).toEqual(['+flaky']); ix.dispose(); }); @@ -324,7 +331,6 @@ describe('cascade engine — mechanism matrix', () => { events = []; const done = ix.cascade.submit({ action: 'unprovide', token: IRoot, reason: 'async teardown' }); - // Reverse topo: leaf starts first; mid must not start until leaf finishes. expect(events).toEqual(['leaf-start']); gates.root.resolve(); await flushMicrotasks(); @@ -352,27 +358,25 @@ describe('cascade engine — mechanism matrix', () => { }, }); - // (a) the cascade waits for the abort to complete. const first = ix.cascade.submit({ action: 'unprovide', token: IRoot, reason: 'feature "x" unloaded' }); expect(seen).toHaveLength(1); expect(seen[0]!.reason).toBe('feature "x" unloaded'); expect(seen[0]!.affected).toContain('cascade-leaf'); await Promise.resolve(); - expect(ix.cascade.isInFlight(IRoot)).toBe(true); // still waiting + expect(ix.cascade.isInFlight(IRoot)).toBe(true); gate!.resolve(); await first; expect(ix.cascade.history().at(-1)!.abortWaited).toBe(true); expect(ix.cascade.history().at(-1)!.abortTimedOut).toBe(false); - expect(ix.cascade.unitState(IRoot)).toBeUndefined(); + expect(ix.cascade.stateOf(IRoot)).toBeUndefined(); - // (b) an abort that never completes is forced after the bounded wait. provideChain(ix); const second = ix.cascade.submit({ action: 'unprovide', token: IRoot, reason: 'forced' }); - await second; // never resolved the gate — the bound fired + await second; const entry = ix.cascade.history().at(-1)!; expect(entry.abortWaited).toBe(true); expect(entry.abortTimedOut).toBe(true); - expect(ix.cascade.unitState(IRoot)).toBeUndefined(); + expect(ix.cascade.stateOf(IRoot)).toBeUndefined(); ix.dispose(); }); @@ -390,17 +394,14 @@ describe('cascade engine — mechanism matrix', () => { }); expect(ix.cascade.isInFlight(IRoot)).toBe(true); - // The sync path cannot suspend: it fails fast. expect(() => ix.invokeFunction((a) => a.get(IRoot))).toThrow(CascadeConflictError); - // The async path suspends until the transaction completes. const suspended = ix.cascade.resolveWhenAvailable(IRoot); gate.resolve(); await replace; const root = await suspended; expect(root).toBeInstanceOf(Root); - // Timeout variant: a transaction that parks forever rejects suspended resolutions. const parked = deferred(); ix.cascade.configure({ onWillCascade: () => parked.promise }); void ix.cascade.submit({ action: 'unprovide', token: IRoot, reason: 'parked' }); @@ -412,8 +413,6 @@ describe('cascade engine — mechanism matrix', () => { it('11. cycle detection holds under dynamic edge add/remove', () => { const ix = makeContainer(); - // Cyclic recipes provided dynamically: neither can satisfy its dependency, - // so both wait — no construction, no spurious failure. const IA = createDecorator<{ a: true }>('cascade-cyc-a'); const IB = createDecorator<{ b: true }>('cascade-cyc-b'); class A { @@ -424,22 +423,20 @@ describe('cascade engine — mechanism matrix', () => { } ix.provide(IA, new SyncDescriptor(A)); ix.provide(IB, new SyncDescriptor(B)); - expect(ix.cascade.unitState(IA)).toBe('Pending'); - expect(ix.cascade.unitState(IB)).toBe('Pending'); + expect(ix.cascade.stateOf(IA)).toBe('Pending'); + expect(ix.cascade.stateOf(IB)).toBe('Pending'); expect(ix.dependencyGraph.findCycle((ref) => ref.token.toString())).toBeNull(); - // Break the cycle: replace A with an independent recipe; both activate. class A2 { readonly a = true; } ix.provide(IA, new SyncDescriptor(A2)); - expect(ix.cascade.unitState(IA)).toBe('Active'); - expect(ix.cascade.unitState(IB)).toBe('Active'); + expect(ix.cascade.stateOf(IA)).toBe('Active'); + expect(ix.cascade.stateOf(IB)).toBe('Active'); expect(ix.dependencyGraph.findCycle((ref) => ref.token.toString())).toBeNull(); - // Dynamic chain teardown keeps the graph acyclic and edge-free. ix.unprovide(IA); - expect(ix.cascade.unitState(IB)).toBe('Pending'); + expect(ix.cascade.stateOf(IB)).toBe('Pending'); expect(ix.dependencyGraph.edges()).toHaveLength(0); expect(ix.dependencyGraph.findCycle((ref) => ref.token.toString())).toBeNull(); ix.dispose(); @@ -447,18 +444,15 @@ describe('cascade engine — mechanism matrix', () => { it('12. ledger balance: arbitrary sequences leave no leaks or dangling edges', async () => { const ix = makeContainer(); provideChain(ix); - // 3 live instances + 3 provide entries on the book. expect(ledgerOf(ix).size).toBe(6); ix.unprovide(IMid); - // Left on the book: the root instance entry + root/leaf provide entries. expect(ledgerOf(ix).size).toBe(3); - // Leaf is Pending (waiting on mid); mid is removed; root is Active. - expect(ix.cascade.unitState(ILeaf)).toBe('Pending'); - expect(ix.cascade.unitState(IMid)).toBeUndefined(); + expect(ix.cascade.stateOf(ILeaf)).toBe('Pending'); + expect(ix.cascade.stateOf(IMid)).toBeUndefined(); ix.provide(IMid, new SyncDescriptor(Mid)); - expect(ix.cascade.unitState(ILeaf)).toBe('Active'); + expect(ix.cascade.stateOf(ILeaf)).toBe('Active'); expect(ledgerOf(ix).size).toBe(6); await ix.cascade.update(IRoot); @@ -486,7 +480,6 @@ describe('cascade engine — mechanism matrix', () => { ix.provide(IRoot, replacement); - // Same transaction: dependents were torn down and rebuilt against the instance. expect(events).toEqual(['-leaf', '-mid', '-root', '+mid', '+leaf']); const newMid = ix.invokeFunction((a) => a.get(IMid)); expect(newMid).not.toBe(firstMid); @@ -510,7 +503,7 @@ describe('cascade engine — mechanism matrix', () => { await ix.cascade.submit({ action: 'unprovide', token: IRoot, reason: 'forced anyway' }); - expect(ix.cascade.unitState(IRoot)).toBeUndefined(); + expect(ix.cascade.stateOf(IRoot)).toBeUndefined(); expect(() => ix.invokeFunction((a) => a.get(IRoot))).toThrow(/unknown service/); expect(ix.cascade.history().at(-1)!.abortWaited).toBe(true); expect(reported).toHaveLength(1); @@ -531,13 +524,11 @@ describe('cascade engine — cross-scope orchestration (D9)', () => { events = []; parent.unprovide(IRoot); - // Global reverse topo: the child unit dies before its parent dependency. expect(events).toEqual(['-mid', '-root']); - expect(child.cascade.unitState(IMid)).toBe('Pending'); - expect(parent.cascade.unitState(IRoot)).toBeUndefined(); + expect(child.cascade.stateOf(IMid)).toBe('Pending'); + expect(parent.cascade.stateOf(IRoot)).toBeUndefined(); parent.provide(IRoot, new SyncDescriptor(Root)); - // Global topo rebuild: parent first, then the child dependent. expect(events).toEqual(['-mid', '-root', '+root', '+mid']); const mid = child.invokeFunction((a) => a.get(IMid)); expect(mid.root).toBe(parent.invokeFunction((a) => a.get(IRoot))); @@ -561,21 +552,31 @@ describe('cascade engine — cross-scope orchestration (D9)', () => { parent.dispose(); }); + it('an eager child unit pulls an on-demand ancestor dependency transitively', () => { + const parent = makeContainer(); + parent.provide(IRoot, new SyncDescriptor(Root), { activation: 'ondemand' }); + const child = parent.createChild(new ServiceCollection()); + events = []; + + child.provide(IMid, new SyncDescriptor(Mid)); + expect(child.cascade.stateOf(IMid)).toBe('Active'); + expect(parent.cascade.stateOf(IRoot)).toBe('Active'); + expect(events).toEqual(['+root', '+mid']); + parent.dispose(); + }); + it('shadowing: a child shadow of the changed token is not in the contagion set', () => { const parent = makeContainer(); parent.provide(IRoot, new SyncDescriptor(Root)); const child = parent.createChild(new ServiceCollection()); - // The child registers its own IRoot; the child's Mid binds the shadow. child.provide(IRoot, new SyncDescriptor(Root, ['shadow'])); child.provide(IMid, new SyncDescriptor(Mid)); events = []; parent.unprovide(IRoot); - // Only the parent's own root is retired; the child's shadow and its - // dependent are untouched. expect(events).toEqual(['-root']); - expect(child.cascade.unitState(IMid)).toBe('Active'); - expect(child.cascade.unitState(IRoot)).toBe('Active'); + expect(child.cascade.stateOf(IMid)).toBe('Active'); + expect(child.cascade.stateOf(IRoot)).toBe('Active'); const mid = child.invokeFunction((a) => a.get(IMid)); expect(mid.root).toBe(child.invokeFunction((a) => a.get(IRoot))); parent.dispose(); @@ -592,12 +593,11 @@ describe('cascade engine — cross-scope orchestration (D9)', () => { childA.unprovide(IMid); expect(events).toEqual(['-mid']); - expect(childB.cascade.unitState(IMid)).toBe('Active'); + expect(childB.cascade.stateOf(IMid)).toBe('Active'); - // But a parent change reaches both subtrees. parent.unprovide(IRoot); expect(events).toEqual(['-mid', '-mid', '-root']); - expect(childB.cascade.unitState(IMid)).toBe('Pending'); + expect(childB.cascade.stateOf(IMid)).toBe('Pending'); parent.dispose(); }); @@ -615,12 +615,10 @@ describe('cascade engine — cross-scope orchestration (D9)', () => { parent.unprovide(IRoot); - // The child's own dispose already retired mid; the cascade skips the dead - // scope's units and completes its own teardown. expect(events).toEqual(['-mid', '-root']); const entry = parent.cascade.history().at(-1)!; expect(entry.tornDown).toEqual(['cascade-root']); - expect(parent.cascade.unitState(IRoot)).toBeUndefined(); + expect(parent.cascade.stateOf(IRoot)).toBeUndefined(); parent.dispose(); }); @@ -638,7 +636,6 @@ describe('cascade engine — cross-scope orchestration (D9)', () => { descriptor: new SyncDescriptor(Root), reason: 'replace root', }); - // The child sees its ancestor's token as in flight. expect(child.cascade.isInFlight(IRoot)).toBe(true); expect(() => child.invokeFunction((a) => a.get(IRoot))).toThrow(CascadeConflictError); @@ -647,7 +644,167 @@ describe('cascade engine — cross-scope orchestration (D9)', () => { await tx; const root = await suspended; expect(root).toBeInstanceOf(Root); - expect(child.cascade.unitState(IMid)).toBe('Active'); + expect(child.cascade.stateOf(IMid)).toBe('Active'); + parent.dispose(); + }); +}); + +describe('cascade engine — introspection (debug surface)', () => { + it('unitsSnapshot reflects unit states, in-flight, and the sticky failure', async () => { + const ix = makeContainer(); + let stateDuringCtor: string | undefined; + class SpyRoot implements IRoot { + label = 'spy'; + constructor() { + stateDuringCtor = ix.cascade.unitsSnapshot().find( + (unit) => unit.token === 'cascade-root', + )?.state; + } + } + ix.provide(IRoot, new SyncDescriptor(SpyRoot)); + expect(stateDuringCtor).toBe('Activating'); + + ix.provide(ILeaf, new SyncDescriptor(Leaf)); + class Boom implements IExtra { + label = 'boom'; + constructor() { + throw new Error('ctor boom'); + } + } + ix.provide(IExtra, new SyncDescriptor(Boom)); + + const byToken = new Map(ix.cascade.unitsSnapshot().map((unit) => [unit.token, unit])); + expect(byToken.get('cascade-root')).toMatchObject({ + state: 'Active', + everActive: true, + inFlight: false, + }); + expect(byToken.get('cascade-leaf')).toMatchObject({ + state: 'Pending', + everActive: false, + inFlight: false, + }); + expect(byToken.get('cascade-leaf')!.error).toBeUndefined(); + expect(byToken.get('cascade-extra')).toMatchObject({ + state: 'Failed', + everActive: false, + error: 'ctor boom', + }); + + const gate = deferred(); + class SlowRoot implements IRoot { + label = 'slow'; + dispose(): void { + return gate.promise as unknown as void; + } + } + ix.provide(IRoot, new SyncDescriptor(SlowRoot)); + const done = ix.cascade.submit({ action: 'unprovide', token: IRoot, reason: 'drop root' }); + const mid = new Map(ix.cascade.unitsSnapshot().map((unit) => [unit.token, unit])); + expect(mid.get('cascade-root')).toMatchObject({ state: 'Unloading', inFlight: true }); + gate.resolve(); + await done; + expect(ix.cascade.unitsSnapshot().some((unit) => unit.token === 'cascade-root')).toBe(false); + ix.dispose(); + }); + + it('onDidChangeUnitState fires the transition sequence (incl. Failed with error)', () => { + const ix = makeContainer(); + const seen: UnitStateChange[] = []; + ix.cascade.onDidChangeUnitState((change) => { seen.push(change); }); + + ix.provide(IRoot, new SyncDescriptor(Root)); + expect(seen).toEqual([ + { token: 'cascade-root', state: 'Pending' }, + { token: 'cascade-root', state: 'Activating' }, + { token: 'cascade-root', state: 'Active' }, + ]); + + ix.provide(IMid, new SyncDescriptor(Mid)); + seen.length = 0; + ix.unprovide(IRoot); + expect(seen).toEqual([ + { token: 'cascade-mid', state: 'Unloading' }, + { token: 'cascade-mid', state: 'Pending' }, + { token: 'cascade-root', state: 'Unloading' }, + ]); + + seen.length = 0; + class Boom implements IExtra { + label = 'boom'; + constructor() { + throw new Error('ctor boom'); + } + } + ix.provide(IExtra, new SyncDescriptor(Boom)); + expect(seen).toEqual([ + { token: 'cascade-extra', state: 'Pending' }, + { token: 'cascade-extra', state: 'Activating' }, + { token: 'cascade-extra', state: 'Failed', error: 'ctor boom' }, + ]); + ix.dispose(); + }); + + it('onDidCascade fires once per completed transaction with the history entry', () => { + const ix = makeContainer(); + const fired: CascadeHistoryEntry[] = []; + ix.cascade.onDidCascade((entry) => { fired.push(entry); }); + + provideChain(ix); + expect(fired).toHaveLength(3); + expect(fired.map((entry) => entry.seq)).toEqual([1, 2, 3]); + expect(fired[2]).toBe(ix.cascade.history().at(-1)); + expect(fired[2]!.changes).toEqual([{ token: 'cascade-leaf', action: 'provide' }]); + ix.dispose(); + }); + + it('CascadeTree onDidAddEngine / onDidRemoveEngine track child containers', () => { + const parent = makeContainer(); + const added: CascadeEngine[] = []; + const removed: CascadeEngine[] = []; + parent.cascadeTree.onDidAddEngine((engine) => { added.push(engine); }); + parent.cascadeTree.onDidRemoveEngine((engine) => { removed.push(engine); }); + + const child = parent.createChild(new ServiceCollection()); + expect(added).toEqual([child.cascade]); + expect(parent.cascadeTree.engines.has(child.cascade)).toBe(true); + + child.dispose(); + expect(removed).toEqual([child.cascade]); + expect(parent.cascadeTree.engines.has(child.cascade)).toBe(false); + parent.dispose(); + }); + + it('servicesSnapshot lists token / uid and tracks provide/unprovide', () => { + const ix = makeContainer(); + const handle = ix.provide(IRoot, new SyncDescriptor(Root)); + const root = ix.servicesSnapshot().find((service) => service.token === 'cascade-root'); + expect(root).toBeDefined(); + expect(root!.uid).toBe(handle.uid); + expect(ix.findIdentifier('cascade-root')).toBe(IRoot); + + ix.provide(IRoot, new SyncDescriptor(Root)); + const next = ix.servicesSnapshot().find((service) => service.token === 'cascade-root'); + expect(next!.uid).toBeGreaterThan(root!.uid); + + ix.unprovide(IRoot); + expect(ix.servicesSnapshot().some((service) => service.token === 'cascade-root')).toBe(false); + expect(ix.findIdentifier('cascade-root')).toBeUndefined(); + ix.dispose(); + }); + + it('exposes ledger / cascadeTree / children for debug introspection', () => { + const parent = makeContainer(); + expect(parent.ledger.state).toBe('active'); + + const child = parent.createChild(new ServiceCollection()); + expect(parent.children).toHaveLength(1); + expect(parent.children[0]).toBe(child); + expect((child as InstantiationService).cascadeTree).toBe(parent.cascadeTree); + + child.dispose(); + expect(parent.children).toHaveLength(0); parent.dispose(); + expect(parent.ledger.state).toBe('disposed'); }); }); diff --git a/packages/agent-core-v2/test/_base/di/child.test.ts b/packages/agent-core-v2/test/_base/di/child.test.ts index eebce1e32c8..efdb838eb7d 100644 --- a/packages/agent-core-v2/test/_base/di/child.test.ts +++ b/packages/agent-core-v2/test/_base/di/child.test.ts @@ -447,8 +447,6 @@ describe('Disposable base class', () => { } } const o = new Owner(); - // Ledger semantics: teardown is uninterruptible — a failing entry is - // reported via onUnexpectedError and teardown continues (reverse order). expect(() => { o.dispose(); }).not.toThrow(); expect(events).toEqual(['tail', 'bad-attempted', 'good']); expect(reported).toHaveLength(1); diff --git a/packages/agent-core-v2/test/_base/di/collection.test.ts b/packages/agent-core-v2/test/_base/di/collection.test.ts new file mode 100644 index 00000000000..ddf63b81351 --- /dev/null +++ b/packages/agent-core-v2/test/_base/di/collection.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { + collection, + type CollectionChange, + type CollectionView, +} from '#/_base/di/collection'; +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { createDecorator } from '#/_base/di/instantiation'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import { Service } from '#/_base/di/service'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; + +interface Tool { + readonly name: string; +} + +const ToolContribution = collection('test-tool-contribution'); + +interface IContributor { + marker: string; +} +const IContributor = createDecorator('collection-contributor'); + +interface IFold { + marker: string; +} +const IFold = createDecorator('collection-fold'); + +class Contributor extends Service { + constructor(value: Tool) { + super(); + this.provide(ToolContribution, value); + } +} + +class Fold extends Service { + disposed = false; + constructor(@ToolContribution readonly view: CollectionView) { + super(); + } + override dispose(): void { + this.disposed = true; + super.dispose(); + } +} + +function contributeIn(container: InstantiationService, value: Tool): void { + container.provide(IContributor, new SyncDescriptor(Contributor, [value] as never)); + container.invokeFunction((a) => a.get(IContributor)); +} + +function foldIn(container: InstantiationService): Fold { + container.provide(IFold, new SyncDescriptor(Fold)); + return container.invokeFunction((a) => a.get(IFold)) as unknown as Fold; +} + +describe('collection tokens — visibility & record lifetime (D12)', () => { + it('flows records upward: a child-scope record lands on the root fold view', () => { + const root = new InstantiationService(new ServiceCollection(), true); + const child = root.createChild(new ServiceCollection()) as InstantiationService; + const fold = foldIn(root); + expect(fold.view.items).toEqual([]); + contributeIn(child, { name: 'from-child' }); + expect(fold.view.items).toEqual([{ name: 'from-child' }]); + expect(fold.view.records[0]!.providerName).toBe('Contributor'); + expect(fold.view.records[0]!.scopePath).toContain('#'); + root.dispose(); + }); + + it('flows records downward: a root record is visible to a child view', () => { + const root = new InstantiationService(new ServiceCollection(), true); + contributeIn(root, { name: 'from-root' }); + const child = root.createChild(new ServiceCollection()) as InstantiationService; + const fold = foldIn(child); + expect(fold.view.items).toEqual([{ name: 'from-root' }]); + root.dispose(); + }); + + it('never leaks records into sibling subtrees', () => { + const root = new InstantiationService(new ServiceCollection(), true); + const childA = root.createChild(new ServiceCollection()) as InstantiationService; + const childB = root.createChild(new ServiceCollection()) as InstantiationService; + contributeIn(childA, { name: 'A' }); + const foldB = foldIn(childB); + expect(foldB.view.items).toEqual([]); + root.dispose(); + }); + + it('withdraws records when the provider dies, with incremental payloads', () => { + const root = new InstantiationService(new ServiceCollection(), true); + const child = root.createChild(new ServiceCollection()) as InstantiationService; + const changes: CollectionChange[] = []; + const fold = foldIn(root); + const subscription = fold.view.onDidChange((change) => changes.push(change)); + contributeIn(child, { name: 'ephemeral' }); + expect(changes).toEqual([{ added: [{ name: 'ephemeral' }], removed: [] }]); + child.dispose(); + expect(changes).toEqual([ + { added: [{ name: 'ephemeral' }], removed: [] }, + { added: [], removed: [{ name: 'ephemeral' }] }, + ]); + subscription.dispose(); + root.dispose(); + }); + + it('withdraws records when the providing unit is unprovided', () => { + const root = new InstantiationService(new ServiceCollection(), true); + const fold = foldIn(root); + contributeIn(root, { name: 'owned' }); + expect(fold.view.items).toEqual([{ name: 'owned' }]); + root.unprovide(IContributor); + expect(fold.view.items).toEqual([]); + root.dispose(); + }); + + it('replays surviving records into a rebuilt fold (records outlive folds)', () => { + const root = new InstantiationService(new ServiceCollection(), true); + contributeIn(root, { name: 'durable' }); + const first = foldIn(root); + expect(first.view.items).toEqual([{ name: 'durable' }]); + root.unprovide(IFold); + const second = foldIn(root); + expect(second.view.items).toEqual([{ name: 'durable' }]); + root.dispose(); + }); + + it('records a collection edge in the graph and never cascades the fold on changes', () => { + const root = new InstantiationService(new ServiceCollection(), true); + const fold = foldIn(root); + const edges = root.dependencyGraph.edges(); + expect( + edges.some( + (edge) => + edge.kind === 'collection' && + String(edge.dependency.token) === 'collection:test-tool-contribution', + ), + ).toBe(true); + const child = root.createChild(new ServiceCollection()) as InstantiationService; + contributeIn(child, { name: 'x' }); + child.dispose(); + expect(fold.disposed).toBe(false); + root.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/_base/di/planSample.test.ts b/packages/agent-core-v2/test/_base/di/planSample.test.ts new file mode 100644 index 00000000000..896a626b8a4 --- /dev/null +++ b/packages/agent-core-v2/test/_base/di/planSample.test.ts @@ -0,0 +1,165 @@ +/** + * Plan-sample acceptance test — `plan/plan-domain-plugin.manifest.ts` as the + * API acceptance standard (Phase 3 验证项), exercised against the REAL kernel + * and the REAL domain collection tokens. Each section cites the sample line + * it proves; the unload chain asserts §3's teardown order end to end. + */ + +import { describe, expect, it } from 'vitest'; + +import { collection, type CollectionView } from '#/_base/di/collection'; +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { FiberState, ScopeUnits } from '#/_base/di/fiber'; +import { createDecorator, ScopeActivation } from '#/_base/di/instantiation'; +import { type InstantiationService } from '#/_base/di/instantiationService'; +import { Scope } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ConfigSectionContribution } from '#/app/config/configSectionContributions'; +import { AgentProfileContribution } from '#/app/agentProfileCatalog/agentProfileContribution'; +import { AgentToolContribution } from '#/agent/toolRegistry/toolContribution'; +import { WireModelContribution } from '#/wire/wireContribution'; + +interface IAgentPlanService { + readonly _serviceBrand: undefined; + marker: string; +} +const IAgentPlanService = createDecorator('plan-service'); + +interface IEnterPlanModeTool { + marker: string; +} +const IEnterPlanModeTool = createDecorator('enter-plan-mode-tool'); + +class AgentPlanService extends Service { + readonly marker = 'plan-service'; +} + +class EnterPlanModeTool extends Service { + readonly marker = 'enter-plan-mode'; + constructor(@IAgentPlanService readonly plan: IAgentPlanService) { + super(); + } +} + +const planProfile = { name: 'plan' } as never; + +describe('Plan sample (plan-domain-plugin.manifest.ts) — API acceptance', () => { + it('§1: the two Plan-domain units assemble through this.provide only', async () => { + const log: string[] = []; + + class PlanFeature extends Service { + static override readonly name = 'plan'; + + constructor() { + super(); + this.provide(ConfigSectionContribution, { + domain: 'defaultPlanMode', + schema: { '~standard': { validate: (v: unknown) => ({ value: v }) } } as never, + options: { defaultValue: false } as never, + }); + this.provide(AgentProfileContribution, { + sourceId: 'builtin', + contribution: { profiles: [planProfile] }, + }); + this.provide(ScopeUnits(LifecycleScope.Agent), PlanAgentFeature); + } + } + + class PlanAgentFeature extends Service { + static override readonly name = 'plan/agent'; + + constructor() { + super(); + this.provide(WireModelContribution, { models: [], ops: [] }); + this.provide(IAgentPlanService, AgentPlanService, { + activation: ScopeActivation.OnScopeCreated, + }); + this.provide(AgentToolContribution, { + id: IEnterPlanModeTool, + ctor: EnterPlanModeTool, + options: { name: 'EnterPlanMode' }, + } as unknown as AgentToolContribution); + log.push('agent feature up'); + } + } + + const seen: string[] = []; + class ConfigFold extends Service { + constructor(@ConfigSectionContribution view: CollectionView) { + super(); + for (const item of view.items) { + seen.push(`config:${(item as { domain: string }).domain}`); + } + this._register( + view.onDidChange(({ added, removed }) => { + for (const item of added) seen.push(`config:+${(item as { domain: string }).domain}`); + for (const item of removed) seen.push(`config:-${(item as { domain: string }).domain}`); + }), + ); + } + } + const IConfigFold = createDecorator('test-config-fold'); + const IPlanFeature = createDecorator('test-plan-feature'); + + const app = Scope.createApp({ id: 'app' }); + app.instantiation.provide(IConfigFold, new SyncDescriptor(ConfigFold)); + app.accessor.get(IConfigFold); + expect(seen).toEqual([]); + + const featureHandle = app.instantiation.provide(IPlanFeature, new SyncDescriptor(PlanFeature)); + app.accessor.get(IPlanFeature); + + expect(seen).toEqual(['config:+defaultPlanMode']); + expect(featureHandle.uid).toBeTypeOf('number'); + + const agent = app.createChild(LifecycleScope.Agent, 'agent-1'); + expect(log).toEqual(['agent feature up']); + expect(agent.accessor.get(IAgentPlanService).marker).toBe('plan-service'); + const toolView = (agent.instantiation as InstantiationService).fiberHost.collectionView(AgentToolContribution); + expect(toolView.items).toHaveLength(1); + expect(toolView.items[0]!.options.name).toBe('EnterPlanMode'); + const wireView = (agent.instantiation as InstantiationService).fiberHost.collectionView(WireModelContribution); + expect(wireView.items).toHaveLength(1); + + const tool = agent.instantiation.createInstance(EnterPlanModeTool); + expect(tool.plan.marker).toBe('plan-service'); + + featureHandle.dispose(); + await app.instantiation.cascade.whenIdle(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect((agent.instantiation as InstantiationService).fiberHost.collectionView(WireModelContribution).items).toHaveLength(0); + expect(toolView.items).toHaveLength(0); + expect(seen).toEqual(['config:+defaultPlanMode', 'config:-defaultPlanMode']); + expect(log).toEqual(['agent feature up']); + expect(() => agent.accessor.get(IAgentPlanService)).toThrow(); + agent.dispose(); + app.dispose(); + }); + + it('§0: class-recipe statics (name) and handle state are honored', () => { + class Named extends Service { + static override readonly name = 'plan'; + } + const INamed = createDecorator('test-named'); + const app = Scope.createApp({ id: 'app' }); + const handle = app.instantiation.provide(INamed, new SyncDescriptor(Named)); + app.accessor.get(INamed); + expect(handle.uid).toBeTypeOf('number'); + expect(app.instantiation.cascade.stateOf(INamed)).toBe('Active'); + app.dispose(); + }); + + it('§1: FiberHandle for a unit provide is thenable and Active', async () => { + const IPlan = createDecorator('test-plan-handle'); + class PlanFeature extends Service { + static override readonly name = 'plan'; + } + const app = Scope.createApp({ id: 'app' }); + app.instantiation.provide(IPlan, new SyncDescriptor(PlanFeature)); + const unit = app.accessor.get(IPlan); + expect(unit.state).toBe(FiberState.Active); + expect(unit.name).toBe('plan'); + app.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/_base/di/provide.test.ts b/packages/agent-core-v2/test/_base/di/provide.test.ts index 83c57efa804..3c02fa999ed 100644 --- a/packages/agent-core-v2/test/_base/di/provide.test.ts +++ b/packages/agent-core-v2/test/_base/di/provide.test.ts @@ -103,17 +103,6 @@ describe('InstantiationService.provide/unprovide (L1)', () => { ix.dispose(); }); - it('records the pinned flag on the entry', () => { - const ix = new InstantiationService(new ServiceCollection(), true); - ix.provide(IFoo, new SyncDescriptor(Foo), { pinned: true }); - const services = (ix as unknown as { _services: ServiceCollection })._services; - expect(services.isPinned(IFoo)).toBe(true); - // Re-provide without the flag keeps the previous pinned metadata. - ix.provide(IFoo, new SyncDescriptor(Foo)); - expect(services.isPinned(IFoo)).toBe(true); - ix.dispose(); - }); - it('the provide handle is a ledger entry: disposing it unprovides', () => { const ix = new InstantiationService(new ServiceCollection(), true); const handle = ix.provide(IFoo, new SyncDescriptor(Foo)); diff --git a/packages/agent-core-v2/test/_base/di/scope-topology.test.ts b/packages/agent-core-v2/test/_base/di/scope-topology.test.ts new file mode 100644 index 00000000000..d2aa2363e93 --- /dev/null +++ b/packages/agent-core-v2/test/_base/di/scope-topology.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { BugIndicatingError } from '#/_base/errors/errors'; +import { createAppScope, setScopeTopology } from '#/_base/di/scope'; + +describe('Scope topology (kernel)', () => { + it('skips the createChild order check while no topology is declared', () => { + const app = createAppScope(); + const child = app.createChild('zzz', 'c1'); + const grandchild = child.createChild('app', 'g1'); + expect(child.kind).toBe('zzz'); + expect(grandchild.kind).toBe('app'); + app.dispose(); + }); + + it('enforces the declared order once setScopeTopology runs', () => { + setScopeTopology(['app', 'mid', 'leaf']); + const app = createAppScope(); + const mid = app.createChild('mid', 'm1'); + expect(() => mid.createChild('leaf', 'l1')).not.toThrow(); + expect(() => mid.createChild('mid', 'm2')).toThrow(/greater/); + expect(() => mid.createChild('app', 'a2')).toThrow(/greater/); + expect(() => app.createChild('unknown', 'u1')).toThrow(/greater/); + app.dispose(); + }); + + it('treats an equal redeclaration as a no-op and rejects a different one', () => { + expect(() => setScopeTopology(['app', 'mid', 'leaf'])).not.toThrow(); + expect(() => setScopeTopology(['app', 'leaf'])).toThrow(BugIndicatingError); + }); +}); 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 639aff1bd5a..6e9290863e3 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 @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, Scope, _clearScopedRegistryForTests, @@ -245,7 +245,7 @@ describe('Scope tree', () => { app.dispose(); }); - it('fails scope creation when an OnScopeCreated service constructor throws', () => { + it('an OnScopeCreated construction failure is sticky Failed (D5), not a scope-creation error', () => { interface IBoom { tag: 'boom'; } @@ -260,7 +260,19 @@ describe('Scope tree', () => { registerScopedService(LifecycleScope.Session, IBoom, Boom); const app = createAppScope(); - expect(() => app.createChild(LifecycleScope.Session, 's1')).toThrow(/boom/); + const session = app.createChild(LifecycleScope.Session, 's1'); + expect(session.instantiation.cascade.stateOf(IBoom)).toBe('Failed'); + expect(() => session.accessor.get(IBoom)).toThrow(/boom/); + app.dispose(); + }); + + it('exposes the scope ledger for debug introspection', () => { + const { app } = buildTree(); + expect(app.ledger.state).toBe('active'); + const labels = app.ledger.entries().map((entry) => entry.label); + expect(labels).toContain('instantiation'); + expect(labels).toContain('scope:s1'); app.dispose(); + expect(app.ledger.state).toBe('disposed'); }); }); diff --git a/packages/agent-core-v2/test/_base/di/scopeUnits.test.ts b/packages/agent-core-v2/test/_base/di/scopeUnits.test.ts new file mode 100644 index 00000000000..577672e6a14 --- /dev/null +++ b/packages/agent-core-v2/test/_base/di/scopeUnits.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { ScopeUnits } from '#/_base/di/fiber'; +import { createDecorator } from '#/_base/di/instantiation'; +import { Scope } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; + +interface IFoo { + tag: string; +} +const IFoo = createDecorator('scope-units-foo'); + +interface IPack { + marker: string; +} +const IPack = createDecorator('scope-units-pack'); + +class Foo implements IFoo { + tag = 'foo'; +} + +describe('ScopeUnits — kernel materialization fold (D11/G2)', () => { + const log: string[] = []; + + class AgentFeature extends Service { + constructor() { + super(); + this.provide(IFoo, Foo); + this.effect(() => { + log.push('feature up'); + return () => { + log.push('feature down'); + }; + }); + } + } + + class FeaturePack extends Service { + constructor() { + super(); + this.provide(ScopeUnits('agent'), AgentFeature); + } + } + + function appWithPack(): Scope { + log.length = 0; + const app = Scope.createApp({ id: 'app' }); + app.instantiation.provide(IPack, new SyncDescriptor(FeaturePack)); + app.accessor.get(IPack); + return app; + } + + it('materializes a contributed recipe inside every new scope of the kind', () => { + const app = appWithPack(); + const a1 = app.createChild('agent', 'a1'); + expect(a1.accessor.get(IFoo).tag).toBe('foo'); + expect(log).toEqual(['feature up']); + const a2 = app.createChild('agent', 'a2'); + expect(a2.accessor.get(IFoo).tag).toBe('foo'); + expect(log).toEqual(['feature up', 'feature up']); + app.dispose(); + }); + + it('tears the materialized unit down when the provider dies (连坐)', () => { + const app = appWithPack(); + const a1 = app.createChild('agent', 'a1'); + expect(a1.accessor.get(IFoo).tag).toBe('foo'); + app.instantiation.unprovide(IPack); + expect(log).toEqual(['feature up', 'feature down']); + expect(() => a1.accessor.get(IFoo)).toThrow(); + app.dispose(); + }); + + it('tears the materialized unit down with the target scope (idempotent with 连坐)', () => { + const app = appWithPack(); + const a1 = app.createChild('agent', 'a1'); + expect(a1.accessor.get(IFoo).tag).toBe('foo'); + a1.dispose(); + expect(log).toEqual(['feature up', 'feature down']); + app.dispose(); + expect(log).toEqual(['feature up', 'feature down']); + }); + + it('materializes records that arrive after the scope exists, and retracts them on withdrawal', () => { + log.length = 0; + const app = Scope.createApp({ id: 'app' }); + const a1 = app.createChild('agent', 'a1'); + app.instantiation.provide(IPack, new SyncDescriptor(FeaturePack)); + app.accessor.get(IPack); + expect(log).toEqual(['feature up']); + expect(a1.accessor.get(IFoo).tag).toBe('foo'); + app.instantiation.unprovide(IPack); + expect(log).toEqual(['feature up', 'feature down']); + app.dispose(); + }); + + it('does not materialize records of a different kind', () => { + log.length = 0; + const app = Scope.createApp({ id: 'app' }); + app.instantiation.provide(IPack, new SyncDescriptor(FeaturePack)); + app.accessor.get(IPack); + app.createChild('session', 's1'); + expect(log).toEqual([]); + app.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/_base/di/scoped-register.test.ts b/packages/agent-core-v2/test/_base/di/scoped-register.test.ts index 18630f2ddbc..810954e0d08 100644 --- a/packages/agent-core-v2/test/_base/di/scoped-register.test.ts +++ b/packages/agent-core-v2/test/_base/di/scoped-register.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createDecorator } from '#/_base/di/instantiation'; +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, getScopedServiceDescriptors, diff --git a/packages/agent-core-v2/test/_base/di/scoped-test-container.test.ts b/packages/agent-core-v2/test/_base/di/scoped-test-container.test.ts index fc5dcaae168..7e08841d612 100644 --- a/packages/agent-core-v2/test/_base/di/scoped-test-container.test.ts +++ b/packages/agent-core-v2/test/_base/di/scoped-test-container.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createDecorator } from '#/_base/di/instantiation'; +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, _clearScopedRegistryForTests, registerScopedService, } from '#/_base/di/scope'; diff --git a/packages/agent-core-v2/test/_base/di/service.test.ts b/packages/agent-core-v2/test/_base/di/service.test.ts new file mode 100644 index 00000000000..87364749533 --- /dev/null +++ b/packages/agent-core-v2/test/_base/di/service.test.ts @@ -0,0 +1,348 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { Emitter } from '#/_base/event'; +import { + FiberProtocolError, + FiberState, + ScopeUnits, + setFiberEventResolver, + type Fiber, + type FiberHandle, +} from '#/_base/di/fiber'; +import { createDecorator, ref, type LiveRef } from '#/_base/di/instantiation'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import { Service } from '#/_base/di/service'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; + +interface IFoo { + tag: string; +} +const IFoo = createDecorator('service-foo'); + +interface IBar { + tag: string; +} +const IBar = createDecorator('service-bar'); + +class Foo implements IFoo { + tag = 'foo'; +} + +class Bar implements IBar { + tag = 'bar'; + constructor(@IFoo public readonly foo: IFoo) {} +} + +describe('Service — kernel construction protocol (L3)', () => { + afterEach(() => { + setFiberEventResolver(undefined); + }); + + it('flushes buffered capability calls in writing order', () => { + const order: string[] = []; + class Unit extends Service { + constructor() { + super(); + this.effect(() => { + order.push('effect-a'); + return undefined; + }); + this.provide(IFoo, Foo); + this.effect(() => { + order.push('effect-b'); + return undefined; + }); + } + } + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IBar, new SyncDescriptor(Unit)); + ix.invokeFunction((a) => a.get(IBar)); + expect(order).toEqual(['effect-a', 'effect-b']); + expect(ix.invokeFunction((a) => a.get(IFoo)).tag).toBe('foo'); + ix.dispose(); + }); + + it('throws when get/ref are called during construction', () => { + class GetInCtor extends Service { + constructor() { + super(); + this.get(IFoo); + } + } + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IBar, new SyncDescriptor(GetInCtor)); + expect(() => ix.invokeFunction((a) => a.get(IBar))).toThrow(FiberProtocolError); + ix.dispose(); + + class RefInCtor extends Service { + constructor() { + super(); + this.ref(IFoo); + } + } + const ix2 = new InstantiationService(new ServiceCollection(), true); + ix2.provide(IBar, new SyncDescriptor(RefInCtor)); + expect(() => ix2.invokeFunction((a) => a.get(IBar))).toThrow(FiberProtocolError); + ix2.dispose(); + }); + + it('throws on every capability call of a manually newed instance', () => { + class Unit extends Service {} + const unit = new Unit(); + expect(() => unit.provide(IFoo, Foo)).toThrow(/no unit runtime/); + expect(() => unit.effect(() => undefined)).toThrow(/no unit runtime/); + expect(() => unit.on('x', () => {})).toThrow(/no unit runtime/); + expect(() => unit.get(IFoo)).toThrow(/no unit runtime/); + expect(() => unit.ref(IFoo)).toThrow(/no unit runtime/); + }); + + it('rejects a manual new nested inside another unit’s ctor', () => { + class Inner extends Service {} + class Outer extends Service { + readonly inner = new Inner(); + } + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IBar, new SyncDescriptor(Outer)); + const outer = ix.invokeFunction((a) => a.get(IBar)) as unknown as Outer; + expect(() => outer.inner.effect(() => undefined)).toThrow(/no unit runtime/); + ix.dispose(); + }); + + it('attaches pending handles handed out during buffering at flush', async () => { + let captured: FiberHandle | undefined; + class Unit extends Service { + constructor() { + super(); + captured = this.provide(IFoo, Foo); + } + } + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IBar, new SyncDescriptor(Unit)); + ix.invokeFunction((a) => a.get(IBar)); + expect(captured).toBeDefined(); + expect(captured!.state).toBe(FiberState.Active); + expect(typeof captured!.uid).toBe('number'); + await expect(captured!).resolves.toMatchObject({ state: FiberState.Active }); + ix.dispose(); + }); + + it('withdraws a unit-provided token when the provider is retired (连坐)', () => { + class Provider extends Service { + constructor() { + super(); + this.provide(IFoo, Foo); + } + } + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IBar, new SyncDescriptor(Provider)); + ix.invokeFunction((a) => a.get(IBar)); + expect(ix.invokeFunction((a) => a.get(IFoo)).tag).toBe('foo'); + ix.unprovide(IBar); + expect(() => ix.invokeFunction((a) => a.get(IFoo))).toThrow(/unknown service/); + ix.dispose(); + }); + + it('auto-activates a pending dependent once a unit provides its dependency', () => { + class Provider extends Service { + constructor() { + super(); + this.provide(IFoo, Foo); + } + } + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IBar, new SyncDescriptor(Bar)); + expect(() => ix.invokeFunction((a) => a.get(IBar))).toThrow(); + const IProvider = createDecorator('service-provider'); + ix.provide(IProvider, new SyncDescriptor(Provider)); + ix.invokeFunction((a) => a.get(IProvider)); + const bar = ix.invokeFunction((a) => a.get(IBar)); + expect(bar.tag).toBe('bar'); + ix.dispose(); + }); + + it('checks get against the declared constructor dependencies', () => { + class Unit extends Service { + constructor(@IFoo public readonly foo: IFoo) { + super(); + } + probe(): [IFoo, unknown] { + return [this.get(IFoo), () => this.get(IBar)]; + } + } + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo)); + ix.provide(IBar, new SyncDescriptor(Unit)); + const unit = ix.invokeFunction((a) => a.get(IBar)) as unknown as Unit; + const [foo, undeclared] = unit.probe(); + expect(foo.tag).toBe('foo'); + expect(undeclared).toThrow(/undeclared dependency/); + ix.dispose(); + }); + + it('injects a live @ref observation without a lifecycle binding', () => { + class Consumer extends Service { + disposed = false; + constructor(@ref(IFoo) public readonly fooRef: LiveRef) { + super(); + } + override dispose(): void { + this.disposed = true; + super.dispose(); + } + } + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IBar, new SyncDescriptor(Consumer)); + const consumer = ix.invokeFunction((a) => a.get(IBar)) as unknown as Consumer; + expect(consumer.fooRef.current).toBeUndefined(); + const provideHandle = ix.provide(IFoo, new SyncDescriptor(Foo)); + expect(consumer.fooRef.current?.tag).toBe('foo'); + provideHandle.dispose(); + expect(consumer.disposed).toBe(false); + ix.dispose(); + }); + + it('runs function recipes against a checked facade and anchors the return disposer', () => { + const log: string[] = []; + class Provider extends Service { + constructor() { + super(); + this.provide( + Object.assign( + (fiber: Fiber) => { + log.push(`run:${fiber.get(IFoo).tag}`); + return () => { + log.push('cleanup'); + }; + }, + { inject: [IFoo] as const }, + ), + ); + } + } + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo)); + ix.provide(IBar, new SyncDescriptor(Provider)); + ix.invokeFunction((a) => a.get(IBar)); + expect(log).toEqual(['run:foo']); + ix.unprovide(IBar); + expect(log).toEqual(['run:foo', 'cleanup']); + ix.dispose(); + }); + + it('rejects a facade get of an undeclared dependency in a function recipe', () => { + class Provider extends Service { + constructor() { + super(); + this.provide((fiber) => { + fiber.get(IFoo); + }); + } + } + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo)); + ix.provide(IBar, new SyncDescriptor(Provider)); + expect(() => ix.invokeFunction((a) => a.get(IBar))).toThrow(/undeclared dependency/); + ix.dispose(); + }); + + it('rebuilds a token unit with new config on update(config)', async () => { + const configs: unknown[] = []; + class Unit extends Service { + constructor() { + super(); + configs.push(this.config); + } + } + let handle: FiberHandle | undefined; + class Provider extends Service { + constructor() { + super(); + handle = this.provide(IFoo, Unit, { config: { v: 1 } }); + } + } + const IProvider = createDecorator('service-config-provider'); + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IProvider, new SyncDescriptor(Provider)); + ix.invokeFunction((a) => a.get(IProvider)); + ix.invokeFunction((a) => a.get(IFoo)); + expect(configs).toEqual([{ v: 1 }]); + await handle!.update({ v: 2 }); + expect(configs).toEqual([{ v: 1 }, { v: 2 }]); + ix.dispose(); + }); + + it('exposes config already inside the constructor (frame-carried)', () => { + let seen: unknown; + class Unit extends Service { + constructor() { + super(); + seen = this.config; + } + } + const ix = new InstantiationService(new ServiceCollection(), true); + class Provider extends Service { + constructor() { + super(); + this.provide(IFoo, Unit, { config: 42 }); + } + } + ix.provide(IBar, new SyncDescriptor(Provider)); + ix.invokeFunction((a) => a.get(IBar)); + ix.invokeFunction((a) => a.get(IFoo)); + expect(seen).toBe(42); + ix.dispose(); + }); + + it('supports on() over a direct Emitter and over the event resolver', () => { + const seen: string[] = []; + const emitter = new Emitter(); + class Unit extends Service { + constructor() { + super(); + this.on(emitter, (e) => seen.push(`emitter:${e}`)); + this.on('domain.event', (e) => seen.push(`bus:${e}`)); + } + } + setFiberEventResolver((_host, event, handler) => { + seen.push(`resolver:subscribed:${event}`); + handler('payload'); + return { dispose: () => seen.push('resolver:disposed') }; + }); + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IBar, new SyncDescriptor(Unit)); + ix.invokeFunction((a) => a.get(IBar)); + emitter.fire('x'); + expect(seen).toContain('emitter:x'); + expect(seen).toContain('resolver:subscribed:domain.event'); + expect(seen).toContain('bus:payload'); + ix.unprovide(IBar); + expect(seen).toContain('resolver:disposed'); + emitter.dispose(); + ix.dispose(); + }); + + it('provides a pre-materialized instance for a token, anchored to the unit', () => { + const foo = new Foo(); + class Provider extends Service { + constructor() { + super(); + this.provide(IFoo, foo); + } + } + const IProvider = createDecorator('service-instance-provider'); + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IProvider, new SyncDescriptor(Provider)); + ix.invokeFunction((a) => a.get(IProvider)); + expect(ix.invokeFunction((a) => a.get(IFoo))).toBe(foo); + ix.unprovide(IProvider); + expect(() => ix.invokeFunction((a) => a.get(IFoo))).toThrow(/unknown service/); + ix.dispose(); + }); + + it('mints one ScopeUnits token per scope kind', () => { + expect(ScopeUnits('agent')).toBe(ScopeUnits('agent')); + expect(ScopeUnits('agent')).not.toBe(ScopeUnits('session')); + expect(String(ScopeUnits('agent'))).toBe('collection:scope-units:agent'); + }); +}); diff --git a/packages/agent-core-v2/test/_base/lifecycle/ledger.test.ts b/packages/agent-core-v2/test/_base/lifecycle/ledger.test.ts index 5ea721df1a8..6e85c086dbc 100644 --- a/packages/agent-core-v2/test/_base/lifecycle/ledger.test.ts +++ b/packages/agent-core-v2/test/_base/lifecycle/ledger.test.ts @@ -60,7 +60,6 @@ describe('Ledger', () => { const out = ledger.teardown(); expect(out).toBeInstanceOf(Promise); expect(ledger.state).toBe('disposing'); - // Reverse order: sync-3 runs first (same tick), then the async entry starts. expect(events).toEqual(['sync-3', 'async-start']); gate.resolve(); @@ -86,7 +85,6 @@ describe('Ledger', () => { const out = ledger.teardown(); expect(events).toEqual(['b-start']); - // Resolving the later-registered gate must not unblock the earlier one. gates[0]!.resolve(); await Promise.resolve(); expect(events).toEqual(['b-start']); @@ -111,9 +109,6 @@ describe('Ledger', () => { let caught: unknown; ledger.register(async () => { await gate.promise; - // Registration happens while the ledger is 'disposing': it must throw - // at the call site (the entry itself is guarded, so the error does not - // abort the in-flight teardown). try { ledger.register(() => {}, 'late'); } catch (error) { @@ -199,7 +194,6 @@ describe('Ledger', () => { throw new Error('async construct failed'); }; const entry = ledger.effect(body, 'gen'); - // The failure surfaces through the entry's disposer chain; teardown reports it. const reported: unknown[] = []; setUnexpectedErrorHandler((err) => { reported.push(err); }); try { diff --git a/packages/agent-core-v2/test/_base/log/logService.test.ts b/packages/agent-core-v2/test/_base/log/logService.test.ts index cbed50e1d0e..fc09dcf7534 100644 --- a/packages/agent-core-v2/test/_base/log/logService.test.ts +++ b/packages/agent-core-v2/test/_base/log/logService.test.ts @@ -3,9 +3,8 @@ import { tmpdir } from 'node:os'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, diff --git a/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts b/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts index af8ef6c40c2..8ff98d2225c 100644 --- a/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts +++ b/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, @@ -143,8 +142,6 @@ describe('StateRegistry', () => { class FakeService { constructor(readonly dep: object) {} } - // A resource graph reachable from plain data: plain -> class -> plain… - // The class boundary stops the walk, so the deep plain tail never copied. const service = new FakeService({ deep: { tail: 'unreachable' } }); const mixedKey = defineState('test.mixed', () => ({ plain: { nested: [1, { ok: true }] }, diff --git a/packages/agent-core-v2/test/agent/activityView/activityView.test.ts b/packages/agent-core-v2/test/agent/activityView/activityView.test.ts index fe97eff8a74..1887d994e1c 100644 --- a/packages/agent-core-v2/test/agent/activityView/activityView.test.ts +++ b/packages/agent-core-v2/test/agent/activityView/activityView.test.ts @@ -210,8 +210,6 @@ describe('AgentActivityView', () => { bus.publish({ type: 'turn.ended', turnId: 1, reason: 'cancelled' }); expect(view.state().lastTurn).toMatchObject({ turnId: 1, reason: 'cancelled' }); - // While the next turn runs there is no current outcome; turn.ended - // publishes the fresh one. bus.publish({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } }); expect(view.state().lastTurn).toBeUndefined(); diff --git a/packages/agent-core-v2/test/agent/blob/agentBlobService.test.ts b/packages/agent-core-v2/test/agent/blob/agentBlobService.test.ts index 97392fbe95b..037a1bf4d31 100644 --- a/packages/agent-core-v2/test/agent/blob/agentBlobService.test.ts +++ b/packages/agent-core-v2/test/agent/blob/agentBlobService.test.ts @@ -21,7 +21,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ContentPart } from '#/kosong/contract/message'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { type ServiceIdentifier } from '#/_base/di/instantiation'; -import { LifecycleScope } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; import { createScopedTestHost, stubPair } from '#/_base/di/test'; import { BLOBREF_PROTOCOL, diff --git a/packages/agent-core-v2/test/agent/command/agentCommand.test.ts b/packages/agent-core-v2/test/agent/command/agentCommand.test.ts new file mode 100644 index 00000000000..aec12ef6cc4 --- /dev/null +++ b/packages/agent-core-v2/test/agent/command/agentCommand.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { createDecorator, ScopeActivation } from '#/_base/di/instantiation'; +import { + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { LifecycleScope } from '#/app/scopes'; +import { + IAgentCommandService, +} from '#/agent/command/agentCommand'; +import { AgentCommandService } from '#/agent/command/agentCommandService'; +import { + CommandContribution, + type CommandRunContext, +} from '#/agent/command/commandContribution'; +import { ErrorCodes } from '#/errors'; + +interface IEcho { + readonly _serviceBrand: undefined; + readonly value: string; +} +const IEcho = createDecorator('test-command-echo'); + +const ICommandProvider = createDecorator('test-command-provider'); + +describe('AgentCommandService — CommandContribution fold', () => { + beforeEach(() => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.Agent, + IAgentCommandService, + AgentCommandService, + ScopeActivation.OnDemand, + 'command', + ); + }); + + function hostWithProvider( + contributions: ReadonlyArray<{ + readonly name: string; + readonly description?: string; + readonly run: (ctx: CommandRunContext) => void | Promise; + }>, + ) { + class CommandProvider extends Service { + constructor() { + super(); + for (const contribution of contributions) { + this.provide(CommandContribution, contribution); + } + } + } + const host = createScopedTestHost([stubPair(IEcho, { _serviceBrand: undefined, value: 'echo!' })]); + const handle = host.app.instantiation.provide(ICommandProvider, new SyncDescriptor(CommandProvider)); + host.app.accessor.get(ICommandProvider); + const agent = host.child(LifecycleScope.Agent, 'agent-1'); + return { host, agent, handle, commands: agent.accessor.get(IAgentCommandService) }; + } + + it('lists contributed commands with source and runs them with container access', async () => { + const calls: string[] = []; + const { host, commands } = hostWithProvider([ + { + name: 'alpha', + description: 'the alpha command', + run: (ctx) => { + calls.push(`alpha:${ctx.args}`); + }, + }, + { + name: 'beta', + run: (ctx) => { + calls.push(`beta:${ctx.get(IEcho).value}`); + }, + }, + { + name: 'gamma', + run: async (ctx) => { + await new Promise((resolve) => setTimeout(resolve, 0)); + calls.push(`gamma:${ctx.args}`); + }, + }, + ]); + + expect(commands.list().map((command) => command.name)).toEqual(['alpha', 'beta', 'gamma']); + expect(commands.list()[0]).toMatchObject({ + name: 'alpha', + description: 'the alpha command', + source: 'CommandProvider', + }); + + await commands.run('alpha', 'x y'); + await commands.run('beta'); + await commands.run('gamma', 'z'); + expect(calls).toEqual(['alpha:x y', 'beta:echo!', 'gamma:z']); + host.dispose(); + }); + + it('shadows an earlier record with a later one of the same name', async () => { + const calls: string[] = []; + const { host, commands } = hostWithProvider([ + { + name: 'dup', + run: () => { + calls.push('first'); + }, + }, + { + name: 'dup', + run: () => { + calls.push('second'); + }, + }, + ]); + + expect(commands.list()).toHaveLength(1); + await commands.run('dup'); + expect(calls).toEqual(['second']); + host.dispose(); + }); + + it('fails unknown commands with a coded REQUEST_INVALID error', async () => { + const { host, commands } = hostWithProvider([]); + await expect(commands.run('nope')).rejects.toMatchObject({ + code: ErrorCodes.REQUEST_INVALID, + }); + host.dispose(); + }); + + it('withdraws the commands when the provider unit dies', async () => { + const { host, handle, commands } = hostWithProvider([ + { name: 'alpha', run: () => {} }, + ]); + expect(commands.list()).toHaveLength(1); + + handle.dispose(); + await host.app.instantiation.cascade.whenIdle(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(commands.list()).toHaveLength(0); + host.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts index c016a62bdb3..c96e90900b3 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -139,7 +139,6 @@ describe('reduceContextTranscript', () => { time: 220, }, { type: 'context.append_loop_event', event: { type: 'step.end', uuid: 'st1' }, time: 230 }, - // No record time → undefined (falls back to session createdAt + index). { type: 'context.append_message', message: userMessage('u2') }, ]); 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 8941c81314c..76eabe6999b 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -1056,9 +1056,6 @@ describe('FullCompaction', () => { it('attributes compaction_failed to the in-flight request trace on a mid-stream failure', async () => { const records: TelemetryRecord[] = []; - // The stream delivers response headers (trace id) and one part, then fails - // — the error itself carries no trace, so attribution must come from the - // trace captured when the headers arrived. const generate = realKosongGenerate(() => { const base = mockStreamedMessage([], 'trace-mid-stream'); return { @@ -1147,7 +1144,6 @@ describe('FullCompaction', () => { const generate: GenerateFn = async (_chat, _systemPrompt, _tools, _history, _callbacks, options) => { signal = options?.signal; started.resolve(); - // Never settles — the compaction stays in flight until disposed. return new Promise(() => {}); }; const ctx = testAgent({ generate }); @@ -1912,10 +1908,6 @@ describe('FullCompaction', () => { it('does not trigger auto compaction from a deferred loaded MCP schema', async () => { vi.stubEnv(MASTER_ENV, '1'); 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: { @@ -2315,9 +2307,6 @@ describe('FullCompaction', () => { }, tools: SNAPSHOT_VISIBLE_TOOLS, }); - // 160k sits between the input-cap trigger (150k × 0.85 = 127.5k) and the - // total-window trigger (200k × 0.85 = 170k): compaction must fire only - // because the input cap is the prompt budget. ctx.appendExchange(1, 'old user one', 'old assistant one', 160_000); ctx.newEvents(); @@ -2522,8 +2511,6 @@ describe('FullCompaction', () => { it('preserves thinking effort when compacting after provider context overflow', async () => { let callCount = 0; const records: TelemetryRecord[] = []; - // The per-turn thinking intent captured from each generate call — the - // replacement for the morph-era provider `thinkingEffort` field. const thinkingEfforts: unknown[] = []; const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks, options) => { callCount += 1; @@ -2739,8 +2726,6 @@ describe('FullCompaction', () => { ...models![CATALOGUED_PROVIDER.model]!, maxOutputSize: 64_000, }; - // The config was mutated behind the services' backs — drop the assembled - // Model cache by hand or the request keeps the previous maxOutputSize. ctx.notifyModelConfigChanged(); ctx.appendExchange(1, 'old user one', 'old assistant one', 20); ctx.newEvents(); @@ -3013,9 +2998,6 @@ function oauthTestAgentOptions( }, }, services: appServices((reg) => { - // The catalog's OAuth port is `IModelOAuthTokens` (the app/kosongConfig - // adapter delegates it to IOAuthService in production); stub the port - // directly, mirroring the adapter's force-flag normalization. reg.defineInstance(IModelOAuthTokens, { _serviceBrand: undefined, hasCachedAccessToken: () => Promise.resolve(true), diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 571aaf31c14..07232ac97ac 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -64,9 +64,6 @@ import { stubLoopWithHooks, type StubLoop } from '../loop/stubs'; import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../toolExecutor/stubs'; import { stubAgentSwarm } from './stubs'; -// The real AgentSwarmService self-wires executor listeners and pulls in the -// swarm runtime; goal tests never exercise swarm behavior, so every test -// agent here stubs it out to keep the wiring focused on the goal domain. function createTestAgent( ...inputs: readonly (TestAgentServiceOverride | TestAgentOptions)[] ): TestAgentContext { diff --git a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts index 3cf96af16a3..d5379b463c7 100644 --- a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts +++ b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts @@ -335,17 +335,10 @@ describe('GoalInjection integration', () => { await toolCallEvents; await ctx.untilTurnEnd(); - // Goal reminders persist asynchronously and the relative order of the - // async injection providers is not contractual, so wait for the - // continuation turn's reminder to land instead of asserting at a fixed, - // ordering-sensitive flush point. await vi.waitFor(async () => { expect(await flushedGoalReminderRecords(ctx, persistence)).toHaveLength(2); }); - // One reminder per turn boundary (two boundaries here), not per step: - // the count settles at exactly two even though the turns ran multiple - // steps. expect(await flushedGoalReminderRecords(ctx, persistence)).toHaveLength(2); }); diff --git a/packages/agent-core-v2/test/agent/goal/stubs.ts b/packages/agent-core-v2/test/agent/goal/stubs.ts index 5f85ebbcec8..4dd1e67808b 100644 --- a/packages/agent-core-v2/test/agent/goal/stubs.ts +++ b/packages/agent-core-v2/test/agent/goal/stubs.ts @@ -4,15 +4,6 @@ import type { IAgentSwarmService } from '#/agent/swarm/swarm'; -/** - * Inert stand-in for `IAgentSwarmService`. - * - * Goal tests never exercise swarm behavior, but the test-agent harness - * instantiates every contributed tool, and `AgentSwarmTool` injects the real - * `AgentSwarmService` — which self-wires executor veto listeners and pulls - * in the swarm runtime. Stubbing the service keeps goal tests focused on - * goal wiring. - */ export function stubAgentSwarm(): IAgentSwarmService { return { _serviceBrand: undefined, diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts index e50876301cf..e0e592504e1 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts @@ -141,8 +141,6 @@ describe('LLMRequester service migration coverage', () => { expect(requests).toHaveLength(2); expect(requests[0]?.args).toMatchObject({ kind: 'loop', - // The durable record's `provider` field carries the wire protocol: - // Kimi is a vendor over the openai base, not a protocol. provider: 'openai', model: 'mock-model', modelAlias: 'mock-model', @@ -407,8 +405,6 @@ describe('LLMRequester service migration coverage', () => { agent_id: 'main', model: 'mock-model', alias: 'mock-model', - // vendor and wire protocol are separate fields now: the mock - // provider is the kimi vendor over the openai base. provider_type: 'kimi', protocol: 'openai', retryable: expect.any(Boolean), @@ -469,8 +465,6 @@ describe('LLMRequester service migration coverage', () => { logEntries = entries; ctx = createTestAgent( llmGenerateServices(async (_provider, _systemPrompt, _tools, _messages, callbacks, options) => { - // The per-turn completion budget arrives as a GenerateOptions - // intent (the morph-era baked `modelParameters.max_tokens` is gone). requestMaxTokens = options?.maxCompletionTokens; options?.onRequestStart?.(); await callbacks?.onMessagePart?.({ type: 'text', text: 'timed' }); @@ -635,10 +629,6 @@ describe('LLMRequester service migration coverage', () => { }); it('forwards the session id as the per-turn cache-key intent', async () => { - // The engine half of the cache-key probe: the same session's id reaches - // the composed provider as GenerateOptions.cacheKey. How each dialect - // encodes it (Kimi `prompt_cache_key`, Anthropic `metadata.user_id`) is - // asserted at the kosong/provider composition layer. await llmRequester.request(); expect(capturedCacheKey).toBe('test-session'); diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index 38c833aa817..a58079cc877 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -751,9 +751,6 @@ describe('AgentLLMRequesterService trace id', () => { }); it('keeps the header-captured trace when the request fails after headers arrived', async () => { - // A failure after the response headers arrived (empty response, mid-stream - // decode error) carries no trace on the error itself; the trace captured - // through the provider callback must remain on the request trace. const requester = createTracedRequester(null); Object.defineProperty(requester, 'request', { value: async function* (...args: unknown[]) { diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 16d7be35f3c..6ed1e1c4f6c 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -709,8 +709,6 @@ describe('Agent loop', () => { ctx.mockNextResponse({ type: 'text', text: 'continued' }); ctx.mockNextResponse({ type: 'text', text: 'hi there' }); - // A goal-continuation turn carries internal steering text as its input — - // the turn boundary must land without a prompt. const system = ( await loop.enqueue( new MessageStepRequest( @@ -1152,7 +1150,7 @@ describe('interruption reminder', () => { await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); await ctx.untilTurnEnd(); subscription.dispose(); - ctx.llmInputs(); // drain the interrupted turn's request + ctx.llmInputs(); ctx.mockNextResponse({ type: 'text', text: 'second answer' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] }); diff --git a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts index 7754432d5ed..76b0cd2e8c8 100644 --- a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts @@ -502,10 +502,6 @@ describe('AgentMcpService', () => { createService(manager); manager.connect('s'); - // The connection drops while no call is in flight: the manager marks the - // server failed. The tools must stay registered so the next call reaches - // the adapter and its reconnect-and-retry path instead of failing with - // "tool not found". manager.fail('s'); const echo = ix.get(IAgentToolRegistryService).resolve('mcp__s__echo'); @@ -572,8 +568,6 @@ describe('AgentMcpService', () => { signal: new AbortController().signal, }), ).rejects.toThrow('Connection closed'); - // The tools stay registered after the failed reconnect so a later call - // can try healing the server again instead of hitting "tool not found". expect(ix.get(IAgentToolRegistryService).list().filter((tool) => tool.source === 'mcp')).toHaveLength(2); }); @@ -755,9 +749,6 @@ describe('AgentMcpService', () => { const registry = ix.get(IAgentToolRegistryService); const staleEcho = registry.resolve('mcp__s__echo'); - // Resolve the stale tool first, then heal the server the way a parallel - // call's reconnect would: the resolved entry swaps to a fresh client and - // the registry re-seeds, leaving `staleEcho` bound to the dead client. manager.setResolved('s', freshClient, await discoverTools(freshClient)); manager.connect('s'); diff --git a/packages/agent-core-v2/test/agent/media/videoResolver.test.ts b/packages/agent-core-v2/test/agent/media/videoResolver.test.ts index 9d66d2d8070..ded03736de7 100644 --- a/packages/agent-core-v2/test/agent/media/videoResolver.test.ts +++ b/packages/agent-core-v2/test/agent/media/videoResolver.test.ts @@ -222,8 +222,6 @@ describe('AgentVideoResolverService', () => { it('rethrows a cancelled upload without memoizing the fallback', async () => { const controller = new AbortController(); - // The rejection is deliberately NOT abort-shaped: the aborted signal alone - // must decide cancellation, since abort error shapes vary by provider. const interrupted = vi.fn(async () => { controller.abort(); throw new Error('socket closed'); diff --git a/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts b/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts index d4c979f5231..0cf2f1b8e9b 100644 --- a/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts +++ b/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts @@ -265,7 +265,6 @@ describe('AgentProfileService.applyProfile', () => { expect(svc.data().systemPrompt).toContain(''); expect(svc.data().systemPrompt).not.toContain(''); - // A reload-driven re-render applies the budget again but does not warn twice. sections.value = [...sections.value, { pluginId: 'third', content: 'small' }]; change.fire(PLUGIN_SKILL_SOURCE_ID); await vi.waitFor(() => { diff --git a/packages/agent-core-v2/test/agent/profile/binding.test.ts b/packages/agent-core-v2/test/agent/profile/binding.test.ts index 63f00d9a334..72acf0a6247 100644 --- a/packages/agent-core-v2/test/agent/profile/binding.test.ts +++ b/packages/agent-core-v2/test/agent/profile/binding.test.ts @@ -5,10 +5,14 @@ import { join, normalize } from 'pathe'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { Event } from '#/_base/event'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; import { ConfigTarget, IConfigService } from '#/app/config/config'; import { TOOLS_SECTION } from '#/agent/toolPolicy/configSection'; -import { DEFAULT_AGENT_PROFILE_NAME, normalizeAgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; -import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; +import { + DEFAULT_AGENT_PROFILE_NAME, + normalizeAgentProfile, +} from '#/app/agentProfileCatalog/agentProfileCatalog'; import { BuiltinAgentProfileLoaderService } from '#/app/agentProfileCatalog/builtinAgentProfileLoaderService'; import { registerAgentProfile } from '#/app/agentProfileCatalog/contribution'; import type { ToolCall } from '#/kosong/contract/message'; @@ -102,9 +106,11 @@ describe('AgentProfileService.bind', () => { it('binds a profile + model atomically and becomes runnable', async () => { const { profile: svc } = buildContext(); - const catalog = new BuiltinAgentProfileLoaderService(new AgentProfileRegistryService()); + const container = new InstantiationService(new ServiceCollection(), true); + const catalog = new BuiltinAgentProfileLoaderService(container); expect(catalog.get(DEFAULT_AGENT_PROFILE_NAME)).toBeDefined(); catalog.dispose(); + container.dispose(); expect(svc.isRunnable()).toBe(false); @@ -328,7 +334,6 @@ describe('AgentProfileService.bind', () => { }), ).rejects.toThrow(/not supported by model/); - // The failed bind must leave the agent unbound — a retry can still bind. expect(svc.data().profileName).toBeUndefined(); await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: 'kimi-code/kimi-for-coding' }); expect(svc.data().profileName).toBe(DEFAULT_AGENT_PROFILE_NAME); @@ -356,8 +361,6 @@ describe('AgentProfileService.bind', () => { ); const svc = ctx.get(IAgentProfileService); - // Spawn paths pass inherited (possibly drifted) thinking without - // strictThinking: the bind must succeed and clamp to a supported effort. await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: 'kimi-code/kimi-for-coding', @@ -384,17 +387,12 @@ describe('AgentProfileService.bind', () => { await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL, thinking: 'off' }); expect(svc.data().thinkingLevel).toBe('off'); - // A same-name rebind without an explicit thinking override must not reset - // the persisted effort to the configured/model default ('on' here). await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL }); expect(svc.data().thinkingLevel).toBe('off'); }); }); describe('AgentToolPolicyService tool denylist', () => { - // Registration is idempotent (replace-by-name) and scoped to this describe's - // run window — module-scope registration would also pollute the bind - // describe above at collection time. beforeAll(() => { registerAgentProfile({ name: 'deny-builtin', @@ -492,9 +490,6 @@ describe('AgentToolPolicyService tool denylist', () => { await ctx.get(IWireService).flush(); await ctx.dispose(); - // Resume by replaying the same records, with a catalog that cannot resolve - // the bound profile (e.g. its agent file was deleted): the denylist must - // come from the persisted record, not from a catalog lookup. const emptyCatalog = { _serviceBrand: undefined, ready: Promise.resolve(), @@ -580,11 +575,8 @@ describe('AgentToolPolicyService global [tools] config', () => { it('intersects the global config with the profile policy instead of overriding it', async () => { const svc = await bindWithToolsConfig({ enabled: ['Read', 'Bash'] }, 'config-intersect'); - // Allowed by both layers. expect(svc.isToolActive('Read')).toBe(true); - // The global allowlist cannot re-enable a tool the profile itself denies. expect(svc.isToolActive('Bash')).toBe(false); - // Absent from the profile allowlist even though the global one admits it. expect(svc.isToolActive('Write')).toBe(false); }); }); @@ -663,9 +655,6 @@ describe('AgentToolPolicyService.setSessionDisabledTools', () => { await ctx.get(IWireService).flush(); await ctx.dispose(); - // Resume by replaying the same records, with a catalog that cannot resolve - // the bound profile: the session denylist must come from the persisted - // record, not from a catalog lookup. const emptyCatalog = { _serviceBrand: undefined, ready: Promise.resolve(), @@ -877,9 +866,6 @@ describe('AgentToolPolicyService executor enforcement', () => { expect(probe.calls).toBe(0); }); - // Phase-4 behavior contract: the workspace (os-level) veto — seeded as - // `ISessionToolPolicyGate` — blocks direct execution just like the classic - // layers, and it wins over every one of them. it('blocks a direct builtin call through the workspace tool-policy gate', async () => { ctx = createTestAgent( hostEnvironmentServices(homeDir), @@ -905,9 +891,6 @@ describe('AgentToolPolicyService executor enforcement', () => { expect(probe.calls).toBe(0); }); - // The prompt projection goes through the same workspace veto: a profile - // whose prompt renders `skillActive` must see the Skill tool as inactive - // when the gate disables it (profileService's `isToolActiveForProfile`). it('applies the workspace gate in the prompt projection (skillActive)', async () => { registerAgentProfile({ name: 'gate-skill-active', @@ -930,9 +913,6 @@ describe('AgentToolPolicyService executor enforcement', () => { it('does not reject select_tools, the policy-gated disclosure loading entry', async () => { ctx = createTestAgent(hostEnvironmentServices(homeDir)); - // The default profile's allowlist does not name select_tools; the guard - // must still let the disclosure entry point through (its loadable set is - // policy-filtered downstream). await ctx.get(IAgentProfileService).bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL }); const probe = new PolicyProbeTool(SELECT_TOOLS_TOOL_NAME); ctx.get(IAgentToolRegistryService).register(probe); @@ -1007,9 +987,6 @@ describe('AgentProfileService tool-pattern warnings', () => { .filter((args) => args.code === 'tool-pattern-no-match'); } - // A file-defined agent, as far as the warning path is concerned: inline so - // its typo stays out of the builtin-profile known-name vocabulary (a - // registerAgentProfile contribution would legitimize its own entries). const fileProfile: ResolvedAgentProfile = normalizeAgentProfile({ name: 'bad-patterns', tools: ['Bashh', 'mcp__github'], diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index 510681f7a3f..0f198a4af57 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -226,8 +226,6 @@ describe('ConfigState model capabilities', () => { }, }; generate = async (_provider, _systemPrompt, _tools, _history, _callbacks, options) => { - // The per-turn completion budget arrives as a GenerateOptions intent - // (the morph-era baked `modelParameters.max_tokens` is gone). requestMaxTokens = options?.maxCompletionTokens; return { id: 'response-1', @@ -289,8 +287,6 @@ describe('ConfigState prompt cache hint', () => { it('uses session id as a provider prompt cache hint without storing it on Agent', () => { profile.update({ modelAlias: 'kimi-code' }); - // Kimi is no longer a protocol: the vendor resolves to its `openai` base - // while keeping `kimi` as the provider type. const model = ctx.modelResolver.get('kimi-code'); expect(model.protocol).toBe('openai'); expect(model.providerType).toBe('kimi'); @@ -353,8 +349,6 @@ describe('ConfigState thinking clamp for always-thinking models', () => { ctx = createTestAgent( configServices(() => kimiConfig), llmGenerateServices(async (_provider, _systemPrompt, _tools, _history, _callbacks, options) => { - // The per-turn thinking intent (effort + keep) — the replacement for - // the morph-era baked `_generationKwargs.extra_body.thinking`. capturedThinking = options?.thinking; return { id: 'response-1', @@ -388,9 +382,6 @@ describe('ConfigState thinking clamp for always-thinking models', () => { await requester.request({}, undefined, new AbortController().signal); - // The always-thinking clamp turns 'off' into the model default ('high'); - // encoding it as `extra_body.thinking: {type:'enabled'}` is the Kimi - // dialect trait's job (`kimiOpenAITrait.withThinking`). expect(capturedThinking).toMatchObject({ effort: 'high' }); }); @@ -478,10 +469,6 @@ describe('ConfigState thinking clamp for always-thinking models', () => { }); it('clamps off to the model default for always-on models, on any transport', () => { - // A model declared always-on never resolves to off: the clamp turns the - // request into the model default ('max') instead of sending a dishonest - // off upstream. (The always-on warning path remains as a defensive layer - // for off values that bypass resolution.) profile.update({ modelAlias: 'kimi-code/compatible', thinkingLevel: 'max' }); expect(() => { @@ -558,10 +545,6 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = profile.update({ modelAlias: 'kimi-code' }); await requester.request({}, undefined, new AbortController().signal); - // The env override lands in `modelOverrides.temperature`, which the - // profile folds into the dialect-free sampling intent (the morph-era - // baked `_generationKwargs.temperature` is gone); the Kimi dialect encodes - // it as the wire `temperature` field. expect(capturedOptions?.sampling).toMatchObject({ temperature: 0.3, }); @@ -574,8 +557,6 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = profile.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' }); await requester.request({}, undefined, new AbortController().signal); - // The model is boolean-thinking (no supportEfforts), so 'high' resolves - // to 'on'; the env keep override rides the same thinking intent. expect(capturedOptions?.thinking).toMatchObject({ effort: 'on', keep: 'all' }); }); @@ -609,10 +590,6 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = await requester.request({}, undefined, new AbortController().signal); - // The harness composes the real provider for a registered vendor: a Kimi - // model on the Anthropic transport resolves to the anthropic base, and - // the forced effort arrives as the per-turn thinking intent (the - // morph-era baked `thinkingEffort` on the provider is gone). expect(capturedProvider).toMatchObject({ name: 'anthropic' }); expect(capturedOptions?.thinking?.effort).toBe('max'); }); diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index c93cc45308c..496d1bb6501 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -37,8 +37,6 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo import { IWireService } from '#/wire/wire'; import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; -// Side-effect registration: `drivesThinkingThroughTraits('kimi')` (used by -// the forced-effort override) answers through the provider-definition registry. import '#/kosong/provider/providers/kimi/kimi.contrib'; import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs'; @@ -62,12 +60,6 @@ function createConfigStub(): IConfigService { } as unknown as IConfigService; } -/** - * The pure-data Model the kosong catalog hands out. No morphs: per-turn - * intent (cache key / sampling / thinking effort+keep) now surfaces through - * `IAgentProfileService.resolveRequestParams()` instead of `with*` call - * records on a recording Model stub. - */ function createTestModel( options: { readonly id?: string; @@ -134,13 +126,6 @@ function createModelCatalogStub(models: Readonly> = {}): I }; } -/** - * The one registry answer the profile reads: whether the (protocol, - * providerType) pair drives thinking through traits, and whether that driver - * demands strict effort validation (`strictThinkingValidation`). Mirrored - * here from the real Kimi definitions: strict on the native openai - * transport, lenient over anthropic, nothing on other protocols. - */ function createProtocolRegistryStub(): IProtocolAdapterRegistry { return { _serviceBrand: undefined, @@ -543,9 +528,6 @@ describe('AgentProfileService (wire-backed config.update)', () => { host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' }); - // The morph chain's replacement: the profile's dialect-free per-turn - // intent. Wire encoding (`extra_body.thinking.keep`) is the Kimi dialect's - // own hook now. expect(host.svc.resolveRequestParams()).toEqual({ cacheKey: 'session-test', sampling: { temperature: 0.3 }, @@ -621,8 +603,6 @@ describe('AgentProfileService (wire-backed config.update)', () => { host.svc.update({ modelAlias: 'claude-code', thinkingLevel: 'high' }); - // The intent is dialect-free now; how a cache key reaches the Anthropic - // wire (`metadata.user_id`) is the dialect's own hook. expect(host.svc.resolveRequestParams()).toEqual({ cacheKey: 'session-test', sampling: { temperature: 0.3 }, @@ -641,10 +621,6 @@ describe('AgentProfileService (wire-backed config.update)', () => { host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' }); - // "Without Kimi generation kwargs" is no longer decidable at the profile: - // the durable record in `llmRequester.recordRequest` carries the - // thinking/sampling knobs unconditionally, and the Anthropic dialect - // encodes the thinking intent itself. expect(host.svc.resolveModelContext().thinkingLevel).toBe('max'); expect(host.svc.resolveRequestParams()).toEqual({ cacheKey: 'session-test', @@ -747,10 +723,6 @@ describe('AgentProfileService (wire-backed config.update)', () => { host.svc.update({ modelAlias: 'claude-sonnet', thinkingLevel: 'high' }); - // The cache-key intent is dialect-free now: the profile resolves it for - // every protocol. How each dialect encodes it (Kimi `prompt_cache_key` - // vs Anthropic `metadata.user_id` vs silently dropped) is the dialect - // hook's own decision, asserted at the kosong/provider composition layer. expect(host.svc.resolveRequestParams().cacheKey).toBe('session-test'); }); }); diff --git a/packages/agent-core-v2/test/agent/profile/thinking.test.ts b/packages/agent-core-v2/test/agent/profile/thinking.test.ts index 2eec72bf4d9..e5e063c942b 100644 --- a/packages/agent-core-v2/test/agent/profile/thinking.test.ts +++ b/packages/agent-core-v2/test/agent/profile/thinking.test.ts @@ -7,12 +7,6 @@ import { resolveThinkingEffortForModel, } from '#/kosong/model/thinking'; -// The old `#/agent/profile/thinking` helpers derived "Kimi thinking -// semantics" from `protocol: 'kimi'` on the model fixture. The kosong layer -// has no Kimi protocol (Kimi is a set of `(baseProtocol, traits)` -// registrations, so fixtures use `providerType: 'kimi'` + a legal protocol); -// the semantics verdict is now an explicit `strictValidation` argument -// resolved by the caller through the adapter registry. const booleanModel = { capabilities: ['thinking'] }; const effortModel = { capabilities: ['thinking'], @@ -38,9 +32,6 @@ const alwaysThinkingEffortModel = { defaultEffort: 'high', }; const nonThinkingModel = { capabilities: ['tool_use'] }; -// Named fixtures for the call sites below: inline literals would trip excess -// property checks (`ModelThinkingMetadata` carries no protocol/providerType — -// those fields only document which semantics verdict the case stands for). const alwaysThinkingAnthropicEffortModel = { ...alwaysThinkingEffortModel, protocol: 'anthropic', @@ -146,10 +137,6 @@ describe('resolveThinkingEffortForModel', () => { }); it('clamps always-thinking models to their default effort even without strict validation', () => { - // A model declared always-on never resolves to off, on any wire — claiming - // off while upstream keeps reasoning at its default would be a lie. This - // covers Kimi-managed models routed through the Anthropic transport and - // catalog-imported always-thinking models (e.g. gpt-5) alike. expect( resolveThinkingEffortForModel('off', undefined, alwaysThinkingAnthropicEffortModel), ).toBe('high'); diff --git a/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts b/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts index 07a8694d1e8..e198e620a11 100644 --- a/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts +++ b/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts @@ -133,8 +133,6 @@ describe('AgentShellCommandService', () => { await ctx.get(IAgentShellCommandService).run({ command: 'echo hi', commandId: 'cmd-9' }); - // Later events carry the task id themselves, so a consumer that missed - // shell.started can still route them. expect(events.find((e) => e.type === 'shell.output')).toMatchObject({ commandId: 'cmd-9', taskId: 'task-9', @@ -153,8 +151,6 @@ describe('AgentShellCommandService', () => { await shell.run({ command: 'false', commandId: 'cmd-3' }); const relevant = events.filter((e) => e.type === 'shell.output' || e.type === 'shell.completed'); - // The failure text was never streamed live — it rides a final output - // chunk ahead of the completion event. expect(relevant[0]).toMatchObject({ type: 'shell.output', commandId: 'cmd-3' }); expect(relevant[0]?.update?.text?.length).toBeGreaterThan(0); expect(relevant.at(-1)).toMatchObject({ type: 'shell.completed', commandId: 'cmd-3' }); diff --git a/packages/agent-core-v2/test/agent/state/agentState.test.ts b/packages/agent-core-v2/test/agent/state/agentState.test.ts index 492dc1035aa..db3216a1554 100644 --- a/packages/agent-core-v2/test/agent/state/agentState.test.ts +++ b/packages/agent-core-v2/test/agent/state/agentState.test.ts @@ -19,7 +19,6 @@ describe('agent state snapshot (full agent scope)', () => { const snapshot = states.snapshot(); expect(Object.keys(snapshot).toSorted()).toEqual(registered.toSorted()); - // The whole snapshot must be JSON-serializable and stay small. const json = JSON.stringify(snapshot); expect(json.length).toBeLessThan(5 * 1024 * 1024); }); 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 14872ee0728..659065f2225 100644 --- a/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts +++ b/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts @@ -13,7 +13,6 @@ 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', () => { @@ -37,12 +36,6 @@ describe('stepRetry plugin', () => { const loop = ctx.get(IAgentLoopService); loop.enqueue(new ContinuationStepRequest()); const resultPromise = loop.run({ turnId, signal }); - // Scope creation activates the registered OnScopeCreated services, 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( () => { diff --git a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts index c6b7d1709a4..7eec9fcbac6 100644 --- a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts @@ -183,8 +183,6 @@ describe('AgentSwarmService', () => { run: async () => [], cancel: () => {}, }); - // A stand-in listener registered after the swarm listener proves whether - // the swarm-exclusive veto ended adjudication or abstained. executorEvents = stubToolExecutorEvents(); permissionGateRan = false; ix.stub(IAgentToolExecutorService, executorEvents.executor); diff --git a/packages/agent-core-v2/test/agent/task/idle-notification-repro.test.ts b/packages/agent-core-v2/test/agent/task/idle-notification-repro.test.ts index cc2a1d857ef..479abbdf61e 100644 --- a/packages/agent-core-v2/test/agent/task/idle-notification-repro.test.ts +++ b/packages/agent-core-v2/test/agent/task/idle-notification-repro.test.ts @@ -23,8 +23,8 @@ import { tmpdir } from 'node:os'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { LifecycleScope, type IAgentScopeHandle } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { type IAgentScopeHandle } from '#/_base/di/scope'; import type { generate as kosongGenerate } from '#/kosong/contract/generate'; import { IAgentTaskService } from '#/agent/task/task'; import { SubagentTask } from '#/agent/tools/agent/subagent-task'; @@ -253,18 +253,7 @@ describe('task notification → main agent (real Agent instance)', () => { } as IAgentScopeHandle; } - // Regression for: "manual stop of a background subagent → main - // auto-resumes → resume fails with 'already running and cannot run - // concurrently'". The killed task used to settle (and notify) the - // moment the abort landed — while the child loop was still unwinding, - // so the resume guard (`ensureOwnedIdleSubagent`, which reads - // `loop.status().state`) rejected the auto-resume. Settlement must - // wait for the loop to go idle. A turn that ignores the cancel stays - // bounded by the task layer's SIGTERM grace instead. it('stop settles killed + notifies only after the child loop goes idle', async () => { - // Child agent whose in-flight LLM call unwinds slowly after cancel - // (models a tool mid-execution / a slow request abort): it rejects - // 200ms after the abort lands, not immediately. let generateStarted!: () => void; const inFlight = new Promise((resolve) => { generateStarted = resolve; @@ -300,20 +289,15 @@ describe('task notification → main agent (real Agent instance)', () => { const childHandle = agentScopeHandle(child, 'agent-child'); const childLoop = child.get(IAgentLoopService); - // Launch the subagent run (what AgentTool.launch does). const controller = new AbortController(); const run = await runAgentTurn( childHandle, { kind: 'prompt', prompt: 'do background work' }, { signal: controller.signal }, ); - // Mirror AgentTool.launch: the task handle maps summary → result. const completion = run.completion.then((r) => ({ result: r.summary, usage: r.usage })); void completion.catch(() => {}); - // Wait until the in-flight step is genuinely parked inside the LLM - // call — the loop reports 'running' before the request starts, and - // stopping that early takes a different (already-fast) path. await inFlight; expect(childLoop.status().state).toBe('running'); @@ -327,20 +311,13 @@ describe('task notification → main agent (real Agent instance)', () => { { detached: true, timeoutMs: 0 }, ); - // The main agent is idle; the killed notification auto-launches a turn. main.mockNextResponse({ type: 'text', text: 'ack from main agent' }); const notificationTurnEnd = main.untilTurnEnd(); - // Manual stop (TUI / REST path — no notification suppression). const info = await background.stop(taskId, 'User initiated stop'); expect(info?.status).toBe('killed'); - // Settlement waited for the child loop to unwind — this is the - // assertion the old race-based implementation fails. expect(childLoop.status().state).toBe('idle'); - // The task.killed notification reaches the main agent (this is what - // makes main call Agent(resume="agent-child")), and by then the - // resume guard's precondition already holds. await vi.waitFor( () => { expect(main.llmCalls.length).toBeGreaterThanOrEqual(1); diff --git a/packages/agent-core-v2/test/agent/task/taskManager.test.ts b/packages/agent-core-v2/test/agent/task/taskManager.test.ts index 09d77da8036..3107a361371 100644 --- a/packages/agent-core-v2/test/agent/task/taskManager.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskManager.test.ts @@ -1282,10 +1282,6 @@ describe('AgentTaskService', () => { expect(manager.getTask('bash-bogusss0')).toBeUndefined(); expect(await persistence!.listTasks()).toEqual([]); - // The session scope's initial metadata write is kicked at creation but - // not awaited by the (synchronous) harness; settle it before the - // cleanup below removes the home dir, the same way session - // materialization awaits metadata readiness in production. await ctx.get(ISessionMetadata).ready; } finally { await rm(sessionDir, { recursive: true, force: true }); diff --git a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts index 68ec8ae1713..ea7f41e7f5a 100644 --- a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts +++ b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts @@ -1,11 +1,17 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DisposableStore, toDisposable } from '#/_base/di/lifecycle'; +import { type CollectionView } from '#/_base/di/collection'; +import { SyncDescriptor } from '#/_base/di/descriptors'; import { createDecorator } from '#/_base/di/instantiation'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, + ScopeActivation, _clearScopedRegistryForTests, createAppScope, + registerScopedService, + type ScopeSeed, } from '#/_base/di/scope'; import { createServices } from '#/_base/di/test'; import { IEventBus } from '#/app/event/eventBus'; @@ -13,16 +19,41 @@ import { Event } from '#/_base/event'; import { IAgentProfileService, type ProfileData } from '#/agent/profile/profile'; import { IAgentToolActivationService } from '#/agent/toolActivation/toolActivation'; import { AgentToolActivationService } from '#/agent/toolActivation/toolActivationService'; +import { + BuiltinToolAssemblyService, + IBuiltinToolAssemblyService, +} from '#/agent/toolRegistry/builtinToolAssemblyService'; import { _clearAgentToolContributionsForTests, + AgentToolContribution, getAgentToolContributions, registerAgentToolService, - type AgentToolContribution, } from '#/agent/toolRegistry/toolContribution'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import type { AgentTool, ToolExecution } from '#/tool/toolContract'; +import '#/agent/tools/agent-swarm/agentSwarmTool'; +import '#/agent/tools/agent/agentTool'; +import '#/agent/tools/ask-user-question/askUserQuestionTool'; +import '#/agent/tools/edit/editTool'; +import '#/agent/tools/fetch-url/fetchUrlTool'; +import '#/agent/tools/goal/create-goal/createGoalTool'; +import '#/agent/tools/goal/get-goal/getGoalTool'; +import '#/agent/tools/goal/set-goal-budget/setGoalBudgetTool'; +import '#/agent/tools/goal/update-goal/updateGoalTool'; +import '#/agent/tools/os/bash/bashTool'; +import '#/agent/tools/os/glob/globTool'; +import '#/agent/tools/os/grep/grepTool'; +import '#/agent/tools/os/read/readTool'; +import '#/agent/tools/os/write/writeTool'; +import '#/agent/tools/select-tools/selectToolsTool'; +import '#/agent/tools/skill/skillTool'; +import '#/agent/tools/task/task-list/taskListTool'; +import '#/agent/tools/task/task-output/taskOutputTool'; +import '#/agent/tools/task/task-stop/taskStopTool'; +import '#/agent/tools/todo-list/todoListTool'; +import '#/agent/tools/web-search/webSearchTool'; class StubTool implements AgentTool { declare readonly _serviceBrand: undefined; @@ -63,6 +94,40 @@ class GammaTool extends StubTool { } } +class TestContributionAssembly extends Service { + constructor() { + super(); + for (const record of getAgentToolContributions()) { + this.provide(AgentToolContribution, record); + } + } +} + +const IDynamicToolProvider = createDecorator( + 'activationTestDynamicToolProvider', +); +class DynamicToolProvider extends Service { + declare readonly _serviceBrand: undefined; + constructor() { + super(); + this.provide(AgentToolContribution, { + id: IGammaTool, + ctor: GammaTool, + options: { name: 'Gamma' }, + }); + } +} + +const ICollectionProbe = createDecorator('activationTestCollectionProbe'); +class CollectionProbe extends Service { + declare readonly _serviceBrand: undefined; + constructor( + @AgentToolContribution readonly view: CollectionView, + ) { + super(); + } +} + describe('AgentToolActivationService', () => { let savedContributions: readonly AgentToolContribution[]; let disposables: DisposableStore; @@ -74,7 +139,7 @@ describe('AgentToolActivationService', () => { function createActivationHost() { disposables = new DisposableStore(); - return createServices(disposables, { + const ix = createServices(disposables, { strict: true, additionalServices: (reg) => { reg.definePartialInstance(IAgentProfileService, { @@ -97,6 +162,8 @@ describe('AgentToolActivationService', () => { reg.define(IGammaTool, GammaTool); }, }); + disposables.add(ix.createInstance(TestContributionAssembly)); + return ix; } beforeEach(() => { @@ -184,9 +251,6 @@ describe('AgentToolActivationService', () => { expect(gammaConstructions).toBe(0); }); - // Phase-4 behavior contract: the workspace (os-level) veto outranks the - // profile — a workspace-disabled tool never activates, so it never lands - // in `registry.list()` (and therefore never reaches the model's schema). it('honors the workspace tool-policy veto before the profile', async () => { gateData.disabledTools = ['Beta']; registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' }); @@ -235,4 +299,139 @@ describe('AgentToolActivationService', () => { expect(registry.resolve('Alpha')).toBe(alpha); expect(registry.resolve('Beta')).toBeInstanceOf(BetaTool); }); + + describe('collection fold (scoped tree)', () => { + beforeEach(() => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.App, + IBuiltinToolAssemblyService, + BuiltinToolAssemblyService, + ScopeActivation.OnScopeCreated, + 'toolRegistry', + ); + registerScopedService( + LifecycleScope.App, + ICollectionProbe, + CollectionProbe, + ScopeActivation.OnDemand, + 'toolActivation', + ); + registerScopedService( + LifecycleScope.Agent, + IAgentToolRegistryService, + AgentToolRegistryService, + ScopeActivation.OnScopeCreated, + 'toolRegistry', + ); + registerScopedService( + LifecycleScope.Agent, + IAgentToolActivationService, + AgentToolActivationService, + ScopeActivation.OnScopeCreated, + 'toolActivation', + ); + registerScopedService( + LifecycleScope.Agent, + IDynamicToolProvider, + DynamicToolProvider, + ScopeActivation.OnDemand, + 'toolActivation', + ); + }); + + function agentSeeds(extra: ScopeSeed = []): ScopeSeed { + return [ + [IAgentProfileService, { data: () => profileData as ProfileData }], + [IEventBus, { subscribe: () => toDisposable(() => {}) }], + ...extra, + ]; + } + + function createScopeTree(agentExtra: ScopeSeed = []) { + const app = createAppScope(); + const session = app.createChild(LifecycleScope.Session, 'session', { + extra: [ + [ + ISessionToolPolicyGate, + { + _serviceBrand: undefined, + get disabledTools() { + return gateData.disabledTools; + }, + onDidChange: Event.None as Event, + } satisfies ISessionToolPolicyGate, + ], + ], + }); + const agent = session.createChild(LifecycleScope.Agent, 'agent', { + extra: agentSeeds(agentExtra), + }); + return { app, session, agent }; + } + + it('activates the built-in records provided once at App scope, in every agent scope', async () => { + registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' }); + registerAgentToolService(IBetaTool, BetaTool, { name: 'Beta' }); + const { app, session, agent } = createScopeTree(); + expect(alphaConstructions).toBe(0); + + await agent.accessor.get(IAgentToolActivationService).activate(); + const registry = agent.accessor.get(IAgentToolRegistryService); + expect(registry.resolve('Alpha')).toBeInstanceOf(AlphaTool); + expect(registry.resolve('Beta')).toBeInstanceOf(BetaTool); + + const agent2 = session.createChild(LifecycleScope.Agent, 'agent-2', { + extra: agentSeeds(), + }); + await agent2.accessor.get(IAgentToolActivationService).activate(); + expect(agent2.accessor.get(IAgentToolRegistryService).resolve('Alpha')).toBeInstanceOf( + AlphaTool, + ); + app.dispose(); + }); + + it('folds a unit-provided record incrementally and withdraws it when the provider dies', async () => { + const { app, agent } = createScopeTree([[IGammaTool, new SyncDescriptor(GammaTool, [])]]); + const registry = agent.accessor.get(IAgentToolRegistryService); + const activation = agent.accessor.get(IAgentToolActivationService); + + await activation.activate(); + expect(registry.resolve('Gamma')).toBeUndefined(); + expect(gammaConstructions).toBe(0); + + const provider = agent.accessor.get(IDynamicToolProvider); + expect(registry.resolve('Gamma')).toBeInstanceOf(GammaTool); + expect(gammaConstructions).toBe(1); + + provider.dispose(); + expect(registry.resolve('Gamma')).toBeUndefined(); + await activation.activate(); + expect(registry.resolve('Gamma')).toBeUndefined(); + app.dispose(); + }); + + it('feeds every built-in contribution through the App-scope assembly unchanged', async () => { + expect(savedContributions).toHaveLength(21); + for (const contribution of savedContributions) { + registerAgentToolService(contribution.id, contribution.ctor, contribution.options); + } + profileData.activeToolNames = []; + const { app, agent } = createScopeTree(); + + const probe = app.accessor.get(ICollectionProbe); + expect(probe.view.items).toHaveLength(savedContributions.length); + const seenByName = new Map(probe.view.items.map((r) => [r.options.name, r] as const)); + for (const contribution of savedContributions) { + const seen = seenByName.get(contribution.options.name); + expect(seen?.id).toBe(contribution.id); + expect(seen?.ctor).toBe(contribution.ctor); + expect(seen?.options).toBe(contribution.options); + } + + await agent.accessor.get(IAgentToolActivationService).activate(); + expect(agent.accessor.get(IAgentToolRegistryService).list()).toHaveLength(0); + app.dispose(); + }); + }); }); diff --git a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts index 4e1c5325c6c..4aff29d17e3 100644 --- a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts +++ b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts @@ -839,9 +839,6 @@ describe('AgentToolDedupeService', () => { }); describe('preflight-rejected calls (bypass onBeforeExecuteTool)', () => { - // Calls rejected by args validation in preflight never fire - // onBeforeExecuteTool; the dedupe hook registers them late at - // onDidExecuteTool time so the repeat breaker still counts them. class StrictTool implements ExecutableTool> { readonly name = 'Strict'; readonly description = 'Requires a command string.'; @@ -865,7 +862,6 @@ describe('AgentToolDedupeService', () => { } function invalidCall(id: string): ToolCall { - // Missing the required "command". return { type: 'function', id, name: 'Strict', arguments: JSON.stringify({ timeout: 60 }) }; } @@ -902,8 +898,6 @@ describe('AgentToolDedupeService', () => { await h.executor.hooks.onDidExecuteTool.run(d); await afterStep(h, 1, i + 1); } - // Exactly one repeat at count 2 — a double registration would inflate - // the streak and fire the reminder one occurrence early. const repeats = telemetryEvents.filter((e) => e.event === 'tool_call_repeat'); expect(repeats.map((e) => e.properties?.['repeat_count'])).toEqual([2]); }); @@ -925,20 +919,16 @@ describe('AgentToolDedupeService', () => { for (let i = 0; i < 3; i += 1) { await runStep(h, 1, i + 1, [malformedCall(`c${String(i)}`, raws[i]!)]); } - // All three normalize to {} on parse failure, but the raw texts - // differ, so no repeat streak may form. expect(telemetryEvents.filter((e) => e.event === 'tool_call_repeat')).toHaveLength(0); }); }); describe('turn-level repeat breaker for rejected calls', () => { function invalidBashCallWithId(id: string): ToolCall { - // Missing the required "command". return { type: 'function', id, name: 'Bash', arguments: JSON.stringify({ timeout: 60 }) }; } function malformedBashCallWithId(id: string, variant: number): ToolCall { - // Invalid JSON (unquoted key), unique per variant. return { type: 'function', id, name: 'Bash', arguments: `{"command_${String(variant)}: "ls"` }; } @@ -960,9 +950,6 @@ describe('AgentToolDedupeService', () => { const records: TelemetryRecord[] = []; const { ctx, exec } = rejectedBashAgent(records); - // 12 identical calls missing the required "command": each is rejected - // in preflight. If the breaker did not count them, the turn would keep - // going and consume the 13th scripted response. for (let i = 0; i < 12; i += 1) { ctx.mockNextResponse(invalidBashCallWithId(`call_bad_${String(i)}`)); } @@ -983,9 +970,6 @@ describe('AgentToolDedupeService', () => { const records: TelemetryRecord[] = []; const { ctx, exec } = rejectedBashAgent(records); - // 12 rejected calls, each with DIFFERENT malformed raw JSON: all - // normalize to {} on parse failure, but they are not repeats of the - // same call, so the turn must not be force-stopped. for (let i = 0; i < 12; i += 1) { ctx.mockNextResponse(malformedBashCallWithId(`call_mal_${String(i)}`, i)); } diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index 4c9b68f4607..0f5d0a5bde0 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -805,10 +805,6 @@ describe('onBeforeExecuteTool veto semantics', () => { expect(tool.calls[0]).toEqual(expect.objectContaining({ metadata })); }); - // Regression for the ask/deny ordering bug: a deny-style veto (btw's - // deny-all) registered after an ask-style listener (permission) must win - // without the ask's Interaction ever starting — the waitUntil factory - // stays cold because the veto lands in the immediate pass. it('never invokes waitUntil factories when an immediate veto decides the call', async () => { const tool = new TestTool('echo'); registry.register(tool); diff --git a/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts b/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts index 723393adcc8..4b959ddf0dd 100644 --- a/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts +++ b/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts @@ -26,8 +26,6 @@ describe('findInactiveToolPatterns', () => { it('flags a bare * as never matching, and the evaluator agrees', () => { expect(findInactiveToolPatterns(['*'])).toEqual([{ pattern: '*', kind: 'wildcard-not-mcp' }]); - // Pin the matching semantics the warning describes: `*` in an allowlist - // disables everything, in a denylist it is a no-op. expect(isToolActive({ tools: ['*'] }, 'Read')).toBe(false); expect(isToolActive({ tools: ['*'] }, 'mcp__github__create_pr', 'mcp')).toBe(false); expect(isToolActive({ disallowedTools: ['*'] }, 'Read')).toBe(true); diff --git a/packages/agent-core-v2/test/agent/undo/undo.test.ts b/packages/agent-core-v2/test/agent/undo/undo.test.ts index 170c3b63c63..1ddc780f753 100644 --- a/packages/agent-core-v2/test/agent/undo/undo.test.ts +++ b/packages/agent-core-v2/test/agent/undo/undo.test.ts @@ -19,8 +19,8 @@ import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompacti import { IAgentLoopService } from '#/agent/loop/loop'; import { MessageStepRequest } from '#/agent/loop/stepRequest'; import { TurnModel } from '#/agent/loop/turnOps'; -import { IAgentPlanService } from '#/agent/plan/plan'; -import { PlanModel } from '#/agent/plan/planOps'; +import { IAgentPlanService } from '#/features/plan/plan'; +import { PlanModel } from '#/features/plan/planOps'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { IAgentConversationUndoService } from '#/agent/undo/undo'; import { IEventBus } from '#/app/event/eventBus'; @@ -214,8 +214,6 @@ describe('AgentConversationUndoService', () => { setup(); const undo = ctx.get(IAgentConversationUndoService); ctx.appendTurnExchange('u1', 'a1'); - // A checkpointed model that never tracks anchors (no reducers) drags the - // depth to 0 without any compaction in history. const defective = defineModel>('testDefective', () => ({ current: null, checkpoints: [], diff --git a/packages/agent-core-v2/test/app/agentIdentity/agentIdentity.test.ts b/packages/agent-core-v2/test/app/agentIdentity/agentIdentity.test.ts index e6ee7941ead..82b962f26b5 100644 --- a/packages/agent-core-v2/test/app/agentIdentity/agentIdentity.test.ts +++ b/packages/agent-core-v2/test/app/agentIdentity/agentIdentity.test.ts @@ -31,7 +31,8 @@ import { AgentIdentityService } from '#/app/agentIdentity/agentIdentityService'; import { IDENTITY_SECTION } from '#/app/agentIdentity/configSection'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; -import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { registerScopedService } from '#/_base/di/scope'; import { stubBootstrap } from '../bootstrap/stubs'; import { StubConfigService } from '../../kosong/stubs'; diff --git a/packages/agent-core-v2/test/app/agentProfileCatalog/agentProfileRegistry.test.ts b/packages/agent-core-v2/test/app/agentProfileCatalog/agentProfileRegistry.test.ts index 36665181061..7725add6dd0 100644 --- a/packages/agent-core-v2/test/app/agentProfileCatalog/agentProfileRegistry.test.ts +++ b/packages/agent-core-v2/test/app/agentProfileCatalog/agentProfileRegistry.test.ts @@ -1,46 +1,99 @@ /** - * Scenario: the App-scope agent-profile registry service. + * Scenario: the App-scope agent-profile registry fold. * - * Exercises `AgentProfileRegistryService` directly: the (sourceId, - * workspaceKey) storage-key encoding (one global entry per source id, with - * same-id workspace-local entries coexisting across handlers), the scoped - * `unregister` / dispose-handle semantics, the `entries()` metadata, and the - * decoded `onDidChange` payload. The generic contribution registry - * underneath is covered by `test/_base/contribution/registry.test.ts`; this - * suite only pins the service layer's workspaceKey dimension. Run: + * Exercises `AgentProfileRegistryService` as a fold over the + * `AgentProfileContribution` collection: records are contributed through real + * containers by contributor units (the same `this.provide` path the + * production loaders take), and the suite pins the folded read surface — the + * (sourceId, workspaceKey) pair encoding (one global entry per source id, + * with same-id workspace-local entries coexisting across handlers), + * later-record-shadows-earlier replacement, provider-death withdrawal, the + * `entries()` metadata, and the decoded `onDidChange` payload (a pair fires + * only when its winning record actually changes). Run: * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run * test/app/agentProfileCatalog/agentProfileRegistry.test.ts`. */ import { describe, expect, it } from 'vitest'; +import { createDecorator } from '#/_base/di/instantiation'; +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; +import { + AgentProfileContribution, + type AgentProfileContributionRecord, +} from '#/app/agentProfileCatalog/agentProfileContribution'; import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; import { normalizeAgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; -import type { AgentProfileContribution } from '#/app/agentProfileCatalog/agentProfileContribution'; -function contribution(marker: string): AgentProfileContribution { - return { profiles: [normalizeAgentProfile({ name: marker, systemPrompt: () => marker })] }; +interface IContributor { + readonly record: AgentProfileContributionRecord; } +const IContributor = createDecorator('test-agent-profile-contributor'); -describe('AgentProfileRegistryService', () => { - it('replaces a same-sourceId global registration', () => { - const registry = new AgentProfileRegistryService(); - registry.register('user', contribution('v1')); - registry.register('user', contribution('v2')); +class Contributor extends Service implements IContributor { + declare readonly _serviceBrand: undefined; + + constructor(readonly record: AgentProfileContributionRecord) { + super(); + this.provide(AgentProfileContribution, record); + } +} + +function record( + sourceId: string, + marker: string, + options?: { readonly priority?: number; readonly workspaceKey?: string }, +): AgentProfileContributionRecord { + return { + sourceId, + priority: options?.priority, + workspaceKey: options?.workspaceKey, + contribution: { profiles: [normalizeAgentProfile({ name: marker, systemPrompt: () => marker })] }, + }; +} + +function makeFold(): { + readonly container: InstantiationService; + readonly registry: AgentProfileRegistryService; +} { + const container = new InstantiationService(new ServiceCollection(), true); + const registry = container.createInstance(AgentProfileRegistryService); + return { container, registry }; +} + +function contribute( + container: InstantiationService, + value: AgentProfileContributionRecord, +): IDisposable { + const child = container.createChild(new ServiceCollection()) as InstantiationService; + child.provide(IContributor, new SyncDescriptor(Contributor, [value] as never)); + child.invokeFunction((accessor) => accessor.get(IContributor)); + return child; +} + +describe('AgentProfileRegistryService (collection fold)', () => { + it('lets a later record for the same pair shadow the earlier one', () => { + const { container, registry } = makeFold(); + contribute(container, record('user', 'v1')); + contribute(container, record('user', 'v2')); const entries = registry.entries(); expect(entries).toHaveLength(1); expect(entries[0]?.contribution.profiles[0]?.name).toBe('v2'); - registry.dispose(); + container.dispose(); }); - it('keeps same-sourceId entries with different workspaceKeys coexisting', () => { - const registry = new AgentProfileRegistryService(); - registry.register('workspace', contribution('global')); - registry.register('workspace', contribution('wd_a'), { workspaceKey: 'wd_a' }); - registry.register('workspace', contribution('wd_b'), { workspaceKey: 'wd_b' }); - // Re-registering one key replaces only that key's entry. - registry.register('workspace', contribution('wd_a-v2'), { workspaceKey: 'wd_a' }); + it('keeps same-sourceId records with different workspaceKeys coexisting', () => { + const { container, registry } = makeFold(); + contribute(container, record('workspace', 'global')); + const wdA = contribute(container, record('workspace', 'wd_a', { workspaceKey: 'wd_a' })); + contribute(container, record('workspace', 'wd_b', { workspaceKey: 'wd_b' })); + wdA.dispose(); + contribute(container, record('workspace', 'wd_a-v2', { workspaceKey: 'wd_a' })); const entries = registry.entries(); expect(entries).toHaveLength(3); @@ -48,48 +101,49 @@ describe('AgentProfileRegistryService', () => { expect(byKey.get(undefined)?.contribution.profiles[0]?.name).toBe('global'); expect(byKey.get('wd_a')?.contribution.profiles[0]?.name).toBe('wd_a-v2'); expect(byKey.get('wd_b')?.contribution.profiles[0]?.name).toBe('wd_b'); - registry.dispose(); + container.dispose(); }); - it('unregister(sourceId, workspaceKey) removes only the matching entry', () => { - const registry = new AgentProfileRegistryService(); - registry.register('workspace', contribution('wd_a'), { workspaceKey: 'wd_a' }); - registry.register('workspace', contribution('wd_b'), { workspaceKey: 'wd_b' }); - registry.register('user', contribution('global')); + it('withdraws only the dead provider’s record', () => { + const { container, registry } = makeFold(); + const wdA = contribute(container, record('workspace', 'wd_a', { workspaceKey: 'wd_a' })); + const wdB = contribute(container, record('workspace', 'wd_b', { workspaceKey: 'wd_b' })); + const global = contribute(container, record('user', 'global')); - registry.unregister('workspace', 'wd_a'); + wdA.dispose(); expect(registry.entries().map((entry) => entry.workspaceKey)).toEqual(['wd_b', undefined]); - registry.unregister('workspace', 'wd_b'); + wdB.dispose(); expect(registry.entries().map((entry) => entry.sourceId)).toEqual(['user']); - registry.unregister('user'); + global.dispose(); expect(registry.entries()).toHaveLength(0); - registry.dispose(); + container.dispose(); }); - it('a dispose handle removes only the entry it registered', () => { - const registry = new AgentProfileRegistryService(); - const stale = registry.register('workspace', contribution('old'), { workspaceKey: 'wd_a' }); - registry.register('workspace', contribution('new'), { workspaceKey: 'wd_a' }); + it('withdrawing a shadowed record keeps the winning entry and stays silent', () => { + const { container, registry } = makeFold(); + const stale = contribute(container, record('workspace', 'old', { workspaceKey: 'wd_a' })); + contribute(container, record('workspace', 'new', { workspaceKey: 'wd_a' })); + const seen: unknown[] = []; + const subscription = registry.onDidChange((change) => seen.push(change)); stale.dispose(); const entries = registry.entries(); expect(entries).toHaveLength(1); expect(entries[0]?.contribution.profiles[0]?.name).toBe('new'); - registry.dispose(); + expect(seen).toEqual([]); + subscription.dispose(); + container.dispose(); }); it('exposes sourceId, priority, workspaceKey, and contribution through entries()', () => { - const registry = new AgentProfileRegistryService(); - const pluginContribution = contribution('plugin-p'); - const workspaceContribution = contribution('ws-p'); - registry.register('plugin', pluginContribution, { priority: 5 }); - registry.register('workspace', workspaceContribution, { - priority: 30, - workspaceKey: 'wd_a', - }); + const { container, registry } = makeFold(); + const pluginRecord = record('plugin', 'plugin-p', { priority: 5 }); + const workspaceRecord = record('workspace', 'ws-p', { priority: 30, workspaceKey: 'wd_a' }); + contribute(container, pluginRecord); + contribute(container, workspaceRecord); const entries = registry.entries(); expect(entries).toHaveLength(2); @@ -97,30 +151,27 @@ describe('AgentProfileRegistryService', () => { sourceId: 'plugin', priority: 5, workspaceKey: undefined, - contribution: pluginContribution, + contribution: pluginRecord.contribution, }); expect(entries[1]).toEqual({ sourceId: 'workspace', priority: 30, workspaceKey: 'wd_a', - contribution: workspaceContribution, + contribution: workspaceRecord.contribution, }); - // Priority defaults to 0 when omitted. - registry.register('user', contribution('user-p')); + contribute(container, record('user', 'user-p')); expect(registry.entries()[2]?.priority).toBe(0); - registry.dispose(); + container.dispose(); }); it('fires onDidChange with the decoded { sourceId, workspaceKey } payload', () => { - const registry = new AgentProfileRegistryService(); + const { container, registry } = makeFold(); const seen: { readonly sourceId: string; readonly workspaceKey?: string }[] = []; const subscription = registry.onDidChange((change) => seen.push(change)); - registry.register('user', contribution('global')); - registry.register('workspace', contribution('wd_a'), { workspaceKey: 'wd_a' }); - registry.unregister('workspace', 'wd_a'); - // Unregistering a missing entry stays silent. - registry.unregister('workspace', 'wd_b'); + contribute(container, record('user', 'global')); + const wdA = contribute(container, record('workspace', 'wd_a', { workspaceKey: 'wd_a' })); + wdA.dispose(); expect(seen).toStrictEqual([ { sourceId: 'user', workspaceKey: undefined }, @@ -128,6 +179,21 @@ describe('AgentProfileRegistryService', () => { { sourceId: 'workspace', workspaceKey: 'wd_a' }, ]); subscription.dispose(); - registry.dispose(); + container.dispose(); + }); + + it('fires once when a record swap lands before the displaced one is withdrawn (the reload shape)', () => { + const { container, registry } = makeFold(); + const stale = contribute(container, record('user', 'v1')); + + const seen: { readonly sourceId: string; readonly workspaceKey?: string }[] = []; + const subscription = registry.onDidChange((change) => seen.push(change)); + contribute(container, record('user', 'v2')); + stale.dispose(); + + expect(registry.entries()[0]?.contribution.profiles[0]?.name).toBe('v2'); + expect(seen).toStrictEqual([{ sourceId: 'user', workspaceKey: undefined }]); + subscription.dispose(); + container.dispose(); }); }); diff --git a/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts b/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts index 80af4f513fe..0d8a2770dc4 100644 --- a/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts +++ b/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts @@ -265,8 +265,6 @@ describe('renderSystemPromptResult', () => { }); it('renders the builtin template with no leftover placeholders', () => { - // Every placeholder in the builtin template must be bound in the variable - // table — an unbound one would stay verbatim in the output. const prompt = renderSystemPromptResult( 'ROLE_TEXT', { diff --git a/packages/agent-core-v2/test/app/auth/auth.test.ts b/packages/agent-core-v2/test/app/auth/auth.test.ts index 4bad4e2ec0a..ebcf7bb71cc 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -38,8 +38,6 @@ import { IModelService, type ModelRecord } from '#/kosong/model/model'; import { MODELS_SECTION } from '#/app/kosongConfig/configSection'; import { IProviderService, type ProviderConfig, type ProvidersChangedEvent } from '#/kosong/provider/provider'; -// Side-effect registration: the OAuth-catalog verdict -// (`isOAuthCatalogProvider`) answers through the provider-definition registry. import '#/kosong/provider/providers/kimi/kimi.contrib'; import { registerBootstrapServices } from '../bootstrap/stubs'; diff --git a/packages/agent-core-v2/test/app/bashParser/bashParserService.test.ts b/packages/agent-core-v2/test/app/bashParser/bashParserService.test.ts index fae671ac7de..b61eb307569 100644 --- a/packages/agent-core-v2/test/app/bashParser/bashParserService.test.ts +++ b/packages/agent-core-v2/test/app/bashParser/bashParserService.test.ts @@ -57,10 +57,6 @@ describe('BashParserService', () => { }); it('snapshots deeply nested trees without overflowing the call stack', () => { - // A long left-associative arithmetic chain nests one binary_expression - // per operand; with a few thousand operands the tree is thousands of - // levels deep but still within budget. A recursive DTO conversion - // overflows the JS call stack here (RangeError) instead of returning. const source = `echo $((${'1+'.repeat(3000)}1))`; const result = service.parse(source, { timeoutMs: 5000 }); if (!result.ok) { 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 65d78a2f083..5e91cf44cb9 100644 --- a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts +++ b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; - -import { LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope'; import { createScopedTestHost } from '#/_base/di/test'; import { IBootstrapService, @@ -16,7 +16,6 @@ import { stubClientIdentity } from './stubs'; describe('BootstrapService (scoped)', () => { beforeEach(() => { - // Keep the registry minimal so unrelated OnScopeCreated services do not run. _clearScopedRegistryForTests(); registerScopedService( LifecycleScope.App, diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 8db191aef33..f56dbe65414 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -16,17 +16,32 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentProfileService, type ResolvedAgentProfile } from '#/agent/profile/profile'; import { normalizeAgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; -import { Error2, ErrorCodes, toErrorPayload } from '#/errors'; +import { + Error2, + ErrorCodes, + resetUnexpectedErrorHandler, + setUnexpectedErrorHandler, + toErrorPayload, +} from '#/errors'; import { WIRE_PROTOCOL_VERSION } from '#/wire/migration/migration'; import { createTestAgent, type TestAgentContext } from '../../harness'; import { DEFAULT_TEST_SYSTEM_PROMPT } from '../../harness/snapshots'; import { SyncDescriptor } from '#/_base/di/descriptors'; +import { createDecorator, type ProvideHandle } from '#/_base/di/instantiation'; import { DisposableStore } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { TestInstantiationService } from '#/_base/di/test'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { ConfigTarget, IConfigRegistry, IConfigService } from '#/app/config/config'; +import { + type ConfigSchema, + ConfigTarget, + IConfigRegistry, + IConfigService, + type RegisterSectionOptions, +} from '#/app/config/config'; import { ConfigRegistry, ConfigService } from '#/app/config/configService'; +import { ConfigSectionContribution } from '#/app/config/configSectionContributions'; import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import '#/app/cron/configSection'; import type { CronConfig } from '#/app/cron/configSection'; @@ -544,9 +559,6 @@ describe('ConfigService env overlay (live)', () => { }); it('deletes a scalar section on replace(undefined) — set(undefined) cannot', async () => { - // Contract the refresh host relies on: an explicit undefined in a refresh - // patch must DELETE the section. `set()` deep-merges, so an undefined - // scalar patch resolves back to the base value; only `replace()` deletes. const disposables = new DisposableStore(); const ix = disposables.add(new TestInstantiationService()); ix.stub(ILogService, stubLog()); @@ -814,15 +826,12 @@ describe('image config section', () => { const config = ix.get(IConfigService); await config.ready; - // A client echoing the env-overlaid section back (plus a genuine edit). await config.set(IMAGE_SECTION, { maxEdgePx: 1500, readByteBudget: 262144 }); - // Runtime resolution still lets the env win… expect(config.get(IMAGE_SECTION)).toEqual({ maxEdgePx: 1500, readByteBudget: 262144, }); - // …but persistence drops the env-owned field and keeps the genuine edit. expect(config.inspect(IMAGE_SECTION).userValue).toEqual({ readByteBudget: 262144, }); @@ -954,20 +963,17 @@ describe('loopControl config section', () => { const config = ix.get(IConfigService); await config.ready; - // A client echoing the env-overlaid section back (plus a genuine edit). await config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 7, maxAttemptsPerStep: 2, reservedContextSize: 5000, }); - // Runtime resolution still lets the env win… expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxStepsPerTurn: 7, maxAttemptsPerStep: 2, reservedContextSize: 5000, }); - // …but persistence keeps the raw value and drops the env-only field. expect(config.inspect(LOOP_CONTROL_SECTION).userValue).toEqual({ maxStepsPerTurn: 100, reservedContextSize: 5000, @@ -1017,7 +1023,6 @@ describe('loopControl config section', () => { await config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 50 }); - // The invalid env value is ignored on both the read and the write path. expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(50); expect(config.inspect(LOOP_CONTROL_SECTION).userValue).toEqual({ maxStepsPerTurn: 50, @@ -1048,14 +1053,12 @@ describe('loopControl config section', () => { env[LOOP_MAX_STEPS_PER_TURN_ENV] = '7'; expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(7); - // A degraded env value falls back to the file, not to the previous override. env[LOOP_MAX_STEPS_PER_TURN_ENV] = 'abc'; expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(100); env[LOOP_MAX_STEPS_PER_TURN_ENV] = '9'; expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(9); - // Unsetting falls back to the file as well, on both get() and getAll(). delete env[LOOP_MAX_STEPS_PER_TURN_ENV]; expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(100); @@ -1086,9 +1089,6 @@ describe('loopControl config section', () => { const config = ix.get(IConfigService); await config.ready; - // The deprecated key no longer maps onto maxStepsPerTurn: the resolved - // section carries only the env override, and the raw user value is the - // un-normalized echo of the file (preserved, not applied). expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxStepsPerTurn: 7 }); expect(config.inspect(LOOP_CONTROL_SECTION).userValue).toEqual({ maxStepsPerRun: 100, @@ -1164,8 +1164,6 @@ describe('loopControl config section', () => { config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 7, reservedContextSize: 5000 }), ).rejects.toThrow(); - // Nothing is persisted: the invalid value stays quarantined on disk and - // the accompanying valid edit is not written either. const onDisk = new TextDecoder().decode(await storage.read('', 'config.toml')); expect(onDisk).toContain('max_steps_per_turn = -1'); expect(onDisk).not.toContain('reserved_context_size'); @@ -1452,20 +1450,17 @@ describe('task config section', () => { '[background]\nmax_running_tasks = 3\n', ); - // A client echoing the env-overlaid section back (plus a genuine edit). await config.set('background', { keepAliveOnExit: true, maxRunningTasks: 8, killGracePeriodMs: 25, }); - // Runtime resolution still lets the env win… expect(config.get('background')).toEqual({ keepAliveOnExit: true, maxRunningTasks: 8, killGracePeriodMs: 25, }); - // …but persistence keeps the raw value and drops the env-only field. expect(config.inspect('background').userValue).toEqual({ maxRunningTasks: 3, killGracePeriodMs: 25, @@ -1480,7 +1475,6 @@ describe('task config section', () => { await config.set('background', { keepAliveOnExit: true }); - // The invalid env value is ignored on both the read and the write path. expect(config.get('background')?.keepAliveOnExit).toBe(true); expect(config.inspect('background').userValue).toEqual({ keepAliveOnExit: true, @@ -1707,12 +1701,9 @@ describe('subagent config section', () => { const env: Record = { [SUBAGENT_TIMEOUT_ENV]: '7000' }; const { config, disposables } = await createConfig(env, '[subagent]\ntimeout_ms = 5000\n'); - // A client echoing the env-overlaid section back. await config.set(SUBAGENT_SECTION, { timeoutMs: 7000 }); - // Runtime resolution still lets the env win… expect(resolveSubagentTimeoutMs(config)).toBe(7000); - // …but persistence keeps the raw value. expect(config.inspect(SUBAGENT_SECTION).userValue).toEqual({ timeoutMs: 5000, }); @@ -1724,9 +1715,6 @@ describe('subagent config section', () => { const env: Record = { [SUBAGENT_TIMEOUT_ENV]: '7000' }; const { config, disposables } = await createConfig(env); - // A client echoing the env-overlaid section back: nothing persistable - // remains, so the raw section is cleared instead of shadowing the default - // with an empty object. await config.set(SUBAGENT_SECTION, { timeoutMs: 7000 }); expect(resolveSubagentTimeoutMs(config)).toBe(7000); @@ -1757,8 +1745,6 @@ describe('subagent config section', () => { noModel.disposables.dispose(); const withModel = await createConfig({}, '[secondary_model]\nmodel = "provider/secondary"\n'); - // Pointer-only recipe: bind the pointed entry directly; thinking resolves - // naturally (no inheriting the caller's level). expect(resolveSubagentBinding(withModel.config, secondaryModelFlags(), own)).toEqual({ model: 'provider/secondary', thinking: undefined, @@ -1775,14 +1761,11 @@ describe('subagent config section', () => { {}, '[secondary_model]\nmodel = "provider/secondary"\ndefault_effort = "low"\n', ); - // Patch fields bind the synthesized derived entry; default_effort is the - // explicit subagent thinking. expect(resolveSubagentBinding(withEffort.config, secondaryModelFlags(), own)).toEqual({ model: SECONDARY_DERIVED_MODEL_ID, thinking: 'low', displayModel: 'provider/secondary', }); - // default_effort only applies together with the secondary model. expect(resolveSubagentBinding(withEffort.config, secondaryModelFlags(), own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', @@ -1875,14 +1858,12 @@ describe('subagent config section', () => { }); it('passes through config-invalid failures that are not a missing bound alias', () => { - // A malformed [models.*] entry fails without details.model. const malformed = new Error2( ErrorCodes.CONFIG_INVALID, 'Model "provider/secondary" must declare a wire protocol (config: models..protocol).', ); expect(wrapSubagentModelError(malformed, 'provider/secondary', 'provider/main')).toBe(malformed); - // A missing alias that is not the bound model. const unrelated = new Error2( ErrorCodes.CONFIG_INVALID, 'Model "provider/other" is not configured in config.toml.', @@ -1925,7 +1906,6 @@ describe('secondaryModel config section', () => { expect(resolveSecondaryModel(config, secondaryModelFlags())?.model).toBe('provider/env-secondary'); expect(resolveSecondaryModel(config, secondaryModelFlags())?.defaultEffort).toBe('high'); - // Blank env values are ignored. env[SECONDARY_MODEL_ENV] = ' '; expect(resolveSecondaryModel(config, secondaryModelFlags())?.model).toBe('provider/secondary'); @@ -1939,7 +1919,6 @@ describe('secondaryModel config section', () => { '[secondary_model]\nmodel = "provider/raw-secondary"\n', ); - // A client echoing the env-overlaid section back. await config.set(SECONDARY_MODEL_SECTION, { model: 'provider/env-secondary' }); expect(resolveSecondaryModel(config, secondaryModelFlags())?.model).toBe('provider/env-secondary'); @@ -1958,16 +1937,12 @@ describe('secondaryModel config section', () => { const domains: string[] = []; config.onDidSectionChange((e) => domains.push(e.domain)); - // Runtime set with patch fields: the derived entry appears in the - // effective models view AND the models section event fires — the - // persistence bridge re-hydrates the registry from that event. await config.set(SECONDARY_MODEL_SECTION, { model: 'k2', maxOutputSize: 8192 }); const models = config.get>(MODELS_SECTION) ?? {}; expect(models[SECONDARY_DERIVED_MODEL_ID]).toBeDefined(); expect(domains).toContain(SECONDARY_MODEL_SECTION); expect(domains).toContain(MODELS_SECTION); - // Removing the patch retracts the derived entry and fires again. domains.length = 0; await config.replace(SECONDARY_MODEL_SECTION, { model: 'k2' }); const after = config.get>(MODELS_SECTION) ?? {}; @@ -2078,12 +2053,9 @@ describe('mcp config section', () => { const env: Record = { [MCP_STARTUP_TIMEOUT_ENV]: '7000' }; const { config, disposables } = await createConfig(env, '[mcp]\nstartup_timeout_ms = 5000\n'); - // A client echoing the env-overlaid section back. await config.set(MCP_SECTION, { startupTimeoutMs: 7000 }); - // Runtime resolution still lets the env win… expect(config.get(MCP_SECTION)?.startupTimeoutMs).toBe(7000); - // …but persistence keeps the raw value. expect(config.inspect(MCP_SECTION).userValue).toEqual({ startupTimeoutMs: 5000, }); @@ -2163,6 +2135,179 @@ describe('nested env bindings', () => { }); }); +describe('config section collection fold (D12)', () => { + const RUNTIME_SECTION = 'runtimeFoldDemo'; + const RUNTIME_NOTE_ENV = 'RUNTIME_FOLD_DEMO_NOTE'; + + interface RuntimeFoldDemo { + enabled: boolean; + note?: string; + } + + const RuntimeFoldDemoSchema: ConfigSchema = { + parse(value: unknown): RuntimeFoldDemo { + const demo = value as RuntimeFoldDemo; + if (typeof demo?.enabled !== 'boolean') { + throw new Error('runtimeFoldDemo.enabled must be a boolean'); + } + return demo; + }, + }; + + interface IRuntimeSectionContributor { + readonly marker: string; + } + const IRuntimeSectionContributor = createDecorator( + 'test-runtime-section-contributor', + ); + + class RuntimeSectionContributor extends Service implements IRuntimeSectionContributor { + readonly marker = 'runtime-section-contributor'; + constructor(contribution: ConfigSectionContribution) { + super(); + this.provide(ConfigSectionContribution, contribution); + } + } + + function sectionContribution( + domain: string, + schema: ConfigSchema, + options: RegisterSectionOptions = {}, + ): ConfigSectionContribution { + return { + domain, + schema: schema as ConfigSchema, + options: options as RegisterSectionOptions, + }; + } + + function provideContribution( + ix: TestInstantiationService, + contribution: ConfigSectionContribution, + ): ProvideHandle { + const handle = ix.provide( + IRuntimeSectionContributor, + new SyncDescriptor(RuntimeSectionContributor, [contribution] as never), + ); + ix.invokeFunction((accessor) => accessor.get(IRuntimeSectionContributor)); + return handle; + } + + function setupFold(env: Record) { + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new InMemoryStorageService(); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env)); + ix.stub(IFileSystemStorageService, storage); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + return { disposables, ix, storage }; + } + + it('activates a runtime-provided section: defaults, env bindings and validation apply', async () => { + const env: Record = {}; + const { disposables, ix } = setupFold(env); + const registry = ix.get(IConfigRegistry); + const config = ix.get(IConfigService); + await config.ready; + expect(registry.getSection(RUNTIME_SECTION)).toBeUndefined(); + + provideContribution( + ix, + sectionContribution(RUNTIME_SECTION, RuntimeFoldDemoSchema, { + defaultValue: { enabled: true }, + env: { note: RUNTIME_NOTE_ENV }, + }), + ); + + expect(registry.getSection(RUNTIME_SECTION)).toBeDefined(); + expect(config.get(RUNTIME_SECTION)).toEqual({ enabled: true }); + env[RUNTIME_NOTE_ENV] = 'from-env'; + expect(config.get(RUNTIME_SECTION)).toEqual({ + enabled: true, + note: 'from-env', + }); + delete env[RUNTIME_NOTE_ENV]; + expect(config.get(RUNTIME_SECTION)).toEqual({ enabled: true }); + + await config.set(RUNTIME_SECTION, { enabled: false }, ConfigTarget.Memory); + expect(config.get(RUNTIME_SECTION)).toEqual({ enabled: false }); + await expect( + config.set(RUNTIME_SECTION, { enabled: 'nope' }, ConfigTarget.Memory), + ).rejects.toThrow('enabled'); + + disposables.dispose(); + }); + + it('withdraws the section when the provider dies; TOML values survive, builtins untouched', async () => { + const env: Record = {}; + const { disposables, ix, storage } = setupFold(env); + const config = ix.get(IConfigService); + await config.ready; + const registry = ix.get(IConfigRegistry); + const builtinSection = registry.getSection(DEFAULT_PERMISSION_MODE_SECTION); + + const handle = provideContribution( + ix, + sectionContribution(RUNTIME_SECTION, RuntimeFoldDemoSchema, { + defaultValue: { enabled: true }, + }), + ); + await config.set(RUNTIME_SECTION, { enabled: false, note: 'kept' }, ConfigTarget.User); + expect(config.get(RUNTIME_SECTION)).toEqual({ + enabled: false, + note: 'kept', + }); + + handle.dispose(); + await ix.cascade.whenIdle(); + + expect(registry.getSection(RUNTIME_SECTION)).toBeUndefined(); + const persisted = await storage.read('', 'config.toml'); + expect(new TextDecoder().decode(persisted)).toContain('runtime_fold_demo'); + expect(config.get(RUNTIME_SECTION)).toEqual({ + enabled: false, + note: 'kept', + }); + expect(registry.getSection(DEFAULT_PERMISSION_MODE_SECTION)).toBe(builtinSection); + expect(registry.validate(DEFAULT_PERMISSION_MODE_SECTION, 'auto')).toBe('auto'); + + disposables.dispose(); + }); + + it('logs — never throws — a record colliding with a builtin section, and the builtin survives', async () => { + const env: Record = {}; + const { disposables, ix } = setupFold(env); + const config = ix.get(IConfigService); + await config.ready; + const registry = ix.get(IConfigRegistry); + const builtinSection = registry.getSection(DEFAULT_PERMISSION_MODE_SECTION); + + const logged: unknown[] = []; + setUnexpectedErrorHandler((err) => logged.push(err)); + try { + const handle = provideContribution( + ix, + sectionContribution(DEFAULT_PERMISSION_MODE_SECTION, { parse: () => 'rogue' }), + ); + expect(logged).toHaveLength(1); + expect(String(logged[0])).toContain('already registered'); + expect(registry.getSection(DEFAULT_PERMISSION_MODE_SECTION)).toBe(builtinSection); + expect(registry.validate(DEFAULT_PERMISSION_MODE_SECTION, 'auto')).toBe('auto'); + + handle.dispose(); + await ix.cascade.whenIdle(); + + expect(registry.getSection(DEFAULT_PERMISSION_MODE_SECTION)).toBe(builtinSection); + } finally { + resetUnexpectedErrorHandler(); + disposables.dispose(); + } + }); +}); + function toolNames(value: unknown): string[] { if (!Array.isArray(value)) return []; return value @@ -2245,7 +2390,6 @@ describe('ConfigService thinking effort max migration', () => { }); describe('ConfigService replaceSections', () => { - // Top-level keys must precede every [table] header in TOML. const SEED_TOML = [ 'default_model = "acme/m1"', '', @@ -2301,9 +2445,6 @@ describe('ConfigService replaceSections', () => { expect(config.get(DEFAULT_MODEL_SECTION)).toBeUndefined(); expect(config.get(THINKING_SECTION)).toEqual({}); expect(config.inspect(DEFAULT_MODEL_SECTION).userValue).toBeUndefined(); - // `stripThinkingEnv` maps a clear to `{}` (`{...undefined}`), so the user - // layer collapses to an empty object instead of disappearing — the - // long-standing `replace(domain, undefined)` behavior, unchanged here. expect(config.inspect(THINKING_SECTION).userValue).toEqual({}); disposables.dispose(); @@ -2325,8 +2466,6 @@ describe('ConfigService replaceSections', () => { acme: { type: 'openai', apiKey: 'sk-acme-2' }, }); - // `replace(domain, null)` clears too, so JSON transports behave - // identically to in-process `replace(domain, undefined)` callers. await config.replace(DEFAULT_MODEL_SECTION, 'acme/m1'); await config.replace(DEFAULT_MODEL_SECTION, null); expect(config.inspect(DEFAULT_MODEL_SECTION).userValue).toBeUndefined(); @@ -2357,9 +2496,6 @@ describe('ConfigService replaceSections', () => { [THINKING_SECTION]: undefined, }); - // Every event — including the very first one — already observes the fully - // applied state; no listener can catch the write half-applied. (The - // cleared thinking section still resolves to its schema default `{}`.) expect(snapshotDuringFirstEvent).toEqual({ providers: { acme: { type: 'openai', apiKey: 'sk-acme-2' } }, models: { 'acme/m2': { provider: 'acme', model: 'm2', maxContextSize: 2000 } }, @@ -2396,9 +2532,6 @@ describe('ConfigService replaceSections', () => { const { config, disposables, store } = await createSectionsConfig(); const setSpy = vi.spyOn(store, 'set'); - // Providers is applied first in key order and validates fine; thinking - // then fails schema validation (`enabled` must be a boolean). The batch - // must reject with NO observable partial application. await expect( config.replaceSections({ [PROVIDERS_SECTION]: { acme: { type: 'openai', apiKey: 'sk-acme-2' } }, diff --git a/packages/agent-core-v2/test/app/event/eventBus.test.ts b/packages/agent-core-v2/test/app/event/eventBus.test.ts index 62508006151..f72cd7285af 100644 --- a/packages/agent-core-v2/test/app/event/eventBus.test.ts +++ b/packages/agent-core-v2/test/app/event/eventBus.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { type DomainEvent } from '#/app/event/eventBus'; +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { createDecorator } from '#/_base/di/instantiation'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import { Service } from '#/_base/di/service'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; +import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; +import '#/app/event/fiberEventResolver'; declare module '#/app/event/eventBus' { interface DomainEventMap { @@ -82,3 +88,53 @@ describe('event bus (full-stream and per-type delivery, dispose and empty-publis expect(seen).toEqual([true]); }); }); + +describe('fiberEventResolver — string on(...) resolved against the scope IEventBus', () => { + it('delivers matching bus events to a unit string subscription and detaches on unload', () => { + const bus = new EventBusService(); + const seen: number[] = []; + class Unit extends Service { + constructor() { + super(); + this.on('test.a', (e: { x: number }) => seen.push(e.x)); + } + } + const IUnit = createDecorator('test-string-on-unit'); + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IEventBus, bus); + ix.provide(IUnit, new SyncDescriptor(Unit)); + ix.invokeFunction((a) => a.get(IUnit)); + + bus.publish({ type: 'test.a', x: 1 }); + bus.publish({ type: 'test.b', y: 'ignored' }); + expect(seen).toEqual([1]); + + ix.unprovide(IUnit); + bus.publish({ type: 'test.a', x: 2 }); + expect(seen).toEqual([1]); + ix.dispose(); + }); + + it('attaches when the bus arrives after the unit was constructed', () => { + const bus = new EventBusService(); + const seen: number[] = []; + class LateUnit extends Service { + constructor() { + super(); + this.on('test.a', (e: { x: number }) => seen.push(e.x)); + } + } + const ILateUnit = createDecorator('test-string-on-late-unit'); + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(ILateUnit, new SyncDescriptor(LateUnit)); + ix.invokeFunction((a) => a.get(ILateUnit)); + + bus.publish({ type: 'test.a', x: 0 }); + expect(seen).toEqual([]); + + ix.provide(IEventBus, bus); + bus.publish({ type: 'test.a', x: 7 }); + expect(seen).toEqual([7]); + ix.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/app/feature/featureManager.test.ts b/packages/agent-core-v2/test/app/feature/featureManager.test.ts new file mode 100644 index 00000000000..243b5489873 --- /dev/null +++ b/packages/agent-core-v2/test/app/feature/featureManager.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { FiberState } from '#/_base/di/fiber'; +import { createDecorator } from '#/_base/di/instantiation'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import { Service } from '#/_base/di/service'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; +import { IFeatureManager } from '#/app/feature/featureManager'; +import { FeatureManagerService } from '#/app/feature/featureManagerService'; + +interface IGizmo { + tag: string; +} +const IGizmo = createDecorator('feature-gizmo'); + +class Gizmo extends Service { + readonly tag = 'gizmo'; +} + +function host(): { ix: InstantiationService; manager: IFeatureManager } { + const ix = new InstantiationService( + new ServiceCollection([IFeatureManager, new SyncDescriptor(FeatureManagerService)]), + true, + ); + return { ix, manager: ix.invokeFunction((a) => a.get(IFeatureManager)) }; +} + +describe('FeatureManager — dynamic unit assembly at App scope (§5.10)', () => { + it('assembles a token-bound unit, introspects it, and retracts it', async () => { + const { ix, manager } = host(); + const events: number[] = []; + manager.onDidChangeUnits(() => events.push(events.length)); + const handle = manager.provideUnit(IGizmo, Gizmo); + expect(handle.state).toBe(FiberState.Active); + expect(ix.invokeFunction((a) => a.get(IGizmo)).tag).toBe('gizmo'); + const infos = manager.units(); + expect(infos).toHaveLength(1); + expect(infos[0]).toMatchObject({ name: 'Gizmo', state: FiberState.Active }); + expect(typeof infos[0]!.uid).toBe('number'); + expect(events.length).toBe(1); + + await manager.unprovideUnit('Gizmo'); + expect(manager.units()).toHaveLength(0); + expect(() => ix.invokeFunction((a) => a.get(IGizmo))).toThrow(/unknown service/); + expect(events.length).toBe(2); + ix.dispose(); + }); + + it('reloads a managed unit with new config via updateUnit', async () => { + const { ix, manager } = host(); + const configs: unknown[] = []; + class Configured extends Service { + constructor() { + super(); + configs.push(this.config); + } + } + manager.provideUnit(IGizmo, Configured, { config: 1 }); + ix.invokeFunction((a) => a.get(IGizmo)); + expect(configs).toEqual([1]); + await manager.updateUnit('Configured', 2); + expect(configs).toEqual([1, 2]); + await expect(manager.updateUnit('unknown-unit')).rejects.toThrow(/not managed/); + ix.dispose(); + }); + + it('retracts every managed unit when the manager dies', async () => { + const { ix, manager } = host(); + manager.provideUnit(IGizmo, Gizmo); + ix.invokeFunction((a) => a.get(IGizmo)); + ix.dispose(); + await expect(Promise.resolve()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/app/gateway/gateway.test.ts b/packages/agent-core-v2/test/app/gateway/gateway.test.ts index 2202f1d9fef..2a3926cbca8 100644 --- a/packages/agent-core-v2/test/app/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/app/gateway/gateway.test.ts @@ -3,7 +3,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; import { Disposable, DisposableStore } from '#/_base/di/lifecycle'; -import { type IAgentScopeHandle, type ISessionScopeHandle, LifecycleScope } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { type IAgentScopeHandle, type ISessionScopeHandle } from '#/_base/di/scope'; import { TestInstantiationService } from '#/_base/di/test'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import type { ContextMessage } from '#/agent/contextMemory/types'; diff --git a/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts b/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts index 33b259e9418..717a9d1ab16 100644 --- a/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts +++ b/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts @@ -114,10 +114,6 @@ async function createHost( ]); const providers = host.app.accessor.get(IProviderService); const models = host.app.accessor.get(IModelService); - // The real persistence bridge (DI-activated): refresh writes land in - // config, and the bridge's config → kosong sync is what carries them into - // the registries (exactly like production). Await its initialization so - // the event subscriptions are in place before the test refreshes. const bridge = host.app.accessor.get(IKosongConfigService); await bridge.ready; return { @@ -207,7 +203,6 @@ describe('refreshProviderModels modelSource short-circuit', () => { }); try { const result = await discovery.refreshProviderModels({ scope: 'all' }); - // The registry provider refreshed; the static one is nowhere in the result. expect(result.changed).toEqual([ { provider_id: 'acme', provider_name: 'Acme', added: 1, removed: 0 }, ]); @@ -217,10 +212,6 @@ describe('refreshProviderModels modelSource short-circuit', () => { expect.objectContaining({ type: 'event.model_catalog.changed' }), ]); - // Static provider, its model, the default selection, and its thinking - // all survived the orchestrator's whole-section writes. Providers and - // models land in the in-memory registries (the persistence bridge owns - // the config write-back); defaultModel/thinking go through config. const providerRecords = providers.list(); expect(Object.keys(providerRecords).toSorted()).toEqual(['acme', 'static-p']); expect(providerRecords['static-p']).toEqual({ type: 'openai', modelSource: 'static', apiKey: 'sk-static' }); @@ -395,7 +386,6 @@ describe('refreshProviderModels write behavior', () => { headers: expect.objectContaining({ Authorization: 'Bearer sk-distributed-key' }), }), ); - // The user-owned provider record survives; only model aliases are merged. expect(providers.list()['my-kimi']).toEqual({ type: 'kimi', baseUrl, @@ -404,7 +394,6 @@ describe('refreshProviderModels write behavior', () => { const modelRecords = models.list(); expect(modelRecords['my-kimi/kimi-k2']?.displayName).toBe('Fresh K2'); expect(modelRecords['my-kimi/kimi-k2.5']).toBeDefined(); - // The surviving default selection is written back, not cleared. expect(config.get('defaultModel')).toBe('my-kimi/kimi-k2'); } finally { host.dispose(); @@ -447,10 +436,6 @@ describe('refreshProviderModels write behavior', () => { expect(result.changed).toEqual([ { provider_id: 'my-kimi', provider_name: 'my-kimi', added: 1, removed: 1 }, ]); - // The dropped alias was the default: an explicit undefined in the patch - // must clear the section instead of leaving the default dangling. It has - // to go through a replacing write — `set()`'s deepMerge would resolve - // undefined back to the stale base value. expect(config.get('defaultModel')).toBeUndefined(); expect(config.get('thinking')).toBeUndefined(); const modelRecords = models.list(); @@ -497,11 +482,6 @@ describe('refreshProviderModels write behavior', () => { defaultModel: 'acme/m1', }); try { - // The orchestrator's host contract is two-phase (removeProvider, then - // setConfig). By the time the atomic write happens, the removal phase - // must NOT have touched the runtime registries — a reader (e.g. a - // profile bind racing the refresh) only ever sees the old catalog or - // the new one, never a half-removed one. let seenDuringWrite: { providers: readonly string[]; models: readonly string[] } | undefined; const originalReplaceSections = config.replaceSections.bind(config); vi.spyOn(config, 'replaceSections').mockImplementation(async (sections) => { @@ -517,8 +497,6 @@ describe('refreshProviderModels write behavior', () => { expect(result.failed).toEqual([]); expect(seenDuringWrite).toEqual({ providers: ['acme'], models: ['acme/m1'] }); expect(vi.mocked(config.replaceSections).mock.calls.length).toBe(1); - // After the write, config and the registries converge on the new state: - // the dropped alias (and its default selection) is gone everywhere. expect(providers.list()['acme']).toBeDefined(); expect(models.list()['acme/m2']).toBeDefined(); expect(models.list()['acme/m1']).toBeUndefined(); diff --git a/packages/agent-core-v2/test/app/kosongConfig/kosongConfigService.test.ts b/packages/agent-core-v2/test/app/kosongConfig/kosongConfigService.test.ts index f5abe0bfd1e..627395784ed 100644 --- a/packages/agent-core-v2/test/app/kosongConfig/kosongConfigService.test.ts +++ b/packages/agent-core-v2/test/app/kosongConfig/kosongConfigService.test.ts @@ -77,7 +77,6 @@ async function createBridge(sections: Record = {}): Promise { for (let i = 0; i < 10; i += 1) { await new Promise((resolve) => setImmediate(resolve)); @@ -102,7 +101,6 @@ describe('KosongConfigService startup hydration', () => { expect(providers.getDefaultProvider()).toBe('kimi'); expect(models.list()).toEqual({ k1: K1_MODEL }); expect(models.getDefaultModel()).toBe('k1'); - // Both registries are hydrated — `ready` has resolved. await expect(providers.ready).resolves.toBeUndefined(); await expect(models.ready).resolves.toBeUndefined(); }); @@ -179,7 +177,6 @@ describe('KosongConfigService awaited-mutation semantics', () => { it('an awaited registry mutation resolves only after the write has landed in config', async () => { const { config, providers, models, bridge } = await createBridge(seededSections); try { - // No flush(): the mutation's own await already covers persistence. await providers.set('openai', { type: 'openai', apiKey: 'sk-o' }); expect(config.get>(PROVIDERS_SECTION)).toEqual({ kimi: KIMI_PROVIDER, @@ -209,7 +206,6 @@ describe('KosongConfigService awaited-mutation semantics', () => { vi.useFakeTimers(); try { const pending = providers.set('openai', { type: 'openai' }); - // The first backoff is ~500ms; advancing past it lets the retry run. await vi.advanceTimersByTimeAsync(1000); await pending; } finally { @@ -234,9 +230,7 @@ describe('KosongConfigService awaited-mutation semantics', () => { vi.useFakeTimers(); try { const pending = providers.set('openai', { type: 'openai' }); - // Two backoffs (~500ms + ~1000ms, plus jitter) before the budget is spent. await vi.advanceTimersByTimeAsync(2500); - // The caller is never rejected: the in-memory change stands. await pending; } finally { vi.useRealTimers(); @@ -249,7 +243,6 @@ describe('KosongConfigService awaited-mutation semantics', () => { expect(log.warnings).toHaveLength(1); expect(log.warnings[0]?.message).toBe('kosong config persist failed'); - // A poisoned task must not stall the persists queued behind it. replaceSpy.mockRestore(); await providers.set('mistral', { type: 'mistral' }); expect(config.get>(PROVIDERS_SECTION)).toEqual({ @@ -300,11 +293,7 @@ describe('KosongConfigService loop termination', () => { await providers.setDefaultProvider('openai'); await flush(); - // Exactly one event per mutation; the config write the persist caused - // synced back equal values, which are silent. expect(events).toEqual(['providers', 'defaultProvider']); - // And the persist did not re-persist after the echo: exactly one - // providers-section replace plus one pointer replace. expect( replaceSpy.mock.calls.filter(([domain]) => domain === PROVIDERS_SECTION), ).toHaveLength(1); @@ -328,8 +317,6 @@ describe('KosongConfigService loop termination', () => { expect(providers.get('openai')).toEqual({ type: 'openai' }); expect(models.get('k2')).toEqual({ provider: 'openai', model: 'gpt-5' }); - // The registry diffs fired, but the persist handlers saw config already - // matching and skipped the write-back. expect(replaceSpy).not.toHaveBeenCalled(); } finally { bridge.dispose(); @@ -349,8 +336,6 @@ describe('KosongConfigService default-provider deletion', () => { await providers.delete('kimi'); await flush(); - // The registry cleared the dangling pointer, and the cleared pointer - // persisted. expect(providers.getDefaultProvider()).toBeUndefined(); expect(replaceSpy).toHaveBeenCalledWith(PROVIDERS_SECTION, { openai: { type: 'openai' }, @@ -364,11 +349,6 @@ describe('KosongConfigService default-provider deletion', () => { }); describe('KosongConfigService env-pinned default pointer', () => { - /** - * Emulates an effective-overlay pin (e.g. `KIMI_MODEL_NAME` → - * `defaultModel`): user-layer writes are accepted, but the effective read - * (`get`) keeps returning the pinned value and no change event fires. - */ class PinnedConfigService extends StubConfigService { constructor( private readonly pinnedDomain: string, @@ -391,15 +371,12 @@ describe('KosongConfigService env-pinned default pointer', () => { const bridge = new KosongConfigService(config, providers, models, stubLogService()); await bridge.ready; try { - // Hydration reads the effective view: the pinned value, not the seeded one. expect(models.getDefaultModel()).toBe('env-model'); const replaceSpy = vi.spyOn(config, 'replace'); await models.setDefaultModel('k1'); await flush(); - // The write landed in the user layer, but the pinned effective view - // did not move, and the bridge reconciled the registry back to the pin. expect(replaceSpy).toHaveBeenCalledWith(DEFAULT_MODEL_SECTION, 'k1'); expect(models.getDefaultModel()).toBe('env-model'); } finally { diff --git a/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts b/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts index 0e3cb47a2c7..5f4dbaf8e90 100644 --- a/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts +++ b/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts @@ -261,7 +261,6 @@ describe('IModelsDevImportService', () => { await imports.importModelsDevProvider({ catalogId: 'openai' }); expect(config.get('defaultModel')).toBe('openai/gpt-4.1'); - // A later import never moves the seeded pointer. await imports.importModelsDevProvider({ catalogId: 'gateway', baseUrl: 'https://gw.example/v1', diff --git a/packages/agent-core-v2/test/app/kosongConfig/secondaryModelOverlay.test.ts b/packages/agent-core-v2/test/app/kosongConfig/secondaryModelOverlay.test.ts index ca1d5c7a532..f575fa5f46c 100644 --- a/packages/agent-core-v2/test/app/kosongConfig/secondaryModelOverlay.test.ts +++ b/packages/agent-core-v2/test/app/kosongConfig/secondaryModelOverlay.test.ts @@ -65,7 +65,6 @@ describe('secondaryModelOverlay.apply', () => { maxOutputSize: 8192, }, }); - // The pointed entry stays untouched. expect(models['k2']).toEqual(baseEntry); }); @@ -103,9 +102,7 @@ describe('secondaryModelOverlay.strip', () => { it('rolls back a defaultModel pointer set to the derived id', () => { expect(strip('defaultModel', 'k2', {})).toBe('k2'); - // Restore the raw pointer when one exists… expect(strip('defaultModel', SECONDARY_DERIVED_MODEL_ID, { default_model: 'k2' })).toBe('k2'); - // …or drop the section when the raw config never had one. expect(strip('defaultModel', SECONDARY_DERIVED_MODEL_ID, {})).toBeUndefined(); }); }); diff --git a/packages/agent-core-v2/test/app/model/model.test.ts b/packages/agent-core-v2/test/app/model/model.test.ts index c30a43cdbc5..d2181cf062d 100644 --- a/packages/agent-core-v2/test/app/model/model.test.ts +++ b/packages/agent-core-v2/test/app/model/model.test.ts @@ -23,8 +23,6 @@ import { import { type ModelRecord } from '#/kosong/model/model'; import { effectiveModelConfig } from '#/kosong/model/modelAuth'; -// Side-effect registrations: endpoint defaults and the trait-driven-thinking -// verdict (`drivesThinkingThroughTraits`) answer through the provider-definition registry. import '#/kosong/provider/providers/kimi/kimi.contrib'; import '#/kosong/provider/providers/standard.contrib'; @@ -258,10 +256,6 @@ describe('models TOML transforms', () => { }); it('deletes on-disk fields the new record carries with an explicit undefined', () => { - // A field absent from the new record stays (plain overlay), but a field - // present with an explicit undefined value must be dropped from the - // merged on-disk raw — spreading `{...raw, ...converted}` would resurrect - // it (setDefined deletes from `converted`, never from the merge). expect( modelsToToml( { @@ -289,7 +283,6 @@ describe('models TOML transforms', () => { provider: 'p', model: 'm', max_context_size: 1000, - // Unknown/unmentioned fields keep their old on-disk value. beta_api: true, }, }); @@ -378,9 +371,6 @@ describe('kimiModelEnvOverlay', () => { { providers: { [ENV_MODEL_PROVIDER_KEY]: { type: 'openai' } } }, ); - // The registry declares no `defaultBaseUrl` for the canonical vendors - // (standard.contrib): construction-time defaults stay inside the bases / - // their SDKs, so the overlay leaves baseUrl out — exactly like anthropic. expect(effective['providers']).toEqual({ [ENV_MODEL_PROVIDER_KEY]: { type: 'openai' }, }); diff --git a/packages/agent-core-v2/test/app/plugin/manager-consumption.test.ts b/packages/agent-core-v2/test/app/plugin/manager-consumption.test.ts index fc1ec6691d4..2262f8c5017 100644 --- a/packages/agent-core-v2/test/app/plugin/manager-consumption.test.ts +++ b/packages/agent-core-v2/test/app/plugin/manager-consumption.test.ts @@ -832,8 +832,6 @@ describe('PluginManager consumption plane', () => { }), }), ); - // An Electron host must not be routed through the CLI's `__plugin_run_node` - // subcommand (which only the CLI binary implements). expect(JSON.stringify(server)).not.toContain('__plugin_run_node'); } finally { if (originalElectron === undefined) delete process.versions['electron']; @@ -850,8 +848,6 @@ describe('PluginManager consumption plane', () => { await manager.load(); await manager.install(root); - // Plain node host (tests run under node): not Electron, not the CLI native - // binary, so the config passes through unchanged (command stays `node`). const server = manager.enabledMcpServers()['plugin-demo:data']; expect(server).toEqual( expect.objectContaining({ diff --git a/packages/agent-core-v2/test/app/plugin/pluginService.test.ts b/packages/agent-core-v2/test/app/plugin/pluginService.test.ts index 59eac597c7e..630d11a90a7 100644 --- a/packages/agent-core-v2/test/app/plugin/pluginService.test.ts +++ b/packages/agent-core-v2/test/app/plugin/pluginService.test.ts @@ -16,9 +16,8 @@ import path from 'node:path'; import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 5faa30643e7..34a49c143bb 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -27,7 +27,8 @@ import { type ServiceRegistration, type TestInstantiationService, } from '#/_base/di/test'; -import { LifecycleScope, type IAgentScopeHandle, type ISessionScopeHandle } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { type IAgentScopeHandle, type ISessionScopeHandle } from '#/_base/di/scope'; import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; import { ILogService, type ILogService as LogService } from '#/_base/log/log'; import { IWireService } from '#/wire/wire'; @@ -172,7 +173,6 @@ describe('sessionExport', () => { request: { sessionId: 'ses_repeated_export', version: '1.0.0-test' }, summary, }); - // Cross the next second boundary so the second export gets a distinct timestamp. await new Promise((resolvePromise) => setTimeout(resolvePromise, 1100 - (Date.now() % 1000))); const second = await exportSessionDirectory({ request: { sessionId: 'ses_repeated_export', version: '1.0.0-test' }, diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index 5bd3931026d..f72192188b4 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -4,8 +4,8 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndexMirror.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndexMirror.test.ts index aec5af222ef..8c4b4173c14 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndexMirror.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndexMirror.test.ts @@ -4,8 +4,8 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, diff --git a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts index 33bd257f8ce..c98195a9e4b 100644 --- a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts +++ b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts @@ -12,11 +12,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; import { DisposableStore } from '#/_base/di/lifecycle'; -import { type IAgentScopeHandle, type ISessionScopeHandle, LifecycleScope } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { type IAgentScopeHandle, type ISessionScopeHandle } from '#/_base/di/scope'; import { TestInstantiationService } from '#/_base/di/test'; import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import { IAgentPlanService } from '#/agent/plan/plan'; +import { IAgentPlanService } from '#/features/plan/plan'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { IConfigService } from '#/app/config/config'; @@ -45,7 +46,6 @@ function accessor( }; } -/** Stub the index → handler → session-lifecycle chain for one live session. */ function stubSessionChain(ix: TestInstantiationService, session: ISessionScopeHandle): void { const handler = { id: 'wd', @@ -129,8 +129,6 @@ describe('Session legacy status (best-effort runtime state)', () => { dispose: () => {}, }; const agents = { - // create is create-or-get for explicit ids: this session's main agent - // already exists, so return it as-is (same as whenReady). create: () => Promise.resolve(agent), whenReady: () => Promise.resolve(agent), list: () => [agent], @@ -158,11 +156,6 @@ describe('Session legacy status (best-effort runtime state)', () => { }); it('reports an empty thinking level for a never-bound main agent', async () => { - // A fresh session's main agent is materialized unbound (no Profile / Model - // — see kap-server's ensureMainAgent). The wire model's initial - // thinkingLevel is the zero value 'off'; reporting it would make clients - // fold a level nobody chose into the session's real state, so the status - // edge must report '' (mirroring `model: undefined`) instead. const profile = { _serviceBrand: undefined, data: () => ({ @@ -185,8 +178,6 @@ describe('Session legacy status (best-effort runtime state)', () => { [IAgentPermissionModeService, { mode: 'manual' }], [IAgentPlanService, { status: () => Promise.resolve(null) }], [IAgentSwarmService, { isActive: false }], - // Unbound: assembleStatus resolves the default model's context cap, - // which reads the `defaultModel` config section first. [IConfigService, { get: () => undefined }], [ IAgentActivityView, @@ -288,8 +279,6 @@ describe('Session legacy status (best-effort runtime state)', () => { const status = await ix.get(ISessionLegacyService).status('session-capped'); - // 120k in context against the 100k input cap (not the 200k window): - // usage would exceed the wire schema bound and is clamped to 1. expect(status).toMatchObject({ max_context_tokens: 100_000, context_usage: 1, diff --git a/packages/agent-core-v2/test/app/telemetry/telemetryService.test.ts b/packages/agent-core-v2/test/app/telemetry/telemetryService.test.ts index 86528094b3b..38572a03728 100644 --- a/packages/agent-core-v2/test/app/telemetry/telemetryService.test.ts +++ b/packages/agent-core-v2/test/app/telemetry/telemetryService.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope'; import { createScopedTestHost } from '#/_base/di/test'; import { resetUnexpectedErrorHandler, diff --git a/packages/agent-core-v2/test/app/web/providers/local-fetch-url.test.ts b/packages/agent-core-v2/test/app/web/providers/local-fetch-url.test.ts index 07673a727d4..86013bba83e 100644 --- a/packages/agent-core-v2/test/app/web/providers/local-fetch-url.test.ts +++ b/packages/agent-core-v2/test/app/web/providers/local-fetch-url.test.ts @@ -18,20 +18,13 @@ vi.mock('node:dns/promises', () => ({ lookup: vi.fn() })); const lookupMock = lookup as unknown as Mock; -// The init's dispatcher property is typed by @types/node's bundled -// undici-types, while the runtime value is the undici package's Agent — -// convert through unknown to bridge the two declarations. function asUndiciAgent(dispatcher: RequestInit['dispatcher']): Agent { return dispatcher as unknown as Agent; } -// Keep DNS hermetic: every hostname resolves to a public address unless a -// test overrides it (mockReset clears per-test overrides first). beforeEach(() => { lookupMock.mockReset(); lookupMock.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); - // Connection pinning is skipped when a proxy is configured — keep the - // environment free of proxy variables so tests stay hermetic anywhere. for (const key of ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY']) { vi.stubEnv(key, ''); } @@ -256,9 +249,7 @@ describe('LocalFetchURLProvider connection pinning', () => { const [, init] = fetchImpl.mock.calls[0]!; const dispatcher = (init as RequestInit).dispatcher; expect(dispatcher).toBeInstanceOf(Agent); - // The DNS answer was validated once and reused for the connection. expect(lookupMock).toHaveBeenCalledTimes(1); - // The per-hop Agent is closed once the body has been consumed. expect(asUndiciAgent(dispatcher).closed).toBe(true); }); diff --git a/packages/agent-core-v2/test/app/web/tools/fetch-url.test.ts b/packages/agent-core-v2/test/app/web/tools/fetch-url.test.ts index a9f7e5edc75..b362dacaea1 100644 --- a/packages/agent-core-v2/test/app/web/tools/fetch-url.test.ts +++ b/packages/agent-core-v2/test/app/web/tools/fetch-url.test.ts @@ -18,8 +18,6 @@ import type { UrlFetcher, UrlFetchResult } from '#/app/web/tools/fetch-url-types vi.mock('node:dns/promises', () => ({ lookup: vi.fn() })); -// LocalFetchURLProvider resolves hostnames before fetching; keep DNS -// hermetic so provider-level tests never touch the real resolver. beforeEach(() => { (lookup as unknown as Mock).mockReset(); (lookup as unknown as Mock).mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); diff --git a/packages/agent-core-v2/test/app/workspace/workspaceService.test.ts b/packages/agent-core-v2/test/app/workspace/workspaceService.test.ts index a7b6960f977..ad287b67b50 100644 --- a/packages/agent-core-v2/test/app/workspace/workspaceService.test.ts +++ b/packages/agent-core-v2/test/app/workspace/workspaceService.test.ts @@ -3,9 +3,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { promises as fsp } from 'node:fs'; import os from 'node:os'; import { join } from 'node:path'; - +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, @@ -76,11 +75,6 @@ describe('WorkspaceService (file-backed)', () => { return build(); } - /** - * hostFs stub that stats every path as an existing directory, so tests can - * exercise Windows-shaped roots on Linux CI — real-fs stat of `C:\...` is - * ENOENT there, and real fs case behavior must never be relied on. - */ function allDirsHostFs(): IHostFileSystem { return { stat: () => Promise.resolve({ isFile: false, isDirectory: true, size: 0 }), @@ -183,13 +177,10 @@ describe('WorkspaceService (file-backed)', () => { [encodeWorkDirKey(work), encodeWorkDirKey(fromIndex)].toSorted(), ); const existing = list.find((w) => w.id === encodeWorkDirKey(work)); - // The registered entry keeps its persisted data; the merged entry only - // gets a basename-derived name. expect(existing?.name).toBe('existing'); expect(existing?.lastOpenedAt).toBe(Date.parse('2024-01-02T00:00:00.000Z')); expect(list.find((w) => w.id === encodeWorkDirKey(fromIndex))?.name).toBe('from-index'); - // The merge is persisted, so a restart sees the same catalog. expect((await restart().list()).map((w) => w.id).toSorted()).toEqual( list.map((w) => w.id).toSorted(), ); @@ -241,12 +232,10 @@ describe('WorkspaceService (file-backed)', () => { await registry.delete(a.id); expect((await registry.list()).map((w) => w.id)).toEqual([encodeWorkDirKey(dirB)]); - // The tombstone is on disk in the v1-compatible field. const onDisk = await readWorkspacesJson(); expect(onDisk.deleted_workspace_ids).toEqual([a.id]); expect(onDisk.workspaces[a.id]).toBeUndefined(); - // Sessions referencing the deleted workDir must not resurrect it. await seedSessionIndex([ { sessionId: 's1', @@ -280,8 +269,6 @@ describe('WorkspaceService (file-backed)', () => { const registry = build(); await registry.createOrTouch(dirA); - // Simulate a v1 writer touching the file after the v2 registry already - // ran an operation: a new workspace entry plus an unrelated tombstone. const onDisk = await readWorkspacesJson(); onDisk.workspaces[encodeWorkDirKey(dirB)] = { root: dirB, @@ -306,7 +293,6 @@ describe('WorkspaceService (file-backed)', () => { [encodeWorkDirKey(dirA), encodeWorkDirKey(dirB), encodeWorkDirKey(dirC)].toSorted(), ); expect(after.deleted_workspace_ids).toEqual(['wd_external_tombstone']); - // Reads also see the external entry without a restart. expect((await registry.list()).map((w) => w.id)).toContain(encodeWorkDirKey(dirB)); }); @@ -342,7 +328,6 @@ describe('WorkspaceService (file-backed)', () => { const registry = build(); const a = await registry.createOrTouch(dirA); - // External rename on disk: the update must start from it, not stale state. const onDisk = await readWorkspacesJson(); const entry = onDisk.workspaces[a.id]; if (entry === undefined) throw new Error('seed entry missing'); @@ -357,7 +342,6 @@ describe('WorkspaceService (file-backed)', () => { expect(renamed?.name).toBe('local-name'); expect(renamed?.lastOpenedAt).toBe(Date.parse(entry.last_opened_at)); - // External removal: update reports the id as gone instead of resurrecting. await fsp.writeFile( join(homeDir, 'workspaces.json'), JSON.stringify({ version: 1, workspaces: {}, deleted_workspace_ids: [] }), @@ -441,15 +425,11 @@ describe('WorkspaceService (file-backed)', () => { expect(cased.id).toBe(first.id); expect(slashed.id).toBe(first.id); - // Folding never rewrites the stored root/name — the first spelling stays; - // only lastOpenedAt advances. expect(cased.root).toBe('C:\\Users\\Foo\\Proj'); expect(cased.name).toBe(first.name); expect(cased.lastOpenedAt).toBeGreaterThanOrEqual(first.lastOpenedAt); expect(await registry.list()).toHaveLength(1); - // ...and the fold persists: a fresh instance over the same homeDir still - // lists one entry under the first-seen spelling. const reloaded = await restart().list(); expect(reloaded).toHaveLength(1); expect(reloaded[0]?.root).toBe('C:\\Users\\Foo\\Proj'); @@ -467,7 +447,6 @@ describe('WorkspaceService (file-backed)', () => { last_opened_at: '2026-01-01T00:00:00.000Z', }); await writeWorkspacesJson({ - // Legacy first so the canonical entry must actively replace it. [legacyId]: entry(typedRoot), [canonicalId]: entry(lowerRoot), }); @@ -479,8 +458,6 @@ describe('WorkspaceService (file-backed)', () => { }); it('rebuild folds session-index workDir variants into one workspace', async () => { - // UNC paths are Windows-shaped (so they case-fold) yet still `isAbsolute` - // on POSIX hosts, so this exercises case folding on Linux CI. const firstSeen = '//Host/Share/Proj'; await seedSessionIndex([ { sessionId: 's1', sessionDir: 'sessions/a/s1', workDir: firstSeen }, @@ -489,7 +466,6 @@ describe('WorkspaceService (file-backed)', () => { ]); const list = await build().list(); - // First seen wins: the id is minted from the first-seen workDir string. expect(list).toHaveLength(1); expect(list[0]?.id).toBe(encodeWorkDirKey(firstSeen)); expect(list[0]?.root).toBe(firstSeen); @@ -509,8 +485,6 @@ describe('WorkspaceService (file-backed)', () => { it('delete tombstones every folded alias so a legacy split cannot resurface', async () => { - // Split legacy state: two registered spellings of one Windows root, plus a - // third spelling remembered only by the session index. const typedRoot = 'C:\\Users\\Foo\\Proj'; const typedId = encodeWorkDirKey(typedRoot); const aliasRoot = 'c:\\Users\\Foo\\Proj'; @@ -540,9 +514,6 @@ describe('WorkspaceService (file-backed)', () => { const registry = build(); await registry.delete(typedId); - // The directory itself is gone (unrelated entries survive); nothing - // identity-matching the deleted root remains, and every id that could - // carry it is tombstoned so the merge cannot resurrect it. const stillListed = (await registry.list()).filter( (w) => workspaceRootKey(w.root) === workspaceRootKey(typedRoot), ); @@ -554,8 +525,6 @@ describe('WorkspaceService (file-backed)', () => { [typedId, aliasId, indexOnlyId].toSorted(), ); - // A fresh process (merge re-runs against the session index) does not - // bring the directory back either. const reopened = restart(); const relisted = (await reopened.list()).filter( (w) => workspaceRootKey(w.root) === workspaceRootKey(typedRoot), @@ -574,7 +543,6 @@ describe('workspaceRootKey', () => { }); it('folds drive roots before separator stripping can mask the shape', () => { - // `C:\` would strip to `C:` and stop reading as Windows-shaped. expect(workspaceRootKey('C:\\')).toBe('c:'); expect(workspaceRootKey('C:\\')).toBe(workspaceRootKey('c:\\')); expect(workspaceRootKey('C:\\')).toBe(workspaceRootKey('c:/')); diff --git a/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts b/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts index d1aac4f4fed..edfc0a8ff92 100644 --- a/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts @@ -3,9 +3,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { promises as fsp } from 'node:fs'; import os from 'node:os'; import { join } from 'node:path'; - +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, @@ -98,9 +97,6 @@ describe('WorkspaceAliasesService (file-backed)', () => { } it('resolveAliasIds returns every registered id for one physical directory', async () => { - // A legacy catalog holds two entries whose roots differ only by casing — - // one physical folder, two bucket ids (this is what `dedupeByRoot` merges - // for listing; the alias set exposes both for multi-bucket reads). const lowerRoot = 'c:\\users\\foo\\proj'; const typedRoot = 'C:\\Users\\Foo\\Proj'; const legacyId = 'wd_proj_deadbeef0002'; @@ -125,9 +121,6 @@ describe('WorkspaceAliasesService (file-backed)', () => { }); it('resolveAliasIds folds in session-index-only spellings of the same root', async () => { - // The sibling bucket's spelling was never registered: only the legacy - // session index remembers it. Malformed index lines are skipped, never - // thrown. const typedRoot = 'C:\\Users\\Foo\\Proj'; const typedId = encodeWorkDirKey(typedRoot); const indexOnlyId = encodeWorkDirKey('c:\\Users\\Foo\\Proj'); @@ -165,11 +158,9 @@ describe('WorkspaceAliasesService (file-backed)', () => { }); const aliases = build(); - // Unknown id: callers keep their existing not-found semantics. expect(await aliases.resolveAliasIds('wd_missing_000000000000')).toEqual([ 'wd_missing_000000000000', ]); - // POSIX roots never fold, so the alias set is just the id itself. expect(await aliases.resolveAliasIds(id)).toEqual([id]); }); }); diff --git a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts index 40d355ad604..562cdb00b08 100644 --- a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, @@ -81,7 +80,6 @@ function hostEnvironmentStub(): IHostEnvironment { }; } -/** Catalog stub that mints `encodeWorkDirKey` ids and records createOrTouch calls. */ function catalogStub() { const workspaces = new Map(); const createOrTouch = vi.fn((root: string, name?: string) => { @@ -431,8 +429,6 @@ describe('WorkspaceLifecycleService', () => { const again = await lifecycle.handlerFor({ workspaceId: encodeWorkDirKey('/tmp/proj') }); expect(again).toBe(handler); - // A live handler is returned as-is — no catalog write beyond the initial - // materialization. expect(createOrTouchSpy).toHaveBeenCalledTimes(1); }); @@ -530,7 +526,6 @@ describe('WorkspaceLifecycleService', () => { const first = await lifecycle.handlerFor({ root: '/tmp/proj' }); await first.accessor.get(ISessionLifecycleService).create({ sessionId: 's1', workDir: '/tmp/proj' }); - // Materialized AFTER the follow subscription — still observed. const second = await lifecycle.handlerFor({ root: '/tmp/other' }); await second.accessor.get(ISessionLifecycleService).create({ sessionId: 's2', workDir: '/tmp/other' }); diff --git a/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts b/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts index 77c0636d323..bcd6c220289 100644 --- a/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, diff --git a/packages/agent-core-v2/test/debug/debug.test.ts b/packages/agent-core-v2/test/debug/debug.test.ts new file mode 100644 index 00000000000..1af5c8de357 --- /dev/null +++ b/packages/agent-core-v2/test/debug/debug.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from 'vitest'; + +import { collection, type CollectionView } from '#/_base/di/collection'; +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { createDecorator } from '#/_base/di/instantiation'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import { Service } from '#/_base/di/service'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; +import { Emitter } from '#/_base/event'; +import type { DomainEvent, IEventService } from '#/app/event/event'; +import { DI_UNIT_CHANGED_EVENT } from '#/debug/debugCascade'; +import { DebugCascadeService } from '#/debug/debugCascadeService'; +import { DebugGraphService } from '#/debug/debugGraphService'; +import { DebugLedgerService } from '#/debug/debugLedgerService'; + + +interface IRoot { + label: string; +} +const IRoot = createDecorator('debug-root'); + +interface IMid { + root: IRoot; +} +const IMid = createDecorator('debug-mid'); + +interface IBoom { + marker: string; +} +const IBoom = createDecorator('debug-boom'); + +interface IFold { + marker: string; +} +const IFold = createDecorator('debug-fold'); + +const ToolContribution = collection<{ name: string }>('debug-tool-contribution'); + +class Root implements IRoot { + label = 'root'; + dispose(): void {} +} + +class Mid implements IMid { + constructor(@IRoot readonly root: IRoot) {} + dispose(): void {} +} + +class Boom implements IBoom { + marker = 'boom'; + constructor() { + throw new Error('boom construction failed'); + } +} + +class Fold extends Service implements IFold { + marker = 'fold'; + constructor(@ToolContribution readonly view: CollectionView<{ name: string }>) { + super(); + } +} + +class FakeEventService implements IEventService { + declare readonly _serviceBrand: undefined; + private readonly emitter = new Emitter(); + readonly onDidPublish = this.emitter.event; + readonly published: DomainEvent[] = []; + publish(event: DomainEvent): void { + this.published.push(event); + this.emitter.fire(event); + } + subscribe(handler: (event: DomainEvent) => void) { + return this.emitter.event(handler); + } +} + +function makeTree(): { app: InstantiationService; ws: InstantiationService } { + const app = new InstantiationService(new ServiceCollection(), true); + app.debugLabel = 'app'; + const ws = app.createChild(new ServiceCollection()) as InstantiationService; + ws.debugLabel = 'workspace:ws1'; + return { app, ws }; +} + + +describe('debug domain — IDebugLedgerService', () => { + it('tree() exposes units, ledger entries, and children recursively', () => { + const { app, ws } = makeTree(); + app.provide(IRoot, new SyncDescriptor(Root)); + ws.provide(IMid, new SyncDescriptor(Mid)); + + const tree = new DebugLedgerService(app).tree(); + + expect(tree.path).toBe('app'); + expect(tree.label).toBe('app'); + const rootUnit = tree.units.find((unit) => unit.token === 'debug-root'); + expect(rootUnit).toMatchObject({ + uid: expect.any(Number), + state: 'Active', + everActive: true, + inFlight: false, + }); + expect(tree.ledger.map((entry) => entry.label)).toContain('provide:debug-root'); + expect(tree.ledger.map((entry) => entry.label)).toContain('service:debug-root'); + + expect(tree.children).toHaveLength(1); + const wsNode = tree.children[0]!; + expect(wsNode.path).toBe('app/workspace:ws1'); + expect(wsNode.label).toBe('workspace:ws1'); + expect(wsNode.units.find((unit) => unit.token === 'debug-mid')).toMatchObject({ + state: 'Active', + }); + expect(() => JSON.stringify(tree)).not.toThrow(); + app.dispose(); + }); +}); + +describe('debug domain — IDebugGraphService', () => { + it('graph() renders instance edges (cross-tree) and collection edges', () => { + const { app, ws } = makeTree(); + app.provide(IRoot, new SyncDescriptor(Root)); + ws.provide(IMid, new SyncDescriptor(Mid)); + app.provide(IFold, new SyncDescriptor(Fold)); + ws.invokeFunction((a) => a.get(IMid)); + app.invokeFunction((a) => a.get(IFold)); + + const graph = new DebugGraphService(app).graph(); + const nodeIds = new Set(graph.nodes.map((node) => node.id)); + expect(nodeIds.has('app::debug-root')).toBe(true); + expect(nodeIds.has('app/workspace:ws1::debug-mid')).toBe(true); + expect(graph.nodes.find((node) => node.id === 'app::debug-root')).toMatchObject({ + token: 'debug-root', + scopePath: 'app', + state: 'Active', + }); + + const instanceEdge = graph.edges.find( + (edge) => + edge.from === 'app/workspace:ws1::debug-mid' && + edge.to === 'app::debug-root' && + edge.kind === 'instance', + ); + expect(instanceEdge).toBeDefined(); + + const collectionEdge = graph.edges.find((edge) => edge.kind === 'collection'); + expect(collectionEdge).toMatchObject({ + from: 'app::debug-fold', + to: 'app::collection:debug-tool-contribution', + }); + expect(nodeIds.has('app::collection:debug-tool-contribution')).toBe(true); + expect(() => JSON.stringify(graph)).not.toThrow(); + app.dispose(); + }); +}); + +describe('debug domain — IDebugCascadeService', () => { + it('history() folds every scope and pending() reports waiting + failed units', () => { + const { app, ws } = makeTree(); + const events = new FakeEventService(); + const service = new DebugCascadeService(app, events); + + ws.provide(IMid, new SyncDescriptor(Mid)); + app.provide(IBoom, new SyncDescriptor(Boom)); + ws.provide(IRoot, new SyncDescriptor(Root)); + + const history = service.history(); + const scopes = new Set(history.map((entry) => entry.scopePath)); + expect(scopes.has('app')).toBe(true); + expect(scopes.has('app/workspace:ws1')).toBe(true); + expect(history.every((entry) => typeof entry.reason === 'string')).toBe(true); + + const pending = service.pending(); + const wsGroup = pending.find((group) => group.scopePath === 'app/workspace:ws1'); + expect(wsGroup?.waiting ?? []).toEqual([]); + const appGroup = pending.find((group) => group.scopePath === 'app'); + expect(appGroup?.failed).toEqual([ + { token: 'debug-boom', error: 'boom construction failed' }, + ]); + app.dispose(); + }); + + it('pending() reports a waiting unit with its missing dependencies', () => { + const { app, ws } = makeTree(); + const service = new DebugCascadeService(app, new FakeEventService()); + ws.provide(IMid, new SyncDescriptor(Mid)); + + const wsGroup = service.pending().find((group) => group.scopePath === 'app/workspace:ws1'); + expect(wsGroup?.waiting).toEqual([{ token: 'debug-mid', missing: ['debug-root'] }]); + app.dispose(); + }); + + it('unprovide/update/dispose triggers drive the public cascade entries', async () => { + const { app, ws } = makeTree(); + const service = new DebugCascadeService(app, new FakeEventService()); + app.provide(IRoot, new SyncDescriptor(Root)); + ws.provide(IMid, new SyncDescriptor(Mid)); + const firstMid = ws.invokeFunction((a) => a.get(IMid)); + + await service.unprovide('app', 'debug-root'); + expect(app.cascade.stateOf(IRoot)).toBeUndefined(); + expect(ws.cascade.stateOf(IMid)).toBe('Pending'); + + app.provide(IRoot, new SyncDescriptor(Root)); + expect(ws.cascade.stateOf(IMid)).toBe('Active'); + await service.update('app', 'debug-root'); + const secondMid = ws.invokeFunction((a) => a.get(IMid)); + expect(secondMid).not.toBe(firstMid); + expect(ws.cascade.stateOf(IMid)).toBe('Active'); + + await service.dispose('app', 'debug-root'); + expect(app.cascade.stateOf(IRoot)).toBeUndefined(); + expect(ws.cascade.stateOf(IMid)).toBe('Pending'); + app.dispose(); + }); + + it('update with a config routes through the fiber host', async () => { + const { app } = makeTree(); + const service = new DebugCascadeService(app, new FakeEventService()); + app.provide(IRoot, new SyncDescriptor(Root)); + await service.update('app', 'debug-root', { tag: 1 }); + expect(app.cascade.stateOf(IRoot)).toBe('Active'); + app.dispose(); + }); + + it('rejects unknown scope paths and tokens with coded errors', async () => { + const { app } = makeTree(); + const service = new DebugCascadeService(app, new FakeEventService()); + app.provide(IRoot, new SyncDescriptor(Root)); + + await expect(service.unprovide('app/nope', 'debug-root')).rejects.toMatchObject({ + code: 'debug.scope_not_found', + }); + await expect(service.update('app', 'debug-nope')).rejects.toMatchObject({ + code: 'debug.token_not_found', + }); + await expect( + (service.dispose as (scopePath?: string) => Promise)('app'), + ).rejects.toMatchObject({ + code: 'debug.token_not_found', + }); + app.dispose(); + }); + + it('publishes event.di.unit_changed for live and late-joined engines until teardown', () => { + const { app } = makeTree(); + const events = new FakeEventService(); + const service = new DebugCascadeService(app, events); + + app.provide(IRoot, new SyncDescriptor(Root)); + const rootEvents = events.published.filter( + (event) => event.type === DI_UNIT_CHANGED_EVENT, + ); + expect(rootEvents).toContainEqual({ + type: DI_UNIT_CHANGED_EVENT, + payload: { scope: 'app', token: 'debug-root', state: 'Active', error: undefined }, + }); + + const ws = app.createChild(new ServiceCollection()) as InstantiationService; + ws.debugLabel = 'workspace:late'; + ws.provide(IMid, new SyncDescriptor(Mid)); + const wsEvents = events.published.filter( + (event) => + event.type === DI_UNIT_CHANGED_EVENT && + (event.payload as { scope?: string }).scope === 'app/workspace:late', + ); + expect(wsEvents.length).toBeGreaterThan(0); + + const publishedBefore = events.published.length; + service.dispose(); + app.provide(IBoom, new SyncDescriptor(Boom)); + expect(events.published.length).toBe(publishedBefore); + app.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/dep-graph/queryParams.test.ts b/packages/agent-core-v2/test/dep-graph/queryParams.test.ts deleted file mode 100644 index b01c8fa5ba6..00000000000 --- a/packages/agent-core-v2/test/dep-graph/queryParams.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { readQueryParams } from '../../scripts/dep-graph/web/src/query-params'; - -describe('readQueryParams', () => { - it('returns an empty object for an empty search string', () => { - expect(readQueryParams('')).toEqual({}); - expect(readQueryParams('?')).toEqual({}); - }); - - it('parses a comma-separated domain list, trimming and deduping', () => { - expect(readQueryParams('?domain=session, sessionMetadata ,session')).toEqual({ - domains: ['session', 'sessionMetadata'], - }); - }); - - it('drops empty entries from a domain list', () => { - expect(readQueryParams('?domain=,session,')).toEqual({ domains: ['session'] }); - }); - - it('omits the field when a list has no valid entries', () => { - expect(readQueryParams('?domain=')).toEqual({}); - expect(readQueryParams('?domain=,,')).toEqual({}); - }); - - it('filters scopes to the known vocabulary', () => { - expect(readQueryParams('?scope=Session,bogus,Agent')).toEqual({ - scopes: ['Session', 'Agent'], - }); - }); - - it('omits scopes when none are valid', () => { - expect(readQueryParams('?scope=bogus')).toEqual({}); - }); - - it('filters edge kinds to the known vocabulary', () => { - expect(readQueryParams('?kind=ctor,nope,publish')).toEqual({ - kinds: ['ctor', 'publish'], - }); - }); - - it('passes through the search string', () => { - expect(readQueryParams('?search=SystemReminder')).toEqual({ - search: 'SystemReminder', - }); - }); - - it('treats a bare hideOrphans flag as true', () => { - expect(readQueryParams('?hideOrphans')).toEqual({ hideOrphans: true }); - expect(readQueryParams('?hideOrphans=')).toEqual({ hideOrphans: true }); - }); - - it('honors explicit false-ish hideOrphans values', () => { - expect(readQueryParams('?hideOrphans=false')).toEqual({ hideOrphans: false }); - expect(readQueryParams('?hideOrphans=0')).toEqual({ hideOrphans: false }); - expect(readQueryParams('?hideOrphans=no')).toEqual({ hideOrphans: false }); - }); - - it('parses groupByScope as a boolean flag', () => { - expect(readQueryParams('?groupByScope=true')).toEqual({ groupByScope: true }); - }); - - it('passes through the focus node id verbatim', () => { - expect(readQueryParams('?focus=Session::IMyService')).toEqual({ - focus: 'Session::IMyService', - }); - }); - - it('combines several params into one overrides object', () => { - expect( - readQueryParams( - '?domain=session,sessionMetadata&scope=Session&kind=ctor&search=meta&hideOrphans&groupByScope=1&focus=Session::ISessionMetadata', - ), - ).toEqual({ - domains: ['session', 'sessionMetadata'], - scopes: ['Session'], - kinds: ['ctor'], - search: 'meta', - hideOrphans: true, - groupByScope: true, - focus: 'Session::ISessionMetadata', - }); - }); -}); diff --git a/packages/agent-core-v2/test/features/feature.test.ts b/packages/agent-core-v2/test/features/feature.test.ts new file mode 100644 index 00000000000..13e25c09424 --- /dev/null +++ b/packages/agent-core-v2/test/features/feature.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { type CollectionToken, type CollectionView } from '#/_base/di/collection'; +import { ScopeUnits } from '#/_base/di/fiber'; +import { createDecorator, ScopeActivation } from '#/_base/di/instantiation'; +import { type InstantiationService } from '#/_base/di/instantiationService'; +import { + _clearScopedRegistryForTests, + registerScopedService, + type Scope, +} from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { createScopedTestHost } from '#/_base/di/test'; +import { AgentProfileContribution } from '#/app/agentProfileCatalog/agentProfileContribution'; +import { ConfigSectionContribution } from '#/app/config/configSectionContributions'; +import { IFeatureManager } from '#/app/feature/featureManager'; +import { FeatureManagerService } from '#/app/feature/featureManagerService'; +import { LifecycleScope } from '#/app/scopes'; +import { AgentToolContribution } from '#/agent/toolRegistry/toolContribution'; +import { Feature } from '#/features/feature'; +import { IFeatureAssemblyService } from '#/features/featureAssembly'; +import { FeatureAssemblyService } from '#/features/featureAssemblyService'; +import { _clearFeatureRecipesForTests, registerFeature } from '#/features/featureRegistry'; +import type { AgentTool, ToolExecution } from '#/tool/toolContract'; + +interface IGreeter { + readonly _serviceBrand: undefined; + greet(): string; +} +const IGreeter = createDecorator('test-feature-greeter'); + +class GreeterService extends Service implements IGreeter { + declare readonly _serviceBrand: undefined; + greet(): string { + return 'hi'; + } +} + +interface ITestTool extends AgentTool {} +const ITestTool = createDecorator('test-feature-tool'); + +class TestTool implements ITestTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TestTool'; + readonly description = 'test tool'; + readonly parameters = {}; + + resolveExecution(): ToolExecution { + return { + approvalRule: this.name, + execute: async () => ({ output: '' }), + }; + } +} + +const TestConfigSchema = { + '~standard': { + validate: (value: unknown) => ({ value }), + }, +} as never; + +function collectionViewOf(scope: Scope, token: CollectionToken): CollectionView { + return (scope.instantiation as InstantiationService).fiberHost.collectionView(token); +} + +describe('Feature — built-in capability assembly (src/features)', () => { + beforeEach(() => { + _clearScopedRegistryForTests(); + _clearFeatureRecipesForTests(); + registerScopedService( + LifecycleScope.App, + IFeatureManager, + FeatureManagerService, + ScopeActivation.OnScopeCreated, + 'feature', + ); + registerScopedService( + LifecycleScope.App, + IFeatureAssemblyService, + FeatureAssemblyService, + ScopeActivation.OnScopeCreated, + 'features', + ); + }); + + it('assembles a registered feature and materializes its contributions per Agent scope', async () => { + const disposed: string[] = []; + + class TestFeature extends Feature { + static override readonly name = 'test-feature'; + + constructor() { + super(); + this.contributeConfig('testFeatureSection', TestConfigSchema, { defaultValue: false }); + this.contributeAgentService(IGreeter, GreeterService); + this.contributeTool(ITestTool, TestTool, { name: 'TestTool' }); + this.contributeProfiles([{ name: 'test-profile' } as never]); + this.onDispose(() => disposed.push('test-feature')); + } + } + registerFeature(TestFeature); + + const host = createScopedTestHost(); + const manager = host.app.accessor.get(IFeatureManager); + expect(manager.units()).toHaveLength(1); + expect(manager.units()[0]!.name).toBe('test-feature'); + + const configView = collectionViewOf(host.app, ConfigSectionContribution); + expect(configView.items.map((item) => item.domain)).toContain('testFeatureSection'); + + const profileView = collectionViewOf(host.app, AgentProfileContribution); + expect(profileView.items).toHaveLength(1); + expect(profileView.items[0]!.sourceId).toBe('feature:test-feature'); + + const agentOne = host.child(LifecycleScope.Agent, 'agent-1'); + const agentTwo = host.child(LifecycleScope.Agent, 'agent-2'); + expect(agentOne.accessor.get(IGreeter).greet()).toBe('hi'); + expect(agentTwo.accessor.get(IGreeter).greet()).toBe('hi'); + expect(agentOne.accessor.get(IGreeter)).not.toBe(agentTwo.accessor.get(IGreeter)); + + const toolView = collectionViewOf(agentOne, AgentToolContribution); + expect(toolView.items).toHaveLength(1); + expect(toolView.items[0]!.options.name).toBe('TestTool'); + expect(agentOne.accessor.get(ITestTool).name).toBe('TestTool'); + + await manager.unprovideUnit('test-feature'); + await host.app.instantiation.cascade.whenIdle(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(manager.units()).toHaveLength(0); + expect(disposed).toEqual(['test-feature']); + expect(configView.items.map((item) => item.domain)).not.toContain('testFeatureSection'); + expect(profileView.items).toHaveLength(0); + expect(toolView.items).toHaveLength(0); + expect(() => agentOne.accessor.get(IGreeter)).toThrow(); + expect(() => agentOne.accessor.get(ITestTool)).toThrow(); + + host.dispose(); + }); + + it('materializes a per-scope class recipe contributed through contribute()', () => { + class SoloAgentUnit extends Service { + static override readonly name = 'solo-feature/agent'; + + constructor() { + super(); + this.provide(IGreeter, GreeterService); + } + } + class SoloFeature extends Feature { + static override readonly name = 'solo-feature'; + + constructor() { + super(); + this.contribute(ScopeUnits(LifecycleScope.Agent), SoloAgentUnit); + } + } + registerFeature(SoloFeature); + + const host = createScopedTestHost(); + const agent = host.child(LifecycleScope.Agent, 'agent-1'); + expect(agent.accessor.get(IGreeter).greet()).toBe('hi'); + host.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/agent/plan/injection/planModeInjection.test.ts b/packages/agent-core-v2/test/features/plan/injection/planModeInjection.test.ts similarity index 99% rename from packages/agent-core-v2/test/agent/plan/injection/planModeInjection.test.ts rename to packages/agent-core-v2/test/features/plan/injection/planModeInjection.test.ts index 87df3bd2900..f1cd3090479 100644 --- a/packages/agent-core-v2/test/agent/plan/injection/planModeInjection.test.ts +++ b/packages/agent-core-v2/test/features/plan/injection/planModeInjection.test.ts @@ -4,7 +4,7 @@ import { createFakeHostFs } from '../../../tools/fixtures/fake-exec'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IAgentPlanService } from '#/agent/plan/plan'; +import { IAgentPlanService } from '#/features/plan/plan'; import { createTestAgent, execEnvServices, diff --git a/packages/agent-core-v2/test/agent/plan/plan.test.ts b/packages/agent-core-v2/test/features/plan/plan.test.ts similarity index 98% rename from packages/agent-core-v2/test/agent/plan/plan.test.ts rename to packages/agent-core-v2/test/features/plan/plan.test.ts index cb2dccf472e..909ec4c82b2 100644 --- a/packages/agent-core-v2/test/agent/plan/plan.test.ts +++ b/packages/agent-core-v2/test/features/plan/plan.test.ts @@ -2,19 +2,13 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; -// Imported first on purpose: OnScopeCreated services activate 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, join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { IAgentPlanService, type PlanData } from '#/agent/plan/plan'; +import { IAgentPlanService, type PlanData } from '#/features/plan/plan'; import { IAgentPermissionRulesService } from '#/agent/permissionRules/permissionRules'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; @@ -340,8 +334,6 @@ describe('Plan service', () => { const content = '# Plan\n\n- After restore'; files.set(planPath, content); - // Simulate a restored v1 fact: replay applies the record to the model - // without re-recording a snapshot entry. await ctx.dispatch({ type: 'plan.revision', id: 'rev-plan', @@ -413,7 +405,6 @@ describe('Plan service', () => { const first = await ctx.takeApprovalRequest(); first.respond({ decision: 'rejected', selectedLabel: 'Revise', feedback: 'Tighten it.' }); - // Set synchronously so the resubmission resolves against the revision. files.set(planPath, '# Plan\n\n- Tightened'); const second = await ctx.takeApprovalRequest(); @@ -695,9 +686,6 @@ describe('Plan service', () => { await ctx.untilTurnEnd(); - // The plan-guard hook lets plan-file writes through before the - // permission chain runs, so user-configured deny rules no longer - // adjudicate them. expect(files.get(planPath)).toBe(content); expect(writeText).toHaveBeenCalledWith(planPath, content); expect(toolResultText(context.get())).not.toContain('denied by permission rule'); diff --git a/packages/agent-core-v2/test/agent/plan/planGuard.test.ts b/packages/agent-core-v2/test/features/plan/planGuard.test.ts similarity index 97% rename from packages/agent-core-v2/test/agent/plan/planGuard.test.ts rename to packages/agent-core-v2/test/features/plan/planGuard.test.ts index fdf292ac547..9a22409ebb7 100644 --- a/packages/agent-core-v2/test/agent/plan/planGuard.test.ts +++ b/packages/agent-core-v2/test/features/plan/planGuard.test.ts @@ -9,7 +9,7 @@ * stub; a stand-in listener registered after the plan listener proves * whether the guard ended adjudication (veto/allow) or abstained; * `IAgentToolApprovalService` is a recording stub. - * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run test/agent/plan/planGuard.test.ts`. + * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run test/features/plan/planGuard.test.ts`. */ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; @@ -25,8 +25,8 @@ import type { PermissionPolicyResolution, PermissionPolicyResult, } from '#/agent/permissionPolicy/types'; -import { IAgentPlanService } from '#/agent/plan/plan'; -import { AgentPlanService } from '#/agent/plan/planService'; +import { IAgentPlanService } from '#/features/plan/plan'; +import { AgentPlanService } from '#/features/plan/planService'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; @@ -46,8 +46,8 @@ import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; import { createFakeHostFs } from '../../tools/fixtures/fake-exec'; import { registerTestAgentWireServices } from '../../wire/stubs'; -import { stubPermissionModeService } from '../permissionMode/stubs'; -import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../toolExecutor/stubs'; +import { stubPermissionModeService } from '../../agent/permissionMode/stubs'; +import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../../agent/toolExecutor/stubs'; const signal = new AbortController().signal; const SESSION_DIR = '/session'; @@ -217,9 +217,6 @@ describe('AgentPlanService plan-guard listener', () => { async function run( ctx: ResolvedToolExecutionHookContext, ): Promise { - // The stand-in must land after the plan-guard listener, so register it - // lazily on first fire — every test constructs the plan service (which - // registers the guard) before firing. if (!permissionStandInRegistered) { permissionStandInRegistered = true; disposables.add( diff --git a/packages/agent-core-v2/test/agent/plan/planOps.test.ts b/packages/agent-core-v2/test/features/plan/planOps.test.ts similarity index 98% rename from packages/agent-core-v2/test/agent/plan/planOps.test.ts rename to packages/agent-core-v2/test/features/plan/planOps.test.ts index f3fe745f85b..9f82af3fad3 100644 --- a/packages/agent-core-v2/test/agent/plan/planOps.test.ts +++ b/packages/agent-core-v2/test/features/plan/planOps.test.ts @@ -12,7 +12,7 @@ import { planModeEnter, planModeExit, planRevision, -} from '#/agent/plan/planOps'; +} from '#/features/plan/planOps'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; @@ -247,8 +247,6 @@ describe('plan ops (wire-backed)', () => { revisionCount: { p1: 1 }, }); - // Re-entering the same plan id continues the counter instead of - // restarting it, so later revisions never overwrite earlier blobs. host.wire.dispatch(planModeEnter({ id: 'p1' })); expect(host.wire.getModel(PlanModel).current.revisionCount).toEqual({ p1: 1 }); diff --git a/packages/agent-core-v2/test/agent/plan/tools/exit-plan-mode.test.ts b/packages/agent-core-v2/test/features/plan/tools/exit-plan-mode.test.ts similarity index 93% rename from packages/agent-core-v2/test/agent/plan/tools/exit-plan-mode.test.ts rename to packages/agent-core-v2/test/features/plan/tools/exit-plan-mode.test.ts index 72c2cacdefd..5f8497795d5 100644 --- a/packages/agent-core-v2/test/agent/plan/tools/exit-plan-mode.test.ts +++ b/packages/agent-core-v2/test/features/plan/tools/exit-plan-mode.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import type { IAgentPlanService, PlanData } from '#/agent/plan/plan'; +import type { IAgentPlanService, PlanData } from '#/features/plan/plan'; import { ExitPlanModeInputSchema, type ExitPlanModeInput, -} from '#/agent/tools/plan/exit-plan-mode/exit-plan-mode'; -import { ExitPlanModeTool } from '#/agent/tools/plan/exit-plan-mode/exitPlanModeTool'; +} from '#/features/plan/tools/exit-plan-mode/exit-plan-mode'; +import { ExitPlanModeTool } from '#/features/plan/tools/exit-plan-mode/exitPlanModeTool'; import type { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -175,8 +175,6 @@ describe('ExitPlanMode option output', () => { ); expect(result.isError).toBeFalsy(); - // In auto permission mode no interactive review can happen, so the - // output must not read as if the user had approved the plan. expect(result.output).toContain('## Plan (auto-approved, not user-reviewed):'); expect(result.output).not.toContain('## Approved Plan:'); expect(result.output).toContain('the user has NOT explicitly approved it'); @@ -197,9 +195,6 @@ describe('ExitPlanMode option output', () => { ); expect(result.isError).toBeFalsy(); - // Outside auto mode the direct-execution path means a configured or - // session allow/ask rule approved the call — an explicit user decision, - // so the output keeps the user-approved wording. expect(result.output).toContain('## Approved Plan:'); expect(result.output).not.toContain('auto-approved'); expect(telemetry.track2).toHaveBeenCalledWith('plan_resolved', { diff --git a/packages/agent-core-v2/test/agent/plan/tools/plan-tools-telemetry.test.ts b/packages/agent-core-v2/test/features/plan/tools/plan-tools-telemetry.test.ts similarity index 97% rename from packages/agent-core-v2/test/agent/plan/tools/plan-tools-telemetry.test.ts rename to packages/agent-core-v2/test/features/plan/tools/plan-tools-telemetry.test.ts index 6672a56e4ef..3c7741d7cf6 100644 --- a/packages/agent-core-v2/test/agent/plan/tools/plan-tools-telemetry.test.ts +++ b/packages/agent-core-v2/test/features/plan/tools/plan-tools-telemetry.test.ts @@ -1,10 +1,10 @@ import type { ToolCall } from '#/kosong/contract/message'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { IAgentPlanService, PlanData } from '#/agent/plan/plan'; -import { EnterPlanModeTool } from '#/agent/tools/plan/enter-plan-mode/enterPlanModeTool'; -import { type ExitPlanModeInput } from '#/agent/tools/plan/exit-plan-mode/exit-plan-mode'; -import { ExitPlanModeTool } from '#/agent/tools/plan/exit-plan-mode/exitPlanModeTool'; +import type { IAgentPlanService, PlanData } from '#/features/plan/plan'; +import { EnterPlanModeTool } from '#/features/plan/tools/enter-plan-mode/enterPlanModeTool'; +import { type ExitPlanModeInput } from '#/features/plan/tools/exit-plan-mode/exit-plan-mode'; +import { ExitPlanModeTool } from '#/features/plan/tools/exit-plan-mode/exitPlanModeTool'; import type { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { ToolResult } from '#/tool/toolContract'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index fea6cd89b76..4321092a650 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -36,7 +36,7 @@ import { ISessionInstructionsProvider } from '#/session/sessionInstructions/inst import { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; import type { PermissionData, PermissionMode } from '#/agent/permissionPolicy/types'; import type { PermissionRule } from '#/agent/permissionRules/permissionRules'; -import { IAgentPlanService, type PlanData } from '#/agent/plan/plan'; +import { IAgentPlanService, type PlanData } from '#/features/plan/plan'; import { IAgentProfileService, type AgentConfigData } from '#/agent/profile/profile'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentPromptService } from '#/agent/prompt/prompt'; @@ -248,11 +248,6 @@ interface ProviderConfigForConfig { }; } -/** - * Harness-local provider descriptor for `configureRuntimeModel`: the vendor - * the scripted provider poses as (`type` = providerType), the wire-facing - * model name, and the endpoint to seed into the test config. - */ interface TestProviderConfig { readonly type: string; readonly model: string; @@ -313,12 +308,6 @@ type RpcPromise = Promise & { reject(reason?: unknown): void; }; -/** - * Wire signatures of the methods removed from `AgentAPI` for being pure - * forwards to domain services. The harness keeps `ctx.rpc` backward - * compatible by re-declaring them here and adapting each onto the - * corresponding domain service in `createPromiseAgentApi`. - */ interface AgentRpcPassthroughAPI { runShellCommand: (payload: RunShellCommandPayload) => Promisable; cancelShellCommand: (payload: CancelShellCommandPayload) => void; @@ -702,8 +691,6 @@ export function cronServices(): TestAgentServiceOverride { export function mcpServices(options: { readonly manager?: McpConnectionManager; }): TestAgentServiceOverride { - // `AgentMcpService` resolves the workspace's shared manager through the - // seeded `ISessionMcpHandle`; tests inject a fake manager by stubbing it. return sessionService(ISessionMcpHandle, { _serviceBrand: undefined, ready: Promise.resolve(), @@ -944,15 +931,6 @@ class ConfigBackedModelCatalog extends ModelCatalog { super(providerRegistry, modelRegistry, oauthTokens, protocolRegistry, hostRequestHeaders); } - /** - * The harness mutates `kimiConfig` BEHIND the config services' backs (no - * section-change events fire), so nothing pushes the new values into the - * kosong registries. Re-hydrate them from the live config view before every - * read: `loadAll` is deep-equal-aware, so an unchanged config is a no-op - * and a changed one fires the diff events that drop the assembled-Model - * cache — preserving the old read-config-live semantics through the new - * in-memory registries. - */ private syncRegistriesFromConfig(): void { this.providerRegistry.loadAll( this.config.get(PROVIDERS_SECTION) ?? {}, @@ -1149,10 +1127,6 @@ export class AgentTestContext { ); this.root = createAppScope({ extra: appSeeds }); - // Hydrate the kosong registries from the (possibly overridden) config so - // direct IProviderService/IModelService reads work before the first - // catalog access; ConfigBackedModelCatalog re-syncs on every read after - // that (the harness mutates kimiConfig behind the config events' backs). const initialConfig = this.root.accessor.get(IConfigService); this.root.accessor .get(IProviderService) @@ -1197,13 +1171,6 @@ export class AgentTestContext { reg.defineInstance(ISessionInteractionService, this.createInteractionService()); reg.defineInstance(ISessionApprovalService, this.createApprovalService()); reg.defineInstance(ISessionQuestionService, this.createQuestionService()); - // Workspace-resource injection contracts (the seeds the real - // handler hands each session): the harness has no Workspace - // scope, so it seeds equivalents directly — an empty skill - // catalog, the workspace key the Session agent-profile catalog - // reads the App registry with, a live-read AGENTS.md - // provider, and a no-server MCP manager. Tests replace them - // through the usual service overrides. reg.defineInstance(ISessionSkillCatalogData, { _serviceBrand: undefined, ready: Promise.resolve(), @@ -1226,9 +1193,6 @@ export class AgentTestContext { additionalDirs: [], onDidChange: Event.None as Event, } satisfies ISessionWorkspaceInfo); - // The harness skips the Workspace scope entirely, so the session - // state service's cascade parent is seeded directly: a workspace - // state instance chained onto the App-scope root. reg.defineInstance( IWorkspaceStateService, new WorkspaceStateService(this.root.accessor.get(IAppStateService)), @@ -1321,8 +1285,6 @@ export class AgentTestContext { }); this.initializeRestorableServices(); - // Resolve the activity view so its constructor subscriptions publish - // `agent.activity.updated` — production ignites it in agentLifecycle. this.get(IAgentActivityView); const eventBus = this.get(IEventBus); @@ -1402,10 +1364,6 @@ export class AgentTestContext { const permissionRules = this.get(IAgentPermissionRulesService); const cron = this.get(ISessionCronService); const plan = this.get(IAgentPlanService); - // Activate the AgentTool contributions before any profile allowlist is - // applied by `configure()` — at this point `activeToolNames` is still - // undefined, so every contribution whose `when` holds lands in the - // registry, matching the harness's historical all-tools behavior. void this.get(IAgentToolActivationService).activate(); this.get(IAgentToolDedupeService); this.get(IAgentExternalHooksService); @@ -1475,20 +1433,11 @@ export class AgentTestContext { modelCapabilities?: ModelCapability | undefined, ): void { this.kimiConfig = configWithProvider(this.kimiConfig, provider, modelCapabilities); - // The harness swaps config BEHIND the config services' backs, so no - // change events fire — drop the assembled-Model cache by hand (the - // load-bearing ModelCatalog contract), or the next `get` keeps serving - // the entry assembled from the previous config. (this.get(IModelCatalog) as ModelCatalog).notifyConfigChanged(); const profile = this.get(IAgentProfileService); profile.update({ modelAlias: provider.model }); } - /** - * The manual cache-drop for tests that mutate `kimiConfig` behind the - * config services' backs (no change events fire): the ModelCatalog keeps - * serving the previously assembled Model until this is called. - */ notifyModelConfigChanged(): void { (this.get(IModelCatalog) as ModelCatalog).notifyConfigChanged(); } @@ -1583,7 +1532,6 @@ export class AgentTestContext { return this.snapshots.until('turn.ended'); } - /** The agent's persisted wire journal (drains the persistence queue first). */ async persistedWireRecords(): Promise { await this.drainWirePersistence(); return this.persistedRecords(); @@ -1921,12 +1869,6 @@ export class AgentTestContext { this.snapshots.respondPending(method, id, result); } - /** - * The harness AGENTS.md provider: no Workspace scope and no fs watch exist - * here, so `ready` re-reads the instruction files on every await and the - * getters expose the freshest load — the same freshness the old per-prompt - * disk read gave prompt builds. - */ private createInstructionsProvider(): ISessionInstructionsProvider { const fs = this.root.accessor.get(IHostFileSystem); const env = this.root.accessor.get(IHostEnvironment); @@ -2098,12 +2040,6 @@ export class AgentTestContext { }) as unknown as PromiseAgentAPI; } - /** - * Adapters for the wire methods removed from `AgentRPCService` as pure - * forwards. Each mirrors the forward the RPC service used to implement - * (including the `beginCompaction` manual source, the `stopTask` reason - * branch, and the `setActiveTools` profile mapping). - */ private createRpcPassthroughAdapters(): AgentRpcPassthroughAPI { return { runShellCommand: (payload) => this.get(IAgentShellCommandService).run(payload), @@ -2437,10 +2373,6 @@ function applyTestAgentOptionsToConfig(config: KimiConfig, options: TestAgentOpt } function configService(readConfig: () => KimiConfig): IConfigService { - // Mirror the production overlay chain: the secondary-model recipe - // materializes its derived entry into the effective models view, so - // spawn-time binding resolves it exactly as in production. Top-level - // shallow clone only — `apply` replaces (never mutates) section values. const effectiveConfig = () => { const effective = { ...configWithEnvOverrides(readConfig()) } as Record; secondaryModelOverlay.apply(effective, () => undefined, (_domain, value) => value); @@ -2688,24 +2620,6 @@ function createLogService(logger: Logger | undefined, bindings: LogContext = {}) }; } -/** - * The harness protocol registry: identity/capability resolution delegates to - * the real `ProtocolAdapterRegistry` (so vendor verdicts like Kimi thinking - * semantics stay truthful), while `createChatProvider` returns a provider - * driven by the scripted `GenerateFn`. - * - * For a registered vendor (`providerType` with a provider definition — today - * only `kimi`) `createChatProvider` composes the REAL provider through the - * registry and replaces only its `generate` (appendix B item 10), so the - * test-visible provider has the production shape: the base's `name` - * (`'openai'`, never `'kimi'`), trait-bound capabilities (`uploadVideo`), - * and no vendor subclass. Unregistered provider types keep the generic - * generate-backed provider. - * - * Either way the per-turn `GenerateOptions` intent fields (cacheKey / - * sampling / thinking / budget) are forwarded into the `GenerateFn` so tests - * assert them as request parameters instead of morph-era provider state. - */ function createGenerateBackedProtocolRegistry(generate: GenerateFn): IProtocolAdapterRegistry { const real = new ProtocolAdapterRegistry(); return { @@ -2728,12 +2642,6 @@ function createGenerateBackedProtocolRegistry(generate: GenerateFn): IProtocolAd } as IProtocolAdapterRegistry; } -/** - * The real composed provider with only `generate` swapped for the scripted - * driver. Everything else — `name`, `thinkingEffort`, `maxCompletionTokens`, - * the trait-bound `uploadVideo` — delegates to the composed provider, and the - * scripted `GenerateFn` receives the composed provider as its `chat` argument. - */ function replaceProviderGenerate(provider: ChatProvider, generate: GenerateFn): ChatProvider { const replaced: ChatProvider = { get name() { @@ -2804,9 +2712,6 @@ async function generateBackedResponse( { signal: options?.signal, auth: options?.auth, - // Forward the per-turn intent fields so tests assert them as request - // parameters — the replacement for morph-era provider state - // (`_generationKwargs` / `modelParameters` / baked `thinkingEffort`). cacheKey: options?.cacheKey, sampling: options?.sampling, thinking: options?.thinking, @@ -2814,9 +2719,6 @@ async function generateBackedResponse( usedContextTokens: options?.usedContextTokens, maxContextTokens: options?.maxContextTokens, responseFormat: options?.responseFormat, - // Forward the early-capture hook so a GenerateFn can fire the trace id - // as soon as its (simulated) response headers arrive — e.g. before a - // mid-stream failure — mirroring real kosong generate() behavior. onTraceId: options?.onTraceId, }, ); diff --git a/packages/agent-core-v2/test/index.test.ts b/packages/agent-core-v2/test/index.test.ts index 1c8719f2b66..d9a583e2157 100644 --- a/packages/agent-core-v2/test/index.test.ts +++ b/packages/agent-core-v2/test/index.test.ts @@ -64,23 +64,11 @@ const V1_RECORD_TYPES: ReadonlySet = new Set([ 'llm.request', 'mcp.tools_discovered', ]); -// `profile.bind` is deliberately classified v2-only: v1's replay switch has no -// case for it and silently skips the record, so a v1 resume of a v2-bound -// session loses the binding (model / prompt / tool policy), and v1's -// empty-prompt fallback then writes builtin defaults back into the shared -// wire, overwriting the binding for later v2 resumes too. Accepted tradeoff -// for the custom-agent rollout; revisit by teaching v1 to replay the record -// rather than by dual-writing v1-shaped companions from v2. const V2_ONLY_RECORD_TYPES: ReadonlySet = new Set([ 'tools.reset_active_tools', 'profile.bind', ]); -// Persisted record types introduced after the v1 vocabulary: the task -// lifecycle journal (the restore seed for ghosts and the cold transcript -// fold), the interaction request/resolution journal, the plan revision -// reference journal, and the terminal turn record. Replay tolerates unknown -// record types (skip + warn), so older readers degrade gracefully. const V2_RECORD_TYPES: ReadonlySet = new Set([ 'task.started', 'task.terminated', @@ -173,12 +161,7 @@ describe('v1 wire vocabulary', () => { }); describe('conversation-time checkpoint registration', () => { - // Models that react to context.* records but deliberately stay on world time - // (ephemeral notice state that must not travel through undo) are exempt. - // Registering a new context-reacting model without `defineCheckpointedModel` - // fails this test — add the name here only with a justification. const CHECKPOINT_EXEMPT_MODELS: ReadonlySet = new Set([ - // goalForkNotice is one-shot reminder bookkeeping, not conversation state. 'goalForkNotice', ]); const CONTEXT_OPS = [ @@ -199,7 +182,6 @@ describe('conversation-time checkpoint registration', () => { violations.push(`${entry.model.name} (on ${opType})`); } } - // Guard against a vacuous pass when module loading changes. expect(entries).toBeGreaterThan(0); expect(violations).toEqual([]); }); @@ -246,7 +228,6 @@ describe('AgentRecords persistence metadata', () => { expectResumeMatches = false; await ctx.restorePersisted(); - // The envelope was synthesized and rewritten ahead of the records. expect(persistence.records.map((record) => record.type)).toEqual([ 'metadata', 'context.append_message', @@ -255,7 +236,6 @@ describe('AgentRecords persistence metadata', () => { type: 'metadata', protocol_version: WIRE_PROTOCOL_VERSION, }); - // And the orphaned message landed in the restored context. expect(ctx.context.get()).toHaveLength(1); }); diff --git a/packages/agent-core-v2/test/kosong/contract/errors.test.ts b/packages/agent-core-v2/test/kosong/contract/errors.test.ts index 2bf3efc7587..a6e9537f5a8 100644 --- a/packages/agent-core-v2/test/kosong/contract/errors.test.ts +++ b/packages/agent-core-v2/test/kosong/contract/errors.test.ts @@ -27,8 +27,6 @@ import { throwIfAbortError, } from '#/kosong/contract/errors'; -// Mirrors the OpenAI/Anthropic SDKs' abort class: the contract recognizes it -// structurally by constructor name, without importing any SDK. class APIUserAbortError extends Error { constructor(message = 'Request was aborted.') { super(message); @@ -91,15 +89,11 @@ describe('throwIfAbortError', () => { }); it('wins at the front of a classification chain, even over network-looking messages', () => { - // A miniature stand-in for a provider error converter: the abort guard - // runs first, then transport heuristics would classify by message. const convert = (error: unknown): ChatProviderError => { throwIfAbortError(error); return new APIConnectionError((error as Error).message); }; - // The abort message mentions a dropped connection — without the guard - // first, this would be misclassified as a retryable connection error. const abort = new APIUserAbortError('connection aborted by user'); let caught: unknown; try { @@ -174,7 +168,6 @@ describe('classifyApiError', () => { expect(classifyApiError(new APIStatusError(403, 'Forbidden')).kind).toBe('auth'); expect(classifyApiError(new APIStatusError(500, 'Internal')).kind).toBe('5xx_server'); expect(classifyApiError(new APIStatusError(422, 'Nope')).kind).toBe('4xx_client'); - // A 413 phrased as token overflow routes to compaction, not 4xx. expect(classifyApiError(new APIStatusError(413, 'Request exceeds the maximum size')).kind).toBe( '4xx_client', ); diff --git a/packages/agent-core-v2/test/kosong/contract/generate.test.ts b/packages/agent-core-v2/test/kosong/contract/generate.test.ts index 20aecc8d42d..68fcfd5a1ad 100644 --- a/packages/agent-core-v2/test/kosong/contract/generate.test.ts +++ b/packages/agent-core-v2/test/kosong/contract/generate.test.ts @@ -159,7 +159,6 @@ describe('generate() stream normalization', () => { { onMessagePart: (part) => { seenParts.push(structuredClone(part)); - // Mutating the callback's copy must not corrupt the driver's merge. if (part.type === 'text') part.text = 'MUTATED'; }, onToolCall: (call) => { diff --git a/packages/agent-core-v2/test/kosong/contract/usage-tokens.test.ts b/packages/agent-core-v2/test/kosong/contract/usage-tokens.test.ts index 10c8e510f9d..ecc4448cd84 100644 --- a/packages/agent-core-v2/test/kosong/contract/usage-tokens.test.ts +++ b/packages/agent-core-v2/test/kosong/contract/usage-tokens.test.ts @@ -55,7 +55,7 @@ describe('estimateTokens', () => { it('estimates non-ASCII at one token per character', () => { expect(estimateTokens('你好')).toBe(2); - expect(estimateTokens('ab你')).toBe(2); // ceil(2/4) + 1 + expect(estimateTokens('ab你')).toBe(2); }); }); @@ -81,8 +81,6 @@ describe('estimateTokensForMessage(s)', () => { toolCalls: [], }; const first = estimateTokensForMessage(message); - // The WeakMap memo returns the cached estimate for the same object even - // when the content is later mutated. message.content.push({ type: 'text', text: 'mutated after the fact' }); expect(estimateTokensForMessage(message)).toBe(first); expect(estimateTokensForMessages([message, message])).toBe(first * 2); diff --git a/packages/agent-core-v2/test/kosong/model/catalog.test.ts b/packages/agent-core-v2/test/kosong/model/catalog.test.ts index 0a65014f299..b450289521a 100644 --- a/packages/agent-core-v2/test/kosong/model/catalog.test.ts +++ b/packages/agent-core-v2/test/kosong/model/catalog.test.ts @@ -96,8 +96,6 @@ function createHost( [IModelOAuthTokens, oauthTokens], [IHostRequestHeaders, hostHeadersPort(hostHeaders)], ]); - // Kosong's registries are pure in-memory stores now (persistence lives in - // the app/kosongConfig bridge): seed them from the fixture sections. const providers = host.app.accessor.get(IProviderService); providers.loadAll( (sections['providers'] ?? {}) as ProvidersSection, @@ -126,12 +124,6 @@ const kimiSections: Record = { }, }; -/** - * Mutate the model registry store WITHOUT firing the change events — the - * silent-write escape hatch for the cache-invalidation tests (replaces the - * old `StubConfigService.setSilent`, which the in-memory registries can no - * longer see). - */ function silentModelWrite(models: IModelService, records: Record): void { (models as unknown as { models: Record }).models = records; } @@ -161,7 +153,6 @@ describe('Model assembly (pure data)', () => { expect(model.baseUrl).toBe('https://api.moonshot.ai/v1'); expect(model.maxContextSize).toBe(262144); expect(model.capabilities.max_context_tokens).toBe(262144); - // Kimi's definition declares `hostHeaders: 'full'`. expect(model.headers).toMatchObject({ 'User-Agent': 'kimi-test/1.0', 'X-Msh-Device-Id': 'device-1', @@ -307,9 +298,7 @@ describe('Model assembly (pure data)', () => { const model = catalog.get('k2'); expect(model.protocol).toBe('anthropic'); expect(model.providerType).toBe('kimi'); - // Anthropic base URLs strip the trailing `/v1`. expect(model.baseUrl).toBe('https://api.example.test'); - // Kimi thinking is trait-driven: no Anthropic effort profile is inferred. expect(model.supportEfforts).toBeUndefined(); } finally { host.dispose(); @@ -414,13 +403,11 @@ describe('Model assembly (pure data)', () => { project: 'my-project', location: 'us-central1', }); - // The location is also discovered from a vertex-style baseUrl host. expect(catalog.get('v2').providerOptions).toEqual({ vertexai: true, project: 'my-project', location: 'us-east4', }); - // Without both coordinates there is no vertex mode and no options bag. expect(catalog.get('g').providerOptions).toBeUndefined(); } finally { host.dispose(); @@ -494,12 +481,10 @@ describe('Model assembly (pure data)', () => { }; expectInvalid(kimiSections, 'nope'); expectInvalid({ models: { ghost: { provider: 'missing', model: 'm', maxContextSize: 1 } } }, 'ghost'); - // Flat model with protocol + baseUrl but no wire-facing name. expectInvalid( { models: { noname: { protocol: 'openai', baseUrl: 'https://x.test', maxContextSize: 1 } } }, 'noname', ); - // Structured kimi model without maxContextSize. expectInvalid( { ...kimiSections, models: { noctx: { provider: 'kimi', model: 'm' } } }, 'noctx', @@ -581,8 +566,6 @@ describe('ModelCatalog caching and config-event invalidation', () => { try { const before = catalog.get('k1'); - // Bypass the change events entirely: the catalog cache is the only - // stale layer, and only an explicit notify drops it. silentModelWrite(models, { k1: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 262144, displayName: 'silent' }, }); @@ -648,7 +631,6 @@ describe('ModelCatalog inspect', () => { kind: 'none', }); expect(view.sources['resolved']).toMatchObject({ kind: 'synthesized' }); - // Kimi's definition capability is UNKNOWN — nothing is detected. expect(view.sources['resolved.capabilities.tool_use']).toMatchObject({ kind: 'none' }); } finally { host.dispose(); @@ -663,8 +645,6 @@ describe('ModelCatalog inspect', () => { const { authProvider: _auth, id: _id, name, ...rest } = model; expect(view.resolved).toMatchObject({ ...rest, wireName: name }); - // A silent registry write keeps the stale generation: inspect reflects - // THAT generation (what get keeps serving), never a re-resolution. silentModelWrite(models, { k1: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 262144, displayName: 'silent' }, }); @@ -964,13 +944,6 @@ describe('ModelCatalog ping', () => { }); -/** - * Enumeration & default-model selection: `listModels` / `listProviders` / - * `getProvider` project the SAME materialization `get` serves (broken config - * falls back to the config-only projection so it stays visible), and - * `setDefaultModel` writes the global default pointer behind a - * materialization gate. - */ const catalogSections: Record = { providers: { @@ -1272,8 +1245,6 @@ describe('ModelCatalog enumeration', () => { }, }); try { - // Conflicting inline credentials make materialization throw; the - // listing still shows the broken model with its config values. await expect(catalog.listModels()).resolves.toEqual([ { provider: '', model: 'bad', display_name: 'Bad', max_context_size: 1000 }, ]); @@ -1376,8 +1347,6 @@ describe('ModelCatalog setDefaultModel', () => { max_context_size: 32768, }, }); - // The catalog writes the in-memory pointer; persisting it to config is - // the app/kosongConfig bridge's job. expect(models.getDefaultModel()).toBe('turbo'); } finally { host.dispose(); diff --git a/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts b/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts index 6641eb52c75..ec7540a7651 100644 --- a/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts +++ b/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts @@ -84,8 +84,6 @@ describe('resolveModelAuthMaterial', () => { provider: { type: 'openai', env: { OPENAI_API_KEY: 'openai-env-key' } }, }), ).toEqual({ apiKey: 'openai-env-key' }); - // The google-genai chain keeps the legacy vertex precedence: VERTEXAI_API_KEY - // first, GOOGLE_API_KEY as fallback. expect( authMaterial({ model: { model: 'm' }, @@ -138,8 +136,6 @@ describe('effectiveModelConfig', () => { expect(inferred.defaultEffort).toBe('high'); expect(inferred.capabilities).toContain('thinking'); - // Trait-driven (kimi) vendor over the anthropic transport: catalog- - // declared metadata only, no inference. const kimiRouted = effectiveModelConfig({ model: 'kimi-k2', protocol: 'anthropic' }, 'kimi'); expect(kimiRouted.supportEfforts).toBeUndefined(); expect(kimiRouted.capabilities).toBeUndefined(); diff --git a/packages/agent-core-v2/test/kosong/model/modelRequester.test.ts b/packages/agent-core-v2/test/kosong/model/modelRequester.test.ts index d1456887763..7daae99708d 100644 --- a/packages/agent-core-v2/test/kosong/model/modelRequester.test.ts +++ b/packages/agent-core-v2/test/kosong/model/modelRequester.test.ts @@ -209,7 +209,6 @@ describe('ModelRequesterImpl request execution', () => { const finish = events.find((e) => e.type === 'finish'); expect(finish).toMatchObject({ id: 'msg-42', traceId: 'trace-1', providerFinishReason: 'completed' }); const timing = events.find((e) => e.type === 'timing'); - // Decode stats are measured by the contract's generate() driver. expect(timing).toMatchObject({ requestBuildMs: expect.any(Number), serverDecodeMs: expect.any(Number), diff --git a/packages/agent-core-v2/test/kosong/model/modelService.test.ts b/packages/agent-core-v2/test/kosong/model/modelService.test.ts index 10d44bb1ccf..e6652df8b15 100644 --- a/packages/agent-core-v2/test/kosong/model/modelService.test.ts +++ b/packages/agent-core-v2/test/kosong/model/modelService.test.ts @@ -106,7 +106,6 @@ describe('ModelService', () => { await service.set('k1', updated); expect(events.at(-1)).toEqual({ added: [], removed: [], changed: ['k1'] }); - // Rewriting with an identical record is silent — no event fires. await service.set('k1', updated); expect(events).toHaveLength(2); diff --git a/packages/agent-core-v2/test/kosong/model/thinking.test.ts b/packages/agent-core-v2/test/kosong/model/thinking.test.ts index 5508b24b034..19234bc5f06 100644 --- a/packages/agent-core-v2/test/kosong/model/thinking.test.ts +++ b/packages/agent-core-v2/test/kosong/model/thinking.test.ts @@ -52,16 +52,10 @@ describe('registry-driven vendor verdicts', () => { expect(usesTraitDrivenThinking(registry, 'openai', 'openai')).toBe(false); expect(usesTraitDrivenThinking(registry, 'openai', undefined)).toBe(false); expect(usesTraitDrivenThinking(registry, 'anthropic', 'anthropic')).toBe(false); - // Kimi registers no google-genai definition — the pair contributes nothing. expect(usesTraitDrivenThinking(registry, 'google-genai', 'kimi')).toBe(false); }); it('requiresStrictThinkingValidation: only the strict-validation thinking driver', () => { - // The strict effort gate (v1 `provider.type === 'kimi'` parity): kimi on - // its native openai transport qualifies (kimiOpenAITrait marks - // `strictThinkingValidation`); kimi over anthropic does NOT — the foreign - // backend may accept unlisted efforts, so the profile stays lenient there - // and warns instead of rejecting. expect(requiresStrictThinkingValidation(registry, 'openai', 'kimi')).toBe(true); expect(requiresStrictThinkingValidation(registry, 'anthropic', 'kimi')).toBe(false); expect(requiresStrictThinkingValidation(registry, 'openai', 'openai')).toBe(false); diff --git a/packages/agent-core-v2/test/kosong/protocol/errors.test.ts b/packages/agent-core-v2/test/kosong/protocol/errors.test.ts index e6c4afc351b..4f7996ffba6 100644 --- a/packages/agent-core-v2/test/kosong/protocol/errors.test.ts +++ b/packages/agent-core-v2/test/kosong/protocol/errors.test.ts @@ -28,8 +28,6 @@ import { translateProviderError, } from '#/kosong/protocol/errors'; -// Mirrors the OpenAI/Anthropic SDKs' abort class: recognized structurally by -// constructor name, without importing any SDK. class APIUserAbortError extends Error { constructor(message = 'Request was aborted.') { super(message); diff --git a/packages/agent-core-v2/test/kosong/protocol/protocol.test.ts b/packages/agent-core-v2/test/kosong/protocol/protocol.test.ts index 842d39e6e60..141f2892339 100644 --- a/packages/agent-core-v2/test/kosong/protocol/protocol.test.ts +++ b/packages/agent-core-v2/test/kosong/protocol/protocol.test.ts @@ -31,9 +31,7 @@ describe('ProtocolSchema', () => { }); it('rejects vendor names and unknown values', () => { - // A vendor is `{ base, traits }`, never a protocol. expect(ProtocolSchema.safeParse('kimi').success).toBe(false); - // Vertex AI is a providerOptions mode of the google-genai base now. expect(ProtocolSchema.safeParse('vertexai').success).toBe(false); expect(ProtocolSchema.safeParse('azure').success).toBe(false); expect(ProtocolSchema.safeParse('').success).toBe(false); diff --git a/packages/agent-core-v2/test/kosong/provider/composition.test.ts b/packages/agent-core-v2/test/kosong/provider/composition.test.ts index c7ebdb9feb3..5204c96bc43 100644 --- a/packages/agent-core-v2/test/kosong/provider/composition.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/composition.test.ts @@ -146,8 +146,6 @@ describe('supportedProtocols (probe 4)', () => { expect([...protocols].toSorted()).toEqual( ['anthropic', 'google-genai', 'openai', 'openai_responses'].toSorted(), ); - // A vendor is not a protocol, and Vertex AI is a providerOptions mode of - // the google-genai base — neither may appear here. expect(protocols).not.toContain('kimi'); expect(protocols).not.toContain('vertexai'); }); @@ -162,8 +160,6 @@ describe('apiKey env suppression (probe 1)', () => { }); await expect(provider.generate('sys', [], [])).rejects.toThrow(/apiKey is required/); - // Even with a stray OPENAI_API_KEY in the environment, the composed Kimi - // provider must not silently use it. process.env['OPENAI_API_KEY'] = 'sk-openai-must-not-leak'; const withStrayEnv = registry.createChatProvider({ protocol: 'openai', @@ -195,8 +191,6 @@ describe('apiKey env suppression (probe 1)', () => { modelName: 'gpt-4o', baseUrl: 'http://127.0.0.1:9/v1', }); - // The request is attempted (key found via the base default) and fails on - // the connection — not on a missing key. await expect(withKey.generate('sys', [], [])).rejects.toThrow(APIConnectionError); }); @@ -246,26 +240,25 @@ describe('resolveAdapterIdentity', () => { it('resolves the (kimi, openai) pair registration: its traits plus the trailing synthetic trait', () => { const identity = registry.resolveAdapterIdentity('openai', 'kimi'); expect(identity.baseId).toBe('openai'); - expect(identity.traits).toHaveLength(2); // 1 vendor trait + synthetic + expect(identity.traits).toHaveLength(2); }); it('resolves the (kimi, anthropic) pair registration: only its own traits', () => { const identity = registry.resolveAdapterIdentity('anthropic', 'kimi'); expect(identity.baseId).toBe('anthropic'); - expect(identity.traits).toHaveLength(2); // 1 pair trait + synthetic + expect(identity.traits).toHaveLength(2); }); it('resolves an unregistered (vendor, protocol) pair to no vendor traits', () => { - // Kimi registers no google-genai definition — the pair contributes nothing. const identity = registry.resolveAdapterIdentity('google-genai', 'kimi'); expect(identity.baseId).toBe('google-genai'); - expect(identity.traits).toHaveLength(1); // synthetic only + expect(identity.traits).toHaveLength(1); }); it('resolves the unregistered-vendor branch: protocol itself as base, no vendor traits', () => { const identity = registry.resolveAdapterIdentity('openai', 'no-such-vendor'); expect(identity.baseId).toBe('openai'); - expect(identity.traits).toHaveLength(1); // synthetic only + expect(identity.traits).toHaveLength(1); }); it('resolves the no-providerType branch identically', () => { @@ -303,10 +296,6 @@ describe('resolveCapability', () => { }); it('kimi declares no vendor-level capability — the base catalog answers instead', () => { - // Kimi model ids never match the bases' builtin catalogs, so the detected - // layer still answers UNKNOWN for them; an id the base does know (gpt-4o) - // now resolves through the base catalog rather than being suppressed by a - // vendor-level UNKNOWN declaration. expect(isUnknownCapability(registry.resolveCapability('openai', 'kimi-for-coding', 'kimi'))).toBe( true, ); @@ -343,7 +332,6 @@ describe('createChatProvider', () => { providerType: 'kimi', modelName: 'kimi-k2', }); - // The composed provider's name is the base's — there is no vendor name. expect(provider.name).toBe('openai'); expect(provider.modelName).toBe('kimi-k2'); expect(typeof provider.uploadVideo).toBe('function'); @@ -466,8 +454,6 @@ describe('kimi provider definitions', () => { }); it('answers id-level queries and reports unregistered pairs', () => { - // The id-level view is the first registration; vendor-level facts are - // identical on both, so any of them answers an id-level query. expect(getProviderDefinition('kimi')?.baseProtocol).toBe('openai'); expect(getProviderDefinitions('kimi')).toHaveLength(2); expect(hasProviderDefinition('kimi')).toBe(true); @@ -489,12 +475,6 @@ describe('kimi provider definitions', () => { }); }); -// --------------------------------------------------------------------------- -// Wire-body probes: drive `generate` with a mocked SDK client and assert the -// exact params the base would send. Registry-composed providers always -// stream, so the mocks answer minimal valid streams; directly constructed -// bases use `stream: false` and answer plain responses. -// --------------------------------------------------------------------------- const PROBE_HISTORY: Message[] = [ { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, @@ -685,13 +665,10 @@ describe('per-turn intent wire encoding (behavior probes)', () => { }); expect(body['prompt_cache_key']).toBe('session-probe'); - // kimiOpenAITrait.buildParams expands extra_body into the top-level params. expect(body['thinking']).toEqual({ type: 'enabled', effort: 'high', keep: 'all' }); expect(body).not.toHaveProperty('extra_body'); - // The Kimi trait takes over the token field (no max_tokens backfill left). expect(body['max_completion_tokens']).toBe(5000); expect(body).not.toHaveProperty('max_tokens'); - // A trait took thinking over — the base must not add reasoning_effort. expect(body).not.toHaveProperty('reasoning_effort'); }); @@ -735,8 +712,6 @@ describe('per-turn intent wire encoding (behavior probes)', () => { expect(via).toBe('standard'); expect(params['thinking']).toEqual({ type: 'enabled' }); expect(params['output_config']).toEqual({ effort: 'high' }); - // The (kimi, anthropic) trait strips the interleaved-thinking beta and - // adds nothing else: no beta header reaches the wire at all. expect(requestOptions).toBeUndefined(); }); }); @@ -862,7 +837,6 @@ describe('reasoning dialect (behavior probes)', () => { }; }); - // Detection happens while draining the first response. await drain(await provider.generate('', [], PROBE_HISTORY)); await drain(await provider.generate('', [], THINK_HISTORY)); @@ -880,7 +854,6 @@ describe('reasoning dialect (behavior probes)', () => { apiKey: 'sk-probe', }); - // The probe stream carries no reasoning field, so nothing is detected. const body = await captureOpenAIBody(provider, undefined, THINK_HISTORY); const messages = body['messages'] as Array>; @@ -917,8 +890,6 @@ describe('reasoning dialect (behavior probes)', () => { }; }); - // With an explicit key, only that key is read inbound: a `reasoning` - // field is not picked up, and detection stays out of the way. const firstParts: unknown[] = []; for await (const part of await provider.generate('', [], PROBE_HISTORY)) { firstParts.push(part); @@ -998,9 +969,6 @@ describe('responseFormat wire encoding (per base)', () => { responseFormat: JSON_SCHEMA_FORMAT, }); - // The morph era seeded `output_config.effort` via withGenerationKwargs; - // the per-turn thinking intent is the channel now, and the format merges - // into the same output_config object. expect(params['output_config']).toEqual({ effort: 'medium', format: { type: 'json_schema', schema: CONTACT_SCHEMA }, @@ -1031,10 +999,6 @@ describe('responseFormat wire encoding (per base)', () => { expect(config['responseMimeType']).toBe('application/json'); expect(config['responseJsonSchema']).toEqual(CONTACT_SCHEMA); - // The deleted suite's "replaces conflicting native schema config" case is - // unreachable now: the morph kwargs channel is gone, so a conflicting - // `responseSchema` can never be seeded (the base still deletes both keys - // defensively before applying the format). }); it('maps json_schema to the OpenAI Responses text.format', async () => { @@ -1051,9 +1015,6 @@ describe('responseFormat wire encoding (per base)', () => { description: undefined, }, }); - // The deleted suite's "preserves existing text options" case is - // unreachable now: no channel seeds `text.verbosity` (the per-request - // merge in the base still stands, but only per-turn formats reach it). }); }); @@ -1130,12 +1091,10 @@ describe('Anthropic max-tokens profile', () => { }); it('falls back to the nearest lower catalogued minor for unknown minors', () => { - // Uncatalogued minors inherit at least their predecessor's cap. expect(resolveDefaultMaxTokens('claude-opus-4-9')).toBe(128000); expect(resolveDefaultMaxTokens('claude-opus-4-10')).toBe(128000); expect(resolveDefaultMaxTokens('claude-sonnet-4-9')).toBe(128000); expect(resolveDefaultMaxTokens('claude-haiku-4-9')).toBe(64000); - // A gap between catalogued minors resolves to the nearest lower one. expect(resolveDefaultMaxTokens('claude-opus-4-3')).toBe(32000); }); @@ -1261,9 +1220,6 @@ describe('OpenAI reasoning_effort path (issue #1616)', () => { }); it('disables the auto-enable entirely once a withThinking hook exists (load-bearing)', async () => { - // A hook that defers (returns undefined) still counts as "a trait took - // thinking over": the base's history scan must not fire, but an explicit - // effort still falls through to the base's own reasoning_effort encoding. const provider = new OpenAILegacyChatProvider({ model: 'gpt-4.1', apiKey: 'sk-probe', diff --git a/packages/agent-core-v2/test/kosong/provider/errors.test.ts b/packages/agent-core-v2/test/kosong/provider/errors.test.ts index 89749b92334..a0f20b5d09d 100644 --- a/packages/agent-core-v2/test/kosong/provider/errors.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/errors.test.ts @@ -40,8 +40,6 @@ import { composeOpenAIChatHooks } from '#/kosong/provider/bases/openai/openaiHoo import { kimiAnthropicTrait, kimiOpenAITrait } from '#/kosong/provider/providers/kimi/kimi.contrib'; import { classifyKimiQuotaError } from '#/kosong/provider/providers/kimi/kimi-errors'; -// Structurally an SDK user-abort: recognized by constructor name, the same -// way the OpenAI and Anthropic SDKs name their abort error class. const APIUserAbortError = class extends Error {}; function expectStandardAbort(run: () => unknown): void { diff --git a/packages/agent-core-v2/test/kosong/provider/kimi.test.ts b/packages/agent-core-v2/test/kosong/provider/kimi.test.ts index b1f73042274..c22545dd61f 100644 --- a/packages/agent-core-v2/test/kosong/provider/kimi.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/kimi.test.ts @@ -151,9 +151,6 @@ describe('kimiOpenAITrait.convertMessage', () => { describe('kimiOpenAITrait reasoning hooks', () => { it('does not pin a reasoning field — the base detects the endpoint dialect', () => { - // Detection defaults to `reasoning_content` (Kimi's native field) and - // adapts to peers that speak `reasoning` (newer vLLM); a trait pin would - // disable that adaptation. Operator config `reasoning_key` still pins. expect(kimiOpenAITrait.reasoningKey).toBeUndefined(); }); @@ -312,8 +309,6 @@ describe('trait objects are plain declarations', () => { }); it('marks only the native-transport thinking trait as strict-validation (v1 parity)', () => { - // Kimi's native API rejects unlisted efforts → strict; over the Anthropic - // transport the backend may accept them → lenient (warning + pass-through). expect(kimiOpenAITrait.strictThinkingValidation).toBe(true); expect(kimiAnthropicTrait.strictThinkingValidation).toBeUndefined(); }); diff --git a/packages/agent-core-v2/test/kosong/provider/providerService.test.ts b/packages/agent-core-v2/test/kosong/provider/providerService.test.ts index 064c1b18658..4064357f5d3 100644 --- a/packages/agent-core-v2/test/kosong/provider/providerService.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/providerService.test.ts @@ -104,7 +104,6 @@ describe('ProviderService', () => { await service.set('moonshot', updated); expect(events.at(-1)).toEqual({ added: [], removed: [], changed: ['moonshot'] }); - // Rewriting with an identical record is silent — no event fires. await service.set('moonshot', updated); expect(events).toHaveLength(2); diff --git a/packages/agent-core-v2/test/kosong/stubs.ts b/packages/agent-core-v2/test/kosong/stubs.ts index 6ad9ade9994..2bd232b363e 100644 --- a/packages/agent-core-v2/test/kosong/stubs.ts +++ b/packages/agent-core-v2/test/kosong/stubs.ts @@ -85,12 +85,6 @@ export class StubConfigService implements IConfigService { return Promise.resolve(); } - /** - * Mutate a section WITHOUT firing the change event — simulates a config - * write that bypasses the services' change events (the cache-invalidation - * tests use it to prove the catalog cache only drops on - * `notifyConfigChanged()`). - */ setSilent(domain: string, value: unknown): void { if (value === undefined) { this._values.delete(domain); @@ -141,11 +135,6 @@ export function stubOAuthService(tokenProvider?: StubTokenProvider): IOAuthServi } as unknown as IOAuthService; } -/** - * The kosong-side OAuth port stub (`IModelOAuthTokens`), mirroring what the - * real `app/kosongConfig` adapter does over `IOAuthService`: a programmable - * token provider for `getAccessToken` and a probeable cached-token flag. - */ export function stubModelOAuthTokens( tokenProvider?: StubTokenProvider, cachedToken?: string, diff --git a/packages/agent-core-v2/test/lint/import-boundaries.test.ts b/packages/agent-core-v2/test/lint/import-boundaries.test.ts index 2cf51f13490..e509bee893e 100644 --- a/packages/agent-core-v2/test/lint/import-boundaries.test.ts +++ b/packages/agent-core-v2/test/lint/import-boundaries.test.ts @@ -120,6 +120,14 @@ describe('check-import-boundaries', () => { expect(violations).toHaveLength(0); }); + it('allows kosong to import the app/scopes DI vocabulary', () => { + const violations = checkSource( + `import { LifecycleScope } from '#/app/scopes';`, + atKosong('provider', 'provider.ts'), + ); + expect(violations).toHaveLength(0); + }); + it('flags a bases implementation importing a registry module', () => { const violations = checkSource( `import { registry } from '#/kosong/provider/protocolAdapterRegistry';`, diff --git a/packages/agent-core-v2/test/lint/vendor-name-gates.test.ts b/packages/agent-core-v2/test/lint/vendor-name-gates.test.ts index 9157e86b975..38ecf5722ef 100644 --- a/packages/agent-core-v2/test/lint/vendor-name-gates.test.ts +++ b/packages/agent-core-v2/test/lint/vendor-name-gates.test.ts @@ -23,10 +23,6 @@ import { describe, expect, it } from 'vitest'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SRC_ROOT = join(__dirname, '..', '..', 'src'); -/** - * Branching on the vendor id: `=== 'kimi'` / `== 'kimi'` / `!== 'kimi'` / - * `!= 'kimi'` (either operand order) and `case 'kimi':`. - */ const VENDOR_GATE_RE = /[!=]==?\s*'kimi'|'kimi'\s*[!=]==?|\bcase\s+'kimi'\s*:/; interface GateHit { @@ -50,7 +46,6 @@ function walk(dir: string): string[] { return out; } -/** Full-line comments may quote the legacy gate as parity documentation. */ function isCommentLine(line: string): boolean { const trimmed = line.trimStart(); return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*'); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts index 85f0e06b60f..76aa5a588e6 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts @@ -3,6 +3,7 @@ import { Readable, type Writable } from 'node:stream'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DisposableStore, toDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { createServices } from '#/_base/di/test'; import type { ExecutableTool, @@ -15,6 +16,7 @@ import { AgentToolActivationService } from '#/agent/toolActivation/toolActivatio import { IAgentProfileService, type ProfileData } from '#/agent/profile/profile'; import { _clearAgentToolContributionsForTests, + AgentToolContribution, getAgentToolContributions, registerAgentToolService, } from '#/agent/toolRegistry/toolContribution'; @@ -53,6 +55,15 @@ vi.mock('#/os/backends/node-local/tools/rgLocator', () => ({ })); const signal = new AbortController().signal; + +class TestContributionAssembly extends Service { + constructor() { + super(); + for (const record of getAgentToolContributions()) { + this.provide(AgentToolContribution, record); + } + } +} const workspace: WorkspaceConfig = { workspaceDir: '/workspace', additionalDirs: ['/extra'] }; const MAX_COLUMNS_RG_ARGS = ['--max-columns', '500'] as const; const COMMON_RG_ARGS = [ @@ -327,6 +338,7 @@ describe('GrepTool', () => { }, }); + disposables.add(ix.createInstance(TestContributionAssembly)); await ix.get(IAgentToolActivationService).activate(); const tool = ix.get(IAgentToolRegistryService).resolve('Grep'); const info = ix.get(IAgentToolRegistryService).list().find((entry) => entry.name === 'Grep'); diff --git a/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts b/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts index 1f1ae364049..6b906dbd1cb 100644 --- a/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts +++ b/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts @@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { promises as fsp } from 'node:fs'; import os from 'node:os'; import { join } from 'node:path'; - -import { LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope'; import { createScopedTestHost, stubPair } from '#/_base/di/test'; import { ILogService } from '#/_base/log/log'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; @@ -134,13 +134,9 @@ describe('MiniDbQueryStore', () => { it('shares the store with a second cluster instance instead of locking it out', async () => { const storeDir = join(homeDir, 'cache', 'query-store'); - // A peer instance stands in for another kimi process: it has its own - // lock pool, so write locks are genuinely contended between the two. const peer = await ClusterDb.open({ dir: storeDir, shardCount: 16, valueCodec: 'json' }); try { const store = build(); - // Writes from the peer are visible here, and vice versa — the - // database-wide single-writer lockout (storage.locked) is gone. await peer.set(`${COLLECTION}${SEP}peer`, { id: 'peer', v: 1 }); expect(await store.get(COLLECTION, 'peer')).toEqual({ id: 'peer', v: 1 }); await store.put(COLLECTION, 'mine', { id: 'mine', v: 2 }); @@ -159,10 +155,6 @@ describe('MiniDbQueryStore', () => { disposeHost?.(); disposeHost = undefined; - // A corrupt cluster registry surfaces as a SyntaxError on the next index - // op. The store answers with one process-lifetime rebuild: the directory - // is wiped (the read model is derivable, so data is NOT preserved) and - // the retried op succeeds against the fresh cluster. const registryFile = join(homeDir, 'cache', 'query-store', 'cluster.indexes.json'); await fsp.writeFile(registryFile, '{ definitely not valid json'); diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index f3e30df4490..e566431514b 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -11,7 +11,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { Disposable, DisposableStore } from '#/_base/di/lifecycle'; -import { type ISessionScopeHandle, LifecycleScope } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { type ISessionScopeHandle } from '#/_base/di/scope'; import { TestInstantiationService } from '#/_base/di/test'; import { Event } from '#/_base/event'; import { IAgentProfileService } from '#/agent/profile/profile'; @@ -23,6 +24,8 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo import '#/agent/permissionMode/permissionModeOps'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { AgentLifecycleService } from '#/session/agentLifecycle/agentLifecycleService'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; @@ -167,6 +170,7 @@ describe('AgentLifecycleService', () => { _clearAgentToolContributionsForTests(); disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); + ix.set(ISessionStateService, new SessionStateService()); ix.set(IAgentStateService, new AgentStateService()); ix.stub(IAppendLogStore, recordingAppendLog().store); stubBlobPassThrough(ix); @@ -355,10 +359,6 @@ describe('AgentLifecycleService', () => { _serviceBrand: undefined, seedInjected: () => {}, }); - // The session's MCP readiness arrives through the seeded - // `ISessionMcpHandle`; the default handle carries an OAuth-wired manager - // over the test atomic document store so the agent mirror's OAuth - // surface stays exercisable. ix.stub(ISessionMcpHandle, { _serviceBrand: undefined, ready: Promise.resolve(), @@ -660,8 +660,6 @@ describe('AgentLifecycleService', () => { } satisfies ISessionMcpHandle); const svc = ix.get(IAgentLifecycleService); - // MCP connects in the background; the agent's LLM steps wait on the - // seeded readiness promise instead of agent creation. const handle = await svc.create({ agentId: 'main' }); expect(handle.id).toBe('main'); @@ -687,8 +685,6 @@ describe('AgentLifecycleService', () => { expect(early).toBeDefined(); const joined = svc.create({ agentId: 'main' }); - // doCreate awaits the wire-log seal before registerAgent, so the mock is - // invoked a few microtasks after create() — wait for the actual call. await registerCalled; releaseRegister(); const handle = await joined; diff --git a/packages/agent-core-v2/test/session/btw/btw.test.ts b/packages/agent-core-v2/test/session/btw/btw.test.ts index 21766a19b12..049d49a114e 100644 --- a/packages/agent-core-v2/test/session/btw/btw.test.ts +++ b/packages/agent-core-v2/test/session/btw/btw.test.ts @@ -29,8 +29,6 @@ describe('SessionBtwService', () => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); appendSystemReminder = vi.fn(); - // The suffix mimics the worker-rejection guidance formatDenyMessage appends - // for forked sub agents, so the assertion proves the reason went through it. formatDenyMessage = vi.fn((message: string) => `${message} [worker guidance]`); executorEvents = stubToolExecutorEvents(); diff --git a/packages/agent-core-v2/test/session/cron/cron-fire-steer.e2e.test.ts b/packages/agent-core-v2/test/session/cron/cron-fire-steer.e2e.test.ts index 2be71a2da01..565b44011be 100644 --- a/packages/agent-core-v2/test/session/cron/cron-fire-steer.e2e.test.ts +++ b/packages/agent-core-v2/test/session/cron/cron-fire-steer.e2e.test.ts @@ -22,7 +22,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Emitter, Event } from '#/_base/event'; import type { ServiceIdentifier } from '#/_base/di/instantiation'; -import { LifecycleScope, type IAgentScopeHandle } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { type IAgentScopeHandle } from '#/_base/di/scope'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentLoopService } from '#/agent/loop/loop'; import type { CronConfig } from '#/app/cron/configSection'; @@ -60,15 +61,12 @@ describe('cron-fired steer turn context', () => { }; ctx = createTestAgent(sessionService(IAgentLifecycleService, lifecycleStub)); - // Bind the cron service to the harness's main agent the way production - // does once AgentLifecycleService.create resolves the main handle. const accessor = { get: (id: ServiceIdentifier): T => ctx.get(id), }; mainHandle = { id: 'main', kind: LifecycleScope.Agent, accessor, dispose: () => {} }; onDidCreate.fire(mainHandle); - // Deterministic cron: file-driven wall clock, manual ticks, no jitter. const cronConfig: CronConfig = { debug: false, noJitter: true, @@ -77,11 +75,7 @@ describe('cron-fired steer turn context', () => { manualTick: true, clock: `file:${clockFile}`, }; - // The harness KimiConfig index signature is readonly: replace the whole - // config object (the harness mutation idiom) instead of index-writing. ctx.kimiConfig = { ...ctx.kimiConfig, cron: cronConfig }; - // Run the wire restore pipeline so the cron service's onDidRestore hook - // picks up the file clock and starts the (manual) scheduler. await ctx.restorePersisted(); await ctx.rpc.setPermission({ mode: 'yolo' }); @@ -104,31 +98,25 @@ describe('cron-fired steer turn context', () => { await ctx.rpc.prompt({ input: [{ type: 'text', text: 'remind me every minute' }] }); await ctx.untilTurnEnd(); - // Sanity: the CronCreate tool result landed in the conversation context. const toolMessages = ctx.contextData().history.filter((m) => m.role === 'tool'); expect(toolMessages).toHaveLength(1); const jobId = textOf(toolMessages[0]!).match(/^id: (\S+)$/m)?.[1]; expect(jobId).toBeDefined(); - // Fire: push the wall clock past the next minute boundary and tick. ctx.mockNextResponse({ type: 'text', text: 'cron turn done' }); writeFileSync(clockFile, String(Date.now() + 120_000)); await ctx.get(ISessionCronService).tick(); await ctx.get(IAgentLoopService).settled(); - // The steer turn ran exactly one more request. expect(ctx.llmCalls.length).toBe(3); const fireRequest = ctx.llmCalls.at(-1)!; - // (1) The cron fire prompt is there as the latest user message. const lastUser = fireRequest.history.filter((m) => m.role === 'user').at(-1); const lastUserText = lastUser?.content .map((part) => (part.type === 'text' ? part.text : '')) .join('') ?? ''; expect(lastUserText).toContain('fire me'); - // (2) The earlier CronCreate tool result is still in the request — - // this is the regression assertion. const requestToolTexts = fireRequest.history .filter((m) => m.role === 'tool') .flatMap((m) => m.content) 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 e6d710da2b4..cbf2817f68b 100644 --- a/packages/agent-core-v2/test/session/interaction/interaction.test.ts +++ b/packages/agent-core-v2/test/session/interaction/interaction.test.ts @@ -3,7 +3,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; import { DisposableStore } from '#/_base/di/lifecycle'; -import { type IAgentScopeHandle, LifecycleScope } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { type IAgentScopeHandle } from '#/_base/di/scope'; import { TestInstantiationService } from '#/_base/di/test'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; diff --git a/packages/agent-core-v2/test/session/process/processRunnerService.test.ts b/packages/agent-core-v2/test/session/process/processRunnerService.test.ts index ee26fbcba93..bf55ad08cd9 100644 --- a/packages/agent-core-v2/test/session/process/processRunnerService.test.ts +++ b/packages/agent-core-v2/test/session/process/processRunnerService.test.ts @@ -4,9 +4,8 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable } from 'node:stream'; - +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, 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 58cd6955580..8336be399bf 100644 --- a/packages/agent-core-v2/test/session/question/question.test.ts +++ b/packages/agent-core-v2/test/session/question/question.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DisposableStore } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; import { _clearScopedRegistryForTests, - LifecycleScope, ScopeActivation, registerScopedService, type Scope, 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 1ca53a71d56..c373dee11a4 100644 --- a/packages/agent-core-v2/test/session/sessionActivity/sessionActivityService.test.ts +++ b/packages/agent-core-v2/test/session/sessionActivity/sessionActivityService.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; import { _clearScopedRegistryForTests, - LifecycleScope, ScopeActivation, registerScopedService, type IAgentScopeHandle, @@ -300,7 +300,6 @@ describe('ISessionActivityView (Session scope aggregate of agent activity + inte cause: 'interaction', }); - // A question joining an already-pending approval does not change the slice. interactions.enqueue({ id: 'q1', kind: 'question', payload: {}, origin: { agentId: MAIN_AGENT_ID } }); expect(changes).toHaveLength(1); 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 aaff31281a4..a7246b1f808 100644 --- a/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts @@ -1,19 +1,26 @@ /** * Scenario: the Session-scope agent-profile catalog projection over the - * App-scope `IAgentProfileRegistry`. + * App-scope `IAgentProfileRegistry` fold. * - * Exercises `SessionAgentProfileCatalogService` directly (no DI scope host): - * a hand-driven `AgentProfileRegistryService` plus a stub log verify the - * projection rules — relevant-entry filtering by the seeded workspace key, - * priority-ordered name dedup, the builtin-override rule, change-event - * fan-out, and the read surface (`get` / `list` / `getDefault` / `inspect`). - * Run: + * Exercises `SessionAgentProfileCatalogService` directly: the registry fold + * is fed through real containers (contributor units on the same + * `this.provide` path the production loaders take) while the catalog itself + * is hand-constructed — the suite verifies the projection rules: + * relevant-entry filtering by the seeded workspace key, priority-ordered + * name dedup, the builtin-override rule, change-event fan-out, and the read + * surface (`get` / `list` / `getDefault` / `inspect`). Run: * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run * test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts`. */ import { describe, expect, it } from 'vitest'; +import { createDecorator } from '#/_base/di/instantiation'; +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; import { DEFAULT_AGENT_PROFILE_NAME, normalizeAgentProfile, @@ -22,12 +29,30 @@ import { import { BUILTIN_AGENT_PROFILE_SOURCE_ID } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; import { SessionAgentProfileCatalogService } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService'; -import { AGENT_PROFILE_SOURCE_PRIORITY } from '#/app/agentProfileCatalog/agentProfileContribution'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + AgentProfileContribution, + type AgentProfileContributionRecord, +} from '#/app/agentProfileCatalog/agentProfileContribution'; import { stubLog } from '../../_base/log/stubs'; const WORKSPACE_KEY = 'wd_a'; +interface IContributor { + readonly record: AgentProfileContributionRecord; +} +const IContributor = createDecorator('test-session-profile-contributor'); + +class Contributor extends Service implements IContributor { + declare readonly _serviceBrand: undefined; + + constructor(readonly record: AgentProfileContributionRecord) { + super(); + this.provide(AgentProfileContribution, record); + } +} + function profile(name: string, options?: { readonly override?: boolean }): AgentProfile { return normalizeAgentProfile({ name, @@ -37,45 +62,60 @@ function profile(name: string, options?: { readonly override?: boolean }): Agent } function makeCatalog(workspaceKey: string = WORKSPACE_KEY) { - const registry = new AgentProfileRegistryService(); + const container = new InstantiationService(new ServiceCollection(), true); + const registry = container.createInstance(AgentProfileRegistryService); const catalog = new SessionAgentProfileCatalogService( registry, { _serviceBrand: undefined, workspaceKey }, stubLog(), ); - return { registry, catalog }; + const contribute = ( + sourceId: string, + profiles: readonly AgentProfile[], + options?: { readonly priority?: number; readonly workspaceKey?: string }, + ): IDisposable => { + const child = container.createChild(new ServiceCollection()) as InstantiationService; + const contributionRecord: AgentProfileContributionRecord = { + sourceId, + priority: options?.priority, + workspaceKey: options?.workspaceKey, + contribution: { profiles }, + }; + child.provide(IContributor, new SyncDescriptor(Contributor, [contributionRecord] as never)); + child.invokeFunction((accessor) => accessor.get(IContributor)); + return child; + }; + return { container, registry, catalog, contribute }; } describe('SessionAgentProfileCatalogService (registry projection)', () => { it('projects global entries and own-workspace entries, filtering other workspace keys', () => { - const { registry, catalog } = makeCatalog(); + const { container, catalog, contribute } = makeCatalog(); const globalProfile = profile('global-p'); const ownProfile = profile('own-ws-p'); - registry.register('user', { profiles: [globalProfile] }); - registry.register('workspace', { profiles: [ownProfile] }, { workspaceKey: 'wd_a' }); - registry.register('workspace', { profiles: [profile('other-ws-p')] }, { workspaceKey: 'wd_b' }); + contribute('user', [globalProfile]); + contribute('workspace', [ownProfile], { workspaceKey: 'wd_a' }); + contribute('workspace', [profile('other-ws-p')], { workspaceKey: 'wd_b' }); expect(catalog.get('global-p')).toBe(globalProfile); expect(catalog.get('own-ws-p')).toBe(ownProfile); expect(catalog.get('other-ws-p')).toBeUndefined(); catalog.dispose(); - registry.dispose(); + container.dispose(); }); it('excludes same-name profiles of other workspace keys from the merge entirely', () => { - const { registry, catalog } = makeCatalog(); + const { container, catalog, contribute } = makeCatalog(); const userProfile = profile('shared'); const ownProfile = profile('shared'); - registry.register('user', { profiles: [userProfile] }, { + contribute('user', [userProfile], { priority: AGENT_PROFILE_SOURCE_PRIORITY.user, }); - // A higher-priority entry from ANOTHER workspace must not even become a - // candidate: it neither wins the name nor shows up as suppressed. - registry.register('workspace', { profiles: [profile('shared')] }, { + contribute('workspace', [profile('shared')], { priority: AGENT_PROFILE_SOURCE_PRIORITY.explicit, workspaceKey: 'wd_b', }); - registry.register('workspace', { profiles: [ownProfile] }, { + contribute('workspace', [ownProfile], { priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, workspaceKey: 'wd_a', }); @@ -91,17 +131,17 @@ describe('SessionAgentProfileCatalogService (registry projection)', () => { ], }); catalog.dispose(); - registry.dispose(); + container.dispose(); }); it('lets the higher-priority source win a name collision and reports the suppressed candidate', () => { - const { registry, catalog } = makeCatalog(); + const { container, catalog, contribute } = makeCatalog(); const lowProfile = profile('x'); const highProfile = profile('x'); - registry.register('user', { profiles: [lowProfile] }, { + contribute('user', [lowProfile], { priority: AGENT_PROFILE_SOURCE_PRIORITY.user, }); - registry.register('workspace', { profiles: [highProfile] }, { + contribute('workspace', [highProfile], { priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, workspaceKey: 'wd_a', }); @@ -117,17 +157,17 @@ describe('SessionAgentProfileCatalogService (registry projection)', () => { ], }); catalog.dispose(); - registry.dispose(); + container.dispose(); }); it('keeps the builtin profile when a same-name file profile lacks override: true', () => { - const { registry, catalog } = makeCatalog(); + const { container, catalog, contribute } = makeCatalog(); const builtinProfile = profile(DEFAULT_AGENT_PROFILE_NAME); const fileProfile = profile(DEFAULT_AGENT_PROFILE_NAME); - registry.register(BUILTIN_AGENT_PROFILE_SOURCE_ID, { profiles: [builtinProfile] }, { + contribute(BUILTIN_AGENT_PROFILE_SOURCE_ID, [builtinProfile], { priority: AGENT_PROFILE_SOURCE_PRIORITY.builtin, }); - registry.register('workspace', { profiles: [fileProfile] }, { + contribute('workspace', [fileProfile], { priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, workspaceKey: 'wd_a', }); @@ -147,15 +187,15 @@ describe('SessionAgentProfileCatalogService (registry projection)', () => { ], }); catalog.dispose(); - registry.dispose(); + container.dispose(); }); it('lets a file profile with override: true replace the same-name builtin', () => { - const { registry, catalog } = makeCatalog(); + const { container, catalog, contribute } = makeCatalog(); const builtinProfile = profile(DEFAULT_AGENT_PROFILE_NAME); const overrideProfile = profile(DEFAULT_AGENT_PROFILE_NAME, { override: true }); - registry.register(BUILTIN_AGENT_PROFILE_SOURCE_ID, { profiles: [builtinProfile] }); - registry.register('workspace', { profiles: [overrideProfile] }, { + contribute(BUILTIN_AGENT_PROFILE_SOURCE_ID, [builtinProfile]); + contribute('workspace', [overrideProfile], { priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, workspaceKey: 'wd_a', }); @@ -169,39 +209,37 @@ describe('SessionAgentProfileCatalogService (registry projection)', () => { suppressed: [], }); catalog.dispose(); - registry.dispose(); + container.dispose(); }); it('re-projects and fires the source id on relevant registry changes, ignoring other keys', () => { - const { registry, catalog } = makeCatalog(); + const { container, catalog, contribute } = makeCatalog(); const seen: string[] = []; const subscription = catalog.onDidChange((sourceId) => seen.push(sourceId)); const ownProfile = profile('own-ws-p'); - registry.register('workspace', { profiles: [ownProfile] }, { workspaceKey: 'wd_a' }); + contribute('workspace', [ownProfile], { workspaceKey: 'wd_a' }); expect(catalog.get('own-ws-p')).toBe(ownProfile); const globalProfile = profile('global-p'); - registry.register('user', { profiles: [globalProfile] }); + const globalHandle = contribute('user', [globalProfile]); expect(catalog.get('global-p')).toBe(globalProfile); - // Changes tagged with another workspace key are ignored entirely: no - // re-projection, no event. - registry.register('workspace', { profiles: [profile('other-ws-p')] }, { workspaceKey: 'wd_b' }); - registry.unregister('workspace', 'wd_b'); + const otherHandle = contribute('workspace', [profile('other-ws-p')], { workspaceKey: 'wd_b' }); + otherHandle.dispose(); expect(catalog.get('other-ws-p')).toBeUndefined(); - registry.unregister('user'); + globalHandle.dispose(); expect(catalog.get('global-p')).toBeUndefined(); expect(seen).toEqual(['workspace', 'user', 'user']); subscription.dispose(); catalog.dispose(); - registry.dispose(); + container.dispose(); }); it("fires 'catalog' on reload", async () => { - const { registry, catalog } = makeCatalog(); + const { container, catalog } = makeCatalog(); const seen: string[] = []; const subscription = catalog.onDidChange((sourceId) => seen.push(sourceId)); @@ -210,20 +248,20 @@ describe('SessionAgentProfileCatalogService (registry projection)', () => { expect(seen).toEqual(['catalog']); subscription.dispose(); catalog.dispose(); - registry.dispose(); + container.dispose(); }); it('resolves ready immediately (loader readiness is the workspace handler’s job)', async () => { - const { registry, catalog } = makeCatalog(); + const { container, catalog } = makeCatalog(); await expect(catalog.ready).resolves.toBeUndefined(); await expect(catalog.load()).resolves.toBeUndefined(); catalog.dispose(); - registry.dispose(); + container.dispose(); }); it('serves the read surface and throws from getDefault without the default profile', () => { - const { registry, catalog } = makeCatalog(); + const { container, catalog, contribute } = makeCatalog(); expect(catalog.get('missing')).toBeUndefined(); expect(catalog.inspect('missing')).toBeUndefined(); expect(catalog.list()).toEqual([]); @@ -233,8 +271,8 @@ describe('SessionAgentProfileCatalogService (registry projection)', () => { const defaultProfile = profile(DEFAULT_AGENT_PROFILE_NAME); const coderProfile = profile('coder'); - registry.register(BUILTIN_AGENT_PROFILE_SOURCE_ID, { profiles: [defaultProfile] }); - registry.register('user', { profiles: [coderProfile] }); + contribute(BUILTIN_AGENT_PROFILE_SOURCE_ID, [defaultProfile]); + contribute('user', [coderProfile]); expect(catalog.getDefault()).toBe(defaultProfile); expect(catalog.get('coder')).toBe(coderProfile); @@ -247,6 +285,6 @@ describe('SessionAgentProfileCatalogService (registry projection)', () => { suppressed: [], }); catalog.dispose(); - registry.dispose(); + container.dispose(); }); }); diff --git a/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts b/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts index b77971926ac..03299a6aa7f 100644 --- a/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts +++ b/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts @@ -212,8 +212,6 @@ describe('SessionInitService', () => { run.mockImplementationOnce((agentId: string, _req: unknown, opts: { signal: AbortSignal }) => ({ agentId, turn: {}, - // The real lifecycle rejects the run completion when the launch signal - // aborts; mirror that so the service-level propagation is exercised. completion: new Promise<{ summary: string }>((_resolve, reject) => { opts.signal.addEventListener('abort', () => reject(opts.signal.reason)); }), @@ -225,8 +223,6 @@ describe('SessionInitService', () => { svc.cancelInit(); const error = await pending.catch((e) => e); - // Surfaces as a user cancellation (TUI resets quietly on isAbortError), - // never as SESSION_INIT_FAILED, and without a subagent.failed event. expect(error).toBeInstanceOf(UserCancellationError); expect(events).not.toContainEqual( expect.objectContaining({ type: 'subagent.failed', subagentId: 'agent-0' }), 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 bd1c524a2df..93ff7603b30 100644 --- a/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts +++ b/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts @@ -3,9 +3,8 @@ import { tmpdir } from 'node:os'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, 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 78a0506dc72..dc4c1c41c9e 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -21,9 +21,6 @@ import { stubLog } from '../../_base/log/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()])) @@ -66,8 +63,6 @@ describe('SessionMetadata', () => { expect(await meta.read()).toMatchObject({ id: 's1', archived: false, - // Seeded so released v1 builds can open a v2-created state.json - // (v1's Session.resume() indexes `agents` unconditionally). agents: {}, custom: {}, }); @@ -93,9 +88,6 @@ describe('SessionMetadata', () => { }); it('mirrors a boolean archived to the read model even when the loaded document lacks the field', async () => { - // A state.json written before `archived` existed: normalizeSessionMeta - // keeps the field undefined, and a naive mirror would drop the key from - // the cached JSON entirely (failing the read-model contract on reads). const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', @@ -126,8 +118,6 @@ describe('SessionMetadata', () => { }); it('backfills and persists missing agents/custom maps on a pre-fix document', async () => { - // Written by a v2 build predating the create-path map seeding: no - // agents / custom keys at all. const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', @@ -140,8 +130,6 @@ describe('SessionMetadata', () => { const meta = ix.get(ISessionMetadata); expect(await meta.read()).toMatchObject({ agents: {}, custom: {} }); - // The heal is persisted: a fresh instance reads the maps from disk, and - // updatedAt is untouched so session listings keep their order. const fresh = createFreshMetadata(ix); const healed = await fresh.read(); expect(healed.agents).toEqual({}); @@ -214,8 +202,6 @@ describe('SessionMetadata', () => { const before = (await meta.read()).updatedAt; await new Promise((r) => setTimeout(r, 2)); - // A resumed session re-registers its materialized agents; with identical - // metadata that must not write, bump updatedAt, or fire an event. let fired = 0; const sub = meta.onDidChangeMetadata(() => { fired++; @@ -234,9 +220,6 @@ describe('SessionMetadata', () => { }); it('stays a no-op when re-registering against a persisted document', async () => { - // The document as it lands on disk: keys with undefined values are gone, - // and a legacy writer stored parentAgentId: null. A server restart then - // re-registers `main` with explicit undefineds — still no update. const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', diff --git a/packages/agent-core-v2/test/session/sessionSeed/sessionSeedAdapters.test.ts b/packages/agent-core-v2/test/session/sessionSeed/sessionSeedAdapters.test.ts new file mode 100644 index 00000000000..c9bfe0b8dc2 --- /dev/null +++ b/packages/agent-core-v2/test/session/sessionSeed/sessionSeedAdapters.test.ts @@ -0,0 +1,436 @@ +/** + * sessionSeed adapters — unit tests over the real scope tree. + * + * Each adapter observes its workspace upstream through `@ref` and provides + * the Session-scope seed token during the scope's `assemble` hook. Covered + * per adapter: live reads across an upstream generation swap (getters never + * serve a stale closure), `onDidChange` forwarding from the current backing + * projection, the re-fire on upstream availability change (switch backing + * view → re-fire), and the early return when no workspace layer exists + * (the seed stays unprovided; the Noop tool-policy gate default survives). + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { + ScopeActivation, + _clearScopedRegistryForTests, + registerScopedService, + type Scope, + type ScopeSeed, +} from '#/_base/di/scope'; +import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; +import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope } from '#/app/scopes'; +import type { SkillCatalog } from '#/app/skillCatalog/types'; +import type { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; +import { assembleSessionSeedAdapters } from '#/session/sessionSeed/sessionSeedAdapters'; +import { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; +import { NoopSessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGateService'; +import { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { + IWorkspaceInstructionsService, + type WorkspaceInstructionsSnapshot, +} from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { IWorkspaceMcpService, type ISessionMcpOverlay } from '#/workspace/workspaceMcp/workspaceMcp'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; +import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; + +class WorkspaceSkillCatalogStub implements IWorkspaceSkillCatalog { + declare readonly _serviceBrand: undefined; + readonly ready = Promise.resolve(); + catalog: SkillCatalog; + private readonly changeEmitter = new Emitter(); + readonly onDidChange: Event = this.changeEmitter.event; + + constructor(catalog: SkillCatalog) { + this.catalog = catalog; + } + + load(): Promise { + return Promise.resolve(); + } + + reload(): Promise { + return Promise.resolve(); + } + + fire(sourceId: string): void { + this.changeEmitter.fire(sourceId); + } + + sessionData(): ISessionSkillCatalogData { + const currentCatalog = (): SkillCatalog => this.catalog; + return { + _serviceBrand: undefined, + ready: this.ready, + onDidChange: this.onDidChange, + get catalog() { + return currentCatalog(); + }, + }; + } +} + +class WorkspaceInstructionsStub implements IWorkspaceInstructionsService { + declare readonly _serviceBrand: undefined; + readonly ready = Promise.resolve(); + snapshot: WorkspaceInstructionsSnapshot; + private readonly changeEmitter = new Emitter(); + readonly onDidChange: Event = this.changeEmitter.event; + + constructor(snapshot: WorkspaceInstructionsSnapshot) { + this.snapshot = snapshot; + } + + reload(): Promise { + return Promise.resolve(); + } + + fire(): void { + this.changeEmitter.fire(); + } + + sessionProvider(): ISessionInstructionsProvider { + const currentAgentsMd = (): string | undefined => this.snapshot.agentsMd; + const currentWarning = (): string | undefined => this.snapshot.agentsMdWarning; + const currentPaths = (): readonly string[] | undefined => this.snapshot.agentsMdPaths; + return { + _serviceBrand: undefined, + ready: this.ready, + onDidChange: this.onDidChange, + get agentsMd() { + return currentAgentsMd(); + }, + get agentsMdWarning() { + return currentWarning(); + }, + get agentsMdPaths() { + return currentPaths(); + }, + }; + } +} + +class WorkspaceMcpStub implements IWorkspaceMcpService { + declare readonly _serviceBrand: undefined; + readonly ready = Promise.resolve(); + + constructor(private readonly manager: McpConnectionManager) {} + + connectionManager(): McpConnectionManager { + return this.manager; + } + + sessionHandle(): ISessionMcpHandle { + return { + _serviceBrand: undefined, + ready: this.ready, + connectionManager: this.manager, + }; + } + + sessionOverlay(): ISessionMcpOverlay { + return { handle: this.sessionHandle(), shutdown: () => Promise.resolve() }; + } +} + +class WorkspaceDirsStub implements IWorkspaceDirs { + declare readonly _serviceBrand: undefined; + readonly ready = Promise.resolve(); + additionalDirs: readonly string[]; + private readonly changeEmitter = new Emitter(); + readonly onDidChange: Event = this.changeEmitter.event; + + constructor(additionalDirs: readonly string[]) { + this.additionalDirs = additionalDirs; + } + + addDir(): Promise { + return Promise.reject(new Error('not implemented')); + } + + mergeAdditionalDirs(): Promise { + return Promise.resolve(); + } + + fire(): void { + this.changeEmitter.fire(); + } + + sessionInfo(): ISessionWorkspaceInfo { + const currentDirs = (): readonly string[] => this.additionalDirs; + return { + _serviceBrand: undefined, + ready: this.ready, + onDidChange: this.onDidChange, + get additionalDirs() { + return currentDirs(); + }, + }; + } +} + +class WorkspaceToolPolicyStub implements IWorkspaceToolPolicy { + declare readonly _serviceBrand: undefined; + private readonly changeEmitter = new Emitter(); + readonly onDidChange: Event = this.changeEmitter.event; + + constructor(private readonly disabled: readonly string[]) {} + + disabledTools(): readonly string[] { + return this.disabled; + } + + fire(): void { + this.changeEmitter.fire(); + } + + sessionGate(): ISessionToolPolicyGate { + const current = (): readonly string[] => this.disabledTools(); + return { + _serviceBrand: undefined, + onDidChange: this.onDidChange, + get disabledTools() { + return current(); + }, + }; + } +} + +function catalogSentinel(tag: string): SkillCatalog { + return { tag } as unknown as SkillCatalog; +} + +function managerSentinel(tag: string): McpConnectionManager { + return { tag } as unknown as McpConnectionManager; +} + +describe('sessionSeed adapters', () => { + let host: ScopedTestHost | undefined; + + beforeEach(() => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.Session, + ISessionToolPolicyGate, + NoopSessionToolPolicyGate, + ScopeActivation.OnScopeCreated, + 'sessionToolPolicyGate', + ); + }); + + afterEach(() => { + host?.dispose(); + host = undefined; + }); + + function buildSession(workspaceStubs: ScopeSeed): { workspace: Scope; session: Scope } { + host = createScopedTestHost(); + const workspace = host.app.createChild(LifecycleScope.Workspace, 'ws', { + extra: workspaceStubs, + }); + const session = workspace.createChild(LifecycleScope.Session, 's1', { + assemble: assembleSessionSeedAdapters, + }); + return { workspace, session }; + } + + describe('SessionSkillCatalogDataAdapter', () => { + it('projects the current upstream and live-reads within its generation', () => { + const gen1 = new WorkspaceSkillCatalogStub(catalogSentinel('gen1')); + const { session } = buildSession([stubPair(IWorkspaceSkillCatalog, gen1)]); + const data = session.accessor.get(ISessionSkillCatalogData); + + expect(data.catalog).toBe(gen1.catalog); + const mutated = catalogSentinel('gen1-mutated'); + gen1.catalog = mutated; + expect(data.catalog).toBe(mutated); + }); + + it('forwards the backing projection’s onDidChange with its payload', () => { + const gen1 = new WorkspaceSkillCatalogStub(catalogSentinel('gen1')); + const { session } = buildSession([stubPair(IWorkspaceSkillCatalog, gen1)]); + const data = session.accessor.get(ISessionSkillCatalogData); + const seen: string[] = []; + data.onDidChange((sourceId) => seen.push(sourceId)); + + gen1.fire('workspace'); + expect(seen).toEqual(['workspace']); + }); + + it('reads the new generation after an upstream swap and re-fires onDidChange', () => { + const gen1 = new WorkspaceSkillCatalogStub(catalogSentinel('gen1')); + const gen2 = new WorkspaceSkillCatalogStub(catalogSentinel('gen2')); + const { workspace, session } = buildSession([stubPair(IWorkspaceSkillCatalog, gen1)]); + const data = session.accessor.get(ISessionSkillCatalogData); + const seen: string[] = []; + data.onDidChange((sourceId) => seen.push(sourceId)); + + workspace.instantiation.provide(IWorkspaceSkillCatalog, gen2); + + expect(seen).toEqual(['catalog']); + expect(data.catalog).toBe(gen2.catalog); + + gen2.fire('plugin'); + expect(seen).toEqual(['catalog', 'plugin']); + gen1.fire('stale'); + expect(seen).toEqual(['catalog', 'plugin']); + }); + + it('re-fires on upstream removal while reads keep the last backing view', () => { + const gen1 = new WorkspaceSkillCatalogStub(catalogSentinel('gen1')); + const { workspace, session } = buildSession([stubPair(IWorkspaceSkillCatalog, gen1)]); + const data = session.accessor.get(ISessionSkillCatalogData); + const seen: string[] = []; + data.onDidChange((sourceId) => seen.push(sourceId)); + + workspace.instantiation.unprovide(IWorkspaceSkillCatalog); + + expect(seen).toEqual(['catalog']); + expect(data.catalog).toBe(gen1.catalog); + }); + + it('provides nothing when no workspace layer exists', () => { + const { session } = buildSession([]); + expect(() => session.accessor.get(ISessionSkillCatalogData)).toThrow(/unknown service/); + }); + }); + + describe('SessionInstructionsProviderAdapter', () => { + it('live-reads the snapshot across an upstream swap and re-fires', () => { + const gen1 = new WorkspaceInstructionsStub({ agentsMd: 'one', agentsMdWarning: undefined , agentsMdPaths: undefined }); + const gen2 = new WorkspaceInstructionsStub({ agentsMd: 'two', agentsMdWarning: 'big' , agentsMdPaths: undefined }); + const { workspace, session } = buildSession([stubPair(IWorkspaceInstructionsService, gen1)]); + const provider = session.accessor.get(ISessionInstructionsProvider); + let fired = 0; + provider.onDidChange(() => fired++); + + expect(provider.agentsMd).toBe('one'); + gen1.snapshot = { agentsMd: 'one-b', agentsMdWarning: undefined , agentsMdPaths: undefined }; + expect(provider.agentsMd).toBe('one-b'); + + workspace.instantiation.provide(IWorkspaceInstructionsService, gen2); + expect(fired).toBe(1); + expect(provider.agentsMd).toBe('two'); + expect(provider.agentsMdWarning).toBe('big'); + + gen2.fire(); + expect(fired).toBe(2); + gen1.fire(); + expect(fired).toBe(2); + }); + + it('provides nothing when no workspace layer exists', () => { + const { session } = buildSession([]); + expect(() => session.accessor.get(ISessionInstructionsProvider)).toThrow(/unknown service/); + }); + }); + + describe('SessionMcpHandleAdapter', () => { + it('live-reads the connection manager across an upstream swap', () => { + const managerA = managerSentinel('a'); + const managerB = managerSentinel('b'); + const gen2 = new WorkspaceMcpStub(managerB); + const { workspace, session } = buildSession([ + stubPair(IWorkspaceMcpService, new WorkspaceMcpStub(managerA)), + ]); + const handle = session.accessor.get(ISessionMcpHandle); + + expect(handle.connectionManager).toBe(managerA); + workspace.instantiation.provide(IWorkspaceMcpService, gen2); + expect(handle.connectionManager).toBe(managerB); + expect(handle.ready).toBe(gen2.ready); + }); + + it('provides nothing when no workspace layer exists', () => { + const { session } = buildSession([]); + expect(() => session.accessor.get(ISessionMcpHandle)).toThrow(/unknown service/); + }); + }); + + describe('SessionWorkspaceInfoAdapter', () => { + it('live-reads additionalDirs across an upstream swap and re-fires', () => { + const gen1 = new WorkspaceDirsStub(['/a']); + const gen2 = new WorkspaceDirsStub(['/b']); + const { workspace, session } = buildSession([stubPair(IWorkspaceDirs, gen1)]); + const info = session.accessor.get(ISessionWorkspaceInfo); + let fired = 0; + info.onDidChange(() => fired++); + + expect(info.additionalDirs).toEqual(['/a']); + workspace.instantiation.provide(IWorkspaceDirs, gen2); + expect(fired).toBe(1); + expect(info.additionalDirs).toEqual(['/b']); + + gen2.additionalDirs = ['/b', '/c']; + gen2.fire(); + expect(info.additionalDirs).toEqual(['/b', '/c']); + expect(fired).toBe(2); + gen1.fire(); + expect(fired).toBe(2); + }); + + it('provides nothing when no workspace layer exists', () => { + const { session } = buildSession([]); + expect(() => session.accessor.get(ISessionWorkspaceInfo)).toThrow(/unknown service/); + }); + }); + + describe('SessionToolPolicyGateAdapter', () => { + it('shadows the Noop default and live-reads across an upstream swap', () => { + const gen1 = new WorkspaceToolPolicyStub(['Bash']); + const gen2 = new WorkspaceToolPolicyStub(['Bash', 'CronCreate']); + const { workspace, session } = buildSession([stubPair(IWorkspaceToolPolicy, gen1)]); + const gate = session.accessor.get(ISessionToolPolicyGate); + let fired = 0; + gate.onDidChange(() => fired++); + + expect(gate).not.toBeInstanceOf(NoopSessionToolPolicyGate); + expect(gate.disabledTools).toEqual(['Bash']); + workspace.instantiation.provide(IWorkspaceToolPolicy, gen2); + expect(fired).toBe(1); + expect(gate.disabledTools).toEqual(['Bash', 'CronCreate']); + }); + + it('keeps the Noop default when no workspace layer exists', () => { + const { session } = buildSession([]); + const gate = session.accessor.get(ISessionToolPolicyGate); + expect(gate).toBeInstanceOf(NoopSessionToolPolicyGate); + expect(gate.disabledTools).toEqual([]); + }); + }); + + describe('assembly ordering', () => { + interface SeedProbe { + readonly _serviceBrand: undefined; + readonly catalogAtActivation: SkillCatalog; + } + const ISeedProbe = createDecorator('testSessionSeedProbe'); + class SeedProbeService implements SeedProbe { + declare readonly _serviceBrand: undefined; + readonly catalogAtActivation: SkillCatalog; + constructor(@ISessionSkillCatalogData data: ISessionSkillCatalogData) { + this.catalogAtActivation = data.catalog; + } + } + + it('provides the seed tokens before OnScopeCreated services activate', () => { + registerScopedService( + LifecycleScope.Session, + ISeedProbe, + SeedProbeService, + ScopeActivation.OnScopeCreated, + 'test', + ); + const gen1 = new WorkspaceSkillCatalogStub(catalogSentinel('gen1')); + const { session } = buildSession([stubPair(IWorkspaceSkillCatalog, gen1)]); + expect(session.accessor.get(ISeedProbe).catalogAtActivation).toBe(gen1.catalog); + }); + }); +}); 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 4302ffe081f..b635accd74f 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts @@ -11,9 +11,9 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { LifecycleScope } from '#/app/scopes'; import { _clearScopedRegistryForTests, - LifecycleScope, registerScopedService, } from '#/_base/di/scope'; import { Emitter } from '#/_base/event'; @@ -129,7 +129,6 @@ describe('SessionSkillCatalogService (seed view)', () => { const { host, catalog } = makeSession(seed.data); await catalog.load(); - // A silent seed swap (no change event) becomes visible through reload. seed.replace(catalogOf(stubSkill('two'))); const seen: string[] = []; const subscription = catalog.onDidChange((sourceId) => seen.push(sourceId)); diff --git a/packages/agent-core-v2/test/session/subagent/secondaryModelWarning.test.ts b/packages/agent-core-v2/test/session/subagent/secondaryModelWarning.test.ts index 31dec110d6d..8d840ac7a92 100644 --- a/packages/agent-core-v2/test/session/subagent/secondaryModelWarning.test.ts +++ b/packages/agent-core-v2/test/session/subagent/secondaryModelWarning.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; -import { LifecycleScope, type IAgentScopeHandle } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { type IAgentScopeHandle } from '#/_base/di/scope'; import { TestInstantiationService } from '#/_base/di/test'; import { Emitter } from '#/_base/event'; import { IConfigService } from '#/app/config/config'; diff --git a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts index 7c58badef8f..1a28612dac2 100644 --- a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts @@ -2,7 +2,7 @@ import { createControlledPromise } from '@antfu/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IAgentScopeHandle } from '#/_base/di/scope'; -import { LifecycleScope } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; @@ -1132,7 +1132,6 @@ describe('SessionSwarmService metadata compatibility', () => { }), ).resolves.toMatchObject([{ status: 'completed', agentId: 'agent-existing' }]); - // No realign: resume must not drag the child back to the parent's model. expect(child.accessor.get(IAgentProfileService).data().modelAlias).toBe('stale-model'); expect(eventBus.publish).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts b/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts index 47c71585501..f573f9f73dd 100644 --- a/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts +++ b/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts @@ -10,7 +10,8 @@ import { describe, expect, it } from 'vitest'; import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; import { IInstantiationService } from '#/_base/di/instantiation'; import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; -import { type IAgentScopeHandle, LifecycleScope } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { type IAgentScopeHandle } from '#/_base/di/scope'; import { Emitter } from '#/_base/event'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; diff --git a/packages/agent-core-v2/test/snapshot/events.ts b/packages/agent-core-v2/test/snapshot/events.ts index 08f9cd157a9..306230ced0f 100644 --- a/packages/agent-core-v2/test/snapshot/events.ts +++ b/packages/agent-core-v2/test/snapshot/events.ts @@ -55,10 +55,6 @@ export function recordAgentEvents() { const emit = (entry: RecordedEventEntry): RecordedEventEntry => { entries.push(entry); - // Snapshot-returning waiters match EMIT entries only: a persisted wire - // record can share its type with an event (e.g. `turn.ended`), and the - // waiter's intent is the event — resolving on the wire entry would also - // truncate the matching emit entry out of the returned snapshot. for (let index = eventWaiters.length - 1; index >= 0; index -= 1) { const waiter = eventWaiters[index]!; if (entry.type !== '[rpc]' || waiter.event !== entry.event) continue; diff --git a/packages/agent-core-v2/test/state/stateManifest.test.ts b/packages/agent-core-v2/test/state/stateManifest.test.ts index c78dc0b70d7..d59f08f72a4 100644 --- a/packages/agent-core-v2/test/state/stateManifest.test.ts +++ b/packages/agent-core-v2/test/state/stateManifest.test.ts @@ -28,7 +28,6 @@ describe('state manifest', () => { 'state-manifest.d.ts', readFileSync(MANIFEST_PATH, 'utf-8'), ); - // `parseDiagnostics` is internal in the compiler typings but populated at runtime. const diagnostics = (sourceFile.compilerNode as { parseDiagnostics?: readonly unknown[] }) .parseDiagnostics; expect(diagnostics ?? []).toEqual([]); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 183e2cb4360..2bd1f4e9e38 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -2,8 +2,8 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable, type Writable } from 'node:stream'; - -import { LifecycleScope, type IAgentScopeHandle } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { type IAgentScopeHandle } from '#/_base/di/scope'; import { Event, type Event as KimiEvent } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; import { IFlagService } from '#/app/flag/flag'; @@ -1525,7 +1525,6 @@ describe('Agent tool execution contract', () => { resume: 'agent-existing', }); - // No realign: resume must not drag the child back to the parent's model. expect(targetProfile.update).not.toHaveBeenCalled(); expect(lifecycle.run).toHaveBeenCalledWith( 'agent-existing', diff --git a/packages/agent-core-v2/test/wire/resume.test.ts b/packages/agent-core-v2/test/wire/resume.test.ts index 098c3279468..f4348456ac9 100644 --- a/packages/agent-core-v2/test/wire/resume.test.ts +++ b/packages/agent-core-v2/test/wire/resume.test.ts @@ -15,7 +15,7 @@ import { type PromptOrigin, } from '#/index'; import { IAgentTaskService } from '#/agent/task/task'; -import { IAgentPlanService } from '#/agent/plan/plan'; +import { IAgentPlanService } from '#/features/plan/plan'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { TurnModel } from '#/agent/loop/turnOps'; import { IWireService } from '#/wire/wire'; diff --git a/packages/agent-core-v2/test/wire/wireManifest.test.ts b/packages/agent-core-v2/test/wire/wireManifest.test.ts index b5e76220be6..4ec0429ba9a 100644 --- a/packages/agent-core-v2/test/wire/wireManifest.test.ts +++ b/packages/agent-core-v2/test/wire/wireManifest.test.ts @@ -26,7 +26,6 @@ describe('wire manifest', () => { 'wire-manifest.d.ts', readFileSync(MANIFEST_PATH, 'utf-8'), ); - // `parseDiagnostics` is internal in the compiler typings but populated at runtime. const diagnostics = (sourceFile.compilerNode as { parseDiagnostics?: readonly unknown[] }) .parseDiagnostics; expect(diagnostics ?? []).toEqual([]); diff --git a/packages/agent-core-v2/test/wire/wireService.test.ts b/packages/agent-core-v2/test/wire/wireService.test.ts index 8e68877765b..2750d8487e4 100644 --- a/packages/agent-core-v2/test/wire/wireService.test.ts +++ b/packages/agent-core-v2/test/wire/wireService.test.ts @@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { z } from 'zod'; import { SyncDescriptor } from '#/_base/di/descriptors'; +import { createDecorator } from '#/_base/di/instantiation'; import { DisposableStore, toDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; import { TestInstantiationService } from '#/_base/di/test'; import { resetUnexpectedErrorHandler, setUnexpectedErrorHandler } from '#/_base/errors/unexpectedError'; import { IEventBus } from '#/app/event/eventBus'; @@ -11,10 +13,17 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; -import { defineModel } from '#/wire/model'; +import { defineModel, type ModelDef } from '#/wire/model'; import { WIRE_PROTOCOL_VERSION } from '#/wire/migration/migration'; +import { bindDefineOp, DuplicateOpError, type Op, type OpDescriptor } from '#/wire/op'; import { IWireService } from '#/wire/wire'; import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; +import { + builtinWireContribution, + foldWireContributions, + WireModelContribution, + type WireModelContributionRecord, +} from '#/wire/wireContribution'; import { CycleError } from '#/wire/wireService'; import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from './stubs'; @@ -75,6 +84,48 @@ const mutateCounter = CounterModel.defineOp('store.counter.mutate', { }, }); +interface DynState { + readonly hits: number; +} + +const dynModel: ModelDef = { + name: 'wire.test.dyn', + initial: () => ({ hits: 0 }), + defineOp: bindDefineOp(() => dynModel), +}; + +const dynHit: OpDescriptor<'wire.test.dyn.hit', DynState, { n: number }> = { + type: 'wire.test.dyn.hit', + model: dynModel, + schema: z.object({ n: z.number() }), + apply: (state, payload) => ({ hits: state.hits + payload.n }), +}; + +const dynHitOp = (payload: { n: number }): Op<'wire.test.dyn.hit', { n: number }> => ({ + type: 'wire.test.dyn.hit', + payload, + descriptor: dynHit, +}); + +const evilCounterAdd: OpDescriptor<'store.counter.add', DynState, { by: number }> = { + type: 'store.counter.add', + model: dynModel, + schema: z.object({ by: z.number() }), + apply: () => ({ hits: 999 }), +}; + +interface IDynContributor { + readonly marker: string; +} +const IDynContributor = createDecorator('wire-test-dyn-contributor'); + +class DynContributor extends Service { + constructor(record: WireModelContributionRecord) { + super(); + this.provide(WireModelContribution, record); + } +} + let disposables: DisposableStore; let ix: TestInstantiationService; let wire: IWireService; @@ -287,3 +338,133 @@ describe('WireService', () => { expect(wire.getModel(CounterModel)).toEqual({ value: 1 }); }); }); + +describe('WireService × WireModelContribution fold', () => { + function contribute(record: WireModelContributionRecord): void { + ix.provide(IDynContributor, new SyncDescriptor(DynContributor, [record] as never)); + ix.invokeFunction((accessor) => accessor.get(IDynContributor)); + } + + it('replays records of a runtime-contributed op while its record lives', async () => { + contribute({ models: [dynModel], ops: [dynHit] }); + + await restoreTestAgentWire(wire, log, testWireScope(SCOPE, KEY), [ + { type: 'wire.test.dyn.hit', n: 2 }, + { type: 'store.counter.add', by: 1 }, + ]); + + expect(wire.getModel(dynModel)).toEqual({ hits: 2 }); + expect(wire.getModel(CounterModel)).toEqual({ value: 1 }); + }); + + it('dispatches a runtime-contributed op and persists its record', async () => { + contribute({ models: [dynModel], ops: [dynHit] }); + + wire.dispatch(dynHitOp({ n: 2 }), counterAdd({ by: 1 })); + + expect(wire.getModel(dynModel)).toEqual({ hits: 2 }); + expect(await readRecords()).toContainEqual({ + type: 'wire.test.dyn.hit', + n: 2, + time: expect.any(Number), + }); + }); + + it('skips and counts replayed records of an op whose contribution was withdrawn', async () => { + contribute({ models: [dynModel], ops: [dynHit] }); + ix.unprovide(IDynContributor); + + const unexpected: unknown[] = []; + setUnexpectedErrorHandler((error) => unexpected.push(error)); + try { + await restoreTestAgentWire(wire, log, testWireScope(SCOPE, KEY), [ + { type: 'wire.test.dyn.hit', n: 2 }, + { type: 'store.counter.add', by: 1 }, + ]); + + expect(wire.getModel(dynModel)).toEqual({ hits: 0 }); + expect(wire.getModel(CounterModel)).toEqual({ value: 1 }); + expect(unexpected).toHaveLength(1); + expect(unexpected[0]).toMatchObject({ + code: 'wire.unknown_record', + details: { type: 'wire.test.dyn.hit', index: 0 }, + }); + } finally { + resetUnexpectedErrorHandler(); + } + }); + + it('keeps the built-in op when a contribution conflicts with a built-in op type', async () => { + const unexpected: unknown[] = []; + setUnexpectedErrorHandler((error) => unexpected.push(error)); + try { + contribute({ models: [dynModel], ops: [evilCounterAdd] }); + expect(unexpected).toHaveLength(1); + expect(unexpected[0]).toMatchObject({ + code: 'wire.duplicate_op', + details: { type: 'store.counter.add' }, + }); + + await restoreTestAgentWire(wire, log, testWireScope(SCOPE, KEY), [ + { type: 'store.counter.add', by: 5 }, + ]); + + expect(wire.getModel(CounterModel)).toEqual({ value: 5 }); + expect(wire.getModel(dynModel)).toEqual({ hits: 0 }); + } finally { + resetUnexpectedErrorHandler(); + } + }); + + it('runs cross-reducers from a contribution, and stops once it is withdrawn', () => { + contribute({ + models: [dynModel], + crossReducers: new Map([ + [ + 'store.counter.add', + [{ model: dynModel, reducer: (state: DynState, p: { by: number }) => ({ hits: state.hits + p.by }) }], + ], + ]), + }); + + wire.dispatch(counterAdd({ by: 2 })); + expect(wire.getModel(dynModel)).toEqual({ hits: 2 }); + + ix.unprovide(IDynContributor); + wire.dispatch(counterAdd({ by: 3 })); + expect(wire.getModel(CounterModel)).toEqual({ value: 5 }); + expect(wire.getModel(dynModel)).toEqual({ hits: 2 }); + }); + + it('keeps defineOp module-path behavior: duplicate type throws DuplicateOpError', () => { + expect(() => + CounterModel.defineOp('store.counter.add', { + schema: z.object({}), + apply: (state) => state, + }), + ).toThrow(DuplicateOpError); + }); + + it('foldWireContributions folds the built-in layer, keeps the first op per type, dedups models', () => { + const dynHitClone: OpDescriptor<'wire.test.dyn.hit', DynState, { n: number }> = { ...dynHit }; + const unexpected: unknown[] = []; + setUnexpectedErrorHandler((error) => unexpected.push(error)); + try { + const folded = foldWireContributions([ + builtinWireContribution(), + { models: [dynModel, dynModel], ops: [dynHit, dynHitClone] }, + ]); + + expect(folded.ops.get('store.counter.add')?.type).toBe('store.counter.add'); + expect(folded.ops.get('wire.test.dyn.hit')).toBe(dynHit); + expect(folded.models.filter((model) => model === dynModel)).toHaveLength(1); + expect(unexpected).toHaveLength(1); + expect(unexpected[0]).toMatchObject({ + code: 'wire.duplicate_op', + details: { type: 'wire.test.dyn.hit' }, + }); + } finally { + resetUnexpectedErrorHandler(); + } + }); +}); diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index 10a88d87c01..12ff392942d 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -5,9 +5,9 @@ import { tmpdir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; import { type IAgentScopeHandle, - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, @@ -38,7 +38,7 @@ import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; import { IWorkspaceMcpService, type ISessionMcpOverlay } from '#/workspace/workspaceMcp/workspaceMcp'; -import { IAgentPlanService } from '#/agent/plan/plan'; +import { IAgentPlanService } from '#/features/plan/plan'; import { ISessionCronService } from '#/session/cron/sessionCronService'; import { ISessionSecondaryModelWarningService } from '#/session/subagent/secondaryModelWarning'; import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; @@ -561,11 +561,6 @@ describe('SessionLifecycleService', () => { await Promise.all(tmpRoots.map((root) => rm(root, { recursive: true, force: true }))); }); - /** - * Build the App host and materialize the default handler (`/tmp/proj`, - * `wd_stub` with the default workspace stub), returning its session - * lifecycle service. - */ async function build( extra: ReturnType[] = [], ): Promise { @@ -670,10 +665,6 @@ describe('SessionLifecycleService', () => { const handle = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - // The index entry addresses the session under the handler's workspace id - // — the same id seeding the session's storage scope — not a recomputed - // encodeWorkDirKey, so the v1 reader finds it in the bucket it was - // materialized into. const workspaceId = handle.accessor.get(ISessionContext).workspaceId; expect(appended).toEqual([ { @@ -728,8 +719,6 @@ describe('SessionLifecycleService', () => { }), stubPair(IWorkspaceService, { ...workspaceStub(), - // As the real registry does after folding: the id minted for the - // first-seen spelling is reused for the alias. createOrTouch: (root: string, name?: string) => Promise.resolve({ id: 'wd_first_spelling', @@ -1041,8 +1030,6 @@ describe('SessionLifecycleService', () => { await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); - // Fork never gates on activity: a mid-work copy is crash-equivalent, and - // replay already normalizes that on restore. const target = await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); expect(target.id).toBe('dst'); }); diff --git a/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts index 4218b77de33..0a75d0ee004 100644 --- a/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts @@ -2,11 +2,13 @@ * Scenario: agent-profile loaders + session catalog — the Contribution / * Registry / Catalog extension point end to end. Exercises the real loader * services (builtin / user / plugin / workspace / extra / explicit), the - * App-scope `AgentProfileRegistryService`, and the Session-scope - * `SessionAgentProfileCatalogService` as directly-constructed instances (no DI - * scope host) against real temp directories: source-priority merge, the - * builtin-override rule, explicit fatal semantics, config / plugin-reload / - * fs-watch driven reloads, and SYSTEM.md interplay. Run: + * App-scope `AgentProfileRegistryService` fold, and the Session-scope + * `SessionAgentProfileCatalogService` — the loaders and the fold are resolved + * through one DI container (the `this.provide` collection-contribution path + * requires container-constructed units) against real temp directories: + * source-priority merge, the builtin-override rule, explicit fatal semantics, + * config / plugin-reload / fs-watch driven reloads, and SYSTEM.md interplay. + * Run: * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run * test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts`. */ @@ -18,40 +20,52 @@ import { join } from 'pathe'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Emitter, Event } from '#/_base/event'; -import type { ILogService } from '#/_base/log/log'; +import { SyncDescriptor } from '#/_base/di/descriptors'; +import type { ServiceIdentifier } from '#/_base/di/instantiation'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; +import { ILogService } from '#/_base/log/log'; import { EXTRA_AGENT_DIRS_SECTION } from '#/workspace/workspaceAgentProfileLoader/configSection'; import { UserAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService'; -import type { PluginAgentRoot } from '#/app/plugin/types'; +import type { PluginAgentRoot, ReloadSummary } from '#/app/plugin/types'; import { DEFAULT_AGENT_PROFILE_NAME, normalizeAgentProfile, type AgentProfile, } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; +import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; import { BuiltinAgentProfileLoaderService } from '#/app/agentProfileCatalog/builtinAgentProfileLoaderService'; +import { AGENT_PROFILE_SOURCE_PRIORITY } from '#/app/agentProfileCatalog/agentProfileContribution'; import { _clearAgentProfileContributionsForTests, registerAgentProfile, } from '#/app/agentProfileCatalog/contribution'; -import type { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import type { IConfigService } from '#/app/config/config'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; import { IPluginService } from '#/app/plugin/plugin'; import { PluginAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService'; -import type { ReloadSummary } from '#/app/plugin/types'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService'; import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { + IHostFsWatchService, type HostFsChange, type IHostFsWatchHandle, - type IHostFsWatchService, } from '#/os/interface/hostFsWatch'; import { SessionAgentProfileCatalogService } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService'; import type { ISessionAgentProfileCatalogSeed } from '#/session/sessionAgentProfileCatalog/agentProfileCatalogSeed'; import { ExplicitAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService'; import { ExtraAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService'; import { WorkspaceAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService'; -import type { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +import { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +import { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; import { stubBootstrap } from '../../app/bootstrap/stubs'; @@ -209,12 +223,6 @@ function waitForEvent(event: Event): Promise { }); } -/** - * Wraps a real `HostFileSystem` so `readdir` throws an injected - * `os.fs.unavailable` whenever `shouldFail` accepts the path — the failure - * class that propagates out of discovery and exercises the loader failure - * policy (non-fatal warn / fatal reject). - */ function failingReaddirFs( hostFs: HostFileSystem, shouldFail: (path: string) => boolean, @@ -241,12 +249,6 @@ interface StackOptions { readonly fsWatch?: IHostFsWatchService; } -/** - * Builds the whole loader stack as directly-constructed instances sharing one - * registry, plus the session catalog projected over it. Loaders start their - * first load in their constructors, so callers `await stack.ready()` (or the - * individual `loader.ready`) before asserting. - */ function makeStack(fixture: Fixture, opts?: StackOptions) { const warnings: string[] = []; const log = logStub(warnings); @@ -259,49 +261,34 @@ function makeStack(fixture: Fixture, opts?: StackOptions) { const hostFs = opts?.hostFs ?? new HostFileSystem(); const workspaceContext = workspaceContextStub(fixture.workDir); - const registry = new AgentProfileRegistryService(); - const builtinLoader = new BuiltinAgentProfileLoaderService(registry); - const userLoader = new UserAgentProfileLoaderService( - bootstrap, - hostFs, - log, - builtinLoader, - registry, - workspaceContext, - ); - const pluginLoader = new PluginAgentProfileLoaderService( - pluginStub(opts?.pluginAgentRoots ?? [], opts?.pluginReloadEmitter), - hostFs, - log, - userLoader, - registry, - workspaceContext, - ); - const workspaceLoader = new WorkspaceAgentProfileLoaderService( - workspaceContext, - hostFs, - log, - userLoader, - opts?.fsWatch ?? fsWatchStub(), - registry, - ); - const extraLoader = new ExtraAgentProfileLoaderService( - config, - workspaceContext, - bootstrap, - hostFs, - log, - userLoader, - registry, - ); - const explicitLoader = new ExplicitAgentProfileLoaderService( - workspaceContext, - bootstrap, - hostFs, - log, - userLoader, - registry, + const container = new InstantiationService( + new ServiceCollection( + [ILogService, log], + [IConfigService, config], + [IBootstrapService, bootstrap], + [IHostFileSystem, hostFs], + [IHostFsWatchService, opts?.fsWatch ?? fsWatchStub()], + [IWorkspaceContext, workspaceContext], + [IPluginService, pluginStub(opts?.pluginAgentRoots ?? [], opts?.pluginReloadEmitter)], + [IAgentProfileRegistry, new SyncDescriptor(AgentProfileRegistryService)], + [IBuiltinAgentProfileLoader, new SyncDescriptor(BuiltinAgentProfileLoaderService)], + [IUserAgentProfileLoader, new SyncDescriptor(UserAgentProfileLoaderService)], + [IPluginAgentProfileLoader, new SyncDescriptor(PluginAgentProfileLoaderService)], + [IWorkspaceAgentProfileLoader, new SyncDescriptor(WorkspaceAgentProfileLoaderService)], + [IExtraAgentProfileLoader, new SyncDescriptor(ExtraAgentProfileLoaderService)], + [IExplicitAgentProfileLoader, new SyncDescriptor(ExplicitAgentProfileLoaderService)], + ), + true, ); + const get = (id: ServiceIdentifier): T => + container.invokeFunction((accessor) => accessor.get(id)); + const registry = get(IAgentProfileRegistry); + const builtinLoader = get(IBuiltinAgentProfileLoader); + const userLoader = get(IUserAgentProfileLoader); + const pluginLoader = get(IPluginAgentProfileLoader); + const workspaceLoader = get(IWorkspaceAgentProfileLoader); + const extraLoader = get(IExtraAgentProfileLoader); + const explicitLoader = get(IExplicitAgentProfileLoader); const seed: ISessionAgentProfileCatalogSeed = { _serviceBrand: undefined, workspaceKey: workspaceContext.workspaceId, @@ -319,7 +306,6 @@ function makeStack(fixture: Fixture, opts?: StackOptions) { catalog, config, warnings, - /** Awaits every loader's latest load pass plus the catalog readiness. */ async ready(): Promise { await Promise.all([ userLoader.ready, @@ -331,18 +317,8 @@ function makeStack(fixture: Fixture, opts?: StackOptions) { await catalog.ready; }, dispose(): void { - for (const disposable of [ - catalog, - explicitLoader, - extraLoader, - workspaceLoader, - pluginLoader, - userLoader, - builtinLoader, - registry, - ]) { - disposable.dispose(); - } + catalog.dispose(); + container.dispose(); }, }; } @@ -364,8 +340,6 @@ async function withStack( describe('agent profile loaders + session catalog', () => { beforeEach(() => { - // The builtin loader snapshots the module-level contributions on - // construction; pin them to one known default profile per test. _clearAgentProfileContributionsForTests(); const builtinDefault: AgentProfile = normalizeAgentProfile({ name: DEFAULT_AGENT_PROFILE_NAME, @@ -652,8 +626,6 @@ describe('agent profile loaders + session catalog', () => { await mkdir(join(fixture.homeDir, 'agents'), { recursive: true }); const hostFs = failingReaddirFs(new HostFileSystem(), () => true); await withStack(fixture, { hostFs }, async (stack) => { - // A non-fatal first-load failure degrades to a warning; `ready` still - // resolves and nothing replaces the builtin contribution. await stack.ready(); expect(stack.catalog.get(DEFAULT_AGENT_PROFILE_NAME)?.description).toBe('builtin default'); @@ -701,9 +673,6 @@ describe('agent profile loaders + session catalog', () => { expect(stack.catalog.get('exp-agent')?.description).toBe('explicit'); await rm(explicitFile, { force: true }); - // Mirror the production event-handler call style - // (`void loader.reload().catch(warn)`): a rejecting fatal reload must - // surface as a warning, never crash, and leave the stale contribution. void stack.explicitLoader .reload() .catch((error) => @@ -791,8 +760,6 @@ describe('agent profile loaders + session catalog', () => { it('rescans the workspace source when a project agent file changes on disk', async () => { await withFixture(async (fixture) => { - // Pre-create the candidate directory so the chokidar initial scan sees - // it; a file written afterwards is guaranteed a non-initial `add` event. await mkdir(join(fixture.workDir, '.kimi-code', 'agents'), { recursive: true }); await withStack(fixture, { fsWatch: new HostFsWatchService() }, async (stack) => { await stack.ready(); @@ -808,7 +775,6 @@ describe('agent profile loaders + session catalog', () => { const timedOut = new Promise((_resolve, reject) => { setTimeout(() => reject(new Error('watch-driven refresh timed out')), 10000); }); - // Let the watcher finish its initial scan before the change lands. await new Promise((resolve) => setTimeout(resolve, 300)); await writeAgent( join(fixture.workDir, '.kimi-code', 'agents'), @@ -821,4 +787,48 @@ describe('agent profile loaders + session catalog', () => { }); }); }, 15000); + + it('lands every loader’s provided record in the registry entries', async () => { + await withFixture(async (fixture) => { + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + const bySourceId = new Map(stack.registry.entries().map((entry) => [entry.sourceId, entry])); + expect([...bySourceId.keys()].toSorted()).toEqual([ + 'builtin', + 'explicit', + 'extra', + 'plugin', + 'user', + 'workspace', + ]); + expect(bySourceId.get('builtin')?.workspaceKey).toBeUndefined(); + expect(bySourceId.get('builtin')?.priority).toBe(AGENT_PROFILE_SOURCE_PRIORITY.builtin); + for (const sourceId of ['explicit', 'extra', 'plugin', 'user', 'workspace'] as const) { + expect(bySourceId.get(sourceId)?.workspaceKey).toBe('wd_test'); + expect(bySourceId.get(sourceId)?.priority).toBe(AGENT_PROFILE_SOURCE_PRIORITY[sourceId]); + } + }); + }); + }); + + it('withdraws a loader’s record (and re-projects the catalog) when the loader is disposed', async () => { + await withFixture(async (fixture) => { + await writeAgent( + join(fixture.homeDir, 'agents'), + 'user-only.md', + agentMd('user-only', 'user agent'), + ); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + expect(stack.catalog.get('user-only')).toBeDefined(); + + (stack.userLoader as unknown as { dispose(): void }).dispose(); + + expect(stack.registry.entries().some((entry) => entry.sourceId === 'user')).toBe(false); + expect(stack.catalog.get('user-only')).toBeUndefined(); + expect(stack.catalog.get(DEFAULT_AGENT_PROFILE_NAME)?.description).toBe('builtin default'); + }); + }); + }); }); diff --git a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts index 2bda70bf0c8..d9b0f576ae2 100644 --- a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts @@ -23,9 +23,8 @@ import { tmpdir } from 'node:os'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, @@ -266,7 +265,6 @@ describe('workspace add-dir (handler chain)', () => { return root; } - /** A project root with a `.git` marker so local.toml lands at the root. */ async function makeProjectRoot(): Promise { const root = await makeRoot('kimi-add-dir-proj-'); await mkdir(join(root, '.git')); @@ -413,13 +411,10 @@ describe('workspace add-dir (handler chain)', () => { expect(result.projectRoot).toBe(root); expect(result.configPath).toBe(join(root, '.kimi-code', 'local.toml')); expect(result.additionalDirs).toEqual([extra]); - // local.toml written on disk. const toml = await readFile(join(root, '.kimi-code', 'local.toml'), 'utf8'); expect(toml).toContain('additional_dir'); expect(toml).toContain(extra); - // The live session's view refreshed through the change event. expect(dirsOf(s1)).toEqual([extra]); - // A second session of the same workspace sees it immediately. const s2 = await service.create({ sessionId: 's2', workDir: root }); expect(dirsOf(s2)).toEqual([extra]); }); @@ -454,9 +449,7 @@ describe('workspace add-dir (handler chain)', () => { expect(result.persisted).toBe(false); expect(result.additionalDirs).toEqual([extra]); expect(dirsOf(s1)).toEqual([extra]); - // Nothing written: local.toml does not exist. await expect(readFile(join(root, '.kimi-code', 'local.toml'), 'utf8')).rejects.toThrow(); - // The in-memory dir is shared with a second session of the workspace. const s2 = await service.create({ sessionId: 's2', workDir: root }); expect(dirsOf(s2)).toEqual([extra]); }); @@ -470,16 +463,11 @@ describe('workspace add-dir (handler chain)', () => { const s1 = await service.create({ sessionId: 's1', workDir: root }); expect(dirsOf(s1)).toEqual([]); - // External write (another process, an editor, `kimi` in a second CLI). await mkdir(join(root, '.kimi-code'), { recursive: true }); const writeLocalToml = () => writeFile(join(root, '.kimi-code', 'local.toml'), `[workspace]\nadditional_dir = ["${extra}"]\n`); await writeLocalToml(); - // The chokidar watcher ignores files it finds during its initial scan - // (`ignoreInitial`), so a write landing inside that window is swallowed; - // rewrite while polling (slower than the 200ms reload debounce, so the - // debounce always gets a quiet window) until a `modify` event lands. const deadline = Date.now() + 10_000; while (!dirsOf(s1).includes(extra)) { if (Date.now() > deadline) { @@ -521,8 +509,6 @@ describe('workspace add-dir (handler chain)', () => { const s1 = await service.create({ sessionId: 's1', workDir: root, additionalDirs: [extra] }); expect(dirsOf(s1)).toEqual([extra]); - // Caller dirs join the handler-shared set: a session created WITHOUT the - // option sees them too, and nothing was persisted. const s2 = await service.create({ sessionId: 's2', workDir: root }); expect(dirsOf(s2)).toEqual([extra]); await expect(readFile(join(root, '.kimi-code', 'local.toml'), 'utf8')).rejects.toThrow(); diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts index b7619d3f78a..c66b43c0ed0 100644 --- a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts @@ -2,9 +2,8 @@ import { isAbsolute, join, relative, resolve } from 'node:path'; import { Readable, Writable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, @@ -486,8 +485,6 @@ describe('WorkspaceFsService.search', () => { emptyHandler, ); const result = await fs.search({ query: '', limit: 50, follow_gitignore: false }); - // Dirs first, then files, alphabetical inside each group; hidden entries - // and nested paths are not listed. expect(result.items.map((i) => i.path)).toEqual(['src', 'README.md']); expect(result.items[0]).toMatchObject({ name: 'src', diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts index 0553515fc24..2441c2276cf 100644 --- a/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts @@ -9,8 +9,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { LifecycleScope } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; import { createScopedTestHost, stubPair } from '#/_base/di/test'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { @@ -238,9 +237,6 @@ describe('WorkspaceFsWatchService', () => { expect(events).toHaveLength(0); }); - // Phase-4 behavior contract: two sessions of one workspace share the - // handler's single os watch — subscriptions fan out, they never hang a - // second watcher. it('shares one os watch across subscriptions and fans events out per subscription', () => { const { svc, watch } = makeWorkspace(); const subA = svc.subscribe(); diff --git a/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts b/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts index 9627b96a707..68c8c8e9dbf 100644 --- a/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts @@ -79,8 +79,6 @@ describe('WorkspaceInstructionsService', () => { } function fireWatch(path: string): void { - // The service watches each plan ROOT (brand home, real home, project - // root) recursively — fire on the root that contains the changed file. for (const [root, emitter] of watchFires) { if (path === root || path.startsWith(`${root}/`)) { emitter.fire({ path, action: 'modified', kind: 'file' }); diff --git a/packages/agent-core-v2/test/workspace/workspaceMcp/initialization.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcp/initialization.test.ts index 2ef798c3877..c00aa152b2d 100644 --- a/packages/agent-core-v2/test/workspace/workspaceMcp/initialization.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceMcp/initialization.test.ts @@ -124,11 +124,9 @@ describe('Workspace MCP initialization', () => { }, }); const service = createWorkspaceMcpService(ready); - // The manager is available synchronously, independent of config readiness. manager = service.connectionManager(); expect(manager.list()).toEqual([]); - // The initial connect is gated on config.ready: no entry exists yet. await sleep(50); expect(manager.list()).toEqual([]); diff --git a/packages/agent-core-v2/test/workspace/workspaceMcpConfig/workspaceMcpConfig.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcpConfig/workspaceMcpConfig.test.ts index 303f1958abb..a308de1931d 100644 --- a/packages/agent-core-v2/test/workspace/workspaceMcpConfig/workspaceMcpConfig.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceMcpConfig/workspaceMcpConfig.test.ts @@ -291,8 +291,6 @@ describe('WorkspaceMcpConfigService', () => { pluginServers = {}; pluginReloads.fire({ added: [], removed: [], errors: [] }); - // The merged view is unchanged (the file entry still wins), so no event - // fires and the snapshot stays put. await new Promise((resolvePromise) => setTimeout(resolvePromise, 500)); expect(changes).toEqual([]); expect(service.servers()).toEqual({ shared: stdioConfig('file-version') }); diff --git a/packages/agent-core-v2/test/workspace/workspaceProcess/workspaceProcessRunnerService.test.ts b/packages/agent-core-v2/test/workspace/workspaceProcess/workspaceProcessRunnerService.test.ts index 8f26c497c44..51e80e4da13 100644 --- a/packages/agent-core-v2/test/workspace/workspaceProcess/workspaceProcessRunnerService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceProcess/workspaceProcessRunnerService.test.ts @@ -4,9 +4,8 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable } from 'node:stream'; - +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, diff --git a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts index d1e917dd613..16a04ef1088 100644 --- a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts @@ -17,9 +17,8 @@ import { tmpdir } from 'node:os'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - +import { LifecycleScope } from '#/app/scopes'; import { - LifecycleScope, ScopeActivation, _clearScopedRegistryForTests, registerScopedService, @@ -472,8 +471,6 @@ describe('workspace resource sharing (handler chain)', () => { () => { expect(catalog.catalog.getSkill('watched-skill')?.description).toBe('from watch'); }, - // Real FSEvents delivery + the 200 ms source debounce + a real disk - // rescan: under high parallel load the 10 s budget flakes, so allow 30 s. { timeout: 30000, interval: 100 }, ); }, 60000); diff --git a/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts index 822a37e55eb..45b7657cf6e 100644 --- a/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts @@ -15,9 +15,9 @@ import { join } from 'pathe'; import { beforeEach, describe, expect, it } from 'vitest'; import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { LifecycleScope } from '#/app/scopes'; import { _clearScopedRegistryForTests, - LifecycleScope, registerScopedService, } from '#/_base/di/scope'; import { Emitter, Event } from '#/_base/event'; @@ -228,9 +228,6 @@ async function withSkillCatalogWorkspace( describe('WorkspaceSkillCatalogService', () => { beforeEach(() => { - // Keep the scoped registry limited to the catalog chain these tests - // exercise so unrelated OnScopeCreated registrations do not run; every - // other dependency arrives as a seeded stub via `createScopedTestHost`. _clearScopedRegistryForTests(); registerScopedService(LifecycleScope.App, IBuiltinSkillSource, BuiltinSkillSource); registerScopedService(LifecycleScope.App, IUserFileSkillSource, UserFileSkillSource); @@ -285,7 +282,6 @@ describe('WorkspaceSkillCatalogService', () => { const contributions = states.get(workspaceSkillCatalogContributionsKey); expect([...contributions.keys()]).toContain('workspace'); expect(states.get(workspaceSkillCatalogMergedKey)).toBe(catalog.catalog); - // A class instance collapses to a marker in the JSON-safe snapshot. expect(states.snapshot()['workspaceSkillCatalog.merged']).toBe('(InMemorySkillCatalog)'); host.dispose(); }); diff --git a/packages/agent-core-v2/test/workspace/workspaceToolPolicy/workspaceToolPolicy.test.ts b/packages/agent-core-v2/test/workspace/workspaceToolPolicy/workspaceToolPolicy.test.ts index 5edac74f5ce..62048abdef9 100644 --- a/packages/agent-core-v2/test/workspace/workspaceToolPolicy/workspaceToolPolicy.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceToolPolicy/workspaceToolPolicy.test.ts @@ -5,8 +5,7 @@ */ import { afterEach, describe, expect, it } from 'vitest'; - -import { LifecycleScope } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; import { createScopedTestHost, stubPair, type ScopedTestHost } from '#/_base/di/test'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; diff --git a/packages/kap-server/AGENTS.md b/packages/kap-server/AGENTS.md index 52267f5c15a..906b10c8c89 100644 --- a/packages/kap-server/AGENTS.md +++ b/packages/kap-server/AGENTS.md @@ -24,7 +24,7 @@ Implements the op-batch sequencing contract: ## Session events - The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. -- Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. +- Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` / `event.di.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. One exception: the high-churn `event.di.*` debug feed only reaches connections opted in via `broadcaster.addDiEventTarget` — a temporary gate until a client-declared event whitelist exists, currently keyed on `client_hello` carrying `client_id: 'kimi-inspect'`. ## Global search diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index bb8361779b2..7556e051292 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -628,6 +628,14 @@ export const configWarningEventSchema = z.object({ ), }); +export const diUnitChangedEventSchema = z.object({ + type: z.literal('event.di.unit_changed'), + scope: z.string().min(1), + token: z.string().min(1), + state: z.enum(['Pending', 'Activating', 'Active', 'Unloading', 'Failed']), + error: z.string().optional(), +}); + export const goalUpdatedEventSchema = z.object({ type: z.literal('goal.updated'), snapshot: goalSnapshotSchema.nullable(), @@ -956,6 +964,7 @@ export const agentEventSchema = z.discriminatedUnion('type', [ workspaceDeletedEventSchema, sessionWorkChangedEventSchema, sessionStatusChangedEventSchema, + diUnitChangedEventSchema, goalUpdatedEventSchema, skillActivatedEventSchema, pluginCommandActivatedEventSchema, diff --git a/packages/kap-server/src/routes/transcript.ts b/packages/kap-server/src/routes/transcript.ts index f8601e545cc..6734f393c31 100644 --- a/packages/kap-server/src/routes/transcript.ts +++ b/packages/kap-server/src/routes/transcript.ts @@ -681,7 +681,7 @@ function readPlanReviewDisplay(display: unknown): PlanReviewDisplayInfo | undefi } // The wording mirrors `formatPlanForOutput` / `formatAutoApprovedPlanForOutput` -// in `agent-core-v2/src/agent/tools/plan/exit-plan-mode/exitPlanModeTool.ts` — the approved +// in `agent-core-v2/src/features/plan/tools/exit-plan-mode/exitPlanModeTool.ts` — the approved // tool result embeds the full plan body after one of these markers, and the // plan file path on a `Plan saved to: ` line. const PLAN_SAVED_TO_MARKER = 'Plan saved to: '; diff --git a/packages/kap-server/src/transport/channelRegistry.ts b/packages/kap-server/src/transport/channelRegistry.ts index 8bda0e997b9..2bf20314cdc 100644 --- a/packages/kap-server/src/transport/channelRegistry.ts +++ b/packages/kap-server/src/transport/channelRegistry.ts @@ -158,7 +158,7 @@ export function describeAllChannels(): readonly ChannelDescriptor[] { return [...byName.entries()] .map(([name, entry]) => ({ name, - scope: SCOPE_NAME[entry.scope], + scope: SCOPE_NAME[entry.scope as LifecycleScope], domain: entry.domain, methods: describeMethods(entry.descriptor.ctor), })) diff --git a/packages/kap-server/src/transport/ws/v1/events.ts b/packages/kap-server/src/transport/ws/v1/events.ts index 629796ba63d..49474daeaba 100644 --- a/packages/kap-server/src/transport/ws/v1/events.ts +++ b/packages/kap-server/src/transport/ws/v1/events.ts @@ -113,6 +113,20 @@ export interface ConfigWarningEvent { readonly warnings: readonly ConfigWarningItem[]; } +/** + * DI unit state transition of the engine's scope tree, produced by + * agent-core-v2's `IDebugCascadeService` (the L5 debug surface feed). Global: + * carries no owning session and fans out to every connection. + */ +export interface DiUnitChangedEvent { + readonly type: 'event.di.unit_changed'; + /** Scope path of the container owning the unit (`app` / `app/workspace:` / …). */ + readonly scope: string; + readonly token: string; + readonly state: 'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed'; + readonly error?: string; +} + export interface PromptSubmittedEvent { readonly type: 'prompt.submitted'; readonly promptId: string; @@ -196,6 +210,7 @@ export type AgentEvent = | SessionStatusChangedEvent | ConfigChangedEvent | ConfigWarningEvent + | DiUnitChangedEvent | PromptSubmittedEvent | BackgroundTaskStartedEvent | BackgroundTaskTerminatedEvent; @@ -211,6 +226,7 @@ export const VOLATILE_EVENT_TYPES = [ 'shell.started', 'shell.completed', 'agent.status.updated', + 'event.di.unit_changed', ] as const; export type VolatileEventType = (typeof VOLATILE_EVENT_TYPES)[number]; diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 5f5b18d9778..38f151e7d92 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -36,9 +36,11 @@ * families, including every session's `event.session.work_changed`) are pushed * to EVERY established connection (registered via * {@link SessionEventBroadcaster.addGlobalTarget}, no subscription needed) - * union every subscribed target. Session/agent events only reach connections - * subscribed to that session, subject to the per-subscription agent allowlist - * and the transcript suppression below. Transcript frames (`transcript.reset` + * union every subscribed target — except the `event.di.*` debug feed, which + * only reaches connections opted in via + * {@link SessionEventBroadcaster.addDiEventTarget}. Session/agent events only + * reach connections subscribed to that session, subject to the + * per-subscription agent allowlist and the transcript suppression below. Transcript frames (`transcript.reset` * / `transcript.ops`) are a separate channel: they are governed by the * per-agent transcript grades alone and bypass the agent allowlist entirely. * @@ -75,6 +77,7 @@ import { } from '@moonshot-ai/agent-core-v2'; import type { ConfigWarningItem, + DiUnitChangedEvent, SessionCreatedEvent, SessionMetaUpdatedEvent, Event, @@ -222,6 +225,16 @@ export class SessionEventBroadcaster { * session's `event.session.work_changed` — without subscribing to anything. */ private readonly globalTargets = new Set(); + /** + * Opt-in set for the `event.di.*` debug-surface feed. That feed is global + * (no owning session) and high-churn, but only kimi-inspect's DI view + * consumes it — pushing it to every connection wastes bandwidth on clients + * that drop the frames unread. Temporary gate until a client-declared + * event-type whitelist exists: `WsConnectionV1` opts a connection in when + * its `client_hello` carries `client_id: 'kimi-inspect'`; every other + * connection (including subscribed targets) skips `event.di.*` frames. + */ + private readonly diEventTargets = new Set(); /** * Single-flight guard for session activation: without it, two concurrent * activations (WS subscribe racing a REST snapshot / replay / resync) each @@ -270,6 +283,16 @@ export class SessionEventBroadcaster { /** Drop a closed connection from the global fan-out set. Idempotent. */ removeGlobalTarget(target: BroadcastTarget): void { this.globalTargets.delete(target); + this.diEventTargets.delete(target); + } + + /** + * Opt a connection into the `event.di.*` debug-surface feed (see + * {@link diEventTargets}). Idempotent; cleaned up by + * {@link removeGlobalTarget}. + */ + addDiEventTarget(target: BroadcastTarget): void { + this.diEventTargets.add(target); } /** @@ -896,6 +919,25 @@ export class SessionEventBroadcaster { } as Event).catch((error: unknown) => this.logDispatchError(GLOBAL_SESSION_ID, 'event.config.warning', error), ); + return; + } + if (event.type === 'event.di.unit_changed') { + const payload = diUnitChangedPayload(event.payload); + if (payload === undefined) return; + // Engine DI unit state transitions (the debug-surface feed) have no + // owning session: route through the global state so the envelope carries + // the '__global__' watermark. `isGlobalEvent` fans it out, but delivery + // is gated to connections opted in via `addDiEventTarget` (kimi-inspect + // only) — `VOLATILE_EVENT_TYPES` keeps the churn unjournaled. + void this.dispatchGlobal({ + type: 'event.di.unit_changed', + ...payload, + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.di.unit_changed', error), + ); + return; } } @@ -1237,7 +1279,11 @@ export class SessionEventBroadcaster { // minimal embeds) on the legacy delivery path. const recipients = new Set(this.globalTargets); for (const target of this.allTargets()) recipients.add(target); + // The `event.di.*` debug feed is opt-in (kimi-inspect only) — every + // other connection drops those frames unread anyway. + const diGated = event.type.startsWith('event.di.'); for (const target of recipients) { + if (diGated && !this.diEventTargets.has(target)) continue; try { target.send(envelope, 'immediate'); } catch { @@ -1327,13 +1373,14 @@ function legacyTaskEvent(event: DomainEvent, agentId: string, sessionId: string) return { ...event, type: legacyType, agentId, sessionId } as unknown as Event; } -/** Session/workspace/config events are broadcast to every connection. */ +/** Session/workspace/config/di events are broadcast to every connection. */ function isGlobalEvent(type: string): boolean { return ( type === 'session.meta.updated' || type.startsWith('event.session.') || type.startsWith('event.workspace.') || - type.startsWith('event.config.') + type.startsWith('event.config.') || + type.startsWith('event.di.') ); } @@ -1582,6 +1629,37 @@ function sessionMetaUpdatedSessionId(payload: unknown): string | undefined { return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : undefined; } +const DI_UNIT_STATES: ReadonlySet = new Set([ + 'Pending', + 'Activating', + 'Active', + 'Unloading', + 'Failed', +]); + +/** + * Validate the `event.di.unit_changed` payload published on the core + * `IEventService` by agent-core-v2's `IDebugCascadeService` (the debug + * surface's unit state feed). Malformed payloads are dropped, never forwarded. + */ +function diUnitChangedPayload( + payload: unknown, +): Pick | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const candidate = payload as Partial; + if (typeof candidate.scope !== 'string' || candidate.scope.length === 0) return undefined; + if (typeof candidate.token !== 'string' || candidate.token.length === 0) return undefined; + if (typeof candidate.state !== 'string' || !DI_UNIT_STATES.has(candidate.state)) { + return undefined; + } + return { + scope: candidate.scope, + token: candidate.token, + state: candidate.state as DiUnitChangedEvent['state'], + error: typeof candidate.error === 'string' ? candidate.error : undefined, + }; +} + /** * Validate the `event.session.created` payload published on the core * `IEventService`. The create/fork/child routes publish diff --git a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts index 555c37fe6aa..2545faf5b8b 100644 --- a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts +++ b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts @@ -239,6 +239,12 @@ export class WsConnectionV1 implements BroadcastTarget { const cursors = payload['cursors'] as Record | undefined; const agentFilter = parseAgentFilter(payload['agent_filter']); + // Temporary opt-in for the `event.di.*` debug feed: only kimi-inspect + // consumes it, so the broadcaster gates that fan-out to connections whose + // hello declares this client id (see `addDiEventTarget`). Both kimi-inspect + // sockets (activity + transcript) send `client_id: 'kimi-inspect'`. + if (payload['client_id'] === 'kimi-inspect') this.broadcaster.addDiEventTarget(this); + const accepted: string[] = []; const resyncRequired: string[] = []; const serverCursors: Record = {}; diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 660f10a540d..9d5fd631d38 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -1074,6 +1074,60 @@ describe('SessionEventBroadcaster', () => { expect(s1View.envelopes[0]!.volatile).toBeUndefined(); }); + it('gates event.di.unit_changed to connections opted into the DI debug feed', async () => { + // The engine's DI debug feed (agent-core-v2's IDebugCascadeService) has no + // owning session: it routes through the global state ('__global__' + // watermark). Only kimi-inspect consumes it, so delivery is opt-in via + // `addDiEventTarget` — every other connection skips the frames; being + // volatile they are never journaled. + const plainView = collectingTarget(); + bc.addGlobalTarget(plainView.target); + const diView = collectingTarget(); + bc.addGlobalTarget(diView.target); + bc.addDiEventTarget(diView.target); + + eventBus.emit({ + type: 'event.di.unit_changed', + payload: { scope: 'app', token: 'debugCascadeService', state: 'Active' }, + }); + + await vi.waitFor(() => expect(diView.envelopes).toHaveLength(1)); + expect(diView.envelopes[0]).toMatchObject({ + type: 'event.di.unit_changed', + session_id: '__global__', + volatile: true, + payload: { + type: 'event.di.unit_changed', + scope: 'app', + token: 'debugCascadeService', + state: 'Active', + agentId: 'main', + sessionId: '__global__', + }, + }); + expect(diView.deliveries).toEqual(['immediate']); + // The non-opted-in connection never sees the debug feed. + expect(plainView.envelopes).toHaveLength(0); + + // A malformed payload is dropped, never forwarded. + eventBus.emit({ + type: 'event.di.unit_changed', + payload: { scope: 'app', token: 'x', state: 'Exploded' }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(diView.envelopes).toHaveLength(1); + + // `removeGlobalTarget` also drops the DI opt-in (the connection-close path). + bc.removeGlobalTarget(diView.target); + eventBus.emit({ + type: 'event.di.unit_changed', + payload: { scope: 'app', token: 'debugCascadeService', state: 'Unloading' }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(diView.envelopes).toHaveLength(1); + expect(plainView.envelopes).toHaveLength(0); + }); + describe('global fan-out to unsubscribed connections', () => { it('delivers event.session.created to a global-only target that never subscribed', async () => { sessions.set('s1', new FakeLifecycle()); diff --git a/packages/kap-server/test/wsConnectionV1.test.ts b/packages/kap-server/test/wsConnectionV1.test.ts index 8012963670f..1dd1707b77c 100644 --- a/packages/kap-server/test/wsConnectionV1.test.ts +++ b/packages/kap-server/test/wsConnectionV1.test.ts @@ -773,11 +773,13 @@ describe('WsConnectionV1 global target registration', () => { function makeGlobalTargetBroadcaster() { const added: unknown[] = []; const removed: unknown[] = []; + const diOptIns: unknown[] = []; const broadcaster = { subscribe: async () => true, unsubscribe: () => {}, addGlobalTarget: (target: unknown) => added.push(target), removeGlobalTarget: (target: unknown) => removed.push(target), + addDiEventTarget: (target: unknown) => diOptIns.push(target), getCursor: async () => ({ seq: 0, epoch: '' }), getBufferedSince: async () => ({ events: [], @@ -786,7 +788,7 @@ describe('WsConnectionV1 global target registration', () => { epoch: '', }), } as unknown as SessionEventBroadcaster; - return { broadcaster, added, removed }; + return { broadcaster, added, removed, diOptIns }; } it('registers the connection as a global target on construction and unregisters on close', () => { @@ -810,4 +812,29 @@ describe('WsConnectionV1 global target registration', () => { socket.emit('close'); expect(removed).toEqual([conn]); }); + + it('opts only kimi-inspect connections into the event.di.* debug feed on client_hello', async () => { + const socket = new FakeSocket(); + const { broadcaster, diOptIns } = makeGlobalTargetBroadcaster(); + const conn = makeConn(socket, { broadcaster }); + + // Another client id (or none) never joins the DI fan-out. + socket.emit( + 'message', + JSON.stringify({ type: 'client_hello', id: 'h1', payload: { client_id: 'kimi-web' } }), + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(diOptIns).toEqual([]); + + socket.emit( + 'message', + JSON.stringify({ + type: 'client_hello', + id: 'h2', + payload: { client_id: 'kimi-inspect' }, + }), + ); + await vi.waitFor(() => expect(diOptIns).toEqual([conn])); + conn.close(); + }); }); diff --git a/packages/klient/src/contract/agent/rpc.ts b/packages/klient/src/contract/agent/rpc.ts index 53a5a4a67c0..1186c694a44 100644 --- a/packages/klient/src/contract/agent/rpc.ts +++ b/packages/klient/src/contract/agent/rpc.ts @@ -122,6 +122,19 @@ export const agentContextDataSchema = z.object({ tokenCount: z.number(), }); +/** `AgentCommandInfo` (`agent-core-v2/agent/command/agentCommand.ts`). */ +export const agentCommandInfoSchema = z.object({ + name: z.string(), + description: z.string().optional(), + source: z.string(), +}); + +/** Same shape as `RunCommandPayload` in the engine. */ +export const runCommandPayloadSchema = z.object({ + name: z.string(), + args: z.string().optional(), +}); + /** `PlanData = null | { id, content, path }` — null is JSON-representable. */ export const planDataSchema = z.union([ z.null(), @@ -209,4 +222,9 @@ export const agentRpcContract = { cancel: { input: z.tuple([cancelPayloadSchema]), output: noResult }, setPermission: { input: z.tuple([setPermissionPayloadSchema]), output: noResult }, getContext: { input: z.tuple([emptyPayloadSchema]), output: agentContextDataSchema }, + listCommands: { + input: z.tuple([emptyPayloadSchema]), + output: z.array(agentCommandInfoSchema), + }, + runCommand: { input: z.tuple([runCommandPayloadSchema]), output: noResult }, } satisfies ServiceContract; diff --git a/packages/klient/src/contract/session/lifecycle.ts b/packages/klient/src/contract/session/lifecycle.ts index 84bca5042e4..edf502c8554 100644 --- a/packages/klient/src/contract/session/lifecycle.ts +++ b/packages/klient/src/contract/session/lifecycle.ts @@ -50,7 +50,7 @@ export const createChildSessionOptionsSchema = forkSessionOptionsSchema; /** `IScopeHandle` as it survives JSON — `{ id, kind }` plus extras. */ export const handleWireSchema = z.looseObject({ id: z.string(), - kind: z.number(), + kind: z.string(), }); /** `WorkspaceRef` — a `workspaceId` (optional `root` hint) or a bare `root`. */ diff --git a/packages/klient/src/core/facade/agent.ts b/packages/klient/src/core/facade/agent.ts index f22c4cdc814..145e5fbaf9f 100644 --- a/packages/klient/src/core/facade/agent.ts +++ b/packages/klient/src/core/facade/agent.ts @@ -8,8 +8,9 @@ */ import type { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; +import type { IAgentCommandService } from '@moonshot-ai/agent-core-v2/agent/command/agentCommand'; import type { IAgentMcpService } from '@moonshot-ai/agent-core-v2/agent/mcp/mcp'; -import type { IAgentPlanService } from '@moonshot-ai/agent-core-v2/agent/plan/plan'; +import type { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import type { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import type { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; import type { IAgentTaskService } from '@moonshot-ai/agent-core-v2/agent/task/task'; @@ -28,6 +29,7 @@ export type SetModelResult = Awaited; export type UsageStatus = Awaited>; export type AgentContextData = Awaited>; +export type AgentCommandInfo = Awaited>[number]; export type PlanData = Awaited>; export type AgentTaskInfo = Awaited>[number]; export type McpServerEntry = ReturnType[number]; @@ -55,6 +57,8 @@ export interface AgentFacade { setPermission(mode: PermissionMode): Promise; getUsage(): Promise; getContext(): Promise; + listCommands(): Promise; + runCommand(input: { name: string; args?: string }): Promise; getPlan(): Promise; enterPlan(): Promise; clearPlan(): Promise; @@ -99,6 +103,8 @@ export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFac setPermission: (mode) => rpc('setPermission', { mode }) as Promise, getUsage: () => call(scope, 'agentUsageService', 'status', []) as Promise, getContext: () => rpc('getContext', {}) as Promise, + listCommands: () => rpc('listCommands', {}) as Promise, + runCommand: (input) => rpc('runCommand', input) as Promise, getPlan: () => call(scope, 'agentPlanService', 'status', []) as Promise, enterPlan: () => call(scope, 'agentPlanService', 'enter', []) as Promise, clearPlan: () => call(scope, 'agentPlanService', 'clear', []) as Promise, diff --git a/packages/klient/src/index.ts b/packages/klient/src/index.ts index 500c0135d16..bd02deee325 100644 --- a/packages/klient/src/index.ts +++ b/packages/klient/src/index.ts @@ -66,6 +66,7 @@ export type { SessionStatus, } from './core/facade/session.js'; export type { + AgentCommandInfo, AgentContextData, AgentFacade, AgentTaskInfo, diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index b442e79db00..00f31551e27 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -32,7 +32,7 @@ import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/ques import { ISessionSkillCatalog } from '@moonshot-ai/agent-core-v2/session/sessionSkillCatalog/skillCatalog'; import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; import { IAgentActivityView } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; -import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/agent/plan/plan'; +import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; import { IAgentTaskService } from '@moonshot-ai/agent-core-v2/agent/task/task'; diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index df3f65708ed..fb6bdf032d1 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -23,7 +23,7 @@ import type { } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; import type { AgentContextData } from '@moonshot-ai/agent-core-v2/agent/contextMemory/types'; import type { TurnEndReason } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; -import type { PlanData } from '@moonshot-ai/agent-core-v2/agent/plan/plan'; +import type { PlanData } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import type { ActivateSkillPayload, AgentAPI, @@ -156,6 +156,7 @@ import { turnPhaseSchema, } from '../src/contract/agent/activity.js'; import { + agentCommandInfoSchema, agentContextDataSchema, agentTaskInfoSchema, activateSkillPayloadSchema, @@ -169,6 +170,7 @@ import { promptLaunchResultSchema, promptPartSchema, promptPayloadSchema, + runCommandPayloadSchema, runShellCommandPayloadSchema, setModelPayloadSchema, setModelResultSchema, @@ -530,6 +532,8 @@ type PromptLaunchResult = NonNullable>; type SteerPayload = Parameters[0]; type CancelPayload = Parameters[0]; type SetPermissionPayload = Parameters[0]; +type AgentCommandInfo = Awaited>[number]; +type RunCommandPayload = Parameters[0]; type TokenUsage = NonNullable; const _emptyPayload: AssertWire = true; @@ -561,6 +565,8 @@ const _usageStatus: AssertWire = true; // One-directional: `history` entries are full `ContextMessage`s (deep // `Message`/`Tool`/`PromptOrigin` unions) mirrored as `unknown`. const _agentContextData: AssertEngineToWire = true; +const _agentCommandInfo: AssertWire = true; +const _runCommandPayload: AssertWire = true; const _planData: AssertWire = true; const _cancelPlanPayload: AssertWire = true; const _getTasksPayload: AssertWire = true; diff --git a/packages/klient/test/facade.test.ts b/packages/klient/test/facade.test.ts index d19feb1e1a4..8c76e8cf9a4 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -321,7 +321,7 @@ describe('session lifecycle routing', () => { const channel = new FakeChannel(); const klient = createKlientFromChannel(channel); channel.results.set('sessionIndex.get', SUMMARY); - channel.results.set('sessionLifecycleService.restore', { id: 's1', kind: 2 }); + channel.results.set('sessionLifecycleService.restore', { id: 's1', kind: 'session' }); const opts = { mcpServers: { example: { transport: 'stdio' as const, command: 'node' } }, @@ -339,8 +339,8 @@ describe('session lifecycle routing', () => { it('sessions.create forwards mcpServers to the engine', async () => { const channel = new FakeChannel(); const klient = createKlientFromChannel(channel); - channel.results.set('workspaceLifecycleService.handlerFor', { id: 'w1', kind: 1 }); - channel.results.set('sessionLifecycleService.create', { id: 's1', kind: 2 }); + channel.results.set('workspaceLifecycleService.handlerFor', { id: 'w1', kind: 'workspace' }); + channel.results.set('sessionLifecycleService.create', { id: 's1', kind: 'session' }); channel.results.set('sessionMetadata.read', { id: 's1', createdAt: 1, diff --git a/packages/klient/test/helpers/conformance.ts b/packages/klient/test/helpers/conformance.ts index 9b410436e51..226dce0b1ab 100644 --- a/packages/klient/test/helpers/conformance.ts +++ b/packages/klient/test/helpers/conformance.ts @@ -11,10 +11,21 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { Service } from '@moonshot-ai/agent-core-v2/_base/di/service'; +import { CommandContribution } from '@moonshot-ai/agent-core-v2/agent/command/commandContribution'; +import { IFeatureManager } from '@moonshot-ai/agent-core-v2/app/feature/featureManager'; + import type { Klient } from '../../src/index.js'; +import type { TestEngine } from './engine.js'; export interface KlientConformanceTarget { readonly klient: Klient; + /** + * The in-process engine's App scope. Both transports boot the engine + * in-process, so the suite can assemble dynamic units (e.g. contributed + * commands) through the production `IFeatureManager` path. + */ + readonly app: TestEngine['app']; cleanup(): Promise; } @@ -256,5 +267,61 @@ export function defineKlientConformance( const status = await target.klient.global.auth.status(); expect(typeof status.loggedIn).toBe('boolean'); }); + + it('agent commands list and run a contributed command', async () => { + const created = await target.klient.global.sessions.create({ + workDir: process.cwd(), + title: 'conformance commands', + }); + const calls: string[] = []; + + // A dynamic App-scope unit contributing one command into the + // `CommandContribution` collection — the same path a Feature takes. + class ConformanceCommands extends Service { + static override readonly name = 'klient-conformance-commands'; + constructor() { + super(); + this.provide(CommandContribution, { + name: 'conformance-echo', + description: 'records its args', + run: (ctx) => { + calls.push(ctx.args); + }, + }); + } + } + + const featureManager = target.app.accessor.get(IFeatureManager); + const handle = featureManager.provideUnit(ConformanceCommands); + try { + const agent = target.klient.session(created.id).agent('main'); + + // Dynamic assembly goes through the cascade — poll until visible. + let infos = await agent.listCommands(); + const deadline = Date.now() + 5_000; + while (!infos.some((command) => command.name === 'conformance-echo')) { + if (Date.now() > deadline) break; + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + infos = await agent.listCommands(); + } + expect(infos.map((command) => command.name)).toContain('conformance-echo'); + const echo = infos.find((command) => command.name === 'conformance-echo'); + expect(echo).toMatchObject({ name: 'conformance-echo', description: 'records its args' }); + expect(typeof echo?.source).toBe('string'); + + await agent.runCommand({ name: 'conformance-echo', args: 'hello commands' }); + expect(calls).toEqual(['hello commands']); + + // Unknown names fail with a coded engine error. + await expect(agent.runCommand({ name: 'conformance-missing' })).rejects.toThrow( + /Unknown command/, + ); + } finally { + await handle.dispose(); + await target.klient.session(created.id).close(); + } + }); }); } diff --git a/packages/klient/test/ipc.test.ts b/packages/klient/test/ipc.test.ts index 0019acc77c8..d0d91c58048 100644 --- a/packages/klient/test/ipc.test.ts +++ b/packages/klient/test/ipc.test.ts @@ -15,6 +15,7 @@ defineKlientConformance('ipc', async () => { const klient = createKlient({ socketPath }); return { klient, + app, cleanup: async () => { await klient.close(); await host.close(); diff --git a/packages/klient/test/memory.test.ts b/packages/klient/test/memory.test.ts index 074d5e865ee..1fd74fdf595 100644 --- a/packages/klient/test/memory.test.ts +++ b/packages/klient/test/memory.test.ts @@ -13,6 +13,7 @@ defineKlientConformance('memory', async () => { const klient = createKlient({ scope: app }); return { klient, + app, cleanup: async () => { await klient.close(); app.dispose(); diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index fc631b8c0a6..e33199d2a56 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -26,6 +26,7 @@ import type { ApprovalHandler, QuestionHandler } from '#/events'; import type { AddAdditionalDirInput, AddAdditionalDirResult, + AgentCommandInfo, BackgroundTaskInfo, ConfigDiagnostics, CreateSessionOptions, @@ -129,6 +130,11 @@ export interface ActivatePluginCommandRpcInput extends SessionIdRpcInput { readonly args?: string | undefined; } +export interface RunCommandRpcInput extends SessionIdRpcInput { + readonly name: string; + readonly args?: string | undefined; +} + export interface ReconnectMcpServerRpcInput extends SessionIdRpcInput { readonly name: string; } @@ -850,6 +856,26 @@ export abstract class SDKRpcClientBase { }); } + /** + * Contributed commands of the session's interactive agent. The + * contributed-command seam exists only in the agent-core-v2 engine, so the + * base implementation reports the empty set and rejects runs with a coded + * error (same shape as `replaceConfigSections`); only the v2 client + * overrides these. + */ + async listCommands(input: SessionIdRpcInput): Promise { + void input; + return []; + } + + async runCommand(input: RunCommandRpcInput): Promise { + void input; + throw new KimiError( + ErrorCodes.NOT_IMPLEMENTED, + 'This SDK client does not support contributed commands.', + ); + } + onEvent(listener: (event: Event) => void): Unsubscribe { this.eventListeners.add(listener); return () => { diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index de2281f3810..d42ddfcd361 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -44,9 +44,10 @@ * (`src/v2/resume-replay.ts`) — `includeSubagents` and `replayTurnLimit` * included. * - `setModel` / `setPermission` / `setPlanMode` / `getPlan` / `clearPlan` / - * `getContext` / `getUsage` / `cancel` → the `klient.session(id).agent(id)` - * facade; `setThinking` / `compact` / `cancelCompaction` / `undoHistory` / - * `clearContext` / `importContext` → agent-scope services through the live + * `getContext` / `getUsage` / `cancel` / `listCommands` / `runCommand` → + * the `klient.session(id).agent(id)` facade; `setThinking` / `compact` / + * `cancelCompaction` / `undoHistory` / `clearContext` / `importContext` → + * agent-scope services through the live * session handle (no facade exists); `getStatus` → the same six-slice * aggregate the base class builds, re-read from the profile / permission / * swarm services plus the facade. `importContext` composes v1's exact @@ -241,6 +242,7 @@ import { type ImportContextRpcInput, type ReconnectMcpServerRpcInput, type ReloadSessionRpcInput, + type RunCommandRpcInput, type SessionIdRpcInput, type SessionPromptRpcInput, type SetSessionModelRpcInput, @@ -254,8 +256,9 @@ import { import type { AddAdditionalDirInput, AddAdditionalDirResult, - CapabilityStatus, + AgentCommandInfo, BackgroundTaskInfo, + CapabilityStatus, CompactOptions, ConfigDiagnostics, CreateGoalInput, @@ -1413,6 +1416,18 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return agent.clearPlan(); } + /** Facade (`agentRPCService.listCommands`) — the v2-only contributed-command seam. */ + override async listCommands(input: SessionIdRpcInput): Promise { + const agent = await this.agentFacade(input.sessionId); + return agent.listCommands(); + } + + /** Facade (`agentRPCService.runCommand`) — runs the contribution engine-side. */ + override async runCommand(input: RunCommandRpcInput): Promise { + const agent = await this.agentFacade(input.sessionId); + return agent.runCommand({ name: input.name, args: input.args }); + } + /** * Facade (`agentRPCService.getContext`). The v2 `AgentContextData` is the * same wire shape as v1's — the cast only bridges the two packages' type diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 5e4db69f899..4a3325fdcc4 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -11,8 +11,9 @@ import type { SDKRpcClientBase } from '#/rpc'; import type { AddAdditionalDirOptions, AddAdditionalDirResult, - CapabilityStatus, + AgentCommandInfo, BackgroundTaskInfo, + CapabilityStatus, CompactOptions, CreateGoalInput, GetCronTasksResult, @@ -371,6 +372,15 @@ export class Session { return this.rpc.listPluginCommands({ sessionId: this.id }); } + /** + * Contributed commands registered with this session's interactive agent + * (agent-core-v2 only — a v1-backed session reports the empty set). + */ + async listCommands(): Promise { + this.ensureOpen(); + return this.rpc.listCommands({ sessionId: this.id }); + } + /** * List background tasks for this session's interactive agent. * @@ -632,6 +642,24 @@ export class Session { }); } + /** + * Run a contributed command engine-side (agent-core-v2 only — a v1-backed + * client rejects with `not_implemented`). Unknown names reject with the + * engine's `request.invalid` error. + */ + async runCommand(name: string, args?: string): Promise { + this.ensureOpen(); + const commandName = name.trim(); + if (commandName.length === 0) { + throw new KimiError(ErrorCodes.REQUEST_INVALID, 'Command name cannot be empty'); + } + await this.rpc.runCommand({ + sessionId: this.id, + name: commandName, + args: normalizeOptionalString(args), + }); + } + async close(): Promise { if (this.closed) return; this.closed = true; diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 5b52c519310..04d94c702ab 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -73,6 +73,9 @@ export type { export type { KimiHostIdentity, OAuthRefreshOutcome }; export type { TelemetryClient, TelemetryContextPatch, TelemetryProperties }; export type { ContentPart, Role, ThinkingEffort, ToolCall } from '@moonshot-ai/kosong'; +// Contributed commands are an agent-core-v2 seam; the type is re-exported +// from the v2 engine (v1 sessions report an empty command set). +export type { AgentCommandInfo } from '@moonshot-ai/agent-core-v2/agent/command/agentCommand'; export type PermissionMode = 'yolo' | 'manual' | 'auto'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c02bc1ad1d4..65ce6650336 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -710,21 +710,12 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: - '@dagrejs/dagre': - specifier: ^1.1.4 - version: 1.1.8 '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 '@types/picomatch': specifier: ^4.0.3 version: 4.0.3 - '@types/react': - specifier: ^19.1.2 - version: 19.2.14 - '@types/react-dom': - specifier: ^19.1.2 - version: 19.2.3(@types/react@19.2.14) '@types/retry': specifier: 0.12.0 version: 0.12.0 @@ -740,18 +731,6 @@ importers: '@types/yazl': specifier: ^2.4.6 version: 2.4.6 - '@vitejs/plugin-react': - specifier: ^4.4.1 - version: 4.7.0(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) - '@xyflow/react': - specifier: ^12.4.0 - version: 12.11.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@11.1.11)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: - specifier: ^19.1.0 - version: 19.2.5 - react-dom: - specifier: ^19.1.0 - version: 19.2.5(react@19.2.5) sinon: specifier: ^22.0.0 version: 22.0.0 @@ -761,9 +740,6 @@ importers: tsx: specifier: ^4.21.0 version: 4.21.0 - vite: - specifier: ^6.3.3 - version: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) packages/kaos: dependencies: @@ -1534,13 +1510,6 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} - '@dagrejs/dagre@1.1.8': - resolution: {integrity: sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw==} - - '@dagrejs/graphlib@2.2.4': - resolution: {integrity: sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==} - engines: {node: '>17.0.0'} - '@docsearch/css@3.8.2': resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} @@ -4771,22 +4740,6 @@ packages: '@xterm/headless@5.5.0': resolution: {integrity: sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==} - '@xyflow/react@12.11.1': - resolution: {integrity: sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q==} - peerDependencies: - '@types/react': '>=17' - '@types/react-dom': '>=17' - react: '>=17' - react-dom: '>=17' - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@xyflow/system@0.0.78': - resolution: {integrity: sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==} - a-sync-waterfall@1.0.1: resolution: {integrity: sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==} @@ -5165,9 +5118,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - classcat@5.0.5: - resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} - classnames@2.3.1: resolution: {integrity: sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==} @@ -9686,21 +9636,6 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - zustand@4.5.7: - resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} - engines: {node: '>=12.7.0'} - peerDependencies: - '@types/react': '>=16.8' - immer: '>=9.0.6' - react: '>=16.8' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - zustand@5.0.14: resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} engines: {node: '>=12.20.0'} @@ -10504,12 +10439,6 @@ snapshots: '@csstools/css-tokenizer@3.0.4': optional: true - '@dagrejs/dagre@1.1.8': - dependencies: - '@dagrejs/graphlib': 2.2.4 - - '@dagrejs/graphlib@2.2.4': {} - '@docsearch/css@3.8.2': {} '@docsearch/js@3.8.2(@algolia/client-search@5.52.1)(search-insights@2.17.3)': @@ -13625,31 +13554,6 @@ snapshots: '@xterm/headless@5.5.0': {} - '@xyflow/react@12.11.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@11.1.11)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@xyflow/system': 0.0.78 - classcat: 5.0.5 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.11)(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - transitivePeerDependencies: - - immer - - '@xyflow/system@0.0.78': - dependencies: - '@types/d3-drag': 3.0.7 - '@types/d3-interpolate': 3.0.4 - '@types/d3-selection': 3.0.11 - '@types/d3-transition': 3.0.9 - '@types/d3-zoom': 3.0.8 - d3-drag: 3.0.0 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-zoom: 3.0.0 - a-sync-waterfall@1.0.1: {} abstract-logging@2.0.1: {} @@ -14045,8 +13949,6 @@ snapshots: dependencies: clsx: 2.1.1 - classcat@5.0.5: {} - classnames@2.3.1: {} cli-cursor@5.0.0: @@ -19310,14 +19212,6 @@ snapshots: zod@4.3.6: {} - zustand@4.5.7(@types/react@19.2.14)(immer@11.1.11)(react@19.2.5): - dependencies: - use-sync-external-store: 1.6.0(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - immer: 11.1.11 - react: 19.2.5 - zustand@5.0.14(@types/react@19.2.14)(immer@11.1.11)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)): optionalDependencies: '@types/react': 19.2.14