Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tui-agent-disallowed-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Honor a custom agent's tools and disallowedTools policy in interactive TUI sessions.
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/program/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
122 changes: 98 additions & 24 deletions packages/node-sdk/src/sdk-rpc-client-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` →
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -160,6 +161,8 @@ import {
drainSessionIndexMirror,
ensureKimiHome,
ensureMainAgent,
parseAgentFileText,
resolveAgentPath,
agentContextOf,
IAgentActivityView,
IAgentContextMemoryService,
Expand Down Expand Up @@ -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<SessionSummary> {
// An explicit id takes the per-session queue so the check-then-create
Expand All @@ -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,
});
Expand Down Expand Up @@ -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<string | undefined> {
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<void> {
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep explicit agent files scoped to each session

When two createSession calls with different agentFiles overlap, both mutate the App-shared IBootstrapService.args before awaiting an asynchronous workspace-loader reload. A reload can therefore read the other call's files, causing creation to fail with profile.unknown or, when the files define the same profile name, binding the wrong tools/disallowedTools policy. Sequential calls also replace or clear the workspace-wide explicit contribution even though agentFiles is documented as session-specific; pass the files through a session-scoped input rather than shared bootstrap state.

Useful? React with 👍 / 👎.

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<IAgentScopeHandle> {
await this.modelReady;
const context = await ensureMainAgent(session);
Expand All @@ -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
) {
Expand Down
130 changes: 130 additions & 0 deletions packages/node-sdk/test/sdk-rpc-client-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ import { foldAgentWireReplay } from '#/v2/resume-replay';
import {
drainQueryStoreDisposals,
drainSessionIndexMirror,
ensureMainAgent,
Error2,
getLiveSessionById,
HostProcessError,
IAgentTodoService,
IAgentLifecycleService,
IAgentProfileService,
IAgentTowerService,
IHostRequestHeaders,
IMcpManagementService,
Expand Down Expand Up @@ -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<void> {
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<void> { await mkdir(dir, { recursive: true });
await writeFile(
join(dir, 'SKILL.md'),
Expand Down