Skip to content

feat(sdk): add Strands Agents adapter - #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 Agents adapter#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 note (rebase on #488)

This PR originally carried a dual-version A2A compatibility layer (a2a-sdk 0.3 + 1.x
behind a compat.py boundary, with a legacy 0.3.26 CI lane). #488 landed on main
in 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 and
its a2a-sdk>=1.1.2,<2 pin stand as-is.

Retained from that work:

  • Import smoke tests for the standalone A2A examples
    (tests/example_agents/a2a/test_example_imports.py), so an example drifting from
    the installed SDK's API fails CI at import time. These now guard main's migrated
    1.x examples and pass against them.
  • Read-only GITHUB_TOKEN permissions on the CI and E2E workflows.

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 (the concurrency smoke folds those markers into the widened
    slow_turn_budget deadlines that landed on main).

Docs

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

Verification

Rebased onto main @ 03065f5.

Offline:

4371 unit tests passed, 85 skipped
ruff check: passed
ruff format --check: passed
pyrefly check: passed (0 errors)
uv lock --check: passed

Live E2E — dispatched e2e.yml with lane=core / os=ubuntu on this branch's head
(run 30638603739, all three jobs green):

258 passed, 135 skipped, 4 rerun in 36m34s  (0 failed)

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:

test_tool_round_trip.py::test_custom_tool_round_trips[strands]                        PASSED
test_room_isolation.py::test_rooms_keep_isolated_context[strands]                     PASSED
test_rehydration_cross_framework.py::test_rehydrates_foreign_peer_message[strands]    PASSED
test_rehydration_idempotency.py::test_handled_work_not_redrained_on_restart[strands]  PASSED
test_rehydration_idempotency.py::test_restart_usage_splits_replay_and_inference[strands] PASSED
test_rehydration_offline.py::test_recalls_offline_note_on_cold_boot[strands]          PASSED
test_rehydration_partial.py::test_partial_reboot_preserves_context_and_peer[strands]  PASSED
test_peer_delegation.py::test_peer_initiated_delegation_with_self_recall[strands]     PASSED
test_participant_management.py::test_invites_messages_and_removes_a_peer[strands]     PASSED

The lane ran with BAND_ALLOW_MISSING_FRAMEWORKS=0, so an unwired framework fails
loudly 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.

@linear-code

linear-code Bot commented Jul 26, 2026

Copy link
Copy Markdown

INT-971

INT-1155

@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
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
nir-singher-band and others added 24 commits July 31, 2026 14:07
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
AlexanderZ-Band force-pushed the feat/strands-agents-adapter-l0l4-conformant-band-sdk-in-INT-971 branch from fda438a to 107d671 Compare July 31, 2026 14:16
@AlexanderZ-Band AlexanderZ-Band changed the title feat(sdk): add Strands adapter and A2A SDK 1.x compatibility feat(sdk): add Strands Agents adapter Jul 31, 2026
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
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.

3 participants