Fix parallel toolcall in agent - #2172
Conversation
Signed-off-by: YiyuanMiao <miaoyiyuan31@gmail.com>
Resolve conflict in aws-strands agent.py _build_strands_history: - Adopt upstream's pending_tool_results / flush_tool_results base (from ag-ui-protocol#1848, which bundles parallel tool results into one user msg) - Layer this branch's _normalize_tool_turns() on top as a post-pass so the stronger guarantees still hold: reorder out-of-order results, keep toolUse/toolResult adjacent when other messages are wedged between them, and merge consecutive tool-call turns. - Preserve this branch's deferred frontend-tool-end / halt-break fixes. Merge test_parallel_tool_call_handling.py: keep upstream's bundling test and this branch's Scenario A/B/C tests; add two tests covering the out-of-order and wedged-message cases the normalize pass handles.
|
Merged The conflict was with #1848 (fix(strands): bundle replayed tool results), which also touched
Tests: kept #1848's bundling test and this PR's existing scenarios, and added two tests for the out-of-order and wedged-message cases. Full suite passes (156 passed, 2 skipped). |
…ll-in-agent # Conflicts: # integrations/aws-strands/python/src/ag_ui_strands/agent.py
|
Rebased onto latest The only conflict was the frontend tool-id handling. My original "reuse Strands' native id" fix is superseded by #2231's durable The remaining 4 fixes in this PR are independent of #2231 and still needed:
Full suite green after the merge: 178 passed, 2 skipped. @contextablemark @NathanTarbert since you both work in this area — could you take a look when you get a chance? Thanks! |
|
Thanks for this, and sorry it's sat unreviewed — that's on us, not you. Some context, because this file has become crowded: #2218 (@FriedhelmWS) touches the inbound prompt-building region, #2286 (@ciolo) adds the interrupt round-trip, and we have a halt-path fix on Your diagnosis is correct on every count. We found fixes #1 and #2 independently, measured them the same way, and reached the same conclusions — so those two hunks are duplicate work, and the cause is that nobody reviewed this for 26 days. Fix #1 — right change, wrong reason, and it needs one more lineThe description says " if not agent_stream.ag_running:
pass
else:
await agent_stream.aclose()takes the Measured on both builds: yours reports the stream not finalized during if halt_event_stream:
await agent_stream.aclose()
elif not agent_stream.ag_running:
pass
else:
await agent_stream.aclose()Fix #3 — the placement doesn't deliver the stated guaranteeThe flush sits at the top of the Two more on the same fix:
Fix #4 — three problems, all measuredRecursion. Duplicate Reordering. Non-tool messages between a Your description lists a fix that's no longer in the diffThe "frontend-id" row — reuse Strands' stable id instead of a per-run UUID — has no corresponding Worth removing that row, or saying explicitly if you think the map approach is worse — that's a reasonable thing to argue, but it should be an argument rather than a stale table entry. Tests
How I'd suggest we land thisEasy either way — you were here first:
Option 1 is less work for you and gets both landed sooner. Happy to open ours immediately on your word, or to pair on the #4 items. |
|
@contextablemark
Let's go with Option 1. You were here first on #1/#2, and you already have the correct implementation plus tests, so I'll drop those hunks and you open yours. I'll keep and fix #3 and #4, which only I've touched. One thing I want to nail down before I start cutting — the hunk boundary, since two of your #3 sub-points sit right on the edge of the overlap:
My read is that both are entangled with the halt/handoff cleanup in #1/#2, so they'd naturally go with your hunks, but I'd rather confirm than double-fix or leave a On my side, once the boundary is set, I'll:
I'll also drop the stale "frontend-id" row from the description — you're right that the session_reconcile.py {wire_id: native_id} map from the 07-29 upstream merge supersedes it, and I don't have a case that the map approach is worse. Happy to pair on the #4 items whenever works for you. Go ahead and open yours for #1/#2 — I'll rebase around it. |
… end flush Fix ag-ui-protocol#4 (_normalize_tool_turns): - Rewrite recursively-defined merge as an iterative loop, so histories with many tool turns no longer raise RecursionError (~1000+ turns). - De-duplicate toolUseIds when collecting results, so a repeated id no longer emits a duplicate toolResult (which Bedrock rejects). - Preserve messages that legitimately follow a completed toolUse/toolResult pair in place; only drop messages wedged between the turn and its results. Fix ag-ui-protocol#3 (deferred hand-off flush placement): - Move the buffered frontend ToolCallEnd flush to after the per-item backend result loop, so the wire order is backend TOOL_CALL_RESULT -> frontend TOOL_CALL_END and the client only starts the frontend tool once backend work has reached it. (The args_streamer path and the safety-flush->finally change remain with the overlapping halt-path work.) Add regression tests: 1200+ turn no-crash, duplicate-id dedup, follow-message ordering, and backend-result-before-frontend-end flush order. Signed-off-by: YiyuanMiao <miaoyiyuan31@gmail.com>
|
@contextablemark Pushed my half of Option 1. Done in this branch (fixes #3 and #4 — the parts only I've touched):
On the two boundary sub-points from my last comment — the Important: I have not dropped the two overlapping hunks ( I'll also drop the stale "frontend-id" row from the description. |
|
@YiyuanMiao Opened as #2334 — thanks for the unusually careful review cycle on this, and for reproducing the findings independently before acting on them. It carries the two overlapping hunks (Fix #1 halt-break, Fix #2 keep-message) with 12 regression tests, plus the explicit Drop your One note on CI: #2172 is showing only a single check, so it looks fork-gated and needs a maintainer to approve the run. I'll get that approved so your new tests actually report. |
…ol#2 to PR ag-ui-protocol#2334 Per the split agreed on ag-ui-protocol#2172, revert the Fix ag-ui-protocol#1 change (halt `break` back to the original `continue`) so the halt-path fix — including the explicit `aclose()` that `break` alone doesn't cover — lands via ag-ui-protocol#2334 instead. Keep the pending_halt message-retention (no longer discarding the tool-result message) since the deferred frontend-end flush (Fix ag-ui-protocol#3, retained here) depends on it; update its comment to no longer reference the reverted break. Signed-off-by: YiyuanMiao <miaoyiyuan31@gmail.com>
|
@contextablemark Thanks for opening #2334 so quickly. Done on my side:
So #2172 is now just Fix#3 (flush placement) + Fix#4 ( On the two sub-points: please take them on #2334 — the One heads-up: we'll likely hit a small merge conflict in the Also — CI on #2172 is still sitting at |
fix(aws-strands): correct frontend/backend parallel tool calls
Summary
Fixes several defects in
ag_ui_strandsthat break parallel frontend + backend tool calls, and removes the temporary build-time patch script that previously carried these fixes out-of-tree.The fixes now live directly in the package source (
agent.py), so downstream consumers (e.g.opensearch-agent-server) get correct behavior simply by installingag_ui_strands— no post-install patching step required.Motivation
When the LLM issues frontend and backend tool calls in the same turn, the stock code produced hung runs, dropped results, and intermittent Bedrock errors. These were previously worked around by a build-time script that rewrote the installed
agent.pyin place — fragile (lost on every reinstall / venv rebuild) and easy to forget. This PR moves the fixes into source and deletes the script.Changes
Added / modified —
src/ag_ui_strands/agent.pycontinue(mute only), leaving the Strands loop alive → extra Bedrock call + hung runbreakso the existingaclose()cleanup terminates StrandsToolCallEnd("execute now" signal) was emitted before backend tools finished → client dispatched its follow-up run early →ConcurrencyExceptionToolCallEndand flush only after backend results arriveToolCallEnds were lost, leavingTOOL_CALL_STARTwith no matchingENDtoolUseof a turn in one assistant message and matchingtoolResultin the immediately-following user message →ValidationExceptionon the 2nd call_normalize_tool_turns()(+_is_tooluse_only_assistant,_is_toolresult_only_userhelpers) to normalize rebuilt historyRemoved
src/ag_ui_strands/patch_ag_ui_strands.py— the build-time patch script is no longer needed now that the fixes are in source. Confirmed no other code references it.