refactor(agent-core-v2): remove the agent RPC aggregation layer (#2871)

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

* refactor(agent-core-v2): move disabledTools gating out of the prompt domain

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.

* chore(agent-core-v2): drop the RPC-removal changeset

* 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.
This commit is contained in:
Haozhe 2026-08-13 10:57:01 +08:00 committed by GitHub
parent 719da94648
commit 23e68eee8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 1079 additions and 1238 deletions

View file

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

View file

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

View file

@ -51,7 +51,7 @@ File names derive from the interface / class names so that scope and role are vi
| Shared-types file | `<domain>.types.ts` | `log.types.ts` |
| Errors file | `<name>.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`).

View file

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

View file

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

View file

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

View file

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

View file

@ -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<T>`: `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`.

View file

@ -24,6 +24,7 @@ export interface IAgentFullCompactionService {
readonly compacting: FullCompactionTask | null;
begin(input: FullCompactionInput): boolean;
cancel(): void;
readonly hooks: Hooks<{
onWillCompact: FullCompactionTask;

View file

@ -247,6 +247,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;

View file

@ -147,6 +147,8 @@ export interface IAgentLoopService {
cancel(turnId?: number, reason?: unknown): boolean;
cancelFromUser(turnId?: number): void;
tryAcquireQuiescence(): IDisposable | undefined;
settled(): Promise<void>;

View file

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

View file

@ -12,6 +12,7 @@ export interface IAgentPermissionModeService {
readonly mode: PermissionMode;
setMode(mode: PermissionMode): void;
setModeAndBroadcast(mode: PermissionMode): void;
readonly onDidChangeMode: Event<PermissionModeChangedContext>;
}

View file

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

View file

@ -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<void>;
}
export const IAgentPluginCommandService: ServiceIdentifier<IAgentPluginCommandService> =
createDecorator<IAgentPluginCommandService>('agentPluginCommandService');

View file

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

View file

@ -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,23 @@ export interface PromptQueueSnapshot {
readonly pending: readonly PromptSnapshot[];
}
export interface PromptPayload {
readonly input: readonly ContentPart[];
}
export interface SteerPayload {
readonly input: readonly ContentPart[];
}
export interface PromptLaunchResult {
readonly turn_id: number;
}
export interface IAgentPromptService {
readonly _serviceBrand: undefined;
enqueue(input: PromptInput): Promise<PromptHandle>;
submit(payload: PromptPayload): Promise<PromptLaunchResult | undefined>;
submitSteer(payload: SteerPayload): Promise<PromptLaunchResult | undefined>;
list(): PromptQueueSnapshot;
steer(promptIds: readonly string[]): Promise<readonly PromptHandle[]>;
abort(promptId: string, reason?: Error): boolean;

View file

@ -4,7 +4,14 @@
* 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 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
* `Record` values carry Deferred promise handles (the container only holds
@ -31,20 +38,31 @@ 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 { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
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,11 @@ export class AgentPromptService implements IAgentPromptService {
@IWireService private readonly wire: IWireService,
@IEventBus private readonly eventBus: IEventBus,
@IAgentStateService private readonly states: IAgentStateService,
@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 +152,64 @@ export class AgentPromptService implements IAgentPromptService {
return record.handle;
}
async submit(payload: PromptPayload): Promise<PromptLaunchResult | undefined> {
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<PromptLaunchResult | undefined> {
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<void> {
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) };
}

View file

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

View file

@ -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<ContentPart, { type: 'text' }>;
export type PromptPart = Extract<ContentPart, { type: 'text' | 'image_url' | 'video_url' }>;
export type PromptInput = readonly PromptPart[];
export type EmptyPayload = {};
export type SessionMetadataPatch = Partial<Omit<SessionMeta, 'agents'>>;
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<string, unknown>;
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<number>;
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<void>;
getContext: (payload: EmptyPayload) => AgentContextData;
getTools: (payload: EmptyPayload) => readonly ToolInfo[];
}
type AgentAPIWithId = WithAgentId<AgentAPI>;
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<SessionAPI>;
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;
}

View file

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

View file

@ -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<AgentAPI> {
readonly _serviceBrand: undefined;
}
export interface ISessionRPCService extends PromisableMethods<SessionAPI> {
readonly _serviceBrand: undefined;
}
export const IAgentRPCService =
createDecorator<IAgentRPCService>('agentRPCService');
export const ISessionRPCService =
createDecorator<ISessionRPCService>('agentSessionRPCService');

View file

@ -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<PromptLaunchResult | undefined> {
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<PromptLaunchResult | undefined> {
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<number> {
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<PromptLaunchResult | undefined> {
// 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<void> {
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<void> {
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<void> {
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',
);

View file

@ -1,11 +0,0 @@
/**
* `rpc` domain (L8) shared request wrapper types.
*/
export type WithSessionId<T = {}> = T & {
readonly sessionId: string;
};
export type WithAgentId<T = {}> = T & {
readonly agentId: string;
};

View file

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

View file

@ -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<Turn>;
activate(input: SkillActivationInput): Promise<PromptLaunchResult>;
recordModelToolActivation(origin: SkillActivationOrigin): void;
}

View file

@ -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<Turn> {
async activate(input: SkillActivationInput): Promise<PromptLaunchResult> {
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 {

View file

@ -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';
@ -618,19 +619,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';

View file

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

View file

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

View file

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

View file

@ -23,6 +23,7 @@ export function stubPermissionModeService(
return mode();
},
setMode: () => {},
setModeAndBroadcast: () => {},
onDidChangeMode: Event.None as Event<PermissionModeChangedContext>,
};
}

View file

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

View file

@ -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('<system>');
expect(text).not.toContain('Image compressed');

View file

@ -17,13 +17,18 @@ 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 { 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 +62,14 @@ function harness() {
reg.define(IEventBus, EventBusService);
reg.define(IAgentSystemReminderService, AgentSystemReminderService);
reg.define(IAgentPromptService, AgentPromptService);
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) };

View file

@ -0,0 +1,82 @@
/**
* Scenario: `IAgentPromptService.submit` is the wire-facing prompt entry
* 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 { 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('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();
});
});

View file

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

View file

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

View file

@ -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 () => {

View file

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

View file

@ -219,6 +219,8 @@ class FakeLoopService implements IAgentLoopService {
onDidFinishStep: new OrderedHookSlot<AfterStepContext>(),
};
cancelFromUser(): void {}
enqueue(_request: StepRequest, _options?: StepEnqueueOptions): EnqueueReceipt {
throw new Error('unused in this suite');
}

View file

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

View file

@ -58,6 +58,7 @@ function permissionMode(mode: PermissionMode = 'auto'): IAgentPermissionModeServ
_serviceBrand: undefined,
mode,
setMode: () => {},
setModeAndBroadcast: () => {},
onDidChangeMode: () => ({ dispose: () => {} }),
};
}

View file

@ -64,6 +64,7 @@ function permissionMode(): IAgentPermissionModeService {
_serviceBrand: undefined,
mode: 'auto',
setMode: () => {},
setModeAndBroadcast: () => {},
onDidChangeMode: () => ({ dispose: () => {} }),
};
}

View file

@ -40,30 +40,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 {
@ -92,7 +113,6 @@ import {
InMemoryStorageService,
AgentFullCompactionService,
IAgentActivityView,
IAgentRPCService,
IAppendLogStore,
IFileSystemStorageService,
ISessionApprovalService,
@ -307,6 +327,18 @@ type RpcPromise<T> = Promise<T> & {
};
interface AgentRpcPassthroughAPI {
prompt: (payload: PromptPayload) => Promisable<PromptLaunchResult | undefined>;
steer: (payload: SteerPayload) => Promisable<PromptLaunchResult | undefined>;
cancel: (payload: CancelPayload) => void;
undoHistory: (payload: UndoHistoryPayload) => Promisable<number>;
setPermission: (payload: SetPermissionPayload) => void;
cancelCompaction: (payload: EmptyPayload) => void;
activateSkill: (payload: SkillActivationInput) => Promisable<PromptLaunchResult>;
activatePluginCommand: (payload: ActivatePluginCommandPayload) => Promisable<void>;
listCommands: (payload: EmptyPayload) => readonly AgentCommandInfo[];
runCommand: (payload: RunCommandPayload) => Promisable<void>;
getContext: (payload: EmptyPayload) => AgentContextData;
getTools: (payload: EmptyPayload) => readonly ToolInfo[];
runShellCommand: (payload: RunShellCommandPayload) => Promisable<ShellCommandResult>;
cancelShellCommand: (payload: CancelShellCommandPayload) => void;
setThinking: (payload: SetThinkingPayload) => void;
@ -339,7 +371,7 @@ interface AgentRpcPassthroughAPI {
getTasks: (payload: GetTasksPayload) => readonly AgentTaskInfo[];
}
type PromiseAgentAPI = PromisifyMethods<AgentAPI & AgentRpcPassthroughAPI>;
type PromiseAgentAPI = PromisifyMethods<AgentRpcPassthroughAPI>;
type GenerateFn = typeof kosongGenerate;
type TestToolResult = ExecutableToolResult & {
@ -1270,8 +1302,7 @@ export class AgentTestContext {
}),
);
const rpcMethods = this.get(IAgentRPCService);
this.rpc = this.createPromiseAgentApi(rpcMethods);
this.rpc = this.createPromiseAgentApi();
if (options.autoConfigure !== false) {
this.configure();
@ -1472,8 +1503,7 @@ export class AgentTestContext {
}
async undoHistory(count: number): Promise<number> {
const rpcMethods = this.get(IAgentRPCService);
return rpcMethods.undoHistory({ count });
return this.get(IAgentConversationUndoService).undo(count);
}
newEvents(): EventSnapshot {
@ -1974,16 +2004,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);
}
@ -1994,6 +2023,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),
@ -2125,7 +2171,7 @@ function createWorkspaceContextStub(
function createPermissionModeService(initialMode: PermissionMode): IAgentPermissionModeService {
let mode = initialMode;
return {
const service: IAgentPermissionModeService = {
_serviceBrand: undefined,
get mode() {
return mode;
@ -2133,8 +2179,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(

View file

@ -1429,6 +1429,7 @@ function agentHandle(
_serviceBrand: undefined,
mode: 'auto',
setMode: () => {},
setModeAndBroadcast: () => {},
onDidChangeMode: Event.None,
} as IAgentPermissionModeService;
return {

View file

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

View file

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

View file

@ -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 `/<skill>`
* message titles the session, matching the native RPC path.
* The engine applies the prompt-metadata update itself
* (main agent only) so a first `/<skill>`
* 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 `/<skill>` 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
// `/<skill>` 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) {

View file

@ -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`).
*/

View file

@ -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<null>(
'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<SessionMetaWire>(
'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<SessionMetaWire>('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<null>('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<SessionMetaWire>('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<null>(
'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<null>(
'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);

View file

@ -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({
@ -45,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. */
@ -55,7 +46,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 +120,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 +200,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;

View file

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

View file

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

View file

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

View file

@ -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,22 +26,22 @@ 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<ReturnType<IAgentRPCService['prompt']>>;
export type PromptLaunchResult = Awaited<ReturnType<IAgentPromptService['submit']>>;
export type ShellCommandResult = Awaited<ReturnType<IAgentShellCommandService['run']>>;
export type SetModelResult = Awaited<ReturnType<IAgentProfileService['setModel']>>;
export type ThinkingLevel = ReturnType<IAgentProfileService['getEffectiveThinkingLevel']>;
export type UsageStatus = Awaited<ReturnType<IAgentUsageService['status']>>;
export type AgentContextData = Awaited<ReturnType<IAgentRPCService['getContext']>>;
export type AgentContextData = {
history: ReturnType<IAgentContextMemoryService['get']>;
tokenCount: ReturnType<IAgentTokenCountingService['statusSize']>;
};
export type AgentCommandInfo = Awaited<ReturnType<IAgentCommandService['list']>>[number];
export type PlanData = Awaited<ReturnType<IAgentPlanService['status']>>;
export type AgentTaskInfo = Awaited<ReturnType<IAgentTaskService['list']>>[number];
export type McpServerEntry = ReturnType<IAgentMcpService['list']>[number];
export interface AgentFacade {
prompt(input: {
input: readonly ContentPart[];
disabledTools?: readonly string[];
}): Promise<PromptLaunchResult>;
prompt(input: { input: readonly ContentPart[] }): Promise<PromptLaunchResult>;
steer(input: { input: readonly ContentPart[] }): Promise<PromptLaunchResult>;
/**
* Activate a skill as a user-slash activation: the engine renders the skill
@ -81,14 +84,17 @@ export interface AgentFacade {
}
export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFacade {
const rpc = (method: string, payload: unknown): Promise<unknown> =>
call(scope, 'agentRPCService', method, [payload]);
return {
prompt: (input) => rpc('prompt', input) as Promise<PromptLaunchResult>,
steer: (input) => rpc('steer', input) as Promise<PromptLaunchResult>,
activateSkill: (input) => rpc('activateSkill', input) as Promise<PromptLaunchResult>,
cancel: (input) => rpc('cancel', input ?? {}) as Promise<void>,
prompt: (input) =>
call(scope, 'agentPromptService', 'submit', [input]) as Promise<PromptLaunchResult>,
steer: (input) =>
call(scope, 'agentPromptService', 'submitSteer', [input]) as Promise<PromptLaunchResult>,
activateSkill: (input) =>
call(scope, 'agentSkillService', 'activate', [input]) as Promise<PromptLaunchResult>,
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<void>,
runShellCommand: (input) =>
call(scope, 'agentShellCommandService', 'run', [input]) as Promise<ShellCommandResult>,
cancelShellCommand: (input) =>
@ -100,11 +106,27 @@ export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFac
call(scope, 'agentProfileService', 'getEffectiveThinkingLevel', []) as Promise<ThinkingLevel>,
setThinking: (level) =>
call(scope, 'agentProfileService', 'setThinking', [level]) as Promise<void>,
setPermission: (mode) => rpc('setPermission', { mode }) as Promise<void>,
setPermission: (mode) =>
call(scope, 'agentPermissionModeService', 'setModeAndBroadcast', [mode]) as Promise<void>,
getUsage: () => call(scope, 'agentUsageService', 'status', []) as Promise<UsageStatus>,
getContext: () => rpc('getContext', {}) as Promise<AgentContextData>,
listCommands: () => rpc('listCommands', {}) as Promise<readonly AgentCommandInfo[]>,
runCommand: (input) => rpc('runCommand', input) as Promise<void>,
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<readonly AgentCommandInfo[]>,
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<void>,
getPlan: () => call(scope, 'agentPlanService', 'status', []) as Promise<PlanData>,
enterPlan: () => call(scope, 'agentPlanService', 'enter', []) as Promise<void>,
clearPlan: () => call(scope, 'agentPlanService', 'clear', []) as Promise<void>,

View file

@ -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<Record<string, ServiceIdentifier<unknown>>>
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,

View file

@ -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<TSchema extends z.ZodType, TEngine> = [MutableDeep<TEngine>] extends [
@ -521,20 +514,32 @@ const _activityViewLifecycle: AssertWire<typeof activityViewLifecycleSchema, Act
const _agentActivityState: AssertEngineToWire<typeof agentActivityStateSchema, AgentActivityState> =
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<AgentAPI['prompt']>[0];
type PromptLaunchResult = NonNullable<ReturnType<AgentAPI['prompt']>>;
type SteerPayload = Parameters<AgentAPI['steer']>[0];
type CancelPayload = Parameters<AgentAPI['cancel']>[0];
type SetPermissionPayload = Parameters<AgentAPI['setPermission']>[0];
type AgentCommandInfo = Awaited<ReturnType<AgentAPI['listCommands']>>[number];
type RunCommandPayload = Parameters<AgentAPI['runCommand']>[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<IAgentPromptService['submit']>[0];
type PromptLaunchResult = NonNullable<Awaited<ReturnType<IAgentPromptService['submit']>>>;
type SteerPayload = Parameters<IAgentPromptService['submitSteer']>[0];
type ActivateSkillPayload = Parameters<IAgentSkillService['activate']>[0];
type AgentCommandInfo = ReturnType<IAgentCommandService['list']>[number];
type RunShellCommandPayload = Parameters<IAgentShellCommandService['run']>[0];
type ShellCommandResult = Awaited<ReturnType<IAgentShellCommandService['run']>>;
type SetModelResult = Awaited<ReturnType<IAgentProfileService['setModel']>>;
type TokenUsage = NonNullable<UsageStatus['total']>;
type PromptPart = Extract<ContentPart, { type: 'text' | 'image_url' | 'video_url' }>;
type EmptyPayload = {};
type CancelPayload = NonNullable<Parameters<AgentFacade['cancel']>[0]>;
type SetPermissionPayload = { mode: PermissionMode };
type RunCommandPayload = Parameters<AgentFacade['runCommand']>[0];
type CancelShellCommandPayload = Parameters<AgentFacade['cancelShellCommand']>[0];
type SetModelPayload = { model: string };
type CancelPlanPayload = NonNullable<Parameters<AgentFacade['cancelPlan']>[0]>;
type GetTasksPayload = NonNullable<Parameters<AgentFacade['getTasks']>[0]>;
type StopTaskPayload = Parameters<AgentFacade['stopTask']>[0];
type GetTaskOutputPayload = Parameters<AgentFacade['getTaskOutput']>[0];
const _emptyPayload: AssertWire<typeof emptyPayloadSchema, EmptyPayload> = true;
const _promptPart: AssertWire<typeof promptPartSchema, PromptPart> = true;

View file

@ -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' }] });
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' }] }],
},
{
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', () => {

View file

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

View file

@ -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,
@ -167,6 +166,7 @@ import {
IAgentActivityView,
IAgentContextInjectorService,
IAgentContextMemoryService,
IAgentConversationUndoService,
IAgentFullCompactionService,
IAgentGoalService,
IAgentPluginService,
@ -174,12 +174,14 @@ import {
IAgentLoopService,
IAgentPermissionModeService,
IAgentPermissionRulesService,
IAgentPluginCommandService,
IAgentProfileService,
IAgentRPCService,
IAgentSkillService,
IAgentSwarmService,
IAgentTaskService,
IAgentTokenCountingService,
IAgentToolPolicyService,
IAgentToolRegistryService,
IBootstrapService,
IConfigService,
IEventService,
@ -223,7 +225,6 @@ import {
PRINT_WAIT_CEILING_S_DEFAULT,
ProfileError,
ProfileErrors,
promptMetadataTextFromSkill,
resolveAgentTaskConfig,
resolveConfigPath,
resolveKimiHome,
@ -965,6 +966,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: {
@ -985,7 +993,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[],
};
@ -1525,20 +1533,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<readonly AgentCommandInfo[]> {
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<void> {
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.
@ -1615,18 +1624,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<void> {
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
@ -1634,7 +1643,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
*/
override async undoHistory(input: SessionIdRpcInput & { count: number }): Promise<void> {
const agent = await this.agentScope(input.sessionId);
await agent.accessor.get(IAgentRPCService).undoHistory({ count: input.count });
await agent.accessor.get(IAgentConversationUndoService).undo(input.count);
}
/**
@ -1682,24 +1691,22 @@ 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
* 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<void> {
const agent = await this.agentFacade(input.sessionId);
await agent.prompt({ input: input.input, disabledTools: input.disabledTools });
await agent.prompt({ input: input.input });
}
/**
* 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.
@ -1738,40 +1745,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<void> {
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<void> {
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,
@ -1796,8 +1795,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
@ -1842,23 +1840,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<void> {
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

View file

@ -2476,8 +2476,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' });