From 65904807dc41a31c6b9f85672c9f601812b5d9cc Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 15 Jul 2026 13:39:34 +0800 Subject: [PATCH 01/24] feat: support custom agent files Discover Markdown+frontmatter agent definitions from user, project, and configured directories; merge them into a session-level profile catalog (priority: builtin < user < extra < project < explicit). Custom agents work as subagents via the Agent tool and as the main agent. - agent-core-v2: new agentFileCatalog domain (discovery/parsing/profile factory, mirroring skillRoots/parseFrontmatter) and sessionAgentProfileCatalog merged view; AgentProfile.tools becomes optional (undefined = all tools) and gains disallowedTools deny list, evaluated in profileService.isToolActive and persisted in session wire records so resume keeps the gate even if the file is gone - CLI: restore --agent/--agent-file for the v2 print runner (KIMI_CODE_EXPERIMENTAL_FLAG); the v1 TUI rejects them with a clear v2-only error - kap-server/protocol: optional profile field on prompt submission with first-bind semantics (same-name no-op, different name rejected) - docs: custom agents section (en/zh) + config/CLI references + changeset --- .changeset/custom-agent-files.md | 8 + apps/kimi-code/src/cli/commands.ts | 16 + apps/kimi-code/src/cli/options.ts | 15 + apps/kimi-code/src/cli/v2/run-v2-print.ts | 63 ++- apps/kimi-code/src/main.ts | 2 + apps/kimi-code/test/cli/main.test.ts | 2 + apps/kimi-code/test/cli/options.test.ts | 47 +- apps/kimi-code/test/cli/run-prompt.test.ts | 2 + apps/kimi-code/test/cli/run-shell.test.ts | 24 + apps/kimi-code/test/cli/v2-run-print.test.ts | 85 +++- apps/kimi-code/test/tui/activity-pane.test.ts | 2 + .../test/tui/kimi-tui-message-flow.test.ts | 2 + .../test/tui/kimi-tui-startup.test.ts | 2 + .../kimi-code/test/tui/message-replay.test.ts | 2 + .../test/tui/signal-handlers.test.ts | 2 + docs/en/configuration/config-files.md | 1 + docs/en/customization/agents.md | 80 ++++ docs/en/reference/kimi-command.md | 13 + docs/zh/configuration/config-files.md | 1 + docs/zh/customization/agents.md | 80 ++++ docs/zh/reference/kimi-command.md | 13 + .../scripts/check-domain-layers.mjs | 2 + .../src/agent/profile/profile.ts | 1 + .../src/agent/profile/profileOps.ts | 26 +- .../src/agent/profile/profileService.ts | 44 +- .../agentCatalogRuntimeOptions.ts | 47 ++ .../src/app/agentFileCatalog/agentFile.ts | 139 ++++++ .../agentFileCatalog/agentFileDiscovery.ts | 100 ++++ .../agentFileCatalog/agentProfileFromFile.ts | 39 ++ .../agentFileCatalog/agentProfileSource.ts | 59 +++ .../src/app/agentFileCatalog/agentRoots.ts | 113 +++++ .../src/app/agentFileCatalog/configSection.ts | 19 + .../src/app/agentFileCatalog/types.ts | 40 ++ .../agentFileCatalog/userFileAgentSource.ts | 58 +++ .../agentProfileCatalog.ts | 5 +- packages/agent-core-v2/src/index.ts | 14 + .../explicitFileAgentSource.ts | 79 ++++ .../extraFileAgentSource.ts | 87 ++++ .../projectFileAgentSource.ts | 58 +++ .../sessionAgentProfileCatalog.ts | 30 ++ .../sessionAgentProfileCatalogService.ts | 173 +++++++ .../src/session/subagent/subagentService.ts | 4 +- .../src/session/subagent/tools/agent.ts | 9 +- .../src/session/swarm/sessionSwarmService.ts | 5 +- .../test/agent/profile/binding.test.ts | 118 +++++ .../test/agent/profile/profileOps.test.ts | 5 +- .../app/agentFileCatalog/agentFile.test.ts | 151 ++++++ .../agentFileDiscovery.test.ts | 96 ++++ .../app/agentFileCatalog/agentRoots.test.ts | 125 +++++ .../test/app/config/config.test.ts | 2 +- .../sessionAgentProfileCatalog.test.ts | 434 ++++++++++++++++++ .../test/session/swarm/sessionSwarm.test.ts | 5 +- packages/kap-server/src/routes/prompts.ts | 59 ++- packages/kap-server/test/prompts.test.ts | 75 ++- .../src/__tests__/rest-prompt.test.ts | 18 + packages/protocol/src/rest/prompt.ts | 4 + 56 files changed, 2669 insertions(+), 36 deletions(-) create mode 100644 .changeset/custom-agent-files.md create mode 100644 packages/agent-core-v2/src/app/agentFileCatalog/agentCatalogRuntimeOptions.ts create mode 100644 packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts create mode 100644 packages/agent-core-v2/src/app/agentFileCatalog/agentFileDiscovery.ts create mode 100644 packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts create mode 100644 packages/agent-core-v2/src/app/agentFileCatalog/agentProfileSource.ts create mode 100644 packages/agent-core-v2/src/app/agentFileCatalog/agentRoots.ts create mode 100644 packages/agent-core-v2/src/app/agentFileCatalog/configSection.ts create mode 100644 packages/agent-core-v2/src/app/agentFileCatalog/types.ts create mode 100644 packages/agent-core-v2/src/app/agentFileCatalog/userFileAgentSource.ts create mode 100644 packages/agent-core-v2/src/session/sessionAgentProfileCatalog/explicitFileAgentSource.ts create mode 100644 packages/agent-core-v2/src/session/sessionAgentProfileCatalog/extraFileAgentSource.ts create mode 100644 packages/agent-core-v2/src/session/sessionAgentProfileCatalog/projectFileAgentSource.ts create mode 100644 packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts create mode 100644 packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts create mode 100644 packages/agent-core-v2/test/app/agentFileCatalog/agentFile.test.ts create mode 100644 packages/agent-core-v2/test/app/agentFileCatalog/agentFileDiscovery.test.ts create mode 100644 packages/agent-core-v2/test/app/agentFileCatalog/agentRoots.test.ts create mode 100644 packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts diff --git a/.changeset/custom-agent-files.md b/.changeset/custom-agent-files.md new file mode 100644 index 0000000000..2ef7b2ca49 --- /dev/null +++ b/.changeset/custom-agent-files.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/agent-core-v2": minor +"@moonshot-ai/kap-server": minor +"@moonshot-ai/protocol": minor +"@moonshot-ai/kimi-code": minor +--- + +Add custom agents defined as Markdown files with frontmatter (custom or appended system prompt, tool allow/deny lists), discovered from user and project `agents/` directories, with `--agent` / `--agent-file` to select the main agent. Requires the v2 engine: `KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent `. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 32a65eb0e0..78efb8820e 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -74,6 +74,20 @@ export function createProgram( .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) .default([]), ) + .addOption( + new Option( + '--agent ', + 'Agent profile to use for this invocation (v2 engine only). Custom profiles are discovered from agent directories or loaded via --agent-file.', + ), + ) + .addOption( + new Option( + '--agent-file ', + 'Load an agent definition from a Markdown file and select it (v2 engine only). Can be repeated.', + ) + .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) + .default([]), + ) .addOption( new Option( '--add-dir ', @@ -133,6 +147,8 @@ export function createProgram( outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'], prompt: raw['prompt'] as string | undefined, skillsDirs: raw['skillsDir'] as string[], + agent: raw['agent'] as string | undefined, + agentFiles: raw['agentFile'] as string[], addDirs: raw['addDir'] as string[], }; diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index ae524abf7d..791d62891a 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -1,3 +1,5 @@ +import { isKimiV2Enabled } from './experimental-v2'; + export type UIMode = 'shell' | 'print'; export type PromptOutputFormat = 'text' | 'stream-json'; @@ -44,6 +46,8 @@ export interface CLIOptions { outputFormat: PromptOutputFormat | undefined; prompt: string | undefined; skillsDirs: string[]; + agent: string | undefined; + agentFiles: string[]; addDirs?: string[]; } @@ -83,6 +87,17 @@ export function validateOptions( if (promptMode && opts.plan) { throw new OptionConflictError('Cannot combine --prompt with --plan.'); } + if (opts.agent !== undefined && opts.agent.trim().length === 0) { + throw new OptionConflictError('Agent cannot be empty.'); + } + if ( + (opts.agent !== undefined || opts.agentFiles.length > 0) && + (!promptMode || !isKimiV2Enabled(env)) + ) { + throw new OptionConflictError( + '--agent/--agent-file are only available with the v2 engine (kimi -p with KIMI_CODE_EXPERIMENTAL_FLAG=1).', + ); + } if (promptMode && opts.session === '') { throw new OptionConflictError('Cannot use --session without an id in prompt mode.'); } diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index b284511ee7..b1da40bd59 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -16,6 +16,8 @@ * Selected by `runPrompt` when `KIMI_CODE_EXPERIMENTAL_FLAG` is set. */ +import { readFile } from 'node:fs/promises'; + import { IAgentGoalService, IAgentLifecycleService, @@ -30,11 +32,13 @@ import { ISessionIndex, ISessionLifecycleService, ITelemetryService, + agentCatalogRuntimeOptionsSeed, bootstrap, createCloudAppender, ensureMainAgent, hostRequestHeadersSeed, logSeed, + parseAgentFileText, resolveAgentTaskConfig, resolveKimiHome, resolveLoggingConfig, @@ -120,6 +124,9 @@ export async function runV2Print( // `--skillsDir` (v1 print parity): explicit skill dirs replace default // user / project discovery for this process. ...skillCatalogRuntimeOptionsSeed(opts.skillsDirs), + // `--agent-file`: explicit agent definition files, registered with the + // highest-precedence source for this process. + ...agentCatalogRuntimeOptionsSeed(opts.agentFiles.map((file) => resolve(file))), ]); const auth = app.accessor.get(IOAuthToolkit); @@ -236,6 +243,52 @@ async function resolveNativeSession( const lifecycle = app.accessor.get(ISessionLifecycleService); const index = app.accessor.get(ISessionIndex); + // `--agent` selects a catalog profile by name; otherwise the last + // `--agent-file` implicitly selects the profile that file defines. The file + // is parsed here (fatal on error) so a bad file fails before any turn. + let agentProfileName = opts.agent; + const lastAgentFile = opts.agentFiles.at(-1); + if (agentProfileName === undefined && lastAgentFile !== undefined) { + const agentFilePath = resolve(lastAgentFile); + let agentFileText: string; + try { + agentFileText = await readFile(agentFilePath, 'utf8'); + } catch (error) { + throw new Error( + `Failed to read agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + try { + agentProfileName = parseAgentFileText({ + path: agentFilePath, + source: 'explicit', + text: agentFileText, + }).name; + } catch (error) { + throw new Error( + `Invalid agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + } + + // `--agent` / `--agent-file` bind an explicit profile; without them the + // historical setModel path (default profile on first bind) is kept. + const applyProfileSelection = async ( + profile: IAgentProfileService, + model: string | undefined, + ): Promise => { + if (agentProfileName !== undefined) { + await profile.bind({ + profile: agentProfileName, + model: requireConfiguredModel(model ?? profile.getModel(), defaultModel), + }); + } else if (model !== undefined) { + await profile.setModel(model); + } + }; + const resumeById = async (id: string): Promise => { const session = await lifecycle.resume(id); if (session === undefined) { @@ -273,9 +326,7 @@ async function resolveNativeSession( const session = await resumeById(opts.session); const agent = await ensureMainAgent(session); const profile = agent.accessor.get(IAgentProfileService); - if (opts.model !== undefined) { - await profile.setModel(opts.model); - } + await applyProfileSelection(profile, opts.model); const currentModel = profile.getModel(); const { restorePermission } = forceAuto(agent); return { @@ -294,9 +345,7 @@ async function resolveNativeSession( const session = await resumeById(previous.id); const agent = await ensureMainAgent(session); const profile = agent.accessor.get(IAgentProfileService); - if (opts.model !== undefined) { - await profile.setModel(opts.model); - } + await applyProfileSelection(profile, opts.model); const currentModel = profile.getModel(); const { restorePermission } = forceAuto(agent); return { @@ -316,7 +365,7 @@ async function resolveNativeSession( additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, }); const agent = await ensureMainAgent(session); - await agent.accessor.get(IAgentProfileService).setModel(model); + await applyProfileSelection(agent.accessor.get(IAgentProfileService), model); agent.accessor.get(IAgentPermissionModeService).setMode('auto'); return { session, diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 860c4423d8..28ed63a900 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -130,6 +130,8 @@ const MIGRATE_CLI_OPTIONS: CLIOptions = { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }; export function main(): void { diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index 31411e8b19..8e058068a4 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -151,6 +151,8 @@ function defaultOpts(): CLIOptions { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }; } diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index b15ed315fe..431a665d0f 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -41,6 +41,8 @@ describe('CLI options parsing', () => { expect(opts.outputFormat).toBeUndefined(); expect(opts.prompt).toBeUndefined(); expect(opts.skillsDirs).toEqual([]); + expect(opts.agent).toBeUndefined(); + expect(opts.agentFiles).toEqual([]); expect(opts.addDirs).toEqual([]); }); }); @@ -390,6 +392,49 @@ describe('CLI options parsing', () => { }); }); + describe('--agent / --agent-file', () => { + it('parses --agent and repeated --agent-file', () => { + const opts = parse([ + '-p', + 'hi', + '--agent', + 'reviewer', + '--agent-file', + 'a.md', + '--agent-file=b.md', + ]); + expect(opts.agent).toBe('reviewer'); + expect(opts.agentFiles).toEqual(['a.md', 'b.md']); + }); + + it('rejects empty agent values', () => { + const opts = parse(['-p', 'hi', '--agent', ' ']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Agent cannot be empty.'); + }); + + it('rejects the flags in shell mode', () => { + const opts = parse(['--agent', 'reviewer']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + '--agent/--agent-file are only available with the v2 engine', + ); + }); + + it('rejects the flags in prompt mode without the v2 engine flag', () => { + const opts = parse(['-p', 'hi', '--agent-file', 'a.md']); + expect(() => validateOptions(opts, {})).toThrow(OptionConflictError); + expect(() => validateOptions(opts, {})).toThrow( + '--agent/--agent-file are only available with the v2 engine', + ); + }); + + it('accepts the flags in prompt mode with the v2 engine flag', () => { + const opts = parse(['-p', 'hi', '--agent', 'reviewer']); + expect(validateOptions(opts, { KIMI_CODE_EXPERIMENTAL_FLAG: '1' }).uiMode).toBe('print'); + }); + }); + describe('--add-dir', () => { it('parses one additional workspace directory', () => { expect(parse(['--add-dir', '/shared']).addDirs).toEqual(['/shared']); @@ -483,13 +528,11 @@ describe('CLI options parsing', () => { '--thinking', '--print', '--wire', - '--agent=default', '--raw-model', '--config-file=x', '--quiet', '--final-message-only', '--input-format=text', - '--agent-file=x', '--mcp-config={}', '--mcp-config-file=/', ]) { diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 8fbe0d9d51..4bad127d89 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -184,6 +184,8 @@ function opts(overrides: Partial[0]> = {}) { outputFormat: undefined, prompt: 'say hello', skillsDirs: [], + agent: undefined, + agentFiles: [], addDirs: [], ...overrides, }; diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index eafc4a9e0e..7f8f0c3a10 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -181,6 +181,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], addDirs: ['../shared', '/tmp/extra'], }; @@ -260,6 +262,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: ['/skills'], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -293,6 +297,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -333,6 +339,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -376,6 +384,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -409,6 +419,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -460,6 +472,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -498,6 +512,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -528,6 +544,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ), @@ -565,6 +583,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -619,6 +639,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -665,6 +687,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', { migrateOnly: true }, diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index 8a74961fc0..2daec19e1b 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -1,6 +1,11 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + IAgentCatalogRuntimeOptions, IAgentGoalService, IAgentLifecycleService, IAgentPermissionModeService, @@ -109,6 +114,8 @@ function opts(overrides: Record = {}) { outputFormat: undefined, prompt: 'say hello', skillsDirs: [], + agent: undefined, + agentFiles: [], addDirs: [], ...overrides, } as const; @@ -120,7 +127,14 @@ function makeFakeHarness() { const eventListeners = new Set<(event: DomainEvent) => void>(); const agentServices = new Map([ - [IAgentProfileService, { setModel: vi.fn(async () => ({ model: 'k2' })), getModel: () => 'k2' }], + [ + IAgentProfileService, + { + bind: vi.fn(async () => {}), + setModel: vi.fn(async () => ({ model: 'k2' })), + getModel: () => 'k2', + }, + ], [IAgentPermissionModeService, { mode: 'auto', setMode: vi.fn() }], [IAuthSummaryService, { ensureReady: vi.fn(async () => {}) }], [ @@ -274,4 +288,73 @@ describe('runV2Print', () => { const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; expect(seeds.some(([id]) => id === ISkillCatalogRuntimeOptions)).toBe(false); }); + + it('seeds explicit agent files from --agentFile and binds the --agent profile', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent, agentServices } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print( + opts({ agent: 'reviewer', agentFiles: ['/agents/reviewer.md'] }) as never, + '1.2.3-test', + { stdout, stderr }, + ); + + const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; + const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions); + expect(seeded?.[1]).toMatchObject({ explicitFiles: ['/agents/reviewer.md'] }); + + const profile = agentServices.get(IAgentProfileService) as { + bind: ReturnType; + setModel: ReturnType; + }; + expect(profile.bind).toHaveBeenCalledWith({ profile: 'reviewer', model: 'k2' }); + expect(profile.setModel).not.toHaveBeenCalled(); + }); + + it('binds the profile named by --agent-file when --agent is absent', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-agent-file-')); + const agentFile = join(dir, 'reviewer.md'); + await writeFile( + agentFile, + '---\nname: file-reviewer\ndescription: Reviews code.\n---\n\nYou review code.\n', + ); + const stdout = writer(); + const stderr = writer(); + const { app, agent, agentServices } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print(opts({ agentFiles: [agentFile] }) as never, '1.2.3-test', { + stdout, + stderr, + }); + + const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; + const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions); + expect(seeded?.[1]).toMatchObject({ explicitFiles: [agentFile] }); + + const profile = agentServices.get(IAgentProfileService) as { + bind: ReturnType; + }; + expect(profile.bind).toHaveBeenCalledWith({ profile: 'file-reviewer', model: 'k2' }); + }); + + it('leaves the agent runtime options unseeded when --agentFile is empty', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }); + + const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; + expect(seeds.some(([id]) => id === IAgentCatalogRuntimeOptions)).toBe(false); + }); }); diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index 0bcae749a5..3d8b2ed382 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -26,6 +26,8 @@ function makeStartupInput(): KimiTUIStartupInput { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, tuiConfig: { theme: 'dark', diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index f285be77dd..a1c6cf85e2 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -125,6 +125,8 @@ function makeStartupInput(): KimiTUIStartupInput { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, tuiConfig: { theme: 'dark', diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 79a36d70f2..a914ff5234 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -84,6 +84,8 @@ function makeStartupInput( outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], ...cliOptions, }, tuiConfig: { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 5e4e11670f..d0e377fc5b 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -49,6 +49,8 @@ function makeStartupInput(): KimiTUIStartupInput { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, tuiConfig: { theme: 'dark', diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index 92a91e0633..91dc5beacf 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -22,6 +22,8 @@ function makeStartupInput(): KimiTUIStartupInput { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, tuiConfig: { theme: 'dark', diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 58199d5d7f..1d30dae419 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -93,6 +93,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `default_plan_mode` | `boolean` | `false` | Whether new sessions start in Plan mode (produce a plan before executing) by default | | `merge_all_available_skills` | `boolean` | `true` | Whether to merge Agent Skills from all available directories | | `extra_skill_dirs` | `array` | — | Extra skill search directories, layered on top of the default directories | +| `extra_agent_dirs` | `array` | — | Extra custom agent search directories, layered on top of the default directories | | `telemetry` | `boolean` | `true` | Whether anonymous telemetry is enabled; disabled only when explicitly set to `false` | | `providers` | `table` | `{}` | API provider table → [`providers`](#providers) | | `models` | `table` | — | Model alias table → [`models`](#models) | diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 4eb0b753bd..27588112b5 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -37,6 +37,86 @@ Sub-agent permission rules are inherited from the main Agent: "always allow" rul If you need a particular type of tool to be permanently unavailable inside sub-agents, tighten the corresponding permission rule on the main Agent. +## Custom Agents + +Beyond the three built-in sub-agents, you can define your own agents as Markdown files. Each file describes one agent: the frontmatter (YAML metadata at the top of the file) declares its name, description, and tool access, and the file body is its system prompt. Custom agents can be delegated to as sub-agents — the main Agent discovers them automatically alongside the built-in ones — or selected as the main Agent at startup. + +::: warning Note +Custom agents currently require the v2 engine: run print mode with `KIMI_CODE_EXPERIMENTAL_FLAG=1`, for example `KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p "…"`. The interactive TUI does not load agent files yet. +::: + +### Agent Locations + +Kimi Code CLI discovers agent files by scope; more specific scopes take higher priority: **Project > Extra > User > Built-in**. When two files define the same `name`, the higher-priority scope wins. Each directory is scanned recursively for `.md` files. + +**User level** (applies to all projects): +- `$KIMI_CODE_HOME/agents/` (default: `~/.kimi-code/agents/`) +- `~/.agents/agents/` + +The Kimi-specific user agent directory moves with `KIMI_CODE_HOME`, while the generic `~/.agents/agents/` directory stays under the real OS home so it can be shared across tools. + +**Project level** (project root = the nearest directory containing `.git`, searching upward from the working directory): +- `.kimi-code/agents/` +- `.agents/agents/` + +**Extra directories**: Declared via `extra_agent_dirs` at the top level of `config.toml`: + +```toml +extra_agent_dirs = ["~/team-agents", ".agents/team-agents"] +``` + +**Built-in agents** are distributed with the CLI and have the lowest priority. A file loaded through `--agent-file` outranks every directory scope and applies to the current launch only. + +### Agent File Format + +An agent file is plain Markdown with a frontmatter block: + +```markdown +--- +name: reviewer +description: Strict code reviewer that reports severity-ranked findings +whenToUse: Code reviews and PR checks +mode: replace +tools: + - Read + - Grep + - Glob + - mcp__github__* +disallowedTools: + - Bash +--- + +You are a strict code reviewer. Read the diff, then report findings grouped by severity… +``` + +| Field | Required | Description | +| --- | --- | --- | +| `name` | yes | Unique identifier in kebab-case. Files without a valid `name` are skipped with a warning | +| `description` | yes | What the agent does. Shown to the main Agent when it picks a sub-agent, so write it to guide delegation decisions | +| `whenToUse` | no | Extra hint describing when the agent should be used | +| `mode` | no | `replace` (default): the body is the agent's entire system prompt. `append`: the body is added to the default system prompt, keeping workspace instructions and Skill injections in effect | +| `tools` | no | Allowlist of tool names such as `Read` or `Bash`; MCP tools are matched with globs such as `mcp__github__*`. Omit to allow all tools | +| `disallowedTools` | no | Denylist with the same matching rules, applied after `tools` | + +Unknown fields are ignored, so newer files stay readable by older versions. + +A file with invalid content discovered in a directory is skipped with a warning and does not affect other files. A file passed explicitly via `--agent-file` must be valid — otherwise the CLI reports the error and exits. + +### Selecting the Main Agent + +Two CLI flags select which agent drives the session: + +- **`--agent `**: Start the session with the named agent as the main Agent. The name can refer to a built-in agent or to any discovered file; an unknown name fails with an error listing the available agents. +- **`--agent-file `**: Load one agent file at the highest priority for this launch and start with it. Repeat the flag to register several files, and combine it with `--agent ` to choose among them by name. + +Both flags currently work only in v2 print mode: + +```sh +KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "Review the changes on this branch" +``` + +Passing them to the interactive TUI fails with an explanatory error. + ## Instruction Files Global Kimi-specific instructions can live at `$KIMI_CODE_HOME/AGENTS.md` (default: `~/.kimi-code/AGENTS.md`). When you relocate the data root with `KIMI_CODE_HOME`, this global instruction file moves with it. Generic cross-tool instructions can still live under `~/.agents/AGENTS.md` in the real OS home, and project-level instructions remain under the project tree, for example `.kimi-code/AGENTS.md` or `AGENTS.md`. diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index b6688148fe..155be8ba32 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -24,6 +24,8 @@ All flags are optional — run `kimi` directly to enter an interactive session: | `--auto` | | Start with auto permission mode; tool approvals are handled automatically and the Agent will not ask the user questions | | `--plan` | | Start a new session in Plan mode — the AI will prioritize read-only tools for exploration and planning | | `--skills-dir ` | | Load Skills from the specified directory, replacing the automatically discovered user and project directories. Can be repeated | +| `--agent ` | | Start the session with the specified agent as the main Agent. Requires the v2 engine | +| `--agent-file ` | | Load a custom agent from a Markdown file for this launch and select it. Can be repeated. Requires the v2 engine | | `--add-dir ` | | Add an extra workspace directory for this session. Relative paths resolve against the current working directory. Can be repeated | `-r` / `--resume` is a hidden alias for `--session`; `--yes` and `--auto-approve` are hidden aliases for `--yolo` and are not shown in help output. @@ -94,6 +96,16 @@ There are two ways to specify Skills directories, with different semantics: - **`extra_skill_dirs`** (`config.toml`): **Adds** directories on top of the automatically discovered ones, taking effect permanently. Suitable for configuring team-shared Skills. See [Agent Skills](../customization/skills.md). +### Custom Agents + +`--agent` and `--agent-file` select which agent drives the session. Both require the v2 engine — run print mode with `KIMI_CODE_EXPERIMENTAL_FLAG=1`; passing them to the interactive TUI fails with an explanatory error: + +```sh +KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "Review the changes on this branch" +``` + +`--agent-file` registers a single agent file at the highest priority for this launch only and selects it; repeat the flag to register several files, and add `--agent` to choose among them by name. See [Agents and Sub-Agents](../customization/agents.md#custom-agents) for the agent file format and discovery directories. + ## Non-Interactive Execution When running a single prompt in a script or CI environment, use `-p`: @@ -397,3 +409,4 @@ kimi provider catalog add anthropic --api-key sk-ant-... --default-model claude- - [Slash Commands](./slash-commands.md) — Quick reference for control commands in the interactive TUI - [Configuration Files](../configuration/config-files.md) — Persistent configuration for `default_model`, permission mode, and other startup parameters - [Agent Skills](../customization/skills.md) — Skill file format for directories loaded via `--skills-dir` +- [Agents and Sub-Agents](../customization/agents.md) — Built-in sub-agents, custom agent files, and main Agent selection via `--agent` diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 574bff2e2c..5ef804d687 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -93,6 +93,7 @@ timeout = 5 | `default_plan_mode` | `boolean` | `false` | 新会话是否默认以 Plan 模式(先出计划再执行)启动 | | `merge_all_available_skills` | `boolean` | `true` | 是否合并所有目录中的 Agent Skills | | `extra_skill_dirs` | `array` | — | 额外 Skill 搜索目录,叠加到默认目录之上 | +| `extra_agent_dirs` | `array` | — | 额外自定义 Agent 搜索目录,叠加到默认目录之上 | | `telemetry` | `boolean` | `true` | 是否启用匿名遥测;显式设为 `false` 时关闭 | | `providers` | `table` | `{}` | API 供应商表 → [`providers`](#providers) | | `models` | `table` | — | 模型别名表 → [`models`](#models) | diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 5b754ada1b..02d38815cc 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -37,6 +37,86 @@ Kimi Code CLI 内置三种子 Agent,开箱即用,分别面向不同任务形 如果需要某类工具在子 Agent 中始终不可用,应收紧主 Agent 的权限规则。 +## 自定义 Agent + +除了三个内置子 Agent,你还可以用 Markdown 文件定义自己的 Agent。每个文件描述一个 Agent:文件顶部的 Frontmatter(YAML 元数据)声明名称、描述和工具权限,文件正文是它的系统提示词。自定义 Agent 可以作为子 Agent 被委派 —— 主 Agent 会自动发现它们,与内置子 Agent 并列 —— 也可以在启动时选为主 Agent。 + +::: warning 注意 +自定义 Agent 目前需要 v2 引擎:以 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 运行 print 模式,例如 `KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p "…"`。交互式 TUI 暂时不会加载 Agent 文件。 +::: + +### Agent 目录 + +Kimi Code CLI 按作用域发现 Agent 文件,作用域越具体,优先级越高:**项目 > 额外 > 用户 > 内置**。两个文件定义了相同的 `name` 时,高优先级作用域胜出。每个目录都会递归扫描 `.md` 文件。 + +**用户级**(对所有项目生效): +- `$KIMI_CODE_HOME/agents/`(默认:`~/.kimi-code/agents/`) +- `~/.agents/agents/` + +Kimi 专属的用户 Agent 目录随 `KIMI_CODE_HOME` 移动,通用的 `~/.agents/agents/` 目录留在真实用户目录下,便于跨工具共享。 + +**项目级**(项目根目录 = 从工作目录向上查找、最近的包含 `.git` 的目录): +- `.kimi-code/agents/` +- `.agents/agents/` + +**额外目录**:在 `config.toml` 顶层通过 `extra_agent_dirs` 声明: + +```toml +extra_agent_dirs = ["~/team-agents", ".agents/team-agents"] +``` + +**内置 Agent** 随 CLI 分发,优先级最低。通过 `--agent-file` 加载的文件优先级高于所有目录作用域,且仅对本次启动生效。 + +### Agent 文件格式 + +Agent 文件是带 Frontmatter 的普通 Markdown: + +```markdown +--- +name: reviewer +description: 严格的代码审查 Agent,按严重度分级报告问题 +whenToUse: 代码评审与 PR 检查 +mode: replace +tools: + - Read + - Grep + - Glob + - mcp__github__* +disallowedTools: + - Bash +--- + +你是严格的代码审查者。阅读 diff 后,按严重度分级报告问题…… +``` + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `name` | 是 | kebab-case 唯一标识。缺少合法 `name` 的文件会被跳过并告警 | +| `description` | 是 | Agent 的用途。主 Agent 挑选子 Agent 时会看到,请围绕委派决策来写 | +| `whenToUse` | 否 | 补充说明何时应使用该 Agent | +| `mode` | 否 | `replace`(默认):正文即 Agent 的完整系统提示词。`append`:正文追加到默认系统提示词之上,工作区指令和 Skill 注入保持生效 | +| `tools` | 否 | 工具名允许列表,如 `Read`、`Bash`;MCP 工具用 glob 匹配,如 `mcp__github__*`。缺省表示允许全部工具 | +| `disallowedTools` | 否 | 禁止列表,匹配规则相同,在 `tools` 之后应用 | + +未知字段会被忽略,新版本写的文件在旧版本上仍可读取。 + +目录中发现的非法文件会被跳过并告警,不影响其他文件。通过 `--agent-file` 显式传入的文件必须合法 —— 否则 CLI 会报错并退出。 + +### 选择主 Agent + +两个 CLI flag 用于选择驱动会话的 Agent: + +- **`--agent `**:以指定 Agent 作为主 Agent 启动会话。名称可以指向内置 Agent 或任何已发现的文件;名称不存在时会报错,并列出可用的 Agent。 +- **`--agent-file `**:以最高优先级加载一个 Agent 文件(仅本次启动)并以其启动。重复传入可注册多个文件,配合 `--agent ` 按名称选择。 + +两个 flag 目前只在 v2 print 模式下可用: + +```sh +KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "审查这个分支上的改动" +``` + +传入交互式 TUI 会报错并说明原因。 + ## 指令文件 全局 Kimi 专属指令可放在 `$KIMI_CODE_HOME/AGENTS.md`(默认:`~/.kimi-code/AGENTS.md`)。当你用 `KIMI_CODE_HOME` 移动数据根时,这份全局指令文件也会一起移动。跨工具通用指令仍可放在真实 OS home 下的 `~/.agents/AGENTS.md`,项目级指令仍放在项目目录中,例如 `.kimi-code/AGENTS.md` 或 `AGENTS.md`。 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 0234f4bc54..b81229faf9 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -24,6 +24,8 @@ kimi [options] | `--auto` | | 以 auto 权限模式启动;工具审批自动处理,Agent 不会向用户提问 | | `--plan` | | 以 Plan 模式启动新会话,AI 会优先使用只读工具进行探索和规划 | | `--skills-dir ` | | 从指定目录加载 Skills,替换自动发现的用户和项目目录。可重复传入 | +| `--agent ` | | 以指定 Agent 作为主 Agent 启动会话。需要 v2 引擎 | +| `--agent-file ` | | 从 Markdown 文件加载自定义 Agent(仅本次启动)并选中它。可重复传入。需要 v2 引擎 | | `--add-dir ` | | 为本次会话添加额外的工作目录。相对路径按当前工作目录解析。可重复传入 | `-r` / `--resume` 是 `--session` 的隐藏别名;`--yes` 和 `--auto-approve` 是 `--yolo` 的隐藏别名,在帮助信息中不显示。 @@ -94,6 +96,16 @@ kimi --plan - **`extra_skill_dirs`**(`config.toml`):**叠加**到自动发现的目录之上,长期生效,适合配置团队共享 Skills。详见 [Agent Skills](../customization/skills.md)。 +### 自定义 Agent + +`--agent` 和 `--agent-file` 用于选择驱动会话的 Agent。两者都需要 v2 引擎 —— 以 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 运行 print 模式;传入交互式 TUI 会报错并说明原因: + +```sh +KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "审查这个分支上的改动" +``` + +`--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;重复传入可注册多个文件,再加 `--agent` 按名称选择。Agent 文件格式与发现目录详见 [Agent 与子 Agent](../customization/agents.md#自定义-agent)。 + ## 非交互执行 在脚本或 CI 中运行单次 prompt 时,使用 `-p`: @@ -397,3 +409,4 @@ kimi provider catalog add anthropic --api-key sk-ant-... --default-model claude- - [斜杠命令](./slash-commands.md) — 交互式 TUI 内的控制命令速查 - [配置文件](../configuration/config-files.md) — `default_model`、权限模式等启动参数的持久化配置 - [Agent Skills](../customization/skills.md) — `--skills-dir` 加载的 Skill 文件格式 +- [Agent 与子 Agent](../customization/agents.md) — 内置子 Agent、自定义 Agent 文件与通过 `--agent` 选择主 Agent diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 8e558eb70f..a3f3a17ff5 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -117,6 +117,7 @@ const DOMAIN_LAYER = new Map([ ['skill', 3], ['skillCatalog', 3], ['sessionSkillCatalog', 3], + ['sessionAgentProfileCatalog', 3], ['permissionGate', 3], ['flag', 3], ['toolExecutor', 3], @@ -131,6 +132,7 @@ const DOMAIN_LAYER = new Map([ ['record', 3], ['modelCatalog', 3], ['agentProfileCatalog', 3], + ['agentFileCatalog', 3], // L4 — agent behaviour ['activity', 4], ['context', 4], diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts index 9c6916b045..b18b8a9296 100644 --- a/packages/agent-core-v2/src/agent/profile/profile.ts +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -54,6 +54,7 @@ export type ProfileUpdateData = Partial<{ profileName: string; thinkingLevel: string; systemPrompt: string; + disallowedTools: readonly string[]; activeToolNames: readonly string[]; }>; diff --git a/packages/agent-core-v2/src/agent/profile/profileOps.ts b/packages/agent-core-v2/src/agent/profile/profileOps.ts index a19f7736d0..51c8b4d9d2 100644 --- a/packages/agent-core-v2/src/agent/profile/profileOps.ts +++ b/packages/agent-core-v2/src/agent/profile/profileOps.ts @@ -3,7 +3,8 @@ * Op (`configUpdate`) for the agent's persistent configuration slice. * * Declares the persistent profile config — `cwd`, `modelAlias`, `profileName`, - * the resolved base thinking effort, and `systemPrompt` — as a wire Model + * the resolved base thinking effort, `systemPrompt`, and the profile + * `disallowedTools` denylist — as a wire Model * (initial `defaultProfileModel()`), plus the single Op whose `apply` is a pure * merge of an already-resolved payload. Live records carry `thinkingEffort` (matching * the v1 wire field); legacy replay still accepts `thinkingLevel`. The value is @@ -24,7 +25,9 @@ * Also declares `ActiveToolsModel` (`readonly string[] | undefined`, initial * `undefined` = every tool active) and the `tools.set_active_tools` Op * (`setActiveTools`), a pure whole-set replace whose type matches the legacy - * record so `wire.replay` restores the base set. The ephemeral per-tool + * record so `wire.replay` restores the base set; an omitted `names` resets to + * `undefined`, letting a profile with no allowlist restore the default. The + * ephemeral per-tool * `addActiveTool` / `removeActiveTool` deltas (used by `userTool`) are NOT Ops — * they are intentionally not persisted and are re-derived on resume. * Consumed by the Agent-scope `profileService`. @@ -44,6 +47,7 @@ export interface ProfileModelState { readonly profileName?: string; readonly thinkingLevel: string; readonly systemPrompt: string; + readonly disallowedTools?: readonly string[]; } export const ProfileModel = defineModel('profile', () => ({ @@ -59,6 +63,7 @@ export const configUpdate = ProfileModel.defineOp('config.update', { thinkingEffort: z.custom().optional(), thinkingLevel: z.custom().optional(), systemPrompt: z.string().optional(), + disallowedTools: z.array(z.string()).readonly().optional(), }), apply: (s, p) => { let next: ProfileModelState | undefined; @@ -78,10 +83,25 @@ export const configUpdate = ProfileModel.defineOp('config.update', { if (p.systemPrompt !== undefined && p.systemPrompt !== s.systemPrompt) { next = { ...(next ?? s), systemPrompt: p.systemPrompt }; } + if ( + p.disallowedTools !== undefined && + !stringArrayEqual(p.disallowedTools, s.disallowedTools) + ) { + next = { ...(next ?? s), disallowedTools: p.disallowedTools }; + } return next ?? s; }, }); +function stringArrayEqual( + a: readonly string[] | undefined, + b: readonly string[] | undefined, +): boolean { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + return a.length === b.length && a.every((value, index) => value === b[index]); +} + function configUpdateThinkingLevel( p: PayloadOf, ): ThinkingEffort | undefined { @@ -118,6 +138,6 @@ declare module '#/wire/types' { } export const setActiveTools = ActiveToolsModel.defineOp('tools.set_active_tools', { - schema: z.object({ names: z.array(z.string()).readonly() }), + schema: z.object({ names: z.array(z.string()).readonly().optional() }), apply: (s, p) => (p.names === s ? s : p.names), }); diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 3a949b05f4..9c8fcfd873 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -5,7 +5,8 @@ * active-tool set; resolves the runnable god-object Model through the App- * scope `IModelResolver`, persists the persistent config slice (`cwd` / * `modelAlias` / `profileName` / resolved base `thinkingLevel` / - * `systemPrompt`) in the `wire` `ProfileModel` through the `config.update` Op + * `systemPrompt` / profile `disallowedTools`) in the `wire` `ProfileModel` + * through the `config.update` Op * and the persisted active-tool set in the `wire` `ActiveToolsModel` through the * `tools.set_active_tools` Op (`wire.dispatch`), and reads both through * `wire.getModel`. The effective active-tool set read by consumers is the @@ -28,7 +29,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/app/llmProtocol/capability'; import { type GenerationKwargs } from '#/app/llmProtocol/kimiOptions'; import { type ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; -import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { type Model } from '#/app/model/modelInstance'; import { type KimiModelOverrides } from '#/app/model/modelOverrides'; import { IModelResolver } from '#/app/model/modelResolver'; @@ -49,6 +50,7 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { isMcpToolName, type ToolSource } from '#/tool/toolContract'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/profile'; import type { WarningEvent } from '@moonshot-ai/protocol'; @@ -115,7 +117,7 @@ export class AgentProfileService implements IAgentProfileService { @ISessionContext private readonly sessionContext: ISessionContext, @IBootstrapService private readonly bootstrap: IBootstrapService, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IAgentProfileCatalogService private readonly catalog: IAgentProfileCatalogService, + @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, ) { this.configure({}); @@ -147,9 +149,16 @@ export class AgentProfileService implements IAgentProfileService { } async bind(input: BindAgentInput): Promise { + await this.catalog.ready; const profile = this.catalog.get(input.profile); if (profile === undefined) { - throw new Error(`Unknown agent profile: "${input.profile}"`); + const available = this.catalog + .list() + .map((p) => p.name) + .join(', '); + throw new Error( + `Unknown agent profile: "${input.profile}". Available profiles: ${available}`, + ); } const model = this.modelFactory.resolve(input.model); @@ -168,6 +177,7 @@ export class AgentProfileService implements IAgentProfileService { cwd: input.cwd, profileName: profile.name, systemPrompt, + disallowedTools: profile.disallowedTools ?? [], }); this.setActiveTools(profile.tools); this.wire.dispatch(configUpdate({ modelAlias: input.model, thinkingEffort: thinkingLevel })); @@ -223,6 +233,7 @@ export class AgentProfileService implements IAgentProfileService { this.update({ profileName: profile.name, systemPrompt: profile.systemPrompt(context), + disallowedTools: profile.disallowedTools ?? [], }); this.setActiveTools(profile.tools); } @@ -369,9 +380,19 @@ export class AgentProfileService implements IAgentProfileService { isToolActive(name: string, source: ToolSource = 'builtin'): boolean { const activeToolNames = this.activeToolNames; - if (activeToolNames === undefined) return true; - if (source !== 'mcp') return activeToolNames.includes(name); - return activeToolNames + if (activeToolNames !== undefined) { + const allowed = + source !== 'mcp' + ? activeToolNames.includes(name) + : activeToolNames + .filter((pattern) => isMcpToolName(pattern)) + .some((pattern) => picomatch.isMatch(name, pattern)); + if (!allowed) return false; + } + const disallowed = this.profileState.disallowedTools; + if (disallowed === undefined) return true; + if (source !== 'mcp') return !disallowed.includes(name); + return !disallowed .filter((pattern) => isMcpToolName(pattern)) .some((pattern) => picomatch.isMatch(name, pattern)); } @@ -408,6 +429,9 @@ export class AgentProfileService implements IAgentProfileService { ); } if (changed.systemPrompt !== undefined) payload.systemPrompt = changed.systemPrompt; + if (changed.disallowedTools !== undefined) { + payload.disallowedTools = [...changed.disallowedTools]; + } return payload; } @@ -424,9 +448,11 @@ export class AgentProfileService implements IAgentProfileService { ); } - private setActiveTools(names: readonly string[]): void { + private setActiveTools(names: readonly string[] | undefined): void { this.activeToolNamesOverlay = undefined; - this.wire.dispatch(setActiveTools({ names: [...names] })); + this.wire.dispatch( + setActiveTools({ names: names === undefined ? undefined : [...names] }), + ); } private emitStatusUpdated(includeThinkingEffort = false): void { diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentCatalogRuntimeOptions.ts b/packages/agent-core-v2/src/app/agentFileCatalog/agentCatalogRuntimeOptions.ts new file mode 100644 index 0000000000..c76141aaca --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/agentCatalogRuntimeOptions.ts @@ -0,0 +1,47 @@ +/** + * `agentFileCatalog` domain (L3) — runtime options for agent-file discovery. + * + * Holds process-level runtime overrides: `explicitFiles` mirrors the CLI's + * `--agent-file` — individual agent Markdown files loaded as the highest- + * priority `explicit` source. Composition roots set it through + * {@link agentCatalogRuntimeOptionsSeed}; the registered default carries no + * explicit files. Bound at App scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService, type ScopeSeed } from '#/_base/di/scope'; + +export interface IAgentCatalogRuntimeOptions { + readonly _serviceBrand: undefined; + readonly explicitFiles?: readonly string[]; +} + +export const IAgentCatalogRuntimeOptions: ServiceIdentifier = + createDecorator('agentCatalogRuntimeOptions'); + +export class AgentCatalogRuntimeOptions implements IAgentCatalogRuntimeOptions { + declare readonly _serviceBrand: undefined; + + constructor(readonly explicitFiles?: readonly string[]) {} +} + +export function agentCatalogRuntimeOptionsSeed( + explicitFiles: readonly string[] | undefined, +): ScopeSeed { + if (explicitFiles === undefined || explicitFiles.length === 0) return []; + return [ + [ + IAgentCatalogRuntimeOptions as ServiceIdentifier, + new AgentCatalogRuntimeOptions(explicitFiles), + ], + ]; +} + +registerScopedService( + LifecycleScope.App, + IAgentCatalogRuntimeOptions, + AgentCatalogRuntimeOptions, + InstantiationType.Eager, + 'agentFileCatalog', +); diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts b/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts new file mode 100644 index 0000000000..cb74da8e62 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts @@ -0,0 +1,139 @@ +/** + * `agentFileCatalog` domain (L3) — agent-file parsing primitives. + * + * Parses a single agent Markdown file (frontmatter + body) into an + * `AgentFileDefinition`. Pure functions with no IO: callers read bytes however + * they like and pass the decoded text in, mirroring `skillCatalog/parser`. + * Unknown frontmatter fields are ignored so later format extensions stay + * forward-compatible. + */ + +import { FrontmatterError, parseFrontmatter } from '#/app/skillCatalog/parser'; + +import type { AgentFileDefinition, AgentFileSource, AgentPromptMode } from './types'; + +export class AgentFileParseError extends Error { + readonly reason?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'AgentFileParseError'; + if (cause !== undefined) this.reason = cause; + } +} + +export interface ParseAgentFileOptions { + readonly path: string; + readonly source: AgentFileSource; + readonly text: string; +} + +const AGENT_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function parseAgentFileText(options: ParseAgentFileOptions): AgentFileDefinition { + let parsed; + try { + parsed = parseFrontmatter(options.text); + } catch (error) { + if (error instanceof FrontmatterError) { + throw new AgentFileParseError( + `Invalid frontmatter in ${options.path}: ${error.message}`, + error, + ); + } + throw error; + } + + const frontmatter = parsed.data; + if (frontmatter === null) { + throw new AgentFileParseError(`Missing frontmatter in ${options.path}`); + } + if (!isRecord(frontmatter)) { + throw new AgentFileParseError( + `Frontmatter in ${options.path} must be a mapping at the top level`, + ); + } + + const name = nonEmptyString(frontmatter['name']); + if (name === undefined) { + throw new AgentFileParseError( + `Missing required frontmatter field "name" in ${options.path}`, + ); + } + if (!AGENT_NAME_PATTERN.test(name)) { + throw new AgentFileParseError( + `Invalid agent name "${name}" in ${options.path}: expected kebab-case (e.g. "code-reviewer")`, + ); + } + + const description = nonEmptyString(frontmatter['description']); + if (description === undefined) { + throw new AgentFileParseError( + `Missing required frontmatter field "description" in ${options.path}`, + ); + } + + const mode = parseMode(nonEmptyString(frontmatter['mode']), options.path); + const tools = parseStringList(frontmatter['tools'], 'tools', options.path); + const disallowedTools = parseStringList( + frontmatter['disallowedTools'], + 'disallowedTools', + options.path, + ); + + const prompt = parsed.body.trim(); + if (prompt.length === 0) { + throw new AgentFileParseError(`Missing prompt body in ${options.path}`); + } + + return { + name, + description, + whenToUse: nonEmptyString(frontmatter['whenToUse']), + mode, + tools, + disallowedTools, + prompt, + path: options.path, + source: options.source, + }; +} + +function parseMode(value: string | undefined, filePath: string): AgentPromptMode { + if (value === undefined) return 'replace'; + if (value === 'replace' || value === 'append') return value; + throw new AgentFileParseError( + `Invalid "mode" value "${value}" in ${filePath}: expected "replace" or "append"`, + ); +} + +function parseStringList( + value: unknown, + field: string, + filePath: string, +): readonly string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) { + throw new AgentFileParseError( + `Frontmatter field "${field}" in ${filePath} must be a list of strings`, + ); + } + const out: string[] = []; + for (const item of value) { + if (typeof item !== 'string' || item.trim() === '') { + throw new AgentFileParseError( + `Frontmatter field "${field}" in ${filePath} must be a list of non-empty strings`, + ); + } + out.push(item.trim()); + } + return out; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentFileDiscovery.ts b/packages/agent-core-v2/src/app/agentFileCatalog/agentFileDiscovery.ts new file mode 100644 index 0000000000..9cde80677a --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/agentFileDiscovery.ts @@ -0,0 +1,100 @@ +/** + * `agentFileCatalog` domain (L3) — filesystem agent-file discovery. + * + * Walks caller-supplied roots recursively for `*.md` files and parses each + * through `agentFile`. Invalid files are skipped with a warning and collected + * into the result's `skipped` list, so one bad file never breaks the scan. + * Name collisions resolve first-wins in root order; priority across sources is + * the caller's (catalog's) concern. Dot-prefixed entries and `node_modules` + * are never scanned. Mirrors `skillCatalog/fileSkillDiscovery`. + */ + +import { promises as fs } from 'node:fs'; +import path from 'pathe'; + +import { AgentFileParseError, parseAgentFileText } from './agentFile'; +import type { + AgentFileDefinition, + AgentFileDiscoveryResult, + AgentFileRoot, + SkippedAgentFile, +} from './types'; + +const MAX_AGENT_SCAN_DEPTH = 8; + +export interface DiscoverAgentFilesWarn { + (message: string, error?: unknown): void; +} + +export async function discoverAgentFiles( + roots: readonly AgentFileRoot[], + warn?: DiscoverAgentFilesWarn, +): Promise { + const byName = new Map(); + const skipped: SkippedAgentFile[] = []; + + async function parseAndRegister(filePath: string, root: AgentFileRoot): Promise { + try { + const text = await fs.readFile(filePath, 'utf8'); + const agent = parseAgentFileText({ path: filePath, source: root.source, text }); + if (!byName.has(agent.name)) { + byName.set(agent.name, agent); + } + } catch (error) { + if (error instanceof AgentFileParseError) { + skipped.push({ path: filePath, reason: error.message }); + warn?.(`Skipping invalid agent file at ${filePath}: ${error.message}`, error); + } else { + warn?.(`Skipping agent file at ${filePath} due to unexpected error`, error); + } + } + } + + async function walk(dirPath: string, root: AgentFileRoot, depth: number): Promise { + if (depth > MAX_AGENT_SCAN_DEPTH) return; + + let entries: readonly string[]; + try { + entries = [...(await fs.readdir(dirPath))].toSorted(); + } catch { + return; + } + + for (const entry of entries) { + if (entry.startsWith('.') || entry === 'node_modules') continue; + const entryPath = path.join(dirPath, entry); + if (await isDir(entryPath)) { + await walk(entryPath, root, depth + 1); + continue; + } + if (!entry.endsWith('.md') || !(await isFile(entryPath))) continue; + await parseAndRegister(entryPath, root); + } + } + + for (const root of roots) { + await walk(root.path, root, 0); + } + + return { + agents: [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name)), + skipped, + scannedRoots: roots.map((root) => root.path), + }; +} + +async function isDir(p: string): Promise { + try { + return (await fs.stat(p)).isDirectory(); + } catch { + return false; + } +} + +async function isFile(p: string): Promise { + try { + return (await fs.stat(p)).isFile(); + } catch { + return false; + } +} diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts b/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts new file mode 100644 index 0000000000..6e5a7b20d0 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts @@ -0,0 +1,39 @@ +/** + * `agentFileCatalog` domain (L3) — `AgentFileDefinition` → `AgentProfile` factory. + * + * `mode: replace` profiles return the file body verbatim as the full system + * prompt — no agentsMd / skills context injection, the user owns the whole + * prompt. `mode: append` profiles reuse the builtin render pipeline and inject + * the body into the shared template's `ROLE_ADDITIONAL` slot, keeping the + * context injection intact. `tools` passes through as the allowlist + * (`undefined` = every tool active); `disallowedTools` passes through as the + * denylist evaluated by `IAgentProfileService.isToolActive`. + */ + +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { renderSystemPrompt } from '#/app/agentProfileCatalog/profile-shared'; + +import type { AgentFileDefinition } from './types'; + +// renderSystemPrompt only consults the list for `includes('Skill')`; inherit-all +// means the Skill tool is active, so probe with a list that answers true. +const SKILL_PROBE_ON_INHERIT = ['Skill'] as const; + +export function agentProfileFromFile(definition: AgentFileDefinition): AgentProfile { + return { + name: definition.name, + description: definition.description, + whenToUse: definition.whenToUse, + tools: definition.tools, + disallowedTools: definition.disallowedTools, + systemPrompt: + definition.mode === 'append' + ? (context) => + renderSystemPrompt( + definition.prompt, + context, + definition.tools ?? SKILL_PROBE_ON_INHERIT, + ) + : () => definition.prompt, + }; +} diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileSource.ts b/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileSource.ts new file mode 100644 index 0000000000..7def97d89b --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileSource.ts @@ -0,0 +1,59 @@ +/** + * `agentFileCatalog` domain (L3) — agent-profile source contract. + * + * `IAgentProfileSource` is the producer half of the agent-file subsystem: each + * source loads an `AgentProfileContribution` and advertises a `priority` so the + * Session catalog can ordered-merge contributions (higher priority wins name + * collisions). Mirrors `skillCatalog/skillSource`, with one deliberate + * deviation: `explicit` outranks every other source (in the skill system it + * aliases `user`) because `--agent-file` is a one-shot command-line intent that + * must always win. Concrete sources (user at App scope; project / extra / + * explicit at Session scope) each bind their own DI token extending this + * contract. + */ + +import type { Event } from '#/_base/event'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; + +import { agentProfileFromFile } from './agentProfileFromFile'; +import type { AgentFileDiscoveryResult, SkippedAgentFile } from './types'; + +export interface AgentProfileContribution { + readonly profiles: readonly AgentProfile[]; + readonly skipped?: readonly SkippedAgentFile[]; + readonly scannedRoots?: readonly string[]; +} + +export const AGENT_PROFILE_SOURCE_PRIORITY = { + user: 10, + extra: 20, + project: 30, + explicit: 40, +} as const; + +export interface IAgentProfileSource { + readonly _serviceBrand: undefined; + readonly id: string; + readonly priority: number; + readonly onDidChange?: Event; + /** + * When true, a `load()` rejection is fatal: the Session catalog lets it + * propagate into `ready` so awaiters see the error. When unset, the catalog + * degrades to a warning and keeps any previously loaded contribution — + * directory sources must never poison a session over a transient fs error. + * `explicit` sets this because `--agent-file` is an explicit user intent + * that must not be silently dropped. + */ + readonly fatal?: boolean; + load(): Promise; +} + +export function profilesFromDiscovery( + result: AgentFileDiscoveryResult, +): AgentProfileContribution { + return { + profiles: result.agents.map(agentProfileFromFile), + skipped: result.skipped, + scannedRoots: result.scannedRoots, + }; +} diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentRoots.ts b/packages/agent-core-v2/src/app/agentFileCatalog/agentRoots.ts new file mode 100644 index 0000000000..e7e440ac07 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/agentRoots.ts @@ -0,0 +1,113 @@ +/** + * `agentFileCatalog` domain (L3) — agent-root resolution primitives. + * + * Resolves the ordered `AgentFileRoot` list a discovery pass should scan for + * the user (home) and project (workspace) agent locations. Brand directories + * are preferred over generic ones (`.kimi-code/agents` before + * `.agents/agents`), and the project root is found by walking up to `.git`. + * Mirrors `skillCatalog/skillRoots` so both discovery systems share one + * directory convention. Pure path/fs probes; no scoped state. + */ + +import { promises as fs } from 'node:fs'; +import path from 'pathe'; + +import type { AgentFileRoot, AgentFileSource } from './types'; + +const USER_BRAND_DIRS = ['agents'] as const; +const USER_GENERIC_DIRS = ['.agents/agents'] as const; +const PROJECT_BRAND_DIRS = ['.kimi-code/agents'] as const; +const PROJECT_GENERIC_DIRS = ['.agents/agents'] as const; + +export async function userAgentRoots( + homeDir: string, + osHomeDir: string, +): Promise { + const roots: AgentFileRoot[] = []; + await pushFirstExisting(roots, USER_BRAND_DIRS, homeDir, 'user'); + await pushFirstExisting(roots, USER_GENERIC_DIRS, osHomeDir, 'user'); + return roots; +} + +export async function projectAgentRoots(workDir: string): Promise { + const projectRoot = await findProjectRoot(workDir); + const roots: AgentFileRoot[] = []; + await pushFirstExisting(roots, PROJECT_BRAND_DIRS, projectRoot, 'project'); + await pushFirstExisting(roots, PROJECT_GENERIC_DIRS, projectRoot, 'project'); + return roots; +} + +export async function configuredAgentRoots( + dirs: readonly string[], + workDir: string, + osHomeDir: string, + source: AgentFileSource, +): Promise { + const projectRoot = await findProjectRoot(workDir); + const roots: AgentFileRoot[] = []; + 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; + while (true) { + if (await exists(path.join(current, '.git'))) return current; + const parent = path.dirname(current); + if (parent === current) return start; + current = parent; + } +} + +async function pushFirstExisting( + out: AgentFileRoot[], + dirs: readonly string[], + base: string, + source: AgentFileSource, +): Promise { + for (const dir of dirs) { + if (await pushExistingRoot(out, path.join(base, dir), source)) return; + } +} + +async function pushExistingRoot( + out: AgentFileRoot[], + dir: string, + source: AgentFileSource, +): Promise { + if (!(await isDir(dir))) return false; + const resolved = await realpath(dir); + if (!out.some((root) => root.path === resolved)) out.push({ path: resolved, source }); + 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(); + } catch { + return false; + } +} + +async function realpath(p: string): Promise { + return (await fs.realpath(p)).replaceAll('\\', '/'); +} + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/configSection.ts b/packages/agent-core-v2/src/app/agentFileCatalog/configSection.ts new file mode 100644 index 0000000000..34806dcf00 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/configSection.ts @@ -0,0 +1,19 @@ +/** + * `agentFileCatalog` domain (L3) — agent-file config sections. + * + * Registers the top-level config domain `extraAgentDirs`: additional + * directories scanned for agent Markdown files. Values stay camelCase in + * memory; TOML uses the snake_case key `extra_agent_dirs`. + */ + +import { z } from 'zod'; + +import { registerConfigSection } from '#/app/config/configSectionContributions'; + +export const EXTRA_AGENT_DIRS_SECTION = 'extraAgentDirs'; +export const ExtraAgentDirsConfigSchema = z.array(z.string()).optional(); +export type ExtraAgentDirsConfig = z.infer; + +registerConfigSection(EXTRA_AGENT_DIRS_SECTION, ExtraAgentDirsConfigSchema, { + defaultValue: [], +}); diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/types.ts b/packages/agent-core-v2/src/app/agentFileCatalog/types.ts new file mode 100644 index 0000000000..e39c6093da --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/types.ts @@ -0,0 +1,40 @@ +/** + * `agentFileCatalog` domain (L3) — agent-file model types. + * + * Shared types for the agent-file primitives: the parsed single-file + * definition (`AgentFileDefinition`), scan roots (`AgentFileRoot`) tagged with + * their source, and the discovery result carrying per-file skip diagnostics. + * Pure data; no scoped state. + */ + +export type AgentFileSource = 'project' | 'user' | 'extra' | 'explicit'; + +export interface AgentFileRoot { + readonly path: string; + readonly source: AgentFileSource; +} + +export type AgentPromptMode = 'replace' | 'append'; + +export interface AgentFileDefinition { + readonly name: string; + readonly description: string; + readonly whenToUse?: string; + readonly mode: AgentPromptMode; + readonly tools?: readonly string[]; + readonly disallowedTools?: readonly string[]; + readonly prompt: string; + readonly path: string; + readonly source: AgentFileSource; +} + +export interface SkippedAgentFile { + readonly path: string; + readonly reason: string; +} + +export interface AgentFileDiscoveryResult { + readonly agents: readonly AgentFileDefinition[]; + readonly skipped: readonly SkippedAgentFile[]; + readonly scannedRoots: readonly string[]; +} diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/userFileAgentSource.ts b/packages/agent-core-v2/src/app/agentFileCatalog/userFileAgentSource.ts new file mode 100644 index 0000000000..d02225c100 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/userFileAgentSource.ts @@ -0,0 +1,58 @@ +/** + * `agentFileCatalog` domain (L3) — user `IAgentProfileSource` producer. + * + * Discovers agent files from the bootstrap home directories + * (`$KIMI_CODE_HOME/agents`, `~/.agents/agents`), contributing them at priority + * 10 (above builtin code contributions, below extra / project / explicit). + * Bound at App scope: home paths are process-global. Mirrors + * `skillCatalog/userFileSkillSource`. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; + +import { discoverAgentFiles } from './agentFileDiscovery'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + profilesFromDiscovery, + type AgentProfileContribution, + type IAgentProfileSource, +} from './agentProfileSource'; +import { userAgentRoots } from './agentRoots'; + +export interface IUserFileAgentSource extends IAgentProfileSource { + readonly _serviceBrand: undefined; +} + +export const IUserFileAgentSource: ServiceIdentifier = + createDecorator('userFileAgentSource'); + +export class UserFileAgentSource implements IUserFileAgentSource { + declare readonly _serviceBrand: undefined; + + readonly id = 'user'; + readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.user; + + constructor( + @IBootstrapService private readonly bootstrap: IBootstrapService, + @ILogService private readonly log: ILogService, + ) {} + + async load(): Promise { + const roots = await userAgentRoots(this.bootstrap.homeDir, this.bootstrap.osHomeDir); + return profilesFromDiscovery( + await discoverAgentFiles(roots, (message) => this.log.warn(message)), + ); + } +} + +registerScopedService( + LifecycleScope.App, + IUserFileAgentSource, + UserFileAgentSource, + InstantiationType.Eager, + 'agentFileCatalog', +); diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index 6ccc248fb5..2092574089 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -58,7 +58,10 @@ export interface AgentProfile { readonly name: string; readonly description?: string; readonly whenToUse?: string; - readonly tools: readonly string[]; + // Allowlist of exact builtin names + mcp__ globs; undefined = every tool active. + readonly tools?: readonly string[]; + // Denylist with the same matching semantics, applied on top of the allowlist result. + readonly disallowedTools?: readonly string[]; systemPrompt(context: AgentProfileContext): string; readonly promptPrefix?: (ctx: AgentProfilePromptPrefixContext) => Promise; readonly summaryPolicy?: AgentProfileSummaryPolicy; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 6774c4ebb6..ed825c7293 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -119,6 +119,15 @@ export { getAgentProfileContributions, _clearAgentProfileContributionsForTests, } from '#/app/agentProfileCatalog/contribution'; +export * from '#/app/agentFileCatalog/types'; +export * from '#/app/agentFileCatalog/agentRoots'; +export * from '#/app/agentFileCatalog/agentFile'; +export * from '#/app/agentFileCatalog/agentFileDiscovery'; +export * from '#/app/agentFileCatalog/agentProfileFromFile'; +export * from '#/app/agentFileCatalog/configSection'; +export * from '#/app/agentFileCatalog/agentProfileSource'; +export * from '#/app/agentFileCatalog/agentCatalogRuntimeOptions'; +export * from '#/app/agentFileCatalog/userFileAgentSource'; export * from '#/app/plugin/types'; export * from '#/app/plugin/commands'; export * from '#/app/plugin/manifest'; @@ -153,6 +162,11 @@ export * from '#/session/sessionSkillCatalog/extraFileSkillSource'; export * from '#/session/sessionSkillCatalog/explicitFileSkillSource'; export * from '#/session/sessionSkillCatalog/workspaceFileSkillSource'; export * from '#/session/sessionSkillCatalog/pluginSkillSource'; +export * from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +export * from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService'; +export * from '#/session/sessionAgentProfileCatalog/projectFileAgentSource'; +export * from '#/session/sessionAgentProfileCatalog/extraFileAgentSource'; +export * from '#/session/sessionAgentProfileCatalog/explicitFileAgentSource'; export * from '#/agent/permissionGate/permissionGate'; export * from '#/agent/permissionGate/permissionGateService'; import '#/app/flag/flag'; diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/explicitFileAgentSource.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/explicitFileAgentSource.ts new file mode 100644 index 0000000000..084b75690a --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/explicitFileAgentSource.ts @@ -0,0 +1,79 @@ +/** + * `sessionAgentProfileCatalog` domain (L3) — explicit `IAgentProfileSource` + * producer. + * + * Loads the individual agent Markdown files named by runtime options + * (`--agent-file`), contributing them at priority 40 — the highest source, so + * an explicitly named file always wins name collisions. Unlike directory + * sources (which skip invalid files with a warning), a missing or invalid + * explicit file is fatal: the user named it on the command line, so silently + * dropping it would mask intent. Bound at Session scope so relative paths + * resolve against the session workDir. + */ + +import { promises as fs } from 'node:fs'; +import path from 'pathe'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { IAgentCatalogRuntimeOptions } from '#/app/agentFileCatalog/agentCatalogRuntimeOptions'; +import { parseAgentFileText } from '#/app/agentFileCatalog/agentFile'; +import { agentProfileFromFile } from '#/app/agentFileCatalog/agentProfileFromFile'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + type AgentProfileContribution, + type IAgentProfileSource, +} from '#/app/agentFileCatalog/agentProfileSource'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; + +export interface IExplicitFileAgentSource extends IAgentProfileSource { + readonly _serviceBrand: undefined; +} + +export const IExplicitFileAgentSource: ServiceIdentifier = + createDecorator('explicitFileAgentSource'); + +export class ExplicitFileAgentSource implements IExplicitFileAgentSource { + declare readonly _serviceBrand: undefined; + + readonly id = 'explicit'; + readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.explicit; + readonly fatal = true; + + constructor( + @IAgentCatalogRuntimeOptions private readonly runtimeOptions: IAgentCatalogRuntimeOptions, + @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @IBootstrapService private readonly bootstrap: IBootstrapService, + ) {} + + async load(): Promise { + const files = this.runtimeOptions.explicitFiles ?? []; + const profiles: AgentProfile[] = []; + for (const file of files) { + const filePath = resolveExplicitFile(file, this.workspace.workDir, this.bootstrap.osHomeDir); + const text = await fs.readFile(filePath, 'utf8'); + profiles.push( + agentProfileFromFile(parseAgentFileText({ path: filePath, source: 'explicit', text })), + ); + } + return { profiles }; + } +} + +function resolveExplicitFile(file: string, workDir: string, osHomeDir: string): string { + if (file === '~') return osHomeDir; + if (file.startsWith('~/')) return path.join(osHomeDir, file.slice(2)); + if (path.isAbsolute(file)) return file; + return path.resolve(workDir, file); +} + +registerScopedService( + LifecycleScope.Session, + IExplicitFileAgentSource, + ExplicitFileAgentSource, + InstantiationType.Eager, + 'sessionAgentProfileCatalog', +); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/extraFileAgentSource.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/extraFileAgentSource.ts new file mode 100644 index 0000000000..490fd3035b --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/extraFileAgentSource.ts @@ -0,0 +1,87 @@ +/** + * `sessionAgentProfileCatalog` domain (L3) — extra `IAgentProfileSource` + * producer. + * + * Discovers agent files from the user-configured extra directories + * (`extraAgentDirs` config), contributing them at priority 20 (above user / + * builtin, below project / explicit). Relative paths resolve against the + * session project root; `~` resolves against the OS home dir. Reloads when the + * config section changes. Bound at Session scope so each session resolves + * against its own workDir. Mirrors `sessionSkillCatalog/extraFileSkillSource`. + */ + +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 { ILogService } from '#/_base/log/log'; +import { discoverAgentFiles } from '#/app/agentFileCatalog/agentFileDiscovery'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + profilesFromDiscovery, + type AgentProfileContribution, + type IAgentProfileSource, +} from '#/app/agentFileCatalog/agentProfileSource'; +import { configuredAgentRoots } from '#/app/agentFileCatalog/agentRoots'; +import { + EXTRA_AGENT_DIRS_SECTION, + type ExtraAgentDirsConfig, +} from '#/app/agentFileCatalog/configSection'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; + +export interface IExtraFileAgentSource extends IAgentProfileSource { + readonly _serviceBrand: undefined; +} + +export const IExtraFileAgentSource: ServiceIdentifier = + createDecorator('extraFileAgentSource'); + +export class ExtraFileAgentSource extends Disposable implements IExtraFileAgentSource { + declare readonly _serviceBrand: undefined; + + readonly id = 'extra'; + readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.extra; + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + + constructor( + @IConfigService private readonly config: IConfigService, + @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @ILogService private readonly log: ILogService, + ) { + super(); + this._register( + this.config.onDidSectionChange((event) => { + if (event.domain === EXTRA_AGENT_DIRS_SECTION) this.onDidChangeEmitter.fire(); + }), + ); + } + + async load(): Promise { + await this.config.ready; + const dirs = this.config.get(EXTRA_AGENT_DIRS_SECTION) ?? []; + return profilesFromDiscovery( + await discoverAgentFiles( + await configuredAgentRoots( + dirs, + this.workspace.workDir, + this.bootstrap.osHomeDir, + 'extra', + ), + (message) => this.log.warn(message), + ), + ); + } +} + +registerScopedService( + LifecycleScope.Session, + IExtraFileAgentSource, + ExtraFileAgentSource, + InstantiationType.Eager, + 'sessionAgentProfileCatalog', +); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/projectFileAgentSource.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/projectFileAgentSource.ts new file mode 100644 index 0000000000..1f13962fd1 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/projectFileAgentSource.ts @@ -0,0 +1,58 @@ +/** + * `sessionAgentProfileCatalog` domain (L3) — project `IAgentProfileSource` + * producer. + * + * Discovers agent files from the session's current `workDir` + * (`.kimi-code/agents`, `.agents/agents`, walking up to `.git`), contributing + * them at priority 30 (above user / extra / builtin, below explicit). Bound at + * Session scope so each session reads its own workspace root. Mirrors + * `sessionSkillCatalog/workspaceFileSkillSource`. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { discoverAgentFiles } from '#/app/agentFileCatalog/agentFileDiscovery'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + profilesFromDiscovery, + type AgentProfileContribution, + type IAgentProfileSource, +} from '#/app/agentFileCatalog/agentProfileSource'; +import { projectAgentRoots } from '#/app/agentFileCatalog/agentRoots'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; + +export interface IProjectFileAgentSource extends IAgentProfileSource { + readonly _serviceBrand: undefined; +} + +export const IProjectFileAgentSource: ServiceIdentifier = + createDecorator('projectFileAgentSource'); + +export class ProjectFileAgentSource implements IProjectFileAgentSource { + declare readonly _serviceBrand: undefined; + + readonly id = 'project'; + readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.project; + + constructor( + @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @ILogService private readonly log: ILogService, + ) {} + + async load(): Promise { + const roots = await projectAgentRoots(this.workspace.workDir); + return profilesFromDiscovery( + await discoverAgentFiles(roots, (message) => this.log.warn(message)), + ); + } +} + +registerScopedService( + LifecycleScope.Session, + IProjectFileAgentSource, + ProjectFileAgentSource, + InstantiationType.Eager, + 'sessionAgentProfileCatalog', +); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts new file mode 100644 index 0000000000..53b0034321 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts @@ -0,0 +1,30 @@ +/** + * `sessionAgentProfileCatalog` domain (L3) — Session-scoped merged agent-profile + * catalog contract. + * + * Defines the merged read view over the builtin (code-contribution) profiles + * and the file-backed sources (user / extra / project / explicit), merged by + * priority — higher-priority sources win name collisions. Consumers + * (`IAgentProfileService.bind`, the `Agent` tool, the swarm scheduler) resolve + * profiles through this view instead of the App-scope catalog so file-defined + * agents are spawnable and bindable. Bound at Session scope. + */ + +import { createDecorator } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; + +export interface ISessionAgentProfileCatalog { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly onDidChange: Event; + get(name: string): AgentProfile | undefined; + getDefault(): AgentProfile; + list(): readonly AgentProfile[]; + load(): Promise; + reload(): Promise; +} + +export const ISessionAgentProfileCatalog = + createDecorator('sessionAgentProfileCatalog'); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts new file mode 100644 index 0000000000..91cc93c817 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts @@ -0,0 +1,173 @@ +/** + * `sessionAgentProfileCatalog` domain (L3) — `ISessionAgentProfileCatalog` + * implementation. + * + * Merges the builtin (code-contribution) App catalog with the file-backed + * sources (user / extra / project / explicit) by priority, serializing + * refreshes per source the same way `sessionSkillCatalog` does. The merged + * view always contains the builtin profiles (seeded at construction); file + * profiles appear once `ready` resolves. A rejecting `fatal` source (an + * invalid `--agent-file`) propagates into `ready` so `bind()` / `load()` + * awaiters see the error; a rejecting non-fatal source (a transient fs error + * inside a directory source) degrades to a warning and keeps any previously + * loaded contribution, so directory problems never poison the session. The + * swallowed handler on `ready` keeps an un-awaited rejection from crashing + * the process, and event-driven reloads get the same warning treatment. + * Bound at Session scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { Emitter, type Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { + DEFAULT_AGENT_PROFILE_NAME, + IAgentProfileCatalogService, + type AgentProfile, +} from '#/app/agentProfileCatalog/agentProfileCatalog'; +import type { + AgentProfileContribution, + IAgentProfileSource, +} from '#/app/agentFileCatalog/agentProfileSource'; +import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource'; + +import { IExplicitFileAgentSource } from './explicitFileAgentSource'; +import { IExtraFileAgentSource } from './extraFileAgentSource'; +import { IProjectFileAgentSource } from './projectFileAgentSource'; +import { ISessionAgentProfileCatalog } from './sessionAgentProfileCatalog'; + +export class SessionAgentProfileCatalogService + extends Disposable + implements ISessionAgentProfileCatalog +{ + declare readonly _serviceBrand: undefined; + + private readonly sources: readonly IAgentProfileSource[]; + private readonly contributions = new Map< + string, + { readonly c: AgentProfileContribution; readonly priority: number } + >(); + private readonly sourceLoadTails = new Map>(); + private merged = new Map(); + readonly ready: Promise; + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + + constructor( + @IAgentProfileCatalogService private readonly builtin: IAgentProfileCatalogService, + @IUserFileAgentSource user: IUserFileAgentSource, + @IExtraFileAgentSource extra: IExtraFileAgentSource, + @IProjectFileAgentSource project: IProjectFileAgentSource, + @IExplicitFileAgentSource explicit: IExplicitFileAgentSource, + @ILogService private readonly log: ILogService, + ) { + super(); + this.sources = [user, extra, project, explicit].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).catch((error) => { + this.log.warn(`agent profile source "${s.id}" reload failed: ${error}`); + }); + }), + ); + } + } + this.remerge(); + this.ready = this.loadAll(); + void this.ready.catch(() => undefined); + } + + get(name: string): AgentProfile | undefined { + return this.merged.get(name); + } + + getDefault(): AgentProfile { + const profile = this.get(DEFAULT_AGENT_PROFILE_NAME); + if (profile === undefined) { + throw new Error( + `Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`, + ); + } + return profile; + } + + list(): readonly AgentProfile[] { + return [...this.merged.values()]; + } + + async load(): Promise { + await this.ready; + } + + async reload(): Promise { + await this.loadAll(); + this.onDidChangeEmitter.fire('catalog'); + } + + private async loadAll(): Promise { + for (const s of this.sources) { + await this.loadSource(s); + } + this.remerge(); + } + + private async reloadSource(id: string): Promise { + const s = this.sources.find((x) => x.id === id); + if (!s) return; + await this.loadSource(s, true); + } + + private loadSource(source: IAgentProfileSource, fireChange = false): Promise { + const previous = this.sourceLoadTails.get(source) ?? Promise.resolve(); + const current = previous + .catch(() => undefined) + .then(async () => { + let contribution: AgentProfileContribution; + try { + contribution = await source.load(); + } catch (error) { + if (source.fatal) throw error; + this.log.warn(`agent profile source "${source.id}" load failed: ${error}`); + return; + } + this.contributions.set(source.id, { c: contribution, priority: source.priority }); + if (fireChange) { + this.remerge(); + this.onDidChangeEmitter.fire(source.id); + } + }); + this.sourceLoadTails.set(source, current); + const clear = () => { + if (this.sourceLoadTails.get(source) === current) { + this.sourceLoadTails.delete(source); + } + }; + void current.then(clear, clear); + return current; + } + + private remerge(): void { + const m = new Map(); + for (const profile of this.builtin.list()) m.set(profile.name, profile); + const ordered = [...this.contributions.values()].toSorted( + (a, b) => a.priority - b.priority, + ); + for (const { c } of ordered) { + for (const profile of c.profiles) m.set(profile.name, profile); + } + this.merged = m; + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionAgentProfileCatalog, + SessionAgentProfileCatalogService, + InstantiationType.Eager, + 'sessionAgentProfileCatalog', +); diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts index efb8e62ca7..b4be65cdc8 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts @@ -15,8 +15,8 @@ import { InstantiationType } from '#/_base/di/extensions'; import { Disposable } from '#/_base/di/lifecycle'; import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { Emitter } from '#/_base/event'; -import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { IAgentProfileService } from '#/agent/profile/profile'; import { createHooks } from '#/hooks'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -45,7 +45,7 @@ export class SessionSubagentService extends Disposable implements ISessionSubage constructor( @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, - @IAgentProfileCatalogService private readonly catalog: IAgentProfileCatalogService, + @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, ) { super(); } diff --git a/packages/agent-core-v2/src/session/subagent/tools/agent.ts b/packages/agent-core-v2/src/session/subagent/tools/agent.ts index 00d94b15a8..ca1ccf8102 100644 --- a/packages/agent-core-v2/src/session/subagent/tools/agent.ts +++ b/packages/agent-core-v2/src/session/subagent/tools/agent.ts @@ -38,7 +38,8 @@ import { type ToolExecution, } from '#/tool/toolContract'; import { registerTool } from '#/agent/toolRegistry/toolContribution'; -import { IAgentProfileCatalogService, type AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { type AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix'; import { ILogService } from '#/_base/log/log'; import { IConfigService } from '#/app/config/config'; @@ -140,7 +141,7 @@ export class AgentTool implements BuiltinTool { constructor( @IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService, @ISessionSubagentService private readonly subagents: ISessionSubagentService, - @IAgentProfileCatalogService private readonly catalog: IAgentProfileCatalogService, + @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @IAgentScopeContext scopeContext: IAgentScopeContext, @IAgentTaskService private readonly tasks: IAgentTaskService, @IAgentProfileService private readonly profile: IAgentProfileService, @@ -237,6 +238,7 @@ export class AgentTool implements BuiltinTool { const requestedProfileName = args.subagent_type?.length ? args.subagent_type : DEFAULT_PROFILE_NAME; + await this.catalog.ready; const profile = this.catalog.get(requestedProfileName); if (profile === undefined) { throw new Error(`Unknown agent type: "${requestedProfileName}"`); @@ -452,6 +454,9 @@ function buildProfileDescriptions( (part): part is string => part !== undefined && part.length > 0, ); const header = details.length === 0 ? `- ${profile.name}` : `- ${profile.name}: ${details.join(' ')}`; + if (profile.tools === undefined) { + return `${header}\n Tools: all`; + } if (profile.tools.length === 0) { return header; } diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 8b3a529c9a..0bbd1acad2 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -25,7 +25,7 @@ import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentUserToolService } from '#/agent/userTool/userTool'; import type { SubagentSuspendedEvent } from '@moonshot-ai/protocol'; import { IEventBus } from '#/app/event/eventBus'; -import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { @@ -72,7 +72,7 @@ export class SessionSwarmService implements ISessionSwarmService { constructor( @IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService, @ISessionSubagentService private readonly subagents: ISessionSubagentService, - @IAgentProfileCatalogService private readonly catalog: IAgentProfileCatalogService, + @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @ISessionContext private readonly sessionContext: ISessionContext, @ISessionMetadata private readonly metadata: ISessionMetadata, @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, @@ -130,6 +130,7 @@ export class SessionSwarmService implements ISessionSwarmService { ): Promise { options.signal.throwIfAborted(); const caller = this.requireHandle(callerAgentId, 'Caller agent'); + await this.catalog.ready; const profile = this.catalog.get(options.profileName); if (profile === undefined) { throw new Error(`Unknown agent type: "${options.profileName}"`); diff --git a/packages/agent-core-v2/test/agent/profile/binding.test.ts b/packages/agent-core-v2/test/agent/profile/binding.test.ts index 20d57fed1d..3bd904936f 100644 --- a/packages/agent-core-v2/test/agent/profile/binding.test.ts +++ b/packages/agent-core-v2/test/agent/profile/binding.test.ts @@ -5,13 +5,16 @@ import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { registerAgentProfile } from '#/app/agentProfileCatalog/contribution'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { IWireService } from '#/wire/wire'; import { InMemoryWireRecordPersistence, createTestAgent, hostEnvironmentServices, + sessionService, type TestAgentContext, } from '../../harness'; @@ -133,3 +136,118 @@ describe('AgentProfileService.bind', () => { expect(svc.data().profileName).toBe(DEFAULT_AGENT_PROFILE_NAME); }); }); + +describe('AgentProfileService tool denylist', () => { + registerAgentProfile({ + name: 'deny-builtin', + disallowedTools: ['Bash'], + systemPrompt: () => 'deny test', + }); + registerAgentProfile({ + name: 'deny-over-allow', + tools: ['Read', 'Bash'], + disallowedTools: ['Bash'], + systemPrompt: () => 'deny test', + }); + registerAgentProfile({ + name: 'deny-mcp', + disallowedTools: ['mcp__github__*'], + systemPrompt: () => 'deny test', + }); + + let ctx: TestAgentContext; + let homeDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'kimi-deny-home-')); + }); + + afterEach(async () => { + await ctx?.dispose(); + await rm(homeDir, { recursive: true, force: true }); + }); + + async function bindProfile(name: string): Promise { + ctx = createTestAgent(hostEnvironmentServices(homeDir)); + const svc = ctx.get(IAgentProfileService); + await svc.bind({ profile: name, model: MOCK_MODEL }); + return svc; + } + + it('blocks a denied builtin tool while others stay active', async () => { + const svc = await bindProfile('deny-builtin'); + expect(svc.isToolActive('Bash')).toBe(false); + expect(svc.isToolActive('Read')).toBe(true); + }); + + it('denylist wins over the allowlist', async () => { + const svc = await bindProfile('deny-over-allow'); + expect(svc.isToolActive('Bash')).toBe(false); + expect(svc.isToolActive('Read')).toBe(true); + expect(svc.isToolActive('Write')).toBe(false); + }); + + it('matches denied mcp tools by glob', async () => { + const svc = await bindProfile('deny-mcp'); + expect(svc.isToolActive('mcp__github__create_pr', 'mcp')).toBe(false); + expect(svc.isToolActive('mcp__other__ping', 'mcp')).toBe(true); + expect(svc.isToolActive('Read')).toBe(true); + }); + + it('lists available profiles when binding an unknown profile', async () => { + ctx = createTestAgent(hostEnvironmentServices(homeDir)); + const svc = ctx.get(IAgentProfileService); + await expect(svc.bind({ profile: 'does-not-exist', model: MOCK_MODEL })).rejects.toThrow( + /Available profiles: .*agent/, + ); + }); + + it('persists the denylist in the bind records', async () => { + const persistence = new InMemoryWireRecordPersistence(); + ctx = createTestAgent({ persistence }, hostEnvironmentServices(homeDir)); + const svc = ctx.get(IAgentProfileService); + + await svc.bind({ profile: 'deny-builtin', model: MOCK_MODEL }); + await ctx.get(IWireService).flush(); + + const record = persistence.records.find((r) => r.type === 'config.update' && 'profileName' in r); + expect(record).toMatchObject({ profileName: 'deny-builtin', disallowedTools: ['Bash'] }); + }); + + it('restores the denylist from persisted records on resume without catalog resolution', async () => { + const persistence = new InMemoryWireRecordPersistence(); + ctx = createTestAgent({ persistence }, hostEnvironmentServices(homeDir)); + const svc = ctx.get(IAgentProfileService); + await svc.bind({ profile: 'deny-builtin', model: MOCK_MODEL }); + await ctx.get(IWireService).flush(); + await ctx.dispose(); + + // Resume by replaying the same records, with a catalog that cannot resolve + // the bound profile (e.g. its agent file was deleted): the denylist must + // come from the persisted record, not from a catalog lookup. + const emptyCatalog = { + _serviceBrand: undefined, + ready: Promise.resolve(), + get: () => undefined, + getDefault: () => ({ + name: DEFAULT_AGENT_PROFILE_NAME, + tools: undefined, + systemPrompt: () => '', + }), + list: () => [], + load: async () => {}, + reload: async () => {}, + } as unknown as ISessionAgentProfileCatalog; + ctx = createTestAgent( + { persistence }, + hostEnvironmentServices(homeDir), + sessionService(ISessionAgentProfileCatalog, emptyCatalog), + ); + await ctx.restorePersisted(); + const resumed = ctx.get(IAgentProfileService); + + expect(resumed.data().profileName).toBe('deny-builtin'); + expect(resumed.isToolActive('Bash')).toBe(false); + expect(resumed.isToolActive('Read')).toBe(true); + }); +}); diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index ab552c51c8..e988a0cd49 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -6,7 +6,8 @@ import { TestInstantiationService } from '#/_base/di/test'; import { IAgentProfileService } from '#/agent/profile/profile'; import { AgentProfileService } from '#/agent/profile/profileService'; import { ProfileModel } from '#/agent/profile/profileOps'; -import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import type { GenerationKwargs } from '#/app/llmProtocol/kimiOptions'; @@ -102,7 +103,7 @@ function buildHost(key: string): { host.stub(IBootstrapService, stubUnused()); host.stub(ISessionContext, createSessionContextStub()); host.stub(ISessionWorkspaceContext, stubUnused()); - host.stub(IAgentProfileCatalogService, stubUnused()); + host.stub(ISessionAgentProfileCatalog, stubUnused()); host.stub(ISessionSkillCatalog, stubUnused()); host.set(IAgentProfileService, new SyncDescriptor(AgentProfileService)); const wire = registerTestAgentWire(host, testWireScope(SCOPE, key), { diff --git a/packages/agent-core-v2/test/app/agentFileCatalog/agentFile.test.ts b/packages/agent-core-v2/test/app/agentFileCatalog/agentFile.test.ts new file mode 100644 index 0000000000..95b0511df2 --- /dev/null +++ b/packages/agent-core-v2/test/app/agentFileCatalog/agentFile.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; + +import { AgentFileParseError, parseAgentFileText } from '#/app/agentFileCatalog/agentFile'; +import { agentProfileFromFile } from '#/app/agentFileCatalog/agentProfileFromFile'; +import type { AgentFileDefinition } from '#/app/agentFileCatalog/types'; + +const FULL_FILE = `--- +name: code-reviewer +description: 严格的代码审查 agent +whenToUse: 代码评审、PR 检查 +mode: append +tools: + - Read + - Grep + - mcp__github__* +disallowedTools: + - Bash +unknownField: tolerated +--- + +你是严格的代码审查者。 +`; + +function parse(text: string): AgentFileDefinition { + return parseAgentFileText({ path: '/tmp/agents/reviewer.md', source: 'project', text }); +} + +describe('parseAgentFileText', () => { + it('parses a full agent file', () => { + const def = parse(FULL_FILE); + + expect(def.name).toBe('code-reviewer'); + expect(def.description).toBe('严格的代码审查 agent'); + expect(def.whenToUse).toBe('代码评审、PR 检查'); + expect(def.mode).toBe('append'); + expect(def.tools).toEqual(['Read', 'Grep', 'mcp__github__*']); + expect(def.disallowedTools).toEqual(['Bash']); + expect(def.prompt).toBe('你是严格的代码审查者。'); + expect(def.source).toBe('project'); + }); + + it('defaults mode to replace and leaves tool lists undefined', () => { + const def = parse('---\nname: solo\ndescription: d\n---\n\nbody\n'); + + expect(def.mode).toBe('replace'); + expect(def.tools).toBeUndefined(); + expect(def.disallowedTools).toBeUndefined(); + expect(def.whenToUse).toBeUndefined(); + expect(def.prompt).toBe('body'); + }); + + it('rejects missing frontmatter', () => { + expect(() => parse('no frontmatter here')).toThrow(AgentFileParseError); + }); + + it('rejects non-mapping frontmatter', () => { + expect(() => parse('---\n- just\n- a\n- list\n---\n\nbody\n')).toThrow(/mapping/); + }); + + it('rejects invalid yaml frontmatter', () => { + expect(() => parse('---\nfoo: [unclosed\n---\n\nbody\n')).toThrow(AgentFileParseError); + }); + + it('rejects a missing name', () => { + expect(() => parse('---\ndescription: d\n---\n\nbody\n')).toThrow(/"name"/); + }); + + it('rejects a missing description', () => { + expect(() => parse('---\nname: solo\n---\n\nbody\n')).toThrow(/"description"/); + }); + + it('rejects non kebab-case names', () => { + expect(() => parse('---\nname: CodeReviewer\ndescription: d\n---\n\nbody\n')).toThrow( + /kebab-case/, + ); + expect(() => parse('---\nname: code_reviewer\ndescription: d\n---\n\nbody\n')).toThrow( + /kebab-case/, + ); + }); + + it('rejects an invalid mode', () => { + expect(() => + parse('---\nname: solo\ndescription: d\nmode: prepend\n---\n\nbody\n'), + ).toThrow(/"mode"/); + }); + + it('rejects a non-list tools field', () => { + expect(() => parse('---\nname: solo\ndescription: d\ntools: Read\n---\n\nbody\n')).toThrow( + /"tools"/, + ); + }); + + it('rejects non-string tool entries', () => { + expect(() => + parse('---\nname: solo\ndescription: d\ntools:\n - 42\n---\n\nbody\n'), + ).toThrow(/non-empty strings/); + }); + + it('rejects an empty prompt body', () => { + expect(() => parse('---\nname: solo\ndescription: d\n---\n')).toThrow(/prompt body/); + }); +}); + +describe('agentProfileFromFile', () => { + const base: AgentFileDefinition = { + name: 'reviewer', + description: 'd', + whenToUse: 'reviews', + mode: 'replace', + prompt: 'PROMPT_BODY', + path: '/tmp/agents/reviewer.md', + source: 'user', + }; + + it('replace mode returns the body verbatim and injects no context', () => { + const profile = agentProfileFromFile(base); + const prompt = profile.systemPrompt({ agentsMd: 'AGENTS_MD_CONTENT', skills: 'SKILLS_LISTING' }); + + expect(prompt).toBe('PROMPT_BODY'); + expect(profile.tools).toBeUndefined(); + expect(profile.whenToUse).toBe('reviews'); + }); + + it('append mode injects the body and keeps context injection', () => { + const profile = agentProfileFromFile({ ...base, mode: 'append' }); + const prompt = profile.systemPrompt({ agentsMd: 'AGENTS_MD_CONTENT', skills: 'SKILLS_LISTING' }); + + expect(prompt).toContain('PROMPT_BODY'); + expect(prompt).toContain('AGENTS_MD_CONTENT'); + expect(prompt).toContain('SKILLS_LISTING'); + }); + + it('append mode with an allowlist without Skill skips the skills listing', () => { + const profile = agentProfileFromFile({ ...base, mode: 'append', tools: ['Read'] }); + const prompt = profile.systemPrompt({ skills: 'SKILLS_LISTING' }); + + expect(prompt).toContain('PROMPT_BODY'); + expect(prompt).not.toContain('SKILLS_LISTING'); + }); + + it('passes tools and disallowedTools through', () => { + const profile = agentProfileFromFile({ + ...base, + tools: ['Read'], + disallowedTools: ['Bash'], + }); + + expect(profile.tools).toEqual(['Read']); + expect(profile.disallowedTools).toEqual(['Bash']); + }); +}); diff --git a/packages/agent-core-v2/test/app/agentFileCatalog/agentFileDiscovery.test.ts b/packages/agent-core-v2/test/app/agentFileCatalog/agentFileDiscovery.test.ts new file mode 100644 index 0000000000..9bf560bc86 --- /dev/null +++ b/packages/agent-core-v2/test/app/agentFileCatalog/agentFileDiscovery.test.ts @@ -0,0 +1,96 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; + +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { discoverAgentFiles } from '#/app/agentFileCatalog/agentFileDiscovery'; +import type { AgentFileRoot } from '#/app/agentFileCatalog/types'; + +function agentMd(name: string): string { + return `---\nname: ${name}\ndescription: ${name} agent\n---\n\n${name} prompt\n`; +} + +describe('discoverAgentFiles', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'agent-discovery-')); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + function fileRoot(path: string, source: AgentFileRoot['source'] = 'project'): AgentFileRoot { + return { path, source }; + } + + it('discovers top-level and nested .md files recursively', async () => { + await writeFile(join(root, 'solo.md'), agentMd('solo')); + await mkdir(join(root, 'team'), { recursive: true }); + await writeFile(join(root, 'team/reviewer.md'), agentMd('reviewer')); + + const result = await discoverAgentFiles([fileRoot(root)]); + + expect(result.agents.map((a) => a.name)).toEqual(['reviewer', 'solo']); + expect(result.skipped).toEqual([]); + expect(result.scannedRoots).toEqual([root]); + }); + + it('skips dot-prefixed entries and node_modules', async () => { + await mkdir(join(root, '.hidden'), { recursive: true }); + await writeFile(join(root, '.hidden/ghost.md'), agentMd('ghost')); + await mkdir(join(root, 'node_modules/pkg'), { recursive: true }); + await writeFile(join(root, 'node_modules/pkg/dep.md'), agentMd('dep')); + await writeFile(join(root, '.dotfile.md'), agentMd('dotfile')); + await writeFile(join(root, 'solo.md'), agentMd('solo')); + + const result = await discoverAgentFiles([fileRoot(root)]); + + expect(result.agents.map((a) => a.name)).toEqual(['solo']); + }); + + it('skips invalid files with reasons and keeps valid ones', async () => { + await writeFile(join(root, 'good.md'), agentMd('good')); + await writeFile(join(root, 'bad.md'), 'not an agent file'); + + const warnings: string[] = []; + const result = await discoverAgentFiles([fileRoot(root)], (message) => + warnings.push(message), + ); + + expect(result.agents.map((a) => a.name)).toEqual(['good']); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]?.path.endsWith('bad.md')).toBe(true); + expect(result.skipped[0]?.reason).toContain('Missing frontmatter'); + expect(warnings).toHaveLength(1); + }); + + it('resolves name collisions first-wins in root order', async () => { + const other = await mkdtemp(join(tmpdir(), 'agent-discovery-other-')); + try { + await writeFile(join(root, 'reviewer.md'), agentMd('reviewer')); + await writeFile( + join(other, 'reviewer.md'), + '---\nname: reviewer\ndescription: other reviewer\n---\n\nother prompt\n', + ); + + const result = await discoverAgentFiles([fileRoot(root, 'user'), fileRoot(other, 'project')]); + + expect(result.agents).toHaveLength(1); + expect(result.agents[0]?.description).toBe('reviewer agent'); + expect(result.agents[0]?.source).toBe('user'); + } finally { + await rm(other, { recursive: true, force: true }); + } + }); + + it('ignores non-markdown files', async () => { + await writeFile(join(root, 'notes.txt'), agentMd('notes')); + + const result = await discoverAgentFiles([fileRoot(root)]); + + expect(result.agents).toEqual([]); + }); +}); diff --git a/packages/agent-core-v2/test/app/agentFileCatalog/agentRoots.test.ts b/packages/agent-core-v2/test/app/agentFileCatalog/agentRoots.test.ts new file mode 100644 index 0000000000..bc8bf25c56 --- /dev/null +++ b/packages/agent-core-v2/test/app/agentFileCatalog/agentRoots.test.ts @@ -0,0 +1,125 @@ +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 { + configuredAgentRoots, + projectAgentRoots, + userAgentRoots, +} from '#/app/agentFileCatalog/agentRoots'; + +describe('agentRoots', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'agent-roots-')); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + async function markGitRoot(dir: string = root): Promise { + await mkdir(join(dir, '.git'), { recursive: true }); + } + + describe('projectRoots', () => { + it('resolves the brand .kimi-code/agents directory at the .git root', async () => { + await markGitRoot(); + await mkdir(join(root, '.kimi-code/agents'), { recursive: true }); + + const roots = await projectAgentRoots(root); + + expect( + roots.some((r) => r.path.endsWith('.kimi-code/agents') && r.source === 'project'), + ).toBe(true); + }); + + it('falls back to the generic .agents/agents directory', async () => { + await markGitRoot(); + await mkdir(join(root, '.agents/agents'), { recursive: true }); + + const roots = await projectAgentRoots(root); + + expect(roots.some((r) => r.path.endsWith('.agents/agents') && r.source === 'project')).toBe( + true, + ); + expect(roots.some((r) => r.path.endsWith('.kimi-code/agents'))).toBe(false); + }); + + it('walks up from a child directory to the .git root', async () => { + await markGitRoot(); + await mkdir(join(root, '.kimi-code/agents'), { recursive: true }); + const child = join(root, 'src/pkg'); + await mkdir(child, { recursive: true }); + + const roots = await projectAgentRoots(child); + + expect(roots.some((r) => r.path.endsWith('.kimi-code/agents'))).toBe(true); + }); + + it('orders the brand directory before the generic directory', async () => { + await markGitRoot(); + await mkdir(join(root, '.kimi-code/agents'), { recursive: true }); + await mkdir(join(root, '.agents/agents'), { recursive: true }); + + const roots = await projectAgentRoots(root); + const brandIdx = roots.findIndex((r) => r.path.endsWith('.kimi-code/agents')); + const genericIdx = roots.findIndex((r) => r.path.endsWith('.agents/agents')); + + expect(brandIdx).toBeGreaterThanOrEqual(0); + expect(genericIdx).toBeGreaterThan(brandIdx); + }); + }); + + describe('userRoots', () => { + it('resolves the brand agents directory under homeDir', async () => { + await mkdir(join(root, 'agents'), { recursive: true }); + + const roots = await userAgentRoots(root, root); + + expect(roots.some((r) => r.path.endsWith('/agents') && r.source === 'user')).toBe(true); + }); + + it('falls back to the generic .agents/agents under osHomeDir', async () => { + const homeDir = join(root, 'brand-home'); + const osHomeDir = join(root, 'os-home'); + await mkdir(homeDir, { recursive: true }); + await mkdir(join(osHomeDir, '.agents/agents'), { recursive: true }); + + const roots = await userAgentRoots(homeDir, osHomeDir); + + expect(roots.some((r) => r.path.endsWith('.agents/agents') && r.source === 'user')).toBe( + true, + ); + }); + }); + + 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, 'team'), { recursive: true }); + await mkdir(absDir, { recursive: true }); + await mkdir(join(root, 'relative'), { recursive: true }); + + const roots = await configuredAgentRoots( + ['~', '~/team', absDir, 'relative'], + root, + homeDir, + 'extra', + ); + const paths = roots.map((r) => r.path); + + expect(roots.every((r) => r.source === 'extra')).toBe(true); + expect(paths).toContain(await realpath(homeDir)); + expect(paths).toContain(await realpath(join(homeDir, 'team'))); + expect(paths).toContain(await realpath(absDir)); + expect(paths).toContain(await realpath(join(root, 'relative'))); + }); + }); +}); diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index de8a9a8bbc..f528abb01d 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -149,7 +149,7 @@ describe('Agent config', () => { }); expect(ctx.newEvents()).toMatchInlineSnapshot(` - [wire] config.update { "profileName": "test-profile", "systemPrompt": "Profile system prompt.", "time": "