Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2

This commit is contained in:
_Kerman 2026-07-09 16:56:11 +08:00
commit 6ad2edca53
31 changed files with 1153 additions and 136 deletions

View file

@ -160,6 +160,9 @@ const DOMAIN_LAYER = new Map([
['llmRequester', 4],
['profile', 4],
['prompt', 4],
// `shellCommand` orchestrates user `!` commands through `toolRegistry` (L3),
// `contextMemory` / `prompt` (L4) and `eventBus` (L1); its highest dependency is L4.
['shellCommand', 4],
['replayBuilder', 4],
['todo', 4],
['web', 4],

View file

@ -154,6 +154,7 @@ export function compactionUserMessageDisposition(
case 'plugin_command':
return origin.trigger === 'user-slash' ? 'keep' : 'drop';
case 'injection':
case 'shell_command':
case 'compaction_summary':
case 'system_trigger':
case 'task':

View file

@ -1,7 +1,7 @@
import type { ContentPart, Message } from '#/app/llmProtocol/message';
import type { AgentTaskStatus } from '#/agent/task/task';
import type { CronJobOrigin, CronMissedOrigin } from '@moonshot-ai/protocol';
import type { CronJobOrigin, CronMissedOrigin, ShellCommandOrigin } from '@moonshot-ai/protocol';
export type SkillSource = 'project' | 'user' | 'extra' | 'builtin';
@ -68,6 +68,7 @@ export type PromptOrigin =
| SkillActivationOrigin
| PluginCommandOrigin
| InjectionOrigin
| ShellCommandOrigin
| CompactionSummaryOrigin
| SystemTriggerOrigin
| TaskOrigin

View file

@ -202,11 +202,15 @@ export class AgentProfileService implements IAgentProfileService {
}
setThinking(level: string): void {
const wasEnabled = this.thinkingLevel !== 'off';
const previousEffort = this.thinkingLevel;
this.update({ thinkingLevel: level });
const enabled = this.thinkingLevel !== 'off';
if (enabled !== wasEnabled) {
this.telemetry.track('thinking_toggle', { enabled });
const effort = this.thinkingLevel;
if (effort !== previousEffort) {
this.telemetry.track('thinking_toggle', {
enabled: effort !== 'off',
effort,
from: previousEffort,
});
}
}

View file

@ -7,15 +7,10 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'
import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
import { IAgentGoalService } from '#/agent/goal/goal';
import type {
PluginCommandActivatedEvent,
ShellOutputEvent,
ShellStartedEvent,
} from '@moonshot-ai/protocol';
import type { PluginCommandActivatedEvent } from '@moonshot-ai/protocol';
import { IEventBus } from '#/app/event/eventBus';
import { IEventService } from '#/app/event/event';
import { ErrorCodes, KimiError } from '#/errors';
import { userCancellationReason } from '#/_base/utils/abort';
import { IAgentPermissionGate } from '#/agent/permissionGate/permissionGate';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import { IAgentPlanService } from '#/agent/plan/plan';
@ -24,13 +19,13 @@ import { expandCommandArguments } from '#/app/plugin/commands';
import { IPluginService } from '#/app/plugin/plugin';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentPromptService } from '#/agent/prompt/prompt';
import { IAgentShellCommandService } from '#/agent/shellCommand/shellCommand';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { IAgentSkillService } from '#/agent/skill/skill';
import { IAgentSwarmService } from '#/agent/swarm/swarm';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import type { ToolUpdate } from '#/agent/tool/toolContract';
import { IAgentTurnService } from '#/agent/turn/turn';
import { IAgentUsageService } from '#/agent/usage/usage';
import { IAgentUserToolService } from '#/agent/userTool/userTool';
@ -71,20 +66,16 @@ import {
declare module '#/app/event/eventBus' {
interface DomainEventMap {
'shell.output': ShellOutputEvent;
'shell.started': ShellStartedEvent;
'plugin_command.activated': PluginCommandActivatedEvent;
}
}
const SHELL_FOREGROUND_TIMEOUT_S = 2 * 60;
export class AgentRPCService implements IAgentRPCService {
declare readonly _serviceBrand: undefined;
private readonly shellCommandControllers = new Map<string, AbortController>();
constructor(
@IAgentPromptService private readonly promptService: IAgentPromptService,
@IAgentShellCommandService private readonly shellCommand: IAgentShellCommandService,
@IAgentTurnService private readonly turnService: IAgentTurnService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService,
@ -123,70 +114,12 @@ export class AgentRPCService implements IAgentRPCService {
return turn === undefined ? undefined : { turn_id: turn.id };
}
private ensureBashTool() {
const bash = this.toolRegistry.resolve('Bash');
if (bash === undefined) {
throw new Error('Bash tool is not registered.');
}
return bash;
}
async runShellCommand(payload: RunShellCommandPayload): Promise<ShellCommandResult> {
const bash = this.ensureBashTool();
const controller = new AbortController();
if (payload.commandId !== undefined) {
this.shellCommandControllers.set(payload.commandId, controller);
}
let stdout = '';
let stderr = '';
try {
const execution = await bash.resolveExecution({
command: payload.command,
timeout: SHELL_FOREGROUND_TIMEOUT_S,
});
if (execution.isError === true) {
const output = typeof execution.output === 'string' ? execution.output : 'Command failed.';
return { stdout: '', stderr: output, isError: true };
}
const result = await execution.execute({
turnId: -1,
toolCallId: 'shell-command',
signal: controller.signal,
onUpdate: (update: ToolUpdate) => {
if (update.kind === 'stdout') stdout += update.text ?? '';
else if (update.kind === 'stderr') stderr += update.text ?? '';
else return;
if (payload.commandId !== undefined) {
this.eventBus.publish({ type: 'shell.output', commandId: payload.commandId, update });
}
},
onForegroundTaskStart: (taskId: string) => {
if (payload.commandId !== undefined) {
this.eventBus.publish({ type: 'shell.started', commandId: payload.commandId, taskId });
}
},
});
const isError = result.isError === true;
if (typeof result.output === 'string' && result.output.startsWith('task_id: ')) {
return { stdout: result.output, stderr: '', isError: false, backgrounded: true };
}
if (isError && stdout.length === 0 && stderr.length === 0) {
stderr = typeof result.output === 'string' ? result.output : 'Command failed.';
}
return { stdout, stderr, isError };
} finally {
if (payload.commandId !== undefined) {
this.shellCommandControllers.delete(payload.commandId);
}
}
return this.shellCommand.run(payload);
}
cancelShellCommand(payload: CancelShellCommandPayload): void {
this.shellCommandControllers.get(payload.commandId)?.abort(userCancellationReason());
this.shellCommand.cancel(payload.commandId);
}
async steer(payload: SteerPayload): Promise<PromptLaunchResult | undefined> {
@ -208,7 +141,9 @@ export class AgentRPCService implements IAgentRPCService {
}
undoHistory(payload: UndoHistoryPayload): number {
return this.promptService.undo(payload.count);
const undone = this.promptService.undo(payload.count);
this.telemetry.track('conversation_undo', { count: payload.count });
return undone;
}
setThinking(payload: SetThinkingPayload): void {

View file

@ -0,0 +1,32 @@
/**
* `shellCommand` domain (L4) shell command contract.
*
* Defines the Agent-scoped `IAgentShellCommandService` used to run user-initiated
* `!` commands: resolves the builtin Bash tool, records the command and its
* output into context, and notifies the model when a command is detached to
* background. Bound at Agent scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface RunShellCommandInput {
readonly command: string;
readonly commandId?: string;
}
export interface RunShellCommandResult {
readonly stdout: string;
readonly stderr: string;
readonly isError?: boolean;
readonly backgrounded?: boolean;
}
export interface IAgentShellCommandService {
readonly _serviceBrand: undefined;
run(input: RunShellCommandInput): Promise<RunShellCommandResult>;
cancel(commandId: string): void;
}
export const IAgentShellCommandService: ServiceIdentifier<IAgentShellCommandService> =
createDecorator<IAgentShellCommandService>('agentShellCommandService');

View file

@ -0,0 +1,176 @@
/**
* `shellCommand` domain (L4) `IAgentShellCommandService` implementation.
*
* Runs user-initiated `!` commands through the builtin `Bash` tool from
* `toolRegistry`, records the command and output as `shell_command`-origin
* context messages via `contextMemory`, streams live `shell.output` /
* `shell.started` events through `eventBus`, and steers the model through
* `promptService` when a command is detached to background. Bound at Agent
* scope.
*/
import type { ShellOutputEvent, ShellStartedEvent } from '@moonshot-ai/protocol';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { userCancellationReason } from '#/_base/utils/abort';
import { escapeXml } from '#/_base/utils/xml-escape';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentPromptService } from '#/agent/prompt/prompt';
import type { ToolUpdate } from '#/agent/tool/toolContract';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { IEventBus } from '#/app/event/eventBus';
import {
IAgentShellCommandService,
type RunShellCommandInput,
type RunShellCommandResult,
} from './shellCommand';
declare module '#/app/event/eventBus' {
interface DomainEventMap {
'shell.output': ShellOutputEvent;
'shell.started': ShellStartedEvent;
}
}
const SHELL_FOREGROUND_TIMEOUT_S = 2 * 60;
export class AgentShellCommandService implements IAgentShellCommandService {
declare readonly _serviceBrand: undefined;
private readonly shellCommandControllers = new Map<string, AbortController>();
constructor(
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentPromptService private readonly promptService: IAgentPromptService,
@IEventBus private readonly eventBus: IEventBus,
) { }
async run(input: RunShellCommandInput): Promise<RunShellCommandResult> {
// Record the command up front so the model sees it on the next turn even if
// resolution or execution fails below. Mirrors v1 `runShellCommand`
// (parity with claude-code's `shouldQuery: false`): a foreground `!`
// command is written into context but does NOT itself start a turn.
this.appendShellInput(input.command);
const controller = new AbortController();
if (input.commandId !== undefined) {
this.shellCommandControllers.set(input.commandId, controller);
}
let stdout = '';
let stderr = '';
try {
const bash = this.ensureBashTool();
const execution = await bash.resolveExecution({
command: input.command,
timeout: SHELL_FOREGROUND_TIMEOUT_S,
});
if (execution.isError === true) {
const output = typeof execution.output === 'string' ? execution.output : 'Command failed.';
this.appendShellOutput('', output);
return { stdout: '', stderr: output, isError: true };
}
const result = await execution.execute({
turnId: -1,
toolCallId: 'shell-command',
signal: controller.signal,
onUpdate: (update: ToolUpdate) => {
if (update.kind === 'stdout') stdout += update.text ?? '';
else if (update.kind === 'stderr') stderr += update.text ?? '';
else return;
if (input.commandId !== undefined) {
this.eventBus.publish({ type: 'shell.output', commandId: input.commandId, update });
}
},
onForegroundTaskStart: (taskId: string) => {
if (input.commandId !== undefined) {
this.eventBus.publish({ type: 'shell.started', commandId: input.commandId, taskId });
}
},
});
const isError = result.isError === true;
if (typeof result.output === 'string' && result.output.startsWith('task_id: ')) {
// Detached to background (ctrl+b): inject the background-task metadata
// (task_id / status / output path) as a user-invisible message and
// immediately notify the model — mirrors the background-task completion
// notification, but hidden. Not recorded as a `shell_command` output;
// the input above is the only user-visible trace.
this.notifyBackgrounded(result.output);
return { stdout: result.output, stderr: '', isError: false, backgrounded: true };
}
if (isError && stdout.length === 0 && stderr.length === 0) {
stderr = typeof result.output === 'string' ? result.output : 'Command failed.';
}
this.appendShellOutput(stdout, stderr, isError);
return { stdout, stderr, isError };
} catch (error) {
// Covers `ensureBashTool` throwing (Bash not registered) and any
// exception escaping `execute`. Surface the reason as stderr and record
// it so the model and replay see what went wrong instead of a bare RPC
// error.
stderr += error instanceof Error ? error.message : String(error);
this.appendShellOutput(stdout, stderr, true);
return { stdout, stderr, isError: true };
} finally {
if (input.commandId !== undefined) {
this.shellCommandControllers.delete(input.commandId);
}
}
}
cancel(commandId: string): void {
this.shellCommandControllers.get(commandId)?.abort(userCancellationReason());
}
private ensureBashTool() {
const bash = this.toolRegistry.resolve('Bash');
if (bash === undefined) {
throw new Error('Bash tool is not registered.');
}
return bash;
}
private appendShellInput(command: string): void {
const text = `<bash-input>\n${escapeXml(command)}\n</bash-input>`;
this.context.append({
role: 'user',
content: [{ type: 'text', text }],
toolCalls: [],
origin: { kind: 'shell_command', phase: 'input' },
});
}
private appendShellOutput(stdout: string, stderr: string, isError?: boolean): void {
const text = `<bash-stdout>${escapeXml(stdout)}</bash-stdout><bash-stderr>${escapeXml(stderr)}</bash-stderr>`;
this.context.append({
role: 'user',
content: [{ type: 'text', text }],
toolCalls: [],
origin:
isError === true
? { kind: 'shell_command', phase: 'output', isError: true }
: { kind: 'shell_command', phase: 'output' },
});
}
private notifyBackgrounded(output: string): void {
this.promptService.steer({
role: 'user',
content: [{ type: 'text', text: output }],
toolCalls: [],
origin: { kind: 'injection', variant: 'shell_command_backgrounded' },
});
}
}
registerScopedService(
LifecycleScope.Agent,
IAgentShellCommandService,
AgentShellCommandService,
InstantiationType.Delayed,
'shellCommand',
);

View file

@ -61,8 +61,17 @@ export interface SkillToolInput {
}
export const SkillToolInputSchema: z.ZodType<SkillToolInput> = z.object({
skill: z.string(),
args: z.string().optional(),
skill: z
.string()
.describe(
'The exact name of the skill to invoke, spelled as it appears in the current skill listing (e.g. "commit", "pdf").',
),
args: z
.string()
.optional()
.describe(
'Optional argument string for the skill, written like a command line (e.g. `-m "fix bug"`, `123`, a file path). It is split on whitespace (quotes group a token) and expanded into the skill\'s placeholders ($NAME, $1, $ARGUMENTS); if the skill body has no placeholders, the whole string is still appended as a trailing `ARGUMENTS:` line. Omit it only when there is nothing to pass.',
),
});
export class SkillTool implements BuiltinTool<SkillToolInput> {

View file

@ -2,7 +2,7 @@
* `skillCatalog` domain (L3) builtin `ISkillSource` producer.
*
* Yields the code-defined `BUILTIN_SKILLS` as the lowest-priority contribution
* (`builtin`, priority 0) so user / workspace / plugin skills override it on
* (`builtin`, priority 0) so extra / user / workspace / plugin skills override it on
* name collision. Bound at App scope.
*/
@ -11,7 +11,7 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { BUILTIN_SKILLS } from './builtin/builtin';
import type { ISkillSource, SkillContribution } from './skillSource';
import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from './skillSource';
export interface IBuiltinSkillSource extends ISkillSource {
readonly _serviceBrand: undefined;
@ -24,7 +24,7 @@ export class BuiltinSkillSource implements IBuiltinSkillSource {
declare readonly _serviceBrand: undefined;
readonly id = 'builtin';
readonly priority = 0;
readonly priority = SKILL_SOURCE_PRIORITY.builtin;
async load(): Promise<SkillContribution> {
return { skills: BUILTIN_SKILLS };

View file

@ -0,0 +1,27 @@
/**
* `skillCatalog` domain (L3) skill config sections.
*
* Registers the v1-compatible top-level config domains `extraSkillDirs` and
* `mergeAllAvailableSkills`. Values stay camelCase in memory; TOML uses the
* snake_case keys `extra_skill_dirs` and `merge_all_available_skills`.
*/
import { z } from 'zod';
import { registerConfigSection } from '#/app/config/configSectionContributions';
export const EXTRA_SKILL_DIRS_SECTION = 'extraSkillDirs';
export const ExtraSkillDirsConfigSchema = z.array(z.string()).optional();
export type ExtraSkillDirsConfig = z.infer<typeof ExtraSkillDirsConfigSchema>;
registerConfigSection(EXTRA_SKILL_DIRS_SECTION, ExtraSkillDirsConfigSchema, {
defaultValue: [],
});
export const MERGE_ALL_AVAILABLE_SKILLS_SECTION = 'mergeAllAvailableSkills';
export const MergeAllAvailableSkillsConfigSchema = z.boolean().optional();
export type MergeAllAvailableSkillsConfig = z.infer<typeof MergeAllAvailableSkillsConfigSchema>;
registerConfigSection(MERGE_ALL_AVAILABLE_SKILLS_SECTION, MergeAllAvailableSkillsConfigSchema, {
defaultValue: true,
});

View file

@ -5,11 +5,13 @@
* App-scope default so tests and scopes work without a filesystem; the
* production composition root overrides it with the filesystem backend. A call
* seeded with project roots returns the project skills, one seeded with user
* roots returns the user skills, and an empty root list (the common test case
* where the resolved directories do not exist on disk) returns everything the
* double holds user skills first, project skills last, so project entries win
* the within-list collision resolution the same way the workspace source's
* higher priority wins across sources. App-scoped.
* roots returns the user skills, one seeded with extra roots returns the extra
* skills, one seeded with plugin roots returns the plugin skills, and an empty
* root list (the common test case where the resolved directories do not exist on
* disk) returns the user and project skills the double holds user skills first,
* project skills last, so project entries win the within-list collision resolution
* the same way the workspace source's higher priority wins across sources.
* App-scoped.
*/
import { InstantiationType } from '#/_base/di/extensions';
@ -24,6 +26,8 @@ export class InMemorySkillDiscovery implements ISkillDiscovery {
private projectSkills: readonly SkillDefinition[] = [];
private userSkills: readonly SkillDefinition[] = [];
private pluginSkills: readonly SkillDefinition[] = [];
private extraSkills: readonly SkillDefinition[] = [];
setProjectSkills(skills: readonly SkillDefinition[]): void {
this.projectSkills = [...skills];
@ -33,11 +37,21 @@ export class InMemorySkillDiscovery implements ISkillDiscovery {
this.userSkills = [...skills];
}
setPluginSkills(skills: readonly SkillDefinition[]): void {
this.pluginSkills = [...skills];
}
setExtraSkills(skills: readonly SkillDefinition[]): void {
this.extraSkills = [...skills];
}
async discover(roots: readonly SkillRoot[]): Promise<SkillDiscoveryResult> {
const skills: SkillDefinition[] = [];
if (roots.length === 0) {
skills.push(...this.userSkills, ...this.projectSkills);
} else {
if (roots.some((root) => root.plugin !== undefined)) skills.push(...this.pluginSkills);
if (roots.some((root) => root.source === 'extra')) skills.push(...this.extraSkills);
if (roots.some((root) => root.source === 'user')) skills.push(...this.userSkills);
if (roots.some((root) => root.source === 'project')) skills.push(...this.projectSkills);
}

View file

@ -0,0 +1,34 @@
/**
* `skillCatalog` domain (L3) runtime options for skill discovery.
*
* Holds process-level runtime overrides that affect how skill roots are
* resolved. `explicitDirs` mirrors v1's SDK `skillDirs`: when present, default
* user / project discovery is skipped and the explicit directories are used as
* the user source. Bound at App scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
export interface ISkillCatalogRuntimeOptions {
readonly _serviceBrand: undefined;
readonly explicitDirs?: readonly string[];
}
export const ISkillCatalogRuntimeOptions: ServiceIdentifier<ISkillCatalogRuntimeOptions> =
createDecorator<ISkillCatalogRuntimeOptions>('skillCatalogRuntimeOptions');
export class SkillCatalogRuntimeOptions implements ISkillCatalogRuntimeOptions {
declare readonly _serviceBrand: undefined;
constructor(readonly explicitDirs?: readonly string[]) {}
}
registerScopedService(
LifecycleScope.App,
ISkillCatalogRuntimeOptions,
SkillCatalogRuntimeOptions,
InstantiationType.Delayed,
'skillCatalog',
);

View file

@ -22,25 +22,49 @@ const USER_GENERIC_DIRS = ['.agents/skills'] as const;
const PROJECT_BRAND_DIRS = ['.kimi-code/skills'] as const;
const PROJECT_GENERIC_DIRS = ['.agents/skills'] as const;
export interface SkillRootsOptions {
readonly mergeAllAvailableSkills?: boolean;
}
export async function userRoots(
homeDir: string,
osHomeDir: string,
options: SkillRootsOptions = {},
): Promise<readonly SkillRoot[]> {
const roots: SkillRoot[] = [];
const mergeAllAvailableSkills = options.mergeAllAvailableSkills ?? true;
// homeDir is already the brand data dir, so brand skills live at <homeDir>/skills.
await pushBrandGroup(roots, USER_BRAND_DIRS, homeDir, 'user');
await pushBrandGroup(roots, USER_BRAND_DIRS, homeDir, 'user', mergeAllAvailableSkills);
await pushFirstExisting(roots, USER_GENERIC_DIRS, osHomeDir, 'user');
return roots;
}
export async function projectRoots(workDir: string): Promise<readonly SkillRoot[]> {
export async function projectRoots(
workDir: string,
options: SkillRootsOptions = {},
): Promise<readonly SkillRoot[]> {
const projectRoot = await findProjectRoot(workDir);
const roots: SkillRoot[] = [];
await pushBrandGroup(roots, PROJECT_BRAND_DIRS, projectRoot, 'project');
const mergeAllAvailableSkills = options.mergeAllAvailableSkills ?? true;
await pushBrandGroup(roots, PROJECT_BRAND_DIRS, projectRoot, 'project', mergeAllAvailableSkills);
await pushFirstExisting(roots, PROJECT_GENERIC_DIRS, projectRoot, 'project');
return roots;
}
export async function configuredRoots(
dirs: readonly string[],
workDir: string,
osHomeDir: string,
source: SkillSource,
): Promise<readonly SkillRoot[]> {
const projectRoot = await findProjectRoot(workDir);
const roots: SkillRoot[] = [];
for (const dir of dirs) {
await pushExistingRoot(roots, resolveConfiguredDir(dir, projectRoot, osHomeDir), source);
}
return roots;
}
async function findProjectRoot(workDir: string): Promise<string> {
const start = path.resolve(workDir);
let current = start;
@ -68,7 +92,12 @@ async function pushBrandGroup(
dirs: readonly string[],
base: string,
source: SkillSource,
mergeAllAvailableSkills: boolean,
): Promise<void> {
if (!mergeAllAvailableSkills) {
await pushFirstExisting(out, dirs, base, source);
return;
}
for (const dir of dirs) {
await pushExistingRoot(out, path.join(base, dir), source);
}
@ -85,6 +114,13 @@ async function pushExistingRoot(
return true;
}
function resolveConfiguredDir(dir: string, projectRoot: string, osHomeDir: string): string {
if (dir === '~') return osHomeDir;
if (dir.startsWith('~/')) return path.join(osHomeDir, dir.slice(2));
if (path.isAbsolute(dir)) return dir;
return path.resolve(projectRoot, dir);
}
async function isDir(p: string): Promise<boolean> {
try {
return (await fs.stat(p)).isDirectory();

View file

@ -5,7 +5,7 @@
* a `SkillContribution` and advertises a `priority` so the Session sink can
* ordered-merge contributions (higher priority wins name collisions). Sources
* PUSH into the sink; the sink is a dumb ordered-merge table. Concrete sources
* (builtin/user at App scope, workspace/plugin at Session scope) each bind
* (builtin/user at App scope, extra/workspace/plugin at Session scope) each bind
* their own DI token extending this contract.
*/
@ -17,6 +17,14 @@ export interface SkillContribution {
readonly skills: readonly SkillDefinition[];
}
export const SKILL_SOURCE_PRIORITY = {
builtin: 0,
plugin: 5,
extra: 10,
user: 20,
workspace: 30,
} as const;
export interface ISkillSource {
readonly _serviceBrand: undefined;
readonly id: string;

View file

@ -2,18 +2,26 @@
* `skillCatalog` domain (L3) user/brand `ISkillSource` producer.
*
* Discovers user skills from the bootstrap home directories through
* `ISkillDiscovery`, contributing them at priority 10 (above builtin, below
* workspace). Reads home paths from `bootstrap`. Bound at App scope.
* `ISkillDiscovery`, contributing them at priority 20 (above extra / plugin /
* builtin, below workspace). Reads home paths from `bootstrap`. Bound at App scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { InstantiationType } from '#/_base/di/extensions';
import { Disposable } from '#/_base/di/lifecycle';
import { Emitter, type Event } from '#/_base/event';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import {
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
type MergeAllAvailableSkillsConfig,
} from './configSection';
import { ISkillCatalogRuntimeOptions } from './skillCatalogRuntimeOptions';
import { ISkillDiscovery } from './skillDiscovery';
import { userRoots } from './skillRoots';
import type { ISkillSource, SkillContribution } from './skillSource';
import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from './skillSource';
export interface IUserFileSkillSource extends ISkillSource {
readonly _serviceBrand: undefined;
@ -22,19 +30,38 @@ export interface IUserFileSkillSource extends ISkillSource {
export const IUserFileSkillSource: ServiceIdentifier<IUserFileSkillSource> =
createDecorator<IUserFileSkillSource>('userFileSkillSource');
export class UserFileSkillSource implements IUserFileSkillSource {
export class UserFileSkillSource extends Disposable implements IUserFileSkillSource {
declare readonly _serviceBrand: undefined;
readonly id = 'user';
readonly priority = 10;
readonly priority = SKILL_SOURCE_PRIORITY.user;
private readonly onDidChangeEmitter = this._register(new Emitter<void>());
readonly onDidChange: Event<void> = this.onDidChangeEmitter.event;
constructor(
@ISkillDiscovery private readonly discovery: ISkillDiscovery,
@IBootstrapService private readonly bootstrap: IBootstrapService,
) {}
@IConfigService private readonly config: IConfigService,
@ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions,
) {
super();
this._register(
this.config.onDidSectionChange((event) => {
if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire();
}),
);
}
async load(): Promise<SkillContribution> {
return this.discovery.discover(await userRoots(this.bootstrap.homeDir, this.bootstrap.osHomeDir));
if ((this.runtimeOptions.explicitDirs?.length ?? 0) > 0) {
return { skills: [] };
}
await this.config.ready;
const mergeAllAvailableSkills =
this.config.get<MergeAllAvailableSkillsConfig>(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true;
return this.discovery.discover(
await userRoots(this.bootstrap.homeDir, this.bootstrap.osHomeDir, { mergeAllAvailableSkills }),
);
}
}

View file

@ -81,6 +81,7 @@ export * from '#/app/provider/providerService';
import '#/app/platform/configSection';
export * from '#/app/platform/platform';
export * from '#/app/platform/platformService';
import '#/app/skillCatalog/configSection';
import '#/app/protocol/errors';
export type { ChatProvider } from '#/app/llmProtocol/provider';
export type { GenerateResult } from '#/app/llmProtocol/generate';
@ -132,6 +133,8 @@ import '#/agent/skill/tools/skill';
export * from '#/agent/skill/skill';
export * from '#/agent/skill/skillService';
export * from '#/app/skillCatalog/types';
export * from '#/app/skillCatalog/configSection';
export * from '#/app/skillCatalog/skillCatalogRuntimeOptions';
export * from '#/app/skillCatalog/parser';
export * from '#/app/skillCatalog/registry';
export * from '#/app/skillCatalog/errors';
@ -144,6 +147,8 @@ export * from '#/app/skillCatalog/builtinSkillSource';
export * from '#/app/skillCatalog/userFileSkillSource';
export * from '#/session/sessionSkillCatalog/skillCatalog';
export * from '#/session/sessionSkillCatalog/skillCatalogService';
export * from '#/session/sessionSkillCatalog/extraFileSkillSource';
export * from '#/session/sessionSkillCatalog/explicitFileSkillSource';
export * from '#/session/sessionSkillCatalog/workspaceFileSkillSource';
export * from '#/session/sessionSkillCatalog/pluginSkillSource';
export * from '#/agent/permissionGate/permissionGate';
@ -370,6 +375,8 @@ export * from '#/app/messageLegacy/messageLegacy';
export * from '#/app/messageLegacy/messageLegacyService';
export * from '#/agent/replayBuilder/replayTimelineModel';
export * from '#/agent/replayBuilder/types';
export * from '#/agent/shellCommand/shellCommand';
export * from '#/agent/shellCommand/shellCommandService';
export * from '#/agent/rpc/rpc';
export * from '#/agent/rpc/rpcService';
export * from '#/agent/scopeContext/scopeContext';

View file

@ -0,0 +1,58 @@
/**
* `sessionSkillCatalog` domain (L3) explicit `ISkillSource` producer.
*
* Mirrors v1 SDK `skillDirs`: when runtime options provide `explicitDirs`, this
* source contributes those directories as the user source, resolving relative
* paths against the session project root. When no explicit dirs are configured,
* it yields nothing so default user / project discovery remains active. Bound at
* Session scope so each session resolves paths against its own workDir.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { configuredRoots } from '#/app/skillCatalog/skillRoots';
import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions';
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
export interface IExplicitFileSkillSource extends ISkillSource {
readonly _serviceBrand: undefined;
}
export const IExplicitFileSkillSource: ServiceIdentifier<IExplicitFileSkillSource> =
createDecorator<IExplicitFileSkillSource>('explicitFileSkillSource');
export class ExplicitFileSkillSource implements IExplicitFileSkillSource {
declare readonly _serviceBrand: undefined;
readonly id = 'explicit';
readonly priority = SKILL_SOURCE_PRIORITY.user;
constructor(
@ISkillDiscovery private readonly discovery: ISkillDiscovery,
@ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions,
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
@IBootstrapService private readonly bootstrap: IBootstrapService,
) {}
async load(): Promise<SkillContribution> {
const explicitDirs = this.runtimeOptions.explicitDirs ?? [];
if (explicitDirs.length === 0) {
return { skills: [] };
}
return this.discovery.discover(
await configuredRoots(explicitDirs, this.workspace.workDir, this.bootstrap.osHomeDir, 'user'),
);
}
}
registerScopedService(
LifecycleScope.Session,
IExplicitFileSkillSource,
ExplicitFileSkillSource,
InstantiationType.Delayed,
'sessionSkillCatalog',
);

View file

@ -0,0 +1,71 @@
/**
* `sessionSkillCatalog` domain (L3) extra `ISkillSource` producer.
*
* Discovers user-configured extra skill directories (`extraSkillDirs`) through
* `ISkillDiscovery`, contributing them at priority 10 (above plugin / builtin,
* below user / workspace). Relative paths resolve against the session project
* root; `~` and `~/...` resolve against the bootstrap home dir. Bound at Session
* scope so each session reads its own workspace root.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { InstantiationType } from '#/_base/di/extensions';
import { Disposable } from '#/_base/di/lifecycle';
import { Emitter, type Event } from '#/_base/event';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import {
EXTRA_SKILL_DIRS_SECTION,
type ExtraSkillDirsConfig,
} from '#/app/skillCatalog/configSection';
import { configuredRoots } from '#/app/skillCatalog/skillRoots';
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
export interface IExtraFileSkillSource extends ISkillSource {
readonly _serviceBrand: undefined;
}
export const IExtraFileSkillSource: ServiceIdentifier<IExtraFileSkillSource> =
createDecorator<IExtraFileSkillSource>('extraFileSkillSource');
export class ExtraFileSkillSource extends Disposable implements IExtraFileSkillSource {
declare readonly _serviceBrand: undefined;
readonly id = 'extra';
readonly priority = SKILL_SOURCE_PRIORITY.extra;
private readonly onDidChangeEmitter = this._register(new Emitter<void>());
readonly onDidChange: Event<void> = this.onDidChangeEmitter.event;
constructor(
@ISkillDiscovery private readonly discovery: ISkillDiscovery,
@IConfigService private readonly config: IConfigService,
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
@IBootstrapService private readonly bootstrap: IBootstrapService,
) {
super();
this._register(
this.config.onDidSectionChange((event) => {
if (event.domain === EXTRA_SKILL_DIRS_SECTION) this.onDidChangeEmitter.fire();
}),
);
}
async load(): Promise<SkillContribution> {
await this.config.ready;
const extraSkillDirs = this.config.get<ExtraSkillDirsConfig>(EXTRA_SKILL_DIRS_SECTION) ?? [];
return this.discovery.discover(
await configuredRoots(extraSkillDirs, this.workspace.workDir, this.bootstrap.osHomeDir, 'extra'),
);
}
}
registerScopedService(
LifecycleScope.Session,
IExtraFileSkillSource,
ExtraFileSkillSource,
InstantiationType.Delayed,
'sessionSkillCatalog',
);

View file

@ -2,8 +2,9 @@
* `sessionSkillCatalog` domain (L3) plugin `ISkillSource` producer.
*
* Discovers skills contributed by enabled plugins through `ISkillDiscovery`
* (roots from `plugin.pluginSkillRoots()`), contributing them at priority 25
* (above workspace, so plugin skills win name collisions). Re-emits
* (roots from `plugin.pluginSkillRoots()`), contributing them at priority 5
* (above builtin, below extra / user / workspace, so project, user and extra skills win name
* collisions). Re-emits
* `plugin.onDidReload` as `onDidChange` so the sink re-pulls plugin skills when
* plugins reload. Bound at Session scope.
*/
@ -13,7 +14,7 @@ import { InstantiationType } from '#/_base/di/extensions';
import type { Event } from '#/_base/event';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
import type { ISkillSource, SkillContribution } from '#/app/skillCatalog/skillSource';
import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource';
import { IPluginService } from '#/app/plugin/plugin';
export interface IPluginSkillSource extends ISkillSource {
@ -27,7 +28,7 @@ export class PluginSkillSource implements IPluginSkillSource {
declare readonly _serviceBrand: undefined;
readonly id = 'plugin';
readonly priority = 25;
readonly priority = SKILL_SOURCE_PRIORITY.plugin;
readonly onDidChange: Event<void> = (listener, thisArg, disposables) =>
this.plugins.onDidReload(() => listener(undefined as void), thisArg, disposables);

View file

@ -1,10 +1,10 @@
/**
* `sessionSkillCatalog` domain (L3) `ISessionSkillCatalog` sink implementation.
*
* Dumb ordered-merge table: pulls the four eager `ISkillSource`s (builtin /
* user / workspace / plugin) and folds their contributions into an in-memory
* Dumb ordered-merge table: pulls the six eager `ISkillSource`s (builtin /
* user / explicit / extra / workspace / plugin) and folds their contributions into an in-memory
* catalog by priority, so higher-priority sources win name collisions. `ready`
* resolves once all four have completed their first `load()`+merge; a source's
* resolves once all six have completed their first `load()`+merge; a source's
* `onDidChange` (e.g. plugin reload) re-pulls just that source and re-merges,
* firing `onDidChange`. `set`/`remove` (`ISkillCatalogSink`) let ad-hoc sources
* push contributions. Bound at Session scope; the same instance is the
@ -22,6 +22,8 @@ import type { SkillCatalog } from '#/app/skillCatalog/types';
import { IUserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource';
import { IPluginSkillSource } from './pluginSkillSource';
import { IExtraFileSkillSource } from './extraFileSkillSource';
import { IExplicitFileSkillSource } from './explicitFileSkillSource';
import { ISessionSkillCatalog, type ISkillCatalogSink } from './skillCatalog';
import { IWorkspaceFileSkillSource } from './workspaceFileSkillSource';
@ -44,11 +46,13 @@ export class SessionSkillCatalogService
constructor(
@IBuiltinSkillSource builtin: IBuiltinSkillSource,
@IUserFileSkillSource user: IUserFileSkillSource,
@IExplicitFileSkillSource explicit: IExplicitFileSkillSource,
@IExtraFileSkillSource extra: IExtraFileSkillSource,
@IWorkspaceFileSkillSource workspace: IWorkspaceFileSkillSource,
@IPluginSkillSource plugin: IPluginSkillSource,
) {
super();
this.sources = [builtin, user, workspace, plugin].toSorted((a, b) => a.priority - b.priority);
this.sources = [builtin, user, explicit, extra, workspace, plugin].toSorted((a, b) => a.priority - b.priority);
for (const s of this.sources) {
if (s.onDidChange) this._register(s.onDidChange(() => { void this.reloadSource(s.id); }));
}

View file

@ -3,16 +3,24 @@
*
* Discovers project skills from the session's current `workDir`
* (`workspaceContext`) through `ISkillDiscovery`, contributing them at priority
* 20 (above user, below plugin). Bound at Session scope so each session reads
* 30 (above user / extra / plugin / builtin). Bound at Session scope so each session reads
* its own workspace root.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { InstantiationType } from '#/_base/di/extensions';
import { Disposable } from '#/_base/di/lifecycle';
import { Emitter, type Event } from '#/_base/event';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IConfigService } from '#/app/config/config';
import {
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
type MergeAllAvailableSkillsConfig,
} from '#/app/skillCatalog/configSection';
import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions';
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
import { projectRoots } from '#/app/skillCatalog/skillRoots';
import type { ISkillSource, SkillContribution } from '#/app/skillCatalog/skillSource';
import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
export interface IWorkspaceFileSkillSource extends ISkillSource {
@ -22,19 +30,36 @@ export interface IWorkspaceFileSkillSource extends ISkillSource {
export const IWorkspaceFileSkillSource: ServiceIdentifier<IWorkspaceFileSkillSource> =
createDecorator<IWorkspaceFileSkillSource>('workspaceFileSkillSource');
export class WorkspaceFileSkillSource implements IWorkspaceFileSkillSource {
export class WorkspaceFileSkillSource extends Disposable implements IWorkspaceFileSkillSource {
declare readonly _serviceBrand: undefined;
readonly id = 'workspace';
readonly priority = 20;
readonly priority = SKILL_SOURCE_PRIORITY.workspace;
private readonly onDidChangeEmitter = this._register(new Emitter<void>());
readonly onDidChange: Event<void> = this.onDidChangeEmitter.event;
constructor(
@ISkillDiscovery private readonly discovery: ISkillDiscovery,
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
) {}
@IConfigService private readonly config: IConfigService,
@ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions,
) {
super();
this._register(
this.config.onDidSectionChange((event) => {
if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire();
}),
);
}
async load(): Promise<SkillContribution> {
return this.discovery.discover(await projectRoots(this.workspace.workDir));
if ((this.runtimeOptions.explicitDirs?.length ?? 0) > 0) {
return { skills: [] };
}
await this.config.ready;
const mergeAllAvailableSkills =
this.config.get<MergeAllAvailableSkillsConfig>(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true;
return this.discovery.discover(await projectRoots(this.workspace.workDir, { mergeAllAvailableSkills }));
}
}

View file

@ -18,6 +18,11 @@ import { ConfigRegistry, ConfigService } from '#/app/config/configService';
// live-overlay test below can read `config.get('cron')`.
import '#/app/cron/configSection';
import type { CronConfig } from '#/app/cron/configSection';
import '#/app/skillCatalog/configSection';
import {
EXTRA_SKILL_DIRS_SECTION,
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
} from '#/app/skillCatalog/configSection';
import { ILogService } from '#/_base/log/log';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
@ -324,6 +329,15 @@ describe('ConfigService env overlay (live)', () => {
});
});
describe('skill config sections', () => {
it('registers defaults for extraSkillDirs and mergeAllAvailableSkills', () => {
const registry = new ConfigRegistry();
expect(registry.getSection(EXTRA_SKILL_DIRS_SECTION)?.defaultValue).toEqual([]);
expect(registry.getSection(MERGE_ALL_AVAILABLE_SKILLS_SECTION)?.defaultValue).toBe(true);
});
});
function toolNames(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value

View file

@ -8,8 +8,10 @@ import {
createTestAgent,
llmGenerateServices,
modelProviderOptionServices,
telemetryServices,
type TestAgentContext,
} from '../harness';
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
type TestKimiConfig = ReturnType<Parameters<typeof configServices>[0]>;
type GenerateFn = Parameters<typeof llmGenerateServices>[0];
@ -24,15 +26,18 @@ describe('ConfigState model capabilities', () => {
let requester: IAgentLLMRequesterService;
let kimiConfig: TestKimiConfig;
let generate: GenerateFn;
let records: TelemetryRecord[];
beforeEach(() => {
kimiConfig = {
providers: {},
};
generate = defaultGenerate;
records = [];
ctx = createTestAgent(
configServices(() => kimiConfig),
llmGenerateServices((...args) => generate(...args)),
telemetryServices(recordingTelemetry(records)),
);
profile = ctx.get(IAgentProfileService);
requester = ctx.get(IAgentLLMRequesterService);
@ -78,6 +83,35 @@ describe('ConfigState model capabilities', () => {
});
});
it('tracks thinking_toggle with the effort payload when effort changes', () => {
kimiConfig = {
providers: {
kimi: {
type: 'kimi',
apiKey: 'test-key',
baseUrl: 'https://api.example.test/v1',
},
},
models: {
'kimi-code/kimi-for-coding': {
provider: 'kimi',
model: 'kimi-for-coding',
maxContextSize: 1_000_000,
},
},
};
profile.update({ modelAlias: 'kimi-code/kimi-for-coding' });
profile.setThinking('off');
records.length = 0;
profile.setThinking('low');
expect(records).toContainEqual({
event: 'thinking_toggle',
properties: { enabled: true, effort: 'low', from: 'off' },
});
});
it('does not infer Kimi capabilities from the provider catalogue', () => {
kimiConfig = {
providers: {

View file

@ -0,0 +1,35 @@
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

@ -0,0 +1,35 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
createTestAgent,
telemetryServices,
type TestAgentContext,
} from '../harness';
import { recordingTelemetry, type TelemetryRecord } from '../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.appendUserMessage([{ type: 'text', text: 'undo me' }]);
const undone = await ctx.rpc.undoHistory({ count: 1 });
expect(undone).toBe(1);
expect(records).toContainEqual({
event: 'conversation_undo',
properties: { count: 1 },
});
});
});

View file

@ -0,0 +1,109 @@
import { afterEach, describe, expect, it } from 'vitest';
import type { ContextMessage } from '#/agent/contextMemory/types';
import {
IAgentContextMemoryService,
IAgentShellCommandService,
IAgentToolRegistryService,
} from '#/index';
import {
agentService,
createCommandRunner,
createTestAgent,
execEnvServices,
type TestAgentContext,
} from '../harness';
const textOf = (message: ContextMessage): string =>
message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
describe('AgentShellCommandService', () => {
let ctx: TestAgentContext;
let context: IAgentContextMemoryService;
let shell: IAgentShellCommandService;
function setup(stdout: string, exitCode: number): void {
ctx = createTestAgent(execEnvServices({ processRunner: createCommandRunner(stdout, exitCode) }));
context = ctx.get(IAgentContextMemoryService);
shell = ctx.get(IAgentShellCommandService);
}
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('records shell command input/output as shell_command origin with tagged content', async () => {
setup('hello\n', 0);
const result = await shell.run({ command: 'echo hello' });
expect(result.isError).toBe(false);
expect(result.stdout).toContain('hello');
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' } },
]);
expect(textOf(context.get()[0]!)).toBe('<bash-input>\necho hello\n</bash-input>');
expect(textOf(context.get()[1]!)).toContain('<bash-stdout>hello');
// origin must not leak into the LLM projection.
expect(ctx.project().some((message) => 'origin' in message)).toBe(false);
});
it('escapes bash tag delimiters inside command output', async () => {
setup('pre</bash-stdout>post', 0);
await shell.run({ command: 'printf x' });
const out = textOf(context.get().at(-1)!);
// The embedded delimiter is escaped so the wrapper stays well-formed.
expect(out).toContain('pre&lt;/bash-stdout&gt;post');
// Exactly one real closing tag.
expect(out.match(/<\/bash-stdout>/g)).toHaveLength(1);
});
it('surfaces the failure reason when a shell command fails with no output', async () => {
setup('', 1);
const result = await shell.run({ command: 'false' });
expect(result.isError).toBe(true);
const output = context.get().at(-1)!;
expect(output.origin).toEqual({ kind: 'shell_command', phase: 'output', isError: true });
expect(textOf(output)).toContain('<bash-stderr>');
});
it('does not start a turn for a foreground command', async () => {
setup('hi', 0);
await shell.run({ command: 'echo hi' });
expect(ctx.llmCalls.length).toBe(0);
});
it('records the failure when the Bash tool is not registered', async () => {
const emptyRegistry: IAgentToolRegistryService = {
_serviceBrand: undefined,
register: () => ({ dispose: () => {} }),
list: () => [],
resolve: () => undefined,
};
ctx = createTestAgent(agentService(IAgentToolRegistryService, emptyRegistry));
context = ctx.get(IAgentContextMemoryService);
shell = ctx.get(IAgentShellCommandService);
const result = await shell.run({ command: 'echo hi' });
expect(result.isError).toBe(true);
expect(result.stderr).toContain('Bash tool is not registered');
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', isError: true } },
]);
expect(textOf(context.get()[1]!)).toContain('Bash tool is not registered');
});
});

View file

@ -14,6 +14,7 @@ import {
MAX_SKILL_QUERY_DEPTH,
NestedSkillTooDeepError,
SkillTool,
SkillToolInputSchema,
} from '#/agent/skill/tools/skill';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
@ -241,16 +242,26 @@ describe('SkillTool', () => {
expect(tool.name).toBe('Skill');
expect(tool.description).toContain('Invoke a registered skill');
expect(tool.description).toContain(String(MAX_SKILL_QUERY_DEPTH));
expect(tool.description).toContain('kimi-skill-loaded');
expect(tool.description).toContain('with the same `args`');
expect(tool.parameters).toMatchObject({
type: 'object',
required: ['skill'],
additionalProperties: false,
properties: {
skill: expect.objectContaining({ type: 'string' }),
args: expect.objectContaining({ type: 'string' }),
skill: expect.objectContaining({
type: 'string',
description: expect.stringMatching(/skill listing/i),
}),
args: expect.objectContaining({
type: 'string',
description: expect.stringMatching(/argument/i),
}),
},
});
expect(SkillToolInputSchema.safeParse({ skill: 'commit' }).success).toBe(true);
expect(SkillToolInputSchema.safeParse({ skill: 'commit', args: '-m fix' }).success).toBe(true);
expect(SkillToolInputSchema.safeParse({}).success).toBe(false);
});
it('returns a tool error when the skill is unknown', async () => {

View file

@ -5,6 +5,12 @@ import { LifecycleScope } from '#/_base/di/scope';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IPluginService } from '#/app/plugin/plugin';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import { IConfigService } from '#/app/config/config';
import {
EXTRA_SKILL_DIRS_SECTION,
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
} from '#/app/skillCatalog/configSection';
import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions';
import '../../src/index';
import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
@ -19,6 +25,51 @@ const bootstrapStub = {
osHomeDir: '/home',
} as unknown as IBootstrapService;
function configStub(): IConfigService & {
setExtraSkillDirs(dirs: readonly string[]): void;
setMergeAllAvailableSkills(value: boolean): void;
fireSectionChange(domain: string): void;
} {
let extraSkillDirs: readonly string[] = [];
let mergeAllAvailableSkills = true;
const sectionChangeListeners: Array<(event: unknown) => void> = [];
return {
_serviceBrand: undefined,
ready: Promise.resolve(),
onDidChangeConfiguration: () => ({ dispose: () => {} }),
onDidSectionChange: (listener: (event: unknown) => void) => {
sectionChangeListeners.push(listener);
return { dispose: () => {} };
},
get: (domain: string) => {
if (domain === EXTRA_SKILL_DIRS_SECTION) return [...extraSkillDirs];
if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return mergeAllAvailableSkills;
return undefined;
},
inspect: () => ({ value: undefined, defaultValue: undefined, userValue: undefined, memoryValue: undefined }),
getAll: () => ({}),
set: async () => {},
replace: async () => {},
reload: async () => {},
diagnostics: () => [],
setExtraSkillDirs: (dirs: readonly string[]) => {
extraSkillDirs = [...dirs];
},
setMergeAllAvailableSkills: (value: boolean) => {
mergeAllAvailableSkills = value;
},
fireSectionChange: (domain: string) => {
for (const listener of sectionChangeListeners) {
listener({ domain, source: 'set', value: undefined, previousValue: undefined });
}
},
} as unknown as IConfigService & {
setExtraSkillDirs(dirs: readonly string[]): void;
setMergeAllAvailableSkills(value: boolean): void;
fireSectionChange(domain: string): void;
};
}
function pluginStub(skillRoots: readonly SkillRoot[] = []): IPluginService {
return {
_serviceBrand: undefined,
@ -67,14 +118,22 @@ function makeHost(
store: ISkillDiscovery,
ws: ISessionWorkspaceContext,
pluginRoots: readonly SkillRoot[] = [],
explicitDirs?: readonly string[],
) {
const config = configStub();
const runtimeOptions = {
_serviceBrand: undefined,
explicitDirs,
} as unknown as ISkillCatalogRuntimeOptions;
const host = createScopedTestHost([
stubPair(ISkillDiscovery, store),
stubPair(IBootstrapService, bootstrapStub),
stubPair(IConfigService, config),
stubPair(ISkillCatalogRuntimeOptions, runtimeOptions),
stubPair(IPluginService, pluginStub(pluginRoots)),
]);
const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]);
return { host, session };
return { host, session, config };
}
describe('SessionSkillCatalogService', () => {
@ -102,6 +161,167 @@ describe('SessionSkillCatalogService', () => {
host.dispose();
});
it('orders project, user and plugin skills as project > user > plugin', async () => {
const store = new InMemorySkillDiscovery();
store.setUserSkills([
stubSkill('shared', { description: 'from user' }),
stubSkill('user-plugin', { description: 'from user' }),
]);
store.setProjectSkills([stubSkill('shared', { description: 'from project' })]);
store.setExtraSkills([
stubSkill('shared', { description: 'from extra', source: 'extra' }),
stubSkill('user-plugin', { description: 'from extra', source: 'extra' }),
stubSkill('extra-plugin', { description: 'from extra', source: 'extra' }),
]);
store.setPluginSkills([
stubSkill('shared', {
description: 'from plugin',
source: 'extra',
plugin: { id: 'demo' },
}),
stubSkill('user-plugin', {
description: 'from plugin',
source: 'extra',
plugin: { id: 'demo' },
}),
stubSkill('extra-plugin', {
description: 'from plugin',
source: 'extra',
plugin: { id: 'demo' },
}),
]);
const pluginRoot: SkillRoot = {
path: '/plugins/demo/skills',
source: 'extra',
plugin: { id: 'demo' },
};
const { stub: ws } = workspaceStub('/work');
const { host, session, config } = makeHost(store, ws, [pluginRoot]);
config.setExtraSkillDirs(['/']);
const catalog = session.accessor.get(ISessionSkillCatalog);
await catalog.load();
expect(catalog.catalog.getSkill('shared')?.description).toBe('from project');
expect(catalog.catalog.getSkill('user-plugin')?.description).toBe('from user');
expect(catalog.catalog.getSkill('extra-plugin')?.description).toBe('from extra');
host.dispose();
});
it('replaces default user and project discovery with explicitDirs', async () => {
const store = new InMemorySkillDiscovery();
store.setUserSkills([stubSkill('from-explicit', { description: 'from explicit' })]);
store.setProjectSkills([stubSkill('project-only', { description: 'from project' })]);
store.setExtraSkills([stubSkill('extra-only', { description: 'from extra', source: 'extra' })]);
store.setPluginSkills([
stubSkill('plugin-only', {
description: 'from plugin',
source: 'extra',
plugin: { id: 'demo' },
}),
]);
const pluginRoot: SkillRoot = {
path: '/plugins/demo/skills',
source: 'extra',
plugin: { id: 'demo' },
};
const { stub: ws } = workspaceStub('/work');
const { host, session, config } = makeHost(store, ws, [pluginRoot], ['/']);
config.setExtraSkillDirs(['/']);
const catalog = session.accessor.get(ISessionSkillCatalog);
await catalog.load();
expect(catalog.catalog.getSkill('from-explicit')?.description).toBe('from explicit');
expect(catalog.catalog.getSkill('project-only')).toBeUndefined();
expect(catalog.catalog.getSkill('extra-only')?.description).toBe('from extra');
expect(catalog.catalog.getSkill('plugin-only')?.description).toBe('from plugin');
host.dispose();
});
it('waits for config ready before loading extra skill dirs', async () => {
let markReady!: () => void;
let ready = false;
const configReady = new Promise<void>((resolve) => {
markReady = () => {
ready = true;
resolve();
};
});
const config = {
...configStub(),
ready: configReady,
get: (domain: string) => {
if (domain === EXTRA_SKILL_DIRS_SECTION) return ready ? ['/'] : [];
if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return true;
return undefined;
},
} as unknown as IConfigService;
const store = new InMemorySkillDiscovery();
store.setExtraSkills([stubSkill('extra-only', { description: 'from extra', source: 'extra' })]);
const runtimeOptions = {
_serviceBrand: undefined,
} as unknown as ISkillCatalogRuntimeOptions;
const { stub: ws } = workspaceStub('/work');
const host = createScopedTestHost([
stubPair(ISkillDiscovery, store),
stubPair(IBootstrapService, bootstrapStub),
stubPair(IConfigService, config),
stubPair(ISkillCatalogRuntimeOptions, runtimeOptions),
stubPair(IPluginService, pluginStub()),
]);
const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]);
const catalog = session.accessor.get(ISessionSkillCatalog);
let settled = false;
const loading = catalog.load().then(() => {
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(settled).toBe(false);
markReady();
await loading;
expect(catalog.catalog.getSkill('extra-only')?.description).toBe('from extra');
host.dispose();
});
it('reloads user and workspace sources when mergeAllAvailableSkills changes', async () => {
class CountingDiscovery implements ISkillDiscovery {
declare readonly _serviceBrand: undefined;
calls = 0;
async discover() {
this.calls++;
return { skills: [], skipped: [], scannedRoots: [] };
}
}
const store = new CountingDiscovery();
const config = configStub();
const runtimeOptions = {
_serviceBrand: undefined,
} as unknown as ISkillCatalogRuntimeOptions;
const { stub: ws } = workspaceStub('/work');
const host = createScopedTestHost([
stubPair(ISkillDiscovery, store),
stubPair(IBootstrapService, bootstrapStub),
stubPair(IConfigService, config),
stubPair(ISkillCatalogRuntimeOptions, runtimeOptions),
stubPair(IPluginService, pluginStub()),
]);
const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]);
const catalog = session.accessor.get(ISessionSkillCatalog);
await catalog.load();
const afterLoad = store.calls;
config.fireSectionChange(MERGE_ALL_AVAILABLE_SKILLS_SECTION);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(store.calls).toBeGreaterThanOrEqual(afterLoad + 2);
host.dispose();
});
it('reload replaces project skills when the workDir changes', async () => {
const store = new InMemorySkillDiscovery();
store.setUserSkills([stubSkill('global-only')]);
@ -150,7 +370,9 @@ describe('SessionSkillCatalogService', () => {
declare readonly _serviceBrand: undefined;
receivedRoots: readonly SkillRoot[] | undefined;
async discover(roots: readonly SkillRoot[]) {
this.receivedRoots = roots;
if (roots.some((root) => root.plugin !== undefined)) {
this.receivedRoots = roots;
}
const pluginSkills = roots
.filter((root) => root.plugin !== undefined)
.map((root) => stubSkill('demo-skill', { source: 'extra', plugin: root.plugin }));

View file

@ -1,10 +1,10 @@
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
import { mkdtemp, mkdir, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { projectRoots, userRoots } from '#/app/skillCatalog/skillRoots';
import { configuredRoots, projectRoots, userRoots } from '#/app/skillCatalog/skillRoots';
describe('skillRoots', () => {
let root: string;
@ -92,4 +92,25 @@ describe('skillRoots', () => {
);
});
});
describe('configuredRoots', () => {
it('resolves ~, ~/, absolute, and project-relative paths', async () => {
await markGitRoot();
const homeDir = join(root, 'home');
const absDir = join(root, 'abs');
await mkdir(homeDir, { recursive: true });
await mkdir(join(homeDir, 'notes'), { recursive: true });
await mkdir(absDir, { recursive: true });
await mkdir(join(root, 'relative'), { recursive: true });
const roots = await configuredRoots(['~', '~/notes', absDir, 'relative'], root, homeDir, 'extra');
const paths = roots.map((root) => root.path);
expect(roots.every((root) => root.source === 'extra')).toBe(true);
expect(paths).toContain(await realpath(homeDir));
expect(paths).toContain(await realpath(join(homeDir, 'notes')));
expect(paths).toContain(await realpath(absDir));
expect(paths).toContain(await realpath(join(root, 'relative')));
});
});
});

View file

@ -17,8 +17,8 @@
* would, so clients can populate the composer skill menu before a session
* exists. The workspace id is resolved to its root via
* `IWorkspaceRegistry.get` (`40410` when unknown); the root is then scanned by
* composing the same four sources the per-session catalog merges builtin /
* user / project(workDir) / plugin through the shared `ISkillDiscovery`,
* composing the same five sources the per-session catalog merges builtin /
* user / extra / project(workDir) / plugin through the shared `ISkillDiscovery`,
* `skillRoots` and `InMemorySkillCatalog` primitives, so the result matches the
* session listing for the same cwd. The composition is intentionally edge-side:
* `InMemorySkillCatalog` is not a scoped service and the `skillRoots` helpers
@ -68,21 +68,29 @@
import {
BUILTIN_SKILLS,
ErrorCodes,
EXTRA_SKILL_DIRS_SECTION,
IAgentSkillService,
IBootstrapService,
IConfigService,
IPluginService,
ISessionIndex,
ISessionLifecycleService,
ISessionSkillCatalog,
ISkillCatalogRuntimeOptions,
ISkillDiscovery,
IWorkspaceRegistry,
InMemorySkillCatalog,
isKimiError,
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
SKILL_SOURCE_PRIORITY,
configuredRoots,
projectRoots,
userRoots,
type ISessionScopeHandle,
type Scope,
type SkillDefinition,
type ExtraSkillDirsConfig,
type MergeAllAvailableSkillsConfig,
} from '@moonshot-ai/agent-core-v2';
import {
ErrorCode,
@ -296,15 +304,14 @@ export function registerSkillsRoutes(app: SkillsRouteHost, core: Scope): void {
/**
* Scan the skills a new session rooted at `workDir` would see, without creating
* a session. Resolves the same four sources the per-session catalog merges
* builtin / user / project(`workDir`) / plugin through the shared
* a session. Resolves the same five sources the per-session catalog merges
* builtin / user / extra / project(`workDir`) / plugin through the shared
* `ISkillDiscovery` and `skillRoots` primitives, then folds them into an
* `InMemorySkillCatalog` by the documented source priorities (lower priority
* first; `replace: true` lets higher-priority sources win name collisions). The
* priority numbers mirror `builtinSkillSource` (0), `userFileSkillSource` (10),
* `workspaceFileSkillSource` (20) and `pluginSkillSource` (25); the resulting
* name set is priority-invariant, but matching them keeps descriptor resolution
* identical to the session catalog.
* priority numbers come from `SKILL_SOURCE_PRIORITY`; the resulting name set is
* priority-invariant, but matching them keeps descriptor resolution identical to
* the session catalog.
*/
async function listWorkspaceSkillsForRoot(
core: Scope,
@ -313,24 +320,41 @@ async function listWorkspaceSkillsForRoot(
const discovery = core.accessor.get(ISkillDiscovery);
const bootstrap = core.accessor.get(IBootstrapService);
const plugins = core.accessor.get(IPluginService);
const config = core.accessor.get(IConfigService);
await config.ready;
const runtimeOptions = core.accessor.get(ISkillCatalogRuntimeOptions);
const extraSkillDirs = config.get<ExtraSkillDirsConfig>(EXTRA_SKILL_DIRS_SECTION) ?? [];
const mergeAllAvailableSkills =
config.get<MergeAllAvailableSkillsConfig>(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true;
const explicitDirs = runtimeOptions.explicitDirs ?? [];
const useExplicitDirs = explicitDirs.length > 0;
const rootOptions = { mergeAllAvailableSkills };
const [userRootList, projectRootList, pluginRootList] = await Promise.all([
userRoots(bootstrap.homeDir, bootstrap.osHomeDir),
projectRoots(workDir),
const [userRootList, projectRootList, explicitRootList, extraRootList, pluginRootList] = await Promise.all([
useExplicitDirs ? Promise.resolve([]) : userRoots(bootstrap.homeDir, bootstrap.osHomeDir, rootOptions),
useExplicitDirs ? Promise.resolve([]) : projectRoots(workDir, rootOptions),
useExplicitDirs
? configuredRoots(explicitDirs, workDir, bootstrap.osHomeDir, 'user')
: Promise.resolve([]),
configuredRoots(extraSkillDirs, workDir, bootstrap.osHomeDir, 'extra'),
plugins.pluginSkillRoots(),
]);
const [user, project, plugin] = await Promise.all([
const [user, project, explicit, extra, plugin] = await Promise.all([
discovery.discover(userRootList),
discovery.discover(projectRootList),
discovery.discover(explicitRootList),
discovery.discover(extraRootList),
discovery.discover(pluginRootList),
]);
const catalog = new InMemorySkillCatalog();
const ordered = [
{ skills: BUILTIN_SKILLS, priority: 0 },
{ skills: user.skills, priority: 10 },
{ skills: project.skills, priority: 20 },
{ skills: plugin.skills, priority: 25 },
{ skills: BUILTIN_SKILLS, priority: SKILL_SOURCE_PRIORITY.builtin },
{ skills: plugin.skills, priority: SKILL_SOURCE_PRIORITY.plugin },
{ skills: extra.skills, priority: SKILL_SOURCE_PRIORITY.extra },
{ skills: user.skills, priority: SKILL_SOURCE_PRIORITY.user },
{ skills: explicit.skills, priority: SKILL_SOURCE_PRIORITY.user },
{ skills: project.skills, priority: SKILL_SOURCE_PRIORITY.workspace },
].toSorted((a, b) => a.priority - b.priority);
for (const { skills } of ordered) {
for (const skill of skills) catalog.register(skill, { replace: true });

View file

@ -28,6 +28,7 @@ import { join } from 'node:path';
import {
IAgentLifecycleService,
ISessionLifecycleService,
ISkillCatalogRuntimeOptions,
} from '@moonshot-ai/agent-core-v2';
import {
activateSkillResultSchema,
@ -139,6 +140,15 @@ describe('server-v2 /api/v1 skills', () => {
);
}
async function seedExplicitSkill(root: string, name: string): Promise<void> {
const dir = join(root, name);
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, 'SKILL.md'),
`---\nname: ${name}\ndescription: explicit skill ${name}\n---\n\nSay hello to $ARGUMENTS.\n`,
);
}
describe('GET /api/v1/sessions/{sid}/skills', () => {
it('returns 40401 for an unknown session', async () => {
const { body } = await getJson<null>('/api/v1/sessions/nope/skills');
@ -259,6 +269,35 @@ describe('server-v2 /api/v1 skills', () => {
expect(names(wsSkills)).toEqual(names(sessSkills));
});
it('honors explicit skill dirs in workspace preview', async () => {
const workspaceDir = await makeWorkspaceDir();
await seedProjectSkill(workspaceDir, 'e2e-explicit');
const explicitDir = await makeWorkspaceDir();
await seedExplicitSkill(explicitDir, 'e2e-explicit');
await server!.close();
server = undefined;
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
seeds: [[ISkillCatalogRuntimeOptions, { _serviceBrand: undefined, explicitDirs: [explicitDir] }]] as never,
});
base = `http://127.0.0.1:${server.port}`;
const wid = await registerWorkspace(workspaceDir);
const { body } = await getJson<{ skills: SkillWire[] }>(
`/api/v1/workspaces/${wid}/skills`,
);
expect(body.code).toBe(0);
const skills = listSkillsResponseSchema.parse(body.data).skills;
const seeded = skills.find((s) => s.name === 'e2e-explicit');
expect(seeded).toBeDefined();
expect(seeded?.source).toBe('user');
expect(seeded?.description).toBe('explicit skill e2e-explicit');
});
it('returns 40410 for an unknown workspace', async () => {
const { body } = await getJson<null>(
'/api/v1/workspaces/wd_does-not-exist_000000000000/skills',