refactor(agent-core-v2): extract swarm into a scope-organized feature (#2874)

* 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

* 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.

* 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
This commit is contained in:
Haozhe 2026-08-13 13:35:49 +08:00 committed by GitHub
parent 6e31722df1
commit 314b39489e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 362 additions and 252 deletions

View file

@ -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';

View file

@ -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)

View file

@ -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';

View file

@ -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<AgentToolContribution>,
) {
this.callerAgentId = scopeContext.agentId;
this.canRunInBackground = () =>
@ -204,7 +207,7 @@ export class SubagentTool implements ISubagentTool {
private knownToolReferences(): ToolReference[] {
const refs = new Map<string, ToolReference>();
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',

View file

@ -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<SessionWireFields>;
status(sessionId: string): Promise<SessionStatusResponse>;
goal(sessionId: string): Promise<GoalSnapshot | null>;
}

View file

@ -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,10 +26,9 @@ 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 '#/agent/swarm/swarm';
import { IAgentSwarmService } from '#/features/swarm/agent/swarm';
import {
getLiveSessionById,
resumeSessionById,
@ -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<SessionWireFields> {
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<string, unknown>) } });
}
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<UpdateSessionProfileRequest['agent_config']>,
): Promise<void> {
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<IAgentScopeHandle> {
const session = await this.resume(sessionId);
if (session === undefined) {

View file

@ -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.'

View file

@ -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',
);

View file

@ -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);

View file

@ -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<SwarmModeTrigger | null>('swarm', () => null);

View file

@ -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';

View file

@ -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<string | undefined>,

View file

@ -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';

View file

@ -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';

View file

@ -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,

View file

@ -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 {

View file

@ -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,

View file

@ -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);
}

View file

@ -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';
@ -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';
@ -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<readonly [ServiceIdentifier<unknown>, 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');
});
});

View file

@ -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';

View file

@ -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;

View file

@ -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);

View file

@ -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';
@ -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,13 +58,14 @@ 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';
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',

View file

@ -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';

View file

@ -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<void> {
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;
}
}
}

View file

@ -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<UpdateSessionProfileRequest, 'title' | 'metadata'>,
): Promise<SessionWireFields> {
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<string, unknown>) } });
}
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,
};
}

View file

@ -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

View file

@ -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`,