Skip to content

feat(sdk): add Strands adapter and A2A SDK 1.x compatibility - #489

Open
AlexanderZ-Band wants to merge 25 commits into
mainfrom
feat/strands-agents-adapter-l0l4-conformant-band-sdk-in-INT-971
Open

feat(sdk): add Strands adapter and A2A SDK 1.x compatibility#489
AlexanderZ-Band wants to merge 25 commits into
mainfrom
feat/strands-agents-adapter-l0l4-conformant-band-sdk-in-INT-971

Conversation

@AlexanderZ-Band

@AlexanderZ-Band AlexanderZ-Band commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

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)

  • One strands.Agent per turn, so each turn's tools own that turn's
    AgentToolsProtocol and no room capability leaks into Strands' untyped
    invocation_state.
  • Platform tools bridge to native 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 @tool
    functions.
  • HookProvider emits Emit.EXECUTION pairs and decides the turn's terminal-action
    policy; Emit.USAGE maps Strands' accumulated usage.
  • Band owns per-room history: the transcript is converted to Converse messages,
    seeded into the fresh Agent, and read back after the run.

Converter (src/band/converters/strands.py) converts Band history to Converse
toolUse/toolResult messages, batching parallel calls and preserving valid tool
message ordering.

Baseline/E2E adds Adapter.STRANDS to the shared matrix and offline support
registry, using the core lane and its injectable model seam.

A2A SDK compatibility

Supports both legacy a2a-sdk 0.3 and current 1.x behind one compatibility
boundary. 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

  • ToolEventKey provides one vocabulary for execution-event payload keys.
  • lazy_exports replaces repeated hand-rolled lazy import maps.
  • E2E smokes cover contacts capability, memory rehydration, and per-instance
    concurrency markers.

Docs

Updates the README adapter and emit matrices, configuration example, and
examples/strands/ provider and usage documentation.

Verification

4165 unit tests passed, 85 skipped
109 A2A legacy-lane tests passed, 5 v1-only tests skipped
ruff check: passed
pyrefly (A2A sources/tests): passed
actionlint: passed
uv lock --check: passed

Live E2E for the new Strands lane cell was not run.

nir-singher-band and others added 7 commits July 26, 2026 20:28
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.

✌️
@linear-code

linear-code Bot commented Jul 26, 2026

Copy link
Copy Markdown

INT-971

AlexanderZ-Band and others added 7 commits July 27, 2026 06:56
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>
@AlexanderZ-Band AlexanderZ-Band changed the title feat(sdk): Strands Agents adapter — Tier-1 injectable-model family feat(sdk): add Strands adapter and A2A SDK 1.x compatibility Jul 27, 2026
AlexanderZ-Band and others added 8 commits July 27, 2026 11:21
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
AlexanderZ-Band marked this pull request as ready for review July 27, 2026 12:18
@AlexanderZ-Band
AlexanderZ-Band requested a review from a team July 27, 2026 12:18
AlexanderZ-Band and others added 3 commits July 27, 2026 15:30
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>
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