mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
fix: require AgentSwarm to run alone (#643)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
This commit is contained in:
parent
30459af6ab
commit
4e5043b03b
14 changed files with 303 additions and 15 deletions
6
.changeset/agent-swarm-exclusive-call.md
Normal file
6
.changeset/agent-swarm-exclusive-call.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@moonshot-ai/agent-core": patch
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Require AgentSwarm tool calls to run alone in a model response.
|
||||
|
|
@ -91,7 +91,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill
|
|||
|
||||
**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), and `run_in_background` (defaults to false). Agent tasks have a fixed 30-minute timeout. In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details.
|
||||
|
||||
**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported.
|
||||
**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. If a model response calls `AgentSwarm`, that call must be the only tool call in the response; to run multiple swarms, call one `AgentSwarm`, wait for its result, then call the next, or combine the work into one swarm when a single template can cover it. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported.
|
||||
|
||||
**`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead.
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只
|
|||
|
||||
**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)和 `run_in_background`(默认 false)。Agent 任务使用固定 30 分钟超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个子 Agent 显示运行、等待、完成或失败状态以及已耗时长。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。
|
||||
|
||||
**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。
|
||||
**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。
|
||||
|
||||
**`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false)。系统自动附加"其他"选项。`background` 为 true 时启动后台问题任务并立即返回任务 ID。宿主未实现交互式提问能力时返回失败提示,Agent 应改为在文本回复中直接提问。
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types';
|
||||
|
||||
export class AgentSwarmExclusiveDenyPermissionPolicy implements PermissionPolicy {
|
||||
readonly name = 'agent-swarm-exclusive-deny';
|
||||
|
||||
evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined {
|
||||
const toolCalls = context.toolCalls;
|
||||
const agentSwarmCount = toolCalls.filter(
|
||||
(toolCall) => toolCall.name === 'AgentSwarm',
|
||||
).length;
|
||||
|
||||
if (agentSwarmCount === 0) return;
|
||||
if (agentSwarmCount === 1 && toolCalls.length === 1) return;
|
||||
|
||||
return {
|
||||
kind: 'deny',
|
||||
message:
|
||||
agentSwarmCount > 1
|
||||
? multipleAgentSwarmDeniedMessage(toolCalls.length > agentSwarmCount)
|
||||
: mixedAgentSwarmDeniedMessage(),
|
||||
reason: {
|
||||
agent_swarm_tool_calls: agentSwarmCount,
|
||||
tool_calls: toolCalls.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function multipleAgentSwarmDeniedMessage(hasOtherToolCalls: boolean): string {
|
||||
const suffix = hasOtherToolCalls
|
||||
? ' AgentSwarm also must not be combined with other tools in the same response.'
|
||||
: '';
|
||||
return (
|
||||
'AgentSwarm must be called one swarm at a time. Multiple AgentSwarm calls are not forbidden, ' +
|
||||
'but issue them sequentially: call one AgentSwarm, wait for its result, then call the next; ' +
|
||||
`or merge the work into a single AgentSwarm when one swarm can cover it.${suffix}`
|
||||
);
|
||||
}
|
||||
|
||||
function mixedAgentSwarmDeniedMessage(): string {
|
||||
return (
|
||||
'AgentSwarm must be the only tool call in a model response. Retry with a single AgentSwarm ' +
|
||||
'call by itself, then call any other tools after it returns.'
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Agent } from '../..';
|
||||
import type { PermissionPolicy } from '../types';
|
||||
import { AgentSwarmExclusiveDenyPermissionPolicy } from './agent-swarm-exclusive-deny';
|
||||
import { AutoModeApprovePermissionPolicy } from './auto-mode-approve';
|
||||
import { AutoModeAskUserQuestionDenyPermissionPolicy } from './auto-mode-ask-user-question-deny';
|
||||
import { DefaultToolApprovePermissionPolicy } from './default-tool-approve';
|
||||
|
|
@ -27,6 +28,8 @@ export function createPermissionDecisionPolicies(agent: Agent): PermissionPolicy
|
|||
return [
|
||||
// PreToolUse hook returned a block → deny.
|
||||
new PreToolCallHookPermissionPolicy(agent),
|
||||
// AgentSwarm is batch-exclusive and must run alone, regardless of permission mode.
|
||||
new AgentSwarmExclusiveDenyPermissionPolicy(),
|
||||
// auto mode + AskUserQuestion → deny.
|
||||
new AutoModeAskUserQuestionDenyPermissionPolicy(agent),
|
||||
// plan mode: Write/Edit outside the plan file, or TaskStop → deny.
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@ export interface ToolCallStepContext {
|
|||
readonly stepUuid: string;
|
||||
}
|
||||
|
||||
interface ToolCallBatchContext extends ToolCallStepContext {
|
||||
readonly toolCalls: readonly ToolCall[];
|
||||
}
|
||||
|
||||
type PreflightedToolCall = RunnableToolCall | RejectedToolCall;
|
||||
|
||||
interface RunnableToolCall {
|
||||
|
|
@ -120,6 +124,7 @@ export async function runToolCallBatch(
|
|||
response: LLMChatResponse,
|
||||
): Promise<ToolCallBatchResult> {
|
||||
if (response.toolCalls.length === 0) return { stopTurn: false };
|
||||
const batchStep: ToolCallBatchContext = { ...step, toolCalls: response.toolCalls };
|
||||
const calls = response.toolCalls.map((toolCall) => preflightToolCall(step.tools, toolCall));
|
||||
const scheduler = new ToolScheduler<PendingToolResult>();
|
||||
const pendingResults: Array<Promise<PendingToolResult>> = [];
|
||||
|
|
@ -128,13 +133,13 @@ export async function runToolCallBatch(
|
|||
try {
|
||||
for (let index = 0; index < calls.length; index += 1) {
|
||||
const call = calls[index]!;
|
||||
const prepared = await prepareToolCall(step, call);
|
||||
const prepared = await prepareToolCall(batchStep, call);
|
||||
pendingResults.push(scheduler.add(prepared.task));
|
||||
|
||||
if (prepared.stopBatchAfterThis === true) {
|
||||
stopTurn = true;
|
||||
for (const skippedCall of calls.slice(index + 1)) {
|
||||
const skippedTask = await prepareSkippedToolCall(step, skippedCall);
|
||||
const skippedTask = await prepareSkippedToolCall(batchStep, skippedCall);
|
||||
pendingResults.push(scheduler.add(skippedTask));
|
||||
}
|
||||
break;
|
||||
|
|
@ -145,7 +150,7 @@ export async function runToolCallBatch(
|
|||
// provider order. Await all tasks so each recorded `tool.call` gets a
|
||||
// paired `tool.result`; the caller checks abort before writing `step.end`.
|
||||
for (const pendingResult of pendingResults) {
|
||||
const result = await finalizePendingToolResult(step, await pendingResult);
|
||||
const result = await finalizePendingToolResult(batchStep, await pendingResult);
|
||||
if (result.stopTurn === true) stopTurn = true;
|
||||
await step.dispatchEvent({
|
||||
type: 'tool.result',
|
||||
|
|
@ -235,7 +240,7 @@ function validateExecutableToolArgs(tool: ExecutableTool, args: unknown): string
|
|||
}
|
||||
|
||||
async function prepareToolCall(
|
||||
step: ToolCallStepContext,
|
||||
step: ToolCallBatchContext,
|
||||
call: PreflightedToolCall,
|
||||
): Promise<PreparedToolCallTask> {
|
||||
const settleError = async (
|
||||
|
|
@ -336,7 +341,7 @@ async function prepareToolCall(
|
|||
}
|
||||
|
||||
async function prepareSkippedToolCall(
|
||||
step: ToolCallStepContext,
|
||||
step: ToolCallBatchContext,
|
||||
call: PreflightedToolCall,
|
||||
): Promise<ToolCallTask<PendingToolResult>> {
|
||||
const output = 'Tool skipped because a previous tool call stopped the turn.';
|
||||
|
|
@ -356,7 +361,7 @@ function makeResolvedToolCallTask(result: PendingToolResult): ToolCallTask<Pendi
|
|||
* Hook decisions can block a call or replace args before execution starts.
|
||||
*/
|
||||
async function runPrepareToolExecutionHook(
|
||||
step: ToolCallStepContext,
|
||||
step: ToolCallBatchContext,
|
||||
call: RunnableToolCall,
|
||||
): Promise<PrepareToolExecutionDecision> {
|
||||
const { hooks, signal, turnId, currentStep, llm } = step;
|
||||
|
|
@ -370,6 +375,7 @@ async function runPrepareToolExecutionHook(
|
|||
try {
|
||||
hookResult = await hooks.prepareToolExecution({
|
||||
toolCall,
|
||||
toolCalls: step.toolCalls,
|
||||
tool: call.tool,
|
||||
args,
|
||||
turnId,
|
||||
|
|
@ -411,7 +417,7 @@ async function runPrepareToolExecutionHook(
|
|||
}
|
||||
|
||||
async function runAuthorizeToolExecutionHook(
|
||||
step: ToolCallStepContext,
|
||||
step: ToolCallBatchContext,
|
||||
call: RunnableToolCall,
|
||||
args: unknown,
|
||||
execution: RunnableToolExecution,
|
||||
|
|
@ -422,6 +428,7 @@ async function runAuthorizeToolExecutionHook(
|
|||
try {
|
||||
return await hooks.authorizeToolExecution({
|
||||
toolCall: call.toolCall,
|
||||
toolCalls: step.toolCalls,
|
||||
tool: call.tool,
|
||||
args,
|
||||
execution,
|
||||
|
|
@ -493,7 +500,7 @@ async function runRunnableToolCall(
|
|||
}
|
||||
|
||||
async function finalizePendingToolResult(
|
||||
step: ToolCallStepContext,
|
||||
step: ToolCallBatchContext,
|
||||
pendingResult: PendingToolResult,
|
||||
): Promise<PendingToolResult> {
|
||||
const { hooks, signal, turnId, currentStep, llm } = step;
|
||||
|
|
@ -504,6 +511,7 @@ async function finalizePendingToolResult(
|
|||
try {
|
||||
const finalizedResult = await hooks.finalizeToolResult({
|
||||
toolCall: pendingResult.toolCall,
|
||||
toolCalls: step.toolCalls,
|
||||
args: pendingResult.args,
|
||||
result: pendingResult.result,
|
||||
turnId,
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ export interface LoopStepHookContext {
|
|||
|
||||
export interface ToolExecutionHookContext extends LoopStepHookContext {
|
||||
readonly toolCall: ToolCall;
|
||||
readonly toolCalls: readonly ToolCall[];
|
||||
readonly tool?: ExecutableTool | undefined;
|
||||
readonly args: unknown;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,3 +5,5 @@ Use AgentSwarm when many subagents should run the same kind of task over differe
|
|||
Use `resume_agent_ids` to continue subagents that already exist from earlier work, such as ones that failed or timed out: map each agent id to the prompt for that resumed subagent (usually `continue` if no extra information is needed). You may combine `resume_agent_ids` with `items` in the same call to resume existing subagents and launch new ones. Do not duplicate resumed work in `items`.
|
||||
|
||||
Use enough subagents to keep the work focused and parallel. AgentSwarm supports up to 128 subagents, and launches are queued automatically, so it is safe to split large tasks into many clear, independent items.
|
||||
|
||||
If `AgentSwarm` is called, that call must be the only tool call in the response.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
parsePattern,
|
||||
type PermissionRuleMatchExecution,
|
||||
} from '../../src/agent/permission/matches-rule';
|
||||
import { AgentSwarmExclusiveDenyPermissionPolicy } from '../../src/agent/permission/policies/agent-swarm-exclusive-deny';
|
||||
import { AutoModeApprovePermissionPolicy } from '../../src/agent/permission/policies/auto-mode-approve';
|
||||
import { AutoModeAskUserQuestionDenyPermissionPolicy } from '../../src/agent/permission/policies/auto-mode-ask-user-question-deny';
|
||||
import { FallbackAskPermissionPolicy } from '../../src/agent/permission/policies/fallback-ask';
|
||||
|
|
@ -697,6 +698,7 @@ describe('Permission policy chain', () => {
|
|||
it('keeps built-in policies in document order', () => {
|
||||
expect(createPermissionDecisionPolicies({} as Agent).map((policy) => policy.name)).toEqual([
|
||||
'pre-tool-call-hook',
|
||||
'agent-swarm-exclusive-deny',
|
||||
'auto-mode-ask-user-question-deny',
|
||||
'plan-mode-guard-deny',
|
||||
'user-configured-deny',
|
||||
|
|
@ -716,6 +718,42 @@ describe('Permission policy chain', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('denies invalid AgentSwarm batches before auto-mode approval', async () => {
|
||||
const { manager, requestApproval, telemetryTrack } = makePermissionManager(async () => ({
|
||||
decision: 'approved',
|
||||
}));
|
||||
manager.mode = 'auto';
|
||||
const agentSwarmCall = toolCall('call_agent_swarm', 'AgentSwarm', {
|
||||
description: 'Review files',
|
||||
prompt_template: 'Review {{item}}',
|
||||
items: ['src/a.ts', 'src/b.ts'],
|
||||
});
|
||||
const readCall = toolCall('call_read', 'Read', { path: 'src/a.ts' });
|
||||
|
||||
await expect(
|
||||
manager.beforeToolCall(
|
||||
hookContext({
|
||||
id: 'call_agent_swarm',
|
||||
toolName: 'AgentSwarm',
|
||||
toolCalls: [agentSwarmCall, readCall],
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
block: true,
|
||||
reason: expect.stringContaining('AgentSwarm must be the only tool call'),
|
||||
});
|
||||
|
||||
expect(requestApproval).not.toHaveBeenCalled();
|
||||
expect(telemetryTrack).toHaveBeenCalledWith(
|
||||
'permission_policy_decision',
|
||||
expect.objectContaining({
|
||||
policy_name: 'agent-swarm-exclusive-deny',
|
||||
tool_name: 'AgentSwarm',
|
||||
permission_mode: 'auto',
|
||||
decision: 'deny',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Simple permission policy direct behavior', () => {
|
||||
|
|
@ -788,6 +826,99 @@ describe('Simple permission policy direct behavior', () => {
|
|||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('denies AgentSwarm mixed with other tool calls in the same response', () => {
|
||||
const policy = new AgentSwarmExclusiveDenyPermissionPolicy();
|
||||
const agentSwarmCall = toolCall('call_agent_swarm', 'AgentSwarm', {
|
||||
description: 'Review files',
|
||||
prompt_template: 'Review {{item}}',
|
||||
items: ['src/a.ts', 'src/b.ts'],
|
||||
});
|
||||
const readCall = toolCall('call_read', 'Read', { path: 'src/a.ts' });
|
||||
|
||||
expect(
|
||||
policy.evaluate(
|
||||
hookContext({
|
||||
id: 'call_agent_swarm',
|
||||
toolName: 'AgentSwarm',
|
||||
toolCalls: [agentSwarmCall, readCall],
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
kind: 'deny',
|
||||
message: expect.stringContaining('AgentSwarm must be the only tool call'),
|
||||
reason: {
|
||||
agent_swarm_tool_calls: 1,
|
||||
tool_calls: 2,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
policy.evaluate(
|
||||
hookContext({
|
||||
id: 'call_read',
|
||||
toolName: 'Read',
|
||||
args: { path: 'src/a.ts' },
|
||||
toolCalls: [agentSwarmCall, readCall],
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ kind: 'deny' });
|
||||
});
|
||||
|
||||
it('denies multiple AgentSwarm calls with one-at-a-time guidance', () => {
|
||||
const policy = new AgentSwarmExclusiveDenyPermissionPolicy();
|
||||
const first = toolCall('call_agent_swarm_1', 'AgentSwarm', {
|
||||
description: 'Review files',
|
||||
prompt_template: 'Review {{item}}',
|
||||
items: ['src/a.ts', 'src/b.ts'],
|
||||
});
|
||||
const second = toolCall('call_agent_swarm_2', 'AgentSwarm', {
|
||||
description: 'Review tests',
|
||||
prompt_template: 'Review {{item}}',
|
||||
items: ['test/a.ts', 'test/b.ts'],
|
||||
});
|
||||
|
||||
const result = policy.evaluate(
|
||||
hookContext({
|
||||
id: 'call_agent_swarm_1',
|
||||
toolName: 'AgentSwarm',
|
||||
toolCalls: [first, second],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'deny',
|
||||
message: expect.stringContaining('Multiple AgentSwarm calls are not forbidden'),
|
||||
reason: {
|
||||
agent_swarm_tool_calls: 2,
|
||||
tool_calls: 2,
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
message: expect.stringContaining('call one AgentSwarm, wait for its result'),
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
message: expect.stringContaining('merge the work into a single AgentSwarm'),
|
||||
});
|
||||
});
|
||||
|
||||
it('allows a single AgentSwarm call for later permission policies', () => {
|
||||
const policy = new AgentSwarmExclusiveDenyPermissionPolicy();
|
||||
const agentSwarmCall = toolCall('call_agent_swarm', 'AgentSwarm', {
|
||||
description: 'Review files',
|
||||
prompt_template: 'Review {{item}}',
|
||||
items: ['src/a.ts', 'src/b.ts'],
|
||||
});
|
||||
|
||||
expect(
|
||||
policy.evaluate(
|
||||
hookContext({
|
||||
id: 'call_agent_swarm',
|
||||
toolName: 'AgentSwarm',
|
||||
toolCalls: [agentSwarmCall],
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('always asks in FallbackAskPermissionPolicy', () => {
|
||||
const policy = new FallbackAskPermissionPolicy();
|
||||
|
||||
|
|
@ -3655,6 +3786,7 @@ function hookContext(input: {
|
|||
readonly toolName?: string | undefined;
|
||||
readonly args?: Record<string, unknown> | undefined;
|
||||
readonly execution?: PermissionPolicyContext['execution'] | undefined;
|
||||
readonly toolCalls?: readonly ToolCall[] | undefined;
|
||||
}): PermissionPolicyContext {
|
||||
const toolName = input.toolName ?? 'Bash';
|
||||
const args = input.args ?? { command: 'printf first', timeout: 60 };
|
||||
|
|
@ -3662,7 +3794,7 @@ function hookContext(input: {
|
|||
type: 'function',
|
||||
id: input.id,
|
||||
name: toolName,
|
||||
arguments: JSON.stringify(args),
|
||||
arguments: JSON.stringify(args),
|
||||
};
|
||||
return {
|
||||
turnId: '0',
|
||||
|
|
@ -3670,11 +3802,21 @@ function hookContext(input: {
|
|||
signal: new AbortController().signal,
|
||||
llm: {} as PermissionPolicyContext['llm'],
|
||||
toolCall,
|
||||
toolCalls: input.toolCalls ?? [toolCall],
|
||||
args,
|
||||
execution: input.execution ?? testExecution(toolName, args),
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id: string, name: string, args: Record<string, unknown>): ToolCall {
|
||||
return {
|
||||
type: 'function',
|
||||
id,
|
||||
name,
|
||||
arguments: JSON.stringify(args),
|
||||
};
|
||||
}
|
||||
|
||||
function permissionRule(
|
||||
pattern: string,
|
||||
decision: PermissionRule['decision'] = 'allow',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@ function policyContext(toolName: string, args: unknown): PermissionPolicyContext
|
|||
name: toolName,
|
||||
arguments: JSON.stringify(args),
|
||||
} satisfies ToolCall,
|
||||
toolCalls: [
|
||||
{
|
||||
type: 'function',
|
||||
id: `call_${toolName}`,
|
||||
name: toolName,
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
],
|
||||
execution: {
|
||||
accesses: ToolAccesses.none(),
|
||||
approvalRule: toolName,
|
||||
|
|
|
|||
|
|
@ -299,6 +299,7 @@ function _typeOnlyChecks(): void {
|
|||
const toolCallHookContext: ToolExecutionHookContext = {
|
||||
...stepHookContext,
|
||||
toolCall: { type: 'function', id: 'tc1', name: 'echo', arguments: '{}' },
|
||||
toolCalls: [{ type: 'function', id: 'tc1', name: 'echo', arguments: '{}' }],
|
||||
args: {},
|
||||
};
|
||||
void toolCallHookContext;
|
||||
|
|
@ -306,6 +307,7 @@ function _typeOnlyChecks(): void {
|
|||
const _badToolExecutionHookContext: ToolExecutionHookContext = {
|
||||
...stepHookContext,
|
||||
toolCall: { type: 'function', id: 'tc1', name: 'echo', arguments: '{}' },
|
||||
toolCalls: [{ type: 'function', id: 'tc1', name: 'echo', arguments: '{}' }],
|
||||
// @ts-expect-error — tool hooks receive `args`, not the old `input` field.
|
||||
input: {},
|
||||
};
|
||||
|
|
@ -484,6 +486,7 @@ function _typeOnlyChecks(): void {
|
|||
};
|
||||
const prepareToolExecutionHook: PrepareToolExecutionHook = async (ctx) => ({
|
||||
updatedArgs: ctx.args,
|
||||
executionMetadata: ctx.toolCalls,
|
||||
});
|
||||
const finalizeToolResultHook: FinalizeToolResultHook = async (ctx) => ctx.result;
|
||||
const shouldContinueAfterStopHook: ShouldContinueAfterStopHook = async (ctx) => ({
|
||||
|
|
|
|||
|
|
@ -161,6 +161,32 @@ describe('runTurn — afterStep hook', () => {
|
|||
});
|
||||
|
||||
describe('runTurn — prepareToolExecution hook', () => {
|
||||
it('receives all tool calls from the same provider response', async () => {
|
||||
const observedBatches: string[][] = [];
|
||||
const hooks: LoopHooks = {
|
||||
prepareToolExecution: async (ctx) => {
|
||||
observedBatches.push(ctx.toolCalls?.map((toolCall) => toolCall.id) ?? []);
|
||||
},
|
||||
};
|
||||
|
||||
await runTurn({
|
||||
hooks,
|
||||
tools: [new EchoTool()],
|
||||
responses: [
|
||||
makeToolUseResponse([
|
||||
makeToolCall('echo', { text: 'a' }, 'tc-a'),
|
||||
makeToolCall('echo', { text: 'b' }, 'tc-b'),
|
||||
]),
|
||||
makeEndTurnResponse('done'),
|
||||
],
|
||||
});
|
||||
|
||||
expect(observedBatches).toEqual([
|
||||
['tc-a', 'tc-b'],
|
||||
['tc-a', 'tc-b'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('block:true records an error result without invoking execute', async () => {
|
||||
const echo = new EchoTool();
|
||||
const hooks: LoopHooks = {
|
||||
|
|
@ -245,6 +271,34 @@ describe('runTurn — prepareToolExecution hook', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('runTurn — authorizeToolExecution hook', () => {
|
||||
it('receives all tool calls from the same provider response', async () => {
|
||||
const observedBatches: string[][] = [];
|
||||
const hooks: LoopHooks = {
|
||||
authorizeToolExecution: async (ctx) => {
|
||||
observedBatches.push(ctx.toolCalls?.map((toolCall) => toolCall.id) ?? []);
|
||||
},
|
||||
};
|
||||
|
||||
await runTurn({
|
||||
hooks,
|
||||
tools: [new EchoTool()],
|
||||
responses: [
|
||||
makeToolUseResponse([
|
||||
makeToolCall('echo', { text: 'a' }, 'tc-a'),
|
||||
makeToolCall('echo', { text: 'b' }, 'tc-b'),
|
||||
]),
|
||||
makeEndTurnResponse('done'),
|
||||
],
|
||||
});
|
||||
|
||||
expect(observedBatches).toEqual([
|
||||
['tc-a', 'tc-b'],
|
||||
['tc-a', 'tc-b'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
class DescribedEchoTool extends EchoTool {
|
||||
override resolveExecution(args: EchoInput) {
|
||||
const execution = super.resolveExecution(args);
|
||||
|
|
|
|||
|
|
@ -35,15 +35,23 @@ function hookContext(toolName: string, args: unknown): ToolExecutionHookContext
|
|||
turnId: '0',
|
||||
stepNumber: 1,
|
||||
signal,
|
||||
llm: {},
|
||||
llm: {} as ToolExecutionHookContext['llm'],
|
||||
args,
|
||||
toolCall: {
|
||||
type: 'function',
|
||||
id: `call_${toolName}`,
|
||||
name: toolName,
|
||||
arguments: JSON.stringify(args),
|
||||
arguments: JSON.stringify(args),
|
||||
} satisfies ToolCall,
|
||||
} as ToolExecutionHookContext;
|
||||
toolCalls: [
|
||||
{
|
||||
type: 'function',
|
||||
id: `call_${toolName}`,
|
||||
name: toolName,
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function policyContext(
|
||||
|
|
|
|||
|
|
@ -102,8 +102,16 @@ function permissionContext(args: ExitPlanModeInput): PermissionPolicyContext {
|
|||
id: 'call_exit_plan',
|
||||
type: 'function',
|
||||
name: 'ExitPlanMode',
|
||||
arguments: JSON.stringify(args),
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call_exit_plan',
|
||||
type: 'function',
|
||||
name: 'ExitPlanMode',
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
],
|
||||
args,
|
||||
execution: {
|
||||
description: 'Presenting plan and exiting plan mode',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue