feat(agent): allow blocked tool calls to terminate (#7715)

Closes #5998
This commit is contained in:
muyiyr 2026-08-06 23:14:48 +08:00 • committed by GitHub
parent 1532c99943
commit 1eb988cfe8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 194 additions and 7 deletions

View file

@ -119,9 +119,9 @@ In parallel mode, tool completion events follow tool completion order, but persi
The mode can be set globally via `toolExecution` in the agent config, or per-tool via `executionMode` on `AgentTool`. If any tool call in a batch targets a tool with `executionMode: "sequential"`, the entire batch executes sequentially regardless of the global setting.
The `beforeToolCall` hook runs after `tool_execution_start` and validated argument parsing. It can block execution. The `afterToolCall` hook runs after tool execution finishes and before `tool_execution_end` and final tool result message events are emitted.
The `beforeToolCall` hook runs after `tool_execution_start` and validated argument parsing. It can block execution and attach `terminate: true` to the blocked result. The `afterToolCall` hook runs after tool execution finishes and before `tool_execution_end` and final tool result message events are emitted.
Tools can also return `terminate: true` to hint that the automatic follow-up LLM call should be skipped. The loop only stops early when every finalized tool result in that batch sets `terminate: true`. Mixed batches continue normally.
Tools, blocked `beforeToolCall` results, and `afterToolCall` overrides can return `terminate: true` to hint that the automatic follow-up LLM call should be skipped. The loop only stops early when every finalized tool result in that batch sets `terminate: true`. Mixed batches continue normally.
The `Agent` class accepts `shouldStopAfterTurn` in `AgentOptions`. Low-level loop callers can set the same hook in `AgentLoopConfig`:
@ -213,7 +213,7 @@ const agent = new Agent({
// Preflight each tool call after args are validated. Can block execution.
beforeToolCall: async ({ toolCall, args, context }) => {
if (toolCall.name === "bash") {
return { block: true, reason: "bash is disabled" };
return { block: true, reason: "bash is disabled", terminate: true };
}
},
@ -453,7 +453,7 @@ execute: async (toolCallId, params, signal, onUpdate) => {
Thrown errors are caught by the agent and reported to the LLM as tool errors with `isError: true`.
Return `terminate: true` from `execute()` or `afterToolCall` to hint that the agent should stop after the current tool batch. This only takes effect when every finalized tool result in the batch is terminating. The hint is runtime-only; emitted `toolResult` transcript messages remain standard LLM tool results.
Return `terminate: true` from `execute()`, a blocked `beforeToolCall`, or `afterToolCall` to hint that the agent should stop after the current tool batch. This only takes effect when every finalized tool result in the batch is terminating. The hint is runtime-only; emitted `toolResult` transcript messages remain standard LLM tool results.
## Proxy Usage

View file

@ -634,9 +634,13 @@ async function prepareToolCall(
};
}
if (beforeResult?.block) {
const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked");
if (beforeResult.terminate === true) {
result.terminate = true;
}
return {
kind: "immediate",
result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"),
result,
isError: true,
};
}

View file

@ -61,6 +61,11 @@ export type AgentToolCall = Extract<AssistantMessage["content"][number], { type:
export interface BeforeToolCallResult {
block?: boolean;
reason?: string;
/**
* Hint that the agent should stop after the current tool batch when this call is blocked.
* Early termination only happens when every finalized tool result in the batch sets this to true.
*/
terminate?: boolean;
}
/**
@ -266,6 +271,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
* Called before a tool is executed, after arguments have been validated.
*
* Return `{ block: true }` to prevent execution. The loop emits an error tool result instead.
* A blocked result can also set `terminate: true` to participate in the batch early-termination rule.
* The hook receives the agent abort signal and is responsible for honoring it.
*/
beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;

View file

@ -1250,6 +1250,124 @@ describe("agentLoop with AgentMessage", () => {
expect(events.filter((event) => event.type === "turn_end")).toHaveLength(1);
});
it("should stop after a blocked tool call when beforeToolCall sets terminate=true", async () => {
const toolSchema = Type.Object({ value: Type.String() });
let executed = false;
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute() {
executed = true;
return {
content: [{ type: "text", text: "should not execute" }],
details: { value: "unexpected" },
};
},
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
beforeToolCall: async () => ({ block: true, reason: "Blocked by policy", terminate: true }),
};
let llmCalls = 0;
const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, () => {
llmCalls++;
const mockStream = new MockAssistantStream();
queueMicrotask(() => {
const message =
llmCalls === 1
? createAssistantMessage(
[{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }],
"toolUse",
)
: createAssistantMessage([{ type: "text", text: "should not run" }]);
mockStream.push({ type: "done", reason: llmCalls === 1 ? "toolUse" : "stop", message });
});
return mockStream;
});
for await (const _event of stream) {
// consume
}
const messages = await stream.result();
const toolResult = messages.find((message) => message.role === "toolResult");
expect(executed).toBe(false);
expect(llmCalls).toBe(1);
expect(toolResult?.role === "toolResult" ? toolResult.isError : false).toBe(true);
expect(toolResult?.role === "toolResult" ? toolResult.content : []).toContainEqual({
type: "text",
text: "Blocked by policy",
});
});
it("should continue after a mixed batch with one terminating blocked call", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
toolExecution: "parallel",
beforeToolCall: async ({ args }) => {
const { value } = args as { value: string };
return value === "first" ? { block: true, reason: "Blocked first", terminate: true } : undefined;
},
};
let llmCalls = 0;
const stream = agentLoop([createUserMessage("echo both")], context, config, undefined, () => {
llmCalls++;
const mockStream = new MockAssistantStream();
queueMicrotask(() => {
const message =
llmCalls === 1
? createAssistantMessage(
[
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
],
"toolUse",
)
: createAssistantMessage([{ type: "text", text: "done" }]);
mockStream.push({ type: "done", reason: llmCalls === 1 ? "toolUse" : "stop", message });
});
return mockStream;
});
for await (const _event of stream) {
// consume
}
expect(executed).toEqual(["second"]);
expect(llmCalls).toBe(2);
});
it("should continue after parallel tool calls when not all tool results terminate", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const tool: AgentTool<typeof toolSchema, { value: string }> = {

View file

@ -762,7 +762,8 @@ Behavior guarantees:
- Mutations to `event.input` affect the actual tool execution
- Later `tool_call` handlers see mutations made by earlier handlers
- No re-validation is performed after your mutation
- Return values from `tool_call` only control blocking via `{ block: true, reason?: string }`
- Return values from `tool_call` control blocking via `{ block: true, reason?: string, terminate?: boolean }`
- `terminate` only applies to a blocked call; the agent stops early only when every finalized result in the batch is terminating
```typescript
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
@ -778,7 +779,7 @@ pi.on("tool_call", async (event, ctx) => {
event.input.command = `source ~/.profile\n${event.input.command}`;
if (event.input.command.includes("rm -rf")) {
return { block: true, reason: "Dangerous command" };
return { block: true, reason: "Dangerous command", terminate: true };
}
}

View file

@ -1072,6 +1072,11 @@ export interface ToolCallEventResult {
/** Block tool execution. To modify arguments, mutate `event.input` in place instead. */
block?: boolean;
reason?: string;
/**
* Hint that the agent should stop after the current tool batch when this call is blocked.
* Early termination only happens when every finalized tool result in the batch sets this to true.
*/
terminate?: boolean;
}
/** Result from user_bash event handler */

View file

@ -0,0 +1,53 @@
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import { createHarness, getAssistantTexts, type Harness } from "../harness.ts";
describe("#5998 blocked tool termination", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("lets a tool_call handler terminate the run after blocking execution", async () => {
const echoTool: AgentTool = {
name: "echo",
label: "Echo",
description: "Echo text back",
parameters: Type.Object({ text: Type.String() }),
execute: async () => {
throw new Error("tool should have been blocked");
},
};
const harness = await createHarness({
tools: [echoTool],
extensionFactories: [
(pi) => {
pi.on("tool_call", async () => ({
block: true,
reason: "Blocked by terminating policy",
terminate: true,
}));
},
],
});
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
fauxAssistantMessage("should not run"),
]);
await harness.session.prompt("hi");
expect(harness.getPendingResponseCount()).toBe(1);
expect(getAssistantTexts(harness)).not.toContain("should not run");
expect(harness.eventsOfType("tool_execution_end")[0]?.result).toHaveProperty("terminate", true);
expect(
harness.session.messages.find((message) => message.role === "toolResult" && message.isError),
).toBeDefined();
});
});