feat(sdk): add Strands Agents adapter - #489
Open
AlexanderZ-Band wants to merge 25 commits into
Open
Conversation
AlexanderZ-Band
marked this pull request as ready for review
July 27, 2026 12:18
AlexanderZ-Band
enabled auto-merge
July 31, 2026 09:22
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 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>
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>
A tool_result whose tool_call event never reached history (failed send or unparseable payload) was emitted as a bare toolResult block, which Converse rejects — bricking the room on every later turn. Synthesize the missing toolUse so the surviving result still forms a valid pair, mirroring the existing repair for the opposite direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB3sAT2SQhCkxZtXpba4i9
The four standalone A2A example scripts (gateway demo + mixed bridges) import SDK modules that only exist on a2a-sdk 1.x. Run each one under runpy in a subprocess so an example drifting from the installed SDK's API fails CI at import time instead of at first live use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fj9BXBQcgxETMu1FUdY3vg
AlexanderZ-Band
force-pushed
the
feat/strands-agents-adapter-l0l4-conformant-band-sdk-in-INT-971
branch
from
July 31, 2026 14:16
fda438a to
107d671
Compare
The memory-rehydration smoke required the retrieval turn to narrate
band_list_memories with a content_query argument. That asserted on how an
adapter reports a tool call rather than on whether the recall worked, and
two live backends failed it for reasons unrelated to memory rehydration:
- opencode reports each tool call once, on the first frame it observes; a
PENDING frame carries no arguments yet, so the narrated args were empty
even though the call itself ran with the query.
- a model may legitimately list memories unfiltered and read the marker
from the results instead of filtering server-side.
Assert the effect instead: the marker comes back in the reply, the list and
get tools fired, and the stored memory carries the marker. This matches the
looser assertion its sibling recall test already uses.
crewai is excluded with a reason: its post-reboot retrieval turn returns an
empty completion ("Invalid response from LLM call - None or empty"), so the
turn never completes. That reproduced on every attempt rather than being a
transient, and it is a crewai-side defect this smoke should surface as an
N/A cell instead of a red lane.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fj9BXBQcgxETMu1FUdY3vg
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 note (rebase on #488)
This PR originally carried a dual-version A2A compatibility layer (a2a-sdk 0.3 + 1.x
behind a
compat.pyboundary, with a legacy 0.3.26 CI lane). #488 landed onmainin the meantime with a full 1.x-only migration of the A2A integration, including
security and lifecycle fixes (JSON-RPC method allowlist, removal of the
unauthenticated task routes, route-shadowing fix, server socket release, task-cache
eviction, pre-migration terminal-state rehydration). The compatibility layer was
superseded and has been dropped from this PR;
main's 1.x-only implementation andits
a2a-sdk>=1.1.2,<2pin stand as-is.Retained from that work:
(
tests/example_agents/a2a/test_example_imports.py), so an example drifting fromthe installed SDK's API fails CI at import time. These now guard
main's migrated1.x examples and pass against them.
GITHUB_TOKENpermissions on the CI and E2E workflows.Also in this PR
ToolEventKeyprovides one vocabulary for execution-event payload keys.lazy_exportsreplaces repeated hand-rolled lazy import maps.concurrency markers (the concurrency smoke folds those markers into the widened
slow_turn_budgetdeadlines that landed onmain).Docs
Updates the README adapter and emit matrices, configuration example, and
examples/strands/provider and usage documentation.Verification
Rebased onto
main@ 03065f5.Offline:
Live E2E — dispatched
e2e.ymlwithlane=core/os=ubuntuon this branch's head(run 30638603739, all three jobs green):
The Strands cells ran live and passed rather than skipping — directly observed in the
job log, including the cells that exercise this PR's load-bearing paths:
The lane ran with
BAND_ALLOW_MISSING_FRAMEWORKS=0, so an unwired framework failsloudly instead of skipping; every skip in the run belongs to an out-of-lane adapter
(crewai, gemini, google_adk, letta, codex, opencode, copilot_acp), each covered by
its own lane.