mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2
This commit is contained in:
commit
6ad2edca53
31 changed files with 1153 additions and 136 deletions
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
27
packages/agent-core-v2/src/app/skillCatalog/configSection.ts
Normal file
27
packages/agent-core-v2/src/app/skillCatalog/configSection.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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); }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
35
packages/agent-core-v2/test/rpc/runShellCommand.test.ts
Normal file
35
packages/agent-core-v2/test/rpc/runShellCommand.test.ts
Normal 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' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
35
packages/agent-core-v2/test/rpc/undoHistory.test.ts
Normal file
35
packages/agent-core-v2/test/rpc/undoHistory.test.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
109
packages/agent-core-v2/test/shellCommand/shellCommand.test.ts
Normal file
109
packages/agent-core-v2/test/shellCommand/shellCommand.test.ts
Normal 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</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('<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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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')));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue