mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 16:40:26 +00:00
Runtime: stabilize tool/run state transitions under compaction and backpressure
Synthesize runtime state transition fixes for compaction tool-use integrity and long-running handler backpressure. Sources: #33630, #33583 Co-authored-by: Kevin Shenghui <shenghuikevin@gmail.com> Co-authored-by: Theo Tarr <theodore@tarr.com>
This commit is contained in:
@@ -336,3 +336,196 @@ describe("mergeConsecutiveUserTurns", () => {
|
||||
expect(merged.timestamp).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAnthropicTurns strips dangling tool_use blocks", () => {
|
||||
it("should strip tool_use blocks without matching tool_result", () => {
|
||||
// Simulates: user asks -> assistant has tool_use -> user responds without tool_result
|
||||
// This happens after compaction trims history
|
||||
const msgs = asMessages([
|
||||
{ role: "user", content: [{ type: "text", text: "Use tool" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolUse", id: "tool-1", name: "test", input: {} },
|
||||
{ type: "text", text: "I'll check that" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
||||
]);
|
||||
|
||||
const result = validateAnthropicTurns(msgs);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
// The dangling tool_use should be stripped, but text content preserved
|
||||
const assistantContent = (result[1] as { content?: unknown[] }).content;
|
||||
expect(assistantContent).toEqual([{ type: "text", text: "I'll check that" }]);
|
||||
});
|
||||
|
||||
it("should preserve tool_use blocks with matching tool_result", () => {
|
||||
const msgs = asMessages([
|
||||
{ role: "user", content: [{ type: "text", text: "Use tool" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolUse", id: "tool-1", name: "test", input: {} },
|
||||
{ type: "text", text: "Here's result" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "toolResult", toolUseId: "tool-1", content: [{ type: "text", text: "Result" }] },
|
||||
{ type: "text", text: "Thanks" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = validateAnthropicTurns(msgs);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
// tool_use should be preserved because matching tool_result exists
|
||||
const assistantContent = (result[1] as { content?: unknown[] }).content;
|
||||
expect(assistantContent).toEqual([
|
||||
{ type: "toolUse", id: "tool-1", name: "test", input: {} },
|
||||
{ type: "text", text: "Here's result" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should insert fallback text when all content would be removed", () => {
|
||||
const msgs = asMessages([
|
||||
{ role: "user", content: [{ type: "text", text: "Use tool" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolUse", id: "tool-1", name: "test", input: {} }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
||||
]);
|
||||
|
||||
const result = validateAnthropicTurns(msgs);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
// Should insert fallback text since all content would be removed
|
||||
const assistantContent = (result[1] as { content?: unknown[] }).content;
|
||||
expect(assistantContent).toEqual([{ type: "text", text: "[tool calls omitted]" }]);
|
||||
});
|
||||
|
||||
it("should handle multiple dangling tool_use blocks", () => {
|
||||
const msgs = asMessages([
|
||||
{ role: "user", content: [{ type: "text", text: "Use tools" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolUse", id: "tool-1", name: "test1", input: {} },
|
||||
{ type: "toolUse", id: "tool-2", name: "test2", input: {} },
|
||||
{ type: "text", text: "Done" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "OK" }] },
|
||||
]);
|
||||
|
||||
const result = validateAnthropicTurns(msgs);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
const assistantContent = (result[1] as { content?: unknown[] }).content;
|
||||
// Only text content should remain
|
||||
expect(assistantContent).toEqual([{ type: "text", text: "Done" }]);
|
||||
});
|
||||
|
||||
it("should handle mixed tool_use with some having matching tool_result", () => {
|
||||
const msgs = asMessages([
|
||||
{ role: "user", content: [{ type: "text", text: "Use tools" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolUse", id: "tool-1", name: "test1", input: {} },
|
||||
{ type: "toolUse", id: "tool-2", name: "test2", input: {} },
|
||||
{ type: "text", text: "Done" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "toolResult",
|
||||
toolUseId: "tool-1",
|
||||
content: [{ type: "text", text: "Result 1" }],
|
||||
},
|
||||
{ type: "text", text: "Thanks" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = validateAnthropicTurns(msgs);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
// tool-1 should be preserved (has matching tool_result), tool-2 stripped, text preserved
|
||||
const assistantContent = (result[1] as { content?: unknown[] }).content;
|
||||
expect(assistantContent).toEqual([
|
||||
{ type: "toolUse", id: "tool-1", name: "test1", input: {} },
|
||||
{ type: "text", text: "Done" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not modify messages when next is not user", () => {
|
||||
const msgs = asMessages([
|
||||
{ role: "user", content: [{ type: "text", text: "Use tool" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolUse", id: "tool-1", name: "test", input: {} }],
|
||||
},
|
||||
// Next is assistant, not user - should not strip
|
||||
{ role: "assistant", content: [{ type: "text", text: "Continue" }] },
|
||||
]);
|
||||
|
||||
const result = validateAnthropicTurns(msgs);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
// Original tool_use should be preserved
|
||||
const assistantContent = (result[1] as { content?: unknown[] }).content;
|
||||
expect(assistantContent).toEqual([{ type: "toolUse", id: "tool-1", name: "test", input: {} }]);
|
||||
});
|
||||
|
||||
it("is replay-safe across repeated validation passes", () => {
|
||||
const msgs = asMessages([
|
||||
{ role: "user", content: [{ type: "text", text: "Use tools" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolUse", id: "tool-1", name: "test1", input: {} },
|
||||
{ type: "toolUse", id: "tool-2", name: "test2", input: {} },
|
||||
{ type: "text", text: "Done" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "toolResult",
|
||||
toolUseId: "tool-1",
|
||||
content: [{ type: "text", text: "Result 1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const firstPass = validateAnthropicTurns(msgs);
|
||||
const secondPass = validateAnthropicTurns(firstPass);
|
||||
|
||||
expect(secondPass).toEqual(firstPass);
|
||||
});
|
||||
|
||||
it("does not crash when assistant content is non-array", () => {
|
||||
const msgs = [
|
||||
{ role: "user", content: [{ type: "text", text: "Use tool" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "legacy-content",
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Thanks" }] },
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
expect(() => validateAnthropicTurns(msgs)).not.toThrow();
|
||||
const result = validateAnthropicTurns(msgs);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,94 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
||||
type AnthropicContentBlock = {
|
||||
type: "text" | "toolUse" | "toolResult";
|
||||
text?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
toolUseId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips dangling tool_use blocks from assistant messages when the immediately
|
||||
* following user message does not contain a matching tool_result block.
|
||||
* This fixes the "tool_use ids found without tool_result blocks" error from Anthropic.
|
||||
*/
|
||||
function stripDanglingAnthropicToolUses(messages: AgentMessage[]): AgentMessage[] {
|
||||
const result: AgentMessage[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (!msg || typeof msg !== "object") {
|
||||
result.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const msgRole = (msg as { role?: unknown }).role as string | undefined;
|
||||
if (msgRole !== "assistant") {
|
||||
result.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const assistantMsg = msg as {
|
||||
content?: AnthropicContentBlock[];
|
||||
};
|
||||
|
||||
// Get the next message to check for tool_result blocks
|
||||
const nextMsg = messages[i + 1];
|
||||
const nextMsgRole =
|
||||
nextMsg && typeof nextMsg === "object"
|
||||
? ((nextMsg as { role?: unknown }).role as string | undefined)
|
||||
: undefined;
|
||||
|
||||
// If next message is not user, keep the assistant message as-is
|
||||
if (nextMsgRole !== "user") {
|
||||
result.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect tool_use_ids from the next user message's tool_result blocks
|
||||
const nextUserMsg = nextMsg as {
|
||||
content?: AnthropicContentBlock[];
|
||||
};
|
||||
const validToolUseIds = new Set<string>();
|
||||
if (Array.isArray(nextUserMsg.content)) {
|
||||
for (const block of nextUserMsg.content) {
|
||||
if (block && block.type === "toolResult" && block.toolUseId) {
|
||||
validToolUseIds.add(block.toolUseId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out tool_use blocks that don't have matching tool_result
|
||||
const originalContent = Array.isArray(assistantMsg.content) ? assistantMsg.content : [];
|
||||
const filteredContent = originalContent.filter((block) => {
|
||||
if (!block) {
|
||||
return false;
|
||||
}
|
||||
if (block.type !== "toolUse") {
|
||||
return true;
|
||||
}
|
||||
// Keep tool_use if its id is in the valid set
|
||||
return validToolUseIds.has(block.id || "");
|
||||
});
|
||||
|
||||
// If all content would be removed, insert a minimal fallback text block
|
||||
if (originalContent.length > 0 && filteredContent.length === 0) {
|
||||
result.push({
|
||||
...assistantMsg,
|
||||
content: [{ type: "text", text: "[tool calls omitted]" }],
|
||||
} as AgentMessage);
|
||||
} else {
|
||||
result.push({
|
||||
...assistantMsg,
|
||||
content: filteredContent,
|
||||
} as AgentMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateTurnsWithConsecutiveMerge<TRole extends "assistant" | "user">(params: {
|
||||
messages: AgentMessage[];
|
||||
role: TRole;
|
||||
@@ -98,10 +187,14 @@ export function mergeConsecutiveUserTurns(
|
||||
* Validates and fixes conversation turn sequences for Anthropic API.
|
||||
* Anthropic requires strict alternating user→assistant pattern.
|
||||
* Merges consecutive user messages together.
|
||||
* Also strips dangling tool_use blocks that lack corresponding tool_result blocks.
|
||||
*/
|
||||
export function validateAnthropicTurns(messages: AgentMessage[]): AgentMessage[] {
|
||||
// First, strip dangling tool_use blocks from assistant messages
|
||||
const stripped = stripDanglingAnthropicToolUses(messages);
|
||||
|
||||
return validateTurnsWithConsecutiveMerge({
|
||||
messages,
|
||||
messages: stripped,
|
||||
role: "user",
|
||||
merge: mergeConsecutiveUserTurns,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user