diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index dab9d9d72..5eeb25c95 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -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], diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts index e5f6d5094..f17242652 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -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': diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index cc236dad1..b976e43eb 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -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 diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 15cebeee6..525bf0380 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -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, + }); } } diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index b790527ed..53a94b30a 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -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(); 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 { - 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 { @@ -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 { diff --git a/packages/agent-core-v2/src/agent/shellCommand/shellCommand.ts b/packages/agent-core-v2/src/agent/shellCommand/shellCommand.ts new file mode 100644 index 000000000..0521c68d7 --- /dev/null +++ b/packages/agent-core-v2/src/agent/shellCommand/shellCommand.ts @@ -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; + cancel(commandId: string): void; +} + +export const IAgentShellCommandService: ServiceIdentifier = + createDecorator('agentShellCommandService'); diff --git a/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts b/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts new file mode 100644 index 000000000..e7a469b84 --- /dev/null +++ b/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts @@ -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(); + + 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 { + // 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 = `\n${escapeXml(command)}\n`; + 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 = `${escapeXml(stdout)}${escapeXml(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', +); diff --git a/packages/agent-core-v2/src/agent/skill/tools/skill.ts b/packages/agent-core-v2/src/agent/skill/tools/skill.ts index ba1b8585f..3682c02ea 100644 --- a/packages/agent-core-v2/src/agent/skill/tools/skill.ts +++ b/packages/agent-core-v2/src/agent/skill/tools/skill.ts @@ -61,8 +61,17 @@ export interface SkillToolInput { } export const SkillToolInputSchema: z.ZodType = 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 { diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtinSkillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/builtinSkillSource.ts index e0e6d7f22..7cb272ce7 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtinSkillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/builtinSkillSource.ts @@ -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 { return { skills: BUILTIN_SKILLS }; diff --git a/packages/agent-core-v2/src/app/skillCatalog/configSection.ts b/packages/agent-core-v2/src/app/skillCatalog/configSection.ts new file mode 100644 index 000000000..bfc68dd7d --- /dev/null +++ b/packages/agent-core-v2/src/app/skillCatalog/configSection.ts @@ -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; + +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; + +registerConfigSection(MERGE_ALL_AVAILABLE_SKILLS_SECTION, MergeAllAvailableSkillsConfigSchema, { + defaultValue: true, +}); diff --git a/packages/agent-core-v2/src/app/skillCatalog/inMemorySkillDiscovery.ts b/packages/agent-core-v2/src/app/skillCatalog/inMemorySkillDiscovery.ts index bbddd6c5b..1067e4373 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/inMemorySkillDiscovery.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/inMemorySkillDiscovery.ts @@ -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 { 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); } diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillCatalogRuntimeOptions.ts b/packages/agent-core-v2/src/app/skillCatalog/skillCatalogRuntimeOptions.ts new file mode 100644 index 000000000..e8ae9f86c --- /dev/null +++ b/packages/agent-core-v2/src/app/skillCatalog/skillCatalogRuntimeOptions.ts @@ -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 = + createDecorator('skillCatalogRuntimeOptions'); + +export class SkillCatalogRuntimeOptions implements ISkillCatalogRuntimeOptions { + declare readonly _serviceBrand: undefined; + + constructor(readonly explicitDirs?: readonly string[]) {} +} + +registerScopedService( + LifecycleScope.App, + ISkillCatalogRuntimeOptions, + SkillCatalogRuntimeOptions, + InstantiationType.Delayed, + 'skillCatalog', +); diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts index 9d4dd8da4..275a4ab00 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts @@ -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 { const roots: SkillRoot[] = []; + const mergeAllAvailableSkills = options.mergeAllAvailableSkills ?? true; // homeDir is already the brand data dir, so brand skills live at /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 { +export async function projectRoots( + workDir: string, + options: SkillRootsOptions = {}, +): Promise { 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 { + 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 { 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 { + 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 { try { return (await fs.stat(p)).isDirectory(); diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts index d5f9a5fc9..1cd00bd48 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts @@ -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; diff --git a/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts index b4804d7c6..de7030d8b 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts @@ -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 = createDecorator('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()); + readonly onDidChange: Event = 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 { - 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(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; + return this.discovery.discover( + await userRoots(this.bootstrap.homeDir, this.bootstrap.osHomeDir, { mergeAllAvailableSkills }), + ); } } diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 1db8dcc1d..9a29ad938 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts new file mode 100644 index 000000000..655eda901 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts @@ -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 = + createDecorator('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 { + 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', +); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts new file mode 100644 index 000000000..5da0cc623 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts @@ -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 = + createDecorator('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()); + readonly onDidChange: Event = 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 { + await this.config.ready; + const extraSkillDirs = this.config.get(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', +); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts index 8d9130789..7a57948ac 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts @@ -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 = (listener, thisArg, disposables) => this.plugins.onDidReload(() => listener(undefined as void), thisArg, disposables); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts index 09c570a0f..e67c3575e 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts @@ -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); })); } diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts index 33aa969e5..6eb4b835c 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts @@ -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 = createDecorator('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()); + readonly onDidChange: Event = 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 { - 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(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; + return this.discovery.discover(await projectRoots(this.workspace.workDir, { mergeAllAvailableSkills })); } } diff --git a/packages/agent-core-v2/test/config/config.test.ts b/packages/agent-core-v2/test/config/config.test.ts index 4ca215740..e94a1e7ab 100644 --- a/packages/agent-core-v2/test/config/config.test.ts +++ b/packages/agent-core-v2/test/config/config.test.ts @@ -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 diff --git a/packages/agent-core-v2/test/profile/config-state.test.ts b/packages/agent-core-v2/test/profile/config-state.test.ts index 8f5f44791..fe58550a1 100644 --- a/packages/agent-core-v2/test/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/profile/config-state.test.ts @@ -8,8 +8,10 @@ import { createTestAgent, llmGenerateServices, modelProviderOptionServices, + telemetryServices, type TestAgentContext, } from '../harness'; +import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs'; type TestKimiConfig = ReturnType[0]>; type GenerateFn = Parameters[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: { diff --git a/packages/agent-core-v2/test/rpc/runShellCommand.test.ts b/packages/agent-core-v2/test/rpc/runShellCommand.test.ts new file mode 100644 index 000000000..6df2d8bf5 --- /dev/null +++ b/packages/agent-core-v2/test/rpc/runShellCommand.test.ts @@ -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' } }, + ]); + }); +}); diff --git a/packages/agent-core-v2/test/rpc/undoHistory.test.ts b/packages/agent-core-v2/test/rpc/undoHistory.test.ts new file mode 100644 index 000000000..e3ffbc72b --- /dev/null +++ b/packages/agent-core-v2/test/rpc/undoHistory.test.ts @@ -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 }, + }); + }); +}); diff --git a/packages/agent-core-v2/test/shellCommand/shellCommand.test.ts b/packages/agent-core-v2/test/shellCommand/shellCommand.test.ts new file mode 100644 index 000000000..cee06d09d --- /dev/null +++ b/packages/agent-core-v2/test/shellCommand/shellCommand.test.ts @@ -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('\necho hello\n'); + expect(textOf(context.get()[1]!)).toContain('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('prepost', 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</bash-stdout>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(''); + }); + + 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'); + }); +}); diff --git a/packages/agent-core-v2/test/skill/skill.test.ts b/packages/agent-core-v2/test/skill/skill.test.ts index 0a11fed41..d584defe4 100644 --- a/packages/agent-core-v2/test/skill/skill.test.ts +++ b/packages/agent-core-v2/test/skill/skill.test.ts @@ -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 () => { diff --git a/packages/agent-core-v2/test/skill/skillCatalog.test.ts b/packages/agent-core-v2/test/skill/skillCatalog.test.ts index 4e0d5d9b3..168fbba0e 100644 --- a/packages/agent-core-v2/test/skill/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/skill/skillCatalog.test.ts @@ -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((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 })); diff --git a/packages/agent-core-v2/test/skill/skillRoots.test.ts b/packages/agent-core-v2/test/skill/skillRoots.test.ts index 0878146f7..ae4e7735f 100644 --- a/packages/agent-core-v2/test/skill/skillRoots.test.ts +++ b/packages/agent-core-v2/test/skill/skillRoots.test.ts @@ -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'))); + }); + }); }); diff --git a/packages/kap-server/src/routes/skills.ts b/packages/kap-server/src/routes/skills.ts index 5ec0b1886..def738121 100644 --- a/packages/kap-server/src/routes/skills.ts +++ b/packages/kap-server/src/routes/skills.ts @@ -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(EXTRA_SKILL_DIRS_SECTION) ?? []; + const mergeAllAvailableSkills = + config.get(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 }); diff --git a/packages/kap-server/test/skills.test.ts b/packages/kap-server/test/skills.test.ts index 7d6984678..5b132acb3 100644 --- a/packages/kap-server/test/skills.test.ts +++ b/packages/kap-server/test/skills.test.ts @@ -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 { + 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('/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( '/api/v1/workspaces/wd_does-not-exist_000000000000/skills',