Map pause_turn to "pauseTurn" instead of "stop" so the agent loop continues when Anthropic's server pauses a long-running turn (e.g. native web search hitting its iteration limit). Previously the incomplete server_tool_use block was saved to history, causing a 400 invalid_request_error on the next API call. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
341a211be2
commit
2ea668ee09
6 changed files with 81 additions and 6 deletions
45
packages/pi-agent-core/src/agent-loop.test.ts
Normal file
45
packages/pi-agent-core/src/agent-loop.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// agent-loop pauseTurn handling tests
|
||||
// Verifies that pause_turn / pauseTurn stop reason causes the inner loop
|
||||
// to continue (re-invoke the LLM) instead of exiting.
|
||||
// Regression test for https://github.com/gsd-build/gsd-2/issues/2869
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe("agent-loop — pauseTurn handling (#2869)", () => {
|
||||
it("sets hasMoreToolCalls when stopReason is pauseTurn", () => {
|
||||
const source = readFileSync(join(__dirname, "agent-loop.ts"), "utf-8");
|
||||
|
||||
// The agent loop must treat pauseTurn as a reason to continue the inner
|
||||
// loop, just like toolUse. This prevents incomplete server_tool_use blocks
|
||||
// from being saved to history, which would cause a 400 on the next request.
|
||||
assert.match(
|
||||
source,
|
||||
/pauseTurn/,
|
||||
"agent-loop.ts must handle the pauseTurn stop reason",
|
||||
);
|
||||
|
||||
// Verify it sets hasMoreToolCalls = true for pauseTurn
|
||||
assert.match(
|
||||
source,
|
||||
/stopReason\s*===?\s*["']pauseTurn["']/,
|
||||
'agent-loop.ts must check for stopReason === "pauseTurn"',
|
||||
);
|
||||
});
|
||||
|
||||
it("pauseTurn is in the StopReason union type", () => {
|
||||
// Read the pi-ai types to ensure pauseTurn is a valid StopReason
|
||||
const typesPath = join(__dirname, "..", "..", "pi-ai", "src", "types.ts");
|
||||
const typesSource = readFileSync(typesPath, "utf-8");
|
||||
assert.match(
|
||||
typesSource,
|
||||
/["']pauseTurn["']/,
|
||||
'StopReason type must include "pauseTurn"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -231,9 +231,10 @@ async function runLoop(
|
|||
return;
|
||||
}
|
||||
|
||||
// Check for tool calls
|
||||
// Check for tool calls or paused server turn
|
||||
const toolCalls = message.content.filter((c) => c.type === "toolCall");
|
||||
hasMoreToolCalls = toolCalls.length > 0;
|
||||
hasMoreToolCalls =
|
||||
toolCalls.length > 0 || message.stopReason === "pauseTurn";
|
||||
|
||||
const toolResults: ToolResultMessage[] = [];
|
||||
if (hasMoreToolCalls && config.externalToolExecution) {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export type ProxyAssistantMessageEvent =
|
|||
| { type: "toolcall_end"; contentIndex: number }
|
||||
| {
|
||||
type: "done";
|
||||
reason: Extract<StopReason, "stop" | "length" | "toolUse">;
|
||||
reason: Extract<StopReason, "stop" | "length" | "toolUse" | "pauseTurn">;
|
||||
usage: AssistantMessage["usage"];
|
||||
}
|
||||
| {
|
||||
|
|
|
|||
29
packages/pi-ai/src/providers/anthropic-shared.test.ts
Normal file
29
packages/pi-ai/src/providers/anthropic-shared.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mapStopReason } from "./anthropic-shared.js";
|
||||
|
||||
describe("mapStopReason", () => {
|
||||
it("maps end_turn to stop", () => {
|
||||
assert.equal(mapStopReason("end_turn"), "stop");
|
||||
});
|
||||
|
||||
it("maps max_tokens to length", () => {
|
||||
assert.equal(mapStopReason("max_tokens"), "length");
|
||||
});
|
||||
|
||||
it("maps tool_use to toolUse", () => {
|
||||
assert.equal(mapStopReason("tool_use"), "toolUse");
|
||||
});
|
||||
|
||||
it("maps pause_turn to pauseTurn (not stop)", () => {
|
||||
// pause_turn means the server paused a long-running turn (e.g. native
|
||||
// web search hit its iteration limit). Mapping it to "stop" causes the
|
||||
// agent loop to exit, leaving an incomplete server_tool_use block in
|
||||
// history which triggers a 400 on the next request.
|
||||
assert.equal(mapStopReason("pause_turn"), "pauseTurn");
|
||||
});
|
||||
|
||||
it("throws on unknown stop reason", () => {
|
||||
assert.throws(() => mapStopReason("bogus"), /Unhandled stop reason/);
|
||||
});
|
||||
});
|
||||
|
|
@ -502,7 +502,7 @@ export function mapStopReason(reason: string): StopReason {
|
|||
case "refusal":
|
||||
return "error";
|
||||
case "pause_turn":
|
||||
return "stop";
|
||||
return "pauseTurn";
|
||||
case "stop_sequence":
|
||||
return "stop";
|
||||
case "sensitive":
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ export interface Usage {
|
|||
};
|
||||
}
|
||||
|
||||
export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
|
||||
export type StopReason = "stop" | "length" | "toolUse" | "pauseTurn" | "error" | "aborted";
|
||||
|
||||
export interface UserMessage {
|
||||
role: "user";
|
||||
|
|
@ -253,7 +253,7 @@ export type AssistantMessageEvent =
|
|||
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage; malformedArguments?: boolean }
|
||||
| { type: "server_tool_use"; contentIndex: number; partial: AssistantMessage }
|
||||
| { type: "web_search_result"; contentIndex: number; partial: AssistantMessage }
|
||||
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; message: AssistantMessage }
|
||||
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse" | "pauseTurn">; message: AssistantMessage }
|
||||
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue