Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,26 @@ def _sanitize_tool_history(messages: list[Message]) -> list[Message]:
pending_tool_call_ids = None
pending_confirm_changes_id = None

# 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",
)
],
))
Comment on lines +197 to +215
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?


return sanitized
Comment on lines +197 to 217
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



Expand Down
17 changes: 17 additions & 0 deletions python/packages/ag-ui/tests/ag_ui/test_message_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -984,6 +984,23 @@ def test_sanitize_json_confirm_changes_response():
assert len(result) >= 1


def test_sanitize_pending_tool_at_end_of_history():
"""Messages ending with an assistant tool call and no following message inject a synthetic result."""
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history

assistant_msg = Message(
role="assistant",
contents=[Content.from_function_call(call_id="c1", name="pieChart", arguments="{}")],
)

result = _sanitize_tool_history([assistant_msg])

tool_results = [m for m in result if m.role == "tool"]
assert len(tool_results) == 1
assert tool_results[0].contents[0].call_id == "c1"
assert "skipped" in str(tool_results[0].contents[0].result).lower()


# ── Deduplication edge cases ──


Expand Down