fix(aws-strands): carry, refuse, and drop what the Python continuation could not - #2589
fix(aws-strands): carry, refuse, and drop what the Python continuation could not#2589ranst91 wants to merge 2 commits into
Conversation
…n could not Three behaviours the TypeScript bridge already had and Python did not. A declined reconciliation was silent, and could lose the client's answer outright. It now says which ids no correction landed for. On the message path it carries those answers into the outgoing prompt ahead of a newer user message, which used to replace them, so the answer reached the model in neither the history nor the prompt. The wording is the trailing derivation's own, extracted into `_continuation_result_line` so one answer reaches the model in one form whichever prompt carries it. On the resume path there is no continuation prompt to carry anything, so a decline left the model reading the uncorrected "Forwarded to client" placeholder as the client's answer. That run is refused under the existing `INTERRUPT_RECONCILIATION_ERROR`. It is the one reconciliation gate that must run after the first write, since only the attempt says a correction declined, and the test says so rather than asserting an atomicity this gate cannot offer. `_build_strands_history` kept a `toolResult` that no `toolUse` in the replayed history answers, which real providers reject. It now tracks the ids it has emitted and drops a result that matches none, the way the seed conversion already did. Two tests in `test_tool_error_status.py` pinned the old shape by building a lone tool message; they pair it with the call it answers now, which is what they were testing about status either way.
Python Preview PackagesVersion
Install with uvAdd the TestPyPI index to your [[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = trueThen install the packages you need: # Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1788254878' --index testpypi
# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1788254878' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1788254878' --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.dev1788254878' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1788254878' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1788254878' --index testpypiInstall with pippip install \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
ag-ui-protocol==0.0.0.dev1788254878
Commit: 947d3ca |
@ag-ui/a2a-middleware
@ag-ui/a2ui-middleware
@ag-ui/event-throttle-middleware
@ag-ui/mcp-apps-middleware
@ag-ui/mcp-middleware
@ag-ui/a2a
@ag-ui/adk
@ag-ui/ag2
@ag-ui/agno
@ag-ui/aws-strands
@ag-ui/claude-agent-sdk
@ag-ui/claude-managed-agents
@ag-ui/crewai
@ag-ui/langchain
@ag-ui/langgraph
@ag-ui/llamaindex
@ag-ui/mastra
@ag-ui/pydantic-ai
@ag-ui/vercel-ai-sdk
@ag-ui/watsonx
@ag-ui/a2ui-toolkit
create-ag-ui-app
@ag-ui/client
@ag-ui/core
@ag-ui/encoder
@ag-ui/proto
commit: |
contextablemark
left a comment
There was a problem hiding this comment.
Changes required: The new orphan-result filter drops a legitimate delta-only frontend tool result on the default no-session-manager path. We then replace the agent’s cached conversation with an empty history and invoke the model without either the result or prior context, producing an error or a plausible but incorrect response. Delta-only continuations are a real CopilotKit payload, so this path needs to preserve the cached history or carry the result in the prompt before merging.
… answer away Review of the previous commit turned up three problems with it. All three are the same mistake: a signal was read as if it proved the thing it only sometimes accompanies. Dropping a tool result no replayed call answers is right, but replaying what is left is not. A delta-only continuation carries the result without the assistant message that opened the call, so the rebuilt history loses the client's answer, and since replay replaces the agent's history wholesale it loses the whole conversation with it: the model was handed an empty history and no prompt, and the run still reported success. That is the re-fire loop this derivation exists to prevent, reached on the default configuration. `_build_strands_history` now reports the ids it left out, and a turn that would drop one keeps the history it has and says the answer in the continuation prompt instead. The resume refusal read a declined correction as proof that an uncorrected placeholder remains. It is not: a decline also describes an id with nothing left to correct anywhere, and a long-lived thread accumulates those, because a recorded id is kept until its placeholder is corrected and one whose placeholder is already gone is re-admitted and re-declined on every later turn. Refusing on the decline wedged that thread permanently, including resumes whose own interrupt reconciled cleanly. The condition is the stub the model would read, so that is what the gate reads now. Today's rewriter always corrects a stub it was given an answer for, so the two cannot co-occur through it and the refusal is a net rather than a live path; the test drives it from the condition directly rather than from a rewriter that cannot produce it. The carry only ran on the reconciliation branch, so with `replay_history_into_strands=False` a non-trailing answer followed by a newer user message was still dropped, which is the case this work set out to fix. The legacy branch fills the same list now, matching what the TypeScript sibling does for a plain prompt plan.
Ports three behaviours from the TypeScript bridge into the Python adapter. They are the three the architecture notes list as things the TypeScript continuation decision does that Python's does not, and they are the whole of that list. A second commit corrects three problems a review of the first one turned up.
A declined reconciliation was silent, and could lose the client's answer
reconcile_frontend_tool_resultscan return without correcting a result it was asked to correct: an admitted id whose placeholder is no longer anywhere reconciliation looks has nothing to rewrite. Python logged nothing for that, and when a newer user message followed the tool result the outgoing prompt was that user message, so the client's answer reached the model in neither the corrected history nor the prompt.The declined ids are now named in a warning, and the answers are carried into the outgoing prompt ahead of the user's own text. The wording is the trailing derivation's own, extracted into
_continuation_result_lineso one answer reaches the model in one form whichever prompt carries it. Every branch that leaves an answer out of the history fills the same list, including the legacy path taken whenreplay_history_into_strandsis off, which is a configuration the first commit still dropped the answer on.The resume path has no prompt to carry anything
So an uncorrected
"Forwarded to client"placeholder is what the model would read for the client's answer. That run is refused under the existingINTERRUPT_RECONCILIATION_ERROR, with the message the TypeScript bridge already emits, byte for byte.It is the one reconciliation gate that must run after the first write, since only the attempt itself says a correction declined. The corrections that did land are therefore already persisted when it fires, and the test says that rather than asserting an atomicity this gate cannot offer.
What the gate reads is the remaining stub, not the decline. Those are not the same thing: a decline also describes an id with nothing left to correct anywhere, which a long-lived thread accumulates, because a recorded id is kept until its placeholder is corrected and one whose placeholder is already gone is re-admitted and re-declined on every later turn. Refusing on the decline wedged such a thread permanently, including resumes whose own interrupt reconciled cleanly.
Worth stating plainly: today's rewriter always corrects a stub it was handed an answer for, so a decline and a remaining stub cannot co-occur through it, and this gate is a net rather than a live path in Python. It is driven in the test from the condition itself rather than from a rewriter that cannot produce it.
The replayed history kept tool results nothing answered
_build_strands_historyemitted atoolResulteven when notoolUsein the replayed history answered it. Real providers reject that history, so replaying one turned a turn the continuation prompt could still have carried into a generic provider failure. It now tracks the ids it has emitted and drops a result matching none, the way the seed conversion already did.Dropping is only half of it. A delta-only continuation carries the result without the assistant message that opened the call, so the rebuilt history loses the answer, and since replay replaces the agent's history wholesale it loses the whole conversation with it. The first commit handed the model an empty history and no prompt, with the run still reporting success, which is the re-fire loop this derivation exists to prevent, on the default configuration.
_build_strands_historynow reports the ids it left out, and a turn that would drop one keeps the history it has and says the answer in the continuation prompt instead.Tests
New tests are organised by behaviour rather than one file per fix:
tests/test_history_replay.pycovers what the replayed history keeps and drops.tests/test_reconciliation_decline.pycovers what happens when reconciliation corrects nothing, split into the message path that carries, the resume path that refuses, the replay path that stands down, and the disabled-reconciliation path.Eleven of the nineteen fail against the code they were written for; the rest are control cases whose behaviour is unchanged.
Two existing tests in
tests/test_tool_error_status.pypinned the old shape by handing_build_strands_historya lone tool message. They were testing the error-to-status mapping either way, so they now pair the result with the call it answers.python -m pytest testsreports 993 passed, up from 976.Not included
TypeScript also fails the run closed when a carried answer cannot be named. Python logs and leaves that answer out of the prompt instead. Worth closing separately if the two bridges should be identical here.
The architecture notes still describe all three of these as TypeScript-only. That entry lives on the branch carrying the TypeScript change and is not in this diff; it needs striking wherever the two meet.