From f5522b139295f372ff61cdd4e2389f642306cfe4 Mon Sep 17 00:00:00 2001 From: Owen Wang Date: Tue, 21 Jul 2026 07:11:48 +0000 Subject: [PATCH 1/2] fix(aws-strands): fIx concurrent frontend tool result handling --- .../python/src/ag_ui_strands/agent.py | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/integrations/aws-strands/python/src/ag_ui_strands/agent.py b/integrations/aws-strands/python/src/ag_ui_strands/agent.py index 53eb22d847..487c6a485f 100644 --- a/integrations/aws-strands/python/src/ag_ui_strands/agent.py +++ b/integrations/aws-strands/python/src/ag_ui_strands/agent.py @@ -778,43 +778,34 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: # understands the context and can generate a proper conclusion. user_message = "" if pending_tool_result_ids and input_data.messages: + # Collect ALL trailing tool results (not just the first). A parallel + # frontend-tool turn sends N results in one continuation run; the model + # must see every answer. + _result_parts: list[str] = [] for msg in reversed(input_data.messages): if msg.role == "tool" and hasattr(msg, "tool_call_id"): tool_name = _tool_call_id_to_name.get(msg.tool_call_id) if tool_name and tool_name in frontend_tool_names: - # Forward the ACTUAL frontend tool result so the model - # can act on the human's decision (e.g. an approval - # resolving to {"approved": false}). Previously this - # discarded ``msg.content`` and hardcoded a success - # string, silently breaking HITL — the model was told - # the tool "executed successfully with no return value" - # regardless of what the human actually returned. - # Only fall back to that synthetic acknowledgement when - # the result is genuinely empty. result_text = ( msg.content if isinstance(msg.content, str) else flatten_content_to_text(msg.content) ) if result_text and result_text.strip(): - user_message = f"{tool_name} returned: {result_text}" + _result_parts.append(f"{tool_name} returned: {result_text}") else: - user_message = f"{tool_name} executed successfully with no return value." + _result_parts.append( + f"{tool_name} executed successfully with no return value." + ) else: - # Could not resolve the executed tool's name from - # input messages or session history. Leave the - # continuation message empty rather than guessing: - # picking an arbitrary frontend tool would feed false - # context to the LLM when several frontend tools exist. - # Strands still has the real tool result in session - # history to conclude the round-trip from. logger.warning( f"Could not resolve tool name for tool_call_id={msg.tool_call_id} " - f"from input messages or session history (assistant message with " - f"tool_calls may be missing — delta-only payload). Leaving the " - f"continuation message empty." + f"from input messages or session history (delta-only payload). " + f"Skipping this tool result in the continuation message." ) + else: break + user_message = "\n".join(reversed(_result_parts)) elif input_data.messages: for msg in reversed(input_data.messages): if (msg.role == "user" or msg.role == "tool") and msg.content: From a9e0eac0d1ed8e450d90ea348b9492a01c0887b6 Mon Sep 17 00:00:00 2001 From: Owen Wang Date: Thu, 6 Aug 2026 07:56:24 +0000 Subject: [PATCH 2/2] Address comments on enhance tests and keep rationale inline comment --- .../python/src/ag_ui_strands/agent.py | 14 ++++++ .../python/tests/test_session_manager.py | 45 ++++++++++++++++--- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/integrations/aws-strands/python/src/ag_ui_strands/agent.py b/integrations/aws-strands/python/src/ag_ui_strands/agent.py index 487c6a485f..619acdd1c2 100644 --- a/integrations/aws-strands/python/src/ag_ui_strands/agent.py +++ b/integrations/aws-strands/python/src/ag_ui_strands/agent.py @@ -786,6 +786,13 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: if msg.role == "tool" and hasattr(msg, "tool_call_id"): tool_name = _tool_call_id_to_name.get(msg.tool_call_id) if tool_name and tool_name in frontend_tool_names: + # Forward the ACTUAL result so the model can act on the + # human's decision (e.g. an approval resolving to + # {"approved": false}). Hardcoding a success string here + # silently breaks HITL — the model would be told the tool + # "executed successfully with no return value" regardless + # of what the human returned. Only use that synthetic + # acknowledgement when the result is genuinely empty. result_text = ( msg.content if isinstance(msg.content, str) @@ -798,6 +805,13 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: f"{tool_name} executed successfully with no return value." ) else: + # Could not resolve this tool's name from input messages + # or session history (e.g. a delta-only payload with no + # assistant tool_calls). Skip it rather than guessing: + # picking an arbitrary frontend tool would feed false + # context to the LLM when several frontend tools exist. + # Strands still has the real result in session history to + # conclude the round-trip from. logger.warning( f"Could not resolve tool name for tool_call_id={msg.tool_call_id} " f"from input messages or session history (delta-only payload). " diff --git a/integrations/aws-strands/python/tests/test_session_manager.py b/integrations/aws-strands/python/tests/test_session_manager.py index 94bb1da72c..1f1e862320 100644 --- a/integrations/aws-strands/python/tests/test_session_manager.py +++ b/integrations/aws-strands/python/tests/test_session_manager.py @@ -760,15 +760,50 @@ async def test_partially_resolvable_turn_falls_back_to_legacy(self, tmp_path): wire_map={"wire-1": "native-1"}, # wire-2 missing -> unresolvable store=store, ) - # Not all non-void results resolved -> legacy fallback: a single - # synthetic user message (not None/empty). The resolvable result's store - # placeholder is still corrected (partial correction is safe — the value - # is real); only the model-facing continuation falls back. - assert instance.stream_prompts == ["approve returned: R2"] + # Not all non-void results resolved -> legacy fallback: a synthetic user + # message (not None/empty). The fallback forwards EVERY frontend result in + # the turn, not just the last — the model must see both answers. The + # resolvable result's store placeholder is still corrected (partial + # correction is safe — the value is real); only the model-facing + # continuation falls back. + assert instance.stream_prompts == ["approve returned: R1\napprove returned: R2"] assert _result_content(sm, "default", 1)[0]["toolResult"]["content"] == [ {"text": "R1"} ] + @pytest.mark.asyncio + async def test_legacy_fallback_forwards_every_frontend_result(self, tmp_path): + # A parallel frontend-tool turn returns N results in one continuation. On + # the legacy path (here: no wire->native map, so nothing reconciles) the + # synthetic user message must carry EVERY result, in call order — not just + # the last one. Guards against re-introducing a ``break`` after the first + # result, which would silently drop the model's view of the other answers. + from strands.session.file_session_manager import FileSessionManager + + sm = FileSessionManager(session_id="thread-multi-legacy", storage_dir=str(tmp_path)) + instance = await _run_session_continuation( + sm, + "default", + messages=[ + _payload_assistant("wire-1", "approve", "{}"), + _payload_assistant("wire-2", "setColor", '{"color": "blue"}'), + _payload_tool("wire-1", "R1"), + _payload_tool("wire-2", "R2"), + ], + tools=[_frontend_tool("approve"), _frontend_tool("setColor")], + wire_map={}, # nothing resolves -> legacy fallback for the whole turn + store=[ + _store_tool_use("native-1", "approve", {}), + _store_placeholder("native-1"), + _store_tool_use("native-2", "setColor", {"color": "blue"}), + _store_placeholder("native-2"), + ], + ) + # Both results reach the model, in the order the tools were called. + assert instance.stream_prompts == [ + "approve returned: R1\nsetColor returned: R2" + ] + @pytest.mark.asyncio async def test_historical_void_placeholder_does_not_block_reconcile(self, tmp_path): # A prior void frontend call left a permanent placeholder in the store.