Description
addToolApprovalResponse({ id, approved, reason }) lets a client attach a reason to a tool-approval response. The harness delivers that reason asymmetrically — verified against the published @ai-sdk/harness@1.0.24 dist (processPendingApprovalContinuation in dist/agent/index.js):
- Denied host tool → the reason reaches the model via
submitToolResult({ output: { type: 'execution-denied', reason } }). ✅
- Built-in tool approval → the reason is forwarded via
control.submitToolApproval({ approved, reason }). ✅
- Approved host tool →
maybeExecuteHostTool re-executes the tool with its original input only. The reason is recorded on the tool-approval-response part (so it appears in the transcript), but it never reaches the tool's execute() — and therefore never reaches the model, which only sees the tool result. ❌
This matters because the approval prompt is a natural human-in-the-loop data channel: "approved — and here is the user's answer / the edited plan / the corrected value". The reason field is the documented way to attach context to an approval decision, and it already works on the deny path; the approve path silently drops it.
Reproduction
const tools = {
ask_user_question: tool({
inputSchema: z.object({ question: z.string() }),
needsApproval: true,
execute: async (input, options) => {
// options has toolCallId / messages / abortSignal — nothing from the approval
// response. The user's answer (sent as the approval reason) is not available here.
return 'answer?';
},
}),
};
// client:
addToolApprovalResponse({
id: part.approval.id,
approved: true,
reason: JSON.stringify({ answers: { question_1: '…' } }),
});
The continuation executes the tool with the original input; the reason payload is nowhere in the execution, and the tool result the model consumes cannot include it.
Suggested fix
Forward the approval response into host-tool execution options, e.g.:
execute(input, { toolCallId, messages, abortSignal, approval: { approved: true, reason } })
mirroring what the denial path already does through execution-denied. Additive on the execution options type, so existing tools are unaffected.
Update: PR is up — #17101, generalized per the discussion below: rather than patching only the harness call site, the approval decision ({ approvalId, approved, reason? }) is threaded as options.approval through every execution path — generateText/streamText explicit continuations, automatic toolApproval approvals, and the harness continuation — with tests on each path and docs. Thanks @babyblueviper1 for the "full decision payload threads through every path uniformly" framing that shaped it.
Workaround
We scan the incoming UIMessage[] for tool parts carrying approval.reason, build a toolCallId → parsed payload map out-of-band before starting the turn, and each HITL tool's execute() looks up its own toolCallId so the data reaches the model through the tool result. Works, but it is exactly the plumbing the SDK could do itself — and it only works because the approval response happens to ride the resent messages.
Versions
@ai-sdk/harness@1.0.24 (latest; first verified on 1.0.18), @ai-sdk/harness-claude-code@1.0.24
ai@7.0.20
Description
addToolApprovalResponse({ id, approved, reason })lets a client attach areasonto a tool-approval response. The harness delivers that reason asymmetrically — verified against the published@ai-sdk/harness@1.0.24dist (processPendingApprovalContinuationindist/agent/index.js):submitToolResult({ output: { type: 'execution-denied', reason } }). ✅control.submitToolApproval({ approved, reason }). ✅maybeExecuteHostToolre-executes the tool with its original input only. The reason is recorded on thetool-approval-responsepart (so it appears in the transcript), but it never reaches the tool'sexecute()— and therefore never reaches the model, which only sees the tool result. ❌This matters because the approval prompt is a natural human-in-the-loop data channel: "approved — and here is the user's answer / the edited plan / the corrected value". The
reasonfield is the documented way to attach context to an approval decision, and it already works on the deny path; the approve path silently drops it.Reproduction
The continuation executes the tool with the original input; the
reasonpayload is nowhere in the execution, and the tool result the model consumes cannot include it.Suggested fix
Forward the approval response into host-tool execution options, e.g.:
mirroring what the denial path already does through
execution-denied. Additive on the execution options type, so existing tools are unaffected.Update: PR is up — #17101, generalized per the discussion below: rather than patching only the harness call site, the approval decision (
{ approvalId, approved, reason? }) is threaded asoptions.approvalthrough every execution path —generateText/streamTextexplicit continuations, automatictoolApprovalapprovals, and the harness continuation — with tests on each path and docs. Thanks @babyblueviper1 for the "full decision payload threads through every path uniformly" framing that shaped it.Workaround
We scan the incoming
UIMessage[]for tool parts carryingapproval.reason, build atoolCallId → parsed payloadmap out-of-band before starting the turn, and each HITL tool'sexecute()looks up its owntoolCallIdso the data reaches the model through the tool result. Works, but it is exactly the plumbing the SDK could do itself — and it only works because the approval response happens to ride the resent messages.Versions
@ai-sdk/harness@1.0.24(latest; first verified on 1.0.18),@ai-sdk/harness-claude-code@1.0.24ai@7.0.20