From eb978099bd9b88fc19ee9460bdabf3687a915a60 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 12 Aug 2026 18:25:08 +0800 Subject: [PATCH 1/4] refactor(agent-core-v2): remove the agent RPC aggregation layer - delete src/agent/rpc/ (AgentRPCService, IAgentRPCService, core-api, prompt-metadata, types) and sink each method's orchestration into its owning domain service - prompt: new submit/submitSteer composing disabledTools gating, MAIN-only session metadata, and engine-side {turn_id} settlement - skill: activate now returns PromptLaunchResult and writes session metadata internally (MAIN-only, unified across prompt/steer/skill/ pluginCommand); node-sdk and kap-server drop their edge-side writes - pluginCommand: new agent-scope domain owning command activation and the plugin_command.activated domain event - permissionMode/loop/fullCompaction: new setModeAndBroadcast / cancelFromUser / cancel; setMode and loop.cancel stay pure for internal callers - klient: agentRpcContract split into per-domain contracts; facade re-routes to domain channels with its public API unchanged - node-sdk, kap-server, kimi-inspect and the v2 test harness now call domain services directly; ctx.rpc keeps its name as a composed adapter - externally visible: the agentRPCService debug channel is gone and session metadata writes are now MAIN-agent-only (see changeset) --- .../skills/agent-core-dev/edge-exposure.md | 2 +- .agents/skills/agent-core-dev/server-align.md | 4 +- .../agent-core-dev/service-authoring.md | 2 +- apps/kimi-inspect/src/channel/channel.test.ts | 4 +- apps/kimi-inspect/src/channel/client.ts | 2 +- apps/kimi-inspect/src/components/ChatView.tsx | 12 +- apps/kimi-inspect/src/panels.ts | 14 - packages/agent-core-v2/AGENTS.md | 2 +- .../agent/fullCompaction/fullCompaction.ts | 1 + .../fullCompaction/fullCompactionService.ts | 11 + packages/agent-core-v2/src/agent/loop/loop.ts | 2 + .../src/agent/loop/loopService.ts | 11 + .../agent/permissionMode/permissionMode.ts | 1 + .../permissionMode/permissionModeService.ts | 33 +- .../src/agent/pluginCommand/pluginCommand.ts | 41 ++ .../pluginCommand/pluginCommandService.ts | 111 ++++++ .../agent-core-v2/src/agent/prompt/prompt.ts | 16 + .../src/agent/prompt/promptService.ts | 96 ++++- .../src/agent/replayBuilder/types.ts | 18 +- .../agent-core-v2/src/agent/rpc/core-api.ts | 357 ------------------ .../src/agent/rpc/prompt-metadata.ts | 84 ----- packages/agent-core-v2/src/agent/rpc/rpc.ts | 20 - .../agent-core-v2/src/agent/rpc/rpcService.ts | 281 -------------- packages/agent-core-v2/src/agent/rpc/types.ts | 11 - .../agent-core-v2/src/agent/skill/prompt.ts | 10 + .../agent-core-v2/src/agent/skill/skill.ts | 4 +- .../src/agent/skill/skillService.ts | 38 +- packages/agent-core-v2/src/index.ts | 11 +- .../session/sessionMetadata/promptMetadata.ts | 56 +++ .../agent-core-v2/test/agent/loop/stubs.ts | 1 + .../setModeAndBroadcast.test.ts} | 2 +- .../test/agent/permissionMode/stubs.ts | 1 + .../agent/pluginCommand/pluginCommand.test.ts | 120 ++++++ .../promptMetadataText.test.ts} | 38 +- .../test/agent/prompt/promptService.test.ts | 17 + .../test/agent/prompt/submit.test.ts | 101 +++++ .../test/agent/rpc/runShellCommand.test.ts | 35 -- .../test/agent/rpc/undoHistory.test.ts | 52 --- .../{rpc => skill}/activateSkill.test.ts | 15 +- .../test/agent/skill/skill.test.ts | 12 + .../toolSelect/toolSelectService.test.ts | 2 + .../test/app/gateway/gateway.test.ts | 2 + .../plan/tools/exit-plan-mode.test.ts | 1 + .../plan/tools/plan-tools-telemetry.test.ts | 1 + packages/agent-core-v2/test/harness/agent.ts | 122 ++++-- .../test/session/swarm/sessionSwarm.test.ts | 1 + .../kap-server/src/protocol/events-zod.ts | 2 +- packages/kap-server/src/routes/sessions.ts | 6 +- packages/kap-server/src/routes/skills.ts | 25 +- .../src/services/transcript/coreEventMap.ts | 2 +- packages/kap-server/test/rpc.test.ts | 96 +---- .../src/contract/agent/{rpc.ts => schemas.ts} | 41 +- .../klient/src/contract/agent/services.ts | 54 ++- packages/klient/src/contract/global/events.ts | 2 +- packages/klient/src/contract/index.ts | 16 +- packages/klient/src/core/facade/agent.ts | 59 ++- .../src/transports/memory/serviceRegistry.ts | 16 +- packages/klient/test/contract-parity.ts | 63 ++-- packages/klient/test/facade.test.ts | 64 +++- packages/node-sdk/src/sdk-rpc-client-v2.ts | 101 +++-- 60 files changed, 1108 insertions(+), 1217 deletions(-) create mode 100644 packages/agent-core-v2/src/agent/pluginCommand/pluginCommand.ts create mode 100644 packages/agent-core-v2/src/agent/pluginCommand/pluginCommandService.ts delete mode 100644 packages/agent-core-v2/src/agent/rpc/core-api.ts delete mode 100644 packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts delete mode 100644 packages/agent-core-v2/src/agent/rpc/rpc.ts delete mode 100644 packages/agent-core-v2/src/agent/rpc/rpcService.ts delete mode 100644 packages/agent-core-v2/src/agent/rpc/types.ts create mode 100644 packages/agent-core-v2/src/session/sessionMetadata/promptMetadata.ts rename packages/agent-core-v2/test/agent/{rpc/setPermission.test.ts => permissionMode/setModeAndBroadcast.test.ts} (97%) create mode 100644 packages/agent-core-v2/test/agent/pluginCommand/pluginCommand.test.ts rename packages/agent-core-v2/test/agent/{rpc/prompt-metadata.test.ts => prompt/promptMetadataText.test.ts} (58%) create mode 100644 packages/agent-core-v2/test/agent/prompt/submit.test.ts delete mode 100644 packages/agent-core-v2/test/agent/rpc/runShellCommand.test.ts delete mode 100644 packages/agent-core-v2/test/agent/rpc/undoHistory.test.ts rename packages/agent-core-v2/test/agent/{rpc => skill}/activateSkill.test.ts (75%) rename packages/klient/src/contract/agent/{rpc.ts => schemas.ts} (73%) diff --git a/.agents/skills/agent-core-dev/edge-exposure.md b/.agents/skills/agent-core-dev/edge-exposure.md index 5039201ac9..0c8e6652d3 100644 --- a/.agents/skills/agent-core-dev/edge-exposure.md +++ b/.agents/skills/agent-core-dev/edge-exposure.md @@ -45,7 +45,7 @@ A Service method is directly exposable iff **all** hold: 3. Errors are `KimiError` (coded). 4. It is a command/query, not a factory, stream, byte-store, or sink. -If any fail → wrap in a **facade** (a Service that takes ids, returns data, throws `KimiError`) and expose the facade. The repo already ships a wire-shaped facade in `rpc/core-api.ts` (`CoreAPI` / `SessionAPI` / `AgentAPI`) behind `IAgentRPCService` / `ISessionRPCService` — prefer building the HTTP edge on top of it rather than re-deriving a new one. +If any fail → add a wire-safe orchestration method to the owning domain Service (e.g. `IAgentPromptService.submit` settles `{turn_id}` instead of returning the live `PromptHandle`) or compose several domain Services at the edge — kap-server's `routes/prompts.ts` is the reference for edge-side composition. ## 3. Per-scope `resource:action` map diff --git a/.agents/skills/agent-core-dev/server-align.md b/.agents/skills/agent-core-dev/server-align.md index 6907a710a2..32a8948a4b 100644 --- a/.agents/skills/agent-core-dev/server-align.md +++ b/.agents/skills/agent-core-dev/server-align.md @@ -165,7 +165,7 @@ const route = defineRoute( app.post(route.path, route.options, route.handler); ``` -**For `/api/v2` (native):** add a `resource:action` entry to `actionMap` ([edge-exposure.md](edge-exposure.md) §3). If the method fails the direct-exposure rules (returns a handle / stream / bytes, takes a live object), wrap it in a wire-shaped facade first (`IAgentRPCService` / `ISessionRPCService`) and map to the facade — as `prompts:*` does via `IAgentRPCService`. +**For `/api/v2` (native):** add a `resource:action` entry to `actionMap` ([edge-exposure.md](edge-exposure.md) §3). If the method fails the direct-exposure rules (returns a handle / stream / bytes, takes a live object), add a wire-safe orchestration method to the owning domain Service first — as `prompts:submit` maps to `IAgentPromptService.submit`, which settles `{turn_id}` engine-side instead of returning the live `PromptHandle`. ### 5. Map errors @@ -218,7 +218,7 @@ This is the reference alignment (commits `feat(server-v2): port v1 /sessions/:si **The split.** -- `/api/v2` keeps the native shape — `prompts:submit` / `steer` / `undo` / `clear` / `cancel` map to `IAgentRPCService` (a wire facade over the v2 turn driver) in `actionMap`. The native `IAgentPromptService` is untouched. +- `/api/v2` keeps the native shape — `prompts:submit` / `steer` / `undo` / `clear` / `cancel` map to the domain Services (`IAgentPromptService.submit` / `submitSteer`, `IAgentConversationUndoService.undo`, `IAgentLoopService.cancelFromUser`) in `actionMap`. - `/api/v1` gets an `AgentPromptLegacyService` (`prompt/`, `LifecycleScope.Agent`) that re-implements the v1 scheduler — queue, `prompt_id`, steer/abort, auto-start-next — **on top of** the native `IAgentPromptService`. The `/api/v1` routes consume the LegacyService. **The schema.** Both surfaces import `promptSubmissionSchema` / `promptSubmitResultSchema` / `promptListResponseSchema` / `promptSteerRequestSchema` / `promptSteerResultSchema` / `promptAbortResponseSchema` from the shared v1 wire schemas (see `packages/kap-server/src/protocol`). The `/api/v1` and `/api/v2` routes are therefore compatible with released clients by construction; the LegacyService projects v2 turn results back into those protocol shapes. diff --git a/.agents/skills/agent-core-dev/service-authoring.md b/.agents/skills/agent-core-dev/service-authoring.md index 5484f48edc..6aef868243 100644 --- a/.agents/skills/agent-core-dev/service-authoring.md +++ b/.agents/skills/agent-core-dev/service-authoring.md @@ -51,7 +51,7 @@ File names derive from the interface / class names so that scope and role are vi | Shared-types file | `.types.ts` | `log.types.ts` | | Errors file | `.errors.ts` | `appendLogStore.errors.ts` | -Acronym-aware lowerCamelCase lowercases a leading acronym as a group: `ILLMRequester` → `llmRequester.ts`, `IWSGateway` → `wsGateway.ts`, `IOAuthToolkit` → `oauthToolkit.ts`, `IAgentRPCService` → `agentRpcService.ts`. +Acronym-aware lowerCamelCase lowercases a leading acronym as a group: `ILLMRequester` → `llmRequester.ts`, `IWSGateway` → `wsGateway.ts`, `IOAuthToolkit` → `oauthToolkit.ts`, `IMcpServerService` → `mcpServerService.ts`. Because the impl class always ends in `Service` and the interface file never does, the two files of one service never collide — even for `Store` / `Registry` / `Resolver` interfaces (`IAppendLogStore` → `appendLogStore.ts` + `appendLogStoreService.ts`). diff --git a/apps/kimi-inspect/src/channel/channel.test.ts b/apps/kimi-inspect/src/channel/channel.test.ts index 1064924fea..ff3d6bfad6 100644 --- a/apps/kimi-inspect/src/channel/channel.test.ts +++ b/apps/kimi-inspect/src/channel/channel.test.ts @@ -31,14 +31,14 @@ describe('ProxyChannel.call', () => { it('POSTs the command to the service base URL; no body and no header without args/token', async () => { const { calls, fetchImpl } = fakeFetch(ok({ id: 's1' })); const channel = new ProxyChannel({ - baseUrl: 'http://h:1/api/v1/debug/session/s%201/agent/main/agentRPCService', + baseUrl: 'http://h:1/api/v1/debug/session/s%201/agent/main/agentLoopService', fetch: fetchImpl, }); const result = await channel.call('getModel', []); expect(result).toEqual({ id: 's1' }); expect(calls).toHaveLength(1); expect(calls[0]!.url).toBe( - 'http://h:1/api/v1/debug/session/s%201/agent/main/agentRPCService/getModel', + 'http://h:1/api/v1/debug/session/s%201/agent/main/agentLoopService/getModel', ); expect(calls[0]!.init?.method).toBe('POST'); expect(calls[0]!.init?.body).toBeUndefined(); diff --git a/apps/kimi-inspect/src/channel/client.ts b/apps/kimi-inspect/src/channel/client.ts index f0149efb1a..a500fdc57b 100644 --- a/apps/kimi-inspect/src/channel/client.ts +++ b/apps/kimi-inspect/src/channel/client.ts @@ -8,7 +8,7 @@ * await client.core(ISessionIndex).listRecent({}); * await client.workspace('wd_1').service(ISessionLifecycleService).resume('s1'); * await client.session('s1').service(ISessionMetadata).read(); - * await client.session('s1').agent('main').service(IAgentRPCService).cancel({}); + * await client.session('s1').agent('main').service(IAgentLoopService).cancelFromUser(); * * The `agent-core-v2` service token is the whole key: its type parameter `T` * types the returned proxy, and its decorator id (`String(id)`) is the channel diff --git a/apps/kimi-inspect/src/components/ChatView.tsx b/apps/kimi-inspect/src/components/ChatView.tsx index 2ba937a2e3..25d50369b1 100644 --- a/apps/kimi-inspect/src/components/ChatView.tsx +++ b/apps/kimi-inspect/src/components/ChatView.tsx @@ -14,12 +14,14 @@ * a full REST refresh; nothing is resynced from the socket itself. * * Rendering is turn-granular (turn → step → frame) and typed entirely by the - * transcript data model. Prompts/cancels go through the `IAgentRPCService` + * transcript data model. Prompts/cancels go through the `IAgentPromptService` + * / `IAgentLoopService` channels * over the debug RPC surface (`/api/v1/debug`); the running indicator * derives from transcript state (`meta.activity` / running turns). */ -import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; +import { IAgentLoopService } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; +import { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/prompt/prompt'; import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; import { ISessionQuestionService, @@ -561,8 +563,8 @@ export function ChatView({ await klient .session(sessionId) .agent(agentId) - .service(IAgentRPCService) - .prompt({ input: [{ type: 'text', text }] }); + .service(IAgentPromptService) + .submit({ input: [{ type: 'text', text }] }); trail?.recordEvent('prompt', text, state); } catch (error) { setSendError(error); @@ -572,7 +574,7 @@ export function ChatView({ const cancel = async () => { if (sessionId === null) return; try { - await klient.session(sessionId).agent(agentId).service(IAgentRPCService).cancel({}); + await klient.session(sessionId).agent(agentId).service(IAgentLoopService).cancelFromUser(); trail?.recordEvent('cancel', undefined, state); } catch (error) { setSendError(error); diff --git a/apps/kimi-inspect/src/panels.ts b/apps/kimi-inspect/src/panels.ts index 51e66304a4..ae42e1afa0 100644 --- a/apps/kimi-inspect/src/panels.ts +++ b/apps/kimi-inspect/src/panels.ts @@ -23,7 +23,6 @@ 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 { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; import { IAgentSwarmService } from '@moonshot-ai/agent-core-v2/agent/swarm/swarm'; import { IAgentTaskService } from '@moonshot-ai/agent-core-v2/agent/task/task'; import { IAgentTokenCountingService } from '@moonshot-ai/agent-core-v2/agent/tokenCounting/tokenCounting'; @@ -269,17 +268,4 @@ export const AGENT_PANELS: readonly ServicePanelDef[] = [ { label: 'exit', run: (svc) => call(svc, 'exit') }, ], }, - { - id: String(IAgentRPCService), - label: 'AgentRPCService', - scope: 'agent', - actions: [ - { label: 'cancel turn', run: (svc) => call(svc, 'cancel', {}) }, - { - label: 'undoHistory', - input: 'Steps', - run: (svc, n) => call(svc, 'undoHistory', { count: Number(n) }), - }, - ], - }, ]; diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index d7509008ce..3f6fa0d3c8 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -17,7 +17,7 @@ The DI kernel (`src/_base/di/`) owns the unit layer on top of the scoped registr - `instantiation.ts` — the `@ref(IX)` decorator factory (`LiveRef`: `current` live read + `onDidChange` availability event; observation creates no binding and no graph edge) and `ScopeActivation`. - `src/app/feature/` — `IFeatureManager` (App scope): runtime unit assembly (`provideUnit` / `unprovideUnit` / `updateUnit`) and introspection (`units()` / `onDidChangeUnits`); managed units hang on the manager's own book. External package management stays with `IPluginService`. The `features` assembly (`src/features/featureAssemblyService.ts`) drains the module-level feature table through it. -The four contribution seams (token → fold): config sections — `ConfigSectionContribution` → `ConfigRegistry` fold (`src/app/config/`; module-level `registerConfigSection` stays the static built-in channel drained at construction, a withdrawn runtime record unregisters the section while user TOML values survive); agent tools — `AgentToolContribution` → `AgentToolActivationService` fold (built-in records provided once at App scope by `builtinToolAssemblyService`; `registerAgentToolService` stays the static channel: Agent-scope DI `OnDemand` registration + module table); agent profiles — `AgentProfileContribution` → `IAgentProfileRegistry` fold (see Scopes); wire vocabulary — `WireModelContribution` → `WireService` fold (a record bundles `models` / `ops` / `crossReducers` / `checkpointedModels`; the built-in layer is the module tables drained at fold time — `defineOp` / `defineModel` / `defineCheckpointedModel` stay the static channel — and replaying a withdrawn domain's history lands on the unknown-op skip-and-count path). A fifth seam: executable commands — `CommandContribution` → `IAgentCommandService` fold (`src/agent/command/`; a contributed command runs engine-side — `run(ctx)` gets `ctx.get` resolving through the agent container, valid only during the synchronous part of `run`; name-level dedup, last record wins; surfaced over RPC as `agentRPCService.listCommands` / `runCommand`). +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`. diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts index 5a005ea283..88c70d1944 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts @@ -24,6 +24,7 @@ export interface IAgentFullCompactionService { readonly compacting: FullCompactionTask | null; begin(input: FullCompactionInput): boolean; + cancel(): void; readonly hooks: Hooks<{ onWillCompact: FullCompactionTask; diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 27cd51d08d..70df92bc8b 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -248,6 +248,17 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom return this._compacting; } + cancel(): void { + const active = this._compacting; + if (active !== null) { + this.telemetry.track2('cancel', { + from: 'compacting', + trace_id: active.traceId, + }); + } + active?.abortController.abort(); + } + private getEffectiveMaxContextTokens(): number { const capability = this.profile.data().modelCapabilities; const configured = capability.max_input_tokens ?? capability.max_context_tokens; diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index d13c066c6a..cc8e805fbd 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -146,6 +146,8 @@ export interface IAgentLoopService { cancel(turnId?: number, reason?: unknown): boolean; + cancelFromUser(turnId?: number): void; + tryAcquireQuiescence(): IDisposable | undefined; settled(): Promise; diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index a6940a27b7..63344c3908 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -250,6 +250,17 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { ); } + cancelFromUser(turnId?: number): void { + const status = this.status(); + if (status.state === 'running') { + this.telemetry.track2('cancel', { + from: 'streaming', + trace_id: status.activeTraceId, + }); + } + this.cancel(turnId); + } + tryAcquireQuiescence(): IDisposable | undefined { if (this.disposing) throw abortError('Agent loop disposed'); if (this.activeTurnJob !== undefined || this.hasPendingRequests()) return undefined; diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts index 1d04758748..aaae863872 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts @@ -12,6 +12,7 @@ export interface IAgentPermissionModeService { readonly mode: PermissionMode; setMode(mode: PermissionMode): void; + setModeAndBroadcast(mode: PermissionMode): void; readonly onDidChangeMode: Event; } diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts index b556bfd9e4..a4c0bada6a 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts @@ -5,8 +5,11 @@ * `PermissionModeModel`, mutating it only through the `permission.set_mode` Op * (`wire.dispatch(setMode({ mode }))`) and reading it through `wire.getModel`. * `setMode` emits `onDidChangeMode` after an actual change, and mode-aware - * reminders are registered through the permission-mode injection helper. Bound - * at Agent scope. + * reminders are registered through the permission-mode injection helper. + * `setModeAndBroadcast` is the user-facing entry: on top of `setMode` it + * broadcasts the mode to every agent of the session through `agentLifecycle` + * (main agent only) and tracks the `yolo_toggle` / `afk_toggle` transitions + * through `telemetry`. Bound at Agent scope. */ import type { PermissionMode } from '#/agent/permissionPolicy/types'; @@ -16,6 +19,12 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; import { PermissionModeInjection } from '#/agent/permissionMode/injection/permissionModeInjection'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { + IAgentLifecycleService, + MAIN_AGENT_ID, +} from '#/session/agentLifecycle/agentLifecycle'; import { IWireService } from '#/wire/wire'; import { IAgentPermissionModeService, type PermissionModeChangedContext } from './permissionMode'; import { @@ -33,6 +42,9 @@ export class AgentPermissionModeService extends Service implements IAgentPermiss constructor( @IWireService private readonly wire: IWireService, @IInstantiationService instantiation: IInstantiationService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, + @ITelemetryService private readonly telemetry: ITelemetryService, ) { super(); this._register(instantiation.createInstance(PermissionModeInjection, this)); @@ -49,6 +61,23 @@ export class AgentPermissionModeService extends Service implements IAgentPermiss this.wire.dispatch(setMode({ mode })); if (changed) this._onDidChangeMode.fire({ mode, previousMode }); } + + setModeAndBroadcast(mode: PermissionMode): void { + const wasYolo = this.mode === 'yolo'; + const wasAuto = this.mode === 'auto'; + this.setMode(mode); + if (this.scopeContext.agentId === MAIN_AGENT_ID) { + this.agentLifecycle.broadcastPermissionMode(mode); + } + const yoloEnabled = this.mode === 'yolo'; + if (yoloEnabled !== wasYolo) { + this.telemetry.track2('yolo_toggle', { enabled: yoloEnabled }); + } + const afkEnabled = this.mode === 'auto'; + if (afkEnabled !== wasAuto) { + this.telemetry.track2('afk_toggle', { enabled: afkEnabled }); + } + } } registerScopedService( diff --git a/packages/agent-core-v2/src/agent/pluginCommand/pluginCommand.ts b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommand.ts new file mode 100644 index 0000000000..4838da64fb --- /dev/null +++ b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommand.ts @@ -0,0 +1,41 @@ +/** + * `pluginCommand` domain — Agent-scoped plugin command activation contract. + * + * `IAgentPluginCommandService.activate` drives a user-slash plugin command + * into the agent's prompt pipeline: the command definition lives in the + * App-scope `plugin` domain, while activation (argument expansion, the + * `plugin_command.activated` domain event, prompt enqueue) must run inside the + * agent scope. Bound at Agent scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ActivatePluginCommandPayload { + readonly pluginId: string; + readonly commandName: string; + readonly args?: string | undefined; +} + +export interface PluginCommandActivatedEvent { + readonly type: 'plugin_command.activated'; + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly commandArgs?: string; + readonly trigger: 'user-slash'; +} + +declare module '#/app/event/eventBus' { + interface DomainEventMap { + 'plugin_command.activated': PluginCommandActivatedEvent; + } +} + +export interface IAgentPluginCommandService { + readonly _serviceBrand: undefined; + + activate(payload: ActivatePluginCommandPayload): Promise; +} + +export const IAgentPluginCommandService: ServiceIdentifier = + createDecorator('agentPluginCommandService'); diff --git a/packages/agent-core-v2/src/agent/pluginCommand/pluginCommandService.ts b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommandService.ts new file mode 100644 index 0000000000..9690cd3746 --- /dev/null +++ b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommandService.ts @@ -0,0 +1,111 @@ +/** + * `pluginCommand` domain — `IAgentPluginCommandService` implementation. + * + * Resolves the command definition through `plugin` (`IPluginService`), expands + * its arguments, publishes the `plugin_command.activated` domain event through + * `eventBus`, enqueues the expanded body as a user message through `prompt`, + * and — for the main agent only — persists the derived title/lastPrompt + * through `sessionMetadata`, publishing the live update through `event`. + * Bound at Agent scope. + */ + +import { randomUUID } from 'node:crypto'; + +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IEventBus } from '#/app/event/eventBus'; +import { IEventService } from '#/app/event/event'; +import { ErrorCodes, Error2 } from '#/errors'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { expandCommandArguments } from '#/app/plugin/commands'; +import { IPluginService } from '#/app/plugin/plugin'; +import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { promptMetadataTextFromText } from '#/agent/prompt/promptMetadataText'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { applyPromptMetadataUpdate } from '#/session/sessionMetadata/promptMetadata'; + +import { + IAgentPluginCommandService, + type ActivatePluginCommandPayload, +} from './pluginCommand'; + +export class AgentPluginCommandService implements IAgentPluginCommandService { + declare readonly _serviceBrand: undefined; + + constructor( + @IPluginService private readonly plugins: IPluginService, + @IAgentPromptService private readonly promptService: IAgentPromptService, + @IEventBus private readonly eventBus: IEventBus, + @ISessionMetadata private readonly metadata: ISessionMetadata, + @IEventService private readonly eventService: IEventService, + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + ) { } + + async activate(payload: ActivatePluginCommandPayload): Promise { + const commands = await this.plugins.listPluginCommands(); + const def = commands.find( + (command) => command.pluginId === payload.pluginId && command.name === payload.commandName, + ); + if (def === undefined) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `Plugin command "${payload.pluginId}:${payload.commandName}" was not found`, + ); + } + const commandArgs = payload.args ?? ''; + const expanded = expandCommandArguments(def.body, commandArgs); + const origin = { + kind: 'plugin_command' as const, + activationId: randomUUID(), + pluginId: payload.pluginId, + commandName: payload.commandName, + commandArgs: payload.args, + trigger: 'user-slash' as const, + }; + this.eventBus.publish({ + type: 'plugin_command.activated', + activationId: origin.activationId, + pluginId: origin.pluginId, + commandName: origin.commandName, + commandArgs: origin.commandArgs, + trigger: origin.trigger, + }); + await this.promptService.enqueue({ message: { + role: 'user', + content: [{ type: 'text', text: expanded }], + toolCalls: [], + origin, + } }); + if (this.scopeContext.agentId === MAIN_AGENT_ID) { + await applyPromptMetadataUpdate( + { + metadata: this.metadata, + eventService: this.eventService, + sessionId: this.sessionContext.sessionId, + }, + promptMetadataTextFromPluginCommand(payload), + ); + } + } +} + +function promptMetadataTextFromPluginCommand( + payload: ActivatePluginCommandPayload, +): string | undefined { + const args = payload.args?.trim(); + const command = `/${payload.pluginId}:${payload.commandName}`; + return promptMetadataTextFromText( + args === undefined || args.length === 0 ? command : `${command} ${args}`, + ); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentPluginCommandService, + AgentPluginCommandService, + ScopeActivation.OnScopeCreated, + 'pluginCommand', +); diff --git a/packages/agent-core-v2/src/agent/prompt/prompt.ts b/packages/agent-core-v2/src/agent/prompt/prompt.ts index d5045dd025..fda6afe220 100644 --- a/packages/agent-core-v2/src/agent/prompt/prompt.ts +++ b/packages/agent-core-v2/src/agent/prompt/prompt.ts @@ -1,6 +1,7 @@ import { createDecorator } from '#/_base/di/instantiation'; import type { ContextMessage } from '#/agent/contextMemory/types'; import type { Turn, TurnResult } from '#/agent/loop/loop'; +import type { ContentPart } from '#/kosong/contract/message'; import type { Hooks } from '#/hooks'; export interface PromptSubmitContext { @@ -47,9 +48,24 @@ export interface PromptQueueSnapshot { readonly pending: readonly PromptSnapshot[]; } +export interface PromptPayload { + readonly input: readonly ContentPart[]; + readonly disabledTools?: readonly string[]; +} + +export interface SteerPayload { + readonly input: readonly ContentPart[]; +} + +export interface PromptLaunchResult { + readonly turn_id: number; +} + export interface IAgentPromptService { readonly _serviceBrand: undefined; enqueue(input: PromptInput): Promise; + submit(payload: PromptPayload): Promise; + submitSteer(payload: SteerPayload): Promise; list(): PromptQueueSnapshot; steer(promptIds: readonly string[]): Promise; abort(promptId: string, reason?: Error): boolean; diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index efd40bac1c..e36c6b126f 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -4,7 +4,12 @@ * Assigns prompt and message identities, serializes user prompts through an * active slot and FIFO, converts selected pending prompts into active-turn * steers, settles lifecycle handles, and keeps system input outside the prompt - * resource model. The pure-data `launching` flag is registered into + * resource model. `submit` / `submitSteer` are the wire-facing user entry + * points: they gate on `toolPolicy` session disabled tools, track `input_steer` + * through `telemetry`, persist the derived title/lastPrompt through + * `sessionMetadata` for the main agent only (publishing the live update + * through `event`), enqueue, and settle `{turn_id}` from the launch handle. + * The pure-data `launching` flag is registered into * `agentState` (`IAgentStateService`) and read/written through it; the * `active` / `pending` / `steered` records stay plain fields because their * `Record` values carry Deferred promise handles (the container only holds @@ -31,20 +36,33 @@ import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ContentPart } from '#/kosong/contract/message'; import { IEventBus } from '#/app/event/eventBus'; -import { ErrorCodes, Error2 } from '#/errors'; +import { IEventService } from '#/app/event/event'; +import { ErrorCodes, Error2, isError2 } from '#/errors'; import { OrderedHookSlot } from '#/hooks'; import { IWireService } from '#/wire/wire'; +import { ProfileError } from '#/agent/profile/profile'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { applyPromptMetadataUpdate } from '#/session/sessionMetadata/promptMetadata'; import { IAgentPromptService, type PromptCompletion, type PromptHandle, type PromptInput, + type PromptLaunchResult, + type PromptPayload, type PromptQueueSnapshot, type PromptSnapshot, type PromptState, type PromptSubmitContext, + type SteerPayload, } from './prompt'; +import { promptMetadataTextFromContentParts } from './promptMetadataText'; import { PromptStepRequest, RetryStepRequest, SteerStepRequest } from './promptStepRequests'; declare module '#/app/event/eventBus' { @@ -83,6 +101,12 @@ export class AgentPromptService implements IAgentPromptService { @IWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, @IAgentStateService private readonly states: IAgentStateService, + @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @ISessionMetadata private readonly metadata: ISessionMetadata, + @IEventService private readonly eventService: IEventService, + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) { this.states.register(promptLaunchingKey); toolExecutor.hooks.onDidExecuteTool.register('prompt-service-delivery', async (ctx, next) => { @@ -129,6 +153,74 @@ export class AgentPromptService implements IAgentPromptService { return record.handle; } + async submit(payload: PromptPayload): Promise { + if (payload.disabledTools !== undefined) { + try { + await this.toolPolicy.setSessionDisabledTools(payload.disabledTools); + } catch (error) { + if (error instanceof ProfileError) { + throw new Error2(ErrorCodes.REQUEST_INVALID, error.message); + } + throw error; + } + } + await this.updatePromptMetadata(promptMetadataTextFromContentParts(payload.input)); + const handle = await this.enqueue({ message: { + role: 'user', + content: [...payload.input], + toolCalls: [], + origin: { kind: 'user' }, + } }); + if (handle.state === 'pending') return undefined; + const turn = await handle.launched; + return turn === undefined ? undefined : { turn_id: turn.id }; + } + + async submitSteer(payload: SteerPayload): Promise { + this.telemetry.track2('input_steer', { parts: payload.input.length }); + // A steer is user input like a prompt — and can even launch the session's + // first turn (e.g. goal mode) — so keep title/lastPrompt in sync the same + // way, matching v1. + await this.updatePromptMetadata(promptMetadataTextFromContentParts(payload.input)); + const queued = await this.enqueue({ message: { + role: 'user', + content: [...payload.input], + toolCalls: [], + } }); + if (queued.state !== 'pending') { + // No active prompt at enqueue time, so the enqueue itself already + // launched this input as its own turn (idle session, or a goal-turn + // boundary where the previous turn just ended) — v1's + // steer-degrades-to-launch end state. Return that turn instead of + // rejecting on a steer-by-id that can never find the record pending. + const turn = await queued.launched; + return turn === undefined ? undefined : { turn_id: turn.id }; + } + try { + const [steered] = await this.steer([queued.id]); + const turn = await steered?.launched; + return turn === undefined ? undefined : { turn_id: turn.id }; + } catch (error) { + // Pending but nothing active to steer into (a manual compaction holds + // the context): the message stays queued and launches once compaction + // finishes, so report it as queued rather than failing the steer. + if (isError2(error) && error.code === ErrorCodes.PROMPT_NOT_FOUND) return undefined; + throw error; + } + } + + private async updatePromptMetadata(text: string | undefined): Promise { + if (this.scopeContext.agentId !== MAIN_AGENT_ID) return; + await applyPromptMetadataUpdate( + { + metadata: this.metadata, + eventService: this.eventService, + sessionId: this.sessionContext.sessionId, + }, + text, + ); + } + list(): PromptQueueSnapshot { return { active: this.active === undefined ? undefined : snapshot(this.active), pending: this.pending.map(snapshot) }; } diff --git a/packages/agent-core-v2/src/agent/replayBuilder/types.ts b/packages/agent-core-v2/src/agent/replayBuilder/types.ts index 8d4b6b49a9..0f13665993 100644 --- a/packages/agent-core-v2/src/agent/replayBuilder/types.ts +++ b/packages/agent-core-v2/src/agent/replayBuilder/types.ts @@ -7,10 +7,26 @@ import type { PermissionApprovalResultRecord } from '#/agent/permissionRules/per import type { PermissionData, PermissionMode } from '#/agent/permissionPolicy/types'; import type { PlanData } from '#/features/plan/plan'; import type { ToolInfo } from '#/tool/toolContract'; -import type { SessionSummary } from '#/agent/rpc/core-api'; import type { UsageStatus } from '#/agent/usage/usage'; import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: JsonValue }; +export type JsonObject = { readonly [key: string]: JsonValue }; + +export interface SessionSummary { + readonly id: string; + readonly title?: string | undefined; + readonly lastPrompt?: string; + readonly workDir: string; + readonly sessionDir: string; + readonly createdAt: number; + readonly updatedAt: number; + readonly archived?: boolean | undefined; + readonly metadata?: JsonObject | undefined; + readonly additionalDirs?: readonly string[]; +} + type AgentType = 'main' | 'sub'; export type AgentReplayRecordPayload = diff --git a/packages/agent-core-v2/src/agent/rpc/core-api.ts b/packages/agent-core-v2/src/agent/rpc/core-api.ts deleted file mode 100644 index f1c68603cc..0000000000 --- a/packages/agent-core-v2/src/agent/rpc/core-api.ts +++ /dev/null @@ -1,357 +0,0 @@ -/** - * `rpc` domain — v2 native RPC contract. - * - * Request/response payloads and event types for the engine's native RPC - * surface. `PromptPayload.disabledTools` is the client-managed session - * denylist, applied before the prompt is enqueued: full-replace semantics, the profile's own - * `disallowedTools` always survive, omitting the field keeps the persisted - * value, and `[]` clears the client portion. It is ignored by engines without - * profile support. - */ - -import type { AgentContextData } from '#/agent/contextMemory/types'; -import type { AgentCommandInfo } from '#/agent/command/agentCommand'; -import type { - GoalBudgetLimits, - GoalBudgetReport, - GoalChange, - GoalChangeStats, - GoalSnapshot, - GoalStatus, - GoalToolResult, -} from '#/agent/goal/types'; -import type { PermissionMode } from '#/agent/permissionPolicy/types'; -import type { SwarmModeTrigger } from '#/agent/swarm/swarm'; -import type { ToolDisclosure, ToolInfo } from '#/tool/toolContract'; -import type { ResolvedConfig } from '#/app/config/config'; -import type { ExperimentalFeatureState } from '#/app/flag/flag'; -import type { ResumeSessionResult } from '#/agent/replayBuilder/types'; -import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; -import type { ContentPart } from '#/kosong/contract/message'; -import type { SessionWarning } from '#/app/sessionLegacy/sessionProtocol'; - -import type { ExportSessionPayload, ExportSessionResult } from '#/app/sessionExport/sessionExport'; -import type { PluginCommandDef, PluginInfo, PluginSummary, ReloadSummary } from '#/app/plugin/types'; -import type { WithAgentId, WithSessionId } from './types'; - -export type { ExportSessionManifest, ExportSessionPayload, ExportSessionResult, ShellEnvironment } from '#/app/sessionExport/sessionExport'; - -export type JsonPrimitive = string | number | boolean | null; -export type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: JsonValue }; -export type JsonObject = { readonly [key: string]: JsonValue }; - -export type Unsubscribe = () => void; - -export type TextPromptPart = Extract; -export type PromptPart = Extract; - -export type PromptInput = readonly PromptPart[]; - -export type EmptyPayload = {}; -export type SessionMetadataPatch = Partial>; - -export interface ClientTelemetryInfo { - readonly id?: string | undefined; - readonly name?: string | undefined; - readonly version?: string | undefined; - readonly uiMode?: string | undefined; -} - -export interface CreateSessionPayload { - readonly id?: string | undefined; - readonly workDir: string; - readonly model?: string | undefined; - readonly thinking?: string | undefined; - readonly permission?: PermissionMode | undefined; - readonly metadata?: JsonObject | undefined; - readonly additionalDirs?: readonly string[]; - readonly client?: ClientTelemetryInfo | undefined; -} - -export interface CloseSessionPayload { - readonly sessionId: string; -} - -export interface ArchiveSessionPayload { - readonly sessionId: string; -} - -export interface ResumeSessionPayload { - readonly sessionId: string; - readonly additionalDirs?: readonly string[]; -} - -export interface ReloadSessionPayload { - readonly sessionId: string; - readonly forcePluginSessionStartReminder?: boolean | undefined; -} - -export interface ForkSessionPayload { - readonly sessionId: string; - readonly id?: string; - readonly title?: string; - readonly metadata?: JsonObject; -} - -export interface ListSessionsPayload { - readonly workDir?: string; - readonly sessionId?: string; - readonly includeArchive?: boolean; -} - -export interface CoreInfo { - readonly version: string; -} - -export interface SessionSummary { - readonly id: string; - readonly title?: string | undefined; - readonly lastPrompt?: string; - readonly workDir: string; - readonly sessionDir: string; - readonly createdAt: number; - readonly updatedAt: number; - readonly archived?: boolean | undefined; - readonly metadata?: JsonObject | undefined; - readonly additionalDirs?: readonly string[]; -} - -export interface PromptPayload { - readonly input: readonly ContentPart[]; - readonly disabledTools?: readonly string[]; -} -export interface RunShellCommandPayload { - readonly command: string; - readonly commandId?: string; -} -export interface ShellCommandResult { - readonly stdout: string; - readonly stderr: string; - readonly isError?: boolean; - readonly backgrounded?: boolean; -} -export interface CancelShellCommandPayload { - readonly commandId: string; -} -export interface SteerPayload { - readonly input: readonly ContentPart[]; -} -export interface CancelPayload { - readonly turnId?: number; -} -export interface SetThinkingPayload { - readonly level: string; -} -export interface SetPermissionPayload { - readonly mode: PermissionMode; -} -export interface SetModelPayload { - readonly model: string; -} -export interface SetModelResult { - readonly model: string; - readonly providerName?: string | undefined; -} -export interface CancelPlanPayload { - readonly id?: string; -} -export interface EnterSwarmPayload { - readonly trigger: SwarmModeTrigger; -} -export interface BeginCompactionPayload { - readonly instruction?: string; -} -export interface UndoHistoryPayload { - readonly count: number; -} -export interface RegisterToolPayload { - readonly name: string; - readonly description: string; - readonly parameters: Record; - readonly disclosure?: ToolDisclosure; -} -export interface UnregisterToolPayload { - readonly name: string; -} -export interface SetActiveToolsPayload { - readonly names: readonly string[]; -} -export interface StopTaskPayload { - readonly taskId: string; - readonly reason?: string; -} -export interface DetachTaskPayload { - readonly taskId: string; -} -export interface GetTaskOutputPayload { - readonly taskId: string; - readonly tail?: number; -} -export interface GetTasksPayload { - readonly activeOnly?: boolean; - readonly limit?: number; -} -export interface SkillSummary { - readonly name: string; - readonly description: string; - readonly path: string; - readonly source: 'builtin' | 'user' | 'extra' | 'project'; - readonly type?: string | undefined; - readonly disableModelInvocation?: boolean | undefined; - readonly isSubSkill?: boolean | undefined; -} - -export interface ActivateSkillPayload { - readonly name: string; - readonly args?: string | undefined; -} - -export interface ActivatePluginCommandPayload { - readonly pluginId: string; - readonly commandName: string; - readonly args?: string | undefined; -} - -export interface RunCommandPayload { - readonly name: string; - readonly args?: string | undefined; -} - -export interface McpServerInfo { - readonly name: string; - readonly transport: 'stdio' | 'http' | 'sse'; - readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth' | 'removed'; - readonly toolCount: number; - readonly error?: string; -} - -export interface McpStartupMetrics { - readonly durationMs: number; -} - -export interface ReconnectMcpServerPayload { - readonly name: string; -} - -export interface InstallPluginPayload { - readonly source: string; -} - -export interface SetPluginEnabledPayload { - readonly id: string; - readonly enabled: boolean; -} - -export interface SetPluginMcpServerEnabledPayload { - readonly id: string; - readonly server: string; - readonly enabled: boolean; -} - -export interface RemovePluginPayload { - readonly id: string; -} - -export interface GetPluginInfoPayload { - readonly id: string; -} - -export type ReloadPluginsResult = ReloadSummary; -export type { PluginSummary, PluginInfo }; - -export interface RenameSessionPayload { - readonly title: string; -} - -export interface UpdateSessionMetadataPayload { - readonly metadata: SessionMetadataPatch; -} - -export type { - GoalBudgetLimits, - GoalBudgetReport, - GoalChange, - GoalChangeStats, - GoalSnapshot, - GoalStatus, - GoalToolResult, -}; - -export interface CreateGoalPayload { - readonly objective: string; - readonly replace?: boolean; -} - -export interface GetKimiConfigPayload { - readonly reload?: boolean; -} - -export interface ConfigDiagnostics { - readonly warnings: readonly string[]; -} - -export type SetKimiConfigPayload = ResolvedConfig; - -export interface RemoveKimiProviderPayload { - readonly providerId: string; -} - -export interface PromptLaunchResult { - readonly turn_id: number; -} - -export interface AgentAPI { - prompt: (payload: PromptPayload) => PromptLaunchResult | undefined; - steer: (payload: SteerPayload) => PromptLaunchResult | undefined; - cancel: (payload: CancelPayload) => void; - undoHistory: (payload: UndoHistoryPayload) => Promise; - setPermission: (payload: SetPermissionPayload) => void; - cancelCompaction: (payload: EmptyPayload) => void; - activateSkill: (payload: ActivateSkillPayload) => PromptLaunchResult | undefined; - activatePluginCommand: (payload: ActivatePluginCommandPayload) => void; - listCommands: (payload: EmptyPayload) => readonly AgentCommandInfo[]; - runCommand: (payload: RunCommandPayload) => Promise; - getContext: (payload: EmptyPayload) => AgentContextData; - getTools: (payload: EmptyPayload) => readonly ToolInfo[]; -} - -type AgentAPIWithId = WithAgentId; - -export interface SessionAPI extends AgentAPIWithId { - renameSession: (payload: RenameSessionPayload) => void; - updateSessionMetadata: (payload: UpdateSessionMetadataPayload) => void; - getSessionMetadata: (payload: EmptyPayload) => SessionMeta; - listSkills: (payload: EmptyPayload) => readonly SkillSummary[]; - listPluginCommands: (payload: EmptyPayload) => readonly PluginCommandDef[]; - listMcpServers: (payload: EmptyPayload) => readonly McpServerInfo[]; - getMcpStartupMetrics: (payload: EmptyPayload) => McpStartupMetrics; - reconnectMcpServer: (payload: ReconnectMcpServerPayload) => void; - generateAgentsMd: (payload: EmptyPayload) => void; - getSessionWarnings: (payload: EmptyPayload) => readonly SessionWarning[]; -} - -type SessionAPIWithId = WithSessionId; - -export interface CoreAPI extends SessionAPIWithId { - getCoreInfo: (payload: EmptyPayload) => CoreInfo; - getExperimentalFeatures: (payload: EmptyPayload) => readonly ExperimentalFeatureState[]; - getKimiConfig: (payload: GetKimiConfigPayload) => ResolvedConfig; - getConfigDiagnostics: (payload: EmptyPayload) => ConfigDiagnostics; - setKimiConfig: (payload: SetKimiConfigPayload) => ResolvedConfig; - removeKimiProvider: (payload: RemoveKimiProviderPayload) => ResolvedConfig; - createSession: (payload: CreateSessionPayload) => SessionSummary; - closeSession: (payload: CloseSessionPayload) => void; - archiveSession: (payload: ArchiveSessionPayload) => void; - resumeSession: (payload: ResumeSessionPayload) => ResumeSessionResult; - reloadSession: (payload: ReloadSessionPayload) => ResumeSessionResult; - forkSession: (payload: ForkSessionPayload) => ResumeSessionResult; - listSessions: (payload: ListSessionsPayload) => readonly SessionSummary[]; - exportSession: (payload: ExportSessionPayload) => ExportSessionResult; - listPlugins: (payload: EmptyPayload) => readonly PluginSummary[]; - installPlugin: (payload: InstallPluginPayload) => PluginSummary; - setPluginEnabled: (payload: SetPluginEnabledPayload) => void; - setPluginMcpServerEnabled: (payload: SetPluginMcpServerEnabledPayload) => void; - removePlugin: (payload: RemovePluginPayload) => void; - reloadPlugins: (payload: EmptyPayload) => ReloadPluginsResult; - getPluginInfo: (payload: GetPluginInfoPayload) => PluginInfo; -} diff --git a/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts b/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts deleted file mode 100644 index 519e2a440f..0000000000 --- a/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * `rpc` domain (Agent) — v1-compatible prompt metadata helpers. - * - * Derives title and last-prompt text from native and legacy prompt payloads, - * persists metadata through `sessionMetadata`, and publishes live updates - * through `event`. - */ - -import type { IEventService } from '#/app/event/event'; -import type { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; - -import { - promptMetadataTextFromContentParts, - promptMetadataTextFromText, - titleFromPromptMetadataText, -} from '#/agent/prompt/promptMetadataText'; - -import type { - ActivatePluginCommandPayload, - ActivateSkillPayload, - PromptPayload, -} from './core-api'; - -export { promptMetadataTextFromContentParts, titleFromPromptMetadataText }; - -export function promptMetadataTextFromPayload(payload: PromptPayload): string | undefined { - return promptMetadataTextFromContentParts(payload.input); -} - -export function promptMetadataTextFromSkill(payload: ActivateSkillPayload): string | undefined { - const args = payload.args?.trim(); - return promptMetadataTextFromText( - args === undefined || args.length === 0 ? `/${payload.name}` : `/${payload.name} ${args}`, - ); -} - -export function promptMetadataTextFromPluginCommand( - payload: ActivatePluginCommandPayload, -): string | undefined { - const args = payload.args?.trim(); - const command = `/${payload.pluginId}:${payload.commandName}`; - return promptMetadataTextFromText( - args === undefined || args.length === 0 ? command : `${command} ${args}`, - ); -} - -export function isUntitled(title: string | undefined): boolean { - return title === undefined || title.trim().length === 0 || title === 'New Session'; -} - -export interface PromptMetadataUpdateTarget { - readonly metadata: ISessionMetadata; - readonly eventService: IEventService; - readonly sessionId: string; -} - -export async function applyPromptMetadataUpdate( - target: PromptMetadataUpdateTarget, - text: string | undefined, -): Promise { - if (text === undefined) return; - const current = await target.metadata.read(); - const patch: { lastPrompt: string; title?: string; isCustomTitle?: boolean } = { - lastPrompt: text, - }; - if (!current.isCustomTitle && isUntitled(current.title)) { - patch.title = titleFromPromptMetadataText(text); - patch.isCustomTitle = false; - } - await target.metadata.update(patch); - target.eventService.publish({ - type: 'session.meta.updated', - payload: { - agentId: 'main', - sessionId: target.sessionId, - title: patch.title, - patch: { - title: patch.title, - isCustomTitle: patch.isCustomTitle, - lastPrompt: text, - }, - }, - }); -} diff --git a/packages/agent-core-v2/src/agent/rpc/rpc.ts b/packages/agent-core-v2/src/agent/rpc/rpc.ts deleted file mode 100644 index 66115e9068..0000000000 --- a/packages/agent-core-v2/src/agent/rpc/rpc.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createDecorator } from "#/_base/di/instantiation"; -import type { - AgentAPI, - SessionAPI, -} from './core-api'; -import type { PromisableMethods } from "#/_base/utils/types"; - -export interface IAgentRPCService extends PromisableMethods { - readonly _serviceBrand: undefined; -} - -export interface ISessionRPCService extends PromisableMethods { - readonly _serviceBrand: undefined; -} - -export const IAgentRPCService = - createDecorator('agentRPCService'); - -export const ISessionRPCService = - createDecorator('agentSessionRPCService'); diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts deleted file mode 100644 index 87e8005acb..0000000000 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; -import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; -import { IEventBus } from '#/app/event/eventBus'; -import { IEventService } from '#/app/event/event'; -import { ErrorCodes, Error2, isError2 } from '#/errors'; -import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { - IAgentLifecycleService, - MAIN_AGENT_ID, -} from '#/session/agentLifecycle/agentLifecycle'; -import { IAgentCommandService } from '#/agent/command/agentCommand'; -import { expandCommandArguments } from '#/app/plugin/commands'; -import { IPluginService } from '#/app/plugin/plugin'; -import { ProfileError } from '#/agent/profile/profile'; -import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; -import { IAgentPromptService } from '#/agent/prompt/prompt'; -import { IAgentConversationUndoService } from '#/agent/undo/undo'; -import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { IAgentSkillService } from '#/agent/skill/skill'; -import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; -import { IAgentLoopService } from '#/agent/loop/loop'; -import type { - ActivatePluginCommandPayload, - ActivateSkillPayload, - CancelPayload, - EmptyPayload, - PromptLaunchResult, - PromptPayload, - RunCommandPayload, - SetPermissionPayload, - SteerPayload, - UndoHistoryPayload, -} from './core-api'; -import { IAgentRPCService } from './rpc'; -import { - applyPromptMetadataUpdate, - promptMetadataTextFromPayload, - promptMetadataTextFromPluginCommand, - promptMetadataTextFromSkill, -} from './prompt-metadata'; - -export interface PluginCommandActivatedEvent { - readonly type: 'plugin_command.activated'; - readonly activationId: string; - readonly pluginId: string; - readonly commandName: string; - readonly commandArgs?: string; - readonly trigger: 'user-slash'; -} - -declare module '#/app/event/eventBus' { - interface DomainEventMap { - 'plugin_command.activated': PluginCommandActivatedEvent; - } -} - -export class AgentRPCService implements IAgentRPCService { - declare readonly _serviceBrand: undefined; - - constructor( - @IAgentPromptService private readonly promptService: IAgentPromptService, - @IAgentConversationUndoService - private readonly conversationUndo: IAgentConversationUndoService, - @IAgentLoopService private readonly loop: IAgentLoopService, - @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, - @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, - @IAgentFullCompactionService private readonly fullCompaction: IAgentFullCompactionService, - @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, - @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentTokenCountingService private readonly tokenCounting: IAgentTokenCountingService, - @IAgentSkillService private readonly skills: IAgentSkillService, - @ITelemetryService private readonly telemetry: ITelemetryService, - @IEventBus private readonly eventBus: IEventBus, - @IEventService private readonly eventService: IEventService, - @IPluginService private readonly plugins: IPluginService, - @ISessionMetadata private readonly metadata: ISessionMetadata, - @ISessionContext private readonly sessionContext: ISessionContext, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, - @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, - @IAgentCommandService private readonly commands: IAgentCommandService, - ) { } - - async prompt(payload: PromptPayload): Promise { - if (payload.disabledTools !== undefined) { - try { - await this.toolPolicy.setSessionDisabledTools(payload.disabledTools); - } catch (error) { - if (error instanceof ProfileError) { - throw new Error2(ErrorCodes.REQUEST_INVALID, error.message); - } - throw error; - } - } - await this.updatePromptMetadata(promptMetadataTextFromPayload(payload)); - const handle = await this.promptService.enqueue({ message: { - role: 'user', - content: [...payload.input], - toolCalls: [], - origin: { kind: 'user' }, - } }); - if (handle.state === 'pending') return undefined; - const turn = await handle.launched; - return turn === undefined ? undefined : { turn_id: turn.id }; - } - - async steer(payload: SteerPayload): Promise { - this.telemetry.track2('input_steer', { parts: payload.input.length }); - if (this.scopeContext.agentId === MAIN_AGENT_ID) { - // A steer is user input like a prompt — and can even launch the - // session's first turn (e.g. goal mode) — so keep title/lastPrompt in - // sync the same way, matching v1. - await this.updatePromptMetadata(promptMetadataTextFromPayload(payload)); - } - const queued = await this.promptService.enqueue({ message: { - role: 'user', - content: [...payload.input], - toolCalls: [], - } }); - if (queued.state !== 'pending') { - // No active prompt at enqueue time, so the enqueue itself already - // launched this input as its own turn (idle session, or a goal-turn - // boundary where the previous turn just ended) — v1's - // steer-degrades-to-launch end state. Return that turn instead of - // rejecting on a steer-by-id that can never find the record pending. - const turn = await queued.launched; - return turn === undefined ? undefined : { turn_id: turn.id }; - } - try { - const [steered] = await this.promptService.steer([queued.id]); - const turn = await steered?.launched; - return turn === undefined ? undefined : { turn_id: turn.id }; - } catch (error) { - // Pending but nothing active to steer into (a manual compaction holds - // the context): the message stays queued and launches once compaction - // finishes, so report it as queued rather than failing the steer. - if (isError2(error) && error.code === ErrorCodes.PROMPT_NOT_FOUND) return undefined; - throw error; - } - } - - cancel({ turnId }: CancelPayload): void { - if (this.loop.status().state === 'running') { - this.telemetry.track2('cancel', { - from: 'streaming', - trace_id: this.loop.status().activeTraceId, - }); - } - this.loop.cancel(turnId); - } - - async undoHistory(payload: UndoHistoryPayload): Promise { - return this.conversationUndo.undo(payload.count); - } - - setPermission(payload: SetPermissionPayload): void { - const wasYolo = this.permissionMode.mode === 'yolo'; - const wasAuto = this.permissionMode.mode === 'auto'; - this.permissionMode.setMode(payload.mode); - if (this.scopeContext.agentId === MAIN_AGENT_ID) { - this.agentLifecycle.broadcastPermissionMode(payload.mode); - } - const enabled = this.permissionMode.mode === 'yolo'; - if (enabled !== wasYolo) { - this.telemetry.track2('yolo_toggle', { enabled }); - } - const afkEnabled = this.permissionMode.mode === 'auto'; - if (afkEnabled !== wasAuto) { - this.telemetry.track2('afk_toggle', { enabled: afkEnabled }); - } - } - - cancelCompaction(_payload: EmptyPayload): void { - const active = this.fullCompaction.compacting; - if (active !== null) { - this.telemetry.track2('cancel', { - from: 'compacting', - trace_id: active.traceId, - }); - } - active?.abortController.abort(); - } - - async activateSkill(payload: ActivateSkillPayload): Promise { - // Awaited (not fire-and-forget): the caller gets the launched turn id and - // activation failures (unknown skill, busy) surface instead of vanishing. - const turn = await this.skills.activate(payload); - await this.updatePromptMetadata(promptMetadataTextFromSkill(payload)); - return { turn_id: turn.id }; - } - - async activatePluginCommand(payload: ActivatePluginCommandPayload): Promise { - const commands = await this.plugins.listPluginCommands(); - const def = commands.find( - (command) => command.pluginId === payload.pluginId && command.name === payload.commandName, - ); - if (def === undefined) { - throw new Error2( - ErrorCodes.REQUEST_INVALID, - `Plugin command "${payload.pluginId}:${payload.commandName}" was not found`, - ); - } - const commandArgs = payload.args ?? ''; - const expanded = expandCommandArguments(def.body, commandArgs); - const origin = { - kind: 'plugin_command' as const, - activationId: randomUUID(), - pluginId: payload.pluginId, - commandName: payload.commandName, - commandArgs: payload.args, - trigger: 'user-slash' as const, - }; - this.eventBus.publish({ - type: 'plugin_command.activated', - activationId: origin.activationId, - pluginId: origin.pluginId, - commandName: origin.commandName, - commandArgs: origin.commandArgs, - trigger: origin.trigger, - }); - await this.promptService.enqueue({ message: { - role: 'user', - content: [{ type: 'text', text: expanded }], - toolCalls: [], - origin, - } }); - await this.updatePromptMetadata(promptMetadataTextFromPluginCommand(payload)); - } - - private async updatePromptMetadata(text: string | undefined): Promise { - await applyPromptMetadataUpdate( - { - metadata: this.metadata, - eventService: this.eventService, - sessionId: this.sessionContext.sessionId, - }, - text, - ); - } - - getContext(_payload: EmptyPayload) { - return { - history: this.context.get(), - // The externally reported context size, resolved by the - // `[token_counting]` strategy inside the service — matching the v1 - // `context.tokenCount` semantics. - tokenCount: this.tokenCounting.statusSize(), - }; - } - - listCommands(_payload: EmptyPayload) { - return this.commands.list(); - } - - async runCommand(payload: RunCommandPayload): Promise { - return this.commands.run(payload.name, payload.args); - } - - getTools(_payload: EmptyPayload) { - return this.toolRegistry.list().map((tool) => ({ - name: tool.name, - description: tool.description, - active: this.toolPolicy.isToolActive(tool.name, tool.source), - source: tool.source, - })); - } -} - -registerScopedService( - LifecycleScope.Agent, - IAgentRPCService, - AgentRPCService, - ScopeActivation.OnScopeCreated, - 'rpc', -); diff --git a/packages/agent-core-v2/src/agent/rpc/types.ts b/packages/agent-core-v2/src/agent/rpc/types.ts deleted file mode 100644 index fb661f597a..0000000000 --- a/packages/agent-core-v2/src/agent/rpc/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * `rpc` domain (L8) — shared request wrapper types. - */ - -export type WithSessionId = T & { - readonly sessionId: string; -}; - -export type WithAgentId = T & { - readonly agentId: string; -}; diff --git a/packages/agent-core-v2/src/agent/skill/prompt.ts b/packages/agent-core-v2/src/agent/skill/prompt.ts index 1cbb50362d..4cffd8522c 100644 --- a/packages/agent-core-v2/src/agent/skill/prompt.ts +++ b/packages/agent-core-v2/src/agent/skill/prompt.ts @@ -1,6 +1,16 @@ import { escapeXml } from '#/_base/utils/xml-escape'; +import { promptMetadataTextFromText } from '#/agent/prompt/promptMetadataText'; import type { SkillSource } from '#/app/skillCatalog/types'; +import type { SkillActivationInput } from './skill'; + +export function promptMetadataTextFromSkill(input: SkillActivationInput): string | undefined { + const args = input.args?.trim(); + return promptMetadataTextFromText( + args === undefined || args.length === 0 ? `/${input.name}` : `/${input.name} ${args}`, + ); +} + export type SkillPromptTrigger = 'user-slash' | 'model-tool' | 'nested-skill'; export interface RenderSkillPromptInput { diff --git a/packages/agent-core-v2/src/agent/skill/skill.ts b/packages/agent-core-v2/src/agent/skill/skill.ts index ed4eb3e93e..e512195a94 100644 --- a/packages/agent-core-v2/src/agent/skill/skill.ts +++ b/packages/agent-core-v2/src/agent/skill/skill.ts @@ -10,7 +10,7 @@ import { createDecorator } from "#/_base/di/instantiation"; import type { SkillActivationOrigin } from '#/agent/contextMemory/types'; -import type { Turn } from '#/agent/loop/loop'; +import type { PromptLaunchResult } from '#/agent/prompt/prompt'; import type { ContentPart } from '#/kosong/contract/message'; export interface SkillActivationInput { @@ -22,7 +22,7 @@ export interface SkillActivationInput { export interface IAgentSkillService { readonly _serviceBrand: undefined; - activate(input: SkillActivationInput): Promise; + activate(input: SkillActivationInput): Promise; recordModelToolActivation(origin: SkillActivationOrigin): void; } diff --git a/packages/agent-core-v2/src/agent/skill/skillService.ts b/packages/agent-core-v2/src/agent/skill/skillService.ts index aed7efba28..6148e52268 100644 --- a/packages/agent-core-v2/src/agent/skill/skillService.ts +++ b/packages/agent-core-v2/src/agent/skill/skillService.ts @@ -6,10 +6,12 @@ * (a stateless, identity-apply Op), derives the `skill.activated` event * through the Op's `toEvent`, drives user-slash activations into a new turn via * `prompt` (attachment parts from the caller ride the same user message after - * the rendered prompt), and reports `skill_invoked` / `flow_invoked` through - * `telemetry`. `wire.replay` reapplies the fact as a no-op, so neither the - * event nor telemetry fires on resume (matching the former `restoring` guard). - * Bound at Agent scope. + * the rendered prompt), settles `{turn_id}` for the caller, persists the + * derived title/lastPrompt through `sessionMetadata` for the main agent only + * (publishing the live update through `event`), and reports `skill_invoked` / + * `flow_invoked` through `telemetry`. `wire.replay` reapplies the fact as a + * no-op, so neither the event nor telemetry fires on resume (matching the + * former `restoring` guard). Bound at Agent scope. */ import { randomUUID } from 'node:crypto'; @@ -19,18 +21,23 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { ContentPart } from '#/kosong/contract/message'; import type { ContextMessage, SkillActivationOrigin } from '#/agent/contextMemory/types'; -import { renderUserSlashSkillPrompt } from './prompt'; +import { promptMetadataTextFromSkill, renderUserSlashSkillPrompt } from './prompt'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { Service } from '#/_base/di/service'; import { ErrorCodes, Error2 } from '#/errors'; import { isUserActivatableSkillType, type SkillDefinition } from '#/app/skillCatalog/types'; -import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { IAgentPromptService, type PromptLaunchResult } from '#/agent/prompt/prompt'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { Turn } from '#/agent/loop/loop'; import { IWireService } from '#/wire/wire'; import { IAgentSkillService, type SkillActivationInput } from './skill'; import { skillActivate } from './skillOps'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { IEventService } from '#/app/event/event'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { applyPromptMetadataUpdate } from '#/session/sessionMetadata/promptMetadata'; export class AgentSkillService extends Service implements IAgentSkillService { declare readonly _serviceBrand: undefined; @@ -41,11 +48,14 @@ export class AgentSkillService extends Service implements IAgentSkillService { @IWireService private readonly wire: IWireService, @ITelemetryService private readonly telemetry: ITelemetryService, @ISessionContext private readonly sessionContext: ISessionContext, + @ISessionMetadata private readonly metadata: ISessionMetadata, + @IEventService private readonly eventService: IEventService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) { super(); } - async activate(input: SkillActivationInput): Promise { + async activate(input: SkillActivationInput): Promise { await this.skillCatalog.ready; const skill = this.skillCatalog.catalog.getSkill(input.name); if (skill === undefined) { @@ -93,7 +103,19 @@ export class AgentSkillService extends Service implements IAgentSkillService { 'Cannot activate skill while another turn is active', ); } - return turn; + // Awaited (not fire-and-forget): the caller gets the launched turn id and + // activation failures (unknown skill, busy) surface instead of vanishing. + if (this.scopeContext.agentId === MAIN_AGENT_ID) { + await applyPromptMetadataUpdate( + { + metadata: this.metadata, + eventService: this.eventService, + sessionId: this.sessionContext.sessionId, + }, + promptMetadataTextFromSkill(input), + ); + } + return { turn_id: turn.id }; } recordModelToolActivation(origin: SkillActivationOrigin): void { diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index e41a76d8f3..e65d69147c 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -124,6 +124,7 @@ export * from '#/app/sessionIndex/sessionIndexService'; export * from '#/app/sessionIndex/sessionIndexMirrorService'; export * from '#/session/sessionMetadata/sessionMetadata'; export * from '#/session/sessionMetadata/sessionMetadataService'; +export * from '#/session/sessionMetadata/promptMetadata'; export * from '#/session/sessionActivity/sessionActivity'; export * from '#/session/sessionActivity/sessionActivityService'; export * from '#/session/sessionActivity/sessionOutcomeMirror'; @@ -615,19 +616,23 @@ import '#/agent/permissionRules/configSection'; export * from '#/agent/permissionRules/permissionRules'; export * from '#/agent/permissionRules/matchesRule'; export * from '#/agent/permissionRules/permissionRulesService'; +export * from '#/agent/pluginCommand/pluginCommand'; +export * from '#/agent/pluginCommand/pluginCommandService'; export * from '#/agent/profile/profile'; export * from '#/agent/profile/profileService'; export * from '#/agent/profile/context'; export * from '#/agent/prompt/prompt'; export * from '#/agent/prompt/promptService'; +export * from '#/agent/prompt/promptMetadataText'; export * from '#/agent/replayBuilder/types'; +// `replayBuilder/types` inlines its own `SessionSummary`; keep the barrel's +// `SessionSummary` pinned to the session-index one (explicit re-export wins +// over the ambiguous `export *` pair). +export { type SessionSummary } from '#/app/sessionIndex/sessionIndex'; export * from '#/agent/undo/undo'; export * from '#/agent/undo/undoService'; export * from '#/agent/shellCommand/shellCommand'; export * from '#/agent/shellCommand/shellCommandService'; -export * from '#/agent/rpc/rpc'; -export * from '#/agent/rpc/rpcService'; -export * from '#/agent/rpc/prompt-metadata'; export * from '#/agent/scopeContext/scopeContext'; export * from '#/agent/stepRetry/stepRetry'; export * from '#/agent/stepRetry/stepRetryService'; diff --git a/packages/agent-core-v2/src/session/sessionMetadata/promptMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/promptMetadata.ts new file mode 100644 index 0000000000..611a495bb3 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionMetadata/promptMetadata.ts @@ -0,0 +1,56 @@ +/** + * `sessionMetadata` domain — prompt-derived title / lastPrompt updates. + * + * Applies the metadata text derived from a prompt-like entry (prompt, steer, + * skill or plugin-command activation) to the session's durable metadata: + * `lastPrompt` always follows the latest text, while `title` is only derived + * for an untitled session without a custom title. Persists through + * `sessionMetadata` and publishes the live `session.meta.updated` update + * through `event`. Session-scoped by target, called from Agent-scope domains + * (main agent only). + */ + +import type { IEventService } from '#/app/event/event'; + +import { titleFromPromptMetadataText } from '#/agent/prompt/promptMetadataText'; + +import type { ISessionMetadata } from './sessionMetadata'; + +export function isUntitled(title: string | undefined): boolean { + return title === undefined || title.trim().length === 0 || title === 'New Session'; +} + +export interface PromptMetadataUpdateTarget { + readonly metadata: ISessionMetadata; + readonly eventService: IEventService; + readonly sessionId: string; +} + +export async function applyPromptMetadataUpdate( + target: PromptMetadataUpdateTarget, + text: string | undefined, +): Promise { + if (text === undefined) return; + const current = await target.metadata.read(); + const patch: { lastPrompt: string; title?: string; isCustomTitle?: boolean } = { + lastPrompt: text, + }; + if (!current.isCustomTitle && isUntitled(current.title)) { + patch.title = titleFromPromptMetadataText(text); + patch.isCustomTitle = false; + } + await target.metadata.update(patch); + target.eventService.publish({ + type: 'session.meta.updated', + payload: { + agentId: 'main', + sessionId: target.sessionId, + title: patch.title, + patch: { + title: patch.title, + isCustomTitle: patch.isCustomTitle, + lastPrompt: text, + }, + }, + }); +} diff --git a/packages/agent-core-v2/test/agent/loop/stubs.ts b/packages/agent-core-v2/test/agent/loop/stubs.ts index 227bf84b3c..49e50e8137 100644 --- a/packages/agent-core-v2/test/agent/loop/stubs.ts +++ b/packages/agent-core-v2/test/agent/loop/stubs.ts @@ -72,6 +72,7 @@ export function stubLoopWithHooks(options: StubLoopOptions = {}): StubLoop { async run() { return { type: 'completed', steps: 0, truncated: false }; }, status() { return { state: active !== undefined ? 'running' : 'idle', activeTurnId: active?.id, pendingTurnIds: [], hasPendingRequests: queue.hasPendingRequests() }; }, cancel(turnId, reason) { cancels.push({ turnId, reason }); if (active === undefined || (turnId !== undefined && active.id !== turnId)) return false; active.cancel(reason); return true; }, + cancelFromUser(turnId) { stub.cancel(turnId); }, tryAcquireQuiescence: () => toDisposable(() => {}), hasPendingRequests: () => queue.hasPendingRequests(), registerLoopErrorHandler: errorHandlers.register, settled: () => Promise.resolve(), diff --git a/packages/agent-core-v2/test/agent/rpc/setPermission.test.ts b/packages/agent-core-v2/test/agent/permissionMode/setModeAndBroadcast.test.ts similarity index 97% rename from packages/agent-core-v2/test/agent/rpc/setPermission.test.ts rename to packages/agent-core-v2/test/agent/permissionMode/setModeAndBroadcast.test.ts index 87088976b4..41a5b23369 100644 --- a/packages/agent-core-v2/test/agent/rpc/setPermission.test.ts +++ b/packages/agent-core-v2/test/agent/permissionMode/setModeAndBroadcast.test.ts @@ -5,7 +5,7 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; import { createTestAgent, telemetryServices, type TestAgentContext } from '../../harness'; -describe('setPermission RPC', () => { +describe('setModeAndBroadcast', () => { let ctx: TestAgentContext; let records: TelemetryRecord[]; diff --git a/packages/agent-core-v2/test/agent/permissionMode/stubs.ts b/packages/agent-core-v2/test/agent/permissionMode/stubs.ts index 9de1a7e43e..a94f764306 100644 --- a/packages/agent-core-v2/test/agent/permissionMode/stubs.ts +++ b/packages/agent-core-v2/test/agent/permissionMode/stubs.ts @@ -23,6 +23,7 @@ export function stubPermissionModeService( return mode(); }, setMode: () => {}, + setModeAndBroadcast: () => {}, onDidChangeMode: Event.None as Event, }; } diff --git a/packages/agent-core-v2/test/agent/pluginCommand/pluginCommand.test.ts b/packages/agent-core-v2/test/agent/pluginCommand/pluginCommand.test.ts new file mode 100644 index 0000000000..216c35ce42 --- /dev/null +++ b/packages/agent-core-v2/test/agent/pluginCommand/pluginCommand.test.ts @@ -0,0 +1,120 @@ +/** + * Scenario: `IAgentPluginCommandService.activate` drives a user-slash plugin + * command into the prompt pipeline. + * + * Pins the activation flow: definition lookup (unknown commands reject with + * `request.invalid`), argument expansion, the `plugin_command.activated` + * domain event, the enqueued user message, and the main-agent prompt-metadata + * update. Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/agent/pluginCommand/pluginCommand.test.ts`. + */ + +import { afterEach, describe, expect, it } from 'vitest'; + +import { IEventBus } from '#/app/event/eventBus'; +import { IPluginService } from '#/app/plugin/plugin'; +import type { PluginCommandDef } from '#/app/plugin/types'; +import { ErrorCodes } from '#/errors'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; + +import { + IAgentPluginCommandService, + type PluginCommandActivatedEvent, +} from '#/agent/pluginCommand/pluginCommand'; + +import { appService, createTestAgent, type TestAgentContext } from '../../harness'; + +const DEPLOY_COMMAND: PluginCommandDef = { + pluginId: 'demo', + name: 'deploy', + description: 'Deploy', + body: 'Deploy body', + path: '/plugins/demo/deploy.md', +}; + +function pluginServiceStub(commands: readonly PluginCommandDef[]): IPluginService { + return { + _serviceBrand: undefined, + onDidReload: () => ({ dispose: () => {} }), + onDidMutate: () => ({ dispose: () => {} }), + listPlugins: async () => [], + installPlugin: async () => ({ id: '' }) as never, + setPluginEnabled: async () => {}, + setPluginMcpServerEnabled: async () => {}, + removePlugin: async () => {}, + reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), + getPluginInfo: async () => { + throw new Error('getPluginInfo is not used by these tests'); + }, + listPluginCommands: async () => commands, + checkUpdates: async () => [], + pluginSkillRoots: async () => [], + pluginAgentRoots: async () => [], + enabledSessionStarts: async () => [], + enabledSystemPrompts: async () => [], + enabledMcpServers: async () => ({}), + enabledHooks: async () => [], + hasLoadedSnapshot: () => true, + }; +} + +describe('AgentPluginCommandService', () => { + let ctx: TestAgentContext; + + afterEach(async () => { + try { + await ctx.expectResumeMatches(); + } finally { + await ctx.dispose(); + } + }); + + function agentWithDeployCommand(): TestAgentContext { + return createTestAgent( + appService(IPluginService, pluginServiceStub([DEPLOY_COMMAND])), + ); + } + + it('publishes the activation event, enqueues the expanded body, and updates metadata', async () => { + ctx = agentWithDeployCommand(); + ctx.mockNextResponse({ type: 'text', text: 'deployed' }); + + const events: PluginCommandActivatedEvent[] = []; + const sub = ctx + .get(IEventBus) + .subscribe('plugin_command.activated', (event) => events.push(event)); + + await ctx + .get(IAgentPluginCommandService) + .activate({ pluginId: 'demo', commandName: 'deploy', args: 'prod' }); + sub.dispose(); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'plugin_command.activated', + pluginId: 'demo', + commandName: 'deploy', + commandArgs: 'prod', + trigger: 'user-slash', + }); + + await ctx.untilTurnEnd(); + const llmInput = JSON.stringify(ctx.llmInputs()); + expect(llmInput).toContain('Deploy body'); + expect(llmInput).toContain('ARGUMENTS: prod'); + + const metadata = await ctx.get(ISessionMetadata).read(); + expect(metadata.title).toBe('/demo:deploy prod'); + expect(metadata.lastPrompt).toBe('/demo:deploy prod'); + }); + + it('rejects an unknown command with request.invalid', async () => { + ctx = agentWithDeployCommand(); + + await expect( + ctx + .get(IAgentPluginCommandService) + .activate({ pluginId: 'demo', commandName: 'missing' }), + ).rejects.toMatchObject({ code: ErrorCodes.REQUEST_INVALID }); + }); +}); diff --git a/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts b/packages/agent-core-v2/test/agent/prompt/promptMetadataText.test.ts similarity index 58% rename from packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts rename to packages/agent-core-v2/test/agent/prompt/promptMetadataText.test.ts index 7e1527c443..629d1fa58a 100644 --- a/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptMetadataText.test.ts @@ -1,6 +1,6 @@ /** - * prompt-metadata — the session title / lastPrompt text derived from a - * prompt payload. + * promptMetadataText — the session title / lastPrompt text derived from + * prompt content parts. * * Tests pin: * - media parts render as `[image]` / `[video]` / `[audio]` placeholders @@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest'; -import { promptMetadataTextFromPayload } from '#/agent/rpc/prompt-metadata'; +import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadataText'; import { buildImageCompressionCaption } from '#/agent/media/image-compress'; const CAPTION = buildImageCompressionCaption({ @@ -20,34 +20,28 @@ const CAPTION = buildImageCompressionCaption({ originalPath: '/tmp/originals/shot.png', }); -describe('promptMetadataTextFromPayload', () => { +describe('promptMetadataTextFromContentParts', () => { it('renders text and media placeholders', () => { - const text = promptMetadataTextFromPayload({ - input: [ - { type: 'text', text: 'look at this' }, - { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }, - ], - }); + const text = promptMetadataTextFromContentParts([ + { type: 'text', text: 'look at this' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }, + ]); expect(text).toBe('look at this [image]'); }); it('keeps a standalone image-compression caption out of the metadata text', () => { - const text = promptMetadataTextFromPayload({ - input: [ - { type: 'text', text: CAPTION }, - { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }, - ], - }); + const text = promptMetadataTextFromContentParts([ + { type: 'text', text: CAPTION }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }, + ]); expect(text).toBe('[image]'); }); it('strips a caption merged into the user text and keeps the rest', () => { - const text = promptMetadataTextFromPayload({ - input: [ - { type: 'text', text: `能展示但是没有快捷键提示${CAPTION}` }, - { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }, - ], - }); + const text = promptMetadataTextFromContentParts([ + { type: 'text', text: `能展示但是没有快捷键提示${CAPTION}` }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }, + ]); expect(text).toBe('能展示但是没有快捷键提示 [image]'); expect(text).not.toContain(''); expect(text).not.toContain('Image compressed'); diff --git a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts index 7336d31c91..d12d3157b9 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -17,13 +17,19 @@ import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompacti import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { AgentPromptService } from '#/agent/prompt/promptService'; +import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IEventBus } from '#/app/event/eventBus'; +import { IEventService } from '#/app/event/event'; import { EventBusService } from '#/app/event/eventBusService'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2 } from '#/errors'; import { createHooks } from '#/hooks'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { IWireService } from '#/wire/wire'; import { stubContextMemory } from '../contextMemory/stubs'; @@ -57,6 +63,17 @@ function harness() { reg.define(IEventBus, EventBusService); reg.define(IAgentSystemReminderService, AgentSystemReminderService); reg.define(IAgentPromptService, AgentPromptService); + reg.definePartialInstance(IAgentToolPolicyService, { + setSessionDisabledTools: async () => {}, + }); + reg.definePartialInstance(ITelemetryService, { track: () => {}, track2: () => {} }); + reg.definePartialInstance(ISessionMetadata, { + read: async () => ({ id: 'test-session', createdAt: 0, updatedAt: 0, archived: false }), + update: async () => {}, + }); + reg.definePartialInstance(IEventService, { publish: () => {} }); + reg.definePartialInstance(ISessionContext, { sessionId: 'test-session' }); + reg.defineInstance(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); } }); return { prompt: ix.get(IAgentPromptService), loop, context, fullCompaction, eventBus: ix.get(IEventBus) }; diff --git a/packages/agent-core-v2/test/agent/prompt/submit.test.ts b/packages/agent-core-v2/test/agent/prompt/submit.test.ts new file mode 100644 index 0000000000..c32621fa37 --- /dev/null +++ b/packages/agent-core-v2/test/agent/prompt/submit.test.ts @@ -0,0 +1,101 @@ +/** + * Scenario: `IAgentPromptService.submit` is the wire-facing prompt entry — + * disabledTools gating, prompt-metadata persistence, and `{turn_id}` + * settlement. + * + * Migrated from the kap-server debug-RPC suite (`test/rpc.test.ts`) when the + * RPC aggregation layer was removed: the composition now lives in the prompt + * domain. Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/agent/prompt/submit.test.ts`. + */ + +import { afterEach, describe, expect, it } from 'vitest'; + +import { IEventService } from '#/app/event/event'; +import { ErrorCodes } from '#/errors'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; + +import { createTestAgent, type TestAgentContext } from '../../harness'; + +describe('prompt submit', () => { + let ctx: TestAgentContext; + + afterEach(async () => { + try { + await ctx.expectResumeMatches(); + } finally { + await ctx.dispose(); + } + }); + + it('submits a prompt and returns the turn id', async () => { + ctx = createTestAgent(); + ctx.mockNextResponse({ type: 'text', text: 'hi' }); + + const launched = await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] }); + // Turn ids are 0-based; the point is the launch result came back at all. + expect(launched?.turn_id).toBe(0); + await ctx.untilTurnEnd(); + }); + + it('rejects disabledTools before bind without mutating prompt metadata', async () => { + ctx = createTestAgent(); + + // The default test agent has no profile bound, so the session denylist is + // rejected as a profile error, mapped to `request.invalid`. + await expect( + ctx.rpc.prompt({ + input: [{ type: 'text', text: 'must not become metadata' }], + disabledTools: ['Bash'], + }), + ).rejects.toMatchObject({ code: ErrorCodes.REQUEST_INVALID }); + + const metadata = await ctx.get(ISessionMetadata).read(); + expect(metadata.title).toBeUndefined(); + expect(metadata.lastPrompt).toBeUndefined(); + }); + + it('derives the session title and lastPrompt from the first prompt', async () => { + ctx = createTestAgent(); + ctx.mockNextResponse({ type: 'text', text: 'hi' }); + + const events: { type: string; payload?: unknown }[] = []; + const sub = ctx.get(IEventService).subscribe((event) => events.push(event)); + + const launched = await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello title' }] }); + expect(launched?.turn_id).toBe(0); + sub.dispose(); + + const metadata = await ctx.get(ISessionMetadata).read(); + expect(metadata.title).toBe('hello title'); + expect(metadata.lastPrompt).toBe('hello title'); + + const updated = events.find((event) => event.type === 'session.meta.updated'); + expect(updated).toBeDefined(); + const payload = updated?.payload as + | { title?: string; patch?: { lastPrompt?: string } } + | undefined; + expect(payload?.title).toBe('hello title'); + expect(payload?.patch?.lastPrompt).toBe('hello title'); + + await ctx.untilTurnEnd(); + }); + + it('keeps a custom title and only refreshes lastPrompt on a later prompt', async () => { + ctx = createTestAgent(); + ctx.mockNextResponse({ type: 'text', text: 'hi' }); + + await ctx.get(ISessionMetadata).setTitle('keep-me'); + + const launched = await ctx.rpc.prompt({ + input: [{ type: 'text', text: 'should not become the title' }], + }); + expect(launched?.turn_id).toBe(0); + + const metadata = await ctx.get(ISessionMetadata).read(); + expect(metadata.title).toBe('keep-me'); + expect(metadata.lastPrompt).toBe('should not become the title'); + + await ctx.untilTurnEnd(); + }); +}); diff --git a/packages/agent-core-v2/test/agent/rpc/runShellCommand.test.ts b/packages/agent-core-v2/test/agent/rpc/runShellCommand.test.ts deleted file mode 100644 index afc735e9bf..0000000000 --- a/packages/agent-core-v2/test/agent/rpc/runShellCommand.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; - -import { IAgentContextMemoryService } from '#/index'; - -import { - createCommandRunner, - createTestAgent, - execEnvServices, - type TestAgentContext, -} from '../../harness'; - -describe('runShellCommand RPC', () => { - let ctx: TestAgentContext; - - afterEach(async () => { - try { - await ctx.expectResumeMatches(); - } finally { - await ctx.dispose(); - } - }); - - it('delegates to the shell command service', async () => { - ctx = createTestAgent(execEnvServices({ processRunner: createCommandRunner('ok\n', 0) })); - const context = ctx.get(IAgentContextMemoryService); - - const result = await ctx.rpc.runShellCommand({ command: 'echo ok' }); - - expect(result.isError).toBe(false); - expect(context.get().map(({ role, origin }) => ({ role, origin }))).toEqual([ - { role: 'user', origin: { kind: 'shell_command', phase: 'input' } }, - { role: 'user', origin: { kind: 'shell_command', phase: 'output' } }, - ]); - }); -}); diff --git a/packages/agent-core-v2/test/agent/rpc/undoHistory.test.ts b/packages/agent-core-v2/test/agent/rpc/undoHistory.test.ts deleted file mode 100644 index 3c586f7b2b..0000000000 --- a/packages/agent-core-v2/test/agent/rpc/undoHistory.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; - -import { ErrorCodes } from '#/errors'; - -import { - createTestAgent, - telemetryServices, - type TestAgentContext, -} from '../../harness'; -import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; - -describe('undoHistory RPC', () => { - let ctx: TestAgentContext; - let records: TelemetryRecord[]; - - afterEach(async () => { - try { - await ctx.expectResumeMatches(); - } finally { - await ctx.dispose(); - } - }); - - it('tracks conversation_undo after undoing history', async () => { - records = []; - ctx = createTestAgent(telemetryServices(recordingTelemetry(records))); - ctx.appendUserTurn('undo me'); - - const undone = await ctx.rpc.undoHistory({ count: 1 }); - - expect(undone).toBe(1); - expect(records).toContainEqual({ - event: 'conversation_undo', - properties: { agent_id: 'main', count: 1 }, - }); - }); - - it('rejects a fractional count without changing persisted history', async () => { - records = []; - ctx = createTestAgent(telemetryServices(recordingTelemetry(records))); - ctx.appendUserTurn('keep me'); - const history = ctx.context.get(); - - await expect(ctx.rpc.undoHistory({ count: 0.5 })).rejects.toMatchObject({ - code: ErrorCodes.REQUEST_INVALID, - details: { field: 'count' }, - }); - - expect(ctx.context.get()).toBe(history); - expect(records).not.toContainEqual(expect.objectContaining({ event: 'conversation_undo' })); - }); -}); diff --git a/packages/agent-core-v2/test/agent/rpc/activateSkill.test.ts b/packages/agent-core-v2/test/agent/skill/activateSkill.test.ts similarity index 75% rename from packages/agent-core-v2/test/agent/rpc/activateSkill.test.ts rename to packages/agent-core-v2/test/agent/skill/activateSkill.test.ts index 5fabb7c22e..179ff4da45 100644 --- a/packages/agent-core-v2/test/agent/rpc/activateSkill.test.ts +++ b/packages/agent-core-v2/test/agent/skill/activateSkill.test.ts @@ -1,12 +1,11 @@ /** - * Scenario: `AgentRPCService.activateSkill` is the wire-facing skill - * activation entry — awaited, returning the launched turn id. + * Scenario: `IAgentSkillService.activate` is the wire-facing skill activation + * entry — awaited, returning the launched turn id. * - * Unlike `IAgentSkillService.activate` (in-process, returns the live `Turn` - * handle), the RPC variant must settle only once the turn has launched and - * must surface activation failures (unknown skill, busy agent) to the caller - * instead of fire-and-forget. Run: `pnpm --filter @moonshot-ai/agent-core-v2 - * exec vitest run test/agent/rpc/activateSkill.test.ts`. + * The activation settles only once the turn has launched, and activation + * failures (unknown skill, busy agent) surface to the caller instead of + * fire-and-forget. Run: `pnpm --filter @moonshot-ai/agent-core-v2 + * exec vitest run test/agent/skill/activateSkill.test.ts`. */ import { afterEach, describe, expect, it } from 'vitest'; @@ -16,7 +15,7 @@ import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import { stubSkill } from '../../app/skillCatalog/stubs'; import { createTestAgent, skillServices, type TestAgentContext } from '../../harness'; -describe('activateSkill RPC', () => { +describe('activateSkill', () => { let ctx: TestAgentContext; afterEach(async () => { diff --git a/packages/agent-core-v2/test/agent/skill/skill.test.ts b/packages/agent-core-v2/test/agent/skill/skill.test.ts index 7621059234..0e8efa2a5a 100644 --- a/packages/agent-core-v2/test/agent/skill/skill.test.ts +++ b/packages/agent-core-v2/test/agent/skill/skill.test.ts @@ -11,6 +11,8 @@ import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import { summarizeSkill } from '#/app/skillCatalog/types'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { IEventService } from '#/app/event/event'; import { AgentSkillService } from '#/agent/skill/skillService'; import { MAX_SKILL_QUERY_DEPTH, @@ -77,6 +79,11 @@ describe('AgentSkillService', () => { reg.definePartialInstance(IAgentToolRegistryService, { register: () => ({ dispose: () => {} }), }); + reg.definePartialInstance(ISessionMetadata, { + read: async () => ({ id: 'test-session', createdAt: 0, updatedAt: 0, archived: false }), + update: async () => {}, + }); + reg.definePartialInstance(IEventService, { publish: () => {} }); reg.defineInstance(ISessionContext, stubSessionContext()); reg.defineInstance(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); }, @@ -171,6 +178,11 @@ describe('SkillTool', () => { reg.definePartialInstance(IAgentToolRegistryService, { register: () => ({ dispose: () => {} }), }); + reg.definePartialInstance(ISessionMetadata, { + read: async () => ({ id: 'test-session', createdAt: 0, updatedAt: 0, archived: false }), + update: async () => {}, + }); + reg.definePartialInstance(IEventService, { publish: () => {} }); reg.defineInstance(ISessionContext, stubSessionContext()); reg.defineInstance(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); }, diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index d3009bfb0d..41769b9ed6 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -214,6 +214,8 @@ class FakeLoopService implements IAgentLoopService { onDidFinishStep: new OrderedHookSlot(), }; + cancelFromUser(): void {} + enqueue(_request: StepRequest, _options?: StepEnqueueOptions): EnqueueReceipt { throw new Error('unused in this suite'); } diff --git a/packages/agent-core-v2/test/app/gateway/gateway.test.ts b/packages/agent-core-v2/test/app/gateway/gateway.test.ts index 373beb8be5..606fb18352 100644 --- a/packages/agent-core-v2/test/app/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/app/gateway/gateway.test.ts @@ -53,6 +53,8 @@ describe('RestGateway', () => { const promptService: IAgentPromptService = { _serviceBrand: undefined, enqueue: ({ message }: { message: ContextMessage }) => { promptCalls.push(message); return Promise.resolve({ id: 'p', launched: Promise.resolve(undefined) } as never); }, + submit: () => Promise.resolve(undefined), + submitSteer: () => Promise.resolve(undefined), steer: () => Promise.resolve([]), list: () => ({ active: undefined, pending: [] }), abort: () => true, diff --git a/packages/agent-core-v2/test/features/plan/tools/exit-plan-mode.test.ts b/packages/agent-core-v2/test/features/plan/tools/exit-plan-mode.test.ts index 5f8497795d..b80f0a30df 100644 --- a/packages/agent-core-v2/test/features/plan/tools/exit-plan-mode.test.ts +++ b/packages/agent-core-v2/test/features/plan/tools/exit-plan-mode.test.ts @@ -58,6 +58,7 @@ function permissionMode(mode: PermissionMode = 'auto'): IAgentPermissionModeServ _serviceBrand: undefined, mode, setMode: () => {}, + setModeAndBroadcast: () => {}, onDidChangeMode: () => ({ dispose: () => {} }), }; } diff --git a/packages/agent-core-v2/test/features/plan/tools/plan-tools-telemetry.test.ts b/packages/agent-core-v2/test/features/plan/tools/plan-tools-telemetry.test.ts index 3c7741d7cf..1e86aa435a 100644 --- a/packages/agent-core-v2/test/features/plan/tools/plan-tools-telemetry.test.ts +++ b/packages/agent-core-v2/test/features/plan/tools/plan-tools-telemetry.test.ts @@ -64,6 +64,7 @@ function permissionMode(): IAgentPermissionModeService { _serviceBrand: undefined, mode: 'auto', setMode: () => {}, + setModeAndBroadcast: () => {}, onDidChangeMode: () => ({ dispose: () => {} }), }; } diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 96c26962e2..1c61ddad75 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -41,30 +41,51 @@ import { IAgentProfileService, type AgentConfigData } from '#/agent/profile/prof import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import type { - AgentAPI, - BeginCompactionPayload, - CancelPlanPayload, - CancelShellCommandPayload, - CreateGoalPayload, - DetachTaskPayload, - EmptyPayload, - EnterSwarmPayload, - GetTaskOutputPayload, - GetTasksPayload, - GoalSnapshot, - GoalToolResult, - RegisterToolPayload, - RunShellCommandPayload, - SetActiveToolsPayload, - SetModelPayload, - SetModelResult, - SetThinkingPayload, - ShellCommandResult, - StopTaskPayload, - UnregisterToolPayload, -} from '#/agent/rpc/core-api'; + PromptLaunchResult, + PromptPayload, + SteerPayload, +} from '#/agent/prompt/prompt'; +import type { AgentCommandInfo } from '#/agent/command/agentCommand'; +import { IAgentCommandService } from '#/agent/command/agentCommand'; +import type { AgentContextData } from '#/agent/contextMemory/types'; +import type { CreateGoalInput, GoalSnapshot, GoalToolResult } from '#/agent/goal/types'; +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 { UserToolRegistration } from '#/agent/userTool/userTool'; +import type { ActivatePluginCommandPayload } from '#/agent/pluginCommand/pluginCommand'; +import { IAgentPluginCommandService } from '#/agent/pluginCommand/pluginCommand'; +import type { ToolInfo } from '#/tool/toolContract'; + +// Test-facing wire vocabulary, formerly imported from the deleted RPC +// aggregation layer; payloads with an owner-domain type are aliased above, +// the rest are local to the harness. +type EmptyPayload = {}; +type CreateGoalPayload = CreateGoalInput; +type RegisterToolPayload = UserToolRegistration; +type RunShellCommandPayload = RunShellCommandInput; +type ShellCommandResult = RunShellCommandResult; +type SetModelResult = ProfileSetModelResult; +interface BeginCompactionPayload { readonly instruction?: string } +interface CancelPayload { readonly turnId?: number } +interface CancelPlanPayload { readonly id?: string } +interface CancelShellCommandPayload { readonly commandId: string } +interface DetachTaskPayload { readonly taskId: string } +interface EnterSwarmPayload { readonly trigger: SwarmModeTrigger } +interface GetTaskOutputPayload { readonly taskId: string; readonly tail?: number } +interface GetTasksPayload { readonly activeOnly?: boolean; readonly limit?: number } +interface RunCommandPayload { readonly name: string; readonly args?: string } +interface SetActiveToolsPayload { readonly names: readonly string[] } +interface SetModelPayload { readonly model: string } +interface SetPermissionPayload { readonly mode: PermissionMode } +interface SetThinkingPayload { readonly level: string } +interface StopTaskPayload { readonly taskId: string; readonly reason?: string } +interface UndoHistoryPayload { readonly count: number } +interface UnregisterToolPayload { readonly name: string } import { type UsageStatus } from '#/agent/usage/usage'; -import { IAgentSkillService } from '#/agent/skill/skill'; +import { IAgentSkillService, type SkillActivationInput } from '#/agent/skill/skill'; import { AgentSkillService } from '#/agent/skill/skillService'; import { IAgentToolDedupeService } from '#/agent/toolDedupe/toolDedupe'; import type { @@ -94,7 +115,6 @@ import { InMemoryStorageService, AgentFullCompactionService, IAgentActivityView, - IAgentRPCService, IAppendLogStore, IFileSystemStorageService, ISessionApprovalService, @@ -309,6 +329,18 @@ type RpcPromise = Promise & { }; interface AgentRpcPassthroughAPI { + prompt: (payload: PromptPayload) => Promisable; + steer: (payload: SteerPayload) => Promisable; + cancel: (payload: CancelPayload) => void; + undoHistory: (payload: UndoHistoryPayload) => Promisable; + setPermission: (payload: SetPermissionPayload) => void; + cancelCompaction: (payload: EmptyPayload) => void; + activateSkill: (payload: SkillActivationInput) => Promisable; + activatePluginCommand: (payload: ActivatePluginCommandPayload) => Promisable; + listCommands: (payload: EmptyPayload) => readonly AgentCommandInfo[]; + runCommand: (payload: RunCommandPayload) => Promisable; + getContext: (payload: EmptyPayload) => AgentContextData; + getTools: (payload: EmptyPayload) => readonly ToolInfo[]; runShellCommand: (payload: RunShellCommandPayload) => Promisable; cancelShellCommand: (payload: CancelShellCommandPayload) => void; setThinking: (payload: SetThinkingPayload) => void; @@ -341,7 +373,7 @@ interface AgentRpcPassthroughAPI { getTasks: (payload: GetTasksPayload) => readonly AgentTaskInfo[]; } -type PromiseAgentAPI = PromisifyMethods; +type PromiseAgentAPI = PromisifyMethods; type GenerateFn = typeof kosongGenerate; type TestToolResult = ExecutableToolResult & { @@ -1297,8 +1329,7 @@ export class AgentTestContext { }), ); - const rpcMethods = this.get(IAgentRPCService); - this.rpc = this.createPromiseAgentApi(rpcMethods); + this.rpc = this.createPromiseAgentApi(); if (options.autoConfigure !== false) { this.configure(); @@ -1522,8 +1553,7 @@ export class AgentTestContext { } async undoHistory(count: number): Promise { - const rpcMethods = this.get(IAgentRPCService); - return rpcMethods.undoHistory({ count }); + return this.get(IAgentConversationUndoService).undo(count); } newEvents(): EventSnapshot { @@ -2024,16 +2054,15 @@ export class AgentTestContext { this.recordWire(cloned); } - private createPromiseAgentApi(agent: IAgentRPCService): PromiseAgentAPI { - const passthrough = this.createRpcPassthroughAdapters(); - return new Proxy(agent, { + private createPromiseAgentApi(): PromiseAgentAPI { + const adapters = this.createRpcPassthroughAdapters(); + return new Proxy(adapters, { get(proxyTarget, property, receiver) { - const override = Reflect.get(passthrough, property) as unknown; - const value = override ?? Reflect.get(proxyTarget, property, receiver); + const value = Reflect.get(proxyTarget, property, receiver) as unknown; if (typeof value !== 'function') return value; return (payload: unknown) => { try { - return Promise.resolve(value.call(proxyTarget, payload)); + return Promise.resolve(value(payload)); } catch (error) { return Promise.reject(error); } @@ -2044,6 +2073,23 @@ export class AgentTestContext { private createRpcPassthroughAdapters(): AgentRpcPassthroughAPI { return { + prompt: (payload) => this.get(IAgentPromptService).submit(payload), + steer: (payload) => this.get(IAgentPromptService).submitSteer(payload), + cancel: (payload) => this.get(IAgentLoopService).cancelFromUser(payload.turnId), + undoHistory: (payload) => this.get(IAgentConversationUndoService).undo(payload.count), + setPermission: (payload) => + this.get(IAgentPermissionModeService).setModeAndBroadcast(payload.mode), + cancelCompaction: () => this.get(IAgentFullCompactionService).cancel(), + activateSkill: (payload) => this.get(IAgentSkillService).activate(payload), + activatePluginCommand: (payload) => + this.get(IAgentPluginCommandService).activate(payload), + listCommands: () => this.get(IAgentCommandService).list(), + runCommand: (payload) => this.get(IAgentCommandService).run(payload.name, payload.args), + getContext: () => ({ + history: this.get(IAgentContextMemoryService).get(), + tokenCount: this.get(IAgentTokenCountingService).statusSize(), + }), + getTools: () => this.toolsData(), runShellCommand: (payload) => this.get(IAgentShellCommandService).run(payload), cancelShellCommand: (payload) => this.get(IAgentShellCommandService).cancel(payload.commandId), @@ -2175,7 +2221,7 @@ function createWorkspaceContextStub( function createPermissionModeService(initialMode: PermissionMode): IAgentPermissionModeService { let mode = initialMode; - return { + const service: IAgentPermissionModeService = { _serviceBrand: undefined, get mode() { return mode; @@ -2183,8 +2229,12 @@ function createPermissionModeService(initialMode: PermissionMode): IAgentPermiss setMode: (nextMode) => { mode = nextMode; }, + setModeAndBroadcast: (nextMode) => { + service.setMode(nextMode); + }, onDidChangeMode: Event.None as IAgentPermissionModeService['onDidChangeMode'], }; + return service; } function createPermissionRulesStub( diff --git a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts index 1a28612dac..539d47f84b 100644 --- a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts @@ -1429,6 +1429,7 @@ function agentHandle( _serviceBrand: undefined, mode: 'auto', setMode: () => {}, + setModeAndBroadcast: () => {}, onDidChangeMode: Event.None, } as IAgentPermissionModeService; return { diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index a99558cfb7..5080dff69b 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -62,7 +62,7 @@ import type { import type { McpOAuthAuthorizationUrlUpdateData } from '@moonshot-ai/agent-core-v2/agent/mcp/tools/auth'; import type { PermissionMode } from '@moonshot-ai/agent-core-v2/agent/permissionPolicy/types'; import type { WarningEvent } from '@moonshot-ai/agent-core-v2/agent/profile/profileService'; -import type { PluginCommandActivatedEvent } from '@moonshot-ai/agent-core-v2/agent/rpc/rpcService'; +import type { PluginCommandActivatedEvent } from '@moonshot-ai/agent-core-v2/agent/pluginCommand/pluginCommand'; import type { ShellCompletedEvent, ShellOutputEvent, diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index e6cdd0c435..937852f09a 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -21,7 +21,7 @@ * the native v2 services directly (the workspace handler's * `ISessionLifecycleService.fork` / `archive` / `restore`, reached through the * `sessionIndex` → `IWorkspaceLifecycleService.handlerFor` composition, - * `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`); there is no + * `IAgentFullCompactionService.begin`, `IAgentLoopService.cancelFromUser`); there is no * v1-only projection to centralize, so no adapter is involved. `undo` likewise * calls `IAgentConversationUndoService.undo` directly (it throws * `session.undo_unavailable` with a structured reason) and only borrows @@ -81,7 +81,7 @@ import { IAgentProfileService, IAgentConversationUndoService, IAgentFullCompactionService, - IAgentRPCService, + IAgentLoopService, IAuthSummaryService, ISessionActivityView, ISessionBtwService, @@ -773,7 +773,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void const agent = await resolveMainAgent(core, parsed.id); // No turnId → cancel whatever turn is active; a safe no-op when idle. // v1 always reports success once the session exists. - await agent.accessor.get(IAgentRPCService).cancel({}); + agent.accessor.get(IAgentLoopService).cancelFromUser(); requestLog(req)?.info({ session_id: parsed.id, action: 'abort' }, 'session action completed'); reply.send(okEnvelope({ aborted: true }, req.id)); return; diff --git a/packages/kap-server/src/routes/skills.ts b/packages/kap-server/src/routes/skills.ts index f2f56de965..987125ccde 100644 --- a/packages/kap-server/src/routes/skills.ts +++ b/packages/kap-server/src/routes/skills.ts @@ -40,12 +40,12 @@ * for the root, then composes the skill scan at the edge (see above). * - activate → `IAgentSkillService` (Agent scope, on the `main` agent) — * renders the skill prompt and starts a turn with a - * `skill_activation` origin. The returned `Turn` handle is + * `skill_activation` origin. The returned `{turn_id}` is * discarded; clients follow progress via the `skill.activated` * + `turn.*` events emitted by the service on the WS stream. - * The edge then applies the prompt-metadata update - * (`applyPromptMetadataUpdate`) so a first `/` - * message titles the session, matching the native RPC path. + * The engine applies the prompt-metadata update itself + * (main agent only) so a first `/` + * message titles the session, matching the native prompt path. * Optional `attachments` (image/video/file parts, same wire * shape as prompt content) run through the shared prompt * media pipeline (`lib/promptMedia.ts`) and are appended to @@ -84,12 +84,10 @@ import { IAgentSkillService, IBootstrapService, IConfigService, - IEventService, IFileService, IPluginService, ISessionContext, ISessionIndex, - ISessionMetadata, ISessionSkillCatalog, ISkillDiscovery, ITelemetryService, @@ -100,10 +98,8 @@ import { resumeSessionById, MERGE_ALL_AVAILABLE_SKILLS_SECTION, SKILL_SOURCE_PRIORITY, - applyPromptMetadataUpdate, configuredRoots, projectRoots, - promptMetadataTextFromSkill, sessionMediaOriginalsDir, userRoots, type ContentPart, @@ -347,19 +343,12 @@ export function registerSkillsRoutes(app: SkillsRouteHost, core: Scope): void { attachmentParts.push(...contentToCoreParts(resolvedContent)); } const agent = await ensureMainAgent(resolved.handle); + // The engine applies the prompt-metadata update itself (main agent + // only), so a first `/` message titles the session (same as + // routes/prompts.ts). await agent.accessor .get(IAgentSkillService) .activate({ name: parsed.id, args: req.body.args, content: attachmentParts }); - // Keep the easy-title behavior of the native RPC / TUI path: a first - // `/` message titles the session (same as routes/prompts.ts). - await applyPromptMetadataUpdate( - { - metadata: resolved.handle.accessor.get(ISessionMetadata), - eventService: core.accessor.get(IEventService), - sessionId: session_id, - }, - promptMetadataTextFromSkill({ name: parsed.id, args: req.body.args }), - ); requestLog(req)?.info({ session_id, skill_name: parsed.id }, 'skill activated'); reply.send(okEnvelope({ activated: true, skill_name: parsed.id }, req.id)); } catch (err) { diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index a54b21b158..9da48be894 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -60,7 +60,7 @@ * `agent/task/taskOps.ts`, `agent/shellCommand/shellCommandService.ts`, * `session/agentLifecycle/mirrorAgentRun.ts`, `session/swarm/sessionSwarmService.ts`, * `agent/goal/goalOps.ts`, `agent/usage/usageOps.ts`, `agent/skill/skillOps.ts`, - * `agent/rpc/rpcService.ts`, `session/cron/cronOps.ts`, + * `agent/pluginCommand/pluginCommandService.ts`, `session/cron/cronOps.ts`, * `agent/fullCompaction/compactionOps.ts`, `agent/mcp/mcpService.ts`, * `agent/profile/profileService.ts`, `agent/contextMemory/contextMemoryService.ts`). */ diff --git a/packages/kap-server/test/rpc.test.ts b/packages/kap-server/test/rpc.test.ts index b0bbb7ab93..ce4b039836 100644 --- a/packages/kap-server/test/rpc.test.ts +++ b/packages/kap-server/test/rpc.test.ts @@ -6,11 +6,11 @@ import { IAgentActivityView, IAgentGoalService, IAgentLifecycleService, - IAgentRPCService, + IAgentPluginCommandService, + IAgentPromptService, IAgentShellCommandService, IAppendLogStore, IDebugEventsService, - IEventService, IInstantiationService, IPluginService, ISessionIndex, @@ -169,7 +169,7 @@ describe('server-v2 /api/v1/debug RPC', () => { const byName = new Map(body.data.map((c) => [c.name, c])); expect(byName.get('sessionIndex')?.scope).toBe('app'); expect(byName.get('sessionMetadata')?.scope).toBe('session'); - expect(byName.get('agentRPCService')?.scope).toBe('agent'); + expect(byName.get('agentPromptService')?.scope).toBe('agent'); const meta = byName.get('sessionMetadata'); expect(meta?.methods.map((m) => m.name)).toEqual( @@ -339,92 +339,6 @@ describe('server-v2 /api/v1/debug RPC', () => { // --- Agent scope ---------------------------------------------------------- - it('submits a prompt and returns the turn id', async () => { - const id = await createSession(home as string); - await createMainAgent(id); - - const { body } = await call<{ turn_id: number }>( - 'POST', - rpc('agent', IAgentRPCService, 'prompt', { sid: id, aid: 'main' }), - { input: [{ type: 'text', text: 'hello' }] }, - ); - expect(body.code).toBe(0); - expect(body.data.turn_id).toBe(0); - }); - - it('rejects disabledTools before bind without mutating prompt metadata', async () => { - const id = await createSession(home as string); - await createMainAgent(id); - - const { body } = await call( - 'POST', - rpc('agent', IAgentRPCService, 'prompt', { sid: id, aid: 'main' }), - { - input: [{ type: 'text', text: 'must not become metadata' }], - disabledTools: ['Bash'], - }, - ); - expect(body.code).toBe(40001); - - const metadata = await call( - 'POST', - rpc('session', ISessionMetadata, 'read', { sid: id }), - ); - expect(metadata.body.data.title).toBeUndefined(); - expect(metadata.body.data.lastPrompt).toBeUndefined(); - }); - - it('derives the session title and lastPrompt from the first prompt', async () => { - const id = await createSession(home as string); - await createMainAgent(id); - - const events: { type: string; payload: unknown }[] = []; - const sub = (server as RunningServer).core.accessor - .get(IEventService) - .subscribe((event) => events.push(event)); - - const { body } = await call<{ turn_id: number }>( - 'POST', - rpc('agent', IAgentRPCService, 'prompt', { sid: id, aid: 'main' }), - { input: [{ type: 'text', text: 'hello title' }] }, - ); - expect(body.code).toBe(0); - sub.dispose(); - - const meta = await call('POST', rpc('session', ISessionMetadata, 'read', { sid: id })); - expect(meta.body.code).toBe(0); - expect(meta.body.data.title).toBe('hello title'); - expect(meta.body.data.lastPrompt).toBe('hello title'); - - const updated = events.find((e) => e.type === 'session.meta.updated'); - expect(updated).toBeDefined(); - const payload = updated?.payload as - | { title?: string; patch?: { lastPrompt?: string } } - | undefined; - expect(payload?.title).toBe('hello title'); - expect(payload?.patch?.lastPrompt).toBe('hello title'); - }); - - it('keeps a custom title and only refreshes lastPrompt on a later prompt', async () => { - const id = await createSession(home as string); - await createMainAgent(id); - - const renamed = await call('POST', rpc('session', ISessionMetadata, 'setTitle', { sid: id }), 'keep-me'); - expect(renamed.body.code).toBe(0); - - const { body } = await call<{ turn_id: number }>( - 'POST', - rpc('agent', IAgentRPCService, 'prompt', { sid: id, aid: 'main' }), - { input: [{ type: 'text', text: 'should not become the title' }] }, - ); - expect(body.code).toBe(0); - - const meta = await call('POST', rpc('session', ISessionMetadata, 'read', { sid: id })); - expect(meta.body.code).toBe(0); - expect(meta.body.data.title).toBe('keep-me'); - expect(meta.body.data.lastPrompt).toBe('should not become the title'); - }); - it('runs a shell command through the shell command service', async () => { const id = await createSession(home as string); await createMainAgent(id); @@ -562,7 +476,7 @@ describe('server-v2 /api/v1/debug RPC', () => { await createMainAgent(sessionId); const activated = await call( 'POST', - rpc('agent', IAgentRPCService, 'activatePluginCommand', { sid: sessionId, aid: 'main' }), + rpc('agent', IAgentPluginCommandService, 'activate', { sid: sessionId, aid: 'main' }), { pluginId: 'rpc-plugin', commandName: 'deploy', args: 'prod' }, ); expect(activated.body.code).toBe(0); @@ -575,7 +489,7 @@ describe('server-v2 /api/v1/debug RPC', () => { const id = await createSession(home as string); const { body } = await call( 'POST', - rpc('agent', IAgentRPCService, 'prompt', { sid: id, aid: 'does-not-exist' }), + rpc('agent', IAgentPromptService, 'submit', { sid: id, aid: 'does-not-exist' }), { input: [{ type: 'text', text: 'hello' }] }, ); expect(body.code).toBe(40401); diff --git a/packages/klient/src/contract/agent/rpc.ts b/packages/klient/src/contract/agent/schemas.ts similarity index 73% rename from packages/klient/src/contract/agent/rpc.ts rename to packages/klient/src/contract/agent/schemas.ts index 1186c694a4..38f0cebe0b 100644 --- a/packages/klient/src/contract/agent/rpc.ts +++ b/packages/klient/src/contract/agent/schemas.ts @@ -1,20 +1,14 @@ /** - * `agentRPCService` — the per-agent RPC surface. Mirrors the `AgentAPI` - * subset of `agent-core-v2/agent/rpc/core-api.ts`; every method takes one - * payload object. Only the methods still implemented by the engine's RPC - * facade live here — the domain services the facade calls directly - * (shellCommand / profile / usage / plan / task) have their own contracts in - * `agent/services.ts`, reusing the payload/result schemas below. - * `PromptPayload.input` mirrors the `PromptPart` subset of `ContentPart` - * (text / image_url / video_url) from `agent-core-v2/kosong/contract/message.ts`. - * Task wire shapes mirror the `TaskInfo` union in `protocol/src/events.ts`. + * Shared agent-scope wire schemas — the payload/result vocabulary reused by + * the per-domain contracts in `agent/services.ts` and pinned against the + * engine types by `test/contract-parity.ts`. `PromptPayload.input` mirrors the + * `PromptPart` subset of `ContentPart` (text / image_url / video_url) from + * `agent-core-v2/kosong/contract/message.ts`. Task wire shapes mirror the + * `TaskInfo` union in `protocol/src/events.ts`. */ import { z } from 'zod'; -import { maybe, noResult } from '../helpers.js'; -import type { ServiceContract } from '../types.js'; - // ── prompt parts ──────────────────────────────────────────────────────────── const textPartSchema = z.object({ @@ -55,7 +49,7 @@ export const steerPayloadSchema = z.object({ input: z.array(promptPartSchema), }); -/** Same shape as `ActivateSkillPayload` in the engine. */ +/** Same shape as `SkillActivationInput`'s wire subset in the engine. */ export const activateSkillPayloadSchema = z.object({ name: z.string(), args: z.string().optional(), @@ -129,7 +123,7 @@ export const agentCommandInfoSchema = z.object({ source: z.string(), }); -/** Same shape as `RunCommandPayload` in the engine. */ +/** The facade's `runCommand` input shape. */ export const runCommandPayloadSchema = z.object({ name: z.string(), args: z.string().optional(), @@ -209,22 +203,3 @@ export const getTaskOutputPayloadSchema = z.object({ taskId: z.string(), tail: z.number().optional(), }); - -// ── contract ──────────────────────────────────────────────────────────────── - -export const agentRpcContract = { - prompt: { input: z.tuple([promptPayloadSchema]), output: maybe(promptLaunchResultSchema) }, - steer: { input: z.tuple([steerPayloadSchema]), output: maybe(promptLaunchResultSchema) }, - activateSkill: { - input: z.tuple([activateSkillPayloadSchema]), - output: maybe(promptLaunchResultSchema), - }, - cancel: { input: z.tuple([cancelPayloadSchema]), output: noResult }, - setPermission: { input: z.tuple([setPermissionPayloadSchema]), output: noResult }, - getContext: { input: z.tuple([emptyPayloadSchema]), output: agentContextDataSchema }, - listCommands: { - input: z.tuple([emptyPayloadSchema]), - output: z.array(agentCommandInfoSchema), - }, - runCommand: { input: z.tuple([runCommandPayloadSchema]), output: noResult }, -} satisfies ServiceContract; diff --git a/packages/klient/src/contract/agent/services.ts b/packages/klient/src/contract/agent/services.ts index f7e6526c74..26483306ed 100644 --- a/packages/klient/src/contract/agent/services.ts +++ b/packages/klient/src/contract/agent/services.ts @@ -1,8 +1,9 @@ /** - * Agent-scope domain service contracts. These mirror the positional-arg - * signatures of the engine's domain Services (shellCommand / profile / usage / - * plan / task) that the agent facade calls directly; payload and result - * schemas are shared with `agent/rpc.ts` (they mirror the same wire shapes). + * Agent-scope domain service contracts. These mirror the signatures of the + * engine's domain Services (prompt / skill / loop / permissionMode / command / + * contextMemory / tokenCounting / shellCommand / profile / usage / plan / + * task) that the agent facade calls directly; payload and result schemas are + * shared in `agent/schemas.ts` (they mirror the same wire shapes). */ import { z } from 'zod'; @@ -10,13 +11,56 @@ import { z } from 'zod'; import { maybe, noResult } from '../helpers.js'; import type { ServiceContract } from '../types.js'; import { + activateSkillPayloadSchema, + agentCommandInfoSchema, agentTaskInfoSchema, + permissionModeSchema, planDataSchema, + promptLaunchResultSchema, + promptPayloadSchema, runShellCommandPayloadSchema, setModelResultSchema, shellCommandResultSchema, + steerPayloadSchema, usageStatusSchema, -} from './rpc.js'; +} from './schemas.js'; + +export const agentPromptContract = { + submit: { + input: z.tuple([promptPayloadSchema]), + output: maybe(promptLaunchResultSchema), + }, + submitSteer: { + input: z.tuple([steerPayloadSchema]), + output: maybe(promptLaunchResultSchema), + }, +} satisfies ServiceContract; + +export const agentSkillContract = { + activate: { input: z.tuple([activateSkillPayloadSchema]), output: promptLaunchResultSchema }, +} satisfies ServiceContract; + +export const agentLoopContract = { + cancelFromUser: { input: z.tuple([z.number().optional()]), output: noResult }, +} satisfies ServiceContract; + +export const agentPermissionModeContract = { + setModeAndBroadcast: { input: z.tuple([permissionModeSchema]), output: noResult }, +} satisfies ServiceContract; + +export const agentCommandContract = { + list: { input: z.tuple([]), output: z.array(agentCommandInfoSchema) }, + run: { input: z.tuple([z.string(), z.string().optional()]), output: noResult }, +} satisfies ServiceContract; + +/** `history` items are full `ContextMessage`s, mirrored as `unknown`. */ +export const agentContextMemoryContract = { + get: { input: z.tuple([]), output: z.array(z.unknown()) }, +} satisfies ServiceContract; + +export const agentTokenCountingContract = { + statusSize: { input: z.tuple([]), output: z.number() }, +} satisfies ServiceContract; export const agentShellCommandContract = { run: { diff --git a/packages/klient/src/contract/global/events.ts b/packages/klient/src/contract/global/events.ts index 599a9e96fd..126d281ef8 100644 --- a/packages/klient/src/contract/global/events.ts +++ b/packages/klient/src/contract/global/events.ts @@ -22,7 +22,7 @@ export interface SessionArchivedPayload { readonly sessionId: string; } -/** Payload of `session.meta.updated` on the global bus (`agent/rpc/prompt-metadata.ts`). */ +/** Payload of `session.meta.updated` on the global bus (`session/sessionMetadata/promptMetadata.ts`). */ export interface SessionMetaUpdatedPayload { readonly agentId: string; readonly sessionId: string; diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts index 6f9ef48efa..510f761c4a 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -8,14 +8,20 @@ import type { KlientContract } from './types.js'; import { agentActivityViewContract } from './agent/activity.js'; -import { agentRpcContract } from './agent/rpc.js'; import { + agentCommandContract, + agentContextMemoryContract, agentFullCompactionContract, + agentLoopContract, agentMcpContract, + agentPermissionModeContract, agentPlanContract, agentProfileContract, + agentPromptContract, agentShellCommandContract, + agentSkillContract, agentTaskContract, + agentTokenCountingContract, agentUsageContract, } from './agent/services.js'; import { authContract, authSummaryContract } from './global/auth.js'; @@ -67,7 +73,13 @@ export const globalContract: KlientContract = { sessionQuestionService: sessionQuestionContract, sessionSkillCatalog: sessionSkillCatalogContract, // agent scope - agentRPCService: agentRpcContract, + agentPromptService: agentPromptContract, + agentSkillService: agentSkillContract, + agentLoopService: agentLoopContract, + agentPermissionModeService: agentPermissionModeContract, + agentCommandService: agentCommandContract, + agentContextMemoryService: agentContextMemoryContract, + agentTokenCountingService: agentTokenCountingContract, agentActivityView: agentActivityViewContract, agentShellCommandService: agentShellCommandContract, agentProfileService: agentProfileContract, diff --git a/packages/klient/src/core/facade/agent.ts b/packages/klient/src/core/facade/agent.ts index 145e5fbaf9..a836ceadc4 100644 --- a/packages/klient/src/core/facade/agent.ts +++ b/packages/klient/src/core/facade/agent.ts @@ -1,15 +1,18 @@ /** * The agent facade — one `session.agent(id)` handle over the agent-scope - * services the wire exposes. Turn-driving calls (prompt / steer / cancel) go - * through the `agentRPCService` channel; shell commands, model, usage, plan, - * and task calls go straight to their domain services. Prompt streaming is + * services the wire exposes. Turn-driving calls (prompt / steer / cancel), + * skill activation, permission mode, and commands go straight to their domain + * services, as do shell commands, model, usage, plan, and task calls; + * `getContext` merges two reads client-side. Prompt streaming is * NOT on this interface: it flows through the agent's `events` hub * (`turn.*`, `assistant.delta`, `tool.call.*`, `prompt.completed`, …). */ -import type { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; import type { IAgentCommandService } from '@moonshot-ai/agent-core-v2/agent/command/agentCommand'; +import type { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory'; import type { IAgentMcpService } from '@moonshot-ai/agent-core-v2/agent/mcp/mcp'; +import type { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/prompt/prompt'; +import type { IAgentTokenCountingService } from '@moonshot-ai/agent-core-v2/agent/tokenCounting/tokenCounting'; import type { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import type { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import type { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; @@ -23,12 +26,15 @@ import type { ScopedCaller } from './session.js'; // Wire-type aliases derived through the engine service interfaces (keeps // klient free of protocol-package imports). -export type PromptLaunchResult = Awaited>; +export type PromptLaunchResult = Awaited>; export type ShellCommandResult = Awaited>; export type SetModelResult = Awaited>; export type ThinkingLevel = ReturnType; export type UsageStatus = Awaited>; -export type AgentContextData = Awaited>; +export type AgentContextData = { + history: ReturnType; + tokenCount: ReturnType; +}; export type AgentCommandInfo = Awaited>[number]; export type PlanData = Awaited>; export type AgentTaskInfo = Awaited>[number]; @@ -81,14 +87,17 @@ export interface AgentFacade { } export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFacade { - const rpc = (method: string, payload: unknown): Promise => - call(scope, 'agentRPCService', method, [payload]); - return { - prompt: (input) => rpc('prompt', input) as Promise, - steer: (input) => rpc('steer', input) as Promise, - activateSkill: (input) => rpc('activateSkill', input) as Promise, - cancel: (input) => rpc('cancel', input ?? {}) as Promise, + prompt: (input) => + call(scope, 'agentPromptService', 'submit', [input]) as Promise, + steer: (input) => + call(scope, 'agentPromptService', 'submitSteer', [input]) as Promise, + activateSkill: (input) => + call(scope, 'agentSkillService', 'activate', [input]) as Promise, + cancel: (input) => + // No turnId sends an empty arg list: `[undefined]` would cross the wire + // as `[null]`, and `cancelFromUser(null)` would not match the active turn. + call(scope, 'agentLoopService', 'cancelFromUser', input?.turnId === undefined ? [] : [input.turnId]) as Promise, runShellCommand: (input) => call(scope, 'agentShellCommandService', 'run', [input]) as Promise, cancelShellCommand: (input) => @@ -100,11 +109,27 @@ export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFac call(scope, 'agentProfileService', 'getEffectiveThinkingLevel', []) as Promise, setThinking: (level) => call(scope, 'agentProfileService', 'setThinking', [level]) as Promise, - setPermission: (mode) => rpc('setPermission', { mode }) as Promise, + setPermission: (mode) => + call(scope, 'agentPermissionModeService', 'setModeAndBroadcast', [mode]) as Promise, getUsage: () => call(scope, 'agentUsageService', 'status', []) as Promise, - getContext: () => rpc('getContext', {}) as Promise, - listCommands: () => rpc('listCommands', {}) as Promise, - runCommand: (input) => rpc('runCommand', input) as Promise, + getContext: async () => { + const [history, tokenCount] = await Promise.all([ + call(scope, 'agentContextMemoryService', 'get', []), + call(scope, 'agentTokenCountingService', 'statusSize', []), + ]); + return { history, tokenCount } as AgentContextData; + }, + listCommands: () => + call(scope, 'agentCommandService', 'list', []) as Promise, + runCommand: (input) => + // Same `[undefined]` → `[null]` wire hazard as `cancel`: the engine's + // `args = ''` default only applies to a missing arg. + call( + scope, + 'agentCommandService', + 'run', + input.args === undefined ? [input.name] : [input.name, input.args], + ) as Promise, getPlan: () => call(scope, 'agentPlanService', 'status', []) as Promise, enterPlan: () => call(scope, 'agentPlanService', 'enter', []) as Promise, clearPlan: () => call(scope, 'agentPlanService', 'clear', []) as Promise, diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index 00f31551e2..adf87dad7b 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -30,7 +30,13 @@ import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/i import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question'; import { ISessionSkillCatalog } from '@moonshot-ai/agent-core-v2/session/sessionSkillCatalog/skillCatalog'; -import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; +import { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/prompt/prompt'; +import { IAgentSkillService } from '@moonshot-ai/agent-core-v2/agent/skill/skill'; +import { IAgentLoopService } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; +import { IAgentPermissionModeService } from '@moonshot-ai/agent-core-v2/agent/permissionMode/permissionMode'; +import { IAgentCommandService } from '@moonshot-ai/agent-core-v2/agent/command/agentCommand'; +import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory'; +import { IAgentTokenCountingService } from '@moonshot-ai/agent-core-v2/agent/tokenCounting/tokenCounting'; import { IAgentActivityView } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; @@ -63,7 +69,13 @@ export const serviceTokens: Readonly>> sessionApprovalService: ISessionApprovalService, sessionQuestionService: ISessionQuestionService, sessionSkillCatalog: ISessionSkillCatalog, - agentRPCService: IAgentRPCService, + agentPromptService: IAgentPromptService, + agentSkillService: IAgentSkillService, + agentLoopService: IAgentLoopService, + agentPermissionModeService: IAgentPermissionModeService, + agentCommandService: IAgentCommandService, + agentContextMemoryService: IAgentContextMemoryService, + agentTokenCountingService: IAgentTokenCountingService, agentActivityView: IAgentActivityView, agentShellCommandService: IAgentShellCommandService, agentProfileService: IAgentProfileService, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index fb6bdf032d..83173652b4 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -22,23 +22,15 @@ import type { TurnPhase, } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; import type { AgentContextData } from '@moonshot-ai/agent-core-v2/agent/contextMemory/types'; +import type { IAgentCommandService } from '@moonshot-ai/agent-core-v2/agent/command/agentCommand'; import type { TurnEndReason } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; +import type { PermissionMode } from '@moonshot-ai/agent-core-v2/agent/permissionPolicy/types'; +import type { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; +import type { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/prompt/prompt'; +import type { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; +import type { IAgentSkillService } from '@moonshot-ai/agent-core-v2/agent/skill/skill'; +import type { ContentPart } from '@moonshot-ai/agent-core-v2/kosong/contract/message'; import type { PlanData } from '@moonshot-ai/agent-core-v2/features/plan/plan'; -import type { - ActivateSkillPayload, - AgentAPI, - CancelPlanPayload, - CancelShellCommandPayload, - EmptyPayload, - GetTaskOutputPayload, - GetTasksPayload, - PromptPart, - RunShellCommandPayload, - SetModelPayload, - SetModelResult, - ShellCommandResult, - StopTaskPayload, -} from '@moonshot-ai/agent-core-v2/agent/rpc/core-api'; import type { UsageStatus } from '@moonshot-ai/agent-core-v2/agent/usage/usage'; import type { SkillSummary } from '@moonshot-ai/agent-core-v2/app/skillCatalog/types'; import type { McpServerEntry } from '@moonshot-ai/agent-core-v2/mcpCore/connection-manager'; @@ -180,7 +172,7 @@ import { stopTaskPayloadSchema, tokenUsageSchema, usageStatusSchema, -} from '../src/contract/agent/rpc.js'; +} from '../src/contract/agent/schemas.js'; import { assistantDeltaEventSchema, compactionBlockedEventSchema, @@ -293,6 +285,7 @@ import { } from '../src/contract/global/workspaces.js'; import type { AssertWire, MutableDeep } from './helpers/typeAssert.js'; +import type { AgentFacade } from '../src/core/facade/agent.js'; /** One-directional: the engine type must be assignable TO the schema's infer. */ type AssertEngineToWire = [MutableDeep] extends [ @@ -521,20 +514,32 @@ const _activityViewLifecycle: AssertWire = true; -// ── agent scope (rpc.ts) ──────────────────────────────────────────────────── -// Payload/result types for the remaining `AgentAPI` methods are reached -// through the interface so the assertions track the exact methods the -// contract mirrors; payloads of the domain services the facade calls -// directly (shellCommand / profile / usage / plan / task) are imported from -// `core-api.ts` (they no longer have `AgentAPI` entries). -type PromptPayload = Parameters[0]; -type PromptLaunchResult = NonNullable>; -type SteerPayload = Parameters[0]; -type CancelPayload = Parameters[0]; -type SetPermissionPayload = Parameters[0]; -type AgentCommandInfo = Awaited>[number]; -type RunCommandPayload = Parameters[0]; +// ── agent scope (services.ts / schemas.ts) ────────────────────────────────── +// Payload/result types are derived from the domain service interfaces the +// facade calls, so the assertions track the exact methods the contract +// mirrors; facade-only payload shapes (cancel / setPermission / plan / task / +// command) derive from the `AgentFacade` input types. +type PromptPayload = Parameters[0]; +type PromptLaunchResult = NonNullable>>; +type SteerPayload = Parameters[0]; +type ActivateSkillPayload = Parameters[0]; +type AgentCommandInfo = ReturnType[number]; +type RunShellCommandPayload = Parameters[0]; +type ShellCommandResult = Awaited>; +type SetModelResult = Awaited>; type TokenUsage = NonNullable; +type PromptPart = Extract; + +type EmptyPayload = {}; +type CancelPayload = NonNullable[0]>; +type SetPermissionPayload = { mode: PermissionMode }; +type RunCommandPayload = Parameters[0]; +type CancelShellCommandPayload = Parameters[0]; +type SetModelPayload = { model: string }; +type CancelPlanPayload = NonNullable[0]>; +type GetTasksPayload = NonNullable[0]>; +type StopTaskPayload = Parameters[0]; +type GetTaskOutputPayload = Parameters[0]; const _emptyPayload: AssertWire = true; const _promptPart: AssertWire = true; diff --git a/packages/klient/test/facade.test.ts b/packages/klient/test/facade.test.ts index 8c76e8cf9a..02c10de0fb 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -222,7 +222,7 @@ describe('session skills routing', () => { expect(seen).toEqual(['workspace']); }); - it('activateSkill routes to agentRPCService with the agent scope', async () => { + it('activateSkill routes to agentSkillService with the agent scope', async () => { const channel = new FakeChannel(); const klient = createKlientFromChannel(channel); const agent = klient.session('s1').agent('main'); @@ -233,11 +233,69 @@ describe('session skills routing', () => { }); expect(channel.calls[0]).toEqual({ scope: { sessionId: 's1', agentId: 'main' }, - service: 'agentRPCService', - method: 'activateSkill', + service: 'agentSkillService', + method: 'activate', args: [{ name: 'review', args: 'src/app.ts' }], }); }); + + it('turn-driving calls route to their domain services with the agent scope', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + const agent = klient.session('s1').agent('main'); + const scope = { sessionId: 's1', agentId: 'main' }; + + channel.results.set('agentPromptService.submit', { turn_id: 1 }); + channel.results.set('agentPromptService.submitSteer', { turn_id: 1 }); + channel.results.set('agentCommandService.list', []); + await agent.prompt({ input: [{ type: 'text', text: 'hi' }], disabledTools: ['Bash'] }); + await agent.steer({ input: [{ type: 'text', text: 'steer' }] }); + await agent.cancel({ turnId: 2 }); + await agent.cancel(); + await agent.setPermission('yolo'); + await agent.listCommands(); + await agent.runCommand({ name: 'cmd', args: 'a b' }); + await agent.runCommand({ name: 'plain' }); + + expect(channel.calls).toEqual([ + { + scope, + service: 'agentPromptService', + method: 'submit', + args: [{ input: [{ type: 'text', text: 'hi' }], disabledTools: ['Bash'] }], + }, + { + scope, + service: 'agentPromptService', + method: 'submitSteer', + args: [{ input: [{ type: 'text', text: 'steer' }] }], + }, + { scope, service: 'agentLoopService', method: 'cancelFromUser', args: [2] }, + { scope, service: 'agentLoopService', method: 'cancelFromUser', args: [] }, + { scope, service: 'agentPermissionModeService', method: 'setModeAndBroadcast', args: ['yolo'] }, + { scope, service: 'agentCommandService', method: 'list', args: [] }, + { scope, service: 'agentCommandService', method: 'run', args: ['cmd', 'a b'] }, + { scope, service: 'agentCommandService', method: 'run', args: ['plain'] }, + ]); + }); + + it('getContext merges the contextMemory and tokenCounting reads', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + const agent = klient.session('s1').agent('main'); + const scope = { sessionId: 's1', agentId: 'main' }; + + channel.results.set('agentContextMemoryService.get', [{ role: 'user' }]); + channel.results.set('agentTokenCountingService.statusSize', 42); + await expect(agent.getContext()).resolves.toEqual({ + history: [{ role: 'user' }], + tokenCount: 42, + }); + expect(channel.calls).toEqual([ + { scope, service: 'agentContextMemoryService', method: 'get', args: [] }, + { scope, service: 'agentTokenCountingService', method: 'statusSize', args: [] }, + ]); + }); }); describe('agent mcp / compaction routing', () => { diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index eea5f7b351..198d3f8251 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -57,10 +57,10 @@ * too (default-profile bind + permission mode). * - `prompt` / `steer` / `runShellCommand` / `cancelShellCommand` → the * `klient.session(id).agent(id)` facade; `activatePluginCommand` → - * `IAgentRPCService` through the agent scope; `activateSkill` → - * `IAgentSkillService` through the agent scope (the RPC service's - * fire-and-forget variant would swallow v1's synchronous rejections) plus - * v1's main-only metadata update; `generateAgentsMd` → + * `IAgentPluginCommandService` through the agent scope; `activateSkill` → + * `IAgentSkillService` through the agent scope (the engine settles + * `{turn_id}` and applies v1's main-only metadata update itself); + * `generateAgentsMd` → * `ISessionInitService` through the session scope; `getSessionWarnings` → * rebuilt over the profile's cached AGENTS.md warning plus the engine's * `prepareSystemPromptContext` (no v2 aggregate service exists). @@ -157,7 +157,6 @@ import { wrapSubagentModelError } from '@moonshot-ai/agent-core-v2/session/subag import { loadMcpServers } from '@moonshot-ai/agent-core-v2/workspace/workspaceMcpConfig/internal/config-loader'; import type { McpServerConfig as WorkspaceMcpServerConfig } from '@moonshot-ai/agent-core-v2/mcpCore/config-schema'; import { - applyPromptMetadataUpdate, bootstrap, DEFAULT_AGENT_PROFILE_NAME, drainQueryStoreDisposals, @@ -166,18 +165,21 @@ import { ensureMainAgent, IAgentActivityView, IAgentContextMemoryService, + IAgentConversationUndoService, IAgentFullCompactionService, IAgentGoalService, IAgentLifecycleService, IAgentLoopService, IAgentPermissionModeService, IAgentPermissionRulesService, + IAgentPluginCommandService, IAgentProfileService, - IAgentRPCService, IAgentSkillService, IAgentSwarmService, IAgentTaskService, IAgentTokenCountingService, + IAgentToolPolicyService, + IAgentToolRegistryService, IBootstrapService, IConfigService, IEventService, @@ -221,7 +223,6 @@ import { PRINT_WAIT_CEILING_S_DEFAULT, ProfileError, ProfileErrors, - promptMetadataTextFromSkill, resolveAgentTaskConfig, resolveConfigPath, resolveKimiHome, @@ -961,6 +962,13 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { foldAgentWireReplay(join(ctx.sessionDir, 'agents', agent.id, 'wire.jsonl')), ]); const profile = agent.accessor.get(IAgentProfileService).data(); + const toolPolicy = agent.accessor.get(IAgentToolPolicyService); + const tools = agent.accessor.get(IAgentToolRegistryService).list().map((tool) => ({ + name: tool.name, + description: tool.description, + active: toolPolicy.isToolActive(tool.name, tool.source), + source: tool.source, + })); return { type, config: { @@ -981,7 +989,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { plan: plan as ResumedAgentState['plan'], swarmMode: agent.accessor.get(IAgentSwarmService).isActive, usage: usage as ResumedAgentState['usage'], - tools: agent.accessor.get(IAgentRPCService).getTools({}) as ResumedAgentState['tools'], + tools: tools as ResumedAgentState['tools'], toolStore: folded.toolStore, background: background as readonly BackgroundTaskInfo[], }; @@ -1500,20 +1508,21 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return agent.clearPlan(); } - /** Facade (`agentRPCService.listCommands`) — the v2-only contributed-command seam. */ + /** Facade (`agentCommandService.list`) — the v2-only contributed-command seam. */ override async listCommands(input: SessionIdRpcInput): Promise { const agent = await this.agentFacade(input.sessionId); return agent.listCommands(); } - /** Facade (`agentRPCService.runCommand`) — runs the contribution engine-side. */ + /** Facade (`agentCommandService.run`) — runs the contribution engine-side. */ override async runCommand(input: RunCommandRpcInput): Promise { const agent = await this.agentFacade(input.sessionId); return agent.runCommand({ name: input.name, args: input.args }); } /** - * Facade (`agentRPCService.getContext`). The v2 `AgentContextData` is the + * Facade (`getContext`, merged client-side from `agentContextMemoryService.get` + * and `agentTokenCountingService.statusSize`). The v2 `AgentContextData` is the * same wire shape as v1's — the cast only bridges the two packages' type * declarations (v2's origin union carries kinds a v1 client never sees in * practice); the data itself crossed the same JSON boundary on both sides. @@ -1590,18 +1599,18 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } /** - * Through the agent scope (`IAgentRPCService.cancelCompaction`, the v2 RPC - * surface's own cancel) — no klient facade exists. Aborts the in-flight - * compaction; a no-op when idle, like v1. + * Through the agent scope (`IAgentFullCompactionService.cancel`) — no + * klient facade exists. Aborts the in-flight compaction; a no-op when idle, + * like v1. */ override async cancelCompaction(input: SessionIdRpcInput): Promise { const agent = await this.agentScope(input.sessionId); - await agent.accessor.get(IAgentRPCService).cancelCompaction({}); + agent.accessor.get(IAgentFullCompactionService).cancel(); } /** - * Through the agent scope (`IAgentRPCService.undoHistory`, the v2 RPC - * surface's own undo) — no klient facade exists; the returned count is + * Through the agent scope (`IAgentConversationUndoService.undo`) — no + * klient facade exists; the returned count is * dropped (v1 returns void). Failure semantics differ by design: v2 * prechecks and rejects atomically with `session.undo_unavailable`, while * v1 splices a partial suffix out of the live history and then throws @@ -1609,7 +1618,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { */ override async undoHistory(input: SessionIdRpcInput & { count: number }): Promise { const agent = await this.agentScope(input.sessionId); - await agent.accessor.get(IAgentRPCService).undoHistory({ count: input.count }); + await agent.accessor.get(IAgentConversationUndoService).undo(input.count); } /** @@ -1657,7 +1666,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } /** - * Facade (`agentRPCService.prompt`). The launch result (`{turn_id}`, or + * Facade (`agentPromptService.submit`). The launch result (`{turn_id}`, or * `undefined` when the prompt queued behind a running turn) is dropped — * v1's RPC returns void. The pre-provider surface matches v1: the metadata * update (title/lastPrompt) runs through the same shared helpers before the @@ -1674,7 +1683,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } /** - * Facade (`agentRPCService.steer`). Matches v1 on both paths: mid-turn + * Facade (`agentPromptService.submitSteer`). Matches v1 on both paths: mid-turn * steers join the running turn, and an idle-session steer degrades to * launching a fresh turn (the enqueue launches it directly) while * title/lastPrompt are updated like a prompt's. @@ -1713,40 +1722,32 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } /** - * Through the agent scope (`IAgentSkillService.activate`) — deliberately - * NOT `IAgentRPCService.activateSkill`, whose `void this.skills.activate(...)` - * fire-and-forget turns v1's synchronous rejections (`skill.not_found` / - * `skill.type_unsupported`) into unhandled rejections. The direct call keeps - * v1's semantics: validate first, then render the skill prompt and launch a - * turn with it. v1's session layer then updates title/lastPrompt for the - * MAIN agent only; replicated here over the engine's shared metadata - * helpers. Busy-turn gap vs v1, pinned in the migration tracker: v1 drops + * Through the agent scope (`IAgentSkillService.activate`) — the direct call + * keeps v1's semantics: validate first (`skill.not_found` / + * `skill.type_unsupported` reject synchronously), then render the skill + * prompt and launch a turn with it. The engine updates title/lastPrompt for + * the MAIN agent only, matching v1's session layer. Busy-turn gap vs v1, + * pinned in the migration tracker: v1 drops * the activation into an error event while a turn runs; v2's activate * awaits the queued prompt's launch. */ override async activateSkill(input: ActivateSkillRpcInput): Promise { const agent = await this.agentScope(input.sessionId); await agent.accessor.get(IAgentSkillService).activate({ name: input.name, args: input.args }); - if (this.interactiveAgentId === MAIN_AGENT_ID) { - await this.updatePromptMetadata(input.sessionId, promptMetadataTextFromSkill(input)); - } } /** - * Through the agent scope (`IAgentRPCService.activatePluginCommand`) — the - * v2 RPC surface's own implementation: the same `request.invalid` rejection - * text for an unknown command, the same argument expansion, the activation - * event, the prompt enqueue, and the metadata update. Two gaps vs v1, + * Through the agent scope (`IAgentPluginCommandService.activate`): the same + * `request.invalid` rejection text for an unknown command, the same + * argument expansion, the activation event, the prompt enqueue, and the + * main-agent-only metadata update. Two gaps vs v1, * pinned in the migration tracker: v1 resolves the command against the * session's creation-time snapshot (v2 uses the app-global live view), and - * v1 drops the activation while a turn runs where v2 queues it. v1 also - * updates title/lastPrompt for the main agent only, where the v2 RPC does - * it unconditionally — only observable through a non-main - * `interactiveAgentId`. + * v1 drops the activation while a turn runs where v2 queues it. */ override async activatePluginCommand(input: ActivatePluginCommandRpcInput): Promise { const agent = await this.agentScope(input.sessionId); - await agent.accessor.get(IAgentRPCService).activatePluginCommand({ + await agent.accessor.get(IAgentPluginCommandService).activate({ pluginId: input.pluginId, commandName: input.commandName, args: input.args, @@ -1771,8 +1772,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } /** - * No v2 service implements the session-warnings aggregate (`ISessionRPCService` - * is an interface without an implementation), so the SDK rebuilds v1's + * No v2 service implements the session-warnings aggregate, so the SDK rebuilds v1's * `Session.getSessionWarnings` over v2 primitives: the profile's cached * `agentsMdWarning` (computed on every bind, v1's bootstrap-time cache), * recomputed through the engine's own `prepareSystemPromptContext` when the @@ -1817,23 +1817,6 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return warnings; } - /** - * v1's session-layer prompt-metadata update (title/lastPrompt), rebuilt - * over the engine's shared helper so skill/plugin-command activations land - * on the same metadata the native v2 prompt path writes. - */ - private async updatePromptMetadata(sessionId: string, text: string | undefined): Promise { - const session = this.requireLiveSession(sessionId); - await applyPromptMetadataUpdate( - { - metadata: session.accessor.get(ISessionMetadata), - eventService: this.engineAccessor.get(IEventService), - sessionId, - }, - text, - ); - } - /** * Through the session scope (`ISessionBtwService`) — no klient facade * exists. The v2 service is the port of v1's btw fork: same inherited From 09c6704c13462f65183ad2e365babbb6ac12cc49 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 12 Aug 2026 20:35:52 +0800 Subject: [PATCH 2/4] refactor(agent-core-v2): move disabledTools gating out of the prompt domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt should not own session tool policy: submit no longer accepts or applies disabledTools. The klient facade keeps its prompt({ disabledTools }) API and composes it edge-side — applying agentToolPolicyService setSessionDisabledTools before calling agentPromptService.submit, the same way kap-server's prompt route already does. Over klient, a profile-less engine now surfaces the raw profile error instead of request.invalid. Also restores the RPC-removal changeset, which did not make it into the previous commit. --- .changeset/agent-rpc-layer-removal.md | 9 ++++++++ .../agent-core-v2/src/agent/prompt/prompt.ts | 1 - .../src/agent/prompt/promptService.ts | 23 +++++-------------- .../test/agent/prompt/promptService.test.ts | 4 ---- .../test/agent/prompt/submit.test.ts | 21 +---------------- packages/klient/src/contract/agent/schemas.ts | 3 --- .../klient/src/contract/agent/services.ts | 5 ++++ packages/klient/src/contract/index.ts | 2 ++ packages/klient/src/core/facade/agent.ts | 15 ++++++++++-- .../src/transports/memory/serviceRegistry.ts | 2 ++ packages/klient/test/facade.test.ts | 8 ++++++- 11 files changed, 45 insertions(+), 48 deletions(-) create mode 100644 .changeset/agent-rpc-layer-removal.md diff --git a/.changeset/agent-rpc-layer-removal.md b/.changeset/agent-rpc-layer-removal.md new file mode 100644 index 0000000000..79b05223da --- /dev/null +++ b/.changeset/agent-rpc-layer-removal.md @@ -0,0 +1,9 @@ +--- +"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code-sdk": minor +--- + +Remove the agent-core-v2 `AgentRPCService` aggregation layer (`agent/rpc/`); orchestration now lives in the owning domain services (`agentPromptService.submit`/`submitSteer`, `agentSkillService.activate`, the new `agentPluginCommandService`, `agentLoopService.cancelFromUser`, `agentPermissionModeService.setModeAndBroadcast`, `agentFullCompactionService.cancel`). Two externally visible changes: + +- Debug surface: the `agentRPCService` channel is gone; the same operations are served by per-domain channels. `agentPromptService.submit` does not take `disabledTools` — session tool gating is applied via `agentToolPolicyService.setSessionDisabledTools` before submitting (the SDK/klient facade `prompt({ disabledTools })` does this composition for you; over klient, a profile-less engine now surfaces the raw profile error instead of `request.invalid`). +- Session metadata writes (title/lastPrompt derivation) are now MAIN-agent-only across prompt/steer/skill/pluginCommand; node-sdk and kap-server no longer write them at the edge for skill activation. diff --git a/packages/agent-core-v2/src/agent/prompt/prompt.ts b/packages/agent-core-v2/src/agent/prompt/prompt.ts index fda6afe220..73f1b49e4d 100644 --- a/packages/agent-core-v2/src/agent/prompt/prompt.ts +++ b/packages/agent-core-v2/src/agent/prompt/prompt.ts @@ -50,7 +50,6 @@ export interface PromptQueueSnapshot { export interface PromptPayload { readonly input: readonly ContentPart[]; - readonly disabledTools?: readonly string[]; } export interface SteerPayload { diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index e36c6b126f..f41ded27c7 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -5,10 +5,12 @@ * active slot and FIFO, converts selected pending prompts into active-turn * steers, settles lifecycle handles, and keeps system input outside the prompt * resource model. `submit` / `submitSteer` are the wire-facing user entry - * points: they gate on `toolPolicy` session disabled tools, track `input_steer` - * through `telemetry`, persist the derived title/lastPrompt through - * `sessionMetadata` for the main agent only (publishing the live update - * through `event`), enqueue, and settle `{turn_id}` from the launch handle. + * points: they track `input_steer` through `telemetry`, persist the derived + * title/lastPrompt through `sessionMetadata` for the main agent only + * (publishing the live update through `event`), enqueue, and settle + * `{turn_id}` from the launch handle. Session tool gating is an edge + * concern: callers apply `IAgentToolPolicyService.setSessionDisabledTools` + * before submitting, the way kap-server's prompt route composes it. * The pure-data `launching` flag is registered into * `agentState` (`IAgentStateService`) and read/written through it; the * `active` / `pending` / `steered` records stay plain fields because their @@ -40,9 +42,7 @@ import { IEventService } from '#/app/event/event'; import { ErrorCodes, Error2, isError2 } from '#/errors'; import { OrderedHookSlot } from '#/hooks'; import { IWireService } from '#/wire/wire'; -import { ProfileError } from '#/agent/profile/profile'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -101,7 +101,6 @@ export class AgentPromptService implements IAgentPromptService { @IWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, @IAgentStateService private readonly states: IAgentStateService, - @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @ITelemetryService private readonly telemetry: ITelemetryService, @ISessionMetadata private readonly metadata: ISessionMetadata, @IEventService private readonly eventService: IEventService, @@ -154,16 +153,6 @@ export class AgentPromptService implements IAgentPromptService { } async submit(payload: PromptPayload): Promise { - if (payload.disabledTools !== undefined) { - try { - await this.toolPolicy.setSessionDisabledTools(payload.disabledTools); - } catch (error) { - if (error instanceof ProfileError) { - throw new Error2(ErrorCodes.REQUEST_INVALID, error.message); - } - throw error; - } - } await this.updatePromptMetadata(promptMetadataTextFromContentParts(payload.input)); const handle = await this.enqueue({ message: { role: 'user', diff --git a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts index d12d3157b9..4e5fc611fc 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -21,7 +21,6 @@ import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/ import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; -import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IEventBus } from '#/app/event/eventBus'; import { IEventService } from '#/app/event/event'; import { EventBusService } from '#/app/event/eventBusService'; @@ -63,9 +62,6 @@ function harness() { reg.define(IEventBus, EventBusService); reg.define(IAgentSystemReminderService, AgentSystemReminderService); reg.define(IAgentPromptService, AgentPromptService); - reg.definePartialInstance(IAgentToolPolicyService, { - setSessionDisabledTools: async () => {}, - }); reg.definePartialInstance(ITelemetryService, { track: () => {}, track2: () => {} }); reg.definePartialInstance(ISessionMetadata, { read: async () => ({ id: 'test-session', createdAt: 0, updatedAt: 0, archived: false }), diff --git a/packages/agent-core-v2/test/agent/prompt/submit.test.ts b/packages/agent-core-v2/test/agent/prompt/submit.test.ts index c32621fa37..e69df7c2d4 100644 --- a/packages/agent-core-v2/test/agent/prompt/submit.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/submit.test.ts @@ -1,7 +1,6 @@ /** * Scenario: `IAgentPromptService.submit` is the wire-facing prompt entry — - * disabledTools gating, prompt-metadata persistence, and `{turn_id}` - * settlement. + * prompt-metadata persistence and `{turn_id}` settlement. * * Migrated from the kap-server debug-RPC suite (`test/rpc.test.ts`) when the * RPC aggregation layer was removed: the composition now lives in the prompt @@ -12,7 +11,6 @@ import { afterEach, describe, expect, it } from 'vitest'; import { IEventService } from '#/app/event/event'; -import { ErrorCodes } from '#/errors'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { createTestAgent, type TestAgentContext } from '../../harness'; @@ -38,23 +36,6 @@ describe('prompt submit', () => { await ctx.untilTurnEnd(); }); - it('rejects disabledTools before bind without mutating prompt metadata', async () => { - ctx = createTestAgent(); - - // The default test agent has no profile bound, so the session denylist is - // rejected as a profile error, mapped to `request.invalid`. - await expect( - ctx.rpc.prompt({ - input: [{ type: 'text', text: 'must not become metadata' }], - disabledTools: ['Bash'], - }), - ).rejects.toMatchObject({ code: ErrorCodes.REQUEST_INVALID }); - - const metadata = await ctx.get(ISessionMetadata).read(); - expect(metadata.title).toBeUndefined(); - expect(metadata.lastPrompt).toBeUndefined(); - }); - it('derives the session title and lastPrompt from the first prompt', async () => { ctx = createTestAgent(); ctx.mockNextResponse({ type: 'text', text: 'hi' }); diff --git a/packages/klient/src/contract/agent/schemas.ts b/packages/klient/src/contract/agent/schemas.ts index 38f0cebe0b..cf1fc2be02 100644 --- a/packages/klient/src/contract/agent/schemas.ts +++ b/packages/klient/src/contract/agent/schemas.ts @@ -39,9 +39,6 @@ export const emptyPayloadSchema = z.object({}); export const promptPayloadSchema = z.object({ input: z.array(promptPartSchema), - // Mirrors `PromptPayload.disabledTools` in the engine (client-managed - // session denylist, full-replace). - disabledTools: z.array(z.string()).optional(), }); /** Same shape as `SteerPayload` in the engine. */ diff --git a/packages/klient/src/contract/agent/services.ts b/packages/klient/src/contract/agent/services.ts index 26483306ed..9dafb4b903 100644 --- a/packages/klient/src/contract/agent/services.ts +++ b/packages/klient/src/contract/agent/services.ts @@ -48,6 +48,11 @@ export const agentPermissionModeContract = { setModeAndBroadcast: { input: z.tuple([permissionModeSchema]), output: noResult }, } satisfies ServiceContract; +/** `IAgentToolPolicyService.setSessionDisabledTools` — the client-managed session tool denylist (full-replace). */ +export const agentToolPolicyContract = { + setSessionDisabledTools: { input: z.tuple([z.array(z.string())]), output: noResult }, +} satisfies ServiceContract; + export const agentCommandContract = { list: { input: z.tuple([]), output: z.array(agentCommandInfoSchema) }, run: { input: z.tuple([z.string(), z.string().optional()]), output: noResult }, diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts index 510f761c4a..43377c3436 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -22,6 +22,7 @@ import { agentSkillContract, agentTaskContract, agentTokenCountingContract, + agentToolPolicyContract, agentUsageContract, } from './agent/services.js'; import { authContract, authSummaryContract } from './global/auth.js'; @@ -77,6 +78,7 @@ export const globalContract: KlientContract = { agentSkillService: agentSkillContract, agentLoopService: agentLoopContract, agentPermissionModeService: agentPermissionModeContract, + agentToolPolicyService: agentToolPolicyContract, agentCommandService: agentCommandContract, agentContextMemoryService: agentContextMemoryContract, agentTokenCountingService: agentTokenCountingContract, diff --git a/packages/klient/src/core/facade/agent.ts b/packages/klient/src/core/facade/agent.ts index a836ceadc4..8fc3d9cecd 100644 --- a/packages/klient/src/core/facade/agent.ts +++ b/packages/klient/src/core/facade/agent.ts @@ -88,8 +88,19 @@ export interface AgentFacade { export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFacade { return { - prompt: (input) => - call(scope, 'agentPromptService', 'submit', [input]) as Promise, + prompt: async (input) => { + // Session tool gating is an edge concern, not the prompt domain's: + // apply the client-managed denylist before submitting (full-replace + // semantics), the way kap-server's prompt route composes it. + if (input.disabledTools !== undefined) { + await call(scope, 'agentToolPolicyService', 'setSessionDisabledTools', [ + [...input.disabledTools], + ]); + } + return call(scope, 'agentPromptService', 'submit', [ + { input: input.input }, + ]) as Promise; + }, steer: (input) => call(scope, 'agentPromptService', 'submitSteer', [input]) as Promise, activateSkill: (input) => diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index adf87dad7b..a63cbc3d5a 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -34,6 +34,7 @@ import { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/prompt/pro import { IAgentSkillService } from '@moonshot-ai/agent-core-v2/agent/skill/skill'; import { IAgentLoopService } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; import { IAgentPermissionModeService } from '@moonshot-ai/agent-core-v2/agent/permissionMode/permissionMode'; +import { IAgentToolPolicyService } from '@moonshot-ai/agent-core-v2/agent/toolPolicy/toolPolicy'; import { IAgentCommandService } from '@moonshot-ai/agent-core-v2/agent/command/agentCommand'; import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory'; import { IAgentTokenCountingService } from '@moonshot-ai/agent-core-v2/agent/tokenCounting/tokenCounting'; @@ -73,6 +74,7 @@ export const serviceTokens: Readonly>> agentSkillService: IAgentSkillService, agentLoopService: IAgentLoopService, agentPermissionModeService: IAgentPermissionModeService, + agentToolPolicyService: IAgentToolPolicyService, agentCommandService: IAgentCommandService, agentContextMemoryService: IAgentContextMemoryService, agentTokenCountingService: IAgentTokenCountingService, diff --git a/packages/klient/test/facade.test.ts b/packages/klient/test/facade.test.ts index 02c10de0fb..7f81bc9cfe 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -258,11 +258,17 @@ describe('session skills routing', () => { await agent.runCommand({ name: 'plain' }); expect(channel.calls).toEqual([ + { + scope, + service: 'agentToolPolicyService', + method: 'setSessionDisabledTools', + args: [['Bash']], + }, { scope, service: 'agentPromptService', method: 'submit', - args: [{ input: [{ type: 'text', text: 'hi' }], disabledTools: ['Bash'] }], + args: [{ input: [{ type: 'text', text: 'hi' }] }], }, { scope, From 14f45110c4b0f1b0564f24368b3fe25b0f66bb58 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 09:45:02 +0800 Subject: [PATCH 3/4] chore(agent-core-v2): drop the RPC-removal changeset --- .changeset/agent-rpc-layer-removal.md | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 .changeset/agent-rpc-layer-removal.md diff --git a/.changeset/agent-rpc-layer-removal.md b/.changeset/agent-rpc-layer-removal.md deleted file mode 100644 index 79b05223da..0000000000 --- a/.changeset/agent-rpc-layer-removal.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor -"@moonshot-ai/kimi-code-sdk": minor ---- - -Remove the agent-core-v2 `AgentRPCService` aggregation layer (`agent/rpc/`); orchestration now lives in the owning domain services (`agentPromptService.submit`/`submitSteer`, `agentSkillService.activate`, the new `agentPluginCommandService`, `agentLoopService.cancelFromUser`, `agentPermissionModeService.setModeAndBroadcast`, `agentFullCompactionService.cancel`). Two externally visible changes: - -- Debug surface: the `agentRPCService` channel is gone; the same operations are served by per-domain channels. `agentPromptService.submit` does not take `disabledTools` — session tool gating is applied via `agentToolPolicyService.setSessionDisabledTools` before submitting (the SDK/klient facade `prompt({ disabledTools })` does this composition for you; over klient, a profile-less engine now surfaces the raw profile error instead of `request.invalid`). -- Session metadata writes (title/lastPrompt derivation) are now MAIN-agent-only across prompt/steer/skill/pluginCommand; node-sdk and kap-server no longer write them at the edge for skill activation. From fad36dd9508c435aa3794f9e80fd7d87fb5e3c5c Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 09:56:43 +0800 Subject: [PATCH 4/4] refactor(klient): drop disabledTools from the prompt entry entirely The prompt path no longer carries session tool gating on any surface: the klient facade prompt() loses the disabledTools field and calls agentPromptService.submit directly, and the node-sdk SessionPromptRpcInput stops accepting or forwarding it (v1 always ignored the field). Session tool gating remains available through IAgentToolPolicyService.setSessionDisabledTools, composed at the edge the way kap-server's prompt route does; the klient toolPolicy contract added for facade-side composition is removed as unused. --- .../klient/src/contract/agent/services.ts | 5 ----- packages/klient/src/contract/index.ts | 2 -- packages/klient/src/core/facade/agent.ts | 20 +++---------------- .../src/transports/memory/serviceRegistry.ts | 2 -- packages/klient/test/facade.test.ts | 8 +------- packages/node-sdk/src/rpc.ts | 7 ------- packages/node-sdk/src/sdk-rpc-client-v2.ts | 8 +++----- packages/node-sdk/test/v1-v2-parity.test.ts | 4 ++-- 8 files changed, 9 insertions(+), 47 deletions(-) diff --git a/packages/klient/src/contract/agent/services.ts b/packages/klient/src/contract/agent/services.ts index 9dafb4b903..26483306ed 100644 --- a/packages/klient/src/contract/agent/services.ts +++ b/packages/klient/src/contract/agent/services.ts @@ -48,11 +48,6 @@ export const agentPermissionModeContract = { setModeAndBroadcast: { input: z.tuple([permissionModeSchema]), output: noResult }, } satisfies ServiceContract; -/** `IAgentToolPolicyService.setSessionDisabledTools` — the client-managed session tool denylist (full-replace). */ -export const agentToolPolicyContract = { - setSessionDisabledTools: { input: z.tuple([z.array(z.string())]), output: noResult }, -} satisfies ServiceContract; - export const agentCommandContract = { list: { input: z.tuple([]), output: z.array(agentCommandInfoSchema) }, run: { input: z.tuple([z.string(), z.string().optional()]), output: noResult }, diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts index 43377c3436..510f761c4a 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -22,7 +22,6 @@ import { agentSkillContract, agentTaskContract, agentTokenCountingContract, - agentToolPolicyContract, agentUsageContract, } from './agent/services.js'; import { authContract, authSummaryContract } from './global/auth.js'; @@ -78,7 +77,6 @@ export const globalContract: KlientContract = { agentSkillService: agentSkillContract, agentLoopService: agentLoopContract, agentPermissionModeService: agentPermissionModeContract, - agentToolPolicyService: agentToolPolicyContract, agentCommandService: agentCommandContract, agentContextMemoryService: agentContextMemoryContract, agentTokenCountingService: agentTokenCountingContract, diff --git a/packages/klient/src/core/facade/agent.ts b/packages/klient/src/core/facade/agent.ts index 8fc3d9cecd..4442935308 100644 --- a/packages/klient/src/core/facade/agent.ts +++ b/packages/klient/src/core/facade/agent.ts @@ -41,10 +41,7 @@ export type AgentTaskInfo = Awaited>[numbe export type McpServerEntry = ReturnType[number]; export interface AgentFacade { - prompt(input: { - input: readonly ContentPart[]; - disabledTools?: readonly string[]; - }): Promise; + prompt(input: { input: readonly ContentPart[] }): Promise; steer(input: { input: readonly ContentPart[] }): Promise; /** * Activate a skill as a user-slash activation: the engine renders the skill @@ -88,19 +85,8 @@ export interface AgentFacade { export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFacade { return { - prompt: async (input) => { - // Session tool gating is an edge concern, not the prompt domain's: - // apply the client-managed denylist before submitting (full-replace - // semantics), the way kap-server's prompt route composes it. - if (input.disabledTools !== undefined) { - await call(scope, 'agentToolPolicyService', 'setSessionDisabledTools', [ - [...input.disabledTools], - ]); - } - return call(scope, 'agentPromptService', 'submit', [ - { input: input.input }, - ]) as Promise; - }, + prompt: (input) => + call(scope, 'agentPromptService', 'submit', [input]) as Promise, steer: (input) => call(scope, 'agentPromptService', 'submitSteer', [input]) as Promise, activateSkill: (input) => diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index a63cbc3d5a..adf87dad7b 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -34,7 +34,6 @@ import { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/prompt/pro import { IAgentSkillService } from '@moonshot-ai/agent-core-v2/agent/skill/skill'; import { IAgentLoopService } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; import { IAgentPermissionModeService } from '@moonshot-ai/agent-core-v2/agent/permissionMode/permissionMode'; -import { IAgentToolPolicyService } from '@moonshot-ai/agent-core-v2/agent/toolPolicy/toolPolicy'; import { IAgentCommandService } from '@moonshot-ai/agent-core-v2/agent/command/agentCommand'; import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory'; import { IAgentTokenCountingService } from '@moonshot-ai/agent-core-v2/agent/tokenCounting/tokenCounting'; @@ -74,7 +73,6 @@ export const serviceTokens: Readonly>> agentSkillService: IAgentSkillService, agentLoopService: IAgentLoopService, agentPermissionModeService: IAgentPermissionModeService, - agentToolPolicyService: IAgentToolPolicyService, agentCommandService: IAgentCommandService, agentContextMemoryService: IAgentContextMemoryService, agentTokenCountingService: IAgentTokenCountingService, diff --git a/packages/klient/test/facade.test.ts b/packages/klient/test/facade.test.ts index 7f81bc9cfe..6c6093a33a 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -248,7 +248,7 @@ describe('session skills routing', () => { channel.results.set('agentPromptService.submit', { turn_id: 1 }); channel.results.set('agentPromptService.submitSteer', { turn_id: 1 }); channel.results.set('agentCommandService.list', []); - await agent.prompt({ input: [{ type: 'text', text: 'hi' }], disabledTools: ['Bash'] }); + await agent.prompt({ input: [{ type: 'text', text: 'hi' }] }); await agent.steer({ input: [{ type: 'text', text: 'steer' }] }); await agent.cancel({ turnId: 2 }); await agent.cancel(); @@ -258,12 +258,6 @@ describe('session skills routing', () => { await agent.runCommand({ name: 'plain' }); expect(channel.calls).toEqual([ - { - scope, - service: 'agentToolPolicyService', - method: 'setSessionDisabledTools', - args: [['Bash']], - }, { scope, service: 'agentPromptService', diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index b78f77c6b8..a75f983d5a 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -71,12 +71,6 @@ const MAIN_AGENT_ID = 'main'; export interface SessionPromptRpcInput { readonly sessionId: string; readonly input: PromptInput; - /** - * Client-managed session tool denylist (full-replace semantics), forwarded - * to engines with profile tool gating. Omit to keep the persisted value; - * `[]` clears the client portion. - */ - readonly disabledTools?: readonly string[]; } export interface SessionIdRpcInput { @@ -386,7 +380,6 @@ export abstract class SDKRpcClientBase { sessionId: input.sessionId, agentId, input: input.input, - disabledTools: input.disabledTools, }); } diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 198d3f8251..e92b674658 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -1671,15 +1671,13 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * v1's RPC returns void. The pre-provider surface matches v1: the metadata * update (title/lastPrompt) runs through the same shared helpers before the * turn launches, and a model-less turn fails asynchronously exactly like - * v1's. Two enqueue-semantics gaps vs v1, pinned in the migration tracker: + * v1's. One enqueue-semantics gap vs v1, pinned in the migration tracker: * v1 drops a prompt submitted while a turn is active (error event only) - * where v2 queues it FIFO, and v1 never consumes `disabledTools` (the - * payload field reaches the agent RPC but no code reads it) where v2 - * applies it as the session tool denylist. + * where v2 queues it FIFO. */ override async prompt(input: SessionPromptRpcInput): Promise { const agent = await this.agentFacade(input.sessionId); - await agent.prompt({ input: input.input, disabledTools: input.disabledTools }); + await agent.prompt({ input: input.input }); } /** diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index cbfa98f498..a6a223ee71 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -2393,8 +2393,8 @@ describe('v1↔v2 agent interaction parity', () => { // Model-less on purpose: parity covers the pre-provider surface — the // call returns without throwing and the metadata update (same shared // helpers on both engines) lands before the turn fails asynchronously. - // The enqueue-semantics gaps (v1 drops a mid-turn prompt, v2 queues it; - // v1 ignores disabledTools, v2 applies it) are pinned in the tracker. + // The enqueue-semantics gap (v1 drops a mid-turn prompt, v2 queues it) is + // pinned in the tracker. const pair = await makeSessionParityPair(); try { await createOnBoth(pair, { id: 'session_parity_agent_prompt' });