Skip to content

fix(aws-strands): carry the client's tool failure onto the reconciled toolResult status - #2363

Merged
contextablemark merged 3 commits into
ag-ui-protocol:mainfrom
TheSeydiCharyyev:fix/2361-strands-session-reconcile-tool-error
Aug 14, 2026
Merged

fix(aws-strands): carry the client's tool failure onto the reconciled toolResult status#2363
contextablemark merged 3 commits into
ag-ui-protocol:mainfrom
TheSeydiCharyyev:fix/2361-strands-session-reconcile-tool-error

Conversation

@TheSeydiCharyyev

Copy link
Copy Markdown
Contributor

Fixes #2361. Follow-up to #2317, on the branch that fix does not reach.

The gap

_build_strands_history stamps toolResult.status from ToolMessage.error after #2317, but it has one production caller, inside the replay_history branch. When a session_manager_provider is configured, the run takes reconcile_session_results instead and the model reads the persisted history. Those toolResult blocks were written by _proxy_func with "status": "success" hardcoded (client_proxy_tool.py:58), and _correct_message rewrote only content — so on that path a failed frontend tool was still asserted to the model as a success.

There was no channel for the flag either: pending_results was Mapping[str, str], native toolUseId -> text.

The change

Widen the reconcile channel to carry the flag next to the text, as sketched on the issue:

  • agent.py:1015 — each frontend_results entry also carries is_error, read from msg.error.
  • session_reconcile.pyresolve_native_ids returns native toolUseId -> (text, is_error).
  • session_reconcile.py_correct_message writes tool_result["status"] in the same place where it rewrites tool_result["content"]. The status is always written, not only on failure, since the value it replaces is the proxy's placeholder rather than a real result.
  • agent.py:1119 — the resolved_non_void comprehension unpacks the new value shape.

session_reconcile is not exported from __init__.py, so the signature change stays inside the package. No public API moves.

Tests

pytest on integrations/aws-strands/python: 206 passed, 2 skipped — 199 before this branch, plus 7 new.

New cases, in test_session_reconcile.py:

  • error flag lands as status: "error" on the persisted block
  • no error keeps status: "success"
  • the in-memory agent.messages copy is stamped too, not just the store
  • two parallel results in one message get independent statuses
  • a block that is not a placeholder is left alone, so an unrelated error flag cannot leak onto it

And in test_session_manager.py, end to end through run(): a client ToolMessage with error set reaches the store as status: "error" with the real text, and a successful one stays "success".

Checked that the tests fail for the right reason: with the tool_result["status"] line removed, 4 of them fail and the rest of the suite stays green.

Scope

Two things left out on purpose, both noted on the issue before I started:

  1. The legacy branch (agent.py:1138). It has the same shape, but there the real result reaches the model as a synthetic user message, so there is no toolResult block to stamp. That is a different change.

  2. Void results. reconcile_session_results is gated on has_nonvoid_frontend_result (agent.py:1037) and ToolMessage.content is a required str, so a failed tool returning content="" with error set never reaches reconciliation — it falls to the legacy path and the placeholder keeps "success". Widening that gate changes when reconciliation runs at all, which is more than this issue asks for. I can fold it in here instead if you prefer one pass.

@contextablemark contextablemark left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good fix overall—the client error flag is carried through ID resolution and stamped into both persisted and in-memory history.

I think we should include the empty-content failure you identified before merging. It currently takes the successful-void fallback, leaving the persisted placeholder marked as successful, so issue #2361 remains behaviorally incomplete.

The inline suggestions keep successful void results unchanged while routing failed void results through reconciliation, and parameterize your existing end-to-end test rather than duplicating its setup.

I verified the suggestions against current main: the empty case fails before the gate change, then the full suite passes with 212 passed and 2 skipped, and the package builds successfully.

resolved_native_results: Dict[str, Tuple[str, bool]] = {}
corrected_native_ids: set[str] = set()
has_nonvoid_frontend_result = any(
(r["text"] or "").strip() for r in frontend_results

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

An errored result with empty content still takes the successful-void path because this gate only examines text. Including is_error preserves successful void handling while allowing the failed placeholder to be reconciled.

Suggested change
(r["text"] or "").strip() for r in frontend_results
(r["text"] or "").strip() or r["is_error"] for r in frontend_results

Comment on lines +631 to +653
@pytest.mark.asyncio
async def test_client_reported_failure_lands_as_an_error_status(self, tmp_path):
# The placeholder was written by the proxy tool with a hardcoded
# "success" status. Reconciliation must overwrite the status as well as
# the text, or the model is told a failed frontend tool succeeded.
from strands.session.file_session_manager import FileSessionManager

sm = FileSessionManager(session_id="thread-errstatus", storage_dir=str(tmp_path))
instance = await _run_session_continuation(
sm,
"default",
messages=[
_payload_assistant("wire-1", "approve"),
_payload_tool("wire-1", "tool failed: invalid id", error="invalid id"),
],
tools=[_frontend_tool("approve")],
wire_map={"wire-1": "native-1"},
store=[_store_tool_use("native-1", "approve"), _store_placeholder("native-1")],
)
assert instance.stream_prompts == [None]
block = _result_content(sm, "default", 1)[0]["toolResult"]
assert block["content"] == [{"text": "tool failed: invalid id"}]
assert block["status"] == "error"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we make the existing end-to-end case cover both non-empty and empty failures? This proves that an empty failure actually enters reconciliation and persists the error status without duplicating the fixture.

Suggested change
@pytest.mark.asyncio
async def test_client_reported_failure_lands_as_an_error_status(self, tmp_path):
# The placeholder was written by the proxy tool with a hardcoded
# "success" status. Reconciliation must overwrite the status as well as
# the text, or the model is told a failed frontend tool succeeded.
from strands.session.file_session_manager import FileSessionManager
sm = FileSessionManager(session_id="thread-errstatus", storage_dir=str(tmp_path))
instance = await _run_session_continuation(
sm,
"default",
messages=[
_payload_assistant("wire-1", "approve"),
_payload_tool("wire-1", "tool failed: invalid id", error="invalid id"),
],
tools=[_frontend_tool("approve")],
wire_map={"wire-1": "native-1"},
store=[_store_tool_use("native-1", "approve"), _store_placeholder("native-1")],
)
assert instance.stream_prompts == [None]
block = _result_content(sm, "default", 1)[0]["toolResult"]
assert block["content"] == [{"text": "tool failed: invalid id"}]
assert block["status"] == "error"
@pytest.mark.parametrize(
"content", ["tool failed: invalid id", ""], ids=["with-text", "empty"]
)
@pytest.mark.asyncio
async def test_client_reported_failure_lands_as_an_error_status(
self, tmp_path, content
):
# The placeholder was written by the proxy tool with a hardcoded
# "success" status. Reconciliation must overwrite the status as well as
# the text, or the model is told a failed frontend tool succeeded.
from strands.session.file_session_manager import FileSessionManager
sm = FileSessionManager(session_id="thread-errstatus", storage_dir=str(tmp_path))
instance = await _run_session_continuation(
sm,
"default",
messages=[
_payload_assistant("wire-1", "approve"),
_payload_tool("wire-1", content, error="invalid id"),
],
tools=[_frontend_tool("approve")],
wire_map={"wire-1": "native-1"},
store=[_store_tool_use("native-1", "approve"), _store_placeholder("native-1")],
)
assert instance.stream_prompts == [None]
block = _result_content(sm, "default", 1)[0]["toolResult"]
assert block["content"] == [{"text": content}]
assert block["status"] == "error"

@@ -1023,7 +1032,7 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]:
# empty toolResult. When reconciling, void placeholders in the same

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please keep the nearby explanation aligned with the broadened gate.

Suggested change
# empty toolResult. When reconciling, void placeholders in the same
# empty toolResult. A failed void result is the exception: it must
# reconcile so its status replaces the proxy's hardcoded success.
# When reconciling, void placeholders in the same

… toolResult status

_build_strands_history stamps toolResult.status from ToolMessage.error, but it runs only on the replay_history branch. With a session_manager configured, the run reconciles the persisted history instead, and reconciliation rewrote only the content - leaving the proxy's hardcoded success status in place. A failed frontend tool still reached the model as a success on that path.

Widen the reconcile channel to carry the flag alongside the text: resolve_native_ids returns (text, is_error) per native toolUseId, and _correct_message writes the status where it rewrites the content.
An errored result with empty content took the successful-void fallback, so its persisted placeholder kept the proxy's hardcoded success status. The gate now also admits results carrying the error flag, leaving successful void handling unchanged.

The end-to-end case is parameterized over non-empty and empty content instead of duplicating the fixture, proving the empty failure reaches reconciliation and persists the error status.
@TheSeydiCharyyev
TheSeydiCharyyev force-pushed the fix/2361-strands-session-reconcile-tool-error branch from 5a5cac9 to c6b64f3 Compare August 14, 2026 09:24
@TheSeydiCharyyev

Copy link
Copy Markdown
Contributor Author

All three suggestions applied, rebased onto current main (e05916dd).

You are right that leaving the empty case out made the fix behaviourally incomplete. The gate now reads (r["text"] or "").strip() or r["is_error"], so a failed void result reconciles while a successful void one still takes the synthetic-message path. The nearby comment says why the exception exists, and the end-to-end case is parameterized over ["tool failed: invalid id", ""] rather than duplicated.

My numbers match yours: 212 passed, 2 skipped. Checked the direction as well — with the gate narrowed back to text only, the [empty] case fails and the other 26 stay green, so the parameter is carrying its weight.

One consequence worth naming, since it is the reason the old gate looked reasonable: a failed void result now clears its placeholder to "" and streams None instead of forwarding the synthetic "executed successfully with no return value" message. That is the intended outcome — the model should not be told a failed call succeeded — but it is a behaviour change on that path, not only a status stamp.

On the ordering with #2387: nothing in my branch touches _correct_single_tool, so once this lands your refactor moves the write into one place. The status == "success" early return there will need to compare against the expected status rather than the literal, or an already-reconciled failure stops being recognised as reconciled.

@github-actions

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1786713500 published to TestPyPI.

Warning: These packages are built from contributor code that may not yet have been vetted for correctness or security. Install at your own risk and do not use in production.

Install with uv

Add the TestPyPI index to your pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true

Then install the packages you need:

# Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1786713500' --index testpypi

# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1786713500' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1786713500' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1786713500' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1786713500' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1786713500' --index testpypi

Install with pip

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  ag-ui-protocol==0.0.0.dev1786713500

Use --extra-index-url https://pypi.org/simple/ so pip can resolve
transitive dependencies (pydantic, fastapi, etc.) from real PyPI.


Commit: 77029cf

@pkg-pr-new

pkg-pr-new Bot commented Aug 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

@ag-ui/a2a-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a-middleware@2363

@ag-ui/a2ui-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-middleware@2363

@ag-ui/event-throttle-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/event-throttle-middleware@2363

@ag-ui/mcp-apps-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-apps-middleware@2363

@ag-ui/mcp-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-middleware@2363

@ag-ui/a2a

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a@2363

@ag-ui/adk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk@2363

@ag-ui/ag2

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/ag2@2363

@ag-ui/agno

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/agno@2363

@ag-ui/aws-strands

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/aws-strands@2363

@ag-ui/claude-agent-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-agent-sdk@2363

@ag-ui/claude-managed-agents

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-managed-agents@2363

@ag-ui/crewai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/crewai@2363

@ag-ui/langchain

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langchain@2363

@ag-ui/langgraph

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langgraph@2363

@ag-ui/llamaindex

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/llamaindex@2363

@ag-ui/mastra

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mastra@2363

@ag-ui/pydantic-ai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/pydantic-ai@2363

@ag-ui/vercel-ai-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/vercel-ai-sdk@2363

@ag-ui/watsonx

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/watsonx@2363

@ag-ui/a2ui-toolkit

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-toolkit@2363

create-ag-ui-app

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/create-ag-ui-app@2363

@ag-ui/client

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/client@2363

@ag-ui/core

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/core@2363

@ag-ui/encoder

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/encoder@2363

@ag-ui/proto

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/proto@2363

commit: 69d57c6

@contextablemark contextablemark left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The requested empty-content reconciliation and regression coverage are now included. All checks pass.

@contextablemark
contextablemark merged commit 42fb9b4 into ag-ui-protocol:main Aug 14, 2026
45 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: aws-strands reports failed frontend tools as success on the session-manager path

2 participants