Skip to content
Merged
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/btw-readonly-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add read-only tools to the /btw side agent.
2 changes: 1 addition & 1 deletion docs/en/reference/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -751,7 +751,7 @@ On success, `data` is `{ aborted: true }`.

#### `POST /api/v1/sessions/{session_id}:btw`

Starts a "by the way" side conversation: forks the main agent into a child agent whose tool calls are disabled, so quick side questions run in isolation without touching the working context. Requires a usable model configuration.
Starts a "by the way" side conversation: forks the main agent into a child agent whose tool calls are limited to the read-only tools `Read`, `Grep`, and `Glob`, so quick side questions run in isolation without touching the working context. Requires a usable model configuration.

On success, `data` is `{ agent_id }` — the id of the new child agent.

Expand Down
2 changes: 1 addition & 1 deletion docs/zh/reference/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -751,7 +751,7 @@ schema 还接受 `agent_config` 内的 `system_prompt`、`tools`、`mcp_servers`

#### `POST /api/v1/sessions/{session_id}:btw`

开启一个 `"by the way"` 旁路对话:把 main agent fork 成一个禁用工具调用的子 Agent,让快速的临时问题在隔离环境中运行,不触碰工作上下文。需要可用的模型配置。
开启一个 `"by the way"` 旁路对话:把 main agent fork 成一个仅可使用只读工具(`Read`、`Grep`、`Glob`)的子 Agent,让快速的临时问题在隔离环境中运行,不触碰工作上下文。需要可用的模型配置。

成功时,`data` 为 `{ agent_id }`——新子 Agent 的 id。

Expand Down
17 changes: 10 additions & 7 deletions packages/agent-core-v2/src/features/btw/btw.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';

export const BTW_READONLY_TOOLS = new Set(['Read', 'Grep', 'Glob']);

export const TOOL_CALL_DISABLED_MESSAGE =
'Tool calls are disabled for side questions. Answer with text only.';
'Only the read-only tools Read, Grep, and Glob are available for side questions. Other tool calls are disabled.';

export const SIDE_QUESTION_SYSTEM_REMINDER = `
This is a side-channel conversation with the user. You should answer user questions directly based on what you already know.
This is a side-channel conversation with the user. You should answer user questions directly.

IMPORTANT:
- You are a separate, lightweight instance.
- The main agent continues independently; do not reference being interrupted.
- Do not call any tools. All tool calls are disabled and will be rejected.
Even though tool definitions are visible in this request, they exist only
for technical reasons (prompt cache). You must not use them.
- Respond only with text based on what you already know from the conversation
and this side-channel conversation.
- You may use the read-only tools Read, Grep, and Glob to inspect files when
the answer depends on current file contents. All other tools are disabled
and will be rejected, even though their definitions are visible in this
request (they exist only for technical reasons — prompt cache).
- Prefer answering from what you already know from the conversation and this
side-channel conversation; reach for the read-only tools only when needed.
- Follow-up turns may happen in this side-channel conversation.
- If you do not know the answer, say so directly.
`.trim();
Expand Down
11 changes: 9 additions & 2 deletions packages/agent-core-v2/src/features/btw/btwService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { ErrorCodes, Error2 } from '#/errors';
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';

import { ISessionBtwService, SIDE_QUESTION_SYSTEM_REMINDER, TOOL_CALL_DISABLED_MESSAGE } from './btw';
import {
BTW_READONLY_TOOLS,
ISessionBtwService,
SIDE_QUESTION_SYSTEM_REMINDER,
TOOL_CALL_DISABLED_MESSAGE,
} from './btw';

export class SessionBtwService implements ISessionBtwService {
declare readonly _serviceBrand: undefined;
Expand All @@ -32,7 +37,9 @@ export class SessionBtwService implements ISessionBtwService {
child.accessor
.get(IAgentToolExecutorService)
?.onBeforeExecuteTool((event) => {
event.veto(denyToolExecution(reason));
if (!BTW_READONLY_TOOLS.has(event.toolCall.name)) {
event.veto(denyToolExecution(reason));
}
});
return childContext.agentId;
}
Expand Down
54 changes: 38 additions & 16 deletions packages/agent-core-v2/test/features/btw/btw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { TestInstantiationService } from '#/_base/di/test';
import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import {
BTW_READONLY_TOOLS,
ISessionBtwService,
SIDE_QUESTION_SYSTEM_REMINDER,
TOOL_CALL_DISABLED_MESSAGE,
Expand Down Expand Up @@ -86,26 +87,47 @@ describe('SessionBtwService', () => {
});
});

it('vetoes every tool call on the child through the btw deny listener', async () => {
it('vetoes non-read-only tool calls on the child through the btw deny listener', async () => {
const svc = ix.get(ISessionBtwService);
await svc.start();

const toolCall: ToolCall = { type: 'function', id: 'call_1', name: 'Bash', arguments: '{}' };
const decision = await executorEvents.fireBeforeExecute({
turnId: 0,
signal: new AbortController().signal,
toolCall,
toolCalls: [toolCall],
args: {},
execution: { approvalRule: 'Bash', execute: async () => ({ output: '' }) },
});
for (const name of ['Bash', 'Write', 'Edit']) {
const toolCall: ToolCall = { type: 'function', id: `call_${name}`, name, arguments: '{}' };
const decision = await executorEvents.fireBeforeExecute({
turnId: 0,
signal: new AbortController().signal,
toolCall,
toolCalls: [toolCall],
args: {},
execution: { approvalRule: name, execute: async () => ({ output: '' }) },
});

expect(decision).toEqual({
veto: {
output: `${TOOL_CALL_DISABLED_MESSAGE} [worker guidance]`,
isError: true,
},
});
expect(decision).toEqual({
veto: {
output: `${TOOL_CALL_DISABLED_MESSAGE} [worker guidance]`,
isError: true,
},
});
}
expect(formatDenyMessage).toHaveBeenCalledWith(TOOL_CALL_DISABLED_MESSAGE);
});

it('allows read-only tool calls (Read, Grep, Glob) on the child', async () => {
const svc = ix.get(ISessionBtwService);
await svc.start();

for (const name of BTW_READONLY_TOOLS) {
const toolCall: ToolCall = { type: 'function', id: `call_${name}`, name, arguments: '{}' };
const decision = await executorEvents.fireBeforeExecute({
turnId: 0,
signal: new AbortController().signal,
toolCall,
toolCalls: [toolCall],
args: {},
execution: { approvalRule: name, execute: async () => ({ output: '' }) },
});

expect(decision).toBeUndefined();
}
});
});
Loading