Summary
In the ag_ui_strands Python adapter (integrations/aws-strands/python), frontend-defined tools used together with a server-side Strands SessionManager leave the persisted session store incorrect. The client-proxy tool persists the literal "Forwarded to client" placeholder as the canonical toolResult, and the real result returned by the client is never reconciled back into Strands' persisted .messages.
What works vs. what doesn't
- Works: the immediate round-trip / HITL turn. On the continuation run the real result is forwarded to the model as a synthetic user message (
"{tool} returned: {result}"), so the model can act on the human's decision within that turn.
- Broken: the persisted session store. The canonical
toolResult in the session stays "Forwarded to client" permanently. On session resume, multi-turn continuations, export, or audit, the frontend tool's real result is absent — only the placeholder plus a stray synthetic user message remain.
Mechanism
-
The proxy tool returns the placeholder result — client_proxy_tool.py → {"status":"success","content":[{"text":"Forwarded to client"}]}.
-
With a SessionManager wired, Strands owns .messages persistence, so it persists that placeholder toolResult into the session store.
-
History reconciliation is force-disabled whenever a session manager is present — agent.py:
replay_history = (
self.config.replay_history_into_strands
and not _has_strands_session_manager(strands_agent)
)
So _build_strands_history(input_data.messages) — which holds the real client result — is never applied to strands_agent.messages and never persisted.
-
Instead the legacy path runs (stream_async(user_message)), where user_message is the synthetic "{tool} returned: {result}" string. Good enough for the current turn; does nothing for the store.
-
The behavior is currently locked in by tests/test_session_manager.py::test_private_session_manager_disables_replay_history, which asserts .messages is never touched when a session manager exists.
Why it can't simply be toggled on
You can't safely do strands_agent.messages = native_history when a SessionManager owns persistence — Strands appends/syncs via its own lifecycle, so an in-place overwrite either won't persist or will desync the store. A correct fix has to reconcile through the SessionManager (rewrite/redact the persisted "Forwarded to client" placeholder using the real result from input_data.messages), or avoid persisting a placeholder for proxy tools altogether.
Proposed direction
- On a continuation run where the client sent the real result for a proxied frontend tool, locate the persisted placeholder
toolResult in the session and rewrite it with the real content via the SessionManager's own API (redact/replace + sync), rather than skipping reconciliation entirely.
- Alternatively, defer persisting proxy-tool results until the real client result arrives.
- Add regression coverage: after a frontend-tool round-trip with a session manager, assert the persisted session
toolResult equals the real client result, not "Forwarded to client".
Workarounds today
- Round-trip / HITL only (model acts on the human decision, concludes the turn): works today even with a SessionManager — the only cost is polluted persisted history.
- Need a correct persisted store (multi-turn / resume / audit): don't use a server-side SessionManager for frontend-tool threads; rely on the frontend as the source of truth with
replay_history_into_strands=True (the default) — the client re-sends full history each run and _build_strands_history reconciles it cleanly.
- Need both persistence and correct FE-tool results: requires a custom SessionManager whose append/sync step rewrites the placeholder using
input_data.messages. There is no built-in for this yet.
Summary
In the
ag_ui_strandsPython adapter (integrations/aws-strands/python), frontend-defined tools used together with a server-side StrandsSessionManagerleave the persisted session store incorrect. The client-proxy tool persists the literal"Forwarded to client"placeholder as the canonicaltoolResult, and the real result returned by the client is never reconciled back into Strands' persisted.messages.What works vs. what doesn't
"{tool} returned: {result}"), so the model can act on the human's decision within that turn.toolResultin the session stays"Forwarded to client"permanently. On session resume, multi-turn continuations, export, or audit, the frontend tool's real result is absent — only the placeholder plus a stray synthetic user message remain.Mechanism
The proxy tool returns the placeholder result —
client_proxy_tool.py→{"status":"success","content":[{"text":"Forwarded to client"}]}.With a
SessionManagerwired, Strands owns.messagespersistence, so it persists that placeholdertoolResultinto the session store.History reconciliation is force-disabled whenever a session manager is present —
agent.py:So
_build_strands_history(input_data.messages)— which holds the real client result — is never applied tostrands_agent.messagesand never persisted.Instead the legacy path runs (
stream_async(user_message)), whereuser_messageis the synthetic"{tool} returned: {result}"string. Good enough for the current turn; does nothing for the store.The behavior is currently locked in by
tests/test_session_manager.py::test_private_session_manager_disables_replay_history, which asserts.messagesis never touched when a session manager exists.Why it can't simply be toggled on
You can't safely do
strands_agent.messages = native_historywhen aSessionManagerowns persistence — Strands appends/syncs via its own lifecycle, so an in-place overwrite either won't persist or will desync the store. A correct fix has to reconcile through the SessionManager (rewrite/redact the persisted"Forwarded to client"placeholder using the real result frominput_data.messages), or avoid persisting a placeholder for proxy tools altogether.Proposed direction
toolResultin the session and rewrite it with the real content via the SessionManager's own API (redact/replace + sync), rather than skipping reconciliation entirely.toolResultequals the real client result, not"Forwarded to client".Workarounds today
replay_history_into_strands=True(the default) — the client re-sends full history each run and_build_strands_historyreconciles it cleanly.input_data.messages. There is no built-in for this yet.