Skip to content

@ai-sdk/harness: approval reason for an APPROVED host tool is dropped — forwarded for denials and built-in tools, never reaches the tool's execute() or the model #17083

Description

@doneumark

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 toolmaybeExecuteHostTool 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions