Skip to content

aws-strands (Python): frontend-tool result never reconciled into the SessionManager store #2222

Description

@contextablemark

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

  1. The proxy tool returns the placeholder result — client_proxy_tool.py{"status":"success","content":[{"text":"Forwarded to client"}]}.

  2. With a SessionManager wired, Strands owns .messages persistence, so it persists that placeholder toolResult into the session store.

  3. 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.

  4. 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.

  5. 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

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions