-
Notifications
You must be signed in to change notification settings - Fork 384
Description
Note: claude wrote up the information below, but it's accurate. You can see how we're working around it below, but the TLDR is:
We have a tool where we want to show a different indicator based on the usage being accepted or rejected. While you can reject a tool use today, it doesn't accurately keep the state for the last message. If this doesn't make sense or there is a better way to handle this, let me know!
Summary
When a user denies a tool with needsApproval: true, the @cloudflare/ai-chat library sets the tool part state to "approval-responded" instead of "output-denied". The AI SDK's convertToModelMessages does not generate a tool_result for "approval-responded", causing the Anthropic API to reject subsequent requests with:
tool_useids were found withouttool_resultblocks immediately after: <toolCallId>. Eachtool_useblock must have a correspondingtool_resultblock in the next message.
Root Cause
There is a mismatch between @cloudflare/ai-chat and the Vercel AI SDK (ai) in how tool approval denials are represented.
What @cloudflare/ai-chat does
_applyToolApproval (index.ts L1805-1825) always sets the state to "approval-responded", regardless of whether the tool was approved or denied:
private async _applyToolApproval(toolCallId: string, approved: boolean): Promise<boolean> {
return this._findAndUpdateToolPart(
toolCallId,
"_applyToolApproval",
["input-available", "approval-requested"],
(part) => ({
...part,
state: "approval-responded", // Always "approval-responded", even for denials
approval: {
...(part.approval as Record<string, unknown> | undefined),
approved,
},
})
);
}What the AI SDK expects
convertToModelMessages (convert-to-model-messages.ts) only generates a tool_result for these states:
| State | Generates tool_result? |
|---|---|
"output-available" |
Yes (with tool output) |
"output-error" |
Yes (with error text) |
"output-denied" |
Yes (with "Tool execution denied.") |
"approval-responded" |
No |
The "approval-responded" state generates a tool-approval-response message part, but the switch statement that generates tool_result entries does not have a case for "approval-responded":
switch (toolPart.state) {
case 'output-denied': // ← generates tool_result with error-text
case 'output-error': // ← generates tool_result with error-text
case 'output-available': // ← generates tool_result with output
// "approval-responded" is NOT handled — no tool_result generated
}The gap
For denied approvals, _applyToolApproval should set the state to "output-denied" (not "approval-responded"), so that convertToModelMessages generates the required tool_result for the Anthropic API.
Additionally: _applyToolResult does not accept approval states
A related issue: _applyToolResult only accepts "input-available" state:
private async _applyToolResult(toolCallId, _toolName, output) {
return this._findAndUpdateToolPart(
toolCallId,
"_applyToolResult",
["input-available"], // Does not accept "approval-requested" or "approval-responded"
...
);
}This means you cannot use addToolOutput to provide a tool result for a needsApproval tool — the state is "approval-requested" when the user acts, so _applyToolResult rejects it. This makes it impossible to follow the Vercel-recommended pattern for client-side denial:
// Vercel's recommended approach for client-side denial — does not work
// with @cloudflare/ai-chat because _applyToolResult rejects the state
addToolOutput({
tool: 'navigateUser',
toolCallId: invocation.toolCallId,
state: 'output-error',
errorText: 'Tool execution denied by user',
})Vercel AI SDK team guidance
From a Vercel support thread (Amy Egan, Vercel Staff):
The
'output-denied'state you noticed in UIToolInvocation is an internal state used by the framework, not something you can set directly. For client-side tools, you can use theoutput-errorstate:addToolOutput({ tool: 'yourToolName', toolCallId: invocation.toolCallId, state: 'output-error', errorText: 'Tool execution denied by user' })That tells the LLM that the tool execution failed, and it will handle it appropriately. This is semantically correct since from the tool's perspective, a user denial is effectively an execution failure.
Suggested fix
Option A: Fix _applyToolApproval for denials
When approved is false, set the state to "output-denied" instead of "approval-responded":
private async _applyToolApproval(toolCallId: string, approved: boolean): Promise<boolean> {
return this._findAndUpdateToolPart(
toolCallId,
"_applyToolApproval",
["input-available", "approval-requested"],
(part) => ({
...part,
state: approved ? "approval-responded" : "output-denied",
approval: {
...(part.approval as Record<string, unknown> | undefined),
approved,
},
})
);
}Option B: Expand _applyToolResult to accept approval states
Allow _applyToolResult to accept "approval-requested" so that addToolOutput works for needsApproval tools:
private async _applyToolResult(toolCallId, _toolName, output) {
return this._findAndUpdateToolPart(
toolCallId,
"_applyToolResult",
["input-available", "approval-requested", "approval-responded"],
...
);
}Option C: Pass state and errorText through the addToolOutput wrapper
The addToolOutput wrapper in react.tsx currently only forwards output. It should also forward state and errorText to the AI SDK's addToolOutput, and include them in the CF_AGENT_TOOL_RESULT message to the server.
Current workaround
We normalize denied approvals in the server's onChatMessage before calling convertToModelMessages:
private normalizeApprovalDenials(messages: typeof this.messages): typeof this.messages {
return messages.map((msg) => {
if (msg.role !== "assistant") return msg;
let changed = false;
const parts = msg.parts.map((part: any) => {
if (part.state === "approval-responded" && part.approval?.approved === false) {
changed = true;
return { ...part, state: "output-denied" };
}
return part;
});
return changed ? { ...msg, parts } : msg;
});
}Affected versions
@cloudflare/ai-chat@0.1.1ai@6.0.86