Skip to content
Merged
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
43 changes: 24 additions & 19 deletions integrations/aws-strands/python/src/ag_ui_strands/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -778,43 +778,48 @@ 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.
# 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)
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:
# 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 tool result in session
# history to conclude the round-trip from.
# 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 (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:
Expand Down
45 changes: 40 additions & 5 deletions integrations/aws-strands/python/tests/test_session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading