From d3f706885474ff7f4553c8cdadc9d5193f77ede8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 17:03:20 +0000 Subject: [PATCH] fix(node-sdk): bind --agent profile tool policy on interactive create Interactive TUI sessions created through the v2 SDK dropped agentProfile and agentFiles, so a custom agent's disallowedTools list never applied. Co-authored-by: Noa --- .changeset/tui-agent-disallowed-tools.md | 5 + packages/agent-core-v2/src/program/program.ts | 1 + packages/node-sdk/src/sdk-rpc-client-v2.ts | 122 ++++++++++++---- .../node-sdk/test/sdk-rpc-client-v2.test.ts | 130 ++++++++++++++++++ 4 files changed, 234 insertions(+), 24 deletions(-) create mode 100644 .changeset/tui-agent-disallowed-tools.md diff --git a/.changeset/tui-agent-disallowed-tools.md b/.changeset/tui-agent-disallowed-tools.md new file mode 100644 index 00000000000..58f2edca192 --- /dev/null +++ b/.changeset/tui-agent-disallowed-tools.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Honor a custom agent's tools and disallowedTools policy in interactive TUI sessions. diff --git a/packages/agent-core-v2/src/program/program.ts b/packages/agent-core-v2/src/program/program.ts index b0877e60530..dfbb4bcb7c9 100644 --- a/packages/agent-core-v2/src/program/program.ts +++ b/packages/agent-core-v2/src/program/program.ts @@ -151,6 +151,7 @@ export class Program { get trust(): IWorkspaceTrust { return this.requireGeneration().trust; } get skills(): IWorkspaceSkillCatalog { return this.requireGeneration().skills; } get agentProfiles(): IWorkspaceAgentProfileLoader { return this.requireGeneration().agentProfiles; } + get explicitAgentProfiles(): IExplicitAgentProfileLoader { return this.requireGeneration().explicitAgentProfiles; } get sessionControllerGeneration(): string { return this.requireGeneration().id; } createSessionController(): SessionLifecycleService { diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 9da7370515a..ef192e41248 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -52,9 +52,10 @@ * aggregate the base class builds, re-read from the profile / permission / * swarm services plus the facade. `importContext` composes v1's exact * message + rejections over v2 primitives (`src/v2/import-context.ts`) — - * the engine has no import capability of its own. `createSession`'s - * `model` / `thinking` / `permission` options are applied in this batch - * too (default-profile bind + permission mode). + * the engine has no import capability of its own. `createSession`'s + * `model` / `thinking` / `permission` / `agentProfile` / `agentFiles` + * options are applied in this batch too (startup-profile or default-profile + * bind + permission mode). * - `prompt` / `steer` / `runShellCommand` / `cancelShellCommand` → the * `klient.session(id).agent(id)` facade; `activatePluginCommand` → * `IAgentPluginCommandService` through the agent scope; `activateSkill` → @@ -131,7 +132,7 @@ * `toolCall` keeps the base class's "not supported" answer, which the * interaction bridge already relies on. */ -import { readdir } from 'node:fs/promises'; +import { readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { @@ -160,6 +161,8 @@ import { drainSessionIndexMirror, ensureKimiHome, ensureMainAgent, + parseAgentFileText, + resolveAgentPath, agentContextOf, IAgentActivityView, IAgentContextMemoryService, @@ -1238,15 +1241,19 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * v1 semantics: register the workDir as a workspace and create the session * (the handler's `ISessionLifecycleService.create` does both; the klient facade * wrapper is bypassed because it takes neither an explicit session id nor - * caller metadata). The `model` / `thinking` / `permission` options are the - * main-agent configuration v1 applies eagerly at creation: supplying any of - * them materializes the main agent here (v2 otherwise keeps it lazy) and - * binds the default profile with the requested model/thinking. v1 never - * validates either at create time — an unknown alias is recorded verbatim - * and an unlisted effort normalizes to the model default — so the bind is - * deliberately NOT `strictThinking`, and the v2-only create-time rejections - * that still leak through (unknown alias → `config.invalid`, no configured - * default model → `model.not_configured`) are pinned in the parity tests. + * caller metadata). The `model` / `thinking` / `permission` / `agentProfile` + * options are the main-agent configuration v1 applies eagerly at creation: + * supplying any of them materializes the main agent here (v2 otherwise keeps + * it lazy). `agentProfile` is bound at create (the same `mainAgentBinding` + * print mode uses) so a custom agent's `tools` / `disallowedTools` apply in + * interactive sessions; otherwise the default profile is bound with the + * requested model/thinking. v1 never validates model/thinking at create time + * — an unknown alias is recorded verbatim and an unlisted effort normalizes + * to the model default — so the bind is deliberately NOT `strictThinking`, + * and the v2-only create-time rejections that still leak through (unknown + * alias → `config.invalid`, no configured default model → + * `model.not_configured`, unknown `agentProfile` → `profile.unknown`) are + * pinned in the tests. */ override async createSession(input: CreateSessionOptions): Promise { // An explicit id takes the per-session queue so the check-then-create @@ -1271,20 +1278,32 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { ); } } + await this.seedExplicitAgentFiles(workDir, input.agentFiles); + const agentProfileName = await this.resolveStartupAgentProfile(input, workDir); const handle = await this.engineAccessor.get(ISessionManager).create({ sessionId: input.id, workDir, additionalDirs: input.additionalDirs, + mainAgentBinding: + agentProfileName !== undefined + ? { + profile: agentProfileName, + model: input.model, + thinking: input.thinking, + } + : undefined, }); // Wired before the optional main-agent materialization so a profile-bind // warning (oversized AGENTS.md) reaches the listeners like v1's create. this.wireSession(handle); if ( + agentProfileName !== undefined || input.model !== undefined || input.thinking !== undefined || input.permission !== undefined ) { const agent = await this.materializeMainAgent(handle, { + profile: agentProfileName, model: input.model, thinking: input.thinking, }); @@ -1594,17 +1613,72 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { // ----------------------------------------------------------------------- /** - * The session's materialized main agent with v1's eager default binding - * applied: a freshly created agent whose profile is still unbound gets the - * default profile + configured default model (the same bind kap-server's - * prompt route performs on first use). A home with no configured model - * leaves the agent unbound instead of failing — v1's model-less session - * reads (`model: undefined`, `'off'` thinking, zero capabilities) map onto - * the unbound state exactly. + * The session's materialized main agent with v1's eager binding applied: a + * freshly created agent whose profile is still unbound gets the requested + * startup profile (or the default) plus the configured default model. An + * already-bound agent is left alone unless a startup profile was requested. + * A home with no configured model leaves the agent unbound instead of + * failing — v1's model-less session reads (`model: undefined`, `'off'` + * thinking, zero capabilities) map onto the unbound state exactly. */ + private async resolveStartupAgentProfile( + input: CreateSessionOptions, + workDir: string, + ): Promise { + if (input.agentProfile !== undefined) return input.agentProfile; + const agentFile = input.agentFiles?.[0]; + if (agentFile === undefined) return undefined; + const agentFilePath = resolveAgentPath( + agentFile, + workDir, + this.engineAccessor.get(IBootstrapService).osHomeDir, + ); + let text: string; + try { + text = await readFile(agentFilePath, 'utf8'); + } catch (error) { + throw new KimiError( + ErrorCodes.AGENT_NOT_FOUND, + `Failed to read agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + try { + return parseAgentFileText({ + path: agentFilePath, + source: 'explicit', + text, + }).name; + } catch (error) { + throw new KimiError( + ErrorCodes.AGENT_NOT_FOUND, + `Invalid agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + } + + private async seedExplicitAgentFiles( + workDir: string, + agentFiles: readonly string[] | undefined, + ): Promise { + const hostArgs = this.engineAccessor.get(IBootstrapService).args as { + agentFiles?: readonly string[]; + }; + const next = + agentFiles !== undefined && agentFiles.length > 0 ? [...agentFiles] : undefined; + if (next === undefined && hostArgs.agentFiles === undefined) return; + hostArgs.agentFiles = next; + const instance = await this.engineAccessor + .get(IWorkspaceInstanceManager) + .getOrCreate({ root: workDir }); + await instance.program.ready; + await instance.program.explicitAgentProfiles.reload(); + } + private async materializeMainAgent( session: ISessionScopeHandle, - binding?: { readonly model?: string; readonly thinking?: string }, + binding?: { readonly profile?: string; readonly model?: string; readonly thinking?: string }, ): Promise { await this.modelReady; const context = await ensureMainAgent(session); @@ -1613,16 +1687,16 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { throw new KimiError(ErrorCodes.AGENT_NOT_FOUND, 'Main agent was not found'); } const profile = agent.accessor.get(IAgentProfileService); - if (binding !== undefined || profile.data().profileName === undefined) { + if (binding?.profile !== undefined || profile.data().profileName === undefined) { try { await profile.bind({ - profile: DEFAULT_AGENT_PROFILE_NAME, + profile: binding?.profile ?? DEFAULT_AGENT_PROFILE_NAME, model: binding?.model, thinking: binding?.thinking, }); } catch (error) { if ( - binding === undefined && + binding?.profile === undefined && error instanceof ProfileError && error.code === ProfileErrors.codes.MODEL_NOT_CONFIGURED ) { diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 5693ea3a5ad..e65b63a7886 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -36,11 +36,13 @@ import { foldAgentWireReplay } from '#/v2/resume-replay'; import { drainQueryStoreDisposals, drainSessionIndexMirror, + ensureMainAgent, Error2, getLiveSessionById, HostProcessError, IAgentTodoService, IAgentLifecycleService, + IAgentProfileService, IAgentTowerService, IHostRequestHeaders, IMcpManagementService, @@ -1459,8 +1461,136 @@ describe('removeProviderFromConfig', () => { expect(next.secondaryModel).toEqual({ defaultModel: 'a/m1' }); }); + + it('binds --agent disallowedTools on interactive createSession (v2 TUI path)', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await writeRestrictedAgentHome(homeDir); + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + const summary = await rpc.createSession({ + workDir, + model: 'kimi-test-model', + agentProfile: 'dev', + }); + const data = await mainAgentProfileData(rpc, summary.id); + expect(data.profileName).toBe('dev'); + expect(data.disallowedTools).toEqual(['Read', 'Write', 'Edit']); + expect(data.activeToolNames ?? []).not.toContain('Read'); + expect(data.activeToolNames ?? []).not.toContain('Write'); + expect(data.activeToolNames ?? []).not.toContain('Edit'); + } finally { + await rpc.close(); + } + }); + + it('binds an --agent-file profile that is not in user/project agent dirs', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await writeRestrictedAgentHome(homeDir); + const agentFilePath = join(workDir, 'explicit-only.md'); + await writeFile( + agentFilePath, + `--- +name: explicit-only +description: Agent-file-only profile for interactive --agent-file bind. +disallowedTools: + - Read + - Write + - Edit +--- + +You are an explicit agent-file-only profile. +`, + 'utf-8', + ); + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + const summary = await rpc.createSession({ + workDir, + model: 'kimi-test-model', + agentFiles: [agentFilePath], + }); + const data = await mainAgentProfileData(rpc, summary.id); + expect(data.profileName).toBe('explicit-only'); + expect(data.disallowedTools).toEqual(['Read', 'Write', 'Edit']); + } finally { + await rpc.close(); + } + }); + + it('rejects an unknown --agent profile at interactive session create', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await writeRestrictedAgentHome(homeDir); + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + await expect( + rpc.createSession({ + workDir, + model: 'kimi-test-model', + agentProfile: 'no-such-agent', + }), + ).rejects.toMatchObject({ code: 'profile.unknown' }); + expect(await sessionDirExists(homeDir, 'ses_unused')).toBe(false); + } finally { + await rpc.close(); + } + }); }); +async function writeRestrictedAgentHome(homeDir: string): Promise { + await writeFile( + join(homeDir, 'config.toml'), + ` +[providers.local] +type = "kimi" +base_url = "https://example.test/v1" +api_key = "sk-test" + +[models."kimi-test-model"] +provider = "local" +model = "kimi-test-model" +max_context_size = 1000 + +default_model = "kimi-test-model" +`, + 'utf-8', + ); + const agentDir = join(homeDir, 'agents'); + await mkdir(agentDir, { recursive: true }); + await writeFile( + join(agentDir, 'dev.md'), + `--- +name: dev +description: Default agent with Read/Write/Edit disabled +disallowedTools: + - Read + - Write + - Edit +--- + +\${base_prompt} +`, + 'utf-8', + ); +} + +async function mainAgentProfileData(rpc: SDKRpcClientV2, sessionId: string) { + const session = getLiveSessionById(rpc.engineAccessor, sessionId); + if (session === undefined) throw new Error(`live session "${sessionId}" not found`); + const context = await ensureMainAgent(session); + const agent = session.accessor.get(IAgentLifecycleService).handleOf(context.agentId); + if (agent === undefined) throw new Error('main agent was not found'); + return agent.accessor.get(IAgentProfileService).data(); +} + async function writeSkill(dir: string, name: string): Promise { await mkdir(dir, { recursive: true }); await writeFile( join(dir, 'SKILL.md'),