Skip to content

fix(aws-strands): carry, refuse, and drop what the Python continuation could not - #2589

Open
ranst91 wants to merge 2 commits into
mainfrom
claude/nervous-dhawan-0ffc59
Open

fix(aws-strands): carry, refuse, and drop what the Python continuation could not#2589
ranst91 wants to merge 2 commits into
mainfrom
claude/nervous-dhawan-0ffc59

Conversation

@ranst91

@ranst91 ranst91 commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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_results can 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_line so 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 when replay_history_into_strands is 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 existing INTERRUPT_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_history emitted a toolResult even when no toolUse in 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_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.

Tests

New tests are organised by behaviour rather than one file per fix:

  • tests/test_history_replay.py covers what the replayed history keeps and drops.
  • tests/test_reconciliation_decline.py covers 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.py pinned the old shape by handing _build_strands_history a 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 tests reports 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.

…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.
@ranst91
ranst91 requested a review from a team as a code owner August 31, 2026 14:48
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1788254878 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.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 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.dev1788254878

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


Commit: 947d3ca

@pkg-pr-new

pkg-pr-new Bot commented Aug 31, 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@2589

@ag-ui/a2ui-middleware

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

@ag-ui/event-throttle-middleware

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

@ag-ui/mcp-apps-middleware

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

@ag-ui/mcp-middleware

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

@ag-ui/a2a

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

@ag-ui/adk

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

@ag-ui/ag2

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

@ag-ui/agno

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

@ag-ui/aws-strands

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

@ag-ui/claude-agent-sdk

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

@ag-ui/claude-managed-agents

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

@ag-ui/crewai

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

@ag-ui/langchain

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

@ag-ui/langgraph

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

@ag-ui/llamaindex

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

@ag-ui/mastra

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

@ag-ui/pydantic-ai

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

@ag-ui/vercel-ai-sdk

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

@ag-ui/watsonx

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

@ag-ui/a2ui-toolkit

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

create-ag-ui-app

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

@ag-ui/client

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

@ag-ui/core

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

@ag-ui/encoder

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

@ag-ui/proto

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

commit: 2e08f4e

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

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

2 participants