Skip to content

fix: key delegated approval by toolName with toolCallId fallback#3090

Merged
anubra266 merged 1 commit into
mainfrom
fix/delegated-approval-key-by-toolname
Apr 9, 2026
Merged

fix: key delegated approval by toolName with toolCallId fallback#3090
anubra266 merged 1 commit into
mainfrom
fix/delegated-approval-key-by-toolname

Conversation

@anubra266
Copy link
Copy Markdown
Contributor

Summary

  • Delegated agent approvals were keyed by toolCallId in the forwarded approved_tool_calls metadata
  • When the delegated agent re-executes after approval, the LLM generates a new toolCallId that doesn't match → infinite approval loop
  • Fix: key by toolName (stable across re-executions) and carry originalToolCallId for audit
  • The consumer looks up by toolCallId first, then falls back to toolName for delegated scenarios

Test plan

  • Typecheck passes
  • Verified the infinite approval loop in Slack (same get_coordinates approval kept re-appearing)
  • Fix keys approval by toolName so delegated agent finds the pre-approved decision

Delegated agent approvals were keyed by toolCallId in the forwarded
approved_tool_calls metadata. But when the delegated agent re-executes
after approval, the LLM generates a new toolCallId that doesn't match
the original — causing an infinite approval loop.

Fix: key by toolName (stable across re-executions) and carry
originalToolCallId for audit. The consumer looks up by toolCallId first,
then falls back to toolName for delegated scenarios.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agents-api Ready Ready Preview, Comment Apr 9, 2026 7:25pm
agents-manage-ui Ready Ready Preview, Comment Apr 9, 2026 7:25pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
agents-docs Skipped Skipped Apr 9, 2026 7:25pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 9, 2026

⚠️ No Changeset found

Latest commit: 59b5b02

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 9, 2026

TL;DR — Fixes delegated tool approvals breaking on re-execution by keying the approved_tool_calls map by toolName (stable) instead of toolCallId (regenerated each run), with a fallback lookup so both keys work.

Key changes

  • Key delegated approvals by toolName in relationTools.ts — the approved_tool_calls payload sent during delegation now uses toolName as the map key and includes originalToolCallId for traceability.
  • Fall back from toolCallId to toolName in tool-approval.ts — pre-approval lookup tries the toolCallId first, then falls back to toolName, and cleans up both keys on match.

Summary | 2 files | 1 commit | base: mainfix/delegated-approval-key-by-toolname


Before: Delegated tool approvals were keyed by toolCallId, which changes on every re-execution of a durable workflow — causing the approval lookup to miss and the agent to re-prompt the user.
After: Approvals are keyed by toolName (stable across re-executions) with a toolCallId fallback, so pre-approved decisions are reliably matched on replay.

In durable workflows, when a delegated agent re-executes after a pause, the AI SDK generates a new toolCallId for the same logical tool call. Since approved_tool_calls was keyed by toolCallId, the approval record from the parent agent could never match the new ID. Switching the key to toolName — which stays constant — and falling back from toolCallId to toolName during lookup ensures the approval is found. The originalToolCallId is preserved in the payload for debugging and tracing.

relationTools.ts · tool-approval.ts

Pullfrog  | View workflow run | Triggered by Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-scoped fix for a real bug. The root cause analysis is sound — toolCallId is unstable across LLM re-executions, toolName is stable. The fallback chain (toolCallIdtoolName) preserves backward compatibility for the direct approval path.

One minor gap (non-blocking): tool-wrapper.ts:156-158 also reads approvedToolCalls but only by toolCallId:

const preApprovedEntry = ctx.durableWorkflowRunId
  ? ctx.approvedToolCalls?.[toolCallId]
  : undefined;

After this fix, delegated approvals are keyed by toolName, so isPreApproved will be false for delegated pre-approved tools. This causes writeToolInputStart/writeToolInputDelta/writeToolInputAvailable to fire even though the tool is about to be auto-approved in waitForToolApproval. Not a correctness issue — just a flash of tool-input streaming in the UI for pre-approved delegated tools. Could apply the same fallback:

const preApprovedEntry = ctx.durableWorkflowRunId
  ? (ctx.approvedToolCalls?.[toolCallId] ?? ctx.approvedToolCalls?.[toolName])
  : undefined;

Pullfrog  | View workflow run | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(1) Total Issues | Risk: Low

🟡 Minor (0) 🟡

No minor issues requiring action.

💭 Consider (1) 💭

Inline Comments:

  • 💭 Consider: relationTools.ts:452 originalToolCallId field added but not consumed — consider adding audit logging or documenting the field's purpose

🧹 While You're Here (1) 🧹

🧹 1) scope Missing changeset

Issue: The changeset-bot flagged that this PR has no changeset, but this is a bug fix that changes runtime behavior in agents-api.

Why: Per AGENTS.md, all bug fixes to published packages need changesets for proper versioning and changelog generation.

Fix: Run:

pnpm bump patch --pkg agents-api "Fix infinite approval loop in delegated agent tool approvals by keying by toolName"

💡 APPROVE WITH SUGGESTIONS

Summary: This is a well-targeted fix for the infinite approval loop issue in delegated agent scenarios. The approach is sound — toolName is stable across LLM re-executions while toolCallId changes. The fallback pattern (toolCallId ?? toolName) maintains backward compatibility with direct approvals while supporting delegated approvals.

The implementation is minimal and correctly targeted. The only action item is adding the missing changeset before merging.

Discarded (2)
Location Issue Reason Discarded
system Multiple same-tool calls could receive wrong approval The workflow design handles one approval at a time — execution suspends until a specific approval is granted, so toolName keying is safe for the delegated single-approval scenario
tool-approval.ts:52-53 Asymmetric delete of both keys Defensive programming pattern that's actually correct — delete on non-existent key is a no-op in JS, and this ensures cleanup for both direct and delegated approval paths
Reviewers (2)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-standards 0 0 0 0 0 0 0
pr-review-precision 3 0 1 0 1 0 2
Total 3 0 1 0 1 0 2

Note: Standards reviewer found no issues. Precision reviewer raised valid structural questions that were validated against the workflow design — the single-approval-at-a-time workflow makes toolName keying safe.

[agentRunContext.delegatedToolApproval.toolName]: {
approved: agentRunContext.delegatedToolApproval.approved,
reason: agentRunContext.delegatedToolApproval.reason,
originalToolCallId: agentRunContext.delegatedToolApproval.toolCallId,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 Consider: originalToolCallId not consumed

Issue: The originalToolCallId field is added to the forwarded approval metadata but isn't read by the consumer (tool-approval.ts). The PR description mentions it's "for audit" but there's no audit logging that consumes this field.

Why: Minor dead data concern. However, this could be valuable for future debugging/tracing if logged when the pre-approved decision is applied.

Fix: Either:

  1. Add a log line in tool-approval.ts when a delegated approval is found: logger.debug({ originalToolCallId: preApproved.originalToolCallId }, 'Applied delegated approval')
  2. Or document in a code comment that this field is intentionally included for observability/debugging purposes

Comment on lines +48 to +53
// Look up by toolCallId first, then fall back to toolName (for delegated approvals
// where the toolCallId changes across re-executions but the toolName stays the same).
const preApproved = approvedToolCalls[toolCallId] ?? approvedToolCalls[toolName];
if (preApproved !== undefined) {
delete approvedToolCalls[toolCallId];
delete approvedToolCalls[toolName];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Comment accurately explains the fallback

The comment on lines 48-49 correctly explains the rationale for the fallback lookup. The defensive deletion of both keys (lines 52-53) is appropriate — one will be a no-op depending on the scenario, but it ensures cleanup regardless of which key matched.

Good: This pattern handles both:

  • Direct approvals (keyed by toolCallId)
  • Delegated approvals (keyed by toolName)

@github-actions github-actions Bot deleted a comment from claude Bot Apr 9, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 9, 2026

Preview URLs

Use these stable preview aliases for testing this PR:

These point to the same Vercel preview deployment as the bot comment, but they stay stable and easier to find.

Raw Vercel deployment URLs

@anubra266 anubra266 added this pull request to the merge queue Apr 9, 2026
Merged via the queue into main with commit 718c81f Apr 9, 2026
32 checks passed
@anubra266 anubra266 deleted the fix/delegated-approval-key-by-toolname branch April 9, 2026 19:42
@itoqa
Copy link
Copy Markdown

itoqa Bot commented Apr 9, 2026

Ito Test Report ✅

12 test cases ran. 12 passed.

Overall, the unified QA run passed 12/12 executed test cases with 0 failures, found no confirmed production defects, and included one additional execution that did not yield completed code-first outcomes. The most important validated behaviors were robust delegated-approval handling across /run/api/chat and SSE flows (including re-execution and interrupted-stream recovery), correct pending-approval suspend/resume lifecycle, idempotent and race-safe single-consumption of approval decisions, safe handling of stale/forged payloads without unintended tool execution, exact-key matching without same-name/normalization collisions, and fully interactive mobile approval controls at 390x844.

✅ Passed (12)
Category Summary Screenshot
Adversarial Concurrent duplicate/competing approvals apply only the first decision. ADV-1
Adversarial Forged or stale approval payloads are handled safely without unintended execution. ADV-3
Edge Not a real bug. Re-execution confirms a decision for the first same-name call is not reused for the second because approval entries are consumed after the first match. EDGE-1
Edge Not a real bug. Re-execution confirms no false-positive fallback across similarly named tools; matching remains exact-string based. EDGE-2
Edge Interrupted-flow recovery via stream resume and pending-approval discovery is present and consistent. EDGE-3
Edge Mobile approval controls render and remain interactive at 390x844; prior blocked signal was tooling-related, not a product defect. EDGE-4
Logic Delegated denial is consumed once and does not re-prompt. LOGIC-1
Logic Replay for unknown/stale toolCallId is idempotently marked already processed. LOGIC-2
Logic Not a real application bug. The prior blocked result was caused by harness timeout, and re-execution confirmed isolated handling of parallel delegated approvals by exact key lookup and consumption logic. LOGIC-3
Happy-path Delegated approval resolves via toolName fallback when re-execution changes toolCallId. ROUTE-1
Happy-path SSE delegated approval parity is implemented and healthy; no production defect was identified. ROUTE-2
Happy-path Pending approval lifecycle is correctly implemented from suspended state to cleared state after resume. ROUTE-4

Commit: 59b5b02

View Full Run


Tell us how we did: Give Ito Feedback

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant