Skip to content

feat(sdk): Strands Agents adapter — Tier-1 injectable-model family#435

Draft
nir-singher-band wants to merge 2 commits into
devfrom
feat/strands-poc
Draft

feat(sdk): Strands Agents adapter — Tier-1 injectable-model family#435
nir-singher-band wants to merge 2 commits into
devfrom
feat/strands-poc

Conversation

@nir-singher-band

@nir-singher-band nir-singher-band commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a Strands Agents adapter (StrandsAdapter) + history converter for AWS's Strands Agents SDK (pinned strands-agents>=1.40,<2), modeled on the pydantic-ai adapter — same integration family (INJECTABLE_MODEL_OBJECT: the framework owns the loop, the model object is injectable at the public Agent(model=...) seam).

  • Adapter (src/band/adapters/strands.py): platform tools as native @tool functions (descriptions from get_tool_description), contact tools gated on Capability.CONTACTS, memory tools on Capability.MEMORY; CustomToolDef tuples bridged through a native AgentTool subclass validating via execute_custom_tool; L6 tool_call/tool_result events via Before/AfterToolCallEvent hooks (ordered, paired, correlated by toolUseId); per-turn TurnUsage from metrics.accumulated_usageemit_usage; terminal detection via is_terminal_success + band_tool_errored, with _report_error on a turn that never called a terminal Band tool.
  • Converter (src/band/converters/strands.py): platform history → Converse-shaped Messages (toolUse/toolResult blocks, batched; own text preserved as assistant turns for rehydration).
  • Registration: strands extra (+ in dev), lazy import in band.adapters, Adapter.STRANDS + @adapter builder (OpenAI provider, core lane derived from Dep.OPENAI — no ci_lanes.py edit needed, the lane partition is registry-derived), conformance config registries (adapter/converter/output-adapter).
  • Tests: Tier-1 injection spike, adapter unit tests, converter unit tests; passes the shared adapter/converter conformance suites and every drift guard.
  • Examples: examples/strands/01_basic_agent.py, 02_custom_tools.py (PEP-723, band-sdk[strands]).

Step-0 findings (verified against installed strands-agents 1.47.0)

  1. Per-invocation contextagent.invoke_async(prompt, invocation_state={...}) reaches tool bodies via @tool(context=True)tool_context.invocation_state, and every hook event via event.invocation_state. Verified empirically; the self._active_tools fallback was not needed.
  2. Scripted Model stream shape — subclass strands.models.Model (must also implement abstract structured_output). One tool call: messageStart{role:assistant}contentBlockStart{start:{toolUse:{toolUseId,name}}}contentBlockDelta{delta:{toolUse:{input:<json-string>}}}contentBlockStopmessageStop{stopReason:"tool_use"}; text turn: delta:{text} + stopReason:"end_turn"; per-call usage on a trailing metadata{usage} event. Strands ships no public test model, so the spike implements the minimal Model subclass (still the public provider ABC).
  3. OpenAI providerstrands.models.openai.OpenAIModel(client_args={"api_key":...}, model_id=...). There is no "openai:model" string shorthand: a bare string to Agent(model=...) is treated as a Bedrock model id. Builder + examples use the explicit OpenAIModel (the design's ⚠ alternative).
  4. Hook events / usageBeforeToolCallEvent.tool_use{name,toolUseId,input}, AfterToolCallEvent.result: ToolResult{status,content,toolUseId} (+ .exception), async callbacks supported. Usage lives on AgentResult.metrics.accumulated_usage (inputTokens/outputTokens/cacheReadInputTokens/cacheWriteInputTokens) — not on AfterModelCallEvent — and accumulates across turns on a shared Agent.
  5. History handleagent.messages is a public read/write list of Converse Message dicts; pre-seeding via Agent(messages=...) and post-run read-back both verified — Band owns per-room history (no Strands sessions).

Design adjustment (D1), flagged rather than silently diverged: a Strands Agent is stateful (owns messages, per-Agent metrics) and raises ConcurrencyException on concurrent invocation (default mode), while the Band runtime runs one asyncio task per room — a single Agent shared across rooms would break. The adapter builds the heavy pieces (prompt, tool set) once in on_started and constructs a lightweight per-turn Agent over the room's band-owned history — the same fresh-runner-per-message shape google_adk.py already uses. Bonus: fresh per-turn metrics make accumulated_usage exactly the turn's usage (no snapshot deltas). Everything else follows the design decisions verbatim.

Injection registry note: the Tier-1 InjectionBinding registry (tests/framework_conformance/injection_registry.py) lives on the unmerged taxonomy branch (int-826-827-tier1-injection-taxonomy; PR #368 was closed) and does not exist on dev. The spike is standalone (matching how the taxonomy branch keeps spikes standalone), and the exact InjectionBinding(...) record for strands — family=INJECTABLE_MODEL_OBJECT, tier1_status=HONEST_TODAY, observation_paths={TYPED_METHODS}, model_seam_kind=PUBLIC_TEST_MODEL, drift_risk=HIGH, version_pin="strands-agents>=1.40,<2", seam="band.adapters.strands:StrandsAdapter._build_agent" — is recorded in the spike's module docstring, ready to paste when the registry lands.

Verification gate

  1. Dependency isolationuv sync --extra strands resolves with no conflicts (coexists with the full dev extra incl. parlant/pydantic-ai);
    uv run python -c "import band; from band.adapters import StrandsAdapter" → OK; in a strands-free env: OK: plain band-sdk does not import/require strands.
  2. Conformanceuv run pytest tests/framework_conformance -q476 passed, 43 skipped (incl. the 4 strands injection-spike tests; skips are pre-existing env-gated cases).
  3. Adapter unit testsuv run pytest tests/adapters/test_strands_adapter.py -q17 passed.
  4. Discovery guardassert_registry_covers_discovered() + assert_every_adapter_has_a_ci_home() pass; strands lands in the core lane: ('core', ['agno', 'anthropic', 'claude_sdk', 'langgraph', 'pydantic_ai', 'strands']). (pytest tests/e2e/baseline -k "registry or discovered" skips locally by design — that tree is live-gated; same guards run in tests/framework_conformance/test_lane_scheduling.py, green.)
  5. Lint/typeruff check .All checks passed!; ruff format applied; pyrefly check src/band/adapters/strands.py src/band/converters/strands.py0 errors (the project-wide pyrefly run is inert inside a .claude/worktrees checkout — pre-existing path quirk, CI unaffected).
  6. Examples smoke — both example modules import and the adapter dry-constructs with OpenAIModel(model_id=...).
  7. Full unit suiteuv run pytest tests/ --ignore=tests/integration --ignore=tests/e2e -q3545 passed, 60 skipped.

Live validation (manual, app.band.ai): registered a fresh strands-example agent, ran examples/strands/01_basic_agent.py against the local branch, and drove it as a user in room ac584ec0-0f6b-4481-9361-fc8d23402b21:

  • L0 — mention → reply via band_send_message (haiku received, no error events, clean turn logs).
  • L4 — killed and restarted the process with a user message queued in the backlog; on reboot it rehydrated 4 messages of platform history through StrandsHistoryConverter (own prior reply as an assistant turn) and correctly answered a follow-up that referenced its pre-restart reply ("make a joke about that line").

Not run locally: the automated baseline matrix (E2E_TESTS_ENABLED=true … -k strands). The cell is registered fail-loud in the core lane, so the e2e workflow exercises L0–L6.

Relates INT-971

🤖 Generated with Claude Code

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.

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

1 participant