From 46e454620ca3ba2f293dab1b306effbaa4ba69c8 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 10:05:13 +0800 Subject: [PATCH 1/3] refactor(agent-core-v2): extract swarm into a scope-organized feature - move src/agent/swarm, src/session/swarm, and src/agent/tools/agent-swarm into src/features/swarm/{agent,session,tools/agent-swarm}; swarmOps.ts stays a static import=register wire channel at the feature root - add SwarmFeature carrying the three runtime registrations (IAgentSwarmService, ISessionSwarmService, IAgentSwarmTool) with ScopeActivation.OnScopeCreated preserved - switch src/index.ts to precise leaf exports and update import sites, including the kap-server and kimi-inspect deep-path imports - move tests to test/features/swarm and re-assert service overrides in the test harness so stubs keep winning over feature contributions --- apps/kimi-inspect/src/panels.ts | 2 +- packages/agent-core-v2/AGENTS.md | 2 +- .../agent-core-v2/docs/wire-manifest.d.ts | 8 ++-- .../app/sessionLegacy/sessionLegacyService.ts | 2 +- .../swarm/agent}/enter-reminder.md | 0 .../swarm/agent}/exit-reminder.md | 0 .../swarm/agent}/injection/swarmInjection.ts | 0 .../swarm => features/swarm/agent}/swarm.ts | 0 .../swarm/agent}/swarmService.ts | 16 ++----- .../swarm/session}/agentRunBatch.ts | 0 .../swarm/session}/sessionSwarm.ts | 0 .../swarm/session}/sessionSwarmService.ts | 13 +---- .../src/features/swarm/swarmFeature.ts | 44 +++++++++++++++++ .../src/{agent => features}/swarm/swarmOps.ts | 2 +- .../swarm}/tools/agent-swarm/agent-swarm.md | 0 .../swarm}/tools/agent-swarm/agent-swarm.ts | 7 ++- .../tools/agent-swarm/agentSwarmTool.ts | 16 ++----- packages/agent-core-v2/src/index.ts | 13 ++--- .../test/agent/goal/goal.test.ts | 2 +- .../goal/injection/goalInjection.test.ts | 2 +- .../agent-core-v2/test/agent/goal/stubs.ts | 2 +- .../test/agent/goal/tools/goal-tools.test.ts | 2 +- .../toolActivationService.test.ts | 3 +- .../app/sessionLegacy/sessionLegacy.test.ts | 2 +- .../swarm/sessionSwarm.test.ts | 6 +-- .../{agent => features}/swarm/swarm.test.ts | 20 ++++---- packages/agent-core-v2/test/harness/agent.ts | 47 ++++++++++++++++++- packages/agent-core-v2/test/tool/tool.test.ts | 4 +- .../kap-server/src/protocol/events-zod.ts | 2 +- .../src/services/transcript/coreEventMap.ts | 2 +- 30 files changed, 141 insertions(+), 78 deletions(-) rename packages/agent-core-v2/src/{agent/swarm => features/swarm/agent}/enter-reminder.md (100%) rename packages/agent-core-v2/src/{agent/swarm => features/swarm/agent}/exit-reminder.md (100%) rename packages/agent-core-v2/src/{agent/swarm => features/swarm/agent}/injection/swarmInjection.ts (100%) rename packages/agent-core-v2/src/{agent/swarm => features/swarm/agent}/swarm.ts (100%) rename packages/agent-core-v2/src/{agent/swarm => features/swarm/agent}/swarmService.ts (91%) rename packages/agent-core-v2/src/{session/swarm => features/swarm/session}/agentRunBatch.ts (100%) rename packages/agent-core-v2/src/{session/swarm => features/swarm/session}/sessionSwarm.ts (100%) rename packages/agent-core-v2/src/{session/swarm => features/swarm/session}/sessionSwarmService.ts (97%) create mode 100644 packages/agent-core-v2/src/features/swarm/swarmFeature.ts rename packages/agent-core-v2/src/{agent => features}/swarm/swarmOps.ts (95%) rename packages/agent-core-v2/src/{agent => features/swarm}/tools/agent-swarm/agent-swarm.md (100%) rename packages/agent-core-v2/src/{agent => features/swarm}/tools/agent-swarm/agent-swarm.ts (91%) rename packages/agent-core-v2/src/{agent => features/swarm}/tools/agent-swarm/agentSwarmTool.ts (95%) rename packages/agent-core-v2/test/{session => features}/swarm/sessionSwarm.test.ts (99%) rename packages/agent-core-v2/test/{agent => features}/swarm/swarm.test.ts (98%) diff --git a/apps/kimi-inspect/src/panels.ts b/apps/kimi-inspect/src/panels.ts index ae42e1afa0..5556470128 100644 --- a/apps/kimi-inspect/src/panels.ts +++ b/apps/kimi-inspect/src/panels.ts @@ -23,7 +23,7 @@ import { IAgentPermissionModeService } from '@moonshot-ai/agent-core-v2/agent/pe import { IAgentPermissionRulesService } from '@moonshot-ai/agent-core-v2/agent/permissionRules/permissionRules'; import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; -import { IAgentSwarmService } from '@moonshot-ai/agent-core-v2/agent/swarm/swarm'; +import { IAgentSwarmService } from '@moonshot-ai/agent-core-v2/features/swarm/agent/swarm'; import { IAgentTaskService } from '@moonshot-ai/agent-core-v2/agent/task/task'; import { IAgentTokenCountingService } from '@moonshot-ai/agent-core-v2/agent/tokenCounting/tokenCounting'; import { IAgentToolRegistryService } from '@moonshot-ai/agent-core-v2/agent/toolRegistry/toolRegistry'; diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index 9873491794..8d370cde78 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -19,7 +19,7 @@ The DI kernel (`src/_base/di/`) owns the unit layer on top of the scoped registr The four contribution seams (token → fold): config sections — `ConfigSectionContribution` → `ConfigRegistry` fold (`src/app/config/`; module-level `registerConfigSection` stays the static built-in channel drained at construction, a withdrawn runtime record unregisters the section while user TOML values survive); agent tools — `AgentToolContribution` → `AgentToolActivationService` fold (built-in records provided once at App scope by `builtinToolAssemblyService`; `registerAgentToolService` stays the static channel: Agent-scope DI `OnDemand` registration + module table); agent profiles — `AgentProfileContribution` → `IAgentProfileRegistry` fold (see Scopes); wire vocabulary — `WireModelContribution` → `WireService` fold (a record bundles `models` / `ops` / `crossReducers` / `checkpointedModels`; the built-in layer is the module tables drained at fold time — `defineOp` / `defineModel` / `defineCheckpointedModel` stay the static channel — and replaying a withdrawn domain's history lands on the unknown-op skip-and-count path). A fifth seam: executable commands — `CommandContribution` → `IAgentCommandService` fold (`src/agent/command/`; a contributed command runs engine-side — `run(ctx)` gets `ctx.get` resolving through the agent container, valid only during the synchronous part of `run`; name-level dedup, last record wins; surfaced over RPC as `agentCommandService.list` / `run`). -`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`. +`src/features/` — built-in capabilities authored as self-contained Feature units (`plan` was the first, extracted from `agent/plan` + `agent/tools/plan`; `swarm` followed, extracted from `agent/swarm` + `session/swarm` + `agent/tools/agent-swarm` into a scope-organized `agent/` + `session/` + `tools/` layout). 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) diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 1b3a0fed0a..f01b5a55d5 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -54,8 +54,8 @@ // plugin.session_start pluginSessionStartSnapshot persisted src/agent/plugin/agentPluginOps.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 -// swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts +// swarm_mode.enter swarm persisted src/features/swarm/swarmOps.ts +// swarm_mode.exit swarm persisted src/features/swarm/swarmOps.ts // task.started task persisted src/agent/task/taskOps.ts // task.terminated task persisted src/agent/task/taskOps.ts // token_counting.measured tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts @@ -502,7 +502,7 @@ interface SkillActivatePayload { /** * model: swarm · persisted · toEvent - * owner: src/agent/swarm/swarmOps.ts + * owner: src/features/swarm/swarmOps.ts */ interface SwarmModeEnterPayload { _name: 'swarm_mode.enter'; @@ -512,7 +512,7 @@ interface SwarmModeEnterPayload { /** * model: swarm · persisted · toEvent · cross-reducers: contextMemory - * owner: src/agent/swarm/swarmOps.ts + * owner: src/features/swarm/swarmOps.ts */ interface SwarmModeExitPayload { _name: 'swarm_mode.exit'; diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index b4353decfa..21a3409536 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -28,7 +28,7 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { IAgentPlanService } from '#/features/plan/plan'; import { IAgentProfileService } from '#/agent/profile/profile'; -import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; import { getLiveSessionById, resumeSessionById, diff --git a/packages/agent-core-v2/src/agent/swarm/enter-reminder.md b/packages/agent-core-v2/src/features/swarm/agent/enter-reminder.md similarity index 100% rename from packages/agent-core-v2/src/agent/swarm/enter-reminder.md rename to packages/agent-core-v2/src/features/swarm/agent/enter-reminder.md diff --git a/packages/agent-core-v2/src/agent/swarm/exit-reminder.md b/packages/agent-core-v2/src/features/swarm/agent/exit-reminder.md similarity index 100% rename from packages/agent-core-v2/src/agent/swarm/exit-reminder.md rename to packages/agent-core-v2/src/features/swarm/agent/exit-reminder.md diff --git a/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts b/packages/agent-core-v2/src/features/swarm/agent/injection/swarmInjection.ts similarity index 100% rename from packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts rename to packages/agent-core-v2/src/features/swarm/agent/injection/swarmInjection.ts diff --git a/packages/agent-core-v2/src/agent/swarm/swarm.ts b/packages/agent-core-v2/src/features/swarm/agent/swarm.ts similarity index 100% rename from packages/agent-core-v2/src/agent/swarm/swarm.ts rename to packages/agent-core-v2/src/features/swarm/agent/swarm.ts diff --git a/packages/agent-core-v2/src/agent/swarm/swarmService.ts b/packages/agent-core-v2/src/features/swarm/agent/swarmService.ts similarity index 91% rename from packages/agent-core-v2/src/agent/swarm/swarmService.ts rename to packages/agent-core-v2/src/features/swarm/agent/swarmService.ts index b27cdfe1d8..d7e42c9391 100644 --- a/packages/agent-core-v2/src/agent/swarm/swarmService.ts +++ b/packages/agent-core-v2/src/features/swarm/agent/swarmService.ts @@ -6,7 +6,9 @@ * derives `agent.status.updated` from the Ops' `toEvent`, announces the mode * through the `swarm_mode` context-injection provider (`SwarmInjection`), * mirrors replayable trailing-enter removal through `contextMemory`, and - * auto-exits on turn end via `turn`. Bound at Agent scope. The service also + * auto-exits on turn end via `turn`. Bound at Agent scope — contributed into + * every Agent scope by `SwarmFeature` (`features/swarm/swarmFeature`). The + * service also * guards AgentSwarm batch exclusivity through an `onBeforeExecuteTool` veto * listener: an AgentSwarm call must be the only tool call in its batch; * anything else is vetoed with a `toolApproval.formatDenyMessage`-formatted @@ -15,8 +17,6 @@ import { Service } from '#/_base/di/service'; import { IInstantiationService } from '#/_base/di/instantiation'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; @@ -26,7 +26,7 @@ import { IWireService } from '#/wire/wire'; import { SwarmInjection } from './injection/swarmInjection'; import { IAgentSwarmService, type SwarmModeTrigger } from './swarm'; -import { swarmEnter, swarmExit, SwarmModel } from './swarmOps'; +import { swarmEnter, swarmExit, SwarmModel } from '../swarmOps'; export class AgentSwarmService extends Service implements IAgentSwarmService { declare readonly _serviceBrand: undefined; @@ -95,14 +95,6 @@ export class AgentSwarmService extends Service implements IAgentSwarmService { } } -registerScopedService( - LifecycleScope.Agent, - IAgentSwarmService, - AgentSwarmService, - ScopeActivation.OnScopeCreated, - 'swarm', -); - function multipleAgentSwarmDeniedMessage(hasOtherToolCalls: boolean): string { const suffix = hasOtherToolCalls ? ' AgentSwarm also must not be combined with other tools in the same response.' diff --git a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts b/packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts similarity index 100% rename from packages/agent-core-v2/src/session/swarm/agentRunBatch.ts rename to packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts b/packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts similarity index 100% rename from packages/agent-core-v2/src/session/swarm/sessionSwarm.ts rename to packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts similarity index 97% rename from packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts rename to packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts index 651bbb7c38..c4a682afda 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts @@ -15,13 +15,12 @@ * bindings are resolved through the model catalog before lifecycle allocation. * Resumed agents keep the model recorded in their own wire journal — with * per-subagent models there is no "child follows the parent's current model" - * invariant to enforce. Bound at Session scope. + * invariant to enforce. Bound at Session scope — contributed into every + * Session scope by `SwarmFeature` (`features/swarm/swarmFeature`). */ import type { TokenUsage } from '#/kosong/contract/usage'; import { IModelCatalog } from '#/kosong/model/catalog'; -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'; @@ -298,11 +297,3 @@ export class SessionSwarmService implements ISessionSwarmService { } export type _AgentRunUsage = TokenUsage; - -registerScopedService( - LifecycleScope.Session, - ISessionSwarmService, - SessionSwarmService, - ScopeActivation.OnScopeCreated, - 'sessionSwarm', -); diff --git a/packages/agent-core-v2/src/features/swarm/swarmFeature.ts b/packages/agent-core-v2/src/features/swarm/swarmFeature.ts new file mode 100644 index 0000000000..3d28b6dad7 --- /dev/null +++ b/packages/agent-core-v2/src/features/swarm/swarmFeature.ts @@ -0,0 +1,44 @@ +/** + * `swarm` domain — `SwarmFeature`: the agent-swarm capability assembled as + * one App-scope Feature unit. + * + * Contributes the per-Agent `IAgentSwarmService` (swarm mode), the + * per-Session `ISessionSwarmService` (batch scheduler), and the `AgentSwarm` + * agent tool through the `features` base-class seams; retracting the unit + * withdraws all of them across the scope tree. Both services keep + * `OnScopeCreated` activation — `AgentSwarmService` subscribes the + * `turn.ended` auto-exit and the AgentSwarm batch-exclusivity veto in its + * constructor. The `swarm` wire vocabulary (`features/swarm/swarmOps`) stays + * on its static import=register channel — wire records must remain + * replayable even when the feature unit is retracted. Registered into the + * feature table at import. + */ + +import { ScopeActivation } from '#/_base/di/instantiation'; +import { LifecycleScope } from '#/app/scopes'; +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import { IAgentSwarmService } from './agent/swarm'; +import { AgentSwarmService } from './agent/swarmService'; +import { ISessionSwarmService } from './session/sessionSwarm'; +import { SessionSwarmService } from './session/sessionSwarmService'; +import { IAgentSwarmTool } from './tools/agent-swarm/agent-swarm'; +import { AgentSwarmTool } from './tools/agent-swarm/agentSwarmTool'; + +export class SwarmFeature extends Feature { + static override readonly name = 'swarm'; + + constructor() { + super(); + this.contributeAgentService(IAgentSwarmService, AgentSwarmService, { + activation: ScopeActivation.OnScopeCreated, + }); + this.contributeService(LifecycleScope.Session, ISessionSwarmService, SessionSwarmService, { + activation: ScopeActivation.OnScopeCreated, + }); + this.contributeTool(IAgentSwarmTool, AgentSwarmTool, { name: 'AgentSwarm', domain: 'swarm' }); + } +} + +registerFeature(SwarmFeature); diff --git a/packages/agent-core-v2/src/agent/swarm/swarmOps.ts b/packages/agent-core-v2/src/features/swarm/swarmOps.ts similarity index 95% rename from packages/agent-core-v2/src/agent/swarm/swarmOps.ts rename to packages/agent-core-v2/src/features/swarm/swarmOps.ts index d222a57517..06ef2cec4e 100644 --- a/packages/agent-core-v2/src/agent/swarm/swarmOps.ts +++ b/packages/agent-core-v2/src/features/swarm/swarmOps.ts @@ -13,7 +13,7 @@ import { z } from 'zod'; import { defineModel } from '#/wire/model'; -import type { SwarmModeTrigger } from './swarm'; +import type { SwarmModeTrigger } from './agent/swarm'; export const SwarmModel = defineModel('swarm', () => null); diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.md b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agent-swarm.md similarity index 100% rename from packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.md rename to packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agent-swarm.md diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.ts b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agent-swarm.ts similarity index 91% rename from packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.ts rename to packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agent-swarm.ts index b936fa3bd7..8d8d911851 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.ts +++ b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agent-swarm.ts @@ -1,12 +1,11 @@ /** - * `tools` domain — `IAgentSwarmTool` contract (the `AgentSwarm` tool). + * `swarm` domain — `IAgentSwarmTool` contract (the `AgentSwarm` tool). * * Public contract of the `AgentSwarm` collaboration tool: the input zod * schema the model-facing parameters are derived from, the tool-owned * constants the schema is built around (prompt template placeholder, maximum - * subagent count), and the `IAgentSwarmTool` DI decorator that the - * implementation registers against via `registerAgentToolService`. Bound at - * Agent scope. + * subagent count), and the `IAgentSwarmTool` DI decorator used to resolve the + * implementation through the container. Bound at Agent scope. */ import { z } from 'zod'; diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts similarity index 95% rename from packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts rename to packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts index a7999260e5..5c70dd474b 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts @@ -1,5 +1,5 @@ /** - * `tools` domain — `AgentSwarmTool` implementation (the `AgentSwarm` + * `swarm` domain — `AgentSwarmTool` implementation (the `AgentSwarm` * tool). * * Launches a batch of child agents (an ordinary Agent scope each) through the @@ -18,11 +18,8 @@ * (or under `[secondary_model].force`) the parameter is not advertised at * all. Swarm mode is * entered through `IAgentSwarmService`; the caller's agent id comes from - * `IAgentScopeContext`. Pure tool — owns no scoped state. - * - * Registered via the module-level `registerAgentToolService(IAgentSwarmTool, - * AgentSwarmTool)` at the bottom of this file — the same "import = register" - * pattern used by every agent tool. Bound at Agent scope. + * `IAgentScopeContext`. Pure tool — owns no scoped state. Bound at Agent + * scope — contributed by `SwarmFeature` (`features/swarm/swarmFeature`). */ import { @@ -32,11 +29,10 @@ import { type ToolExecution, } from '#/tool/toolContract'; import { Error2, ErrorCodes } from '#/errors'; -import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { toInputJsonSchema } from '#/tool/input-schema'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; -import { ISessionSwarmService, type SessionSwarmTask } from '#/session/swarm/sessionSwarm'; +import { ISessionSwarmService, type SessionSwarmTask } from '#/features/swarm/session/sessionSwarm'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { IAgentProfileService } from '#/agent/profile/profile'; import { @@ -44,7 +40,7 @@ import { subagentTypeNotAllowedMessage, } from '#/app/agentProfileCatalog/profile-shared'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; import { buildSubagentModelDescriptions, exposesSubagentModelChoice, @@ -235,8 +231,6 @@ export class AgentSwarmTool implements IAgentSwarmTool { } } -registerAgentToolService(IAgentSwarmTool, AgentSwarmTool, { name: 'AgentSwarm', domain: 'swarm' }); - async function createAgentSwarmSpecs( args: AgentSwarmToolInput, getResumeItem: (agentId: string) => Promise, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 8c2399d921..33e343ba40 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -299,6 +299,13 @@ import '#/features/plan/planFeature'; export * from '#/features/debugEvents/debugEvents'; export * from '#/features/debugEvents/debugEventsService'; import '#/features/debugEvents/debugEventsFeature'; +export * from '#/features/swarm/agent/swarm'; +export * from '#/features/swarm/agent/swarmService'; +export * from '#/features/swarm/session/sessionSwarm'; +export * from '#/features/swarm/session/sessionSwarmService'; +export * from '#/features/swarm/tools/agent-swarm/agent-swarm'; +import '#/features/swarm/tools/agent-swarm/agentSwarmTool'; +import '#/features/swarm/swarmFeature'; 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'; @@ -312,10 +319,6 @@ import '#/agent/goal/goalDeadlineSchedulerService'; export * from '#/agent/goal/goal'; export * from '#/agent/goal/goalService'; export * from '#/agent/goal/types'; -export * from '#/agent/tools/agent-swarm/agent-swarm'; -import '#/agent/tools/agent-swarm/agentSwarmTool'; -export * from '#/agent/swarm/swarm'; -export * from '#/agent/swarm/swarmService'; export * from '#/agent/usage/usage'; export * from '#/agent/usage/usageService'; export * from '#/agent/toolDedupe/toolDedupe'; @@ -635,8 +638,6 @@ export * from '#/agent/stepRetry/stepRetryService'; export * from '#/session/sessionInit/sessionInit'; export * from '#/session/sessionInit/sessionInitService'; export * from '#/session/sessionInit/profile/init'; -export * from '#/session/swarm/sessionSwarm'; -export * from '#/session/swarm/sessionSwarmService'; export * from '#/session/todo/todoItem'; export * from '#/session/todo/todoListReminder'; export * from '#/session/todo/sessionTodo'; 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 722284b243..b7ca8d0ec5 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -27,7 +27,7 @@ import { } from '#/agent/loop/loop'; import { MessageStepRequest } from '#/agent/loop/stepRequest'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { PermissionMode, PermissionPolicyResult } from '#/agent/permissionPolicy/types'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; 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 068d38727f..f88ad343f8 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 @@ -6,7 +6,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import { IAgentGoalService } from '#/agent/goal/goal'; import { type AgentGoalService } from '#/agent/goal/goalService'; import { IAgentProfileService } from '#/agent/profile/profile'; -import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; import { InMemoryWireRecordPersistence, agentService, diff --git a/packages/agent-core-v2/test/agent/goal/stubs.ts b/packages/agent-core-v2/test/agent/goal/stubs.ts index 4dd1e67808..b4f463805f 100644 --- a/packages/agent-core-v2/test/agent/goal/stubs.ts +++ b/packages/agent-core-v2/test/agent/goal/stubs.ts @@ -2,7 +2,7 @@ * Shared stubs for goal tests. */ -import type { IAgentSwarmService } from '#/agent/swarm/swarm'; +import type { IAgentSwarmService } from '#/features/swarm/agent/swarm'; export function stubAgentSwarm(): IAgentSwarmService { return { diff --git a/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts b/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts index 07f36943f6..d25250996b 100644 --- a/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts +++ b/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts @@ -21,7 +21,7 @@ import { UpdateGoalToolInputSchema } from '#/agent/tools/goal/update-goal/update import { UpdateGoalTool } from '#/agent/tools/goal/update-goal/updateGoalTool'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; import { IAgentToolExecutorService, type ToolExecutionResult, 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 932b978ab9..d410dcb731 100644 --- a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts +++ b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts @@ -33,7 +33,6 @@ 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'; @@ -412,7 +411,7 @@ describe('AgentToolActivationService', () => { }); it('feeds every built-in contribution through the App-scope assembly unchanged', async () => { - expect(savedContributions).toHaveLength(21); + expect(savedContributions).toHaveLength(20); for (const contribution of savedContributions) { registerAgentToolService(contribution.id, contribution.ctor, contribution.options); } 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 5076079144..8acd18434a 100644 --- a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts +++ b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts @@ -19,7 +19,7 @@ import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting' import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentPlanService } from '#/features/plan/plan'; import { IAgentProfileService } from '#/agent/profile/profile'; -import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability'; import { IModelCatalog } from '#/kosong/model/catalog'; import { IModelService } from '#/kosong/model/model'; diff --git a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts similarity index 99% rename from packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts rename to packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts index 1c60d85854..407f3203cf 100644 --- a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts @@ -48,11 +48,11 @@ import { type AgentRunSuspendedEvent, type AgentSpawnAttemptOptions, type QueuedAgentRunTask, -} from '#/session/swarm/agentRunBatch'; -import { ISessionSwarmService, type SessionSwarmSpawnTask, type SessionSwarmTask } from '#/session/swarm/sessionSwarm'; +} from '#/features/swarm/session/agentRunBatch'; +import { ISessionSwarmService, type SessionSwarmSpawnTask, type SessionSwarmTask } from '#/features/swarm/session/sessionSwarm'; import { Error2 } from '#/_base/errors/errors'; import { ConfigErrors } from '#/app/config/errors'; -import { SessionSwarmService } from '#/session/swarm/sessionSwarmService'; +import { SessionSwarmService } from '#/features/swarm/session/sessionSwarmService'; import { stubLog } from '../../_base/log/stubs'; import { stubFlag } from '../../app/flag/stubs'; diff --git a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts b/packages/agent-core-v2/test/features/swarm/swarm.test.ts similarity index 98% rename from packages/agent-core-v2/test/agent/swarm/swarm.test.ts rename to packages/agent-core-v2/test/features/swarm/swarm.test.ts index 6e9fb08114..a5274192cc 100644 --- a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/features/swarm/swarm.test.ts @@ -5,7 +5,7 @@ * Exercises the Agent-scoped service through DI and public loop boundaries, * with storage, session swarm execution, and approvals stubbed. Run: * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run - * test/agent/swarm/swarm.test.ts`. + * test/features/swarm/swarm.test.ts`. */ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; @@ -24,7 +24,7 @@ import { AgentContextMemoryService } from '#/agent/contextMemory/contextMemorySe import type { ContextMessage } from '#/agent/contextMemory/types'; import { DEFAULT_SUBAGENT_TIMEOUT_MS } from '#/session/subagent/configSection'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; -import { ISessionSwarmService, type SessionSwarmRunResult, type SessionSwarmTask } from '#/session/swarm/sessionSwarm'; +import { ISessionSwarmService, type SessionSwarmRunResult, type SessionSwarmTask } from '#/features/swarm/session/sessionSwarm'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; @@ -33,12 +33,12 @@ import { wrapSystemReminder, } from '#/agent/systemReminder/systemReminder'; import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; -import { IAgentSwarmService } from '#/agent/swarm/swarm'; -import { AgentSwarmService } from '#/agent/swarm/swarmService'; -import SWARM_MODE_ENTER_REMINDER from '../../../src/agent/swarm/enter-reminder.md?raw'; -import { SwarmModel } from '#/agent/swarm/swarmOps'; -import { AgentSwarmToolInputSchema } from '#/agent/tools/agent-swarm/agent-swarm'; -import { AgentSwarmTool } from '#/agent/tools/agent-swarm/agentSwarmTool'; +import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; +import { AgentSwarmService } from '#/features/swarm/agent/swarmService'; +import SWARM_MODE_ENTER_REMINDER from '../../../src/features/swarm/agent/enter-reminder.md?raw'; +import { SwarmModel } from '#/features/swarm/swarmOps'; +import { AgentSwarmToolInputSchema } from '#/features/swarm/tools/agent-swarm/agent-swarm'; +import { AgentSwarmTool } from '#/features/swarm/tools/agent-swarm/agentSwarmTool'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { @@ -65,8 +65,8 @@ import { EventBusService } from '#/app/event/eventBusService'; import { executeTool } from '../../tools/fixtures/execute-tool'; import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs'; -import { stubLoopWithHooks } from '../loop/stubs'; -import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../toolExecutor/stubs'; +import { stubLoopWithHooks } from '../../agent/loop/stubs'; +import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../../agent/toolExecutor/stubs'; import { createTestAgent } from '../../harness'; const signal = new AbortController().signal; diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 03edcf84d1..4de0ba782e 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -6,7 +6,9 @@ import { createControlledPromise } from '@antfu/utils'; import { expect, vi } from 'vitest'; import { toDisposable } from '#/_base/di/lifecycle'; +import type { IInstantiationService } from '#/_base/di/instantiation'; import type { IAgentScopeHandle } from '#/_base/di/scope'; +import { getContributedServices } from '#/features/featureRegistry'; import { Emitter, Event } from '#/_base/event'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import type { Promisable, PromisifyMethods } from '#/_base/utils/types'; @@ -52,7 +54,7 @@ import { IAgentConversationUndoService } from '#/agent/undo/undo'; import { IAgentLoopService } from '#/agent/loop/loop'; import type { RunShellCommandInput, RunShellCommandResult } from '#/agent/shellCommand/shellCommand'; import type { ProfileSetModelResult } from '#/agent/profile/profile'; -import type { SwarmModeTrigger } from '#/agent/swarm/swarm'; +import type { SwarmModeTrigger } from '#/features/swarm/agent/swarm'; import type { UserToolRegistration } from '#/agent/userTool/userTool'; import type { ActivatePluginCommandPayload } from '#/agent/pluginCommand/pluginCommand'; import { IAgentPluginCommandService } from '#/agent/pluginCommand/pluginCommand'; @@ -207,7 +209,7 @@ import { import type { IProcess } from '#/session/process/processRunner'; import { ISessionQuestionService, type QuestionResult } from '#/session/question/question'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; -import { ISessionSwarmService } from '#/session/swarm/sessionSwarm'; +import { ISessionSwarmService } from '#/features/swarm/session/sessionSwarm'; import type { PathAccessOperation } from '#/session/workspaceContext/workspaceContext'; import { stubAgentIdentity } from '../app/agentIdentity/stubs'; @@ -897,6 +899,45 @@ function collectScopeSeed( return seed; } +// Feature contributions (`ScopeUnits` fold) provide into a scope through the +// cascade and would replace a same-token seed instance installed at creation; +// re-asserting overrides of feature-contributed tokens through the live +// container right after scope creation keeps test stubs winning, as they did +// over static registrations (which `provideScopeServices` skips when seeded). +function reassertServiceOverrides( + overrides: readonly TestAgentScopedServiceOverride[], + scope: TestAgentServiceScope, + instantiation: IInstantiationService, +): void { + const contributed = new Set( + getContributedServices() + .filter((entry) => entry.scope === scope) + .map((entry) => entry.id), + ); + if (contributed.size === 0) { + return; + } + const reg: TestAgentServiceRegistration = { + define: (id, ctor) => { + if (contributed.has(id)) instantiation.provide(id, new SyncDescriptor(ctor)); + }, + defineDescriptor: (id, descriptor) => { + if (contributed.has(id)) instantiation.provide(id, descriptor); + }, + defineInstance: (id, instance) => { + if (contributed.has(id)) instantiation.provide(id, instance); + }, + definePartialInstance: (id, instance) => { + if (contributed.has(id)) instantiation.provide(id, instance as never); + }, + }; + for (const override of overrides) { + if (override.scope === scope) { + override.register(reg); + } + } +} + class PersistenceAppendLogStore implements IAppendLogStore { declare readonly _serviceBrand: undefined; private readonly history: WireRecord[] = []; @@ -1236,6 +1277,7 @@ export class AgentTestContext { 'session', ), }); + reassertServiceOverrides(this.serviceOverrides, 'session', this.session.instantiation); const workspace = this.session.accessor.get(ISessionWorkspaceContext); this.agent = this.session.createChild(LifecycleScope.Agent, agentId, { @@ -1289,6 +1331,7 @@ export class AgentTestContext { 'agent', ), }); + reassertServiceOverrides(this.serviceOverrides, 'agent', this.agent.instantiation); this.initializeRestorableServices(); this.get(IAgentActivityView); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 930b13e96a..f33cc0d66c 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -28,7 +28,7 @@ import { IAgentUserToolService, type UserToolRegistration } from '#/agent/userTo import { AgentSwarmToolInputSchema, type AgentSwarmToolInput, -} from '#/agent/tools/agent-swarm/agent-swarm'; +} from '#/features/swarm/tools/agent-swarm/agent-swarm'; import { SubagentToolInputSchema, type SubagentToolInput, @@ -58,7 +58,7 @@ import type { ISessionSwarmService, SessionSwarmRunArgs, SessionSwarmRunResult, -} from '#/session/swarm/sessionSwarm'; +} from '#/features/swarm/session/sessionSwarm'; import type { IProcess, ISessionProcessRunner } from '#/session/process/processRunner'; import { IWireService } from '#/wire/wire'; import { createFakeProcessRunner } from '../tools/fixtures/fake-exec'; diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 5080dff69b..35ddf7efef 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -85,7 +85,7 @@ import type { SubagentSpawnedEvent, SubagentStartedEvent, } from '@moonshot-ai/agent-core-v2/session/subagent/mirrorAgentRun'; -import type { SubagentSuspendedEvent } from '@moonshot-ai/agent-core-v2/session/swarm/sessionSwarmService'; +import type { SubagentSuspendedEvent } from '@moonshot-ai/agent-core-v2/features/swarm/session/sessionSwarmService'; import type { ToolUpdate } from '@moonshot-ai/agent-core-v2/tool/toolContract'; import { ToolInputDisplaySchema } from './display'; diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index 9da48be894..e2c2ae71c9 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -58,7 +58,7 @@ * `DomainEventMap` augmentations in `packages/agent-core-v2/src`, e.g. * `agent/loop/loopService.ts`, `agent/toolExecutor/toolExecutorService.ts`, * `agent/task/taskOps.ts`, `agent/shellCommand/shellCommandService.ts`, - * `session/agentLifecycle/mirrorAgentRun.ts`, `session/swarm/sessionSwarmService.ts`, + * `session/agentLifecycle/mirrorAgentRun.ts`, `features/swarm/session/sessionSwarmService.ts`, * `agent/goal/goalOps.ts`, `agent/usage/usageOps.ts`, `agent/skill/skillOps.ts`, * `agent/pluginCommand/pluginCommandService.ts`, `session/cron/cronOps.ts`, * `agent/fullCompaction/compactionOps.ts`, `agent/mcp/mcpService.ts`, From 17401c1df49277fe0fab165b614f91e52be99052 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 10:39:57 +0800 Subject: [PATCH 2/3] fix(agent-core-v2): keep feature-contributed tools in Agent tool descriptions SubagentTool.knownToolReferences() now reads the full AgentToolContribution collection (static registrations and feature contributions alike) instead of the static contribution table. A caller profile that does not activate a feature-contributed tool (e.g. AgentSwarm) no longer drops it from the per-profile tool listings the description advertises for spawned profiles when a workspace/session restriction forces explicit enumeration. Add a regression test with a caller profile lacking AgentSwarm under a global tool restriction. --- .../src/agent/tools/agent/agentTool.ts | 13 ++++--- packages/agent-core-v2/test/tool/tool.test.ts | 34 ++++++++++++++++++- 2 files changed, 41 insertions(+), 6 deletions(-) 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 6abcde9f8b..7401a36283 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -26,9 +26,10 @@ * Registered via the module-level `registerAgentToolService(ISubagentTool, * SubagentTool)` at the bottom of this file — the same "import = register" * pattern used by every agent tool. The per-profile tool listings in the - * description read the full contribution table (not the runtime registry, - * which only holds tools the caller's own Profile activated), plus any - * dynamically registered tools. The description's catalog profile list is + * description read the full `AgentToolContribution` collection — static + * registrations and feature-contributed tools alike — not the runtime + * registry, which only holds tools the caller's own Profile activated, + * plus any dynamically registered tools. The description's catalog profile list is * snapshotted once the session catalog has loaded and frozen for the agent's * lifetime: plugin install / enable / disable / remove re-contributes * profiles mid-session, and a live read would rewrite the tools payload of @@ -37,6 +38,7 @@ * Bound at Agent scope. */ +import { type CollectionView } from '#/_base/di/collection'; import type { IAgentScopeHandle } from '#/_base/di/scope'; import { isAbortError, @@ -67,7 +69,7 @@ import { type ToolExecution, } from '#/tool/toolContract'; import { - getAgentToolContributions, + AgentToolContribution, registerAgentToolService, } from '#/agent/toolRegistry/toolContribution'; import { IAgentToolRegistryService, type ToolReference } from '#/agent/toolRegistry/toolRegistry'; @@ -151,6 +153,7 @@ export class SubagentTool implements ISubagentTool { @IConfigService private readonly config: IConfigService, @IFlagService private readonly flags: IFlagService, @IModelCatalog private readonly modelCatalog: IModelCatalog, + @AgentToolContribution private readonly contributions: CollectionView, ) { this.callerAgentId = scopeContext.agentId; this.canRunInBackground = () => @@ -204,7 +207,7 @@ export class SubagentTool implements ISubagentTool { private knownToolReferences(): ToolReference[] { const refs = new Map(); - for (const contribution of getAgentToolContributions()) { + for (const contribution of this.contributions.items) { refs.set(contribution.options.name, { name: contribution.options.name, source: contribution.options.source ?? 'builtin', diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index f33cc0d66c..8424196090 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -19,7 +19,7 @@ import { IAgentTaskService } from '#/agent/task/task'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; import { makeHookRunner } from '../agent/externalHooks/runner-stub'; -import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentProfileService, type ProfileData } from '#/agent/profile/profile'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { ToolAccesses, type ExecutableTool } from '#/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; @@ -65,6 +65,7 @@ import { createFakeProcessRunner } from '../tools/fixtures/fake-exec'; import { StubConfigService } from '../kosong/stubs'; import { stubFlag } from '../app/flag/stubs'; import { + agentService, appService, configServices, createCommandRunner, @@ -552,6 +553,37 @@ describe('Agent tool description', () => { expect(coderTools).not.toContain('Bash'); }); + it('lists contributed tools the caller profile does not activate', () => { + // The caller binds a profile without AgentSwarm, so the runtime registry + // never holds it; a global restriction forces the explicit enumeration + // branch. The listing must still draw from the full contribution + // collection — spawned `agent` profiles can use AgentSwarm. + const callerData = { + profileName: 'orchestrator', + activeToolNames: ['Agent', 'Bash', 'Read'], + disallowedTools: [], + } as unknown as ProfileData; + ctx = createTestAgent( + { autoConfigure: false }, + agentService(IAgentProfileService, { + _serviceBrand: undefined, + data: () => callerData, + onDidChange: Event.None, + } as unknown as IAgentProfileService), + configServices(() => ({ + providers: {}, + tools: { disabled: ['Write'] }, + })), + ); + + const description = agentDescription(); + const agentTools = description.match(/- agent: [^\n]*\n Tools: ([^\n]*)/)?.[1]; + + expect(agentTools).toBeDefined(); + expect(agentTools).toContain('AgentSwarm'); + expect(agentTools).not.toContain('Write'); + }); + it('renders effective tools after applying disallowedTools', () => { const restricted: AgentProfile = normalizeAgentProfile({ name: 'restricted', From e74248b7280bf9c240265fd2e8f60dcf82232635 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 11:00:38 +0800 Subject: [PATCH 3/3] refactor(kap-server): lift session profile updates to the route edge - add sessionProfile.ts/sessionAgentConfig.ts route helpers that resume the session and dispatch title/metadata and the agent_config patch to the native v2 services directly - drop updateProfile from ISessionLegacyService, leaving only the status rollup and the goal read in the legacy adapter - wire shape and client-visible behavior unchanged --- .../src/app/sessionLegacy/sessionLegacy.ts | 27 ++--- .../app/sessionLegacy/sessionLegacyService.ts | 108 +----------------- .../app/sessionLegacy/sessionLegacy.test.ts | 47 +------- .../src/routes/sessionAgentConfig.ts | 84 ++++++++++++++ .../kap-server/src/routes/sessionProfile.ts | 58 ++++++++++ packages/kap-server/src/routes/sessions.ts | 24 ++-- 6 files changed, 180 insertions(+), 168 deletions(-) create mode 100644 packages/kap-server/src/routes/sessionAgentConfig.ts create mode 100644 packages/kap-server/src/routes/sessionProfile.ts diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts index ecf83bfe2e..9b5644763d 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts @@ -1,23 +1,25 @@ /** - * `sessionLegacy` domain (L7 edge adapter) — v1-compatible session actions. + * `sessionLegacy` domain (L7 edge adapter) — v1-compatible session reads. * - * Implements `POST /sessions/{id}/profile` (`updateProfile` — title rename, - * metadata merge, and the cross-domain `agent_config` patch), - * `GET /sessions/{id}/status` (`status`), and `GET /sessions/{id}/goal` - * (`goal`). The thin pass-through actions (`fork` / `compact` / `abort` / - * `archive`), the `:undo` action, and the `/sessions/{id}/children` endpoints - * are deliberately NOT wrapped here because none of them carries v1-only - * projection worth centralizing; only `updateProfile`, `status`, and `goal` - * stay in this adapter (the `agent_config` patch, the best-effort status - * rollup, and the current-goal read). Bound at App scope — it is a stateless - * dispatcher that resolves the target session/agent per call. + * Implements `GET /sessions/{id}/status` (`status` — the best-effort status + * rollup) and `GET /sessions/{id}/goal` (`goal` — the current-goal read), the + * two endpoints that hold real cross-domain adaptation. Everything else is + * deliberately NOT wrapped here: the thin pass-through actions (`fork` / + * `compact` / `abort` / `archive`), the `:undo` action, the + * `/sessions/{id}/children` endpoints, and `POST /sessions/{id}/profile` + * (title/metadata patch and `agent_config` dispatch) are plain wire-to-native + * translations composed by the kap-server routes directly. `SessionWireFields` + * stays exported here as the profile route's projection shape, consumed by the + * kap-server helper (`routes/sessionProfile.ts`) via deep-path import. Bound + * at App scope — it is a stateless dispatcher that resolves the target + * session/agent per call. */ import type { GoalSnapshot } from '#/agent/goal/types'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { SessionStatusResponse, UpdateSessionProfileRequest } from './sessionProtocol'; +import type { SessionStatusResponse } from './sessionProtocol'; export interface SessionWireFields { readonly id: string; @@ -35,7 +37,6 @@ export interface SessionWireFields { export interface ISessionLegacyService { readonly _serviceBrand: undefined; - updateProfile(sessionId: string, body: UpdateSessionProfileRequest): Promise; status(sessionId: string): Promise; goal(sessionId: string): Promise; } diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index 21a3409536..2f08f42bdc 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -3,14 +3,15 @@ * * Stateless App-scope dispatcher: each method resolves the target session (and * its main agent) per call, delegates to the native v2 services, and projects - * the result into the v1 wire shape. Only `updateProfile` (the cross-domain - * `agent_config` patch), `status` (the best-effort status rollup), and `goal` - * (the current-goal read) live here. No business logic is duplicated here. + * the result into the v1 wire shape. Only `status` (the best-effort status + * rollup) and `goal` (the current-goal read) live here — the profile route's + * title/metadata patch and `agent_config` dispatch are composed by kap-server. + * No business logic is duplicated here. */ import type { GoalSnapshot } from '#/agent/goal/types'; -import type { SessionStatusResponse, UpdateSessionProfileRequest } from './sessionProtocol'; +import type { SessionStatusResponse } from './sessionProtocol'; import { LifecycleScope } from '#/app/scopes'; import { type IAgentScopeHandle, @@ -25,7 +26,6 @@ import { 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 '#/features/plan/plan'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; @@ -39,10 +39,8 @@ import { ErrorCodes, Error2 } from '#/errors'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IAgentActivityView } from '#/agent/activityView/activityView'; -import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import { ISessionLegacyService, type SessionWireFields } from './sessionLegacy'; +import { ISessionLegacyService } from './sessionLegacy'; export class SessionLegacyService implements ISessionLegacyService { declare readonly _serviceBrand: undefined; @@ -59,100 +57,6 @@ export class SessionLegacyService implements ISessionLegacyService { return resumeSessionById(this.services, sessionId); } - async updateProfile( - sessionId: string, - body: UpdateSessionProfileRequest, - ): Promise { - const session = await this.resume(sessionId); - if (session === undefined) { - throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); - } - const metadata = session.accessor.get(ISessionMetadata); - - if (typeof body.title === 'string') { - await metadata.setTitle(body.title); - } - - const metadataPatch = body.metadata; - if (metadataPatch !== undefined && Object.keys(metadataPatch).length > 0) { - await metadata.update({ custom: { ...(metadataPatch as Record) } }); - } - - const agentConfig = body.agent_config; - if (agentConfig !== undefined) { - const agent = await this.resolveMainAgent(sessionId); - await this.applyAgentConfig(agent, agentConfig); - } - - const meta = await metadata.read(); - const ctx = session.accessor.get(ISessionContext); - return { - id: meta.id, - workspaceId: ctx.workspaceId, - root: ctx.cwd, - title: meta.title, - lastPrompt: meta.lastPrompt, - createdAt: meta.createdAt, - updatedAt: meta.updatedAt, - archived: meta.archived, - archivedAt: meta.archivedAt, - custom: meta.custom, - }; - } - - - private async applyAgentConfig( - agent: IAgentScopeHandle, - agentConfig: NonNullable, - ): Promise { - const profile = agent.accessor.get(IAgentProfileService); - if (agentConfig.model !== undefined && agentConfig.model !== '') { - await profile.setModel(agentConfig.model); - } - if (agentConfig.thinking !== undefined) { - profile.setThinking(agentConfig.thinking); - } - if (agentConfig.permission_mode !== undefined) { - agent.accessor - .get(IAgentLifecycleService) - .broadcastPermissionMode(agentConfig.permission_mode as PermissionMode); - } - if (agentConfig.plan_mode !== undefined) { - const plan = agent.accessor.get(IAgentPlanService); - const active = (await plan.status()) !== null; - if (active !== agentConfig.plan_mode) { - if (agentConfig.plan_mode) await plan.enter(); - else plan.exit(); - } - } - if (agentConfig.swarm_mode !== undefined) { - const swarm = agent.accessor.get(IAgentSwarmService); - if (swarm.isActive !== agentConfig.swarm_mode) { - if (agentConfig.swarm_mode) swarm.enter('manual'); - else swarm.exit(); - } - } - if (agentConfig.goal_objective !== undefined) { - await agent.accessor - .get(IAgentGoalService) - .createGoal({ objective: agentConfig.goal_objective }); - } - if (agentConfig.goal_control !== undefined) { - const goal = agent.accessor.get(IAgentGoalService); - switch (agentConfig.goal_control) { - case 'pause': - await goal.pauseGoal({}); - break; - case 'resume': - await goal.resumeGoal({ continueIfPaused: true, continueIfBlocked: true }); - break; - case 'cancel': - await goal.cancelGoal({}); - break; - } - } - } - private async resolveMainAgent(sessionId: string): Promise { const session = await this.resume(sessionId); if (session === undefined) { 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 8acd18434a..3dc96892a9 100644 --- a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts +++ b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts @@ -7,7 +7,7 @@ * current model catalog. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; @@ -30,9 +30,7 @@ import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLi import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IAgentActivityView } from '#/agent/activityView/activityView'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; -import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionCronService } from '#/session/cron/sessionCronService'; -import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; function accessor( entries: ReadonlyArray, unknown]>, @@ -365,47 +363,4 @@ describe('Session legacy status (best-effort runtime state)', () => { context_usage: 1, }); }); - - it('fans a permission_mode patch out through the session agent registry', async () => { - const broadcastPermissionMode = vi.fn(); - const agent: IAgentScopeHandle = { - id: 'main', - kind: LifecycleScope.Agent, - accessor: accessor([ - [IAgentProfileService, { _serviceBrand: undefined }], - [IAgentLifecycleService, { broadcastPermissionMode }], - ]), - dispose: () => {}, - }; - const agents = { - create: () => Promise.resolve(agent), - whenReady: () => Promise.resolve(agent), - list: () => [agent], - broadcastPermissionMode, - } as unknown as IAgentLifecycleService; - const session: ISessionScopeHandle = { - id: 'session-test', - kind: LifecycleScope.Session, - accessor: accessor([ - [IAgentLifecycleService, agents], - [ - ISessionMetadata, - { - read: () => - Promise.resolve({ id: 'session-test', createdAt: 0, updatedAt: 0, archived: false }), - }, - ], - [ISessionContext, { workspaceId: 'ws-test', cwd: '/workspace' }], - ]), - dispose: () => {}, - }; - stubSessionChain(ix, session); - ix.set(ISessionLegacyService, new SyncDescriptor(SessionLegacyService)); - - await ix.get(ISessionLegacyService).updateProfile('session-test', { - agent_config: { permission_mode: 'yolo' }, - }); - - expect(broadcastPermissionMode).toHaveBeenCalledWith('yolo'); - }); }); diff --git a/packages/kap-server/src/routes/sessionAgentConfig.ts b/packages/kap-server/src/routes/sessionAgentConfig.ts new file mode 100644 index 0000000000..5129bf7f98 --- /dev/null +++ b/packages/kap-server/src/routes/sessionAgentConfig.ts @@ -0,0 +1,84 @@ +/** + * `agent_config` patch dispatch for `POST /sessions/{session_id}/profile`. + * + * The `agent_config` body field is a wire-to-native translation, not a v1-only + * projection, so it lives at the server edge alongside the other routes that + * call the native v2 services directly (`fork` / `compact` / `undo` / `abort`) + * instead of inside `ISessionLegacyService`. The helper resumes the session + * (cold-load if needed), resolves its main agent, and fans each present field + * out to the owning Agent-scope service, preserving the per-field + * apply-only-when-set semantics and the plan/swarm idempotency guards. + */ + +import { + ErrorCodes, + Error2, + IAgentGoalService, + IAgentLifecycleService, + IAgentPlanService, + IAgentProfileService, + IAgentSwarmService, + resumeSessionById, + type PermissionMode, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import type { SessionAgentConfigPartial } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; + +import { ensureMainAgent } from '../transport/mainAgent'; + +export async function applySessionAgentConfig( + core: Scope, + sessionId: string, + agentConfig: SessionAgentConfigPartial, +): Promise { + const session = await resumeSessionById(core.accessor, sessionId); + if (session === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); + } + const agent = await ensureMainAgent(session); + + const profile = agent.accessor.get(IAgentProfileService); + if (agentConfig.model !== undefined && agentConfig.model !== '') { + await profile.setModel(agentConfig.model); + } + if (agentConfig.thinking !== undefined) { + profile.setThinking(agentConfig.thinking); + } + if (agentConfig.permission_mode !== undefined) { + agent.accessor + .get(IAgentLifecycleService) + .broadcastPermissionMode(agentConfig.permission_mode as PermissionMode); + } + if (agentConfig.plan_mode !== undefined) { + const plan = agent.accessor.get(IAgentPlanService); + const active = (await plan.status()) !== null; + if (active !== agentConfig.plan_mode) { + if (agentConfig.plan_mode) await plan.enter(); + else plan.exit(); + } + } + if (agentConfig.swarm_mode !== undefined) { + const swarm = agent.accessor.get(IAgentSwarmService); + if (swarm.isActive !== agentConfig.swarm_mode) { + if (agentConfig.swarm_mode) swarm.enter('manual'); + else swarm.exit(); + } + } + if (agentConfig.goal_objective !== undefined) { + await agent.accessor.get(IAgentGoalService).createGoal({ objective: agentConfig.goal_objective }); + } + if (agentConfig.goal_control !== undefined) { + const goal = agent.accessor.get(IAgentGoalService); + switch (agentConfig.goal_control) { + case 'pause': + await goal.pauseGoal({}); + break; + case 'resume': + await goal.resumeGoal({ continueIfPaused: true, continueIfBlocked: true }); + break; + case 'cancel': + await goal.cancelGoal({}); + break; + } + } +} diff --git a/packages/kap-server/src/routes/sessionProfile.ts b/packages/kap-server/src/routes/sessionProfile.ts new file mode 100644 index 0000000000..1fbc144a83 --- /dev/null +++ b/packages/kap-server/src/routes/sessionProfile.ts @@ -0,0 +1,58 @@ +/** + * title/metadata patch for `POST /sessions/{session_id}/profile`. + * + * Like the `agent_config` dispatch (`sessionAgentConfig.ts`), the + * title/metadata update is a wire-to-native translation with no v1-only + * projection, so it lives at the server edge instead of inside + * `ISessionLegacyService`. The helper resumes the session (cold-load if + * needed), applies the patch through `ISessionMetadata`, and reads the + * metadata document back together with `ISessionContext` to assemble the + * `SessionWireFields` shape the route feeds to `toWireSession`. + */ + +import { + ErrorCodes, + Error2, + ISessionContext, + ISessionMetadata, + resumeSessionById, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import type { SessionWireFields } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionLegacy'; +import type { UpdateSessionProfileRequest } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; + +export async function updateSessionProfile( + core: Scope, + sessionId: string, + body: Pick, +): Promise { + const session = await resumeSessionById(core.accessor, sessionId); + if (session === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); + } + const metadata = session.accessor.get(ISessionMetadata); + + if (typeof body.title === 'string') { + await metadata.setTitle(body.title); + } + + const metadataPatch = body.metadata; + if (metadataPatch !== undefined && Object.keys(metadataPatch).length > 0) { + await metadata.update({ custom: { ...(metadataPatch as Record) } }); + } + + const meta = await metadata.read(); + const ctx = session.accessor.get(ISessionContext); + return { + id: meta.id, + workspaceId: ctx.workspaceId, + root: ctx.cwd, + title: meta.title, + lastPrompt: meta.lastPrompt, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + archived: meta.archived, + archivedAt: meta.archivedAt, + custom: meta.custom, + }; +} diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index c20f913d3b..6611ee1d35 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -29,10 +29,12 @@ * `/sessions/{id}/children` endpoints call `ISessionLifecycleService.createChild` * and `ISessionIndex.list({ childOf })` directly — the child markers and * parent-title default live in the lifecycle, and the child filter lives in the - * index. Only `POST /sessions/{id}/profile` (`updateProfile`), - * `GET /sessions/{id}/status`, and `GET /sessions/{id}/goal` go through - * `ISessionLegacyService` (the `agent_config` patch, the status rollup, and the - * current-goal read hold real cross-domain adaptation); + * index. `POST /sessions/{id}/profile` is composed at the edge too: both the + * title/metadata patch (`sessionProfile.ts`) and the `agent_config` dispatch + * (`sessionAgentConfig.ts`) are wire-to-native translations over the native v2 + * services. Only `GET /sessions/{id}/status` and `GET /sessions/{id}/goal` go + * through `ISessionLegacyService` (the status rollup and the current-goal read + * hold real cross-domain adaptation); * the route forwards each adapter result verbatim, mirroring v1's thin handler. * `create`, `fork`, and child creation publish `event.session.created` on the * core event bus, matching v1. @@ -135,6 +137,8 @@ import { requestLog } from '../lib/requestLog'; import { defineRoute } from '../middleware/defineRoute'; import { ensureMainAgent } from '../transport/mainAgent'; import { parseActionSuffix } from './action-suffix'; +import { applySessionAgentConfig } from './sessionAgentConfig'; +import { updateSessionProfile } from './sessionProfile'; interface SessionRouteHost { post( @@ -611,9 +615,15 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void async (req, reply) => { try { const { session_id } = req.params; - const fields = await core.accessor - .get(ISessionLegacyService) - .updateProfile(session_id, req.body); + const { agent_config, ...profileBody } = req.body; + // Both halves of the profile patch are wire-to-native translations + // dispatched to the native v2 services at the edge (same direct-call + // pattern as fork/compact/undo); title/metadata applies first, + // matching the original in-adapter ordering. + const fields = await updateSessionProfile(core, session_id, profileBody); + if (agent_config !== undefined) { + await applySessionAgentConfig(core, session_id, agent_config); + } const session = toWireSession(fields, fields.root, resolveSessionFacts(core, fields.id)); // Broadcast the title change to every connection (including clients not // subscribed to this session, and covering inactive sessions), so session