feat(sdk): add Strands adapter and A2A SDK 1.x compatibility - #489
Open
AlexanderZ-Band wants to merge 25 commits into
Open
feat(sdk): add Strands adapter and A2A SDK 1.x compatibility#489AlexanderZ-Band wants to merge 25 commits into
AlexanderZ-Band wants to merge 25 commits into
Conversation
Add StrandsAdapter (SimpleAdapter[StrandsMessages]) + StrandsHistoryConverter for AWS Strands Agents (strands-agents>=1.40,<2), mirroring the pydantic-ai adapter: platform tools as native @tool functions (per-turn tools handle via invocation_state), CustomToolDef bridged through a native AgentTool, L6 tool_call/tool_result via Before/AfterToolCall hooks, per-turn TurnUsage from accumulated metrics, terminal detection via is_terminal_success. A Strands Agent is stateful (owns messages, throws on concurrent invocation), so the adapter builds prompt+tools once in on_started and constructs a lightweight per-turn Agent over band-owned per-room history — the same fresh-runner-per-message shape as the google_adk adapter. Registered across the strands/dev extras, lazy adapter import, the baseline matrix (core lane via Dep.OPENAI), and the conformance config registries; Tier-1 injection spike proves scripted-model -> real loop -> typed dispatch with ordered/paired L6 events and a negative control. ✌️
Adding Adapter.STRANDS without a SUPPORT entry broke
assert_support_is_complete, failing every `test` CI job. The adapter's
seam is the injectable strands Model it passes to Agent(model=...), so it
registers as supported rather than with a reason.
The spike file carried that binding as prose ("recorded here until a
registry exists"); the registry is the source of truth, so the prose copy
is gone and the docstring points at it instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ToolEventKey arrived as the canonical vocabulary for execution-event payloads, but only the parser and the strands adapter used it — every other producer still spelled the keys as literals, so a typo would still fail silently on the read side. Every tool_call/tool_result payload now keys off the enum. The wire format is unchanged (StrEnum serializes to the same strings); adapter vocabularies that only look similar are left alone, e.g. Anthropic's own tool_result content block and the ACP permission metadata. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lazy_exports carried machinery nothing needed: a duplicate-name check, a setattr cache importlib already makes cheap, and a __dir__ the packages never had. It is now the mapping plus a PEP 562 __getattr__, and each package declares its exports as `submodule=[names]` keyword arguments, so module names are identifiers instead of quoted keys. Conditional (try/except ImportError) imports were the alternative and are worse: they would import every installed framework on `import band.adapters` and swallow genuine ImportErrors from inside a module. The export names still have to be strings — importing them is exactly what laziness avoids — so a new test compares each package's TYPE_CHECKING block against its lazy_exports call over the parsed source, where a name present in only one of the two now fails loudly. band.converters gained the strands converter it was missing, and band.testing is lazy too, so the strands test model can live beside FakeAgentTools without making the package require the extra. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A custom tool whose name matches a Band platform tool silently replaced it: Strands' registry is last-wins. Construction now fails with the colliding names, and a callable with no derivable name fails there too instead of registering under a junk name. Terminal-name collection zips strictly, since the two lists must line up. The adapter unit tests and the injection spike each carried a near-identical 50-line scripted Model. Both now use band.testing.ScriptedStrandsModel with typed ToolTurn/TextTurn scripts, and the plain-text negative control lives only in the spike. Also documents that Strands' default conversation manager, not the adapter, bounds the persisted transcript. Restores the capability-matrix rationale a docs pass had flattened: why the filters partition the matrix, what a fail-never-skip error means, and why the memory read-back needs the get hop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The adapter shipped invisible: no README row, no emit-matrix entry, and no strands_agent block in agent_config.yaml.example, so both examples failed at Agent.from_config. Adds the missing docs plus four examples covering what the adapter exposes, in the shape the sibling adapters use: a full system_prompt override (and the messaging contract it then has to carry), Strands-native @tool functions with the band_terminal marker, Bedrock via both the bare model id and BedrockModel, and the Tom/Jerry pair on shared credentials. An examples README records the provider gotcha — a bare model string is a Bedrock id, not a provider route. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Room history interleaves: a peer can post while a tool is still running, and the converter emitted that turn between the assistant toolUse and the user toolResult that answers it. Converse (and the OpenAI mapping of it) rejects that shape, and since the transcript is replayed on every later turn, one interleaved message stopped the room from responding for good. Hold turns that arrive inside an open tool exchange and replay them once the pair closes, and answer any toolUse the transcript never got a result for — a dropped or unparseable tool_result event — with a synthetic error result, mirroring the Anthropic converter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…to say The agent answers through tools, so its last response carries no output — which providers spell as a partless response, thinking only, or blank text. With `output_type=str` and the output budget refused, every one of those hard -failed the turn with UnexpectedModelBehavior, including a thinking-only first response before any tool had run, where the swallow does not apply. Allow `None` as an outcome so pydantic-ai ends such a run instead of prompting the model to retry, and normalize blank text into a partless response through an after_model_request hook so the ordinary end of turn takes that same clean path. The refused output budget stays: it is what keeps a retry prompt from making the agent post its reply to the room twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard fires whenever a turn ends without band_send_message, but its text blamed a plain-text final answer. Live runs show the commoner cause is the model returning nothing at all — a model that considers the exchange finished answers with an empty response — so the message sent a reader looking for text that was never there. Name both endings, and keep the wording in one helper next to the terminal-work rule that decides when it fires, instead of retyping it in three adapters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The interrupted-tool placeholder was typed out in three converters, so a reword in one would silently leave the others inconsistent. It now lives in converters/parsing.py next to the parsers that produce the events it stands in for. lazy_exports' resolver never bound its result into the package namespace, so PEP 562 re-entered importlib on every read of a lazily exported name. It now binds on first access, leaving later reads plain lookups. Also names the Strands tool input schema behind a helper that says why it must not be shared, and adds StrandsOutputAdapter to its module's __all__. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A turn arriving inside an open tool exchange is held so it cannot split a toolUse from its toolResult. But a second parallel call released the turns held by the first, emitting the peer message before the assistant message carrying the calls it actually arrived after — a silent reordering of history the model then reasons over. Releasing held turns is now its own step, run only when the exchange has actually closed, not on every tool call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The v1 bridge's task map lived on the client, which lives as long as the adapter. A stream that ended without a terminal state or final flag — a disconnect, a timeout, a crashed remote — left its Task behind with the full history and artifacts it had accumulated, one per abandoned task, for the process's lifetime. The map is now local to the stream it folds. Terminal task states were also defined twice with identical membership; they are now defined once at the SDK compatibility boundary, and a2a.types is imported where the v1 bridge needs it rather than by every importer. The three A2A examples pinned a2a-sdk >=0.3.22,<2 in their PEP 723 headers while importing 1.x-only modules, so uv run could resolve a version that dies at import. They now pin >=1,<2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The crewai coverage guard counted a listed directory as covering every file under it, ignoring the -k crewai filter the CI job applies to that directory — so a future test needing the dev-crewai venv but not matching that name would pass the guard while running in no job at all. The sys.modules eviction guard scanned only test_*.py, missing conftest.py, where an eviction has the widest blast radius: it poisons annotation resolution for every later test in the run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI and E2E were the two workflows without a permissions block, so their jobs ran with the repository's default token scope. Neither writes through GITHUB_TOKEN: E2E's GitHub API work uses E2E_GITHUB_TOKEN, and its scorecard artifacts are downloaded from the same run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AlexanderZ-Band
marked this pull request as ready for review
July 27, 2026 12:18
The adapter's happy path and its dispatch through the framework loop were already pinned by the conformance spike. What no test reached: a provider call that fails mid-turn, a tool call the model got wrong, a custom tool that raises, a successful read-only tool that still owes the room a reply, an event backend that is down while the reply itself succeeds, and a turn after the first, which must not reseed from platform history. The scripted model grows an ErrorTurn so a failing provider call can be scripted like any other turn, and the room, adapter, and tool-result projections are fixtures and helpers now rather than repeated per test. Two tests went the other way: one echoed the constructor's own assignments and one restated two class constants, so neither could fail for a reason that mattered. Also pins two behaviors the last commits introduced without a guard — that a tool's input schema is never shared between turns, since Strands writes into it, and that a resolved lazy export binds into its package namespace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bedrock's Converse rejects a conversation that does not alternate between user and assistant, and the adapter produced same-role neighbours two ways: platform context was appended as its own user message before Strands added the prompt as another, and the converter emitted a peer's turn as a turn of its own behind the tool results it waited for. Context now leads the prompt it belongs to, and the converter merges same-role neighbours the way the Gemini converter already does. An exchange also closed too early. Strands runs a round's tools through a ConcurrentToolExecutor, so its hooks interleave: call C can be recorded after result A. Flushing A's result there stranded B and left C's result answering a toolUse no longer immediately before it — rejected on the next rehydration. An exchange now stays open until every call it made has been answered, which collapses three emit helpers into one close. Two more, both about what the model is told: - The caller's include/exclude/category tool filters were never applied, so a room that excluded band_remove_participant still offered it, and offering it is enough to let the model run it. - A failed tool's persisted event omitted is_error, so a restart replayed the failure to the model as a success. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Real AgnoAgent.arun() sends an async telemetry request to Agno's API unless AGNO_TELEMETRY=false, an accidental live network dependency that hung under CI egress and timed out 6 tests intermittently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a Strands Agents adapter (Tier-1 injectable-model family) plus the wiring
that makes it a first-class adapter in this repo.
Adapter (
src/band/adapters/strands.py)strands.Agentper turn, so each turn's tools own that turn'sAgentToolsProtocoland no room capability leaks into Strands' untypedinvocation_state.AgentTools from the shared Band registry(names, schemas, validation, serialization all stay single-sourced); custom tools
accept both the portable
(InputModel, handler)pair and Strands' own@toolfunctions.
HookProvideremitsEmit.EXECUTIONpairs and decides the turn's terminal-actionpolicy;
Emit.USAGEmaps Strands' accumulated usage.seeded into the fresh
Agent, and read back after the run.Converter (
src/band/converters/strands.py) converts Band history to ConversetoolUse/toolResultmessages, batching parallel calls and preserving valid toolmessage ordering.
Baseline/E2E adds
Adapter.STRANDSto the shared matrix and offline supportregistry, using the core lane and its injectable model seam.
A2A SDK compatibility
Supports both legacy
a2a-sdk0.3 and current 1.x behind one compatibilityboundary. The adapter and gateway keep a stable internal event shape while the
boundary handles request conversion, authentication headers, status history,
artifact chunk accumulation, task eviction, resubscription, and transport shutdown.
Regular CI runs against the locked 1.x SDK. A self-contained legacy job pins
0.3.26, so legacy support can later be removed with one job. Legacy users receive
a once-per-process deprecation warning. The declared range is
>=0.3.22,<2.Standalone A2A server examples now use the 1.x helper, route, protobuf, and
agent-card APIs, with import smoke tests preventing drift.
Also in this PR
ToolEventKeyprovides one vocabulary for execution-event payload keys.lazy_exportsreplaces repeated hand-rolled lazy import maps.concurrency markers.
Docs
Updates the README adapter and emit matrices, configuration example, and
examples/strands/provider and usage documentation.Verification
Live E2E for the new Strands lane cell was not run.