Skip to content

Python: fix: inject synthetic tool results for pending frontend tool calls#4935

Open
ranst91 wants to merge 2 commits intomicrosoft:mainfrom
ranst91:fix/tool-results-for-pendind-tool-calls
Open

Python: fix: inject synthetic tool results for pending frontend tool calls#4935
ranst91 wants to merge 2 commits intomicrosoft:mainfrom
ranst91:fix/tool-results-for-pendind-tool-calls

Conversation

@ranst91
Copy link
Copy Markdown

@ranst91 ranst91 commented Mar 26, 2026

Description

When an agent calls a declaration-only (frontend/client-side) tool — such as
a generative UI component like pieChart — and the conversation ends without
a user message following, the tool call remains "pending" in the message
history with no corresponding tool result.

On the next request, OpenAI rejects the conversation with a 400 error:
"An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'."

_sanitize_tool_history already handles this when a user message arrives while
calls are pending — it injects synthetic results before the user turn. This
fix extends that logic to also cover the end-of-messages case, so pending
tool calls are always resolved before the history is sent to OpenAI.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • The PR follows the Contribution Guidelines
  • All unit tests pass, and I have added new tests where possible
  • Is this a breaking change? If yes, add "[BREAKING]" prefix to the title of the PR.

Copilot AI review requested due to automatic review settings March 26, 2026 16:52
@github-actions github-actions bot changed the title fix: inject synthetic tool results for pending frontend tool calls Python: fix: inject synthetic tool results for pending frontend tool calls Mar 26, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Extends AG-UI message sanitization so that when a conversation ends with an assistant tool_calls message (common for declaration-only/frontend tools), synthetic tool results are injected to prevent OpenAI from rejecting the next request due to unmatched tool_call_ids.

Changes:

  • Inject synthetic role="tool" function_result messages when the message list ends with pending tool calls.
  • Add logging to indicate pending tool calls were resolved via synthetic results.

Comment on lines +197 to +215
# If the conversation ends with pending tool calls (e.g. a declaration-only
# frontend tool was called with no user message following), inject synthetic
# results so the next OpenAI call doesn't get a 400 for unmatched tool_call_ids.
if pending_tool_call_ids:
logger.info(
f"Messages ended with {len(pending_tool_call_ids)} pending tool calls - "
"injecting synthetic results"
)
for pending_call_id in pending_tool_call_ids:
logger.info(f"Injecting synthetic tool result for pending call_id={pending_call_id}")
sanitized.append(Message(
role="tool",
contents=[
Content.from_function_result(
call_id=pending_call_id,
result="Tool execution skipped - frontend tool result not yet received",
)
],
))
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

Injecting a synthetic tool result at the end will cause any later real tool result for the same call_id (arriving in a subsequent request) to be treated as unmatched and dropped by _sanitize_tool_history (the synthetic result clears pending_tool_call_ids before the real tool message is processed). If frontend tools can legitimately return results asynchronously, consider limiting this end-of-history injection to tools known to be result-less (declaration-only), or adjust the sanitization logic to prefer/replace the synthetic placeholder when a real tool result is later received.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@copilot
Currently we do not foresee how this theoretic case turns into a real one, but considering we want to avoid "ship now, bug later", allow me to suggest the following safeguard:
If there's no user message at all after the pending tool call, it means we're in a "tool result submission only" request, real results might still be coming. Don't inject in that case. Only inject when we know the user is
moving the conversation forward.

Would that be sufficient? anything else in mind?

Comment on lines +197 to 217
# If the conversation ends with pending tool calls (e.g. a declaration-only
# frontend tool was called with no user message following), inject synthetic
# results so the next OpenAI call doesn't get a 400 for unmatched tool_call_ids.
if pending_tool_call_ids:
logger.info(
f"Messages ended with {len(pending_tool_call_ids)} pending tool calls - "
"injecting synthetic results"
)
for pending_call_id in pending_tool_call_ids:
logger.info(f"Injecting synthetic tool result for pending call_id={pending_call_id}")
sanitized.append(Message(
role="tool",
contents=[
Content.from_function_result(
call_id=pending_call_id,
result="Tool execution skipped - frontend tool result not yet received",
)
],
))

return sanitized
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

This adds new behavior for the "assistant tool_calls at end of history" case, but there isn't a regression test covering it. Please add a unit test that passes [assistant(tool_call)] with no following user/tool message and asserts a synthetic role="tool" function_result is appended for the call_id.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

should be resolved now

@moonbox3
Copy link
Copy Markdown
Contributor

Could you let us know, please, which version of agent-framework-ag-ui you're using?

@markwallace-microsoft
Copy link
Copy Markdown
Member

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/ag-ui/agent_framework_ag_ui
   _message_adapters.py6025990%102–103, 112–115, 118–122, 124–129, 132, 141–147, 187, 201, 205–207, 338, 428–431, 433–435, 482–484, 538, 541, 543, 546, 549, 565, 582, 604, 704, 720–721, 792, 814, 884, 919–920, 988, 1031
TOTAL28017341687% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
5454 20 💤 0 ❌ 0 🔥 1m 29s ⏱️

@ranst91
Copy link
Copy Markdown
Author

ranst91 commented Mar 27, 2026

@microsoft-github-policy-service agree company="CopilotKit"

@ranst91
Copy link
Copy Markdown
Author

ranst91 commented Mar 27, 2026

@moonbox3 I used latest.
The follow is as follows:

  • Created this fork yesterday (so using latest).
  • Tested against this fork instead of the published package (so latest main)
  • Verify that issue persists
  • Write the fix and test it
  • PR

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants