diff --git a/.changeset/custom-agent-files.md b/.changeset/custom-agent-files.md new file mode 100644 index 0000000000..0e3af30a77 --- /dev/null +++ b/.changeset/custom-agent-files.md @@ -0,0 +1,11 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": minor +"@moonshot-ai/kap-server": minor +"@moonshot-ai/protocol": minor +"@moonshot-ai/kimi-code": minor +--- + +Support custom agents defined as Markdown files with frontmatter — replace or append the system prompt, allow/deny tools — selected via `--agent ` / `--agent-file ` (v2 engine only: `KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p`). See "Custom Agents" in the Agents docs for the file format, discovery directories, and binding semantics. + +Fix resuming a session in the v1 engine when its wire log contains a `tools.set_active_tools` record without `names` (written by v2-engine sessions): such records no longer crash replay. diff --git a/.changeset/global-tool-gating.md b/.changeset/global-tool-gating.md new file mode 100644 index 0000000000..5883997383 --- /dev/null +++ b/.changeset/global-tool-gating.md @@ -0,0 +1,11 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": minor +"@moonshot-ai/kap-server": minor +"@moonshot-ai/protocol": minor +"@moonshot-ai/klient": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Add globally enforced tool gating (v2 engine only): a `[tools]` section in `config.toml` with `enabled` / `disabled` lists constrains every profile and direct tool execution, while prompt submissions accept a session-persistent `disabledTools` list (REST field `disabled_tools`, SDK prompt options). Set `[tools] disabled = ["Task"]` in `config.toml`, or pass `disabledTools` when prompting through the SDK. diff --git a/.changeset/system-md-override.md b/.changeset/system-md-override.md new file mode 100644 index 0000000000..7278fbbe0e --- /dev/null +++ b/.changeset/system-md-override.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core-v2": minor +"@moonshot-ai/kimi-code": minor +--- + +Support a permanent main-agent system prompt override via `~/.kimi-code/SYSTEM.md`: when present, it replaces the default system prompt for every session, and `${skills}` / `${agents_md}` / `${cwd}` variables are substituted (v2 engine only: `KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p`). Write `~/.kimi-code/SYSTEM.md` to override the main prompt. 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..45339d3365 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,20 @@ 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.agentFiles.some((file) => file.trim().length === 0)) { + throw new OptionConflictError('Agent file path 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..3e8460feaf 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, @@ -24,17 +26,21 @@ import { IAgentPromptService, IAgentTaskService, IAuthSummaryService, + IBootstrapService, IConfigService, IEventBus, IOAuthToolkit, ISessionIndex, ISessionLifecycleService, ITelemetryService, + agentCatalogRuntimeOptionsSeed, bootstrap, createCloudAppender, ensureMainAgent, hostRequestHeadersSeed, logSeed, + parseAgentFileText, + resolveAgentPath, resolveAgentTaskConfig, resolveKimiHome, resolveLoggingConfig, @@ -120,6 +126,11 @@ 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. Passed through unresolved — + // the engine expands `~` and resolves relative paths against the session + // workDir (mirroring `--skills-dir`). + ...agentCatalogRuntimeOptionsSeed(opts.agentFiles), ]); const auth = app.accessor.get(IOAuthToolkit); @@ -236,6 +247,63 @@ 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 = resolveAgentPath( + lastAgentFile, + workDir, + app.accessor.get(IBootstrapService).osHomeDir, + ); + 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. A + // same-name re-select on a resumed session keeps the profile and only applies + // an explicitly requested model; a different name is rejected by the + // engine's first-bind guard inside `bind`. + const applyProfileSelection = async ( + profile: IAgentProfileService, + model: string | undefined, + ): Promise => { + if (agentProfileName !== undefined) { + if (profile.data().profileName === agentProfileName) { + if (model !== undefined) await profile.setModel(model); + return; + } + 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 +341,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 +360,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 { @@ -314,9 +378,12 @@ async function resolveNativeSession( const session = await lifecycle.create({ workDir, additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + mainAgentBinding: { + profile: agentProfileName ?? 'agent', + model, + }, }); const agent = await ensureMainAgent(session); - await agent.accessor.get(IAgentProfileService).setModel(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..0ade3e7550 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,55 @@ 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 empty agent file values', () => { + const opts = parse(['-p', 'hi', '--agent-file', ' ']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Agent file path 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 +534,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 cb9478d960..2275d3a267 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -183,6 +183,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], addDirs: ['../shared', '/tmp/extra'], }; @@ -262,6 +264,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: ['/skills'], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -295,6 +299,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -335,6 +341,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -378,6 +386,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -411,6 +421,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -462,6 +474,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -500,6 +514,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -534,6 +550,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -583,6 +601,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -625,6 +645,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ), @@ -662,6 +684,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -716,6 +740,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -762,6 +788,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..2ba3ae75de 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; @@ -118,9 +125,18 @@ function makeFakeHarness() { // Native event listeners registered on the main agent's IEventBus; the turn // emits a streaming assistant delta before completing. const eventListeners = new Set<(event: DomainEvent) => void>(); + const profileState: { profileName: string | undefined } = { profileName: undefined }; 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', + data: () => ({ profileName: profileState.profileName }), + }, + ], [IAgentPermissionModeService, { mode: 'auto', setMode: vi.fn() }], [IAuthSummaryService, { ensureReady: vi.fn(async () => {}) }], [ @@ -183,6 +199,7 @@ function makeFakeHarness() { platform: 'linux', arch: 'x64', clientVersion: '1.2.3-test', + osHomeDir: '/home/test', getEnv: () => undefined, }, ], @@ -204,7 +221,7 @@ function makeFakeHarness() { ], ]); const app = fakeScope('app', appServices); - return { app, agent, session, agentServices }; + return { app, agent, session, agentServices, appServices, profileState }; } describe('runV2Print', () => { @@ -274,4 +291,191 @@ 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, appServices, 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 lifecycle = appServices.get(ISessionLifecycleService) as { + create: ReturnType; + }; + expect(lifecycle.create).toHaveBeenCalledWith({ + workDir: process.cwd(), + additionalDirs: undefined, + mainAgentBinding: { profile: 'reviewer', model: 'k2' }, + }); + const profile = agentServices.get(IAgentProfileService) as { bind: ReturnType }; + expect(profile.bind).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, appServices, 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 lifecycle = appServices.get(ISessionLifecycleService) as { + create: ReturnType; + }; + expect(lifecycle.create).toHaveBeenCalledWith({ + workDir: process.cwd(), + additionalDirs: undefined, + mainAgentBinding: { profile: 'file-reviewer', model: 'k2' }, + }); + const profile = agentServices.get(IAgentProfileService) as { bind: ReturnType }; + expect(profile.bind).not.toHaveBeenCalled(); + }); + + it('does not materialize a main agent after fresh profile binding fails', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, appServices } = makeFakeHarness(); + const lifecycle = appServices.get(ISessionLifecycleService) as { + create: ReturnType; + }; + lifecycle.create.mockRejectedValueOnce(new Error('Unknown agent profile')); + mocks.bootstrap.mockReturnValue({ app }); + + await expect( + runV2Print(opts({ agent: 'missing' }) as never, '1.2.3-test', { stdout, stderr }), + ).rejects.toThrow('Unknown agent profile'); + + expect(mocks.ensureMainAgent).not.toHaveBeenCalled(); + }); + + it('fails before any turn when --agent-file is invalid', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-agent-file-')); + const agentFile = join(dir, 'broken.md'); + await writeFile(agentFile, '---\ndescription: no name\n---\n\nbody\n'); + const stdout = writer(); + const stderr = writer(); + const { app, agent, agentServices } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await expect( + runV2Print(opts({ agentFiles: [agentFile] }) as never, '1.2.3-test', { stdout, stderr }), + ).rejects.toThrow(/Invalid agent file/); + + const profile = agentServices.get(IAgentProfileService) as { + bind: ReturnType; + }; + expect(profile.bind).not.toHaveBeenCalled(); + }); + + 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); + }); + + it('passes --agent-file paths through unresolved so the engine can expand ~', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent } = 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'] }); + }); + + it('treats re-selecting the already-bound profile on resume as a no-op', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent, agentServices, appServices, profileState } = makeFakeHarness(); + profileState.profileName = 'reviewer'; + + const index = appServices.get(ISessionIndex) as { list: ReturnType }; + index.list.mockResolvedValue({ items: [{ id: 'ses_1', cwd: process.cwd() }] }); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print(opts({ session: 'ses_1', agent: 'reviewer' }) as never, '1.2.3-test', { + stdout, + stderr, + }); + + const profile = agentServices.get(IAgentProfileService) as { + bind: ReturnType; + setModel: ReturnType; + }; + expect(profile.bind).not.toHaveBeenCalled(); + expect(profile.setModel).not.toHaveBeenCalled(); + }); + + it('switches the model when resuming with the already-bound profile and an explicit model', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent, agentServices, appServices, profileState } = makeFakeHarness(); + profileState.profileName = 'reviewer'; + + const index = appServices.get(ISessionIndex) as { list: ReturnType }; + index.list.mockResolvedValue({ items: [{ id: 'ses_1', cwd: process.cwd() }] }); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print( + opts({ session: 'ses_1', agent: 'reviewer', model: 'new-model' }) as never, + '1.2.3-test', + { stdout, stderr }, + ); + + const profile = agentServices.get(IAgentProfileService) as { + bind: ReturnType; + setModel: ReturnType; + }; + expect(profile.bind).not.toHaveBeenCalled(); + expect(profile.setModel).toHaveBeenCalledWith('new-model'); + }); }); 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 3bc1497fa0..29c07dfac3 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 @@ -126,6 +126,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 f3e5faa253..eadee3c3fb 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 933f282cc6..f13094b964 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -93,18 +93,20 @@ 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) | | `thinking` | `table` | — | Default parameters for Thinking mode → [`thinking`](#thinking) | | `loop_control` | `table` | — | Agent loop control parameters → [`loop_control`](#loop_control) | | `background` | `table` | — | Background task runtime parameters → [`background`](#background) | +| `tools` | `table` | — | Global tool switch → [`tools`](#tools) | | `image` | `table` | — | Image compression parameters → [`image`](#image) | | `services` | `table` | — | Built-in external service configuration → [`services`](#services) | | `permission` | `table` | — | Initial permission rules → [`permission`](#permission) | | `hooks` | `array` | — | Lifecycle hooks; see [Hooks](../customization/hooks.md) | -The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `image`, `services`, and `permission`. +The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `tools`, `image`, `services`, and `permission`. ## `providers` @@ -228,6 +230,26 @@ In print mode (`kimi -p ""`), Kimi Code stays alive after the main agent `timeout_ms` can be overridden by the `KIMI_SUBAGENT_TIMEOUT_MS` environment variable, which takes higher priority than `config.toml`. +## `tools` + +`tools` is the global tool switch: it applies to every agent in all sessions and intersects with each agent's own `tools` / `disallowedTools` policy. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `enabled` | `array` | — | Global allowlist: when non-empty, only the listed tools are available; omitting the field or setting an empty array imposes no constraint | +| `disabled` | `array` | — | Global denylist, applied after `enabled` | + +Name matching follows the same rules as the same-named fields in an agent file: built-in tools match by exact name (such as `Read`), and MCP tools match with globs (such as `mcp__github__*`). + +```toml +[tools] +disabled = ["EnterPlanMode", "ExitPlanMode", "mcp__github__*"] +``` + +::: warning Note +Like the `tools` / `disallowedTools` fields of an agent file, this section shapes the tools shown to the model and is enforced again before execution. [Permission rules](#permission) remain a separate control for operations that require approval. +::: + ## `image` `image` controls how images are compressed before being sent to the model, across every ingestion point (pasted images, `ReadMediaFile` reads, images in MCP tool results, and so on). diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 909848a24e..75f29f0647 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -39,6 +39,120 @@ 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. + +### 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 directory-discovered file does not override a same-name built-in Agent unless its frontmatter declares `override: true`. A file loaded through `--agent-file` is treated as explicit launch intent, may override a same-name built-in Agent, 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 +override: false +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 | +| `override` | no | Whether this file may replace a same-name built-in Agent. Defaults to `false`; `--agent-file` is already explicit and does not require this field | +| `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; an empty list (`tools: []`) disables 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. + +::: warning Note +`tools` and `disallowedTools` shape the tools shown to the model and are enforced again before execution. Permission rules remain a separate control for operations that require approval. +::: + +Custom agents delegated as sub-agents run without the built-in sub-agent framing ("your final message is the entire handoff"). If you write an agent meant for delegation, state in the body that its last message should be the complete, self-contained result for the caller. + +### Selecting the Main Agent + +Two CLI flags select which agent drives the session. **Both currently require the v2 engine** — `kimi -p` with `KIMI_CODE_EXPERIMENTAL_FLAG=1`; the interactive TUI (v1) rejects them with a clear error for now: + +- **`--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 — without `--agent`, the profile defined by the last `--agent-file` is selected — and combine it with `--agent ` to choose among them by name. + +For example, in print mode: + +```sh +KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "Review the changes on this branch" +``` + +The bound agent is the session's identity: it is fixed at the session's first bind and cannot be switched later. Re-selecting the already-bound agent (for example resuming with the same `--agent`) is a no-op; selecting a different one fails with an "already bound" error. + +For main-agent customization, prefer `mode: append` so the environment, workspace-instruction, and Skill injections stay in effect; `mode: replace` fits self-contained sub-agents that own their entire prompt. + +### Overriding the main agent's system prompt with SYSTEM.md + +To override the main agent's system prompt permanently — without passing `--agent` or `--agent-file` on every launch — write a `$KIMI_CODE_HOME/SYSTEM.md` file (default: `~/.kimi-code/SYSTEM.md`; it moves with `KIMI_CODE_HOME`). While the file exists and is non-empty, it replaces the built-in default main agent's system prompt in full — and only the prompt: the description and tool set are inherited from the built-in defaults. Like `--agent` / `--agent-file`, SYSTEM.md currently takes effect only under the v2 engine (`KIMI_CODE_EXPERIMENTAL_FLAG=1`); the v1 engine ignores the file. + +SYSTEM.md is a plain Markdown body — no frontmatter is required or read. A missing or empty file has no effect, and a read failure falls back to the built-in prompt with a warning. Explicit intent still outranks it: a project-scoped same-name agent file declaring `override: true` and any file passed via `--agent-file` take precedence, and selecting another agent with `--agent` bypasses it entirely. Within the user scope itself, SYSTEM.md wins over a same-name file discovered in the `agents/` directories. + +Unlike a regular agent file whose body is used as-is, SYSTEM.md is rendered as a template each time the prompt is built — `${var}` placeholders in the body are substituted: + +| Variable | Content | +| --- | --- | +| `${skills}` | The merged Agent Skills injection; empty when the `Skill` tool is unavailable | +| `${agents_md}` | Content of the workspace instruction files (such as `AGENTS.md`) | +| `${cwd}` | Current working directory | +| `${cwd_listing}` | Listing of the working directory | +| `${os}` | Operating system kind | +| `${shell}` | Shell name and path, for example `bash (\`/bin/bash\`)` | +| `${now}` | Current time in ISO format | + +Unknown variables stay verbatim, a bare `$` is never special, and a variable with no context value renders as an empty string. The variables are enough to rebuild the skeleton of the built-in prompt, for example: + +```markdown +You are Kimi, running at ${cwd} on ${os}. + +${agents_md} + +${skills} +``` + ## 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..47df1fba41 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 (v2 engine only) | +| `--agent-file ` | | Load a custom agent from a Markdown file for this launch and select it (v2 engine only). Can be repeated | | `--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 currently require the v2 engine — `kimi -p` with `KIMI_CODE_EXPERIMENTAL_FLAG=1`: + +```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 — without `--agent`, the profile defined by the last `--agent-file` is selected. The selection is fixed at the session's first bind: resuming with the same `--agent` is a no-op, and switching to a different one fails with an "already bound" error. 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 529b16676e..54209af48b 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -93,12 +93,14 @@ 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) | | `thinking` | `table` | — | Thinking 模式默认参数 → [`thinking`](#thinking) | | `loop_control` | `table` | — | Agent 循环控制参数 → [`loop_control`](#loop_control) | | `background` | `table` | — | 后台任务运行参数 → [`background`](#background) | +| `tools` | `table` | — | 全局工具开关 → [`tools`](#tools) | | `image` | `table` | — | 图片压缩参数 → [`image`](#image) | | `services` | `table` | — | 内置外部服务配置 → [`services`](#services) | | `permission` | `table` | — | 初始权限规则 → [`permission`](#permission) | @@ -228,6 +230,26 @@ display_name = "Kimi for Coding (custom)" `timeout_ms` 可被环境变量 `KIMI_SUBAGENT_TIMEOUT_MS` 覆盖,优先级高于配置文件。 +## `tools` + +`tools` 设置全局工具开关,对所有会话中的每个 Agent 生效,并在 Agent 自身的 `tools` / `disallowedTools` 策略之上再取一次交集。 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `enabled` | `array` | — | 全局允许列表:非空时仅列出的工具可用;省略或设为空数组均表示不约束 | +| `disabled` | `array` | — | 全局禁止列表,在 `enabled` 之后应用 | + +工具名匹配规则与 Agent 文件中的同名字段一致:内置工具按名称精确匹配(如 `Read`),MCP 工具用 glob 匹配(如 `mcp__github__*`)。 + +```toml +[tools] +disabled = ["EnterPlanMode", "ExitPlanMode", "mcp__github__*"] +``` + +::: warning 注意 +与 Agent 文件中的 `tools` / `disallowedTools` 一样,本节不仅决定模型能"看到"哪些工具,还会在执行前再次强制检查。[权限规则](#permission)仍是独立的控制层,用于决定哪些操作需要审批。 +::: + ## `image` `image` 控制图片发送给模型前的压缩行为,对所有图片入口生效(粘贴图片、`ReadMediaFile` 读图、MCP 工具结果里的图片等)。 diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 10d0bb9edb..e0a9c16bee 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -39,6 +39,120 @@ Kimi Code CLI 内置三种子 Agent,开箱即用,分别面向不同任务形 如果需要某类工具在子 Agent 中始终不可用,应收紧主 Agent 的权限规则。 +## 自定义 Agent + +除了三个内置子 Agent,你还可以用 Markdown 文件定义自己的 Agent。每个文件描述一个 Agent:文件顶部的 Frontmatter(YAML 元数据)声明名称、描述和工具权限,文件正文是它的系统提示词。自定义 Agent 可以作为子 Agent 被委派 —— 主 Agent 会自动发现它们,与内置子 Agent 并列 —— 也可以在启动时选为主 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;如确需替换,必须在 Frontmatter 中声明 `override: true`。通过 `--agent-file` 加载的文件视为显式启动意图,可以覆盖同名内置 Agent,优先级高于所有目录作用域,且仅对本次启动生效。 + +### Agent 文件格式 + +Agent 文件是带 Frontmatter 的普通 Markdown: + +```markdown +--- +name: reviewer +description: 严格的代码审查 Agent,按严重度分级报告问题 +whenToUse: 代码评审与 PR 检查 +override: false +mode: replace +tools: + - Read + - Grep + - Glob + - mcp__github__* +disallowedTools: + - Bash +--- + +你是严格的代码审查者。阅读 diff 后,按严重度分级报告问题…… +``` + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `name` | 是 | kebab-case 唯一标识。缺少合法 `name` 的文件会被跳过并告警 | +| `description` | 是 | Agent 的用途。主 Agent 挑选子 Agent 时会看到,请围绕委派决策来写 | +| `whenToUse` | 否 | 补充说明何时应使用该 Agent | +| `override` | 否 | 是否允许覆盖同名内置 Agent,默认 `false`。`--agent-file` 属于显式启动意图,无需设置此字段 | +| `mode` | 否 | `replace`(默认):正文即 Agent 的完整系统提示词。`append`:正文追加到默认系统提示词之上,工作区指令和 Skill 注入保持生效 | +| `tools` | 否 | 工具名允许列表,如 `Read`、`Bash`;MCP 工具用 glob 匹配,如 `mcp__github__*`。缺省表示允许全部工具;空列表(`tools: []`)表示禁用全部工具 | +| `disallowedTools` | 否 | 禁止列表,匹配规则相同,在 `tools` 之后应用 | + +未知字段会被忽略,新版本写的文件在旧版本上仍可读取。 + +目录中发现的非法文件会被跳过并告警,不影响其他文件。通过 `--agent-file` 显式传入的文件必须合法 —— 否则 CLI 会报错并退出。 + +::: warning 注意 +`tools` 与 `disallowedTools` 不仅决定模型能"看到"哪些工具,还会在执行前再次强制检查。权限规则仍是独立的控制层,用于决定哪些操作需要审批。 +::: + +作为子 Agent 委派的自定义 Agent 不会携带内置子 Agent 的角色框架("你的最后一条消息就是完整交付")。如果编写的 Agent 用于委派,请在正文中说明:其最后一条消息应当是交付给调用方的完整、自包含的结果。 + +### 选择主 Agent + +两个 CLI flag 用于选择驱动会话的 Agent。**目前二者都要求 v2 引擎** —— 即 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 下的 `kimi -p`;交互式 TUI(v1)暂时会以明确错误拒绝它们: + +- **`--agent `**:以指定 Agent 作为主 Agent 启动会话。名称可以指向内置 Agent 或任何已发现的文件;名称不存在时会报错,并列出可用的 Agent。 +- **`--agent-file `**:以最高优先级加载一个 Agent 文件(仅本次启动)并以其启动。重复传入可注册多个文件 —— 不传 `--agent` 时,以最后一个 `--agent-file` 定义的 Agent 启动 —— 配合 `--agent ` 按名称选择。 + +例如在 print 模式下: + +```sh +KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "审查这个分支上的改动" +``` + +绑定的 Agent 即会话的身份:在会话首次绑定后即固定,之后不可切换。重复选择已绑定的 Agent(例如以相同的 `--agent` 恢复会话)是 no-op;选择不同的 Agent 会报 "already bound" 错误。 + +定制主 Agent 时推荐使用 `mode: append`,以保持环境、工作区指令和 Skill 注入生效;`mode: replace` 适合自包含、完全拥有自己提示词的子 Agent。 + +### 用 SYSTEM.md 覆盖主 Agent 的系统提示词 + +希望永久覆盖主 Agent 的系统提示词、而不必每次启动都传入 `--agent` 或 `--agent-file` 时,可以写一份 `$KIMI_CODE_HOME/SYSTEM.md`(默认:`~/.kimi-code/SYSTEM.md`,随 `KIMI_CODE_HOME` 移动)。文件存在且非空期间,它整体替换内置默认主 Agent 的系统提示词——但只替换提示词,描述与工具集仍沿用内置默认值。与 `--agent` / `--agent-file` 一样,SYSTEM.md 目前仅在 v2 引擎下生效(`KIMI_CODE_EXPERIMENTAL_FLAG=1`);v1 引擎会忽略该文件。 + +SYSTEM.md 是纯 Markdown 正文,不需要也不读取 Frontmatter。文件缺失或为空时不生效;读取失败时会告警并回退到内置提示词。优先级上,显式意图仍然胜出:项目作用域中声明了 `override: true` 的同名 Agent 文件、通过 `--agent-file` 传入的文件都排在 SYSTEM.md 之前,用 `--agent` 选择其他 Agent 时 SYSTEM.md 也不会生效;而在用户作用域内部,SYSTEM.md 优先于 `agents/` 目录中扫描到的同名文件。 + +与按原样使用的普通 Agent 文件正文不同,SYSTEM.md 在每次构建提示词时作为模板渲染——正文中的 `${var}` 占位符会被替换: + +| 变量 | 内容 | +| --- | --- | +| `${skills}` | 合并后的 Agent Skills 注入内容;`Skill` 工具不可用时为空 | +| `${agents_md}` | 工作区指令文件(如 `AGENTS.md`)的内容 | +| `${cwd}` | 当前工作目录 | +| `${cwd_listing}` | 工作目录的文件列表 | +| `${os}` | 操作系统类型 | +| `${shell}` | Shell 名称与路径,例如 `bash (\`/bin/bash\`)` | +| `${now}` | 当前时间(ISO 格式) | + +未知变量原样保留,单独的 `$` 没有特殊含义;上下文中缺失的变量渲染为空字符串。利用这些变量可以重建内置提示词的骨架,例如: + +```markdown +You are Kimi, running at ${cwd} on ${os}. + +${agents_md} + +${skills} +``` + ## 指令文件 全局 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..88bda8e6ea 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` 下的 `kimi -p`: + +```sh +KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "审查这个分支上的改动" +``` + +`--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;重复传入可注册多个文件,再加 `--agent` 按名称选择 —— 不传 `--agent` 时,以最后一个 `--agent-file` 定义的 Agent 启动。选择在会话首次绑定后即固定:以相同的 `--agent` 恢复会话是 no-op,换成不同的 Agent 会报 "already bound" 错误。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 2f4a57693a..b7136f413b 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -117,6 +117,8 @@ const DOMAIN_LAYER = new Map([ ['skill', 3], ['skillCatalog', 3], ['sessionSkillCatalog', 3], + ['sessionAgentProfileCatalog', 3], + ['sessionToolPolicy', 3], ['permissionGate', 3], ['flag', 3], ['toolExecutor', 3], @@ -131,6 +133,7 @@ const DOMAIN_LAYER = new Map([ ['record', 3], ['modelCatalog', 3], ['agentProfileCatalog', 3], + ['agentFileCatalog', 3], // L4 — agent behaviour // `activityView` is the Agent-scope read model folding the agent's own event // bus into the activity projection (`agent.activity.updated`); it owns no @@ -148,6 +151,7 @@ const DOMAIN_LAYER = new Map([ ['runtime', 4], ['toolDedupe', 4], ['toolSelect', 4], + ['toolPolicy', 4], ['contextMemory', 4], ['contextInjector', 4], ['agentPlugin', 4], diff --git a/packages/agent-core-v2/src/agent/plan/profile/plan.ts b/packages/agent-core-v2/src/agent/plan/profile/plan.ts index f3b9d752f5..d41029fb7a 100644 --- a/packages/agent-core-v2/src/agent/plan/profile/plan.ts +++ b/packages/agent-core-v2/src/agent/plan/profile/plan.ts @@ -14,6 +14,7 @@ import { registerAgentProfile } from '#/app/agentProfileCatalog/contribution'; import { renderSystemPrompt, + skillActiveFor, TASK_AGENT_ROLE_PREFIX, } from '#/app/agentProfileCatalog/profile-shared'; @@ -46,5 +47,6 @@ registerAgentProfile({ whenToUse: 'Use this agent when the parent agent needs a step-by-step implementation plan, key file identification, and architectural trade-off analysis before code changes are made.', tools: PLAN_TOOLS, - systemPrompt: (context) => renderSystemPrompt(PLAN_ROLE, context, PLAN_TOOLS), + systemPrompt: (context) => + renderSystemPrompt(PLAN_ROLE, context, { skillActive: skillActiveFor(PLAN_TOOLS) }), }); diff --git a/packages/agent-core-v2/src/agent/profile/configSection.ts b/packages/agent-core-v2/src/agent/profile/configSection.ts index b33769cd48..5b467bfcc6 100644 --- a/packages/agent-core-v2/src/agent/profile/configSection.ts +++ b/packages/agent-core-v2/src/agent/profile/configSection.ts @@ -1,9 +1,15 @@ /** - * `profile` domain (L4) — `thinking` config-section env bindings. + * `profile` domain (L4) — `thinking` config-section env bindings and the + * global `tools` tool-activation section. * * Declares the env-only `KIMI_MODEL_THINKING_EFFORT` force override. Applied * to the effective `thinking` value by `config` and stripped before * persistence. + * + * The `tools` section is the global tool switch: `enabled` is an allowlist + * (when non-empty, only listed tools are active) and `disabled` a denylist, + * applied on top of every profile's own `tools` / `disallowedTools` policy by + * `IAgentToolPolicyService`. */ import { z } from 'zod'; @@ -36,3 +42,14 @@ registerConfigSection(THINKING_SECTION, ThinkingConfigSchema, { env: thinkingEnvBindings, stripEnv: stripThinkingEnv, }); + +export const TOOLS_SECTION = 'tools'; + +export const ToolsConfigSchema = z.object({ + enabled: z.array(z.string()).optional(), + disabled: z.array(z.string()).optional(), +}); + +export type ToolsConfig = z.infer; + +registerConfigSection(TOOLS_SECTION, ToolsConfigSchema); diff --git a/packages/agent-core-v2/src/agent/profile/errors.ts b/packages/agent-core-v2/src/agent/profile/errors.ts index 148035b437..2fd364a23c 100644 --- a/packages/agent-core-v2/src/agent/profile/errors.ts +++ b/packages/agent-core-v2/src/agent/profile/errors.ts @@ -9,6 +9,9 @@ export const ProfileErrors = { MODEL_NOT_CONFIGURED: 'model.not_configured', MODEL_CONFIG_INVALID: 'model.config_invalid', THINKING_ALIAS_CONFLICT: 'profile.thinking_alias_conflict', + PROFILE_UNKNOWN: 'profile.unknown', + PROFILE_ALREADY_BOUND: 'profile.already_bound', + PROFILE_NOT_BOUND: 'profile.not_bound', }, } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts index e8dbf7ad48..d178b6bd75 100644 --- a/packages/agent-core-v2/src/agent/profile/profile.ts +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -6,7 +6,6 @@ import type { Model } from '#/app/model/modelInstance'; import { createDecorator } from "#/_base/di/instantiation"; import type { ErrorCode } from '#/errors'; import { Error2 } from '#/_base/errors/errors'; -import type { ToolSource } from '#/tool/toolContract'; import { ProfileErrors } from './errors'; @@ -46,6 +45,7 @@ export type ResolvedAgentProfile = AgentProfile; export interface ProfileData extends AgentConfigData { readonly activeToolNames?: readonly string[]; + readonly disallowedTools?: readonly string[]; } export type ProfileUpdateData = Partial<{ @@ -54,9 +54,20 @@ export type ProfileUpdateData = Partial<{ profileName: string; thinkingLevel: string; systemPrompt: string; + disallowedTools: readonly string[]; activeToolNames: readonly string[]; }>; +export interface ProfileBindingSnapshot { + readonly cwd: string; + readonly modelAlias?: string; + readonly profileName?: string; + readonly thinkingLevel: string; + readonly systemPrompt: string; + readonly activeToolNames?: readonly string[]; + readonly disallowedTools?: readonly string[]; +} + export interface ProfileServiceOptions { readonly cwd?: string | (() => string | undefined); readonly chdir?: (cwd: string) => void | Promise; @@ -84,8 +95,22 @@ export interface ProfileSetModelResult { export interface BindAgentInput { readonly profile: string; - readonly model: string; + /** + * Model alias to bind with. Optional: the engine falls back to the + * configured `defaultModel` so edges don't each re-implement the fallback; + * a missing model everywhere throws `model.not_configured`. + */ + readonly model?: string; readonly thinking?: string; + /** + * Set when `thinking` is an explicit user request (edge input) rather than + * inherited state: the effort is then validated against the model's + * supported efforts and the bind rejects up front when unsupported. + * Internal spawns pass inherited thinking without this flag — a persisted + * effort that drifted out of the model's support list clamps instead of + * breaking the spawn. + */ + readonly strictThinking?: boolean; readonly cwd?: string; } @@ -94,6 +119,7 @@ export interface IAgentProfileService { configure(options: ProfileServiceOptions): void; update(changed: ProfileUpdateData): void; + applyBindingSnapshot(snapshot: ProfileBindingSnapshot): void; bind(input: BindAgentInput): Promise; setModel(model: string): Promise; setThinking(level: string): void; @@ -115,7 +141,6 @@ export interface IAgentProfileService { hasProvider(): boolean; getSystemPrompt(): string; getActiveToolNames(): readonly string[] | undefined; - isToolActive(name: string, source?: ToolSource): boolean; addActiveTool(name: string): void; removeActiveTool(name: string): void; } diff --git a/packages/agent-core-v2/src/agent/profile/profileOps.ts b/packages/agent-core-v2/src/agent/profile/profileOps.ts index a19f7736d0..df1cc55ab1 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 @@ -22,9 +23,10 @@ * silently. * * 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 + * `undefined` = every tool active), the `tools.set_active_tools` whole-set + * replace, and the v2-only `tools.reset_active_tools` transition back to the + * unrestricted default. Both persisted transitions replay the base set. 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 +46,7 @@ export interface ProfileModelState { readonly profileName?: string; readonly thinkingLevel: string; readonly systemPrompt: string; + readonly disallowedTools?: readonly string[]; } export const ProfileModel = defineModel('profile', () => ({ @@ -59,6 +62,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 +82,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 { @@ -114,6 +133,7 @@ declare module '#/wire/types' { interface PersistedOpMap { 'config.update': typeof configUpdate; 'tools.set_active_tools': typeof setActiveTools; + 'tools.reset_active_tools': typeof resetActiveTools; } } @@ -121,3 +141,8 @@ export const setActiveTools = ActiveToolsModel.defineOp('tools.set_active_tools' schema: z.object({ names: z.array(z.string()).readonly() }), apply: (s, p) => (p.names === s ? s : p.names), }); + +export const resetActiveTools = ActiveToolsModel.defineOp('tools.reset_active_tools', { + schema: z.object({}), + apply: (s) => (s === undefined ? s : undefined), +}); diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index bbb2e5e365..a94c16d624 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -1,11 +1,12 @@ /** - * `profile` domain (L3) — `IAgentProfileService` implementation. + * `profile` domain (L4) — `IAgentProfileService` implementation. * * Owns the active agent's model alias, thinking level, system prompt, and * 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 @@ -13,7 +14,8 @@ * the ephemeral per-tool deltas from `addActiveTool` / `removeActiveTool` * (used by `userTool`; intentionally not persisted, re-derived on resume); the * live overlay is cached in a field and falls back to the Model when unset, so - * no restore-ordering coupling with `userTool` arises. The `agent.status.updated` + * no restore-ordering coupling with `userTool` arises. Profile and client + * policy are persisted independently. The `agent.status.updated` * / `warning` events now ride `IEventBus` (`agent.status.updated` canonical in * `usageOps`). `chdir` and * `emitStatusUpdated` run live-only after the dispatch, so `wire.replay` @@ -24,11 +26,12 @@ */ import { InstantiationType } from '#/_base/di/extensions'; +import { Disposable } from '#/_base/di/lifecycle'; 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'; @@ -36,8 +39,6 @@ import { normalizeRequestedThinkingEffort, resolveKimiThinkingEffortOverride, } from '#/app/model/thinking'; -import picomatch from 'picomatch'; - import { ErrorCodes, Error2 } from "#/errors"; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; @@ -46,9 +47,11 @@ import type { LoopControl } from '#/agent/loop/configSection'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { isMcpToolName, type ToolSource } from '#/tool/toolContract'; +import type { ToolSource } from '#/tool/toolContract'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/profile'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -60,6 +63,7 @@ import { prepareSystemPromptContext } from './context'; import type { ApplyProfileOptions, BindAgentInput, + ProfileBindingSnapshot, ProfileData, ProfileModelContext, ProfileServiceOptions, @@ -69,13 +73,17 @@ import type { import { IAgentProfileService, ProfileError, ProfileErrors } from './profile'; import { THINKING_SECTION, + TOOLS_SECTION, type ThinkingConfig, + type ToolsConfig, } from './configSection'; +import { isToolActive as evaluateToolActive } from '#/agent/toolPolicy/evaluate'; import { ActiveToolsModel, configUpdate, ProfileModel, setActiveTools, + resetActiveTools, type ActiveToolsState, type ProfileModelState, } from './profileOps'; @@ -92,7 +100,7 @@ declare module '#/app/event/eventBus' { } } -export class AgentProfileService implements IAgentProfileService { +export class AgentProfileService extends Disposable implements IAgentProfileService { declare readonly _serviceBrand: undefined; private optionsValue: ProfileServiceOptions = {}; @@ -121,10 +129,22 @@ 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, + @ISessionToolPolicy private readonly sessionToolPolicy: ISessionToolPolicy, ) { + super(); this.configure({}); + this._register( + this.sessionToolPolicy.onDidChange((event) => { + event.waitUntil(this.refreshSystemPrompt()); + }), + ); + this._register( + this.config.onDidSectionChange(({ domain }) => { + if (domain === TOOLS_SECTION) void this.refreshSystemPrompt(); + }), + ); } configure(options: ProfileServiceOptions): void { @@ -152,20 +172,83 @@ export class AgentProfileService implements IAgentProfileService { } } + applyBindingSnapshot(snapshot: ProfileBindingSnapshot): void { + this.activeProfile = undefined; + this.wire.dispatch( + configUpdate( + this.resolveConfigPayload({ + cwd: snapshot.cwd, + modelAlias: snapshot.modelAlias, + profileName: snapshot.profileName, + thinkingLevel: snapshot.thinkingLevel, + systemPrompt: snapshot.systemPrompt, + disallowedTools: snapshot.disallowedTools ?? [], + }), + ), + ); + this.afterConfigDispatch({ + cwd: snapshot.cwd, + modelAlias: snapshot.modelAlias, + profileName: snapshot.profileName, + thinkingLevel: snapshot.thinkingLevel, + systemPrompt: snapshot.systemPrompt, + disallowedTools: snapshot.disallowedTools ?? [], + }); + this.setActiveTools(snapshot.activeToolNames); + } + async bind(input: BindAgentInput): Promise { + await this.catalog.ready; + // A profile is the session's identity: first-bind only. The guard runs + // twice — here, before name resolution, so `already bound` wins over + // `unknown profile` and the common case fails fast; and again in the + // synchronous segment after every await and before the first + // wire.dispatch, so check-and-set is atomic and concurrent binds cannot + // both pass (an edge-level guard always leaves an interleaving window). + this.assertBindable(input.profile); 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 ProfileError( + ProfileErrors.codes.PROFILE_UNKNOWN, + `Unknown agent profile: "${input.profile}". Available profiles: ${available}`, + { profile: input.profile, available }, + ); + } + const alias = input.model ?? this.config.get('defaultModel'); + if (alias === undefined || alias === '') { + throw new ProfileError( + ProfileErrors.codes.MODEL_NOT_CONFIGURED, + `model is required to bind profile "${input.profile}" (no default model configured)`, + ); + } + const model = this.modelFactory.resolve(alias); + + // An explicitly user-requested thinking effort (strictThinking) must be + // supported by the model: reject before any await or state mutation so a + // bad edge request cannot wedge the session's identity after first-bind. + // Inherited thinking (subagent spawn, fork) deliberately skips this and + // clamps below instead — a persisted effort that drifted out of the + // model's support list must not break spawning. + if (input.strictThinking === true && input.thinking !== undefined) { + this.assertThinkingEffortSupported(input.thinking, model, alias); } - const model = this.modelFactory.resolve(input.model); - const context = await this.buildSystemPromptContext(input.cwd); + await this.sessionToolPolicy.ready; + const context = await this.buildSystemPromptContext(profile, input.cwd); + this.assertBindable(profile.name); + const currentProfileName = this.profileName; const systemPrompt = profile.systemPrompt(context); this.activeProfile = profile; this.cacheAgentsMdWarning(context); + // A same-name rebind keeps the persisted thinking effort unless the caller + // explicitly overrides it; only a first bind resolves the default. const thinkingLevel = resolveThinkingEffort( - input.thinking, + input.thinking ?? (currentProfileName !== undefined ? this.thinkingLevel : undefined), this.config.get(THINKING_SECTION), model, ); @@ -174,10 +257,11 @@ 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 })); - this.afterConfigDispatch({ modelAlias: input.model, thinkingLevel }); + this.wire.dispatch(configUpdate({ modelAlias: alias, thinkingEffort: thinkingLevel })); + this.afterConfigDispatch({ modelAlias: alias, thinkingLevel }); this.publishAgentsMdWarning(); } @@ -199,16 +283,8 @@ export class AgentProfileService implements IAgentProfileService { setThinking(level: string): void { const previousEffort = this.thinkingLevel; - const model = this.tryResolveRawModel(); + this.assertThinkingEffortSupported(level, this.tryResolveRawModel(), this.modelAlias ?? ''); const normalized = normalizeRequestedThinkingEffort(level); - if (normalized !== undefined && !supportsThinkingEffort(normalized, model)) { - const efforts = model?.supportEfforts ?? []; - const supported = efforts.length === 0 ? 'off' : ['off', ...efforts].join(', '); - throw new ProfileError( - ProfileErrors.codes.MODEL_CONFIG_INVALID, - `Thinking effort "${level}" is not supported by model "${this.modelAlias}". Supported efforts: ${supported}.`, - ); - } this.update({ thinkingLevel: normalized ?? level }); const effort = this.thinkingLevel; if (effort !== previousEffort) { @@ -220,6 +296,21 @@ export class AgentProfileService implements IAgentProfileService { } } + private assertThinkingEffortSupported( + requested: string, + model: Model | undefined, + modelAlias: string, + ): void { + const normalized = normalizeRequestedThinkingEffort(requested); + if (normalized === undefined || supportsThinkingEffort(normalized, model)) return; + const efforts = model?.supportEfforts ?? []; + const supported = efforts.length === 0 ? 'off' : ['off', ...efforts].join(', '); + throw new ProfileError( + ProfileErrors.codes.MODEL_CONFIG_INVALID, + `Thinking effort "${requested}" is not supported by model "${modelAlias}". Supported efforts: ${supported}.`, + ); + } + getModel(): string { return this.modelAlias ?? ''; } @@ -229,12 +320,13 @@ export class AgentProfileService implements IAgentProfileService { this.update({ profileName: profile.name, systemPrompt: profile.systemPrompt(context), + disallowedTools: profile.disallowedTools ?? [], }); this.setActiveTools(profile.tools); } async applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise { - const context = await this.buildSystemPromptContext(undefined, options); + const context = await this.buildSystemPromptContext(profile, undefined, options); this.useProfile(profile, context); this.cacheAgentsMdWarning(context); this.publishAgentsMdWarning(); @@ -244,7 +336,7 @@ export class AgentProfileService implements IAgentProfileService { const profile = this.resolveActiveProfile(); if (profile === undefined) return; - const context = await this.buildSystemPromptContext(this.cwd); + const context = await this.buildSystemPromptContext(profile, this.cwd); this.activeProfile = profile; this.update({ profileName: profile.name, @@ -268,6 +360,7 @@ export class AgentProfileService implements IAgentProfileService { thinkingLevel: this.thinkingLevel, systemPrompt: this.systemPrompt, activeToolNames: this.activeToolNames === undefined ? undefined : [...this.activeToolNames], + disallowedTools: [...(this.profileState.disallowedTools ?? [])], }; } @@ -373,15 +466,6 @@ export class AgentProfileService implements IAgentProfileService { return this.activeToolNames; } - 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 - .filter((pattern) => isMcpToolName(pattern)) - .some((pattern) => picomatch.isMatch(name, pattern)); - } - addActiveTool(name: string): void { const activeToolNames = this.activeToolNames; if (activeToolNames === undefined || activeToolNames.includes(name)) return; @@ -414,6 +498,9 @@ export class AgentProfileService implements IAgentProfileService { ); } if (changed.systemPrompt !== undefined) payload.systemPrompt = changed.systemPrompt; + if (changed.disallowedTools !== undefined) { + payload.disallowedTools = [...changed.disallowedTools]; + } return payload; } @@ -463,8 +550,12 @@ export class AgentProfileService implements IAgentProfileService { } } - private setActiveTools(names: readonly string[]): void { + private setActiveTools(names: readonly string[] | undefined): void { this.activeToolNamesOverlay = undefined; + if (names === undefined) { + this.wire.dispatch(resetActiveTools({})); + return; + } this.wire.dispatch(setActiveTools({ names: [...names] })); } @@ -556,6 +647,17 @@ export class AgentProfileService implements IAgentProfileService { } } + private assertBindable(requested: string): void { + const current = this.profileName; + if (current !== undefined && current !== requested) { + throw new ProfileError( + ProfileErrors.codes.PROFILE_ALREADY_BOUND, + `agent is already bound to profile "${current}"; cannot switch to "${requested}" in this session`, + { current, requested }, + ); + } + } + private resolveActiveProfile(): ResolvedAgentProfile | undefined { if (this.activeProfile !== undefined) return this.activeProfile; const profileName = this.profileName; @@ -578,6 +680,7 @@ export class AgentProfileService implements IAgentProfileService { } private async buildSystemPromptContext( + profile: ResolvedAgentProfile, cwd?: string, options?: ApplyProfileOptions, ): Promise { @@ -597,9 +700,34 @@ export class AgentProfileService implements IAgentProfileService { shellPath: this.env.shellPath, now: new Date().toISOString(), skills, + skillActive: this.isToolActiveForProfile(profile, 'Skill'), }; } + private isToolActiveForProfile( + profile: ResolvedAgentProfile, + name: string, + source: ToolSource = 'builtin', + ): boolean { + const globalTools = this.config.get(TOOLS_SECTION); + return ( + evaluateToolActive(profile, name, source) && + evaluateToolActive( + { + tools: globalTools?.enabled?.length ? globalTools.enabled : undefined, + disallowedTools: globalTools?.disabled, + }, + name, + source, + ) && + evaluateToolActive( + { disallowedTools: this.sessionToolPolicy.disabledTools() }, + name, + source, + ) + ); + } + private async resolveSkillListing(): Promise { try { await this.skillCatalog.ready; diff --git a/packages/agent-core-v2/src/agent/rpc/core-api.ts b/packages/agent-core-v2/src/agent/rpc/core-api.ts index 0285cdb8e0..56cbeca13e 100644 --- a/packages/agent-core-v2/src/agent/rpc/core-api.ts +++ b/packages/agent-core-v2/src/agent/rpc/core-api.ts @@ -113,6 +113,14 @@ export interface SessionSummary { export interface PromptPayload { readonly input: readonly ContentPart[]; + /** + * Client-managed session denylist, applied via + * `IAgentProfileService.setSessionDisabledTools` before the prompt is + * enqueued: full-replace semantics, the profile's own `disallowedTools` + * always survive. Omit to keep the persisted value; `[]` clears the client + * portion. + */ + readonly disabledTools?: readonly string[]; } export interface RunShellCommandPayload { readonly command: string; diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index d8c9d95495..0a8071634b 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -16,7 +16,8 @@ import { IAgentPlanService } from '#/agent/plan/plan'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { expandCommandArguments } from '#/app/plugin/commands'; import { IPluginService } from '#/app/plugin/plugin'; -import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentProfileService, ProfileError } from '#/agent/profile/profile'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { IAgentShellCommandService } from '#/agent/shellCommand/shellCommand'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; @@ -87,6 +88,7 @@ export class AgentRPCService implements IAgentRPCService { @IAgentShellCommandService private readonly shellCommand: IAgentShellCommandService, @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentProfileService private readonly profile: IAgentProfileService, + @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, @IAgentPermissionGate private readonly permission: IAgentPermissionGate, @IAgentPlanService private readonly planMode: IAgentPlanService, @@ -111,6 +113,16 @@ export class AgentRPCService implements IAgentRPCService { ) { } async prompt(payload: PromptPayload): Promise { + if (payload.disabledTools !== undefined) { + try { + await this.toolPolicy.setSessionDisabledTools(payload.disabledTools); + } catch (error) { + if (error instanceof ProfileError) { + throw new Error2(ErrorCodes.REQUEST_INVALID, error.message); + } + throw error; + } + } await this.updatePromptMetadata(promptMetadataTextFromPayload(payload)); const handle = await this.promptService.enqueue({ message: { role: 'user', @@ -361,7 +373,7 @@ export class AgentRPCService implements IAgentRPCService { return this.toolRegistry.list().map((tool) => ({ name: tool.name, description: tool.description, - active: this.profile.isToolActive(tool.name, tool.source), + active: this.toolPolicy.isToolActive(tool.name, tool.source), source: tool.source, })); } diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts index d8ca83fb6f..76ec199e50 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts @@ -13,6 +13,7 @@ import type { ToolDidExecuteContext, ToolBeforeExecuteContext } from '#/agent/to import type { ToolCall } from '#/app/llmProtocol/message'; import type { OrderedHookSlot } from '#/hooks'; import type { LLMRequestTrace } from '#/app/llmProtocol/requestTrace'; +import type { ToolSource } from '#/tool/toolContract'; export interface ToolCallStartedPayload { readonly toolCallId: string; @@ -35,6 +36,10 @@ export interface ToolExecutionResult { export type MissingToolDescriber = (toolName: string) => string | undefined; export type UnavailableToolDescriber = (toolName: string) => string | undefined; +export type ToolCallGuard = (tool: { + readonly name: string; + readonly source: ToolSource; +}) => string | undefined; export type ToolCallDupType = 'same_step' | 'cross_step'; @@ -50,6 +55,7 @@ export interface IAgentToolExecutorService { recordDupType(toolCallId: string, dupType: ToolCallDupType): void; + registerToolCallGuard(guard: ToolCallGuard): IDisposable; registerUnavailableToolDescriber(describer: UnavailableToolDescriber): IDisposable; registerMissingToolDescriber(describer: MissingToolDescriber): IDisposable; } diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index c7e2f02726..e84075bc70 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -41,6 +41,7 @@ import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/ import { IAgentToolExecutorService, type MissingToolDescriber, + type ToolCallGuard, type ToolCallDupType, type ToolExecutionResult, type ToolExecutorExecuteOptions, @@ -97,6 +98,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { private missingToolDescriber: MissingToolDescriber | undefined; private unavailableToolDescriber: UnavailableToolDescriber | undefined; + private toolCallGuard: ToolCallGuard | undefined; private readonly toolCallDupTypes = new Map(); private dupTypeTurnId: number | undefined; @@ -104,6 +106,13 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { this.toolCallDupTypes.set(toolCallId, dupType); } + registerToolCallGuard(guard: ToolCallGuard) { + this.toolCallGuard = guard; + return toDisposable(() => { + if (this.toolCallGuard === guard) this.toolCallGuard = undefined; + }); + } + registerUnavailableToolDescriber(describer: UnavailableToolDescriber) { this.unavailableToolDescriber = describer; return toDisposable(() => { @@ -141,6 +150,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { preflightToolCall( this.toolRegistry, call, + this.toolCallGuard, this.unavailableToolDescriber, this.missingToolDescriber, this.log, @@ -646,6 +656,7 @@ function buildWillExecuteContext( function preflightToolCall( toolRegistry: IAgentToolRegistryService, toolCall: ToolCall, + guard: ToolCallGuard | undefined, describeUnavailableTool: UnavailableToolDescriber | undefined, describeMissingTool: MissingToolDescriber | undefined, log?: ILogService, @@ -660,24 +671,35 @@ function preflightToolCall( error: parsedArgs.error, }); } - const unavailable = describeUnavailableTool?.(toolName); - if (unavailable !== undefined) { + const tool = toolRegistry.resolve(toolName); + if (tool === undefined) { return { kind: 'rejected', toolCall, toolName, args: parsedArgs.data, - output: unavailable, + output: describeMissingTool?.(toolName) ?? `Tool "${toolName}" not found`, }; } - const tool = toolRegistry.resolve(toolName); - if (tool === undefined) { + const source = toolRegistry.list().find((entry) => entry.name === toolName)?.source ?? 'builtin'; + const denied = guard?.({ name: toolName, source }); + if (denied !== undefined) { return { kind: 'rejected', toolCall, toolName, args: parsedArgs.data, - output: describeMissingTool?.(toolName) ?? `Tool "${toolName}" not found`, + output: denied, + }; + } + const unavailable = describeUnavailableTool?.(toolName); + if (unavailable !== undefined) { + return { + kind: 'rejected', + toolCall, + toolName, + args: parsedArgs.data, + output: unavailable, }; } const validationError = validateExecutableToolArgs(tool, parsedArgs.data); diff --git a/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts b/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts new file mode 100644 index 0000000000..722141c1ea --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts @@ -0,0 +1,45 @@ +/** + * `toolPolicy` domain (L4) — pure tool-activation policy evaluation. + * + * Applies allowlists and denylists with builtin/MCP matching semantics shared + * by Agent authorization, profile prompt construction, and child-agent setup. + */ + +import picomatch from 'picomatch'; + +import { isMcpToolName, type ToolSource } from '#/tool/toolContract'; + +export interface ToolActivationPolicy { + readonly tools?: readonly string[]; + readonly disallowedTools?: readonly string[]; +} + +export function isToolActive( + policy: ToolActivationPolicy, + name: string, + source: ToolSource = 'builtin', +): boolean { + if (policy.tools !== undefined) { + const allowed = + source !== 'mcp' + ? policy.tools.includes(name) + : policy.tools + .filter((pattern) => isMcpToolName(pattern)) + .some((pattern) => picomatch.isMatch(name, pattern)); + if (!allowed) return false; + } + if (policy.disallowedTools === undefined) return true; + if (source !== 'mcp') return !policy.disallowedTools.includes(name); + return !policy.disallowedTools + .filter((pattern) => isMcpToolName(pattern)) + .some((pattern) => picomatch.isMatch(name, pattern)); +} + +export function resolveActiveToolNames( + policy: ToolActivationPolicy, +): readonly string[] | undefined { + if (policy.tools === undefined) return undefined; + return policy.tools.filter((name) => + isToolActive(policy, name, isMcpToolName(name) ? 'mcp' : 'builtin'), + ); +} diff --git a/packages/agent-core-v2/src/agent/toolPolicy/toolPolicy.ts b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicy.ts new file mode 100644 index 0000000000..958df5caad --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicy.ts @@ -0,0 +1,26 @@ +/** + * `toolPolicy` domain (L4) — Agent-scope tool authorization contract. + * + * Combines profile, global configuration, and Session-owned restrictions into + * one policy used by both provider schema projection and executor preflight. + */ + +import { createDecorator } from '#/_base/di/instantiation'; +import type { ToolSource } from '#/tool/toolContract'; + +import type { ToolActivationPolicy } from './evaluate'; + +export interface IAgentToolPolicyService { + readonly _serviceBrand: undefined; + + isToolActive(name: string, source?: ToolSource): boolean; + isToolActiveForProfile( + profile: ToolActivationPolicy, + name: string, + source?: ToolSource, + ): boolean; + setSessionDisabledTools(names: readonly string[]): Promise; +} + +export const IAgentToolPolicyService = + createDecorator('agentToolPolicyService'); diff --git a/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts new file mode 100644 index 0000000000..794eb5513d --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts @@ -0,0 +1,101 @@ +/** + * `toolPolicy` domain (L4) — Agent-scope tool authorization service. + * + * Intersects the bound profile policy, global `[tools]` configuration, and + * Session denylist, and installs the resulting authorization check into the + * L3 executor preflight so direct tool calls cannot bypass schema filtering. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentProfileService, ProfileError, ProfileErrors } from '#/agent/profile/profile'; +import { + TOOLS_SECTION, + type ToolsConfig, +} from '#/agent/profile/configSection'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import { IConfigService } from '#/app/config/config'; +import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import type { ToolSource } from '#/tool/toolContract'; + +import { isToolActive as evaluateToolActive } from './evaluate'; +import { IAgentToolPolicyService } from './toolPolicy'; + +export class AgentToolPolicyService extends Disposable implements IAgentToolPolicyService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentProfileService private readonly profile: IAgentProfileService, + @IConfigService private readonly config: IConfigService, + @ISessionToolPolicy private readonly sessionToolPolicy: ISessionToolPolicy, + @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + ) { + super(); + this._register( + toolExecutor.registerToolCallGuard(({ name, source }) => + this.isToolActive(name, source) + ? undefined + : `Tool "${name}" is disabled by the active tool policy`, + ), + ); + } + + isToolActive(name: string, source: ToolSource = 'builtin'): boolean { + const profile = this.profile.data(); + return this.isToolActiveForProfile( + { + tools: profile.activeToolNames, + disallowedTools: profile.disallowedTools, + }, + name, + source, + ); + } + + isToolActiveForProfile( + profile: { readonly tools?: readonly string[]; readonly disallowedTools?: readonly string[] }, + name: string, + source: ToolSource = 'builtin', + ): boolean { + const globalTools = this.config.get(TOOLS_SECTION); + return ( + evaluateToolActive( + profile, + name, + source, + ) && + evaluateToolActive( + { + tools: globalTools?.enabled?.length ? globalTools.enabled : undefined, + disallowedTools: globalTools?.disabled, + }, + name, + source, + ) && + evaluateToolActive( + { disallowedTools: this.sessionToolPolicy.disabledTools() }, + name, + source, + ) + ); + } + + async setSessionDisabledTools(names: readonly string[]): Promise { + if (this.profile.data().profileName === undefined) { + throw new ProfileError( + ProfileErrors.codes.PROFILE_NOT_BOUND, + 'Cannot set session disabled tools: agent profile is not bound', + ); + } + await this.sessionToolPolicy.setDisabledTools(names); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolPolicyService, + AgentToolPolicyService, + InstantiationType.Eager, + 'toolPolicy', +); diff --git a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts index 7bd8618cf8..ba1ab7134d 100644 --- a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts +++ b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts @@ -16,11 +16,17 @@ export interface ToolRegistrationOptions { readonly source?: ToolSource; } +export interface ToolReference { + readonly name: string; + readonly source: ToolSource; +} + export interface IAgentToolRegistryService { readonly _serviceBrand: undefined; register(tool: ExecutableTool, options?: ToolRegistrationOptions): IDisposable; list(): readonly ToolInfo[]; + listReferences(): readonly ToolReference[]; resolve(name: string): ExecutableTool | undefined; } diff --git a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts index 707efb4d69..f5e9d649f6 100644 --- a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts +++ b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts @@ -2,7 +2,11 @@ import { toDisposable, type IDisposable } from "#/_base/di/lifecycle"; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import type { ExecutableTool, ToolInfo, ToolSource } from '#/tool/toolContract'; -import { IAgentToolRegistryService, type ToolRegistrationOptions } from './toolRegistry'; +import { + IAgentToolRegistryService, + type ToolReference, + type ToolRegistrationOptions, +} from './toolRegistry'; interface ToolEntry { readonly tool: ExecutableTool; @@ -37,6 +41,12 @@ export class AgentToolRegistryService implements IAgentToolRegistryService { .toSorted((a, b) => a.name.localeCompare(b.name)); } + listReferences(): readonly ToolReference[] { + return [...this.tools.entries()] + .map(([name, { source }]) => ({ name, source })) + .toSorted((a, b) => a.name.localeCompare(b.name)); + } + resolve(name: string): ExecutableTool | undefined { return this.tools.get(name)?.tool; } diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts index 8e605a643a..66d169ace9 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -18,6 +18,7 @@ import type { Tool } from '#/app/llmProtocol/tool'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import type { ToolInfo } from '#/tool/toolContract'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; @@ -44,6 +45,7 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele constructor( @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @IAgentProfileService private readonly profile: IAgentProfileService, + @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @IFlagService private readonly flags: IFlagService, @@ -179,7 +181,7 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele private loadableToolNames(): string[] { return this.toolRegistry .list() - .filter((info) => info.source === 'mcp' && this.profile.isToolActive(info.name, info.source)) + .filter((info) => info.source === 'mcp' && this.toolPolicy.isToolActive(info.name, info.source)) .map((info) => info.name) .toSorted((a, b) => a.localeCompare(b)); } @@ -204,7 +206,7 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele } private isLoadedToolActive(name: string): boolean { - return this.profile.isToolActive(name, 'mcp'); + return this.toolPolicy.isToolActive(name, 'mcp'); } private shapeActiveHistory(messages: readonly ContextMessage[]): readonly ContextMessage[] { @@ -259,7 +261,7 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele for (let i = 0; i < entries.length; i += 1) { const entry = entries[i]!; const active = - this.profile.isToolActive(entry.name, entry.source) || + this.toolPolicy.isToolActive(entry.name, entry.source) || (disclosure && entry.name === SELECT_TOOLS_TOOL_NAME); const keep = active && (disclosure || entry.name !== SELECT_TOOLS_TOOL_NAME); if (keep) { 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..343674347d --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts @@ -0,0 +1,157 @@ +/** + * `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 = requiredNonEmptyString(frontmatter['name'], 'name', 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 = requiredNonEmptyString( + frontmatter['description'], + 'description', + options.path, + ); + + const override = parseBoolean(frontmatter['override'], 'override', options.path); + const mode = parseMode(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']), + override, + mode, + tools, + disallowedTools, + prompt, + path: options.path, + source: options.source, + }; +} + +function parseBoolean(value: unknown, field: string, filePath: string): boolean { + if (value === undefined || value === null) return false; + if (typeof value === 'boolean') return value; + throw new AgentFileParseError( + `Frontmatter field "${field}" in ${filePath} must be a boolean`, + ); +} + +function parseMode(value: unknown, filePath: string): AgentPromptMode { + if (value === undefined || value === null) return 'replace'; + const mode = typeof value === 'string' ? value.trim() : undefined; + if (mode === 'replace' || mode === 'append') return mode; + throw new AgentFileParseError( + `Invalid "mode" value ${JSON.stringify(value)} in ${filePath}: expected "replace" or "append"`, + ); +} + +function parseStringList( + value: unknown, + field: string, + filePath: string, +): readonly string[] | undefined { + if (value === undefined || value === null) 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 requiredNonEmptyString(value: unknown, field: string, filePath: string): string { + if (value !== undefined && value !== null && typeof value !== 'string') { + throw new AgentFileParseError( + `Frontmatter field "${field}" in ${filePath} must be a non-empty string`, + ); + } + const parsed = nonEmptyString(value); + if (parsed === undefined) { + throw new AgentFileParseError(`Missing required frontmatter field "${field}" in ${filePath}`); + } + return parsed; +} + +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..85ae30fd37 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/agentFileDiscovery.ts @@ -0,0 +1,151 @@ +/** + * `agentFileCatalog` domain (L3) — filesystem agent-file discovery. + * + * Discovers and parses agent files through the `hostFs` filesystem boundary. + * Invalid files are isolated from the rest of the discovery pass. No scoped + * state. + */ + +import { join } from 'pathe'; + +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; + +import { AgentFileParseError, parseAgentFileText } from './agentFile'; +import { isDirectoryPath, isFilePath } from './paths'; +import type { + AgentFileDefinition, + AgentFileDiscoveryResult, + AgentFileRoot, + SkippedAgentFile, +} from './types'; + +const MAX_AGENT_SCAN_DEPTH = 8; +const MAX_SKIP_WARNINGS = 5; + +export interface DiscoverAgentFilesWarn { + (message: string, error?: unknown): void; +} + +export async function discoverAgentFiles( + fs: IHostFileSystem, + roots: readonly AgentFileRoot[], + warn?: DiscoverAgentFilesWarn, +): Promise { + const byName = new Map(); + const skipped: SkippedAgentFile[] = []; + + // Skip warnings are capped: a misconfigured root (e.g. an extra dir pointing + // at a docs-heavy tree) must not spam one line per non-agent markdown file. + // The returned `skipped` list keeps the parse-failure detail regardless; + // the summary below names a few suppressed paths so the rest stay findable. + let emittedWarnings = 0; + let suppressedWarnings = 0; + const suppressedSubjects: string[] = []; + const warnCapped = (subject: string, message: string, error?: unknown): void => { + if (emittedWarnings < MAX_SKIP_WARNINGS) { + emittedWarnings += 1; + warn?.(message, error); + } else { + suppressedWarnings += 1; + if (suppressedSubjects.length < 3) suppressedSubjects.push(subject); + } + }; + + async function parseAndRegister(filePath: string, root: AgentFileRoot): Promise { + try { + const text = await fs.readText(filePath); + const agent = parseAgentFileText({ path: filePath, source: root.source, text }); + if (!byName.has(agent.name)) { + byName.set(agent.name, agent); + } + } catch (error) { + if ( + error instanceof HostFsError && + error.code === OsFsErrors.codes.OS_FS_UNAVAILABLE + ) { + throw error; + } + if (error instanceof AgentFileParseError) { + skipped.push({ path: filePath, reason: error.message }); + warnCapped(filePath, `Skipping invalid agent file at ${filePath}: ${error.message}`, error); + } else { + warnCapped(filePath, `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)).map((entry) => entry.name).toSorted(); + } catch (error) { + // Below the root, ANY readdir failure (notably EACCES) skips just this + // directory — one unreadable subdirectory must not zero the whole + // source, mirroring fileSkillDiscovery's per-directory tolerance. + if (depth > 0) { + warnCapped(dirPath, `Skipping unreadable directory ${dirPath}: ${errorMessage(error)}`, error); + return; + } + // At the root, a missing directory is simply "no agents here"; other + // failures propagate so the session catalog keeps its previous + // contribution instead of wiping it over a transient fs error. + if ( + error instanceof HostFsError && + (error.code === OsFsErrors.codes.OS_FS_NOT_FOUND || + error.code === OsFsErrors.codes.OS_FS_NOT_DIRECTORY) + ) { + return; + } + throw error; + } + + for (const entry of entries) { + if (entry.startsWith('.') || entry === 'node_modules') continue; + const entryPath = join(dirPath, entry); + if (await isDirectoryPath(fs, entryPath)) { + await walk(entryPath, root, depth + 1); + continue; + } + if (!entry.endsWith('.md') || !(await isFilePath(fs, entryPath))) continue; + await parseAndRegister(entryPath, root); + } + } + + for (const root of roots) { + try { + await walk(root.path, root, 0); + } catch (error) { + // A transient whole-fs outage (`os.fs.unavailable`) propagates so the + // session catalog keeps its previous contribution instead of replacing + // it with a partial scan. Any other root-level failure (notably EACCES) + // skips just this root — one bad root must not take its siblings down. + if ( + error instanceof HostFsError && + error.code === OsFsErrors.codes.OS_FS_UNAVAILABLE + ) { + throw error; + } + warnCapped(root.path, `Skipping unreadable agent root ${root.path}: ${errorMessage(error)}`, error); + } + } + + if (suppressedWarnings > 0) { + const examples = suppressedSubjects.map((subject) => `"${subject}"`).join(', '); + warn?.( + `Suppressed ${suppressedWarnings} further agent-discovery skip warnings (e.g. ${examples}); fix or remove the offending files/directories to silence them`, + ); + } + + return { + agents: [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name)), + skipped, + scannedRoots: roots.map((root) => root.path), + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} 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..ff7f558086 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts @@ -0,0 +1,35 @@ +/** + * `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. Explicit files are marked as builtin overrides; + * directory files must opt in through frontmatter. `tools` passes through as the allowlist + * (`undefined` = every tool active); `disallowedTools` passes through as the + * denylist evaluated by `IAgentToolPolicyService`. + */ + +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { renderSystemPrompt } from '#/app/agentProfileCatalog/profile-shared'; + +import type { AgentFileDefinition } from './types'; + +export function agentProfileFromFile(definition: AgentFileDefinition): AgentProfile { + const skillActive = + (definition.tools === undefined || definition.tools.includes('Skill')) && + !(definition.disallowedTools ?? []).includes('Skill'); + return { + name: definition.name, + description: definition.description, + whenToUse: definition.whenToUse, + override: definition.override || definition.source === 'explicit', + tools: definition.tools, + disallowedTools: definition.disallowedTools, + systemPrompt: + definition.mode === 'append' + ? (context) => renderSystemPrompt(definition.prompt, context, { skillActive }) + : () => 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..3ba8d8c459 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/agentRoots.ts @@ -0,0 +1,90 @@ +/** + * `agentFileCatalog` domain (L3) — agent-root resolution primitives. + * + * Resolves user, project, and configured discovery roots through the `hostFs` + * filesystem boundary. Pure path probes; no scoped state. + */ + +import { dirname, join, resolve } from 'pathe'; + +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; + +import { isDirectoryPath, pathExists, resolveAgentPath } from './paths'; +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( + fs: IHostFileSystem, + homeDir: string, + osHomeDir: string, +): Promise { + const roots: AgentFileRoot[] = []; + await pushFirstExisting(fs, roots, USER_BRAND_DIRS, homeDir, 'user'); + await pushFirstExisting(fs, roots, USER_GENERIC_DIRS, osHomeDir, 'user'); + return roots; +} + +export async function projectAgentRoots( + fs: IHostFileSystem, + workDir: string, +): Promise { + const projectRoot = await findProjectRoot(fs, workDir); + const roots: AgentFileRoot[] = []; + await pushFirstExisting(fs, roots, PROJECT_BRAND_DIRS, projectRoot, 'project'); + await pushFirstExisting(fs, roots, PROJECT_GENERIC_DIRS, projectRoot, 'project'); + return roots; +} + +export async function configuredAgentRoots( + fs: IHostFileSystem, + dirs: readonly string[], + workDir: string, + osHomeDir: string, + source: AgentFileSource, +): Promise { + const projectRoot = await findProjectRoot(fs, workDir); + const roots: AgentFileRoot[] = []; + for (const dir of dirs) { + await pushExistingRoot(fs, roots, resolveAgentPath(dir, projectRoot, osHomeDir), source); + } + return roots; +} + +async function findProjectRoot(fs: IHostFileSystem, workDir: string): Promise { + const start = resolve(workDir); + let current = start; + while (true) { + if (await pathExists(fs, join(current, '.git'))) return current; + const parent = dirname(current); + if (parent === current) return start; + current = parent; + } +} + +async function pushFirstExisting( + fs: IHostFileSystem, + out: AgentFileRoot[], + dirs: readonly string[], + base: string, + source: AgentFileSource, +): Promise { + for (const dir of dirs) { + if (await pushExistingRoot(fs, out, join(base, dir), source)) return; + } +} + +async function pushExistingRoot( + fs: IHostFileSystem, + out: AgentFileRoot[], + dir: string, + source: AgentFileSource, +): Promise { + if (!(await isDirectoryPath(fs, dir))) return false; + const resolved = (await fs.realpath(dir)).replaceAll('\\', '/'); + if (!out.some((root) => root.path === resolved)) out.push({ path: resolved, source }); + return true; +} 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/paths.ts b/packages/agent-core-v2/src/app/agentFileCatalog/paths.ts new file mode 100644 index 0000000000..3ebace1af8 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/paths.ts @@ -0,0 +1,63 @@ +/** + * `agentFileCatalog` domain (L3) — shared path primitives for agent-file + * discovery. + * + * `~` expansion, base-relative resolution, and `hostFs` type probes used by + * the root resolvers, the directory walker, and the explicit-file source. + * Pure helpers; no scoped state. + */ + +import { isAbsolute, join, resolve } from 'pathe'; + +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; + +/** + * Expand a leading `~` to `osHomeDir` and resolve relative paths against + * `baseDir`. Callers pick the base: discovery roots resolve against the + * project root, explicit files against the session workDir. + */ +export function resolveAgentPath(path: string, baseDir: string, osHomeDir: string): string { + if (path === '~') return osHomeDir; + if (path.startsWith('~/')) return join(osHomeDir, path.slice(2)); + if (isAbsolute(path)) return path; + return resolve(baseDir, path); +} + +export async function isDirectoryPath(fs: IHostFileSystem, p: string): Promise { + try { + const resolved = await fs.realpath(p); + return (await fs.stat(resolved)).isDirectory; + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; + } +} + +export async function isFilePath(fs: IHostFileSystem, p: string): Promise { + try { + const resolved = await fs.realpath(p); + return (await fs.stat(resolved)).isFile; + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; + } +} + +export async function pathExists(fs: IHostFileSystem, p: string): Promise { + try { + await fs.stat(p); + return true; + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; + } +} + +function isMissingPathError(error: unknown): boolean { + return ( + error instanceof HostFsError && + (error.code === OsFsErrors.codes.OS_FS_NOT_FOUND || + error.code === OsFsErrors.codes.OS_FS_NOT_DIRECTORY) + ); +} diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/systemFile.ts b/packages/agent-core-v2/src/app/agentFileCatalog/systemFile.ts new file mode 100644 index 0000000000..5c24da5bb3 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/systemFile.ts @@ -0,0 +1,91 @@ +/** + * `agentFileCatalog` domain (L3) — `SYSTEM.md` global main-agent prompt override. + * + * `/SYSTEM.md` (default `~/.kimi-code/SYSTEM.md`, moves with + * `KIMI_CODE_HOME`) permanently replaces the builtin default profile's system + * prompt while the file exists and is non-empty. Only the prompt is replaced — + * tools and description are copied from the builtin default — and explicit + * intent still wins: higher-priority sources (project `agent.md`, + * `--agent-file`) override it, and binding a different profile ignores it. + * Unlike plain agent files the body is a template: `${var}` placeholders are + * substituted from the live `AgentProfileContext` at render time; unknown + * placeholders stay verbatim. Pure logic; no scoped state. + */ + +import { join } from 'pathe'; + +import { + DEFAULT_AGENT_PROFILE_NAME, + type AgentProfile, + type AgentProfileContext, +} from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { skillActiveFor } from '#/app/agentProfileCatalog/profile-shared'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; + +import { isFilePath } from './paths'; + +export const SYSTEM_MD_FILENAME = 'SYSTEM.md'; + +/** + * Synthesize the prompt-override profile from `/SYSTEM.md`. Returns + * `undefined` when the file is missing or empty after trimming; a read failure + * degrades to `warn` instead of rejecting, matching the directory-source + * policy that a transient fs error must never poison a session. + */ +export async function loadSystemMdProfile( + fs: IHostFileSystem, + brandHome: string, + builtinDefault: AgentProfile, + warn: (message: string) => void, +): Promise { + const path = join(brandHome, SYSTEM_MD_FILENAME); + if (!(await isFilePath(fs, path))) return undefined; + let text: string; + try { + text = await fs.readText(path); + } catch (error) { + warn(`agent SYSTEM.md load failed: ${String(error)} [${path}]`); + return undefined; + } + if (text.trim().length === 0) return undefined; + const skillActive = + (builtinDefault.tools === undefined || skillActiveFor(builtinDefault.tools)) && + !(builtinDefault.disallowedTools ?? []).includes('Skill'); + return { + name: DEFAULT_AGENT_PROFILE_NAME, + description: builtinDefault.description, + override: true, + tools: builtinDefault.tools, + disallowedTools: builtinDefault.disallowedTools, + systemPrompt: (context) => renderSystemMdPrompt(text, context, { skillActive }), + }; +} + +const SYSTEM_MD_VARIABLE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; + +/** + * Substitute `${var}` placeholders from the live prompt context. Only the + * known variables are replaced; unknown `${...}` stays verbatim and a bare `$` + * is never special. Missing context fields render as empty strings, matching + * the builtin template; `${skills}` is empty when the profile disables the + * Skill tool. + */ +export function renderSystemMdPrompt( + template: string, + context: AgentProfileContext, + options: { readonly skillActive: boolean }, +): string { + const shellName = context.shellName ?? ''; + const shellPath = context.shellPath ?? ''; + const skillActive = context.skillActive ?? options.skillActive; + const vars: Readonly> = { + skills: skillActive ? (context.skills ?? '') : '', + agents_md: context.agentsMd ?? '', + cwd: context.cwd ?? '', + cwd_listing: context.cwdListing ?? '', + os: context.osKind ?? '', + shell: shellName.length > 0 ? `${shellName} (\`${shellPath}\`)` : '', + now: context.now ?? new Date().toISOString(), + }; + return template.replace(SYSTEM_MD_VARIABLE, (match: string, name: string) => vars[name] ?? match); +} 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..38dabfd75e --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/types.ts @@ -0,0 +1,41 @@ +/** + * `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 override: boolean; + 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..582bbdd917 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentFileCatalog/userFileAgentSource.ts @@ -0,0 +1,77 @@ +/** + * `agentFileCatalog` domain (L3) — user `IAgentProfileSource` producer. + * + * Discovers user agent profiles through `bootstrap` home paths and `hostFs`, + * reports skipped files through `log`, and appends the `/SYSTEM.md` + * prompt-override profile (synthesized against the builtin default from the + * App profile catalog) after the scanned profiles so it wins same-name + * collisions within this contribution. Bound at App scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; + +import { discoverAgentFiles } from './agentFileDiscovery'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + profilesFromDiscovery, + type AgentProfileContribution, + type IAgentProfileSource, +} from './agentProfileSource'; +import { userAgentRoots } from './agentRoots'; +import { loadSystemMdProfile } from './systemFile'; + +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, + @IHostFileSystem private readonly fs: IHostFileSystem, + @ILogService private readonly log: ILogService, + @IAgentProfileCatalogService private readonly builtin: IAgentProfileCatalogService, + ) {} + + async load(): Promise { + const roots = await userAgentRoots( + this.fs, + this.bootstrap.homeDir, + this.bootstrap.osHomeDir, + ); + const contribution = profilesFromDiscovery( + await discoverAgentFiles(this.fs, roots, (message) => this.log.warn(message)), + ); + const systemMd = await loadSystemMdProfile( + this.fs, + this.bootstrap.homeDir, + this.builtin.getDefault(), + (message) => this.log.warn(message), + ); + if (systemMd === undefined) return contribution; + // Append last: within one contribution a later same-name profile wins, so + // SYSTEM.md beats a scanned `agents/agent.md` from the user directory. + return { ...contribution, profiles: [...contribution.profiles, systemMd] }; + } +} + +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..285638c40e 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -51,6 +51,7 @@ export interface AgentProfileContext { readonly shellPath?: string; readonly now?: string; readonly skills?: string; + readonly skillActive?: boolean; readonly [key: string]: unknown; } @@ -58,7 +59,11 @@ export interface AgentProfile { readonly name: string; readonly description?: string; readonly whenToUse?: string; - readonly tools: readonly string[]; + readonly override?: boolean; + // 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/app/agentProfileCatalog/profile-shared.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts index dadaac6c13..82f1837ed1 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts @@ -18,13 +18,19 @@ export const TASK_AGENT_ROLE_PREFIX = 'You must treat the parent agent as your caller. Do not directly ask the end user questions. ' + 'If something is unclear, explain the ambiguity in your final summary to the parent agent.'; +/** Whether the Skill tool survives a profile's tool list — drives KIMI_SKILLS injection. */ +export function skillActiveFor(tools: readonly string[]): boolean { + return tools.includes('Skill'); +} + export function renderSystemPrompt( roleAdditional: string, context: AgentProfileContext, - tools: readonly string[], + options: { readonly skillActive: boolean }, ): string { const shellName = context.shellName ?? ''; const shellPath = context.shellPath ?? ''; + const skillActive = context.skillActive ?? options.skillActive; return renderPrompt(SYSTEM_PROMPT_TEMPLATE, { ROLE_ADDITIONAL: roleAdditional, KIMI_OS: context.osKind ?? '', @@ -34,6 +40,6 @@ export function renderSystemPrompt( KIMI_WORK_DIR_LS: context.cwdListing ?? '', KIMI_AGENTS_MD: context.agentsMd ?? '', KIMI_ADDITIONAL_DIRS_INFO: context.additionalDirsInfo ?? '', - KIMI_SKILLS: tools.includes('Skill') ? (context.skills ?? '') : '', + KIMI_SKILLS: skillActive ? (context.skills ?? '') : '', }); } diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts index 965cf96be9..c57bb8d214 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts @@ -18,6 +18,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio import type { ISessionScopeHandle } from '#/_base/di/scope'; import type { Event } from '#/_base/event'; import type { McpServerConfig } from '#/agent/mcp/config-schema'; +import type { BindAgentInput } from '#/agent/profile/profile'; import type { Hooks } from '#/hooks'; export interface CreateSessionOptions { @@ -25,6 +26,7 @@ export interface CreateSessionOptions { readonly workDir: string; readonly additionalDirs?: readonly string[]; readonly mcpServers?: Readonly>; + readonly mainAgentBinding?: BindAgentInput; } export interface ForkSessionOptions { diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 5eb7d3c57e..282ceb1fca 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -69,6 +69,8 @@ import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/se import { ISessionCronService } from '#/session/cron/sessionCronService'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { IWireService } from '#/wire/wire'; import { @@ -134,10 +136,26 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec async create(opts: CreateSessionOptions): Promise { const sessionId = opts.sessionId ?? createSessionId(); const handle = await this.materializeSession({ ...opts, sessionId }); - await this.appendSessionIndexEntry(sessionId, opts.workDir); - if (this.config.get(DEFAULT_PLAN_MODE_SECTION) === true) { - const main = await ensureMainAgent(handle); - await main.accessor.get(IAgentPlanService).enter(); + try { + const main = + opts.mainAgentBinding === undefined + ? undefined + : await handle.accessor.get(IAgentLifecycleService).create({ + agentId: MAIN_AGENT_ID, + binding: opts.mainAgentBinding, + }); + if (this.config.get(DEFAULT_PLAN_MODE_SECTION) === true) { + const planAgent = main ?? (await ensureMainAgent(handle)); + await planAgent.accessor.get(IAgentPlanService).enter(); + } + await this.appendSessionIndexEntry(sessionId, opts.workDir); + } catch (error) { + const sessionDir = handle.accessor.get(ISessionContext).sessionDir; + this.sessions.delete(sessionId); + await this.drainAgents(handle).catch(() => {}); + handle.dispose(); + await this.hostFs.remove(sessionDir).catch(() => {}); + throw error; } await this.announceCreated({ sessionId, handle, source: 'startup' }); return handle; @@ -177,14 +195,27 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec if (additionalDirs.length > 0) { handle.accessor.get(ISessionWorkspaceContext).setAdditionalDirs(additionalDirs); } + try { + await handle.accessor.get(ISessionMetadata).ready; + await handle.accessor.get(ISessionToolPolicy).ready; + void handle.accessor.get(ISessionSkillCatalog).ready; + // Agent-file discovery is awaited (unlike the skill catalog above): it is + // local-fs and cheap, and a resumed session's first turn must see + // file-defined agent types in the `Agent` tool description. `ready` only + // rejects for a fatal explicit-source error — exactly the case that + // should fail fast here. On that failure the half-materialized handle is + // removed and disposed instead of poisoning the session cache. + await handle.accessor.get(ISessionAgentProfileCatalog).ready; + await handle.accessor.get(ISessionMcpService).ensureMcpReady(opts.mcpServers); + // Force-instantiate the session-level eager services whose subscriptions + // must exist before the first agent / turn (external hooks, cron). + handle.accessor.get(ISessionExternalHooksService); + handle.accessor.get(ISessionCronService); + } catch (error) { + handle.dispose(); + throw error; + } this.sessions.set(opts.sessionId, handle); - await handle.accessor.get(ISessionMetadata).ready; - void handle.accessor.get(ISessionSkillCatalog).ready; - await handle.accessor.get(ISessionMcpService).ensureMcpReady(opts.mcpServers); - // Force-instantiate the session-level eager services whose subscriptions - // must exist before the first agent / turn (external hooks, cron). - handle.accessor.get(ISessionExternalHooksService); - handle.accessor.get(ISessionCronService); return handle; } @@ -347,19 +378,19 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ); } + targetSessionDir = this.bootstrap.sessionDir(workspaceId, targetId); + await this.copySessionFiles( + this.bootstrap.sessionDir(workspaceId, sourceId), + targetSessionDir, + ); + target = await this.materializeSession({ sessionId: targetId, workDir: workspace.root, }); const targetCtx = target.accessor.get(ISessionContext); - targetSessionDir = targetCtx.sessionDir; const targetMeta = target.accessor.get(ISessionMetadata); - await this.copySessionFiles( - this.bootstrap.sessionDir(workspaceId, sourceId), - targetCtx.sessionDir, - ); - const sourceAgents = sourceMeta?.agents ?? {}; const agentIds = Object.keys(sourceAgents); for (const agentId of agentIds) { diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 8ecf108c84..56ce3c4b88 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -75,6 +75,8 @@ export * from '#/app/sessionIndex/sessionIndex'; export * from '#/app/sessionIndex/sessionIndexService'; export * from '#/session/sessionMetadata/sessionMetadata'; export * from '#/session/sessionMetadata/sessionMetadataService'; +export * from '#/session/sessionToolPolicy/sessionToolPolicy'; +export * from '#/session/sessionToolPolicy/sessionToolPolicyService'; export * from '#/app/config/config'; export * from '#/app/config/configService'; import '#/app/provider/configSection'; @@ -119,6 +121,16 @@ export { getAgentProfileContributions, _clearAgentProfileContributionsForTests, } from '#/app/agentProfileCatalog/contribution'; +export * from '#/app/agentFileCatalog/types'; +export * from '#/app/agentFileCatalog/paths'; +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 +165,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'; @@ -200,6 +217,9 @@ export * from '#/agent/toolSelect/toolSelect'; export * from '#/agent/toolSelect/toolSelectService'; export * from '#/agent/toolSelect/toolSelectAnnouncements'; export * from '#/agent/toolSelect/toolSelectAnnouncementsService'; +export * from '#/agent/toolPolicy/evaluate'; +export * from '#/agent/toolPolicy/toolPolicy'; +export * from '#/agent/toolPolicy/toolPolicyService'; import '#/agent/task/configSection'; export { diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts index f7d018992c..95adde0484 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts @@ -40,7 +40,7 @@ import { IConfigService } from '#/app/config/config'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionProcessRunner, type IProcess } from '#/session/process/processRunner'; -import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import type { BuiltinTool, ExecutableToolResult, ToolExecution, ToolUpdate } from '#/tool/toolContract'; import { type ExecutableToolResultBuilderResult, @@ -185,7 +185,7 @@ export class BashTool implements BuiltinTool { @IHostEnvironment private readonly env: IHostEnvironment, @ISessionContext private readonly ctx: ISessionContext, @IAgentTaskService private readonly tasks: IAgentTaskService, - @IAgentProfileService private readonly profile: IAgentProfileService, + @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IConfigService private readonly config: IConfigService, ) { this.isWindowsBash = this.env.osKind === 'Windows'; @@ -194,9 +194,9 @@ export class BashTool implements BuiltinTool { private allowBackground(): boolean { return ( - this.profile.isToolActive('TaskList') && - this.profile.isToolActive('TaskOutput') && - this.profile.isToolActive('TaskStop') + this.toolPolicy.isToolActive('TaskList') && + this.toolPolicy.isToolActive('TaskOutput') && + this.toolPolicy.isToolActive('TaskStop') ); } diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 24cf4fdea4..d875ec9e9b 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -264,21 +264,18 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle const sourceData = source.accessor.get(IAgentProfileService).data(); const childProfile = child.accessor.get(IAgentProfileService); const override = opts?.binding; - const model = override?.model ?? sourceData.modelAlias; - if (model !== undefined) { + if (override?.profile !== undefined) { await childProfile.bind({ - profile: override?.profile ?? sourceData.profileName ?? 'agent', - model, + profile: override.profile, + model: override.model ?? sourceData.modelAlias, thinking: override?.thinking ?? sourceData.thinkingLevel, cwd: override?.cwd ?? sourceData.cwd, }); } else { - childProfile.update({ - profileName: override?.profile ?? sourceData.profileName, - thinkingLevel: override?.thinking ?? sourceData.thinkingLevel, - systemPrompt: sourceData.systemPrompt, - activeToolNames: sourceData.activeToolNames, - }); + childProfile.applyBindingSnapshot(sourceData); + if (override?.model !== undefined) await childProfile.setModel(override.model); + if (override?.thinking !== undefined) childProfile.setThinking(override.thinking); + if (override?.cwd !== undefined) childProfile.update({ cwd: override.cwd }); } const sourceMessages = source.accessor.get(IAgentContextMemoryService)?.get(); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts index c01096f4fc..43d5befca0 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -16,6 +16,7 @@ import { collectGitContext } from '#/session/sessionFs/gitContext'; import { registerAgentProfile } from '#/app/agentProfileCatalog/contribution'; import { renderSystemPrompt, + skillActiveFor, TASK_AGENT_ROLE_PREFIX, } from '#/app/agentProfileCatalog/profile-shared'; @@ -105,7 +106,8 @@ registerAgentProfile({ name: 'agent', description: 'Default Kimi Code agent', tools: AGENT_TOOLS, - systemPrompt: (context) => renderSystemPrompt('', context, AGENT_TOOLS), + systemPrompt: (context) => + renderSystemPrompt('', context, { skillActive: skillActiveFor(AGENT_TOOLS) }), }); registerAgentProfile({ @@ -115,7 +117,8 @@ registerAgentProfile({ whenToUse: 'Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent.', tools: CODER_TOOLS, - systemPrompt: (context) => renderSystemPrompt(CODER_ROLE, context, CODER_TOOLS), + systemPrompt: (context) => + renderSystemPrompt(CODER_ROLE, context, { skillActive: skillActiveFor(CODER_TOOLS) }), summaryPolicy: DEFAULT_SUMMARY_POLICY, }); @@ -125,7 +128,8 @@ registerAgentProfile({ whenToUse: 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 search queries. Prefer launching multiple explore agents concurrently when investigating independent questions.', tools: EXPLORE_TOOLS, - systemPrompt: (context) => renderSystemPrompt(EXPLORE_ROLE, context, EXPLORE_TOOLS), + systemPrompt: (context) => + renderSystemPrompt(EXPLORE_ROLE, context, { skillActive: skillActiveFor(EXPLORE_TOOLS) }), promptPrefix: async ({ cwd, runner, log }) => { try { return await collectGitContext(runner, cwd, log); 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..4ce377003e --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/explicitFileAgentSource.ts @@ -0,0 +1,67 @@ +/** + * `sessionAgentProfileCatalog` domain (L3) — explicit `IAgentProfileSource` + * producer. + * + * Loads runtime-selected agent files through `hostFs`, resolving paths through + * `workspace` and `bootstrap`. Bound at Session scope. + */ + +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 { resolveAgentPath } from '#/app/agentFileCatalog/paths'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +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, + @IHostFileSystem private readonly fs: IHostFileSystem, + ) {} + + async load(): Promise { + const files = this.runtimeOptions.explicitFiles ?? []; + const profiles: AgentProfile[] = []; + for (const file of files) { + const filePath = resolveAgentPath(file, this.workspace.workDir, this.bootstrap.osHomeDir); + const text = await this.fs.readText(filePath); + profiles.push( + agentProfileFromFile(parseAgentFileText({ path: filePath, source: 'explicit', text })), + ); + } + return { profiles }; + } +} + +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..e91b1e2cf4 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/extraFileAgentSource.ts @@ -0,0 +1,88 @@ +/** + * `sessionAgentProfileCatalog` domain (L3) — extra `IAgentProfileSource` + * producer. + * + * Discovers configured agent profiles through `config`, `workspace`, + * `bootstrap`, and `hostFs`, and reports skipped files through `log`. Bound at + * Session scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { InstantiationType } from '#/_base/di/extensions'; +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { 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 { IHostFileSystem } from '#/os/interface/hostFileSystem'; +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, + @IHostFileSystem private readonly fs: IHostFileSystem, + @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( + this.fs, + await configuredAgentRoots( + this.fs, + 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..89ac253ff1 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/projectFileAgentSource.ts @@ -0,0 +1,57 @@ +/** + * `sessionAgentProfileCatalog` domain (L3) — project `IAgentProfileSource` + * producer. + * + * Discovers project agent profiles through `workspace` and `hostFs`, and + * reports skipped files through `log`. Bound at Session scope. + */ + +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 { IHostFileSystem } from '#/os/interface/hostFileSystem'; +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, + @IHostFileSystem private readonly fs: IHostFileSystem, + @ILogService private readonly log: ILogService, + ) {} + + async load(): Promise { + const roots = await projectAgentRoots(this.fs, this.workspace.workDir); + return profilesFromDiscovery( + await discoverAgentFiles(this.fs, 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..7be8ea6535 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts @@ -0,0 +1,31 @@ +/** + * `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 file sources win name collisions, while builtin + * names require an explicit override opt-in. 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..6b07b9e9f3 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts @@ -0,0 +1,222 @@ +/** + * `sessionAgentProfileCatalog` domain (L3) — `ISessionAgentProfileCatalog` + * implementation. + * + * Merges the builtin (code-contribution) App catalog with the file-backed + * sources (user / extra / project / explicit) by priority, requiring an + * explicit opt-in before a file replaces a same-name builtin, and 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. + * `ready` tracks the most recent load pass: `reload()` replaces it, so a + * fatal failure does not wedge the catalog once the underlying problem is + * fixed, and `loadAll` merges whatever loaded even when a fatal source + * rejects mid-pass. 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 { isError2 } from '#/_base/errors/errors'; +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(); + private readyPromise: 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: ${String(error)}`); + }); + }), + ); + } + } + this.remerge(); + this.readyPromise = this.loadAll(); + void this.readyPromise.catch(() => undefined); + } + + /** + * The most recent load pass. Replaced by `reload()`, so a `fatal` source + * failure does not wedge the catalog forever: once a reload succeeds, + * awaiters observe the fresh pass instead of the original rejection. + */ + get ready(): Promise { + return this.readyPromise; + } + + 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 { + this.readyPromise = this.loadAll(); + void this.readyPromise.catch(() => undefined); + await this.readyPromise; + this.onDidChangeEmitter.fire('catalog'); + } + + private async loadAll(): Promise { + try { + for (const s of this.sources) { + await this.loadSource(s); + } + } finally { + // Even when a fatal source rejects mid-pass, merge the sources that did + // load — otherwise their contributions sit in `contributions` without + // ever reaching the merged view. + 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; + // Surface the offending path when the failure carries one (e.g. an + // unreadable root directory) — `String(error)` drops `details`. + const at = isError2(error) ? error.details?.['path'] : undefined; + this.log.warn( + `agent profile source "${source.id}" load failed: ${String(error)}${typeof at === 'string' ? ` [${at}]` : ''}`, + ); + 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 fileProfiles = new Map(); + const ordered = [...this.contributions.values()].toSorted( + (a, b) => b.priority - a.priority, + ); + for (const { c } of ordered) { + const sourceProfiles = new Map(); + for (const profile of c.profiles) sourceProfiles.set(profile.name, profile); + for (const profile of sourceProfiles.values()) { + const candidates = fileProfiles.get(profile.name) ?? []; + candidates.push(profile); + fileProfiles.set(profile.name, candidates); + } + } + for (const candidates of fileProfiles.values()) { + for (const profile of candidates) { + if (m.has(profile.name) && profile.override !== true) { + this.log.warn( + `agent file profile "${profile.name}" ignored: a same-name builtin profile exists; set "override: true" in the frontmatter to replace it`, + ); + continue; + } + m.set(profile.name, profile); + break; + } + } + this.merged = m; + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionAgentProfileCatalog, + SessionAgentProfileCatalogService, + InstantiationType.Eager, + 'sessionAgentProfileCatalog', +); diff --git a/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicy.ts b/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicy.ts new file mode 100644 index 0000000000..afefa7b800 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicy.ts @@ -0,0 +1,26 @@ +/** + * `sessionToolPolicy` domain (L3) — session-wide client tool restrictions. + * + * Defines the Session-scoped policy shared by every Agent in a session. The + * client-managed denylist is persisted independently from each Agent's frozen + * profile policy, survives resume, and emits an awaitable change event so + * existing agents can refresh policy-derived system-prompt content before the + * mutating request continues. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event, IWaitUntil } from '#/_base/event'; + +export type SessionToolPolicyChangedEvent = IWaitUntil; + +export interface ISessionToolPolicy { + readonly _serviceBrand: undefined; + readonly ready: Promise; + readonly onDidChange: Event; + + disabledTools(): readonly string[]; + setDisabledTools(names: readonly string[]): Promise; +} + +export const ISessionToolPolicy: ServiceIdentifier = + createDecorator('sessionToolPolicy'); diff --git a/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts b/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts new file mode 100644 index 0000000000..1848669c92 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts @@ -0,0 +1,88 @@ +/** + * `sessionToolPolicy` domain (L3) — persisted session tool-policy service. + * + * Stores the client-managed denylist as one atomic document below the session + * scope and serializes replacements. A successful replacement awaits all + * registered Agent prompt refreshes before returning. Bound at Session scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { AsyncEmitter, type Event } from '#/_base/event'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; + +import { + ISessionToolPolicy, + type SessionToolPolicyChangedEvent, +} from './sessionToolPolicy'; + +interface SessionToolPolicyState { + readonly disabledTools: readonly string[]; +} + +const STATE_KEY = 'state.json'; + +export class SessionToolPolicyService extends Disposable implements ISessionToolPolicy { + declare readonly _serviceBrand: undefined; + readonly ready: Promise; + readonly onDidChange: Event; + + private readonly changeEmitter = this._register( + new AsyncEmitter(), + ); + private readonly scope: string; + private updateQueue: Promise = Promise.resolve(); + private state: SessionToolPolicyState = { disabledTools: [] }; + + constructor( + @ISessionContext sessionContext: ISessionContext, + @IAtomicDocumentStore private readonly store: IAtomicDocumentStore, + ) { + super(); + this.scope = sessionContext.scope('tool-policy'); + this.onDidChange = this.changeEmitter.event; + this.ready = this.load(); + } + + disabledTools(): readonly string[] { + return this.state.disabledTools; + } + + setDisabledTools(names: readonly string[]): Promise { + const run = this.updateQueue.then(() => this.replace(names)); + this.updateQueue = run.catch(() => {}); + return run; + } + + private async load(): Promise { + const stored = await this.store.get(this.scope, STATE_KEY); + if (stored !== undefined) { + this.state = { disabledTools: [...new Set(stored.disabledTools)] }; + } + } + + private async replace(names: readonly string[]): Promise { + await this.ready; + const disabledTools = [...new Set(names)]; + if ( + disabledTools.length === this.state.disabledTools.length && + disabledTools.every((name, index) => name === this.state.disabledTools[index]) + ) { + return; + } + const nextState = { disabledTools }; + await this.store.set(this.scope, STATE_KEY, nextState); + this.state = nextState; + await this.changeEmitter.fireAsync({}, new AbortController().signal); + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionToolPolicy, + SessionToolPolicyService, + InstantiationType.Eager, + 'sessionToolPolicy', +); 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 5999e9d0e1..3859df7738 100644 --- a/packages/agent-core-v2/src/session/subagent/tools/agent.ts +++ b/packages/agent-core-v2/src/session/subagent/tools/agent.ts @@ -29,6 +29,11 @@ import { type RegisterAgentTaskOptions, } from '#/agent/task/task'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { + isToolActive as evaluateToolActive, + resolveActiveToolNames, +} from '#/agent/toolPolicy/evaluate'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentLoopService } from '#/agent/loop/loop'; @@ -41,7 +46,9 @@ import { type ToolExecution, } from '#/tool/toolContract'; import { registerTool } from '#/agent/toolRegistry/toolContribution'; -import { IAgentProfileCatalogService, type AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { IAgentToolRegistryService, type ToolReference } from '#/agent/toolRegistry/toolRegistry'; +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'; @@ -144,10 +151,12 @@ 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, + @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, + @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, @ISessionMetadata private readonly sessionMetadata: ISessionMetadata, @@ -157,9 +166,9 @@ export class AgentTool implements BuiltinTool { ) { this.callerAgentId = scopeContext.agentId; this.canRunInBackground = () => - this.profile.isToolActive('TaskList') && - this.profile.isToolActive('TaskOutput') && - this.profile.isToolActive('TaskStop'); + this.toolPolicy.isToolActive('TaskList') && + this.toolPolicy.isToolActive('TaskOutput') && + this.toolPolicy.isToolActive('TaskStop'); } get description(): string { @@ -167,7 +176,12 @@ export class AgentTool implements BuiltinTool { ? AGENT_BACKGROUND_DESCRIPTION : AGENT_BACKGROUND_DISABLED_DESCRIPTION; const baseDescription = `${AGENT_DESCRIPTION_BASE}\n\n${backgroundDescription}`; - const typeLines = buildProfileDescriptions(this.catalog.list()); + const typeLines = buildProfileDescriptions( + this.catalog.list(), + this.toolRegistry.listReferences(), + (profile, name, source) => + this.toolPolicy.isToolActiveForProfile(profile, name, source), + ); return typeLines ? `${baseDescription}\n\nAvailable agent types (pass via subagent_type):\n${typeLines}` : baseDescription; @@ -241,6 +255,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}"`); @@ -445,6 +460,12 @@ registerTool(AgentTool); function buildProfileDescriptions( profiles: readonly AgentProfile[], + tools: readonly ToolReference[], + isToolActive: ( + profile: { readonly tools?: readonly string[]; readonly disallowedTools?: readonly string[] }, + name: string, + source: ToolReference['source'], + ) => boolean, ): string { return profiles .map((profile) => { @@ -452,10 +473,31 @@ 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.length === 0) { - return header; + const activeTools = resolveActiveToolNames(profile); + const externallyRestricted = tools.some( + (tool) => + evaluateToolActive(profile, tool.name, tool.source) && + !isToolActive(profile, tool.name, tool.source), + ); + if (externallyRestricted) { + const effectiveTools = tools + .filter((tool) => isToolActive(profile, tool.name, tool.source)) + .map((tool) => tool.name); + if (effectiveTools.length === 0) { + return `${header}\n Tools: none`; + } + return `${header}\n Tools: ${effectiveTools.join(', ')}`; + } + if (activeTools === undefined) { + if ((profile.disallowedTools?.length ?? 0) > 0) { + return `${header}\n Tools: all except ${profile.disallowedTools!.join(', ')}`; + } + return `${header}\n Tools: all`; + } + if (activeTools.length === 0) { + return `${header}\n Tools: none`; } - return `${header}\n Tools: ${profile.tools.join(', ')}`; + return `${header}\n Tools: ${activeTools.join(', ')}`; }) .join('\n'); } diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 33be825af0..45bf7642d6 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -24,7 +24,7 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentUserToolService } from '#/agent/userTool/userTool'; 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 { @@ -77,7 +77,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, @@ -135,6 +135,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/src/session/todo/sessionTodoService.ts b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts index 8d9fb24de0..a7dcd94fb4 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts @@ -24,7 +24,7 @@ import { Emitter } from '#/_base/event'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IWireService } from '#/wire/wire'; @@ -106,9 +106,9 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic private staleReminder(handle: IAgentScopeHandle): string | undefined { const memory = handle.accessor.get(IAgentContextMemoryService); - const profile = handle.accessor.get(IAgentProfileService); + const toolPolicy = handle.accessor.get(IAgentToolPolicyService); return todoListStaleReminder({ - active: profile.isToolActive(TODO_LIST_TOOL_NAME, 'builtin'), + active: toolPolicy.isToolActive(TODO_LIST_TOOL_NAME, 'builtin'), history: memory.get(), todos: this.getTodos(), }); diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index d6d60e89da..1c386a04c2 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -163,7 +163,6 @@ function createService( thinkingLevel, systemPrompt: 'system', }), - isToolActive: () => true, }; const contextSize = { get: () => ({ size: 0, measured: 0, estimated: 0 }), diff --git a/packages/agent-core-v2/test/agent/loop/stubs.ts b/packages/agent-core-v2/test/agent/loop/stubs.ts index 53bb9ad81e..9569e2807a 100644 --- a/packages/agent-core-v2/test/agent/loop/stubs.ts +++ b/packages/agent-core-v2/test/agent/loop/stubs.ts @@ -77,4 +77,4 @@ export function stubLoopWithHooks(options: StubLoopOptions = {}): StubLoop { } export type StubWire = IWireService & { readonly ops: readonly Op[]; readonly steered: readonly { readonly input: readonly ContentPart[]; readonly origin?: PromptOrigin }[] }; export function stubWire(): StubWire { const ops: Op[] = []; const steered: { input: readonly ContentPart[]; origin?: PromptOrigin }[] = []; return { _serviceBrand: undefined, hooks: createHooks(['onDidRestore']), ops, steered, dispatch: (...incoming: Op[]) => { for (const op of incoming) { ops.push(op); if (op.type === 'turn.steer') steered.push(op.payload as never); } }, replay: async () => {}, signal: () => {}, flush: async () => {}, getModel: () => ({}), subscribe: () => toDisposable(() => {}), onEmission: () => toDisposable(() => {}) } as unknown as StubWire; } -export function stubToolExecutor(): IAgentToolExecutorService { return { _serviceBrand: undefined, execute: async function* () {}, hooks: createHooks(['onBeforeExecuteTool', 'onDidExecuteTool']) as IAgentToolExecutorService['hooks'], recordDupType: () => {}, registerUnavailableToolDescriber: () => ({ dispose() {} }), registerMissingToolDescriber: () => ({ dispose() {} }) }; } +export function stubToolExecutor(): IAgentToolExecutorService { return { _serviceBrand: undefined, execute: async function* () {}, hooks: createHooks(['onBeforeExecuteTool', 'onDidExecuteTool']) as IAgentToolExecutorService['hooks'], recordDupType: () => {}, registerToolCallGuard: () => ({ dispose() {} }), registerUnavailableToolDescriber: () => ({ dispose() {} }), registerMissingToolDescriber: () => ({ dispose() {} }) }; } 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..044956a5f5 100644 --- a/packages/agent-core-v2/test/agent/profile/binding.test.ts +++ b/packages/agent-core-v2/test/agent/profile/binding.test.ts @@ -2,21 +2,69 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Event } from '#/_base/event'; +import { ConfigTarget, IConfigService } from '#/app/config/config'; +import { TOOLS_SECTION } from '#/agent/profile/configSection'; import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { registerAgentProfile } from '#/app/agentProfileCatalog/contribution'; +import type { ToolCall } from '#/app/llmProtocol/message'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { IAtomicDocumentStore, type IAtomicDocumentStore as AtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { IWireService } from '#/wire/wire'; +import type { ExecutableTool, ToolExecution, ToolResult, ToolSource } from '#/tool/toolContract'; import { InMemoryWireRecordPersistence, + appService, createTestAgent, hostEnvironmentServices, + sessionService, type TestAgentContext, } from '../../harness'; const MOCK_MODEL = 'mock-model'; +type ProfileWithToolPolicy = IAgentProfileService & + Pick; + +function profileWithToolPolicy(ctx: TestAgentContext): ProfileWithToolPolicy { + const profile = ctx.get(IAgentProfileService); + const toolPolicy = ctx.get(IAgentToolPolicyService); + return Object.assign(profile, { + isToolActive: toolPolicy.isToolActive.bind(toolPolicy), + setSessionDisabledTools: toolPolicy.setSessionDisabledTools.bind(toolPolicy), + }); +} + +function createAtomicDocumentStore(): AtomicDocumentStore { + const documents = new Map(); + const documentKey = (scope: string, key: string): string => `${scope}/${key}`; + return { + _serviceBrand: undefined, + get: async (scope: string, key: string) => documents.get(documentKey(scope, key)) as T | undefined, + set: async (scope: string, key: string, value: T) => { + documents.set(documentKey(scope, key), structuredClone(value)); + }, + delete: async (scope: string, key: string) => { + documents.delete(documentKey(scope, key)); + }, + list: async (scope: string, prefix = '') => + [...documents.keys()] + .filter((key) => key.startsWith(`${scope}/${prefix}`)) + .map((key) => key.slice(scope.length + 1)), + watch: () => Event.None as Event, + acquire: () => ({ dispose: () => {} }), + }; +} + describe('AgentProfileService.bind', () => { let ctx: TestAgentContext; let homeDir: string; @@ -30,9 +78,9 @@ describe('AgentProfileService.bind', () => { await rm(homeDir, { recursive: true, force: true }); }); - function buildContext(): { ctx: TestAgentContext; profile: IAgentProfileService } { + function buildContext(): { ctx: TestAgentContext; profile: ProfileWithToolPolicy } { ctx = createTestAgent(hostEnvironmentServices(homeDir)); - return { ctx, profile: ctx.get(IAgentProfileService) }; + return { ctx, profile: profileWithToolPolicy(ctx) }; } it('binds a profile + model atomically and becomes runnable', async () => { @@ -73,7 +121,7 @@ describe('AgentProfileService.bind', () => { max_context_tokens: 1_000_000, }, }); - const svc = ctx.get(IAgentProfileService); + const svc = profileWithToolPolicy(ctx); await ctx.get(IWireService).flush(); const start = persistence.records.length; @@ -132,4 +180,636 @@ describe('AgentProfileService.bind', () => { expect(svc.data().profileName).toBe(DEFAULT_AGENT_PROFILE_NAME); }); + + it('rejects binding a different profile once bound', async () => { + const { profile: svc } = buildContext(); + + await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL }); + + await expect(svc.bind({ profile: 'coder', model: MOCK_MODEL })).rejects.toThrow( + /already bound/, + ); + expect(svc.data().profileName).toBe(DEFAULT_AGENT_PROFILE_NAME); + }); + + it('rejects an unsupported thinking effort atomically before first bind', async () => { + ctx = createTestAgent( + { + initialConfig: { + providers: { + kimi: { type: 'kimi', apiKey: 'test-key', baseUrl: 'https://api.example.test/v1' }, + }, + models: { + 'kimi-code/kimi-for-coding': { + provider: 'kimi', + model: 'kimi-for-coding', + maxContextSize: 1_000_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + }, + }, + }, + }, + hostEnvironmentServices(homeDir), + ); + const svc = profileWithToolPolicy(ctx); + + await expect( + svc.bind({ + profile: DEFAULT_AGENT_PROFILE_NAME, + model: 'kimi-code/kimi-for-coding', + thinking: 'ultra', + strictThinking: true, + }), + ).rejects.toThrow(/not supported by model/); + + // The failed bind must leave the agent unbound — a retry can still bind. + expect(svc.data().profileName).toBeUndefined(); + await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: 'kimi-code/kimi-for-coding' }); + expect(svc.data().profileName).toBe(DEFAULT_AGENT_PROFILE_NAME); + }); + + it('clamps an inherited unsupported thinking effort instead of rejecting the bind', async () => { + ctx = createTestAgent( + { + initialConfig: { + providers: { + kimi: { type: 'kimi', apiKey: 'test-key', baseUrl: 'https://api.example.test/v1' }, + }, + models: { + 'kimi-code/kimi-for-coding': { + provider: 'kimi', + model: 'kimi-for-coding', + maxContextSize: 1_000_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + }, + }, + }, + }, + hostEnvironmentServices(homeDir), + ); + const svc = profileWithToolPolicy(ctx); + + // Spawn paths pass inherited (possibly drifted) thinking without + // strictThinking: the bind must succeed and clamp to a supported effort. + await svc.bind({ + profile: DEFAULT_AGENT_PROFILE_NAME, + model: 'kimi-code/kimi-for-coding', + thinking: 'ultra', + }); + + expect(svc.data().profileName).toBe(DEFAULT_AGENT_PROFILE_NAME); + expect(svc.data().thinkingLevel).toBe('high'); + }); + + it('keeps the persisted thinking effort on a same-name rebind', async () => { + ctx = createTestAgent(hostEnvironmentServices(homeDir)); + ctx.configure({ + modelCapabilities: { + image_in: false, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 1_000_000, + }, + }); + const svc = profileWithToolPolicy(ctx); + await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL, thinking: 'off' }); + expect(svc.data().thinkingLevel).toBe('off'); + + // A same-name rebind without an explicit thinking override must not reset + // the persisted effort to the configured/model default ('on' here). + await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL }); + expect(svc.data().thinkingLevel).toBe('off'); + }); +}); + +describe('AgentProfileService tool denylist', () => { + // Registration is idempotent (replace-by-name) and scoped to this describe's + // run window — module-scope registration would also pollute the bind + // describe above at collection time. + beforeAll(() => { + 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 = profileWithToolPolicy(ctx); + 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 = profileWithToolPolicy(ctx); + 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 = profileWithToolPolicy(ctx); + + 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('writes no tools.set_active_tools record when the profile has no allowlist', async () => { + const persistence = new InMemoryWireRecordPersistence(); + ctx = createTestAgent({ persistence }, hostEnvironmentServices(homeDir)); + const svc = profileWithToolPolicy(ctx); + + await svc.bind({ profile: 'deny-builtin', model: MOCK_MODEL }); + await ctx.get(IWireService).flush(); + + // The all-tools default must be represented by the ABSENCE of the record: + // a `tools.set_active_tools` record without `names` crashes v1 replay. + expect(persistence.records.some((r) => r.type === 'tools.set_active_tools')).toBe(false); + expect(svc.isToolActive('Read')).toBe(true); + expect(svc.isToolActive('Bash')).toBe(false); + }); + + it('restores the denylist from persisted records on resume without catalog resolution', async () => { + const persistence = new InMemoryWireRecordPersistence(); + ctx = createTestAgent({ persistence }, hostEnvironmentServices(homeDir)); + const svc = profileWithToolPolicy(ctx); + 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 = profileWithToolPolicy(ctx); + + expect(resumed.data().profileName).toBe('deny-builtin'); + expect(resumed.isToolActive('Bash')).toBe(false); + expect(resumed.isToolActive('Read')).toBe(true); + }); +}); + +describe('AgentProfileService global [tools] config', () => { + beforeAll(() => { + registerAgentProfile({ + name: 'config-intersect', + tools: ['Read', 'Bash'], + disallowedTools: ['Bash'], + systemPrompt: () => 'config intersect test', + }); + }); + + let ctx: TestAgentContext; + let homeDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'kimi-tools-config-home-')); + }); + + afterEach(async () => { + await ctx?.dispose(); + await rm(homeDir, { recursive: true, force: true }); + }); + + async function bindWithToolsConfig( + tools: Record, + profile: string = DEFAULT_AGENT_PROFILE_NAME, + ): Promise { + ctx = createTestAgent({ initialConfig: { tools } }, hostEnvironmentServices(homeDir)); + const svc = profileWithToolPolicy(ctx); + await svc.bind({ profile, model: MOCK_MODEL }); + return svc; + } + + it('treats a non-empty enabled list as a global allowlist', async () => { + const svc = await bindWithToolsConfig({ enabled: ['Read'] }); + expect(svc.isToolActive('Read')).toBe(true); + expect(svc.isToolActive('Bash')).toBe(false); + }); + + it('treats an empty enabled list as unconstrained', async () => { + const svc = await bindWithToolsConfig({ enabled: [] }); + expect(svc.isToolActive('Read')).toBe(true); + expect(svc.isToolActive('Bash')).toBe(true); + }); + + it('applies disabled as a global denylist', async () => { + const svc = await bindWithToolsConfig({ disabled: ['Bash'] }); + expect(svc.isToolActive('Bash')).toBe(false); + expect(svc.isToolActive('Read')).toBe(true); + }); + + it('matches globally disabled mcp tools by glob', async () => { + const svc = await bindWithToolsConfig({ disabled: ['mcp__github__*'] }); + 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('intersects the global config with the profile policy instead of overriding it', async () => { + const svc = await bindWithToolsConfig({ enabled: ['Read', 'Bash'] }, 'config-intersect'); + // Allowed by both layers. + expect(svc.isToolActive('Read')).toBe(true); + // The global allowlist cannot re-enable a tool the profile itself denies. + expect(svc.isToolActive('Bash')).toBe(false); + // Absent from the profile allowlist even though the global one admits it. + expect(svc.isToolActive('Write')).toBe(false); + }); +}); + +describe('AgentProfileService.setSessionDisabledTools', () => { + beforeAll(() => { + registerAgentProfile({ + name: 'session-deny', + disallowedTools: ['Write'], + systemPrompt: () => 'session deny test', + }); + }); + + let ctx: TestAgentContext; + let homeDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'kimi-session-deny-home-')); + }); + + afterEach(async () => { + await ctx?.dispose(); + await rm(homeDir, { recursive: true, force: true }); + }); + + async function bind(profile: string): Promise { + ctx = createTestAgent(hostEnvironmentServices(homeDir)); + const svc = profileWithToolPolicy(ctx); + await svc.bind({ profile, model: MOCK_MODEL }); + return svc; + } + + it('rejects when no profile is bound yet', async () => { + ctx = createTestAgent(hostEnvironmentServices(homeDir)); + const svc = profileWithToolPolicy(ctx); + + await expect(svc.setSessionDisabledTools(['Bash'])).rejects.toThrow(/not bound/); + expect(svc.isToolActive('Bash')).toBe(true); + }); + + it('replaces the client-managed denylist on every call', async () => { + const svc = await bind(DEFAULT_AGENT_PROFILE_NAME); + + await svc.setSessionDisabledTools(['Bash']); + expect(svc.isToolActive('Bash')).toBe(false); + expect(svc.isToolActive('Read')).toBe(true); + + await svc.setSessionDisabledTools(['Edit']); + expect(svc.isToolActive('Bash')).toBe(true); + expect(svc.isToolActive('Edit')).toBe(false); + }); + + it('keeps the profile own denylist across replacement calls', async () => { + const svc = await bind('session-deny'); + + await svc.setSessionDisabledTools(['Bash']); + expect(svc.isToolActive('Write')).toBe(false); + expect(svc.isToolActive('Bash')).toBe(false); + + await svc.setSessionDisabledTools([]); + expect(svc.isToolActive('Write')).toBe(false); + expect(svc.isToolActive('Bash')).toBe(true); + }); + + it('persists the session denylist across a resume', async () => { + const persistence = new InMemoryWireRecordPersistence(); + const atomicDocuments = createAtomicDocumentStore(); + const documentServices = appService(IAtomicDocumentStore, atomicDocuments); + ctx = createTestAgent( + { persistence }, + documentServices, + hostEnvironmentServices(homeDir), + ); + const svc = profileWithToolPolicy(ctx); + await svc.bind({ profile: 'session-deny', model: MOCK_MODEL }); + await svc.setSessionDisabledTools(['Bash']); + await ctx.get(IWireService).flush(); + await ctx.dispose(); + + // Resume by replaying the same records, with a catalog that cannot resolve + // the bound profile: the session 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 }, + documentServices, + hostEnvironmentServices(homeDir), + sessionService(ISessionAgentProfileCatalog, emptyCatalog), + ); + await ctx.restorePersisted(); + await ctx.get(ISessionToolPolicy).ready; + const resumed = profileWithToolPolicy(ctx); + + expect(resumed.isToolActive('Bash')).toBe(false); + expect(resumed.isToolActive('Write')).toBe(false); + expect(resumed.isToolActive('Read')).toBe(true); + + await resumed.setSessionDisabledTools(['Edit']); + expect(resumed.isToolActive('Bash')).toBe(true); + expect(resumed.isToolActive('Edit')).toBe(false); + expect(resumed.isToolActive('Write')).toBe(false); + }); + + it('retries persistence after a failed session denylist replacement', async () => { + const atomicDocuments = createAtomicDocumentStore(); + const persist = atomicDocuments.set.bind(atomicDocuments); + let attempts = 0; + atomicDocuments.set = async (...args) => { + if (args[0].endsWith('/tool-policy')) { + attempts += 1; + if (attempts === 1) throw new Error('disk full'); + } + await persist(...args); + }; + ctx = createTestAgent( + appService(IAtomicDocumentStore, atomicDocuments), + hostEnvironmentServices(homeDir), + ); + const svc = profileWithToolPolicy(ctx); + await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL }); + + await expect(svc.setSessionDisabledTools(['Bash'])).rejects.toThrow('disk full'); + expect(svc.isToolActive('Bash')).toBe(true); + await svc.setSessionDisabledTools(['Bash']); + + expect(attempts).toBe(2); + expect(svc.isToolActive('Bash')).toBe(false); + }); + + it('removes the skill listing when the session disables Skill', async () => { + const skillMarker = 'session-policy-skill-marker'; + ctx = createTestAgent( + hostEnvironmentServices(homeDir), + sessionService(ISessionSkillCatalog, { + _serviceBrand: undefined, + catalog: { getModelSkillListing: () => skillMarker } as never, + ready: Promise.resolve(), + onDidChange: Event.None as Event, + load: async () => {}, + reload: async () => {}, + }), + ); + const svc = profileWithToolPolicy(ctx); + await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL }); + expect(svc.getSystemPrompt()).toContain(skillMarker); + + await svc.setSessionDisabledTools(['Skill']); + + expect(svc.isToolActive('Skill')).toBe(false); + expect(svc.getSystemPrompt()).not.toContain(skillMarker); + }); + + it('omits the skill listing when global tools disable Skill', async () => { + const skillMarker = 'global-policy-skill-marker'; + ctx = createTestAgent( + { initialConfig: { tools: { disabled: ['Skill'] } } }, + hostEnvironmentServices(homeDir), + sessionService(ISessionSkillCatalog, { + _serviceBrand: undefined, + catalog: { getModelSkillListing: () => skillMarker } as never, + ready: Promise.resolve(), + onDidChange: Event.None as Event, + load: async () => {}, + reload: async () => {}, + }), + ); + const svc = profileWithToolPolicy(ctx); + await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL }); + + expect(svc.isToolActive('Skill')).toBe(false); + expect(svc.getSystemPrompt()).not.toContain(skillMarker); + }); + + it('refreshes the skill listing when global tool policy changes at runtime', async () => { + const skillMarker = 'live-global-policy-skill-marker'; + ctx = createTestAgent( + hostEnvironmentServices(homeDir), + sessionService(ISessionSkillCatalog, { + _serviceBrand: undefined, + catalog: { getModelSkillListing: () => skillMarker } as never, + ready: Promise.resolve(), + onDidChange: Event.None as Event, + load: async () => {}, + reload: async () => {}, + }), + ); + const svc = profileWithToolPolicy(ctx); + await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL }); + expect(svc.getSystemPrompt()).toContain(skillMarker); + + await ctx + .get(IConfigService) + .replace(TOOLS_SECTION, { disabled: ['Skill'] }, ConfigTarget.Memory); + + expect(svc.isToolActive('Skill')).toBe(false); + await vi.waitFor(() => expect(svc.getSystemPrompt()).not.toContain(skillMarker)); + }); }); + +describe('AgentToolPolicyService executor enforcement', () => { + let ctx: TestAgentContext; + let homeDir: string; + + beforeAll(() => { + registerAgentProfile({ + name: 'executor-deny-builtin', + disallowedTools: ['PolicyProbe'], + systemPrompt: () => 'executor policy test', + }); + registerAgentProfile({ + name: 'executor-deny-mcp', + disallowedTools: ['mcp__blocked__*'], + systemPrompt: () => 'executor policy test', + }); + }); + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'kimi-executor-policy-home-')); + }); + + afterEach(async () => { + await ctx?.dispose(); + await rm(homeDir, { recursive: true, force: true }); + }); + + it.each([ + { + name: 'profile denylist', + options: {}, + profile: 'executor-deny-builtin', + disable: undefined, + }, + { + name: 'global tools config', + options: { initialConfig: { tools: { disabled: ['PolicyProbe'] } } }, + profile: DEFAULT_AGENT_PROFILE_NAME, + disable: undefined, + }, + { + name: 'session denylist', + options: {}, + profile: DEFAULT_AGENT_PROFILE_NAME, + disable: ['PolicyProbe'], + }, + ])('blocks a direct builtin call through $name', async ({ options, profile, disable }) => { + ctx = createTestAgent(options, hostEnvironmentServices(homeDir)); + const profileService = ctx.get(IAgentProfileService); + await profileService.bind({ profile, model: MOCK_MODEL }); + if (disable !== undefined) { + await ctx.get(IAgentToolPolicyService).setSessionDisabledTools(disable); + } + const probe = new PolicyProbeTool('PolicyProbe'); + ctx.get(IAgentToolRegistryService).register(probe); + + const result = await executeDirectToolCall(ctx, 'PolicyProbe'); + + expect(result).toMatchObject({ + isError: true, + output: 'Tool "PolicyProbe" is disabled by the active tool policy', + }); + expect(probe.calls).toBe(0); + }); + + it('blocks a direct MCP call by glob before execution', async () => { + ctx = createTestAgent(hostEnvironmentServices(homeDir)); + await ctx.get(IAgentProfileService).bind({ profile: 'executor-deny-mcp', model: MOCK_MODEL }); + const probe = new PolicyProbeTool('mcp__blocked__write'); + ctx.get(IAgentToolRegistryService).register(probe, { source: 'mcp' }); + + const result = await executeDirectToolCall(ctx, probe.name); + + expect(result).toMatchObject({ + isError: true, + output: `Tool "${probe.name}" is disabled by the active tool policy`, + }); + expect(probe.calls).toBe(0); + }); + +}); + +async function executeDirectToolCall(ctx: TestAgentContext, name: string): Promise { + const call: ToolCall = { + type: 'function', + id: `call_${name}`, + name, + arguments: '{}', + }; + for await (const result of ctx.get(IAgentToolExecutorService).execute([call], { + signal: new AbortController().signal, + turnId: 1, + })) { + return result.result; + } + throw new Error(`No result for tool ${name}`); +} + +class PolicyProbeTool implements ExecutableTool> { + readonly description = 'Policy enforcement probe.'; + readonly parameters = { type: 'object', additionalProperties: false }; + calls = 0; + + constructor( + readonly name: string, + readonly source?: ToolSource, + ) {} + + resolveExecution(): ToolExecution { + return { + approvalRule: this.name, + execute: async () => { + this.calls += 1; + return { isError: false, output: 'executed' }; + }, + }; + } +} 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..71ff363a58 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -5,8 +5,9 @@ import { DisposableStore } from '#/_base/di/lifecycle'; 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 { ActiveToolsModel, ProfileModel } from '#/agent/profile/profileOps'; +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'; @@ -24,6 +25,7 @@ import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { IWireService } from '#/wire/wire'; import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; @@ -44,6 +46,7 @@ function createTelemetryStub(): ITelemetryService { function createConfigStub(): IConfigService { return { _serviceBrand: undefined, + onDidSectionChange: () => ({ dispose: () => {} }), get: ((key: string) => configValues[key]) as unknown as IConfigService['get'], } as unknown as IConfigService; } @@ -102,8 +105,15 @@ 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.stub(ISessionToolPolicy, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChange: () => ({ dispose: () => {} }), + disabledTools: () => [], + setDisabledTools: () => Promise.resolve(), + }); host.set(IAgentProfileService, new SyncDescriptor(AgentProfileService)); const wire = registerTestAgentWire(host, testWireScope(SCOPE, key), { log: host.get(IAppendLogStore), @@ -142,6 +152,10 @@ function modelOf(target: IWireService) { return target.getModel(ProfileModel); } +function activeToolsOf(target: IWireService) { + return target.getModel(ActiveToolsModel); +} + function createRecordingModel( generationKwargs: GenerationKwargs[], thinkingEfforts: ThinkingEffort[], @@ -230,6 +244,34 @@ describe('AgentProfileService (wire-backed config.update)', () => { expect(modelOf(wire)).toBe(before); }); + it('persists and replays an allowlist reset to unrestricted', async () => { + svc.applyBindingSnapshot({ + cwd: '/work', + profileName: 'restricted', + thinkingLevel: 'off', + systemPrompt: 'restricted', + activeToolNames: ['Read'], + }); + svc.applyBindingSnapshot({ + cwd: '/work', + profileName: 'unrestricted', + thinkingLevel: 'off', + systemPrompt: 'unrestricted', + activeToolNames: undefined, + }); + expect(activeToolsOf(wire)).toBeUndefined(); + + const replay = buildHost('profile-replay-active-tools'); + await restoreTestAgentWire( + replay.wire, + log, + testWireScope(SCOPE, KEY), + await readRecords(), + ); + expect(activeToolsOf(replay.wire)).toBeUndefined(); + replay.ix.dispose(); + }); + it('chdir and emitStatusUpdated run live-only and are silent during replay', async () => { let chdirCalls = 0; let statusEmits = 0; diff --git a/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts b/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts index 71701675f1..09420f0b4f 100644 --- a/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts +++ b/packages/agent-core-v2/test/agent/shellCommand/shellCommand.test.ts @@ -87,6 +87,7 @@ describe('AgentShellCommandService', () => { _serviceBrand: undefined, register: () => ({ dispose: () => {} }), list: () => [], + listReferences: () => [], resolve: () => undefined, }; ctx = createTestAgent(agentService(IAgentToolRegistryService, emptyRegistry)); diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index fbf9eba0ff..96ff1a9f79 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -112,6 +112,25 @@ describe('AgentToolExecutorService', () => { }); }); + it('rejects by policy before dynamic availability when a tool-call guard denies it', async () => { + const tool = new TestTool('blocked'); + registry.register(tool, { source: 'mcp' }); + executor.registerUnavailableToolDescriber(() => 'Tool "blocked" is not loaded'); + executor.registerToolCallGuard(({ name, source }) => + name === 'blocked' && source === 'mcp' ? 'Tool "blocked" is disabled' : undefined, + ); + + const results = await execute([toolCall('call_blocked', 'blocked', {})]); + + expect(results).toEqual([ + expect.objectContaining({ + isError: true, + output: 'Tool "blocked" is disabled', + }), + ]); + expect(tool.calls).toEqual([]); + }); + it('tags tool_call telemetry with recorded dup types, defaulting to normal', async () => { const tool = new TestTool('echo'); registry.register(tool); diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index 2ef2859c6a..92afef3fd9 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -33,6 +33,7 @@ import { } from '#/agent/loop/loop'; import type { StepRequest } from '#/agent/loop/stepRequest'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; import type { ExecutableTool, ToolExecution } from '#/tool/toolContract'; @@ -297,6 +298,8 @@ function registerSharedServices( reg.defineInstance(IAgentContextMemoryService, contextMemory); reg.definePartialInstance(IAgentProfileService, { getModelCapabilities: () => capabilities, + }); + reg.definePartialInstance(IAgentToolPolicyService, { isToolActive: (name: string) => activeToolNames === undefined || activeToolNames.has(name), }); reg.definePartialInstance(IFlagService, { 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..2759f0bde2 --- /dev/null +++ b/packages/agent-core-v2/test/app/agentFileCatalog/agentFile.test.ts @@ -0,0 +1,190 @@ +/** + * Scenario: agent-file parsing primitives — frontmatter validation, defaults, + * and the AgentFileDefinition → AgentProfile factory (replace/append modes, + * tool pass-through, explicit override intent). Pure-function level, no IO. + * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/app/agentFileCatalog/agentFile.test.ts`. + */ + +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 检查 +override: true +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.override).toBe(true); + 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.override).toBe(false); + 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-string mode instead of defaulting to replace', () => { + expect(() => parse('---\nname: solo\ndescription: d\nmode: 42\n---\n\nbody\n')).toThrow( + /"mode"/, + ); + }); + + it('rejects a non-boolean override field', () => { + expect(() => parse('---\nname: solo\ndescription: d\noverride: yes\n---\n\nbody\n')).toThrow( + /"override"/, + ); + }); + + 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', + override: false, + 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'); + expect(profile.override).toBe(false); + }); + + 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('append mode with Skill in disallowedTools skips the skills listing', () => { + const profile = agentProfileFromFile({ ...base, mode: 'append', disallowedTools: ['Skill'] }); + 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']); + }); + + it('treats an explicit file as an override intent', () => { + const profile = agentProfileFromFile({ ...base, source: 'explicit' }); + + expect(profile.override).toBe(true); + }); +}); 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..0b5dab3c7f --- /dev/null +++ b/packages/agent-core-v2/test/app/agentFileCatalog/agentFileDiscovery.test.ts @@ -0,0 +1,222 @@ +/** + * Scenario: filesystem agent-file discovery — recursive scanning, dot-entry + * pruning, per-file parse isolation, first-wins name collisions, and + * directory-failure tolerance (root propagates, subdirectories skip-and-warn). + * Exercises discoverAgentFiles against real temp dirs and targeted fake fs. + * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/app/agentFileCatalog/agentFileDiscovery.test.ts`. + */ + +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'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; + +const hostFs = new HostFileSystem(); + +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(hostFs, [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(hostFs, [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(hostFs, [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(hostFs, [ + 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(hostFs, [fileRoot(root)]); + + expect(result.agents).toEqual([]); + }); + + it('rejects when a directory cannot be scanned so callers can keep stale contributions', async () => { + const failingFs = { + readdir: async () => { + throw new HostFsError( + OsFsErrors.codes.OS_FS_UNAVAILABLE, + 'readdir failed: filesystem resource unavailable', + ); + }, + } as unknown as IHostFileSystem; + + await expect(discoverAgentFiles(failingFs, [fileRoot(root)])).rejects.toMatchObject({ + code: OsFsErrors.codes.OS_FS_UNAVAILABLE, + }); + }); + + it('rejects when a file cannot be read during a filesystem outage', async () => { + await writeFile(join(root, 'agent.md'), agentMd('agent')); + const failingFs = { + _serviceBrand: undefined, + readdir: hostFs.readdir.bind(hostFs), + stat: hostFs.stat.bind(hostFs), + realpath: hostFs.realpath.bind(hostFs), + readText: async () => { + throw new HostFsError( + OsFsErrors.codes.OS_FS_UNAVAILABLE, + 'read failed: filesystem resource unavailable', + ); + }, + } as unknown as IHostFileSystem; + + await expect(discoverAgentFiles(failingFs, [fileRoot(root)])).rejects.toMatchObject({ + code: OsFsErrors.codes.OS_FS_UNAVAILABLE, + }); + }); + + it('treats a directory that disappears during scanning as absent', async () => { + const disappearingFs = { + readdir: async () => { + throw new HostFsError( + OsFsErrors.codes.OS_FS_NOT_FOUND, + 'readdir failed: path does not exist', + ); + }, + } as unknown as IHostFileSystem; + + const result = await discoverAgentFiles(disappearingFs, [fileRoot(root)]); + + expect(result.agents).toEqual([]); + }); + + it('skips an unreadable subdirectory with a warning and keeps scanning the rest', async () => { + const locked = join(root, 'locked'); + const fakeFs = { + realpath: async (p: string) => p, + stat: async (p: string) => + p === locked ? { isDirectory: true, isFile: false } : { isDirectory: false, isFile: true }, + readdir: async (p: string) => { + if (p === locked) { + throw new HostFsError( + OsFsErrors.codes.OS_FS_PERMISSION_DENIED, + 'readdir failed: permission denied', + ); + } + return [{ name: 'locked' }, { name: 'solo.md' }]; + }, + readText: async () => agentMd('solo'), + } as unknown as IHostFileSystem; + + const warnings: string[] = []; + const result = await discoverAgentFiles(fakeFs, [fileRoot(root)], (message) => + warnings.push(message), + ); + + expect(result.agents.map((a) => a.name)).toEqual(['solo']); + expect(warnings.some((w) => w.includes('locked'))).toBe(true); + }); + + it('isolates a failed root and keeps scanning sibling roots', async () => { + const other = await mkdtemp(join(tmpdir(), 'agent-discovery-other-')); + try { + const fakeFs = { + realpath: async (p: string) => p, + stat: async () => ({ isDirectory: false, isFile: true }), + readdir: async (p: string) => { + if (p === root) { + throw new HostFsError( + OsFsErrors.codes.OS_FS_PERMISSION_DENIED, + 'readdir failed: permission denied', + ); + } + return [{ name: 'solo.md' }]; + }, + readText: async () => agentMd('solo'), + } as unknown as IHostFileSystem; + + const warnings: string[] = []; + const result = await discoverAgentFiles( + fakeFs, + [fileRoot(root), fileRoot(other)], + (message) => warnings.push(message), + ); + + expect(result.agents.map((a) => a.name)).toEqual(['solo']); + expect(warnings.some((w) => w.includes(root))).toBe(true); + } finally { + await rm(other, { recursive: true, force: true }); + } + }); +}); 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..e52a7dff39 --- /dev/null +++ b/packages/agent-core-v2/test/app/agentFileCatalog/agentRoots.test.ts @@ -0,0 +1,157 @@ +/** + * Scenario: agent-root resolution — user / project / configured roots, + * .git walk-up, brand-vs-generic ordering, `~` and relative path expansion, + * and canonical dedup. Exercises the path primitives against real temp dirs. + * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/app/agentFileCatalog/agentRoots.test.ts`. + */ + +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'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; + +const hostFs = new HostFileSystem(); + +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(hostFs, 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(hostFs, 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(hostFs, 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(hostFs, 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(hostFs, 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(hostFs, 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( + hostFs, + ['~', '~/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'))); + }); + + it('propagates filesystem-unavailable failures while probing a root', async () => { + const unavailableFs = new Proxy(hostFs, { + get(target, property, receiver) { + if (property === 'realpath') { + return () => + Promise.reject( + new HostFsError(OsFsErrors.codes.OS_FS_UNAVAILABLE, 'filesystem unavailable'), + ); + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + + await expect( + configuredAgentRoots(unavailableFs, ['agents'], root, root, 'extra'), + ).rejects.toMatchObject({ code: OsFsErrors.codes.OS_FS_UNAVAILABLE }); + }); + }); +}); diff --git a/packages/agent-core-v2/test/app/agentFileCatalog/systemFile.test.ts b/packages/agent-core-v2/test/app/agentFileCatalog/systemFile.test.ts new file mode 100644 index 0000000000..dd1e4e1e66 --- /dev/null +++ b/packages/agent-core-v2/test/app/agentFileCatalog/systemFile.test.ts @@ -0,0 +1,174 @@ +/** + * Scenario: SYSTEM.md prompt-override profile — file tolerance (missing / + * empty / unreadable → no profile), synthesized profile shape (default name + + * override opt-in, description/tools inherited from the builtin default), and + * `${var}` template substitution (known variables replaced, unknown kept + * verbatim, `${skills}` gated on the Skill tool). Pure logic against real + * temp dirs plus a targeted fake fs for the read-failure path. + * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/app/agentFileCatalog/systemFile.test.ts`. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; + +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + DEFAULT_AGENT_PROFILE_NAME, + type AgentProfile, +} from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { + SYSTEM_MD_FILENAME, + loadSystemMdProfile, + renderSystemMdPrompt, +} from '#/app/agentFileCatalog/systemFile'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; + +const hostFs = new HostFileSystem(); + +const BUILTIN_DEFAULT: AgentProfile = { + name: DEFAULT_AGENT_PROFILE_NAME, + description: 'builtin default description', + tools: ['Read', 'Skill', 'Bash'], + disallowedTools: ['Write'], + systemPrompt: () => 'BUILTIN PROMPT', +}; + +function collectWarnings(): { warnings: string[]; warn: (message: string) => void } { + const warnings: string[] = []; + return { warnings, warn: (message) => warnings.push(message) }; +} + +describe('loadSystemMdProfile', () => { + let home: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'system-md-')); + }); + + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + it('returns undefined when SYSTEM.md does not exist', async () => { + const { warnings, warn } = collectWarnings(); + expect(await loadSystemMdProfile(hostFs, home, BUILTIN_DEFAULT, warn)).toBeUndefined(); + expect(warnings).toEqual([]); + }); + + it('returns undefined when SYSTEM.md is empty or whitespace-only', async () => { + await writeFile(join(home, SYSTEM_MD_FILENAME), ' \n\n'); + const { warn } = collectWarnings(); + expect(await loadSystemMdProfile(hostFs, home, BUILTIN_DEFAULT, warn)).toBeUndefined(); + }); + + it('degrades to a warning when the file cannot be read', async () => { + const unreadableFs = { + realpath: async (p: string) => p, + stat: async () => ({ isFile: true }), + readText: async () => { + throw new Error('disk gone'); + }, + } as unknown as IHostFileSystem; + const { warnings, warn } = collectWarnings(); + + expect(await loadSystemMdProfile(unreadableFs, home, BUILTIN_DEFAULT, warn)).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('SYSTEM.md'); + }); + + it('synthesizes a default-named override profile that inherits the builtin shape', async () => { + await writeFile(join(home, SYSTEM_MD_FILENAME), 'You are a custom main agent.'); + const { warn } = collectWarnings(); + + const profile = await loadSystemMdProfile(hostFs, home, BUILTIN_DEFAULT, warn); + + expect(profile?.name).toBe(DEFAULT_AGENT_PROFILE_NAME); + expect(profile?.override).toBe(true); + expect(profile?.description).toBe('builtin default description'); + expect(profile?.tools).toEqual(['Read', 'Skill', 'Bash']); + expect(profile?.disallowedTools).toEqual(['Write']); + expect(profile?.systemPrompt({})).toBe('You are a custom main agent.'); + }); + + it('empties ${skills} when the builtin default disables the Skill tool', async () => { + await writeFile(join(home, SYSTEM_MD_FILENAME), 'skills=${skills}'); + const noSkillBuiltin: AgentProfile = { + name: DEFAULT_AGENT_PROFILE_NAME, + description: 'builtin without Skill', + tools: ['Read', 'Bash'], + systemPrompt: () => 'BUILTIN PROMPT', + }; + const { warn } = collectWarnings(); + + const profile = await loadSystemMdProfile(hostFs, home, noSkillBuiltin, warn); + + expect(profile?.systemPrompt({ skills: 'SKILLS' })).toBe('skills='); + }); +}); + +describe('renderSystemMdPrompt', () => { + it('substitutes all known variables from the context', () => { + const out = renderSystemMdPrompt( + [ + 'skills=${skills}', + 'agents=${agents_md}', + 'cwd=${cwd}', + 'listing=${cwd_listing}', + 'os=${os}', + 'shell=${shell}', + 'now=${now}', + ].join('\n'), + { + skills: 'SKILLS', + agentsMd: 'AGENTS', + cwd: '/work', + cwdListing: 'LISTING', + osKind: 'macOS', + shellName: 'zsh', + shellPath: '/bin/zsh', + now: 'NOW', + }, + { skillActive: true }, + ); + + expect(out).toBe( + [ + 'skills=SKILLS', + 'agents=AGENTS', + 'cwd=/work', + 'listing=LISTING', + 'os=macOS', + 'shell=zsh (`/bin/zsh`)', + 'now=NOW', + ].join('\n'), + ); + }); + + it('keeps unknown placeholders and bare dollars verbatim', () => { + const out = renderSystemMdPrompt( + 'a=${unknown} b=$cwd c=$ d=$${cwd}', + { cwd: '/work' }, + { skillActive: true }, + ); + expect(out).toBe('a=${unknown} b=$cwd c=$ d=$/work'); + }); + + it('renders missing context fields as empty strings', () => { + const out = renderSystemMdPrompt('x${cwd}y${shell}z${agents_md}', {}, { skillActive: true }); + expect(out).toBe('xyz'); + }); + + it('renders ${skills} as empty when skill rendering is off', () => { + const out = renderSystemMdPrompt('s=${skills}', { skills: 'SKILLS' }, { skillActive: false }); + expect(out).toBe('s='); + }); + + it('defaults ${now} to the current ISO timestamp when the context omits it', () => { + const out = renderSystemMdPrompt('${now}', {}, { skillActive: true }); + expect(Number.isNaN(Date.parse(out))).toBe(false); + }); +}); 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 08b9b38fcf..ea7b766dd4 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": "