diff --git a/.kiro/skills/library-development/SKILL.md b/.kiro/skills/library-development/SKILL.md index 65f63dc..b9936e8 100644 --- a/.kiro/skills/library-development/SKILL.md +++ b/.kiro/skills/library-development/SKILL.md @@ -35,7 +35,7 @@ load it whenever you are unsure where something goes. 3. **The pipeline flows one way** — text -> dict -> validated schema -> live objects. Never resolve during parsing; never parse during resolution (see The Pipeline). 4. **Explicit over implicit** — no auto-registration, no global singletons, no hidden state. Every object is wired by hand and passed as an argument. 5. **Single responsibility** — each module does one thing; one resolver per config concept, one builder per orchestration mode. -6. **Composition over inheritance** — small functions and focused modules that compose. The only base classes are the strands-facing ones (`MCPServer`, `HookProvider`). +6. **Composition over inheritance** — small functions and focused modules that compose. The only base classes are the strands-facing ones (e.g. `HookProvider`). 7. **Smallest reasonable change** — don't refactor unrelated code to land a feature. --- @@ -49,32 +49,31 @@ story end to end: load(config) ├─ load_config(config) ─────────────────────────────► AppConfig (validated, pure data) │ ├─ parse_single_source read/inline YAML · strip x-* anchors · -│ │ interpolate ${VAR:-default} · rewrite relative paths -> absolute +│ │ interpolate ${VAR:-default} · rewrite relative paths -> absolute · +│ │ default stdio mcp cwd to the config dir │ ├─ sanitize_collection_keys names -> [a-zA-Z0-9_-]; update internal refs │ ├─ merge_raw_configs multi-source merge (duplicate names raise) │ ├─ normalize schema-version migration hook │ ├─ AppConfig.model_validate Pydantic schema validation │ └─ validate_references every model/mcp/node reference must exist -├─ resolve_infra(config) ───────────────────────────► ResolvedInfra (COLD — nothing started) -│ models · mcp servers · mcp clients · cold MCPLifecycle -├─ infra.mcp_lifecycle.start() servers must be up before agents (Agent.__init__ auto-starts clients) -└─ load_session(config, infra) ─────────────────────► ResolvedConfig (live agents, entry, lifecycle) - resolve_agents · resolve_orchestrations · pick entry +└─ resolve ─────────────────────────────────────────► ResolvedConfig (live agents, entry) + models · mcp clients · resolve_agents · resolve_orchestrations · pick entry ``` Two hard boundaries define where code goes: - **Parse vs resolve.** `load_config` produces pure validated data (`AppConfig` - and its `*Def` models). `resolve_infra` / `load_session` turn that data into - live strands objects. Dict-munging, YAML, interpolation, and merging belong to - the parse side (`config/loaders/`); constructing strands objects belongs to - the resolve side (`config/resolvers/`). Never mix them. -- **Infra vs session.** `resolve_infra` builds process-lifetime, shareable - things (models, MCP servers/clients, the lifecycle) with **no session - managers** and a cold lifecycle. `load_session` builds per-session things - (agents, orchestrations, session managers). This split is what lets one - process serve many isolated sessions — one `resolve_infra`, many - `load_session` calls. Never store a session manager on `ResolvedInfra`. + and its `*Def` models); the second half of `load` turns that data into live + strands objects. Dict-munging, YAML, interpolation, and merging belong to + the parse side (`config/loaders/helpers.py`, `validators.py`, + `config/interpolation.py`); constructing + strands objects belongs to the resolve side (`config/resolvers/`). Never mix + them. +- **Config vs session.** An `AppConfig` is data and can be kept for the life of + the process. Everything `load` returns is **per session** — agents hold + conversation state, so they are never shared. A server calls `load_config` + once and `load(app_config, session_id=…)` per session. There is no third + phase: never add a shared-infrastructure object between the two. --- @@ -88,8 +87,8 @@ Pydantic model and returns a live strands object: `"swarm"`, …) route to a dedicated factory. Anything else is treated as an **import spec** and loaded via `load_object` — this is the single, unified entry point for every `module.path:Name` or `./file.py:Name` string in the - whole library (agent factories, model classes, hooks, session managers, MCP - server factories, graph-edge conditions). Never write your own import logic. + whole library (agent factories, model classes, hooks, plugins, session + managers, graph-edge conditions). Never write your own import logic. 2. **Validate the result type.** After constructing a custom object, assert it is the expected strands base (`isinstance` / `issubclass`) and raise `TypeError` with context if not. A resolver must never return the wrong kind @@ -106,8 +105,7 @@ Two structural rules layered on top: - **Session managers resolve through one uniform leaf chain** (`resolve_leaf_session_manager`): per-leaf override -> explicit opt-out (`session_manager: ~`) -> global default -> `None`. Agents and orchestrations - use it identically; the effective `session_id` is threaded down from - `load_session`. + use it identically; the effective `session_id` is threaded down from `load`. --- @@ -145,8 +143,10 @@ foundation, imported freely: types.py · exceptions.py · wire.py · manifest.p ``` - `schema.py` depends on Pydantic only — the floor. -- `loaders/` do text I/O and dict transforms, import `schema`, and **never - import resolvers**. Parsing must not construct live objects. +- `loaders/helpers.py` and `loaders/validators.py` do text I/O and dict + transforms, import `schema`, and **never import resolvers**. Parsing must not + construct live objects. `loaders/loaders.py` is the one exception: it is the + pipeline entry point, so it drives both sides — parse, then resolve. - `resolvers/` import `schema`, strands, and the subsystem builders (`models.py`, `mcp/`, `tools/`, `hooks/`, `utils.load_object`). They turn a `*Def` into a live object and nothing else. @@ -170,10 +170,12 @@ foundation, imported freely: types.py · exceptions.py · wire.py · manifest.p `Agent` / `Swarm` / `Graph` / `SessionManager` objects and produces a `SessionManifest`. No I/O, no mutation. It is decoupled from the YAML schema on purpose — it describes what was *wired*, not what was *configured*. -- **MCP lifecycle is ordered and idempotent.** Servers start (and become ready) - before clients connect; clients stop before servers. `start()` is idempotent - because `Agent.__init__` also auto-starts clients — the context manager is - still required for graceful shutdown. +- **MCP lifecycle belongs to strands, not to us.** `resolve_mcp_client` returns + an unconnected `MCPClient`; strands reference-counts consumers, connecting on + the first tool load and calling `stop()` once the last agent using it is torn + down (including the stdio subprocess). Never add a lifecycle manager, a + start/stop ordering layer, or a server host — a server is either spawned by + the client (`command:`) or already running elsewhere (`url:`). - **Optional providers import lazily inside the function** that needs them (`bedrock`, `ollama`, `openai`, `gemini`, `agentcore`), each raising a clear `ImportError` pointing at the extra (`pip install strands-compose[openai]`). @@ -198,10 +200,10 @@ foundation, imported freely: types.py · exceptions.py · wire.py · manifest.p When re-raising, chain with `raise … from exc` (or `from None` to suppress a noisy upstream trace, as the loaders do for Pydantic/YAML errors). - **Never swallow exceptions silently**, no bare `except:`. The sanctioned broad - catch is best-effort cleanup/shutdown (e.g. `MCPLifecycle.stop`): catch - `Exception`, log with `exc_info=True`, and continue. + catch is best-effort cleanup/shutdown: catch `Exception`, log with + `exc_info=True`, and continue. - **Return copies from properties** exposing mutable state: - `return dict(self._servers)`. + `return dict(self._clients)`. - **Naming:** `PascalCase` classes · `snake_case` functions/methods · `UPPER_SNAKE_CASE` constants · `_prefix` for private. No abbreviations in the public API. Booleans read as `is_` / `has_` / `enable_`. Don't shadow builtins. @@ -223,7 +225,7 @@ Use `%s` interpolation with structured field-value pairs — never f-strings: ```python logger.info("model=<%s>, provider=<%s> | resolved model", name, provider) -logger.warning("server=<%s> | failed to stop MCP server", name, exc_info=True) +logger.warning("client=<%s> | failed to resolve MCP client", name, exc_info=True) ``` - Field-value pairs first (`key=`, comma-separated), human-readable @@ -273,12 +275,12 @@ Run from the repository root (use the `check-and-test` skill for detail): ```bash uv run just check # ruff format-check + ruff lint + ty type-check + bandit -uv run just test # pytest with coverage gate (≥ 70%) +uv run just test # pytest with coverage gate (≥ 80%) ``` `just check` is the gate; it must pass before a change is done. If it fails, -`uv run just format` first, then re-run. Do **not** start a long-running MCP -server or the CLI `load` command to "verify" — rely on `check` and `test`. +`uv run just format` first, then re-run. Do **not** run the CLI `load` command +to "verify" — it connects to real MCP servers; rely on `check` and `test`. --- @@ -288,10 +290,14 @@ server or the CLI `load` command to "verify" — rely on `check` and `test`. return plain strands objects (no wrappers, no subclasses). - Don't construct live objects during parsing, or munge raw dicts during resolution — respect the parse/resolve boundary. -- Don't import a resolver from a loader, or `Agent`/`MCPClient` from - `schema.py` — respect the one-way dependency flow; keep the schema pure. -- Don't store a session manager on `ResolvedInfra`, or blur the infra/session - split. +- Don't import a resolver from `loaders/helpers.py` or `loaders/validators.py`, + and don't import `Agent`/`MCPClient` into `schema.py` — respect the one-way + dependency flow; keep the schema pure. `loaders/loaders.py` is the only + sanctioned exception (see Dependency Direction). +- Don't add a shared-infrastructure phase or object between `load_config` and + `load` — one call builds one session, and that is the whole model. +- Don't host, start, or stop an MCP server, and don't add an MCP lifecycle + manager — strands owns client lifetime, and servers run as their own process. - Don't write bespoke import logic — route every `module:Name` / `./file.py:Name` spec through `load_object`. - Don't mutate an existing agent to build an orchestration — fork a new one from diff --git a/.kiro/skills/library-development/references/project-map.md b/.kiro/skills/library-development/references/project-map.md index c4c2e65..0a1396a 100644 --- a/.kiro/skills/library-development/references/project-map.md +++ b/.kiro/skills/library-development/references/project-map.md @@ -10,8 +10,8 @@ a factory in a subsystem package. ``` src/strands_compose/ -├── __init__.py # PUBLIC API — load, load_config, resolve_infra, load_session, -│ # ResolvedConfig, ResolvedInfra, EventQueue, StreamEvent, hooks, … +├── __init__.py # PUBLIC API — load, load_config, ResolvedConfig, +│ # AppConfig, EventQueue, StreamEvent, hooks, renderers, … ├── models.py # model provider factory: create_model() → Bedrock/Ollama/OpenAI/Gemini ├── types.py # Node alias · EventType · StreamEvent · SessionManifest family (Pydantic) ├── exceptions.py # ConfigurationError hierarchy (all subclass ValueError) @@ -23,25 +23,24 @@ src/strands_compose/ │ ├── schema.py # PURE Pydantic *Def models · AppConfig · COLLECTION_KEYS · JOINT_NAMESPACES │ ├── interpolation.py # ${VAR:-default} interpolation + x-* anchor stripping (two-pass vars) │ ├── loaders/ -│ │ ├── loaders.py # load / load_config / load_session — pipeline entry points +│ │ ├── loaders.py # load / load_config — THE pipeline entry points │ │ ├── helpers.py # parse source · sanitize keys · rewrite relative paths · merge sources │ │ └── validators.py # validate_references — cross-reference checks before resolution │ └── resolvers/ # *Def → live strands object (the Resolver Contract) -│ ├── config.py # ResolvedConfig · ResolvedInfra · resolve_infra +│ ├── config.py # ResolvedConfig (+ wire_event_queue) │ ├── agents.py # build_agent_from_def (canonical) · resolve_agents │ ├── models.py # resolve_model — built-in provider or custom import -│ ├── mcp.py # resolve_mcp_server / resolve_mcp_client / resolve_tools +│ ├── mcp.py # resolve_mcp_client / resolve_tools │ ├── hooks.py # resolve_hook / resolve_hook_entry +│ ├── plugins.py # resolve_plugin / resolve_plugin_entry │ ├── session_manager.py # resolve_session_manager · resolve_leaf_session_manager (leaf chain) │ ├── conversation_manager.py │ └── orchestrations/ │ ├── planner.py # topological_sort · collect_node_refs (cycle detection) │ └── builders.py # OrchestrationBuilder · build_delegate/swarm/graph ├── mcp/ -│ ├── server.py # MCPServer ABC + create_mcp_server() — background uvicorn thread -│ ├── client.py # create_mcp_client() — returns strands MCPClient -│ ├── transports.py # stdio / sse / streamable_http transport factories + transport Literals -│ └── lifecycle.py # MCPLifecycle — ordered start/stop (servers↔clients), idempotent +│ ├── client.py # create_mcp_client() — returns strands MCPClient (url= or command=) +│ └── transports.py # stdio / sse / streamable_http transport factories + MCP_TRANSPORT ├── tools/ │ ├── loaders.py # resolve_tool_spec(s) — module/file/dir → AgentTool │ ├── extractors.py # extract_last_message · serialize_multiagent_result @@ -51,15 +50,14 @@ src/strands_compose/ │ ├── stop_guard.py # StopGuard / MultiAgentStopGuard — external cancel signal │ ├── max_calls_guard.py # MaxToolCallsGuard — tool-call circuit breaker │ └── tool_name_sanitizer.py# ToolNameSanitizer — repair model-mangled tool names -├── renderers/ # terminal output (base ABC · ansi) -└── startup/ # opt-in health checks (validator.py) + report (report.py) +└── renderers/ # terminal output (base ABC · ansi) ``` ## Where to read first, by task | Task | Read these first | |------|------------------| -| Understand the whole flow | `config/loaders/loaders.py` (`load` → `load_config` → `resolve_infra` → `load_session`) | +| Understand the whole flow | `config/loaders/loaders.py` — `load()` is the entire pipeline, `load_config()` is its parse half | | Add / change a config field | `config/schema.py` (the matching `*Def`), then its `resolve_*` | | Write a new `resolve_*` | `config/resolvers/models.py` (simplest built-in-vs-import example) + `hooks.py` | | Agent construction | `config/resolvers/agents.py` — `build_agent_from_def` (the canonical path) | @@ -70,14 +68,13 @@ src/strands_compose/ | Cross-reference validation | `config/loaders/validators.py` | | An import-spec string (`module:Name`) | `utils.py` — `load_object` (never re-implement) | | Model providers | `models.py` — `create_model` + `PROVIDERS` | -| MCP server / client / transport | `mcp/server.py`, `mcp/client.py`, `mcp/transports.py` | -| MCP start/stop ordering | `mcp/lifecycle.py` | +| MCP client / transport | `mcp/client.py`, `mcp/transports.py` | | Tool loading from spec strings | `tools/loaders.py` — `resolve_tool_spec` | | Delegation (node as a tool) | `tools/wrappers.py` | | Streaming events | `hooks/event_publisher.py` + `wire.py` (`EventQueue`, `make_event_queue`) | | A new event type | `types.py` (`EventType`) then `hooks/event_publisher.py` | | Session topology / introspection | `manifest.py` + `types.py` (`SessionManifest`) | -| CLI behaviour | `cli.py` + `startup/validator.py`, `startup/report.py` | +| CLI behaviour | `cli.py` — `check` (parse only) and `load` (build everything) | ## Invariants observed in the tree @@ -91,8 +88,10 @@ src/strands_compose/ `./file.py:Name` specs, everywhere. - **`build_agent_from_def` is the only agent constructor**; delegate mode forks a new agent from a blueprint via `model_copy`, never mutating the original. -- **Infra (shared, cold, no session managers) vs session (per-run agents + - session managers)** — the split that enables one process → many sessions. +- **`load()` is the single resolution entry point**, and one call is one + session. It accepts an `AppConfig` so a server can parse once with + `load_config()` and resolve per session. There is no shared-infrastructure + phase. - **Optional providers import lazily** inside the resolving function, each with an `ImportError` naming the extra. - **`__all__` lives only in `__init__.py`**; the top-level package is the public @@ -100,22 +99,23 @@ src/strands_compose/ ## Config surface (what the YAML author writes) -`AppConfig` (root): `version` · `models` · `mcp_servers` · `mcp_clients` · +`AppConfig` (root): `version` · `models` · `mcp_clients` · `agents` · `session_manager` · `orchestrations` · `entry` (required) · `log_level`. Merged collection sections are `COLLECTION_KEYS`; `agents` and `orchestrations` share one name namespace (`JOINT_NAMESPACES`). Orchestration `mode` ∈ {`delegate`, `swarm`, `graph`} (discriminated union). See -`examples/` (numbered 01–14) for a worked config per feature and `docs/configuration/` +`examples/` (numbered 01–15) for a worked config per feature and `docs/configuration/` for the chapter-by-chapter reference. ## Stack notes - **Python ≥ 3.11** (ruff/ty target 3.13). Runtime deps: `strands-agents` - (>=1.48,<2), `pydantic` v2, `pyyaml`, `mcp`. Optional extras: + (>=1.52.0,<2), `pydantic` v2, `pyyaml`, `mcp`. Optional extras: `agentcore-memory`, `ollama`, `openai`, `gemini`, `anthropic`. -- **MCP servers** run on a background daemon thread with a self-managed - `uvicorn.Server` (HTTP transports only — `streamable-http`, `sse`); `stdio` - is client-side (the client spawns a subprocess). +- **MCP is client-side only.** A server is either spawned by the client as a + subprocess (`command:`, stdio) or already running elsewhere (`url:`, + `streamable-http` / `sse`). We never host one, and strands owns client + lifetime via consumer reference counting. - **Tooling:** `ruff` (lint + format), `ty` (type check), `bandit` (security), `pytest` + `pytest-asyncio` + coverage — orchestrated through `just`, run via `uv run just …`. diff --git a/.kiro/skills/library-testing/SKILL.md b/.kiro/skills/library-testing/SKILL.md index 8621ec0..95bc707 100644 --- a/.kiro/skills/library-testing/SKILL.md +++ b/.kiro/skills/library-testing/SKILL.md @@ -24,7 +24,7 @@ This library is a **thin translator**: YAML text → validated `*Def` data → l `Agent`, `Swarm`, `Graph`, `Model`, `MCPClient`, or strands' hook events — so we never test them and never mock them. We test **our translation**: that the right config produces the right wired object, that bad config fails with the right -error, and that our runtime edges (streaming, lifecycle, manifest) behave. +error, and that our runtime edges (streaming, guards, manifest) behave. Read `references/test-patterns.md` for the concrete, copy-paste templates (owned fakes, the resolve-seam patches, config builders, the wiring test, the contract @@ -69,11 +69,10 @@ the library is mostly glue. Weight effort roughly in this order; let the code under test decide, not dogma. - **Resolution / wiring (the core, most tests).** Drive the resolvers and the - `load` / `resolve_infra` / `load_session` seams against small configs and - assert the *wiring*: entry is the expected type, the agent got the right - model/tools/hooks/system-prompt, orchestration topology is correct, the - session-manager leaf-chain resolves per the rules, the infra-vs-session split - holds (one `resolve_infra`, many `load_session`, no session manager on infra). + `load` seam against small configs and assert the *wiring*: entry is the + expected type, the agent got the right model/tools/hooks/system-prompt, + orchestration topology is correct, the session-manager leaf-chain resolves per + the rules, and each `load` call yields isolated agents. This is where real bugs live and where tests survive refactors. - **Schema validation contracts (fast, no strands).** Good config validates; bad config raises the **right `ConfigurationError` subclass** @@ -87,8 +86,8 @@ under test decide, not dogma. deterministic functions — test them directly, property-based where a rule generalises (see below). - **Runtime edges (behaviour, per edge).** The `StreamEvent` stream through - `make_event_queue`; MCP lifecycle ordering and idempotency; `build_manifest` - introspection. Test the *observable* contract, not the private handlers. + `make_event_queue`; the hook guards; `build_manifest` introspection. Test the + *observable* contract, not the private handlers. - **The pipeline end-to-end (a thin top layer).** `load()` over real YAML fixtures and over every `examples/` config, with strands faked at our seams. Asserts the whole flow wires up and the entry object exists — not business @@ -109,7 +108,7 @@ This list is as important as the one above. Do not write tests that assert on: - **Mock interactions.** `registry.add_callback.call_args_list`, `mock.assert_called_once_with(...)` on our own internals, call order/counts. These freeze implementation, not behaviour. (Asserting a *faked seam* was hit - is acceptable only when the seam-hit *is* the contract, e.g. lifecycle order.) + is acceptable only when the seam-hit *is* the contract.) - **Log output, warning text, error/exception *messages*, human copy.** Only the error *type* is contract. When tempted to assert on a message, assert on the **type or state** behind it. @@ -138,7 +137,7 @@ tests/ ├── conftest.py # root: markers + shared infrastructure fixtures only ├── factories.py # *Def builders and YAML-string builders (defaults + overrides) ├── fakes/ # hand-written fakes for owned seams -│ └── strands.py # FakeModel · FakeAgent · FakeMCPServer · FakeMCPClient +│ └── strands.py # FakeModel · ToolThenTextModel · BoomModel · FakeMCPClient · FakePlugin ├── contract/ │ └── test_shape.py # the ONE manifest + StreamEvent shape snapshot + baseline ├── property/ # Hypothesis property tests for pure transforms @@ -150,8 +149,8 @@ tests/ ├── resolve/ # *Def -> live object wiring, through public seams (the core) │ ├── test_agents.py · test_models.py · test_mcp.py │ ├── test_orchestrations.py · test_session_manager.py · test_hooks.py -├── runtime/ # streaming, lifecycle, manifest behaviour -│ ├── test_event_stream.py · test_mcp_lifecycle.py · test_manifest.py +├── runtime/ # streaming, guards, manifest behaviour +│ ├── test_event_stream.py · test_guards.py · test_manifest.py └── pipeline/ # end-to-end load() (integration marker) ├── fixtures/ # small worked YAML configs ├── test_load.py # load() wiring over fixtures @@ -175,18 +174,19 @@ Rules: ## Mocking Policy — Fake at Our Seam, Never Mock strands Our only true external dependencies are the **strands runtime** (model provider -network calls, the MCP subprocess/uvicorn machinery) and **the environment** -(env vars, filesystem). Everything else is our own code and must run for real. +network calls, the MCP connection and its stdio subprocess) and **the +environment** (env vars, filesystem). Everything else is our own code and must +run for real. - **Never mock strands or MCP internals directly, and never fabricate strands events with `MagicMock`.** Mock at the thin seam *we* own — the resolver or factory. The canonical seams to substitute are `resolve_model`, - `resolve_mcp_server`, `resolve_mcp_client`, and (for streaming) the model that - drives an `Agent`. Patch them to return a **fake** from `tests/fakes/`. + `resolve_mcp_client`, and (for streaming) the model that drives an `Agent`. + Patch them to return a **fake** from `tests/fakes/`. - **Prefer fakes over `Mock`.** A fake is a real object with a working implementation; it survives strands upgrades and reads clearly. A `FakeModel` - emits a canned event stream; a `FakeMCPServer` records `start`/`wait_ready`/ - `stop`. Reserve `unittest.mock` for forcing hard-to-produce conditions + emits a canned event stream; a `FakeMCPClient` records `start`/`stop` and + contributes no tools. Reserve `unittest.mock` for forcing hard-to-produce conditions (a provider raising, a queue full), and when you must, use `spec_set=` so API drift fails loudly. - **Never mock our own resolvers, loaders, or the objects under test.** Use the @@ -208,9 +208,11 @@ network calls, the MCP subprocess/uvicorn machinery) and **the environment** - **The provider seam is the fake boundary.** `resolve_model` → `FakeModel` keeps us off the network while exercising every line of our own agent/model wiring. Never reach past it into a provider SDK. -- **MCP is faked at the server/client factory.** Assert the *observable* order - contract (servers ready before clients connect, clients stop before servers, - `start()` idempotent) via the fake's recorded calls — never via `_started`. +- **MCP is faked at `resolve_mcp_client`.** We do not own client lifetime — + strands connects a client on first tool load and stops it when the last + consuming agent goes away — so there is no start/stop ordering of ours to + assert. Test that a `*Def` produces the right client and that the client + reaches the agent. - **Filesystem via `tmp_path`; env via `monkeypatch.setenv`.** Never touch the real home dir, real `~/.aws`, or real network. Never rely on ambient env. - **Streaming is deterministic.** A `FakeModel` yields a fixed event list; @@ -258,7 +260,7 @@ replace the contract or pipeline tests. - **Coverage is a floor and a gap-finder, never a goal.** A high number with weak assertions is false confidence. Tests that execute lines without asserting are forbidden. Do not chase 100%, and do not add a test purely to move the - number. The `≥70%` gate is a safety net, not the definition of done. + number. The `≥80%` gate is a safety net, not the definition of done. - **Assertion quality is the real signal.** For the modules that matter most — `config/schema.py` validators, `config/loaders/validators.py`, the resolvers, `utils.load_object`, `config/interpolation.py` — validate the suite with diff --git a/.kiro/skills/library-testing/references/test-patterns.md b/.kiro/skills/library-testing/references/test-patterns.md index 81bba64..2c51de4 100644 --- a/.kiro/skills/library-testing/references/test-patterns.md +++ b/.kiro/skills/library-testing/references/test-patterns.md @@ -39,28 +39,8 @@ class FakeModel: yield event -class FakeMCPServer: - """Records lifecycle calls; asserts ordering/idempotency without a real server.""" - - def __init__(self) -> None: - self.calls: list[str] = [] - self.started = False - - def start(self) -> None: - self.calls.append("start") - self.started = True - - def wait_ready(self, timeout: float) -> bool: - self.calls.append("wait_ready") - return True - - def stop(self) -> None: - self.calls.append("stop") - self.started = False - - class FakeMCPClient: - """Minimal MCP client stand-in for lifecycle tests.""" + """Stands in for a strands MCPClient — contributes no tools, hits no network.""" def __init__(self) -> None: self.calls: list[str] = [] @@ -80,29 +60,24 @@ agent when construction is genuinely too costly for the test's purpose. ## 2. Faking the resolve seams — the patch boundary -Patch **our** resolvers, not strands. The seams live where `resolve_infra` / -`load_session` call them, so patch them there (patch where used, not where -defined). +Patch **our** resolvers, not strands. The seams live where `load` calls them, +so patch them there (patch where used, not where defined). ```python from unittest.mock import patch -from tests.fakes.strands import FakeMCPClient, FakeMCPServer, FakeModel +from tests.fakes.strands import FakeMCPClient, FakeModel def fake_runtime(): """Context managers that swap the strands-facing seams for fakes.""" return ( patch( - "strands_compose.config.resolvers.config.resolve_model", + "strands_compose.config.loaders.loaders.resolve_model", lambda model_def: FakeModel(), ), patch( - "strands_compose.config.resolvers.config.resolve_mcp_server", - lambda *a, **k: FakeMCPServer(), - ), - patch( - "strands_compose.config.resolvers.config.resolve_mcp_client", + "strands_compose.config.loaders.loaders.resolve_mcp_client", lambda *a, **k: FakeMCPClient(), ), ) @@ -165,7 +140,7 @@ from __future__ import annotations from strands import Agent -from strands_compose.config import load_config, resolve_infra, load_session +from strands_compose.config import load from tests.factories import app_config, agent_def @@ -176,8 +151,7 @@ def test_agent_receives_configured_model_and_prompt(fake_runtime): entry="a", ) with fake_runtime: - infra = resolve_infra(config) - resolved = load_session(config, infra) + resolved = load(config) # load() accepts an AppConfig entry = resolved.entry assert isinstance(entry, Agent) # correct *type* of wired object @@ -317,30 +291,34 @@ Merge invariant: merging disjoint sources yields the union; a duplicate name --- -## 9. MCP lifecycle ordering — via the fake, observable only +## 9. MCP client resolution — the `*Def` → client contract -Assert the *contract* (order + idempotency) through the fake's recorded calls. -Never read `lifecycle._started`. +We do not own MCP client lifetime (strands connects on first tool load and stops +when the last consuming agent goes away), so there is no start/stop ordering of +ours to assert. Test the two things that *are* ours: connection-mode dispatch and +the exactly-one-mode rule. ```python -from strands_compose.mcp.lifecycle import MCPLifecycle -from tests.fakes.strands import FakeMCPServer +import pytest +from pydantic import ValidationError +from strands.tools.mcp import MCPClient + +from strands_compose.config.resolvers.mcp import resolve_mcp_client +from strands_compose.config.schema import MCPClientDef -def test_start_is_idempotent_and_starts_server_once(): - lc = MCPLifecycle() - server = FakeMCPServer() - lc.add_server("s", server) +def test_command_client_resolves_to_strands_mcp_client(): + client = resolve_mcp_client(MCPClientDef(command=["python", "-m", "srv"])) + assert isinstance(client, MCPClient) # a real strands object, unconnected - lc.start() - lc.start() # idempotent - assert server.calls.count("start") == 1 - assert "wait_ready" in server.calls # ready before use — the observable order +def test_client_requires_exactly_one_connection_mode(): + with pytest.raises(ValidationError): + MCPClientDef() # neither url nor command ``` -For concurrency, spawn real threads calling `start()`, then assert the fake saw -exactly one `start` — the observable idempotency contract, not a private flag. +Resolution is cheap and hits no network — no fake needed here. Fake +`resolve_mcp_client` only when a *higher* layer (agents, pipeline) is under test. --- @@ -362,8 +340,8 @@ def test_minimal_pipeline_wires_entry_agent(fixture_path): Every `examples/` config gets loaded once, parametrized by directory, with the runtime seams faked (see pattern 2). This is a smoke/wiring guard — assert the -result is a `ResolvedConfig` with a non-None entry, then `stop()` the lifecycle. -Do not assert business rules here; those are proven in `resolve/`. +result is a `ResolvedConfig` with a non-None entry. There is nothing to tear +down. Do not assert business rules here; those are proven in `resolve/`. --- @@ -375,6 +353,6 @@ Do not assert business rules here; those are proven in `resolve/`. | good vs bad config | `schema/` | nothing | error **type** | | a `*Def` → live object | `resolve/` | strands at resolver seam | **type + wiring** | | the event stream | `runtime/` | `FakeModel` | emitted `StreamEvent` | -| MCP start/stop order | `runtime/` | `FakeMCPServer/Client` | recorded call order | +| an MCP client from a `*Def` | `resolve/` | nothing | `MCPClient` type + error type | | the whole flow | `pipeline/` | all runtime seams | `ResolvedConfig` + entry | | the public shape | `contract/` | nothing | field names (snapshot) | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7a9455..260bf2a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,7 +73,7 @@ Three hook stages are registered automatically by `just install-hooks`: ```bash uv run just check # format + lint + type check + security -uv run just test # pytest with coverage (≥70%) +uv run just test # pytest with coverage (≥80%) uv run just format # auto-format with Ruff ``` diff --git a/README.md b/README.md index b0bd095..5d22f17 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

Python 3.11+ PyPI version - Strands Agents + Strands Agents License

@@ -22,7 +22,7 @@ [Strands](https://github.com/strands-agents/harness-sdk) is a powerful agent SDK. But once you have more than one agent, a few MCP servers, safety hooks, and shared models — you end up writing the same plumbing over and over. **strands-compose kills that boilerplate.** -You describe the shape of your agent system in YAML, and strands-compose resolves, validates, and starts everything — models, MCP servers & clients, hooks, tools, orchestration topology — as a live, fully wired multi-agent system. +You describe the shape of your agent system in YAML, and strands-compose resolves, validates, and wires everything — models, MCP clients, hooks, tools, orchestration topology — into a live, fully wired multi-agent system. ```yaml models: @@ -86,7 +86,7 @@ Strands Compose is an ecosystem that includes the following packages: ## Why this changes everything -Your entire agent network — models, prompts, tools, hooks, MCP servers, orchestration topology — captured in a single YAML file and maybe a few Python files for custom tools or hooks. That's it. That's your agent environment. Here's what that unlocks: +Your entire agent network — models, prompts, tools, hooks, MCP connections, orchestration topology — captured in a single YAML file and maybe a few Python files for custom tools or hooks. That's it. That's your agent environment. Here's what that unlocks: ### 🔖 Version it @@ -115,8 +115,7 @@ A bug report comes in. You have the exact YAML config. Load it, replay it, debug | **YAML-first config** | Models, agents, tools, hooks, MCP, orchestrations — all in one file | | **Full YAML power** | Variables (`${VAR:-default}`), anchors (`&ref` / `*ref`), `x-` scratch pads, multi-file merge | | **Multi-model support** | Bedrock, Anthropic, OpenAI, Ollama, Gemini — swap with one line | -| **MCP servers & clients** | Launch local servers from Python files, connect to remote HTTP endpoints, or spawn stdio subprocesses | -| **MCP lifecycle management** | Startup ordering, readiness polling, graceful shutdown — servers before clients, always | +| **MCP clients** | Connect to remote HTTP endpoints or spawn stdio subprocess servers with prefixes and tool filters | | **Orchestration modes** | Delegate (agent-as-tool), Swarm (peer handoffs), Graph (DAG pipelines) — arbitrarily nestable | | **Event streaming** | Unified async event queue across any orchestration depth — tokens, tool calls, handoffs, completions | | **Session persistence** | File, S3, or [Bedrock AgentCore Memory](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/memory.html) — agents remember across restarts | @@ -141,7 +140,7 @@ uv run python examples/01_minimal/main.py | 03 | [Tools](examples/03_tools/) | `tools:` — auto-load `@tool` functions from Python files | | 04 | [Session](examples/04_session/) | `session_manager:` — persistent memory across turns and restarts | | 05 | [Hooks](examples/05_hooks/) | `hooks:` — `MaxToolCallsGuard`, `ToolNameSanitizer`, and custom hooks | -| 06 | [MCP](examples/06_mcp/) | All three MCP modes: local server, remote URL, stdio subprocess | +| 06 | [MCP](examples/06_mcp/) | Both MCP modes: stdio subprocess and remote URL | | 07 | [Delegate](examples/07_delegate/) | `mode: delegate` — coordinator routes work to specialist agents | | 08 | [Swarm](examples/08_swarm/) | `mode: swarm` — peer agents hand off to each other autonomously | | 09 | [Graph](examples/09_graph/) | `mode: graph` — deterministic DAG pipeline between agents | @@ -150,6 +149,7 @@ uv run python examples/01_minimal/main.py | 12 | [Streaming](examples/12_streaming/) | `wire_event_queue()` — stream every token, tool call, and handoff live | | 13 | [Graph conditions](examples/13_graph_conditions/) | Conditional edges — `condition:`, `reset_on_revisit`, `max_node_executions` | | 14 | [Agent factory](examples/14_agent_factory/) | `type:` + `agent_kwargs:` — custom agent factory instead of `Agent()` | +| 15 | [Plugins](examples/15_plugins/) | `plugins:` — reusable behaviour packages (skills, context injection, quality loops) | --- @@ -184,9 +184,8 @@ from strands_compose import load resolved = load("config.yaml") -with resolved.mcp_lifecycle: - result = resolved.entry("Hello!") - print(result) +result = resolved.entry("Hello!") +print(result) ``` --- @@ -357,7 +356,7 @@ git clone https://github.com/strands-compose/sdk-python cd sdk-python uv run just install # install deps + wire git hooks (run once after clone) -uv run just check # lint + type check + security scan +uv run just check # format + lint + type check + security uv run just test # pytest with coverage uv run just format # auto-format (Ruff) ``` diff --git a/docs/README.md b/docs/README.md index 652aeb5..fa3e61e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Strands Compose — Documentation **[YAML Configuration Guide](configuration/README.md)** — the complete reference, from your -first one-agent config to nested multi-orchestration systems with MCP servers, hooks, +first one-agent config to nested multi-orchestration systems with MCP clients, hooks, plugins, session persistence, and streaming. Read it in order, or jump to a chapter: | # | Chapter | What it covers | @@ -10,8 +10,8 @@ plugins, session persistence, and streaming. Read it in order, or jump to a chap | 2 | [Models](configuration/Chapter_02.md) | Choosing your LLM | | 3 | [Variables](configuration/Chapter_03.md) | Environment-driven config | | 4 | [YAML Anchors](configuration/Chapter_04.md) | DRY config blocks | -| 5 | [Tools](configuration/Chapter_05.md) | Giving agents capabilities | -| 6 | [Hooks](configuration/Chapter_06.md) | Lifecycle middleware | +| 5 | [Tools](configuration/Chapter_05.md) | Giving agents superpowers | +| 6 | [Hooks](configuration/Chapter_06.md) | Middleware for agents | | 7 | [Session Persistence](configuration/Chapter_07.md) | Memory that survives restarts | | 8 | [Conversation Managers](configuration/Chapter_08.md) | Controlling context windows | | 9 | [MCP](configuration/Chapter_09.md) | External tool servers | diff --git a/docs/configuration/Chapter_01.md b/docs/configuration/Chapter_01.md index 349f573..5191238 100644 --- a/docs/configuration/Chapter_01.md +++ b/docs/configuration/Chapter_01.md @@ -39,7 +39,6 @@ Here is the full list of top-level keys you can put in a config file: | `models` | dict | No | Named LLM model definitions. | | `agents` | dict | **Yes** (at least one somewhere) | Named agent definitions. | | `orchestrations` | dict | No | Named multi-agent orchestration definitions. | -| `mcp_servers` | dict | No | Named MCP server definitions (managed lifecycle). | | `mcp_clients` | dict | No | Named MCP client connections. | | `session_manager` | dict | No | Global session manager (inherited by all agents). | | `entry` | string | **Yes** | Name of the agent or orchestration to use as the entry point. | diff --git a/docs/configuration/Chapter_02.md b/docs/configuration/Chapter_02.md index 2d6e9a6..f020ab0 100644 --- a/docs/configuration/Chapter_02.md +++ b/docs/configuration/Chapter_02.md @@ -84,7 +84,7 @@ This is handy when only one agent uses a specific model — no need to pollute t ## Custom Model Providers -If the built-in four providers aren't enough, you can point `provider` to a custom `Model` subclass: +If the built-in five providers aren't enough, you can point `provider` to a custom `Model` subclass: ```yaml models: diff --git a/docs/configuration/Chapter_05.md b/docs/configuration/Chapter_05.md index aafd376..6cb6b8f 100644 --- a/docs/configuration/Chapter_05.md +++ b/docs/configuration/Chapter_05.md @@ -26,7 +26,7 @@ agents: |--------|---------------| | `./file.py` | All `@tool`-decorated functions from the file | | `./file.py:func_name` | One specific function (auto-wrapped with `@tool` if needed) | -| `./dir/` | All `@tool` functions from all `.py` files in directory (skips `_`-prefixed files) | +| `./dir/` | All `@tool` functions from all `.py` files in directory tree (skips path segments starting with `_` or `.`) | | `module.path` | All `@tool` functions from an installed Python module | | `module.path:func_name` | One specific function from a module | @@ -84,14 +84,16 @@ Module-based specs (`module.path:func`) use the standard Python import system ## Directory Scanning -The directory spec (`./dir/`) recursively loads all `.py` files in the directory, skipping any file whose name starts with `_`: +The directory spec (`./dir/`) recursively loads all `.py` files in the directory tree. Any path segment starting with `_` or `.` is skipped — so `_private.py`, `_helpers/`, `__pycache__/`, and `.venv/` are never imported: ``` tools/ -├── _helpers.py # Skipped (underscore prefix) -├── __init__.py # Skipped (underscore prefix) -├── analysis.py # Loaded — all @tool functions extracted -└── formatting.py # Loaded — all @tool functions extracted +├── _helpers.py # Skipped (underscore prefix) +├── __pycache__/ # Skipped (underscore prefix on directory) +├── .venv/ # Skipped (dot prefix on directory) +├── analysis.py # Loaded — all @tool functions extracted +└── sub/ + └── extras.py # Loaded — recursion includes nested dirs ``` > **Tips & Tricks** diff --git a/docs/configuration/Chapter_07.md b/docs/configuration/Chapter_07.md index 1b5935a..2a9e772 100644 --- a/docs/configuration/Chapter_07.md +++ b/docs/configuration/Chapter_07.md @@ -101,7 +101,7 @@ Setting `session_manager: ~` (YAML null) on an agent **explicitly opts it out** When no `session_id` is provided, strands-compose generates a random UUID — meaning each run gets a fresh session. The resolution order is: -1. **Runtime override** — via `load_session(..., session_id="abc")` +1. **Runtime override** — via `load(..., session_id="abc")` 2. **`params.session_id`** — from YAML config 3. **Random UUID** — fresh session per run @@ -119,46 +119,44 @@ session_manager: The class must be a subclass of `strands.session.SessionManager`. When `type` is set, `provider` is ignored. -## Swarm Agents and Sessions +## Swarm and Graph Agents and Sessions -**Important limitation**: agents that participate in a Swarm orchestration **cannot** have a session manager. This is a strands-agents limitation. If a global session manager is set and an agent is used in a swarm, strands-compose will raise a clear error: +**Important limitation**: agents that participate in a Swarm or Graph orchestration **cannot** have a session manager. If a global session manager is set and an agent is used in a swarm or graph, strands-compose will raise a clear error: ``` -ConfigurationError: Agent 'drafter' is in swarm orchestration and cannot -have a session manager (source: global 'session_manager:' in config). +ConfigurationError: Agent 'drafter' is in a swarm or graph orchestration and cannot have a session manager (source: global 'session_manager:' in config). +Strands does not yet support session persistence for Swarm or Graph node agents. Fix: Add 'session_manager: ~' to agent 'drafter' to opt out of the global default. ``` -The fix: add `session_manager: ~` to each swarm agent to opt out. +The fix: add `session_manager: ~` to each swarm or graph node agent to opt out. > **Tips & Tricks** > > - For development, `file` provider with a fixed `session_id` is great — restart your script and the agent remembers your conversation. -> - For server/API deployments, use `load_session()` with a per-request `session_id`. strands-compose +> - For server/API deployments, use `load()` with a per-session `session_id`. strands-compose > computes a single `effective_session_id` from your value and threads it to every agent and > orchestration, so all agents in one request share the same session folder. See -> [the multi-tenant pattern](#the-multi-tenant-server-pattern) below. +> [the multi-session pattern](#the-multi-session-server-pattern) below. > - Delete the `.sessions/` directory to "factory reset" your agent's memory. -## The Multi-Tenant Server Pattern +## The Multi-Session Server Pattern -For web servers where each HTTP request needs its own session: +For web servers where each request needs its own session: ```python -from strands_compose import load_config, resolve_infra, load_session +from strands_compose import load, load_config -# Once at startup +# Once at startup — parse and validate, no live objects yet app_config = load_config("config.yaml") -infra = resolve_infra(app_config) -infra.mcp_lifecycle.start() -# Per request -def handle_request(user_session_id: str, message: str): - resolved = load_session(app_config, infra, session_id=user_session_id) +# Per session +def handle_session(user_session_id: str, message: str): + resolved = load(app_config, session_id=user_session_id) return resolved.entry(message) ``` -MCP servers are shared across sessions (started once), but agents and their conversation state are created fresh per session. +YAML is parsed once; every `load()` call builds its own agents, so sessions never share conversation state. Cache the `ResolvedConfig` for follow-up turns within the same session. --- diff --git a/docs/configuration/Chapter_09.md b/docs/configuration/Chapter_09.md index b513169..8281a81 100644 --- a/docs/configuration/Chapter_09.md +++ b/docs/configuration/Chapter_09.md @@ -4,32 +4,33 @@ --- -The Model Context Protocol (MCP) lets agents connect to external tool servers. strands-compose supports three connection modes and manages the full server lifecycle. +The Model Context Protocol (MCP) lets agents connect to external tool servers. strands-compose creates the **clients**; the servers run outside it. ## Architecture ``` -mcp_servers: → Define managed local servers (strands-compose starts/stops them) -mcp_clients: → Define connections to servers (local, remote, or subprocess) +mcp_clients: → Define connections to MCP servers (subprocess or remote) agents: my_agent: mcp: [client_name] → Attach MCP clients as tool providers ``` -## Mode 1: Managed Local Server +strands-compose never runs an MCP server. There are two ways to connect: -You define a server, strands-compose starts it in a background thread before creating agents, and stops it on shutdown: +| Mode | Key | The server is… | +|------|-----|----------------| +| 1 | `command:` | started as a subprocess by the MCP client | +| 2 | `url:` | already running somewhere else | -```yaml -mcp_servers: - calculator: - type: ./server.py:create - params: - port: 9001 +## Mode 1: Stdio Subprocess + +The client spawns the server process and talks to it over stdin/stdout. Nothing to +start by hand, no ports, no readiness checks: +```yaml mcp_clients: calc: - server: calculator # References the server above + command: ["python", "-m", "myserver"] params: prefix: calc # Tools become calc_add, calc_multiply, etc. @@ -41,34 +42,46 @@ agents: entry: assistant ``` -The `type` field points to a factory function that returns an `MCPServer` instance: +Any MCP server works. A [FastMCP](https://github.com/modelcontextprotocol/python-sdk) script is +the shortest way to write one: ```python -# server.py +# myserver.py from mcp.server.fastmcp import FastMCP -from strands_compose.mcp import MCPServer - -class CalculatorServer(MCPServer): - def _register_tools(self, mcp: FastMCP) -> None: - @mcp.tool() - def add(a: float, b: float) -> float: - """Add two numbers.""" - return a + b - - @mcp.tool() - def multiply(a: float, b: float) -> float: - """Multiply two numbers.""" - return a * b - -def create(name: str, port: int = 9001) -> CalculatorServer: - return CalculatorServer(name=name, port=port) + +mcp = FastMCP("calculator") + + +@mcp.tool() +def add(a: float, b: float) -> float: + """Add two numbers.""" + return a + b + + +@mcp.tool() +def multiply(a: float, b: float) -> float: + """Multiply two numbers.""" + return a * b + + +if __name__ == "__main__": + mcp.run(transport="stdio") ``` -The factory receives `name` (from the YAML key) plus everything in `params`. +This mode also works with any CLI tool that speaks MCP over stdio: + +```yaml +mcp_clients: + filesystem: + command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + params: + prefix: fs +``` ## Mode 2: Remote URL -Connect to an existing MCP server over HTTP — no server management needed: +Connect to a server you deploy and operate separately — a container, a VM, or +something behind an API gateway: ```yaml mcp_clients: @@ -80,17 +93,9 @@ mcp_clients: startup_timeout: 30 ``` -## Mode 3: Stdio Subprocess - -Spawn a local process that speaks MCP over stdin/stdout: - -```yaml -mcp_clients: - filesystem: - command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"] - params: - prefix: fs -``` +This is the mode to prefer in production. Because the server lives outside your +agent process, one deployment can serve many agents and many sessions, and you +can put authentication, rate limiting, and observability in front of it. ## The `transport` Field @@ -98,9 +103,9 @@ Transport auto-detection usually works, but you can override it: | Transport | When to Use | |-----------|-------------| -| `streamable-http` | Default for URLs and managed servers. Modern MCP transport. | -| `sse` | Older Server-Sent Events transport. Auto-detected if URL ends in `/sse`. | -| `stdio` | Set automatically for `command:` mode. Not valid for managed servers. | +| `streamable-http` | Default for `url:` clients. Modern MCP transport. | +| `sse` | Older Server-Sent Events transport. Auto-detected if the URL path ends in `/sse`. | +| `stdio` | Set automatically for `command:` mode. Not valid with `url:`. | ## Client `params` @@ -127,45 +132,58 @@ mcp_clients: Available options vary by transport: -- **stdio**: `env`, `cwd`, `encoding` -- **sse**: `headers`, `timeout`, `sse_read_timeout` +- **stdio**: `env`, `cwd`, `encoding`, `encoding_error_handler` +- **sse**: `headers`, `timeout`, `sse_read_timeout`, `auth`, `httpx_client_factory` - **streamable-http**: `headers`, `http_client`, `terminate_on_close` -## Lifecycle Management +## Lifecycle -strands-compose handles the startup ordering automatically: +There is nothing to manage. Strands starts an MCP client when it is attached to +an agent, and stops it once the last agent using it is torn down — including the +subprocess in `command:` mode. -1. Start all MCP **servers** (in parallel) -2. Wait for all servers to be **ready** (TCP port check with configurable timeout) -3. Create agents (which auto-start MCP **clients**) +```python +resolved = load("config.yaml") +result = resolved.entry("Hello!") +``` -On shutdown (via context manager or `.stop()`): +Or in an async context: -1. Stop all **clients** first -2. Then stop all **servers** +```python +result = await resolved.entry.invoke_async("Hello!") +``` -Always use the MCP lifecycle context manager: +A short-lived script needs no teardown — the process exits and the subprocess +goes with it. A long-running process that discards sessions should release them +explicitly, since strands only stops a client once every `Agent` holding it is +garbage-collected: ```python -resolved = load("config.yaml") +from strands import Agent -with resolved.mcp_lifecycle: - result = resolved.entry("Hello!") +for node in (*resolved.agents.values(), *resolved.orchestrators.values()): + if isinstance(node, Agent): + node.cleanup() ``` -Or for async contexts: +Include the orchestrators — a `delegate` orchestration is an `Agent` forked from +its entry agent, holding the same MCP clients without appearing in `agents`. -```python -async with resolved.mcp_lifecycle: - result = await resolved.entry.invoke_async("Hello!") +To confirm the whole config builds — including every MCP client — before you +ship, run the CLI: + +```bash +strands-compose load config.yaml ``` +A connection failure surfaces on the first tool call, as a normal agent error. + ## MCPClientDef Validation -Exactly **one** of `server`, `url`, or `command` must be set on each client. Setting zero or more than one raises a validation error: +Exactly **one** of `url` or `command` must be set on each client. Setting neither or both raises a validation error: ``` -MCPClientDef requires exactly one of 'server', 'url', or 'command'; got none. +MCPClientDef requires exactly one of 'url' or 'command'; got none. ``` ## Combining Multiple MCP Sources @@ -176,8 +194,8 @@ A single agent can use tools from multiple MCP clients: agents: super_agent: mcp: - - calc_client - - aws_knowledge + - calc + - aws_docs - filesystem system_prompt: "You have math, AWS docs, and filesystem access." ``` @@ -185,10 +203,9 @@ agents: > **Tips & Tricks** > > - The `prefix` parameter is your friend. It namespaces tools to avoid collisions: `calc_add` vs `aws_add`. -> - For development, managed servers (Mode 1) are the most convenient — everything starts and stops with your script. -> - For production, prefer remote URLs (Mode 2) — deploy MCP servers independently and connect agents to them. -> - Server transport defaults to `streamable-http`. You can also use `sse` for older MCP servers. -> - MCP servers support `server_params` which are forwarded to FastMCP constructor — useful for `stateless_http`, `json_response`, etc. +> - For development, `command:` is the most convenient — the server starts and stops with your agent, and you edit it in the same repo. +> - For production, prefer `url:` — deploy MCP servers independently so a single instance can serve every agent and hold shared resources like database connection pools. +> - Use `tool_filters` to give different agents different slices of the same server. --- diff --git a/docs/configuration/Chapter_10.md b/docs/configuration/Chapter_10.md index e6bbbb9..cf7c2a9 100644 --- a/docs/configuration/Chapter_10.md +++ b/docs/configuration/Chapter_10.md @@ -56,7 +56,7 @@ entry: team | `connections` | list | Yes | Sub-agents to wire as tools | | `connections[].agent` | string | Yes | Name of the target agent or orchestration | | `connections[].description` | string | Yes | Tool description the LLM sees | -| `connections[].preserve_context` | bool | No | Keep the delegate's history between calls (default `true`). Set `false` for a stateless delegate that starts from its construction-time baseline every call. Rejected for a nested orchestration, or for an agent carrying a session manager | +| `connections[].preserve_context` | bool | No | Keep the delegate's history between calls (default `true`). `false` restarts it from its construction-time baseline every call — rejected for a Swarm or Graph target, and rejected by strands if the agent has a session manager | | `session_manager` | dict | No | Override session manager for the forked agent | | `hooks` | list | No | Additional hooks for the forked agent | | `agent_kwargs` | dict | No | Override agent kwargs (merged with entry agent's kwargs) | diff --git a/docs/configuration/Chapter_13.md b/docs/configuration/Chapter_13.md index 25b902d..05fc01a 100644 --- a/docs/configuration/Chapter_13.md +++ b/docs/configuration/Chapter_13.md @@ -19,7 +19,6 @@ resolved = load(["base.yaml", "agents.yaml", "mcp.yaml"]) - `models` — merged - `agents` — merged - `orchestrations` — merged -- `mcp_servers` — merged - `mcp_clients` — merged **Singleton fields** use last-wins semantics: @@ -76,7 +75,7 @@ entry: assistant **Infrastructure + Application**: ``` -base.yaml — vars, models, mcp_servers, mcp_clients, session_manager +base.yaml — vars, models, mcp_clients, session_manager agents.yaml — agents, orchestrations, entry ``` @@ -103,7 +102,7 @@ Individual files don't need to be valid on their own. `base.yaml` can define mod > > - Use multi-file configs when your single file exceeds ~200 lines. It makes diffs cleaner and team collaboration easier. > - The `entry` field should typically go in the "application" file, not the "infrastructure" file — it's the most likely to change between use cases. -> - File paths for tools/hooks/servers are resolved relative to the file they appear in. If `agents.yaml` says `tools: [./tools.py]`, it looks for `tools.py` next to `agents.yaml`. +> - File paths for tools/hooks/plugins/conditions are resolved relative to the file they appear in. If `agents.yaml` says `tools: [./tools.py]`, it looks for `tools.py` next to `agents.yaml`. --- diff --git a/docs/configuration/Chapter_15.md b/docs/configuration/Chapter_15.md index ea83db5..747d0c2 100644 --- a/docs/configuration/Chapter_15.md +++ b/docs/configuration/Chapter_15.md @@ -106,7 +106,7 @@ Event streaming is configured in Python, not YAML — it's a runtime concern. Bu > - Call `wire_event_queue()` only **once** per `ResolvedConfig` — it mutates agents and orchestrators by adding hooks. Calling it twice would double-attach publishers. > - Call `queue.flush()` between requests to clear stale events from a previous invocation. This also resets the `SESSION_START` / `SESSION_END` guards so the next cycle can re-emit them. > - The queue has a max size of 10,000. If your agent generates more events than the consumer processes, events are dropped with a warning. -> - `SESSION_START` is emitted synchronously by `wire_event_queue()` before any agent runs. `SESSION_END` is emitted by `queue.close()` — always call it in a `finally` block. +> - `SESSION_START` is emitted synchronously by `wire_event_queue()` before any agent runs. `SESSION_END` is emitted by `await queue.close()` — always call it in a `finally` block. --- diff --git a/docs/configuration/Chapter_16.md b/docs/configuration/Chapter_16.md index dcdf5e3..87f3de8 100644 --- a/docs/configuration/Chapter_16.md +++ b/docs/configuration/Chapter_16.md @@ -4,7 +4,7 @@ --- -Names in config (agent names, model names, MCP client/server names, orchestration names) follow strict rules: +Names in config (agent names, model names, MCP client names, orchestration names) follow strict rules: ## Valid Names @@ -55,10 +55,10 @@ orchestrations: ``` ValueError: Name collision between agents and orchestrations: ['team']. -Names must be unique within each section. +Names must be unique across agents and orchestrations — they share one lookup namespace. ``` -Models, MCP servers, and MCP clients each have their own independent namespaces — a model and an agent can share a name (though it's confusing and not recommended). +Models and MCP clients each have their own independent namespace — a model and an agent can share a name (though it's confusing and not recommended). > **Tips & Tricks** > diff --git a/docs/configuration/Chapter_17.md b/docs/configuration/Chapter_17.md index 50d11b3..06317a8 100644 --- a/docs/configuration/Chapter_17.md +++ b/docs/configuration/Chapter_17.md @@ -38,172 +38,113 @@ The merged dict is validated against Pydantic models. Invalid fields, missing re Cross-references are checked: - Agent `model` references → must exist in `models` - Agent `mcp` references → must exist in `mcp_clients` -- MCP client `server` references → must exist in `mcp_servers` - Orchestration agent references → must exist in `agents` or `orchestrations` -## Step 8: Resolve Infrastructure +## Step 8: Resolve Models and MCP Clients -Models, MCP servers, MCP clients, and session managers are created as Python objects. Nothing is started yet. +Model objects and MCP client objects are created. MCP clients are not connected yet — strands connects one when it is attached to an agent. -## Step 9: Start MCP Lifecycle - -MCP servers are started in background threads. The pipeline waits for all servers to be ready (TCP port check). This happens **before** agent creation because `Agent.__init__` auto-starts MCP clients which need running servers. - -## Step 10: Create Agents +## Step 9: Create Agents Each agent definition is resolved: model looked up, tools loaded, hooks instantiated, MCP clients attached, session manager wired. Each agent is a fresh `strands.Agent` instance. -## Step 11: Wire Orchestrations +## Step 10: Wire Orchestrations Orchestrations are topologically sorted and built in dependency order. Inner orchestrations first, outer orchestrations reference the already-built inner ones. -## Step 12: Return ResolvedConfig +## Step 11: Return ResolvedConfig The final `ResolvedConfig` has: - `agents` — dict of all agents by name - `orchestrators` — dict of all built orchestrations by name - `entry` — the entry point (Agent, Swarm, or Graph) -- `mcp_lifecycle` — for managing shutdown -## Advanced Topic: `load()` vs `load_config()` + `resolve_infra()` + `load_session()` +## Sessions: one `load()` call per session -Most users only need: +`load()` is the only entry point. Every call builds **fresh** agents, so every +call is an isolated session: ```python from strands_compose import load resolved = load("config.yaml") +result = resolved.entry("Hello!") ``` -That one call runs the whole pipeline: - -1. Parse YAML -2. Interpolate variables -3. Sanitize names -4. Merge files -5. Validate schema + references -6. Resolve infrastructure -7. Start MCP lifecycle -8. Create agents and orchestrations - -But strands-compose also exposes the lower-level split because **config parsing** and **session creation** are not always the same thing. - -### What counts as "config"? - -`load_config()` returns a validated `AppConfig` — just structured data. - -At this point, nothing is started and no live strands objects exist yet: - -- no `Agent` instances -- no orchestration objects -- no started MCP servers -- no connected MCP clients - -This step is useful when you want to parse and validate once at process startup, fail fast on bad YAML, and keep the validated config around. - -### What counts as "infrastructure"? - -`resolve_infra(app_config)` turns the validated config into the shared runtime pieces: - -- resolved model objects -- resolved MCP server objects -- resolved MCP client objects -- a cold `mcp_lifecycle` - -> **Note:** `resolve_infra()` does **not** build session manager instances. Session managers are -> created per agent and per orchestration at session time (inside `load_session()`), once a real -> session ID is known. This avoids creating orphan filesystem folders before a session actually starts. - -Important nuance: **resolved** does not mean **started**. - -After `resolve_infra()`: - -- MCP servers exist as Python objects, but are not running yet -- MCP clients exist as Python objects, but are not connected yet -- agents still do not exist -- orchestrations still do not exist - -You then start the shared MCP runtime explicitly: - -```python -from strands_compose.config import load_config, resolve_infra - -app_config = load_config("config.yaml") -infra = resolve_infra(app_config) -infra.mcp_lifecycle.start() -``` - -### What `load_session()` does - -`load_session(app_config, infra, session_id=...)` is the final step. It uses the already-started shared infrastructure to create a **fresh** `ResolvedConfig` for one session: - -- fresh agents -- fresh orchestrations -- fresh entry point -- the same shared MCP lifecycle +Agents hold conversation history, so they cannot be shared between sessions. +Everything else — models, MCP clients, tools, hooks — is built alongside them. -This is the key distinction: +### Parse once, resolve per session -- `resolve_infra()` gives you **shared process-level infrastructure** -- `load_session()` gives you **session-level agent graph built on top of that infrastructure** - -### Why this split matters for multi-tenant deployments - -In a multi-tenant server, you usually do **not** want to re-parse YAML, re-resolve models, or restart MCP servers on every request. Those are process-level concerns. - -Instead, you want: - -- one validated config shared by the process -- one resolved infrastructure shared by the process -- one started MCP lifecycle shared by the process -- one fresh set of agents per tenant/session/request - -Typical pattern: +A long-running server should not re-read YAML on every request. Parse once with +`load_config()`, then hand the validated `AppConfig` to `load()` per session: ```python -from strands_compose.config import load_config, load_session, resolve_infra +from strands_compose import load, load_config -# Once at process startup +# Once at startup — fail fast on bad YAML app_config = load_config("config.yaml") -infra = resolve_infra(app_config) -infra.mcp_lifecycle.start() -# Per request / websocket / tenant session -resolved = load_session(app_config, infra, session_id="tenant-123") +# Per session +resolved = load(app_config, session_id="abc") result = resolved.entry("Hello!") ``` -This avoids paying the startup cost repeatedly while still keeping per-session agent state isolated. - -### Session manager nuance +`load_config()` returns pure data: no `Agent` instances, no MCP clients, nothing +started. That makes it safe to run in CI (`strands-compose check`) and cheap to +keep around for the life of the process. -Session manager instances are **not** built during `resolve_infra()`. Instead, `load_session()` -computes a single `effective_session_id` and threads it down to every agent and orchestration leaf: +### Two scopes, that's all -1. If you pass `session_id="my-id"` to `load_session()`, that value is used as-is. -2. If you do **not** pass a `session_id` but the config declares a global `session_manager:`, - strands-compose looks for a `session_id` in `session_manager.params`. If found, that value is - used; otherwise a fresh `uuid.uuid4()` is generated once and shared by all agents in that - call — matching the "one folder per CLI run" behaviour. -3. If neither a `session_id` is provided nor a global `session_manager:` is configured, no session - manager instances are created at all. Each individual agent or orchestration with a per-agent - `session_manager:` will generate its own ID as usual. +| Scope | Lasts | Holds | +|-------|-------|-------| +| **config** | as long as you keep it | validated `AppConfig` — pure data | +| **session** | one `load()` result | models, MCP clients, agents, orchestrations, session managers | -This design guarantees exactly one folder is created per `load_session()` call, never an orphan -folder from `resolve_infra()`. +Follow-up turns reuse the same `ResolvedConfig` — that is what carries +conversation history forward. Call `load()` again only when you want a new +session. -### Mental model +### Releasing a session -Use this rule of thumb: +There is normally nothing to tear down: strands stops an MCP client once every +agent holding it is gone. In a script that is process exit. In a long-running +process that discards sessions it is garbage collection, which is not immediate +if an agent sits in a reference cycle — so release those sessions explicitly: -- **`load()`** = convenience API for scripts and local apps -- **`load_config()`** = validate and freeze the declarative config -- **`resolve_infra()`** = build shared runtime dependencies, but do not start them yet -- **`load_session()`** = build one session's live agents/orchestrations from shared infra +```python +from strands import Agent -If you're building a CLI, a notebook, or a one-shot script, use `load()`. +for node in (*resolved.agents.values(), *resolved.orchestrators.values()): + if isinstance(node, Agent): + node.cleanup() +``` -If you're building a long-running web server with many user sessions, use `load_config()` + `resolve_infra()` once, then `load_session()` for each session. +Include the orchestrators. A `delegate` orchestration is an `Agent` forked from +its entry agent's blueprint and holds the same MCP clients, but it lives only in +`orchestrators` — a loop over `agents` alone would leave the client with a live +consumer and it would never stop. `Swarm` and `Graph` need no separate handling: +their nodes are the very same agent objects that are already in `agents`. + +### Session ID resolution + +`load()` computes a single effective session ID and threads it down to every +agent and orchestration leaf: + +1. If you pass `session_id="my-id"`, that value is used as-is. +2. If you do **not** pass one but the config declares a global `session_manager:`, + strands-compose looks for a `session_id` in `session_manager.params`. If found, + that value is used; otherwise a fresh `uuid.uuid4()` is generated once and + shared by all agents in that call — matching the "one folder per CLI run" + behaviour. +3. If neither a `session_id` is provided nor a global `session_manager:` is + configured, each agent or orchestration that declares its own + `session_manager:` still gets one, generating its own ID. + +When a global `session_manager:` is configured, this means every leaf that falls +back to it shares one ID — so one `load()` call produces one session folder, not +one per agent. Per-leaf `session_manager:` blocks with their own `session_id` or +`storage_dir` are independent of that. --- diff --git a/docs/configuration/Chapter_18.md b/docs/configuration/Chapter_18.md index 7b13141..7e5877f 100644 --- a/docs/configuration/Chapter_18.md +++ b/docs/configuration/Chapter_18.md @@ -12,7 +12,6 @@ vars: {} # Variable definitions (removed after interpolation) models: {} # Named model definitions agents: {} # Named agent definitions (required: at least one) orchestrations: {} # Named orchestration definitions -mcp_servers: {} # Named MCP server definitions mcp_clients: {} # Named MCP client connections session_manager: {} # Global session manager entry: "name" # Required: entry point agent or orchestration @@ -43,7 +42,6 @@ agents: hooks: [] # List of HookDef objects or import path strings plugins: [] # List of PluginDef objects or import path strings mcp: [] # List of MCP client names - tool_labels: {} # Tool name -> display label mapping conversation_manager: null # ConversationManagerDef session_manager: null # Per-agent SessionManagerDef (overrides global) ``` @@ -93,23 +91,13 @@ conversation_manager: params: {} # Constructor kwargs (window_size, etc.) ``` -## MCPServerDef - -```yaml -mcp_servers: - name: - type: ./server.py:create # Factory function: module.path:func or ./file.py:func - params: {} # Forwarded to factory (port, host, etc.) -``` - ## MCPClientDef ```yaml mcp_clients: name: # Exactly one of: - server: "server_name" # Reference to mcp_servers entry - url: "https://..." # External MCP server URL + url: "https://..." # MCP server URL command: ["cmd", "arg"] # Stdio subprocess command transport: null # Override: "streamable-http" | "sse" | "stdio" @@ -127,6 +115,7 @@ orchestrations: connections: - agent: "target_name" # Agent or orchestration name description: "..." # Tool description for LLM + preserve_context: true # Keep delegate history between calls (default true) session_manager: null # Override session manager hooks: [] # Additional hooks agent_kwargs: {} # Override agent kwargs (merged) @@ -169,4 +158,4 @@ orchestrations: --- -**Bonus**: [Quick Recipes →](Quick_Recipes.md) +[Next: Chapter 19 — Plugins →](Chapter_19.md) diff --git a/docs/configuration/Chapter_19.md b/docs/configuration/Chapter_19.md index 69ab709..1d3cc14 100644 --- a/docs/configuration/Chapter_19.md +++ b/docs/configuration/Chapter_19.md @@ -125,7 +125,7 @@ open Agent Skills format, not by strands-compose; `examples/15_plugins/` ships a | Condition | Exception | |-----------|-----------| -| Malformed spec (no `:` separator) / missing file / module / attribute | `ImportResolutionError` (a `ValueError` subclass, from `load_object`) | +| Malformed spec (no `:` separator) / missing file / module / attribute | `ImportResolutionError` (a `ConfigurationError` subclass — itself a `ValueError` — from `load_object`) | | Resolved object is not callable | `TypeError` | | Resolved object is callable but does not return a `Plugin` | `TypeError` | | Constructor or factory raises | the original exception, unwrapped | @@ -146,3 +146,5 @@ strands-compose adds no plugin-specific exception types; everything propagates u --- [← Previous: Full Reference](Chapter_18.md) | [Back to Table of Contents](README.md) + +**Bonus**: [Quick Recipes →](Quick_Recipes.md) diff --git a/docs/configuration/Quick_Recipes.md b/docs/configuration/Quick_Recipes.md index a2010d6..aad6ea2 100644 --- a/docs/configuration/Quick_Recipes.md +++ b/docs/configuration/Quick_Recipes.md @@ -1,6 +1,6 @@ # Quick Recipes -[← Back to Table of Contents](README.md) | [← Previous: Full Reference](Chapter_18.md) +[← Back to Table of Contents](README.md) | [← Previous: Plugins](Chapter_19.md) --- diff --git a/docs/configuration/README.md b/docs/configuration/README.md index 4730c26..138c38d 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -2,7 +2,7 @@ **Everything you need to know about writing strands-compose YAML configs — from zero to production.** -strands-compose lets you describe entire multi-agent systems in YAML and get back live, fully wired strands objects. This guide walks you through every configuration option, from the simplest one-agent setup to nested multi-orchestration systems with MCP servers, hooks, session persistence, conditional graph pipelines, and multi-file configs. +strands-compose lets you describe entire multi-agent systems in YAML and get back live, fully wired strands objects. This guide walks you through every configuration option, from the simplest one-agent setup to nested multi-orchestration systems with MCP clients, hooks, session persistence, conditional graph pipelines, and multi-file configs. No prior YAML expertise required. We start simple and build up. diff --git a/examples/01_minimal/main.py b/examples/01_minimal/main.py index 969ad39..d1ed12d 100644 --- a/examples/01_minimal/main.py +++ b/examples/01_minimal/main.py @@ -34,8 +34,6 @@ def main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/02_vars_and_anchors/main.py b/examples/02_vars_and_anchors/main.py index 5d8d35c..d6dcd4b 100644 --- a/examples/02_vars_and_anchors/main.py +++ b/examples/02_vars_and_anchors/main.py @@ -46,8 +46,6 @@ def main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/03_tools/main.py b/examples/03_tools/main.py index f5bdcff..f53fca6 100644 --- a/examples/03_tools/main.py +++ b/examples/03_tools/main.py @@ -35,8 +35,6 @@ def main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/04_session/main.py b/examples/04_session/main.py index fc3b754..c505883 100644 --- a/examples/04_session/main.py +++ b/examples/04_session/main.py @@ -34,8 +34,6 @@ def main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/05_hooks/README.md b/examples/05_hooks/README.md index 732fdcc..83ab2ee 100644 --- a/examples/05_hooks/README.md +++ b/examples/05_hooks/README.md @@ -26,7 +26,8 @@ class FingerprintHook(HookProvider): self._tool_calls += 1 def _on_after_invocation(self, event: AfterInvocationEvent) -> None: - print(f">>> THIS IS YOUR CUSTOM HOOK: Agent used {self._tool_calls} tools <<<") + msg = f"Agent '{event.agent.name}' used {self._tool_calls} tools in this turn." + print(f"\n\n\033[32m>>> CUSTOM HOOK: {msg} <<<\033[0m\n") self._tool_calls = 0 # reset for the next turn ``` @@ -65,7 +66,7 @@ uv run python examples/05_hooks/main.py ## Try these prompts At the end you'll see our FingerprintHook log: -`>>> THIS IS YOUR CUSTOM HOOK: Agent used N tools <<<` +`>>> CUSTOM HOOK: Agent '' used N tools in this turn. <<<` - `Research the impact of electric vehicles on city air quality. Be thorough.` - `Find facts about Python programming and write a short summary.` diff --git a/examples/05_hooks/main.py b/examples/05_hooks/main.py index 8f75bd4..d0e28f8 100644 --- a/examples/05_hooks/main.py +++ b/examples/05_hooks/main.py @@ -37,8 +37,6 @@ def main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/06_mcp/README.md b/examples/06_mcp/README.md index 1a8f195..19d7701 100644 --- a/examples/06_mcp/README.md +++ b/examples/06_mcp/README.md @@ -1,42 +1,50 @@ -# 06 — MCP: All Connection Modes +# 06 — MCP: Both Connection Modes -> One example that covers every way to wire MCP tools to an agent. +> One example that covers both ways to wire MCP tools to an agent. ## What this shows | Mode | Key | What it does | |---|---|---| -| 1 | `server:` | Launch a local Python MCP server; strands-compose owns its full lifecycle | -| 2 | `url:` | Connect to a real external MCP server over Streamable HTTP — no server setup | -| 3 | `command:` *(commented)* | Spawn a local CLI tool that speaks MCP over stdio | +| 1 | `command:` | Spawn an MCP server as a stdio subprocess — the client owns it | +| 2 | `url:` | Connect to an MCP server running somewhere else over Streamable HTTP | -Both live clients are attached to a **single agent**, which gets calculator tools -from the local server and AWS documentation tools from the remote server. +Both clients are attached to a **single agent**, which gets calculator tools from +the local subprocess and AWS documentation tools from the remote server. ## How it works -### Mode 1 — local managed server +### Mode 1 — stdio subprocess ```yaml -mcp_servers: - calculator: - type: ./server.py:create # factory function -> MCPServer subclass - params: - port: 9001 - mcp_clients: calc_client: - server: calculator # auto-connects; transport/URL inferred + command: ["python", "calculator_server.py"] params: prefix: calc # tools: calc_add, calc_multiply, calc_percentage ``` -`server.py` subclasses `MCPServer` and uses FastMCP's `@mcp.tool()` decorator. -The `create()` factory is called by strands-compose with `params` from YAML. -On `load()`, strands-compose starts the server, connects the client, and on exit -`mcp_lifecycle.stop()` tears everything down — you never manage threads or sockets. +`calculator_server.py` is an ordinary `FastMCP` script. The MCP client spawns it +on first use and tears it down with the agent, so its whole lifetime is handled +for you. + +The subprocess's working directory defaults to the config file's own +directory, so `calculator_server.py` above resolves relative to +`examples/06_mcp/` regardless of where you launch the process from. Set +`transport_options.cwd` explicitly to override it. + +This also works with any CLI tool that speaks MCP over stdio — for example +the filesystem server, run on demand via `npx` with no local install: + +```yaml +mcp_clients: + fs_tools: + command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + params: + prefix: fs # tools: fs_read_file, fs_list_directory, … +``` -### Mode 2 — real external HTTP server +### Mode 2 — external HTTP server ```yaml mcp_clients: @@ -49,21 +57,16 @@ mcp_clients: ``` AWS publicly hosts a Knowledge MCP server at `https://knowledge-mcp.global.api.aws`. -No API key is needed. No `mcp_servers:` block — the server is already running. +No API key is needed. -### Mode 3 — stdio subprocess *(uncomment in config to try)* +This is the mode to use in production: deploy your MCP server independently +(container, VM, or behind a gateway) and point agents at its URL. To try it +locally, run the example server over HTTP and swap `command:` for `url:`: -```yaml -mcp_clients: - fs_tools: - command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"] - params: - prefix: fs +```bash +uv run python examples/06_mcp/calculator_server.py --http ``` -For CLI tools that speak MCP over stdin/stdout. Works with any `npx`, `uvx`, -or binary that implements the MCP stdio protocol. - ### Attaching both clients to one agent ```yaml @@ -79,8 +82,12 @@ based on the question. ## Good to know -**`mcp_servers:` is only needed for locally managed servers.** For `url:` or -`command:` clients you connect to servers you don't own — no `mcp_servers:` block. +**strands-compose never runs MCP servers.** It creates clients and connects +them. For a local server use `command:` (the client spawns the process); for a +remote one use `url:`. + +**No teardown to write.** Strands starts an MCP client when it is attached to an +agent and stops it when the last agent using it goes away. **`params.prefix`** namespaces all tool names from a client — avoids collisions when two servers expose identically named tools. @@ -89,9 +96,7 @@ when two servers expose identically named tools. for large servers where you only need a few tools. **Transport auto-detection.** `url:` clients infer the transport from the URL -scheme and path. Override with `transport:` if needed. - -**Paths** in `type:` are relative to the config file, not the working directory. +path (`/sse` → SSE, otherwise Streamable HTTP). Override with `transport:`. ## Prerequisites diff --git a/examples/06_mcp/calculator_server.py b/examples/06_mcp/calculator_server.py new file mode 100644 index 0000000..b2ff034 --- /dev/null +++ b/examples/06_mcp/calculator_server.py @@ -0,0 +1,66 @@ +"""A standalone MCP server for the 06_mcp example. + +This is a plain ``FastMCP`` server. +The config launches it as a stdio subprocess via ``command:``, so you never +start it by hand. + +To run it directly over HTTP instead (and connect with ``url:``):: + + uv run python examples/06_mcp/calculator_server.py --http +""" + +from __future__ import annotations + +import sys + +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("calculator") + + +@mcp.tool() +def add(a: float, b: float) -> float: + """Add two numbers together. + + Args: + a: The first operand. + b: The second operand. + + Returns: + The sum of a and b. + """ + return a + b + + +@mcp.tool() +def multiply(a: float, b: float) -> float: + """Multiply two numbers together. + + Args: + a: The first factor. + b: The second factor. + + Returns: + The product of a and b. + """ + return a * b + + +@mcp.tool() +def percentage(value: float, percent: float) -> float: + """Calculate what percent% of value is. + + Args: + value: The base value. + percent: The percentage to calculate (e.g. 30 means 30%). + + Returns: + The result of value * percent / 100. + """ + return value * percent / 100 + + +if __name__ == "__main__": + # stdio is the default: the MCP client spawns this file as a subprocess. + # --http serves over Streamable HTTP for use with a url: client instead. + mcp.run(transport="streamable-http" if "--http" in sys.argv else "stdio") diff --git a/examples/06_mcp/config.yaml b/examples/06_mcp/config.yaml index 331b04f..65b33d5 100644 --- a/examples/06_mcp/config.yaml +++ b/examples/06_mcp/config.yaml @@ -1,13 +1,12 @@ -# 06_mcp — MCP: All Connection Modes in One Example +# 06_mcp — MCP: Both Connection Modes in One Example # -# Shows all three ways to wire MCP tools to an agent: +# Shows both ways to wire MCP tools to an agent: # -# 1. mcp_servers: + server: — launch a local Python server (managed lifecycle) -# 2. url: — connect to a real external MCP server over HTTP -# 3. command: (commented) — spawn a stdio subprocess that speaks MCP +# 1. command: — spawn a local MCP server as a stdio subprocess +# 2. url: — connect to an MCP server running somewhere else over HTTP # -# Both live clients are attached to a single agent, giving it calculator tools -# from the local server AND AWS documentation tools from the remote server. +# Both clients are attached to a single agent, giving it calculator tools +# from the local subprocess AND AWS documentation tools from the remote server. # # Run: # uv run python examples/06_mcp/main.py @@ -17,39 +16,23 @@ models: provider: bedrock model_id: openai.gpt-oss-20b-1:0 -# ── Local server (managed lifecycle) ───────────────────────────────────────── -# strands-compose calls create(**params) -> starts the server before load() returns. -mcp_servers: - calculator: - type: ./server.py:create - params: - port: 9001 - # ── Clients ─────────────────────────────────────────────────────────────────── mcp_clients: - - # Mode 1 — server: reference — connects to an mcp_servers entry above + # Mode 1 — command: spawn a local MCP server over stdio. calc_client: - server: calculator + command: ["python", "calculator_server.py"] params: - prefix: calc # tools: calc_add, calc_multiply, calc_percentage + prefix: calc - # Mode 2 — url: — connects to a real external server over Streamable HTTP. + # Mode 2 — url: connect to a server you don't run yourself. # AWS publicly hosts a Knowledge MCP server; no API key required. aws_knowledge: url: https://knowledge-mcp.global.api.aws transport: streamable-http params: - prefix: aws # tools: aws_search, aws_read_doc, … + prefix: aws startup_timeout: 30 - # Mode 3 — command: — spawn a local CLI tool that speaks MCP over stdio. - # (uncomment to try; requires npx) - # fs_tools: - # command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"] - # params: - # prefix: fs - # ── Agent ───────────────────────────────────────────────────────────────────── # Both clients are attached — the agent gets calc_* AND aws_* tools. agents: diff --git a/examples/06_mcp/main.py b/examples/06_mcp/main.py index 5fdda18..89269f6 100644 --- a/examples/06_mcp/main.py +++ b/examples/06_mcp/main.py @@ -1,9 +1,8 @@ -"""06_mcp — MCP: All Connection Modes in One Example. +"""06_mcp — MCP: Both Connection Modes in One Example. -Demonstrates all three MCP client connection modes in a single agent: - - server: local Python MCP server (managed lifecycle via mcp_servers) - - url: real external HTTP server (AWS Knowledge MCP, no API key needed) - - command: stdio subprocess (shown in config comments) +Demonstrates both MCP client connection modes in a single agent: + - command: stdio subprocess (local calculator_server.py) + - url: external HTTP server (AWS Knowledge MCP, no API key needed) Usage: uv run python examples/06_mcp/main.py @@ -24,7 +23,7 @@ def main() -> None: agent = resolved.entry print(f"\n{52 * '-'}") print(f"Try: {STARTER}\n") - print("Tools: calc_add/multiply/percentage (local MCP) + aws_* (AWS Knowledge MCP).") + print("Tools: calc_add/multiply/percentage (stdio MCP) + aws_* (AWS Knowledge MCP).") print("Type a message and press Enter. Empty line to exit.\n") try: while True: @@ -36,8 +35,6 @@ def main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/06_mcp/server.py b/examples/06_mcp/server.py deleted file mode 100644 index 795f20f..0000000 --- a/examples/06_mcp/server.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Custom MCP server for the 06_mcp example. - -Subclass MCPServer and implement _register_tools(mcp) to expose -any Python functions as MCP tools. The create() factory at the -bottom is called by strands-compose with the params from YAML. -""" - -from __future__ import annotations - -from mcp.server.fastmcp import FastMCP - -from strands_compose.mcp import MCPServer - - -class CalculatorServer(MCPServer): - """A simple arithmetic tool server that runs as a background HTTP process.""" - - def _register_tools(self, mcp: FastMCP) -> None: - """Register all tools with the FastMCP instance. - - This method is called once when the server starts. - Use FastMCP's @mcp.tool() decorator to expose functions. - """ - - @mcp.tool() - def add(a: float, b: float) -> float: - """Add two numbers together. - - Args: - a: The first operand. - b: The second operand. - - Returns: - The sum of a and b. - """ - return a + b - - @mcp.tool() - def multiply(a: float, b: float) -> float: - """Multiply two numbers together. - - Args: - a: The first factor. - b: The second factor. - - Returns: - The product of a and b. - """ - return a * b - - @mcp.tool() - def percentage(value: float, percent: float) -> float: - """Calculate what percent% of value is. - - Args: - value: The base value. - percent: The percentage to calculate (e.g. 30 means 30%). - - Returns: - The result of value * percent / 100. - """ - return value * percent / 100 - - -def create(name: str = "calculator", port: int = 9001) -> CalculatorServer: - """Factory called by strands-compose with params from YAML. - - Args: - name: Server name assigned by strands-compose (from the YAML key). - port: The TCP port the MCP server will listen on. - - Returns: - A configured CalculatorServer instance (not yet started). - """ - return CalculatorServer(name=name, port=port) diff --git a/examples/07_delegate/main.py b/examples/07_delegate/main.py index 0891dbb..8e62dd4 100644 --- a/examples/07_delegate/main.py +++ b/examples/07_delegate/main.py @@ -34,8 +34,6 @@ def main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/08_swarm/main.py b/examples/08_swarm/main.py index 40deccb..89b573a 100644 --- a/examples/08_swarm/main.py +++ b/examples/08_swarm/main.py @@ -34,8 +34,6 @@ def main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/09_graph/main.py b/examples/09_graph/main.py index cc8a689..3cac55a 100644 --- a/examples/09_graph/main.py +++ b/examples/09_graph/main.py @@ -34,8 +34,6 @@ def main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/10_nested/main.py b/examples/10_nested/main.py index d526355..cb2ac43 100644 --- a/examples/10_nested/main.py +++ b/examples/10_nested/main.py @@ -35,8 +35,6 @@ def main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/11_multi_file_config/README.md b/examples/11_multi_file_config/README.md index a13e6e3..3101fcd 100644 --- a/examples/11_multi_file_config/README.md +++ b/examples/11_multi_file_config/README.md @@ -20,7 +20,7 @@ load(["base.yaml", "agents.yaml"]) ``` **Merging rules:** -- **Collections** (`agents`, `models`, `mcp_servers`, etc.) are **combined** — each file +- **Collections** (`agents`, `models`, `mcp_clients`, etc.) are **combined** — each file contributes unique names - **Singletons** (`entry`, `log_level`) use **last-wins** — the last file to define it wins - **Duplicate names** across files raise `ValueError` — intentional, not a bug @@ -33,7 +33,7 @@ load(["base.yaml", "agents.yaml"]) ## Good to know -**Infra / app separation.** One team owns `base.yaml` (models, MCP servers), another +**Infra / app separation.** One team owns `base.yaml` (models, MCP clients), another owns `agents.yaml` (agent definitions). Swap `base.yaml` per environment without touching agent logic. diff --git a/examples/11_multi_file_config/main.py b/examples/11_multi_file_config/main.py index 52e4775..9c003e1 100644 --- a/examples/11_multi_file_config/main.py +++ b/examples/11_multi_file_config/main.py @@ -37,8 +37,6 @@ def main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/12_streaming/main.py b/examples/12_streaming/main.py index e2a5efc..a2d94b1 100644 --- a/examples/12_streaming/main.py +++ b/examples/12_streaming/main.py @@ -24,6 +24,8 @@ async def _stream(prompt: str, entry, queue): """Invoke the entry agent and render the event stream.""" + # Reset the queue so session_start/session_end fire cleanly on each turn. + queue.flush() result = None async def _invoke() -> None: @@ -65,8 +67,6 @@ async def _main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/13_graph_conditions/main.py b/examples/13_graph_conditions/main.py index 28cda01..439c084 100644 --- a/examples/13_graph_conditions/main.py +++ b/examples/13_graph_conditions/main.py @@ -35,8 +35,6 @@ def main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/14_agent_factory/README.md b/examples/14_agent_factory/README.md index 56709ef..8164e82 100644 --- a/examples/14_agent_factory/README.md +++ b/examples/14_agent_factory/README.md @@ -6,7 +6,7 @@ - **`type:`** — point an agent at a custom callable instead of the built-in constructor - **`agent_kwargs:`** — pass additional keyword arguments that only your factory understands -- Factory receives all standard params (`name`, `agent_id`, `model`, `system_prompt`, `tools`, `hooks`, `session_manager`) plus your extras +- Factory receives all standard params (`name`, `agent_id`, `model`, `system_prompt`, `description`, `tools`, `hooks`, `plugins`, `conversation_manager`, `session_manager`) plus your extras via `**agent_kwargs` ## How it works diff --git a/examples/14_agent_factory/factory.py b/examples/14_agent_factory/factory.py index 5bc8cd6..ae6ba18 100644 --- a/examples/14_agent_factory/factory.py +++ b/examples/14_agent_factory/factory.py @@ -13,6 +13,8 @@ description=..., tools=..., hooks=..., + plugins=..., + conversation_manager=..., session_manager=..., **agent_kwargs, ) @@ -20,6 +22,7 @@ ⚠️ ``strands.Agent.__init__`` does NOT accept **kwargs — it has explicit parameters only. Your factory MUST consume any custom keys from ``agent_kwargs`` before forwarding the rest to ``Agent()``. +Always keep ``**kwargs`` so that new standard parameters cannot break it. """ from __future__ import annotations diff --git a/examples/14_agent_factory/main.py b/examples/14_agent_factory/main.py index 23f6a13..476e924 100644 --- a/examples/14_agent_factory/main.py +++ b/examples/14_agent_factory/main.py @@ -34,8 +34,6 @@ def main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/15_plugins/README.md b/examples/15_plugins/README.md index 005a8fb..46e9249 100644 --- a/examples/15_plugins/README.md +++ b/examples/15_plugins/README.md @@ -31,7 +31,7 @@ plugins: - type: strands.vended_plugins.goal:GoalLoop # retry until the answer is concise params: - goal: "Answer in at most three sentences, in plain language with no jargon." + goal: "Respond concisely and skip filler, hedging, and restating the question." max_attempts: 2 ``` diff --git a/examples/15_plugins/main.py b/examples/15_plugins/main.py index ce71430..cf208f4 100644 --- a/examples/15_plugins/main.py +++ b/examples/15_plugins/main.py @@ -50,8 +50,6 @@ def main() -> None: print("\n" + 52 * "-" + "\n") except KeyboardInterrupt: print("\nGoodbye!") - finally: - resolved.mcp_lifecycle.stop() # ── entry point ─────────────────────────────────────────────────────────────── diff --git a/examples/README.md b/examples/README.md index c822fdc..7e6cabe 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,7 +9,7 @@ Each example is a self-contained folder with a `README.md`, `config.yaml`, and ` | 03 | [03_tools](./03_tools/) | `tools:` list — auto-loading Python functions as agent tools | | 04 | [04_session](./04_session/) | `session_manager:` — persistent memory across turns | | 05 | [05_hooks](./05_hooks/) | `hooks:` — `MaxToolCallsGuard`, `ToolNameSanitizer`, and custom hooks | -| 06 | [06_mcp](./06_mcp/) | MCP — all three connection modes: local server (`mcp_servers:`), external URL (`url:`), stdio (`command:`) | +| 06 | [06_mcp](./06_mcp/) | MCP — both connection modes: stdio subprocess (`command:`) and external URL (`url:`) | | 07 | [07_delegate](./07_delegate/) | `mode: delegate` — coordinator routes to specialist agents | | 08 | [08_swarm](./08_swarm/) | `mode: swarm` — peer agents hand off autonomously | | 09 | [09_graph](./09_graph/) | `mode: graph` — explicit DAG pipeline between agents | diff --git a/src/strands_compose/__init__.py b/src/strands_compose/__init__.py index 196562a..ced2729 100644 --- a/src/strands_compose/__init__.py +++ b/src/strands_compose/__init__.py @@ -6,11 +6,8 @@ AppConfig, ConfigInput, ResolvedConfig, - ResolvedInfra, load, load_config, - load_session, - resolve_infra, ) from .config.resolvers.orchestrations import OrchestrationBuilder from .exceptions import ( @@ -21,7 +18,7 @@ UnresolvedReferenceError, ) from .hooks import EventPublisher, MaxToolCallsGuard, StopGuard, ToolNameSanitizer -from .mcp import MCPLifecycle, create_mcp_client, create_mcp_server +from .mcp import create_mcp_client from .renderers import AnsiRenderer from .tools import ( multiagent_as_tool, @@ -41,11 +38,9 @@ "EventQueue", "EventType", "ImportResolutionError", - "MCPLifecycle", "MaxToolCallsGuard", "OrchestrationBuilder", "ResolvedConfig", - "ResolvedInfra", "SchemaValidationError", "StopGuard", "StreamEvent", @@ -53,12 +48,9 @@ "UnresolvedReferenceError", "cli_errors", "create_mcp_client", - "create_mcp_server", "load", "load_config", - "load_session", "make_event_queue", "multiagent_as_tool", - "resolve_infra", "serialize_multiagent_result", ] diff --git a/src/strands_compose/cli.py b/src/strands_compose/cli.py index b720f58..e46fafe 100644 --- a/src/strands_compose/cli.py +++ b/src/strands_compose/cli.py @@ -7,9 +7,9 @@ Pure, fast, zero side-effects — safe to run in CI and pre-deploy hooks. ``load`` - Full pipeline via :func:`load` followed by an async MCP health check - via :func:`validate_mcp`. Starts MCP server processes; always cleans - them up before exiting. + Full pipeline via :func:`load` — builds every model, MCP client, tool, + hook, agent, and orchestration. Catches what ``check`` cannot: broken + import specs, missing provider extras, unloadable tool files. Usage:: @@ -18,13 +18,12 @@ strands-compose load config.yaml [--json] strands-compose load config.yaml [--quiet] -Exit codes: ``0`` on success, ``1`` on any error or critical health failure. +Exit codes: ``0`` on success, ``1`` on any error. """ from __future__ import annotations import argparse -import asyncio import json import logging import sys @@ -33,13 +32,10 @@ from typing import TYPE_CHECKING from .config import AppConfig, ConfigInput, load, load_config -from .startup.report import CheckResult, StartupReport -from .startup.validator import validate_mcp from .utils import cli_errors if TYPE_CHECKING: - from .config.resolvers import ResolvedConfig - + from .config import ResolvedConfig logger = logging.getLogger(__name__) @@ -48,9 +44,6 @@ # --------------------------------------------------------------------------- _GREEN = "\033[32m" -_RED = "\033[31m" -_YELLOW = "\033[33m" -_DIM = "\033[2m" _BOLD = "\033[1m" _RESET = "\033[0m" @@ -116,7 +109,6 @@ def _render_check_success_ansi(app_config: AppConfig) -> None: """ agent_names = list(app_config.agents) orch_names = list(app_config.orchestrations) - mcp_server_names = list(app_config.mcp_servers) mcp_client_names = list(app_config.mcp_clients) agent_str = f"{len(agent_names)} agent{'s' if len(agent_names) != 1 else ''}" @@ -130,14 +122,13 @@ def _render_check_success_ansi(app_config: AppConfig) -> None: ] if app_config.models: rows.append(("models", ", ".join(app_config.models))) - if mcp_server_names: - rows.append(("mcp servers", ", ".join(mcp_server_names))) if mcp_client_names: rows.append(("mcp clients", ", ".join(mcp_client_names))) if orch_names: rows.append(("orchestrations", ", ".join(orch_names))) if app_config.session_manager: - rows.append(("session", str(app_config.session_manager.type))) + session = app_config.session_manager + rows.append(("session", session.type or session.provider)) hook_count = _count_hooks(app_config) if hook_count: @@ -157,6 +148,7 @@ def _render_check_success_json(app_config: AppConfig) -> None: Args: app_config: The validated :class:`AppConfig`. """ + session = app_config.session_manager payload = { "ok": True, "stage": "check", @@ -165,9 +157,8 @@ def _render_check_success_json(app_config: AppConfig) -> None: "agents": list(app_config.agents), "models": list(app_config.models), "mcp_clients": list(app_config.mcp_clients), - "mcp_servers": list(app_config.mcp_servers), "orchestrations": list(app_config.orchestrations), - "session_manager": app_config.session_manager.type if app_config.session_manager else None, + "session_manager": (session.type or session.provider) if session else None, "hooks": _count_hooks(app_config), } print(json.dumps(payload)) @@ -202,130 +193,66 @@ def _cmd_check(configs: list[ConfigInput], *, json_output: bool, quiet: bool) -> # --------------------------------------------------------------------------- -def _render_check_result_ansi(check: CheckResult) -> str: - """Format one :class:`CheckResult` as a coloured ANSI line. +def _render_load_success_ansi(resolved: ResolvedConfig) -> None: + """Print a human-readable summary of what was actually wired. Args: - check: The check result to format. - - Returns: - A single (possibly multi-line) string ready for printing. + resolved: The :class:`ResolvedConfig` returned by :func:`load`. """ - if check.ok: - icon = _colour("✓", _GREEN) - elif check.severity == "warning": - icon = _colour("⚠", _YELLOW) - else: - icon = _colour("✗", _RED) - - line = f"[{check.category:8s}] {icon} {check.subject}: {check.message}" - if not check.ok and check.hint: - indent = " " * 14 - line += f"\n{indent}{_colour('hint:', _DIM)} {check.hint}" - return line - - -def _render_report_ansi(report: StartupReport) -> None: - """Print a human-readable MCP health report to stdout. - - Args: - report: The :class:`StartupReport` from :func:`validate_mcp`. - """ - for check in report.checks: - print(_render_check_result_ansi(check)) - - n_ok = len(report.passed_checks) - n_warn = len(report.warnings) - n_crit = len(report.critical_checks) - total = len(report.checks) - - if total == 0: - print(_colour("✓ Load OK", _GREEN + _BOLD) + " (no MCP servers/clients configured)") - return + rows: list[tuple[str, str]] = [ + ("entry", type(resolved.entry).__name__), + ("agents", ", ".join(resolved.agents) or "(none)"), + ] + if resolved.orchestrators: + rows.append(("orchestrations", ", ".join(resolved.orchestrators))) - summary = f"{n_ok}/{total} passed" - if n_warn: - summary += f", {n_warn} warning(s)" - if n_crit: - summary += f", {n_crit} critical" + width = max(len(label) for label, _ in rows) + parts = [_colour("✓ Load OK", _GREEN + _BOLD)] + for label, value in rows: + parts.append(f" {label.ljust(width)} : {value}") - if report.ok: - print(_colour(f"✓ Load OK — {summary}", _GREEN + _BOLD)) - else: - print(_colour(f"✗ Load FAILED — {summary}", _RED + _BOLD)) + print("\n".join(parts)) -def _render_report_json(report: StartupReport) -> None: - """Print a JSON health report to stdout. +def _render_load_success_json(resolved: ResolvedConfig) -> None: + """Print a JSON summary of what was actually wired. Args: - report: The :class:`StartupReport` from :func:`validate_mcp`. + resolved: The :class:`ResolvedConfig` returned by :func:`load`. """ payload = { - "ok": report.ok, + "ok": True, "stage": "load", "version": _get_version(), - "checks": [ - { - "ok": c.ok, - "category": c.category, - "subject": c.subject, - "message": c.message, - "severity": c.severity, - "hint": c.hint, - } - for c in report.checks - ], + "entry": type(resolved.entry).__name__, + "agents": list(resolved.agents), + "orchestrations": list(resolved.orchestrators), } print(json.dumps(payload)) -async def _cmd_load_async(configs: list[ConfigInput], *, json_output: bool, quiet: bool) -> None: - """Async body of the ``load`` sub-command. - - Calls :func:`load`, runs :func:`validate_mcp`, prints the health - report, and always stops the MCP lifecycle before returning. - - Args: - configs: Paths to one or more YAML configuration files. - json_output: When ``True``, emit JSON instead of ANSI output. - quiet: When ``True``, suppress output on success (exit code only). - - Raises: - SystemExit: With code 1 when any critical MCP health check fails. - """ - resolved: ResolvedConfig | None = None - try: - with cli_errors(): - config_input = configs[0] if len(configs) == 1 else configs - resolved = load(config_input) - - report = await validate_mcp(resolved) - - if not quiet or not report.ok: - if json_output: - _render_report_json(report) - else: - _render_report_ansi(report) - - if not report.ok: - sys.exit(1) - finally: - if resolved is not None: - resolved.mcp_lifecycle.stop() - - def _cmd_load(configs: list[ConfigInput], *, json_output: bool, quiet: bool) -> None: """Run the ``load`` sub-command. - Delegates to :func:`_cmd_load_async` via :func:`asyncio.run`. + Calls :func:`load` so every live object is constructed, then prints what + was wired or exits with code 1 on any error (via :func:`cli_errors`). Args: configs: Paths to one or more YAML configuration files. json_output: When ``True``, emit JSON instead of ANSI output. quiet: When ``True``, suppress output on success (exit code only). """ - asyncio.run(_cmd_load_async(configs, json_output=json_output, quiet=quiet)) + with cli_errors(): + config_input = configs[0] if len(configs) == 1 else configs + resolved = load(config_input) + + if quiet: + return + + if json_output: + _render_load_success_json(resolved) + else: + _render_load_success_ansi(resolved) # --------------------------------------------------------------------------- @@ -364,7 +291,7 @@ def _build_parser() -> argparse.ArgumentParser: Sub-commands: check Validate config (no side-effects, safe for CI) - load Full load + MCP health check + load Build every live object (models, MCP, tools, agents) """ ), formatter_class=argparse.RawDescriptionHelpFormatter, @@ -398,10 +325,10 @@ def _build_parser() -> argparse.ArgumentParser: # -- load -- load_parser = subparsers.add_parser( "load", - help="Full load pipeline + MCP health check", - description="Run the full load() pipeline (starts MCP servers, builds agents) " - "then probe MCP connectivity. Always stops MCP servers on exit. " - "Exits 0 on success, 1 on any error or critical health failure.", + help="Build every live object from the config", + description="Run the full load() pipeline — models, MCP clients, tools, " + "hooks, agents, orchestrations. Catches broken import specs and missing " + "provider extras that 'check' cannot. Exits 0 on success, 1 on any error.", ) load_parser.add_argument( "config", diff --git a/src/strands_compose/config/__init__.py b/src/strands_compose/config/__init__.py index 4ebc13a..e3eea2d 100644 --- a/src/strands_compose/config/__init__.py +++ b/src/strands_compose/config/__init__.py @@ -3,8 +3,8 @@ from __future__ import annotations from .interpolation import interpolate, strip_anchors -from .loaders import ConfigInput, load, load_config, load_session -from .resolvers import ResolvedConfig, ResolvedInfra, resolve_infra +from .loaders import ConfigInput, load, load_config +from .resolvers import ResolvedConfig from .schema import ( COLLECTION_KEYS, JOINT_NAMESPACES, @@ -17,7 +17,6 @@ GraphOrchestrationDef, HookDef, MCPClientDef, - MCPServerDef, ModelDef, OrchestrationDef, PluginDef, @@ -38,18 +37,14 @@ "GraphOrchestrationDef", "HookDef", "MCPClientDef", - "MCPServerDef", "ModelDef", "OrchestrationDef", "PluginDef", "ResolvedConfig", - "ResolvedInfra", "SessionManagerDef", "SwarmOrchestrationDef", "interpolate", "load", "load_config", - "load_session", - "resolve_infra", "strip_anchors", ] diff --git a/src/strands_compose/config/loaders/__init__.py b/src/strands_compose/config/loaders/__init__.py index a80324a..447d81c 100644 --- a/src/strands_compose/config/loaders/__init__.py +++ b/src/strands_compose/config/loaders/__init__.py @@ -1,12 +1,11 @@ -"""YAML config loading — parse, validate, and resolve to live objects.""" +"""Config loading package -- __all__ is the single source of truth.""" from __future__ import annotations -from .loaders import ConfigInput, load, load_config, load_session +from .loaders import ConfigInput, load, load_config __all__ = [ "ConfigInput", "load", "load_config", - "load_session", ] diff --git a/src/strands_compose/config/loaders/helpers.py b/src/strands_compose/config/loaders/helpers.py index df23a4a..575655f 100644 --- a/src/strands_compose/config/loaders/helpers.py +++ b/src/strands_compose/config/loaders/helpers.py @@ -109,13 +109,6 @@ def _rename(name: str) -> str: if isinstance(agent_def.get("mcp"), list): agent_def["mcp"] = [_rename(m) for m in agent_def["mcp"]] - # MCP client server references - clients = raw.get("mcp_clients", {}) - if isinstance(clients, dict): - for client_def in clients.values(): - if isinstance(client_def, dict) and isinstance(client_def.get("server"), str): - client_def["server"] = _rename(client_def["server"]) - # Orchestration definitions — driven by reference_fields() descriptors _ORCH_DEFS = { "delegate": DelegateOrchestrationDef, @@ -230,7 +223,6 @@ def rewrite_relative_paths(raw: dict, config_dir: Path) -> None: - ``agents..hooks[]`` — string import specs and ``HookDef.type`` - ``agents..plugins[]`` — string import specs and ``PluginDef.type`` - ``agents..type`` — custom agent factory path - - ``mcp_servers..type`` — MCP server factory path - ``models..provider`` — custom model class path - ``session_manager.type`` — custom session manager path @@ -238,7 +230,7 @@ def rewrite_relative_paths(raw: dict, config_dir: Path) -> None: raw: Parsed raw config dict (mutated in place). config_dir: Directory of the config file being parsed. """ - # ── agents ──────────────────────────────────────────────────────────── + # agents agents = raw.get("agents") if isinstance(agents, dict): for agent_def in agents.values(): @@ -281,26 +273,19 @@ def rewrite_relative_paths(raw: dict, config_dir: Path) -> None: if isinstance(agent_def.get("type"), str): agent_def["type"] = make_absolute(agent_def["type"], config_dir) - # ── mcp_servers ─────────────────────────────────────────────────────── - mcp_servers = raw.get("mcp_servers") - if isinstance(mcp_servers, dict): - for server_def in mcp_servers.values(): - if isinstance(server_def, dict) and isinstance(server_def.get("type"), str): - server_def["type"] = make_absolute(server_def["type"], config_dir) - - # ── models (custom provider) ────────────────────────────────────────── + # models (custom provider) models = raw.get("models") if isinstance(models, dict): for model_def in models.values(): if isinstance(model_def, dict) and isinstance(model_def.get("provider"), str): model_def["provider"] = make_absolute(model_def["provider"], config_dir) - # ── session_manager (root-level or per-agent — agent already handled) ─ + # session_manager (root-level or per-agent — agent already handled) ─ sm = raw.get("session_manager") if isinstance(sm, dict) and isinstance(sm.get("type"), str): sm["type"] = make_absolute(sm["type"], config_dir) - # ── orchestrations (hooks + edge conditions on swarm/graph) ───────── + # orchestrations (hooks + edge conditions on swarm/graph) orchestrations = raw.get("orchestrations") if isinstance(orchestrations, dict): for orch_def in orchestrations.values(): @@ -325,13 +310,41 @@ def rewrite_relative_paths(raw: dict, config_dir: Path) -> None: edge["condition"] = make_absolute(edge["condition"], config_dir) +def apply_mcp_stdio_cwd_default(raw: dict, config_dir: Path) -> None: + """Default ``mcp_clients.*.transport_options.cwd`` to ``config_dir``. + + Applies only to ``command:`` clients and only when ``cwd`` isn't already + set. ``command`` itself is left untouched. + + Args: + raw: Parsed raw config dict (mutated in place). + config_dir: Directory of the config file being parsed. + """ + clients = raw.get("mcp_clients") + if not isinstance(clients, dict): + return + + for client_def in clients.values(): + if not isinstance(client_def, dict): + continue + if not isinstance(client_def.get("command"), list): + continue # url: clients have no subprocess cwd to default + + transport_options = client_def.setdefault("transport_options", {}) + if not isinstance(transport_options, dict): + continue # malformed; let schema validation report it + transport_options.setdefault("cwd", str(config_dir)) + + def parse_single_source(source: str | Path) -> dict: """Parse one config source into a processed raw dict. Handles file reading (for Path or existing file-path strings), - anchor stripping, and per-source variable interpolation. Relative - filesystem tool specs are rewritten to absolute paths anchored to the - config file's directory (not the process CWD). + anchor stripping, and per-source variable interpolation. + Relative filesystem tool specs are rewritten to absolute paths anchored + to the config file's directory (not the process CWD), and stdio MCP client + subprocesses (``mcp_clients.*.command``) default their ``cwd`` to the + same directory unless the config already sets one. Args: source: File path or raw YAML string. @@ -378,6 +391,7 @@ def parse_single_source(source: str | Path) -> dict: if config_dir is not None: rewrite_relative_paths(raw, config_dir) + apply_mcp_stdio_cwd_default(raw, config_dir) return raw diff --git a/src/strands_compose/config/loaders/loaders.py b/src/strands_compose/config/loaders/loaders.py index d6a7eb5..aa53842 100644 --- a/src/strands_compose/config/loaders/loaders.py +++ b/src/strands_compose/config/loaders/loaders.py @@ -2,7 +2,7 @@ Usage:: - from strands_compose.config import load + from strands_compose import load # Single file resolved = load("config.yaml") @@ -13,14 +13,12 @@ # Raw YAML string resolved = load("agents:\\n a:\\n system_prompt: hi") - with resolved.mcp_lifecycle: - result = resolved.entry("Hello!") + result = resolved.entry("Hello!") -Key Features: - - Single-file and multi-file config loading with automatic merging - - Per-source variable interpolation and anchor stripping - - Automatic MCP server startup before agent creation - - Session-level isolation for multi-tenant server deployments +A server parses once and resolves per session:: + + app_config = load_config("config.yaml") + resolved = load(app_config, session_id="abc") """ from __future__ import annotations @@ -28,21 +26,31 @@ import logging import uuid from pathlib import Path +from typing import TYPE_CHECKING from pydantic import ValidationError from ...exceptions import SchemaValidationError from ..resolvers import ( ResolvedConfig, - ResolvedInfra, resolve_agents, - resolve_infra, + resolve_mcp_client, + resolve_model, resolve_orchestrations, ) -from ..schema import AppConfig, GraphOrchestrationDef, SwarmOrchestrationDef +from ..schema import ( + AppConfig, + GraphOrchestrationDef, + SessionManagerDef, + SwarmOrchestrationDef, +) from .helpers import merge_raw_configs, parse_single_source, sanitize_collection_keys from .validators import validate_references +if TYPE_CHECKING: + from strands.models import Model + from strands.tools.mcp import MCPClient as StrandsMCPClient + logger = logging.getLogger(__name__) # Single config source: file path (``str`` or ``Path``) or raw YAML string. @@ -76,68 +84,96 @@ def normalize(raw: dict) -> dict: return raw -def load(config: ConfigInput | list[ConfigInput]) -> ResolvedConfig: - """Load config from file(s) or YAML string(s) and resolve to live objects. +def load( + config: ConfigInput | list[ConfigInput] | AppConfig, + *, + session_id: str | None = None, +) -> ResolvedConfig: + """Load config and resolve it to live strands objects. + + Pass a file path, a raw YAML string, a list of either, or an + already-validated :class:`AppConfig`. File paths are detected by checking + if the path exists on disk; anything else is parsed as inline YAML. - This is the main entry point for zero-code usage. - Accepts a single source or a list of sources. Each source is either - a file path (``str`` or ``Path``) or a raw YAML string. File paths - are detected by checking if the path exists on disk; anything else - is parsed as inline YAML. + Every call builds **fresh** agents and MCP clients, so each call is an + isolated session — N sessions against a ``command:`` client means N stdio + subprocesses. A long-running server parses once with :func:`load_config` + and then calls this per session with the ``AppConfig`` and a ``session_id``. ### Pipeline: - 1. Parse each source (file read or inline YAML) + 1. Parse each source (file read or inline YAML) — skipped for an ``AppConfig`` 2. Per-source: strip anchors, interpolate variables 3. Sanitize collection keys (spaces/special chars -> underscores) 4. Merge sources (if multiple), detect duplicate names - 5. Validate against schema (Pydantic) - 6. Resolve infrastructure (models, MCP — pure; no session manager) - 7. Start MCP servers (so clients can connect) - 8. Create agents (Agent.__init__ auto-starts MCP clients) - 9. Wire orchestration / entry point + 5. Validate against schema (Pydantic) and check cross-references + 6. Resolve models and MCP clients + 7. Create agents, wire orchestrations, pick the entry point Args: - config: File path, raw YAML string, or list of either. + config: File path, raw YAML string, list of either, or a validated + ``AppConfig``. + session_id: Optional session ID. Combined with + ``session_manager.params.session_id`` (if any) and a + ``uuid.uuid4()`` fallback to derive a single effective session ID + that is threaded into every per-agent and per-orchestration + session-manager resolution. When ``None`` and no global + ``session_manager:`` is configured, leaves fall back to their own + UUIDs per ``resolve_session_manager``. Returns: - ResolvedConfig with agents, entry (callable), and mcp_lifecycle. + ResolvedConfig with agents, orchestrators, and entry (callable). Raises: FileNotFoundError: Config file doesn't exist. - ConfigurationError: Invalid YAML syntax, schema validation failure, or invalid references. + ConfigurationError: Invalid YAML syntax, schema validation failure, or + invalid references. + ValueError: The global ``session_manager`` uses the ``agentcore`` + provider, which requires a unique ``actor_id`` per agent. + """ - --- - ## REMARKS: + if isinstance(config, AppConfig): + app_config = config + else: + app_config = load_config(config) + # Apply the configured level only when this call parsed the config + logging.getLogger("strands_compose").setLevel(app_config.log_level.upper()) - When multiple sources are provided, collection sections (``agents``, - ``models``, ``mcp_servers``, ``mcp_clients``, ``orchestrations``) are - merged. Duplicate names within the same section raise ``ValueError``. - Singleton fields (``entry``, ``session_manager``, ``log_level``) use - last-wins semantics. + _reject_global_agentcore_session_manager(app_config.session_manager) - **Side effect**: this function starts MCP servers during resolution. - ``Agent.__init__`` auto-starts MCP clients (via ``process_tools()`` -> - ``MCPClient.load_tools()``), and those clients need running servers to - connect to. ``MCPLifecycle.start()`` is called **before** agent - creation to satisfy this dependency. + models: dict[str, Model] = {} + for name, model_def in app_config.models.items(): + models[name] = resolve_model(model_def) + logger.info("model=<%s>, provider=<%s> | resolved model", name, model_def.provider) - ``MCPLifecycle.start()`` is idempotent, so the caller's context - manager (``async with resolved.mcp_lifecycle:``) is a no-op on enter - but **still required for graceful shutdown** — ``__aexit__`` stops - clients first, then servers. - """ - app_config = load_config(config) + clients: dict[str, StrandsMCPClient] = {} + for name, client_def in app_config.mcp_clients.items(): + clients[name] = resolve_mcp_client(client_def) + logger.info("client=<%s> | resolved MCP client", name) - logging.getLogger("strands_compose").setLevel(app_config.log_level.upper()) + effective_session_id = _effective_session_id(app_config, session_id) - infra = resolve_infra(app_config) + agents = resolve_agents( + agent_defs=app_config.agents, + models=models, + mcp_clients=clients, + global_session_manager_def=app_config.session_manager, + session_id=effective_session_id, + orchestration_agent_names=_orchestration_agent_names(app_config), + ) + orchestrators = resolve_orchestrations( + app_config, + agents, + agent_defs=app_config.agents, + models=models, + mcp_clients=clients, + global_session_manager_def=app_config.session_manager, + session_id=effective_session_id, + ) - # Start MCP servers BEFORE creating agents. - # Initializing Agent starts MCP clients, so we need servers up first. - infra.mcp_lifecycle.start() + entry = (dict(agents) | orchestrators)[app_config.entry] - return load_session(app_config, infra) + return ResolvedConfig(agents=agents, orchestrators=orchestrators, entry=entry) def load_config(config: ConfigInput | list[ConfigInput]) -> AppConfig: @@ -150,14 +186,17 @@ def load_config(config: ConfigInput | list[ConfigInput]) -> AppConfig: otherwise they are parsed as inline YAML content. When multiple sources are provided, their collection sections - (``agents``, ``models``, ``mcp_servers``, ``mcp_clients``, - ``orchestrations``) are merged. Duplicate names within the same - section raise ``ValueError``. Singleton fields (``entry``, - ``session_manager``, ``log_level``) use last-wins semantics. + (``agents``, ``models``, ``mcp_clients``, ``orchestrations``) are + merged. Duplicate names within the same section raise ``ValueError``. + Singleton fields (``entry``, ``session_manager``, ``log_level``) use + last-wins semantics. Each source's ``vars:`` block is applied only to that source (interpolation is per-source). + Use this when you want to parse and validate once — at process startup, + or in CI — and hand the result to :func:`load` one or more times. + Args: config: File path, raw YAML string, or list of either. @@ -191,98 +230,67 @@ def load_config(config: ConfigInput | list[ConfigInput]) -> AppConfig: return app_config -def load_session( - config: AppConfig, - infra: ResolvedInfra, - *, - session_id: str | None = None, -) -> ResolvedConfig: - """Create agents and orchestration from already-started infrastructure. +def _reject_global_agentcore_session_manager(session_manager: SessionManagerDef | None) -> None: + """Reject the ``agentcore`` session provider when set globally. - This is the session-level counterpart to :func:`load`. Use it when - you want to share MCP servers across multiple sessions (e.g. one - session per HTTP request) while creating **isolated** agents per - session. + ``agentcore`` requires a unique ``actor_id`` per agent, so a single global + definition cannot be shared. Fail fast rather than silently giving every + agent the same actor. - ``infra`` does NOT carry a session manager; instances are built per - agent/orchestration from ``config.session_manager`` plus an - ``effective_session_id`` computed here. + Args: + session_manager: The global ``AppConfig.session_manager`` def. - Typical server pattern:: + Raises: + ValueError: If the global provider is ``agentcore``. + """ + if session_manager is not None and session_manager.provider.lower() == "agentcore": + raise ValueError( + "The 'agentcore' session manager cannot be set globally.\n" + "Configure it per-agent — 'actor_id' must be unique per agent." + ) - app_config = load_config("config.yaml") - infra = resolve_infra(app_config) - infra.mcp_lifecycle.start() - # Per request: - resolved = load_session(app_config, infra, session_id="abc") +def _effective_session_id(config: AppConfig, session_id: str | None) -> str | None: + """Derive the one session ID shared by every leaf that falls back to the global def. - ``infra.mcp_lifecycle`` must already be started before calling this. + Precedence: the caller's ``session_id`` -> ``session_manager.params.session_id`` + -> a fresh UUID. Returns ``None`` when no global ``session_manager:`` is + configured, letting each leaf generate its own ID. Args: config: The validated AppConfig. - infra: Resolved infrastructure with servers already started. - session_id: Optional runtime session ID. Combined with - ``config.session_manager.params.session_id`` (if any) and a - ``uuid.uuid4()`` fallback to derive a single - ``effective_session_id`` that is threaded into every per-agent - and per-orchestration session-manager resolution. When ``None`` - and no global ``session_manager:`` is configured, leaves fall - back to their own UUIDs per ``resolve_session_manager``. + session_id: The caller-supplied session ID, if any. Returns: - ResolvedConfig with freshly created agents and entry point. + The effective session ID, or ``None``. + """ + if session_id is not None: + return session_id + if config.session_manager is None: + return None + yaml_session_id = (config.session_manager.params or {}).get("session_id") + return yaml_session_id or str(uuid.uuid4()) + + +def _orchestration_agent_names(config: AppConfig) -> set[str]: + """Collect the agents used as nodes in a Swarm or Graph orchestration. + + Those agents cannot carry a session manager — a strands-agents limitation + that ``resolve_agents`` reports as a config error. + + Args: + config: The validated AppConfig. - Note: - No ``SessionManager`` instance is built in this function; instances - are constructed at the leaves (``build_agent_from_def``, - ``OrchestrationBuilder._build_one``). + Returns: + Names of every agent referenced by a swarm or graph orchestration. """ - # Compute a single effective session id for every leaf that will resolve a global SM. - # CLI parity: - # when no real session_id is supplied but the config declares a global session_manager, - # all leaves that fall back to that def share one fresh UUID - effective_session_id: str | None = session_id - if effective_session_id is None and config.session_manager is not None: - yaml_sid = (config.session_manager.params or {}).get("session_id") - effective_session_id = yaml_sid or str(uuid.uuid4()) - - # Agents used in Swarm or Graph orchestrations cannot have session_manager set. - # Temporary until strands-agents supports session persistence for orchestration node agents. - orchestration_agent_names: set[str] = set() + names: set[str] = set() for orch in config.orchestrations.values(): if isinstance(orch, SwarmOrchestrationDef): - orchestration_agent_names.update(orch.agents) + names.update(orch.agents) elif isinstance(orch, GraphOrchestrationDef): - orchestration_agent_names.add(orch.entry_name) + names.add(orch.entry_name) for edge in orch.edges: - orchestration_agent_names.add(edge.from_agent) - orchestration_agent_names.add(edge.to_agent) - - agents = resolve_agents( - agent_defs=config.agents, - models=infra.models, - mcp_clients=infra.clients, - global_session_manager_def=config.session_manager, - session_id=effective_session_id, - orchestration_agent_names=orchestration_agent_names, - ) - orchestrators = resolve_orchestrations( - config, - agents, - agent_defs=config.agents, - models=infra.models, - mcp_clients=infra.clients, - global_session_manager_def=config.session_manager, - session_id=effective_session_id, - ) - - all_nodes = dict(agents) | orchestrators - entry = all_nodes[config.entry] - - return ResolvedConfig( - agents=agents, - orchestrators=orchestrators, - entry=entry, - mcp_lifecycle=infra.mcp_lifecycle, - ) + names.add(edge.from_agent) + names.add(edge.to_agent) + return names diff --git a/src/strands_compose/config/loaders/validators.py b/src/strands_compose/config/loaders/validators.py index 519763e..1cfdc9b 100644 --- a/src/strands_compose/config/loaders/validators.py +++ b/src/strands_compose/config/loaders/validators.py @@ -17,7 +17,6 @@ def validate_references(config: AppConfig) -> None: Checks: - Agent model references exist in config.models - Agent MCP client references exist in config.mcp_clients - - MCP client server references exist in config.mcp_servers - Orchestration node references exist in agents or orchestrations Args: @@ -45,14 +44,6 @@ def validate_references(config: AppConfig) -> None: f"Available: {list(config.mcp_clients)}" ) - for client_name, client_def in config.mcp_clients.items(): - if client_def.server and client_def.server not in config.mcp_servers: - raise UnresolvedReferenceError( - f"MCP client '{client_name}' references server '{client_def.server}' " - f"which is not defined.\n" - f"Available: {list(config.mcp_servers)}" - ) - for orch_name, orch_def in config.orchestrations.items(): validate_orchestration_refs(orch_def, all_node_names, orch_name=orch_name) diff --git a/src/strands_compose/config/resolvers/__init__.py b/src/strands_compose/config/resolvers/__init__.py index 8f4b2e1..5f10e85 100644 --- a/src/strands_compose/config/resolvers/__init__.py +++ b/src/strands_compose/config/resolvers/__init__.py @@ -3,10 +3,10 @@ from __future__ import annotations from .agents import resolve_agents -from .config import ResolvedConfig, ResolvedInfra, resolve_infra +from .config import ResolvedConfig from .conversation_manager import resolve_conversation_manager from .hooks import resolve_hook, resolve_hook_entry -from .mcp import resolve_mcp_client, resolve_mcp_server, resolve_tools +from .mcp import resolve_mcp_client, resolve_tools from .models import resolve_model from .orchestrations import resolve_orchestrations from .plugins import resolve_plugin, resolve_plugin_entry @@ -14,14 +14,11 @@ __all__ = [ "ResolvedConfig", - "ResolvedInfra", "resolve_agents", "resolve_conversation_manager", "resolve_hook", "resolve_hook_entry", - "resolve_infra", "resolve_mcp_client", - "resolve_mcp_server", "resolve_model", "resolve_orchestrations", "resolve_plugin", diff --git a/src/strands_compose/config/resolvers/agents.py b/src/strands_compose/config/resolvers/agents.py index 0eb8f3c..445477f 100644 --- a/src/strands_compose/config/resolvers/agents.py +++ b/src/strands_compose/config/resolvers/agents.py @@ -41,10 +41,8 @@ def build_agent_from_def( ) -> Agent: """Build a single Agent from an AgentDef blueprint. - This is the canonical way to construct an agent from its YAML definition. - Used by both :func:`resolve_agents` (to build all declared agents) and - by :func:`~strands_compose.config.resolvers.orchestrations.builders.build_delegate` - (to fork an agent with delegate tools). + The canonical agent constructor — used by :func:`resolve_agents` and by the + delegate builder, which forks an agent with extra delegate tools. Args: name: Agent name / agent_id. @@ -55,7 +53,7 @@ def build_agent_from_def( ``AppConfig.session_manager``, used as a fallback when the agent declares no ``session_manager:`` of its own and has not explicitly opted out (``session_manager: ~``). - session_id: Effective session id threaded down from ``load_session``. + session_id: Effective session id threaded down from ``load``. Passed as ``session_id_override`` to every ``resolve_session_manager`` call made in this function. extra_tools: Additional tools to append (e.g. delegate tools). @@ -192,7 +190,7 @@ def resolve_agents( ``AppConfig.session_manager``, used as a fallback when an agent declares no ``session_manager:`` of its own and has not explicitly opted out (``session_manager: ~``). - session_id: Effective session id threaded down from ``load_session``. + session_id: Effective session id threaded down from ``load``. Passed as ``session_id_override`` to every ``resolve_session_manager`` call made in this function. orchestration_agent_names: Names of agents in swarm or graph orchestrations diff --git a/src/strands_compose/config/resolvers/config.py b/src/strands_compose/config/resolvers/config.py index 8e3d711..d375439 100644 --- a/src/strands_compose/config/resolvers/config.py +++ b/src/strands_compose/config/resolvers/config.py @@ -1,33 +1,23 @@ -"""ResolvedConfig, ResolvedInfra, and resolve_infra orchestration.""" +"""ResolvedConfig — the result of resolving a config to live strands objects.""" from __future__ import annotations -import logging from dataclasses import dataclass, field from typing import TYPE_CHECKING from ...manifest import build_manifest -from ...mcp.lifecycle import MCPLifecycle from ...wire import make_event_queue -from .mcp import resolve_mcp_client, resolve_mcp_server -from .models import resolve_model if TYPE_CHECKING: from strands import Agent - from strands.models import Model - from strands.tools.mcp import MCPClient as StrandsMCPClient - from ...mcp.server import MCPServer from ...types import Node from ...wire import EventQueue - from ..schema import AppConfig - -logger = logging.getLogger(__name__) @dataclass(kw_only=True) class ResolvedConfig: - """Fully resolved config — lifecycle started, agents ready. + """Fully resolved config — agents ready to invoke. After calling :func:`~strands_compose.config.loaders.load`, use :meth:`wire_event_queue` to set up event streaming:: @@ -39,39 +29,27 @@ class ResolvedConfig: agents: dict[str, Agent] = field(default_factory=dict) orchestrators: dict[str, Node] = field(default_factory=dict) entry: Node - mcp_lifecycle: MCPLifecycle = field(default_factory=MCPLifecycle) def wire_event_queue( self, *, session_id: str | None = None, - tool_labels: dict[str, str] | None = None, ) -> EventQueue: - """Wire all agents and orchestrators for event streaming. - - This is the recommended way to set up event streaming. It: + """Wire every agent and orchestrator for event streaming. - 1. Builds a :class:`~strands_compose.types.SessionManifest` from the - resolved runtime objects. - 2. Wires every agent (and orchestrator) with an - :class:`~strands_compose.hooks.EventPublisher` via - :func:`~strands_compose.wire.make_event_queue`. - 3. Emits a SESSION_START event carrying the manifest as the first - event on the queue. + The returned queue already carries a SESSION_START event describing the + session topology. .. warning:: - This **mutates** the agents and orchestrators stored on this - instance by adding hooks and overwriting ``callback_handler``. - Call it only once per ``ResolvedConfig`` instance. + **Mutates** the agents and orchestrators on this instance by adding + hooks and overwriting ``callback_handler``. Call it only once. Args: session_id: Optional session ID to embed in events. - tool_labels: Optional tool name → display label mapping. Returns: - A ready-to-use :class:`~strands_compose.wire.EventQueue` with - SESSION_START already on it. + A ready-to-use EventQueue. Raises: ValueError: If the entry node cannot be resolved by object identity. @@ -80,106 +58,8 @@ def wire_event_queue( event_queue = make_event_queue( self.agents, orchestrators=self.orchestrators, - tool_labels=tool_labels, entry_name=manifest.entry.name, session_id=session_id, ) event_queue.emit_session_start(manifest) return event_queue - - -@dataclass -class ResolvedInfra: - """Infrastructure resolved from config — lifecycle NOT started. - - This is the pure result of :func:`resolve_infra`. Lifecycle is cold, - agents are not yet created. - - Session managers are NOT stored here — they are built per agent and per - orchestration at session time, from ``config.session_manager`` (the global - def) plus ``effective_session_id`` computed by ``load_session``. - - Use :func:`~strands_compose.config.loaders.load` for a fully - activated system, or manually:: - - infra = resolve_infra(config) - infra.mcp_lifecycle.start() - agents = resolve_agents(agent_defs=config.agents, ...) - """ - - models: dict[str, Model] = field(default_factory=dict) - clients: dict[str, StrandsMCPClient] = field(default_factory=dict) - mcp_lifecycle: MCPLifecycle = field(default_factory=MCPLifecycle) - - -def resolve_infra(config: AppConfig) -> ResolvedInfra: - """Resolve infrastructure from an AppConfig (pure, no I/O). - - Creates model objects, MCP server/client objects, and a lifecycle - manager. Nothing is started. - - Resolution order: - - 1. Models (no dependencies) - 2. MCP servers (no dependencies) - 3. MCP clients (depend on servers) - 4. MCP lifecycle (assembles servers + clients, **not** started) - 5. Session manager validation only — ``agentcore`` provider rejected - globally; no instance is constructed (instances are built per-leaf - at session time). - - Agents and orchestration are resolved in :func:`load` after - ``mcp_lifecycle.start()`` because ``Agent.__init__`` auto-starts - MCP clients which need servers to be running first. The lifecycle - start in ``load()`` is idempotent — the context manager is still - used for graceful shutdown. - - Args: - config: Parsed AppConfig from YAML. - - Returns: - A :class:`ResolvedInfra` with models, clients, and a cold MCP lifecycle. - """ - # Models - models: dict[str, Model] = {} - for name, model_def in config.models.items(): - models[name] = resolve_model(model_def) - logger.info("model=<%s>, provider=<%s> | resolved model", name, model_def.provider) - - # MCP servers - servers: dict[str, MCPServer] = {} - for name, server_def in config.mcp_servers.items(): - servers[name] = resolve_mcp_server(server_def, name=name) - logger.info("server=<%s> | resolved MCP server", name) - - # MCP clients (resolved but NOT started) - clients: dict[str, StrandsMCPClient] = {} - for name, client_def in config.mcp_clients.items(): - clients[name] = resolve_mcp_client(client_def, servers, name=name) - logger.info("client=<%s> | resolved MCP client", name) - - # MCP lifecycle (cold — not started) - lifecycle = MCPLifecycle() - for name, server in servers.items(): - lifecycle.add_server(name, server) - for name, client in clients.items(): - lifecycle.add_client(name, client) - - # Session manager — validation only - # Instances are built per leaf in load_session / agents / orchestrations. - # Provider 'agentcore' cannot be set globally - - # it requires a unique 'actor_id' per agent. Fail fast at boot. - if ( - config.session_manager is not None - and config.session_manager.provider.lower() == "agentcore" - ): - raise ValueError( - "The 'agentcore' session manager cannot be set globally.\n" - "Configure it per-agent — 'actor_id' must be unique per agent." - ) - - return ResolvedInfra( - models=models, - clients=clients, - mcp_lifecycle=lifecycle, - ) diff --git a/src/strands_compose/config/resolvers/conversation_manager.py b/src/strands_compose/config/resolvers/conversation_manager.py index d042c65..7629f55 100644 --- a/src/strands_compose/config/resolvers/conversation_manager.py +++ b/src/strands_compose/config/resolvers/conversation_manager.py @@ -6,6 +6,7 @@ from strands.agent.conversation_manager import ConversationManager +from ...exceptions import ConfigurationError from ...utils import load_object if TYPE_CHECKING: @@ -21,9 +22,6 @@ def resolve_conversation_manager(cm_def: ConversationManagerDef) -> Conversation ``"strands.agent:SlidingWindowConversationManager"``) - ``"./path/to/file.py:ClassName"`` -- file-based import - No short-name aliases are supported. Use the full import path so that - custom and third-party managers work without ambiguity. - Args: cm_def: Conversation manager definition from YAML. @@ -31,12 +29,12 @@ def resolve_conversation_manager(cm_def: ConversationManagerDef) -> Conversation Instantiated ConversationManager. Raises: - ValueError: If ``type`` is not in ``module:Class`` format. + ConfigurationError: If ``type`` is not in ``module:Class`` format. TypeError: If the resolved object is not a ConversationManager subclass. """ type_str = cm_def.type if ":" not in type_str: - raise ValueError( + raise ConfigurationError( f"Conversation manager type {type_str!r} is not a valid import spec.\n" f"Use 'module.path:ClassName' (e.g. " f"'strands.agent:SlidingWindowConversationManager') " diff --git a/src/strands_compose/config/resolvers/hooks.py b/src/strands_compose/config/resolvers/hooks.py index 2d879bf..e13e593 100644 --- a/src/strands_compose/config/resolvers/hooks.py +++ b/src/strands_compose/config/resolvers/hooks.py @@ -17,9 +17,6 @@ def resolve_hook(hook_def: HookDef) -> HookProvider: ``"strands_compose.hooks:StopGuard"``) - ``"./path/to/hooks.py:ClassName"`` -- file-based import - No short-name aliases are supported. Use the full import path so that - submodules and third-party hooks work without ambiguity. - Args: hook_def: Hook definition from YAML. diff --git a/src/strands_compose/config/resolvers/mcp.py b/src/strands_compose/config/resolvers/mcp.py index 0b60528..e9c82b8 100644 --- a/src/strands_compose/config/resolvers/mcp.py +++ b/src/strands_compose/config/resolvers/mcp.py @@ -1,26 +1,23 @@ -"""Resolve MCPServerDef, MCPClientDef, and tool specs.""" +"""Resolve MCPClientDef and tool specs.""" from __future__ import annotations from typing import TYPE_CHECKING, Any, cast from ...mcp.client import create_mcp_client -from ...mcp.server import MCPServer from ...mcp.transports import MCP_TRANSPORT from ...tools import resolve_tool_specs -from ...utils import load_object if TYPE_CHECKING: from strands.tools.mcp import MCPClient as StrandsMCPClient - from ..schema import MCPClientDef, MCPServerDef + from ..schema import MCPClientDef def resolve_tools(tool_specs: list[str]) -> list[Any]: """Resolve tool specification strings to tool objects. - Delegates to :func:``~strands_compose.tools.resolve_tool_specs``, - which understands module paths, file paths, and directory paths. + Understands module paths, file paths, and directory paths. Args: tool_specs: List of tool specification strings. @@ -31,76 +28,26 @@ def resolve_tools(tool_specs: list[str]) -> list[Any]: return resolve_tool_specs(tool_specs) -def resolve_mcp_server( - server_def: MCPServerDef, - *, - name: str = "", -) -> MCPServer: - """Resolve an MCPServerDef to an MCPServer instance. - - Imports the factory from the full import path and passes - ``server_def.params`` as constructor kwargs. - - Args: - server_def: MCP server definition from YAML. - name: Server name (key under ``mcp_servers:``). - - Returns: - Instantiated MCPServer (not yet started). - - Raises: - ValueError: If the server type cannot be resolved. - TypeError: If the resolved object is not an MCPServer subclass. - """ - factory = load_object(server_def.type, target="MCP server") - server = factory(name=name, **server_def.params) - if not isinstance(server, MCPServer): - raise TypeError( - f"MCP server factory '{server_def.type}' returned {type(server).__name__}, " - f"expected MCPServer subclass." - ) - return server - - -def resolve_mcp_client( - client_def: MCPClientDef, - servers: dict[str, MCPServer], - *, - name: str = "", -) -> StrandsMCPClient: +def resolve_mcp_client(client_def: MCPClientDef) -> StrandsMCPClient: """Resolve an MCPClientDef to a strands MCPClient. - Uses :func:``~strands_compose.mcp.client.create_mcp_client``. - Resolves server reference to actual MCPServer instance. - Args: client_def: MCP client definition from YAML. - servers: Already-resolved server instances by name. - name: Client name (key under ``mcp_clients:``). Returns: - A strands MCPClient instance (not started). + A strands MCPClient instance (not started — strands starts it when + the client is registered as a tool provider on an agent). Raises: - ValueError: If a server reference cannot be resolved. + ValueError: If the connection parameters are ambiguous. """ - server: MCPServer | None = None - if client_def.server: - if client_def.server not in servers: - raise ValueError( - f"MCP client '{name}' references server '{client_def.server}' " - f"which is not defined under mcp_servers:.\n" - f"Available: {', '.join(sorted(servers)) or '(none)'}" - ) - server = servers[client_def.server] - - kwargs: dict[str, Any] = { - "server": server, - "url": client_def.url, - "command": client_def.command, - "transport_options": client_def.transport_options or None, + # transport stays None when the YAML omits it, so create_mcp_client can + # detect it from the URL path instead of being forced to a default. + transport = cast(MCP_TRANSPORT, client_def.transport) if client_def.transport else None + return create_mcp_client( + url=client_def.url, + command=client_def.command, + transport=transport, + transport_options=client_def.transport_options or None, **client_def.params, - } - if client_def.transport is not None: - kwargs["transport"] = cast(MCP_TRANSPORT, client_def.transport) - return create_mcp_client(**kwargs) + ) diff --git a/src/strands_compose/config/resolvers/orchestrations/__init__.py b/src/strands_compose/config/resolvers/orchestrations/__init__.py index 55d095d..70bc897 100644 --- a/src/strands_compose/config/resolvers/orchestrations/__init__.py +++ b/src/strands_compose/config/resolvers/orchestrations/__init__.py @@ -48,7 +48,7 @@ def resolve_orchestrations( global_session_manager_def: Global session manager def from ``AppConfig.session_manager``, used as a fallback when an orchestration declares no ``session_manager:`` of its own. - session_id: Effective session id threaded down from ``load_session``. + session_id: Effective session id threaded down from ``load``. Passed as ``session_id_override`` to every ``resolve_session_manager`` call made by leaf builders. diff --git a/src/strands_compose/config/resolvers/orchestrations/builders.py b/src/strands_compose/config/resolvers/orchestrations/builders.py index a9131fe..afb4023 100644 --- a/src/strands_compose/config/resolvers/orchestrations/builders.py +++ b/src/strands_compose/config/resolvers/orchestrations/builders.py @@ -72,7 +72,7 @@ def __init__( ``AppConfig.session_manager``, used as a fallback when an orchestration declares no ``session_manager:`` of its own and has not explicitly opted out. - session_id: Effective session id threaded down from ``load_session``. + session_id: Effective session id threaded down from ``load``. Passed as ``session_id_override`` to every ``resolve_session_manager`` call made by leaf builders. """ @@ -208,7 +208,7 @@ def build_delegate( global_session_manager_def: Global session manager def from ``AppConfig.session_manager``, used as a fallback when neither the orchestration nor the entry agent declares a ``session_manager:``. - session_id: Effective session id threaded down from ``load_session``. + session_id: Effective session id threaded down from ``load``. Passed as ``session_id_override`` to ``resolve_session_manager``. Returns: @@ -293,7 +293,7 @@ def build_swarm( global_session_manager_def: Global session manager def from ``AppConfig.session_manager``, used as a fallback when the orchestration declares no ``session_manager:`` of its own. - session_id: Effective session id threaded down from ``load_session``. + session_id: Effective session id threaded down from ``load``. Passed as ``session_id_override`` to ``resolve_session_manager``. Returns: @@ -366,7 +366,7 @@ def build_graph( global_session_manager_def: Global session manager def from ``AppConfig.session_manager``, used as a fallback when the orchestration declares no ``session_manager:`` of its own. - session_id: Effective session id threaded down from ``load_session``. + session_id: Effective session id threaded down from ``load``. Passed as ``session_id_override`` to ``resolve_session_manager``. Returns: diff --git a/src/strands_compose/config/resolvers/orchestrations/planner.py b/src/strands_compose/config/resolvers/orchestrations/planner.py index 6f99bb8..057364b 100644 --- a/src/strands_compose/config/resolvers/orchestrations/planner.py +++ b/src/strands_compose/config/resolvers/orchestrations/planner.py @@ -39,8 +39,10 @@ def collect_node_refs(config: OrchestrationDef) -> set[str]: for conn in config.connections: refs.add(conn.agent) elif isinstance(config, SwarmOrchestrationDef): + refs.add(config.entry_name) refs.update(config.agents) elif isinstance(config, GraphOrchestrationDef): + refs.add(config.entry_name) for edge in config.edges: refs.add(edge.from_agent) refs.add(edge.to_agent) diff --git a/src/strands_compose/config/resolvers/plugins.py b/src/strands_compose/config/resolvers/plugins.py index aed5cc6..779c4a0 100644 --- a/src/strands_compose/config/resolvers/plugins.py +++ b/src/strands_compose/config/resolvers/plugins.py @@ -17,9 +17,6 @@ def resolve_plugin(plugin_def: PluginDef) -> Plugin: ``"strands:AgentSkills"``) - ``"./path/to/plugins.py:ClassName"`` -- file-based import - No short-name aliases are supported. Use the full import path so that - submodules and third-party plugins work without ambiguity. - Args: plugin_def: Plugin definition from YAML. diff --git a/src/strands_compose/config/resolvers/session_manager.py b/src/strands_compose/config/resolvers/session_manager.py index e8d7d15..258fd2d 100644 --- a/src/strands_compose/config/resolvers/session_manager.py +++ b/src/strands_compose/config/resolvers/session_manager.py @@ -19,11 +19,7 @@ def _resolve_bedrock_agentcore_session_manager( params: dict[str, Any], session_id: str ) -> AgentCoreMemorySessionManager: - """Helper to resolve an AgentCoreMemorySessionManager with Bedrock-specific config. - - This is used by resolve_session_manager when the provider is "agentcore". - It extracts the relevant parameters from the config and constructs the - necessary AgentCoreMemoryConfig and AgentCoreMemorySessionManager objects. + """Resolve an AgentCoreMemorySessionManager from ``agentcore`` provider params. Args: params: The "params" dict from the SessionManagerDef for an "agentcore" provider. @@ -169,7 +165,7 @@ def resolve_leaf_session_manager( leaf_def: The leaf model's ``session_manager`` value. leaf_is_set: ``"session_manager" in leaf_model.model_fields_set``. global_def: The global ``AppConfig.session_manager`` def. - session_id: Effective session id from ``load_session``. + session_id: Effective session id from ``load``. Returns: A new ``SessionManager`` instance or ``None``. diff --git a/src/strands_compose/config/schema.py b/src/strands_compose/config/schema.py index 112f2ac..29b75e6 100644 --- a/src/strands_compose/config/schema.py +++ b/src/strands_compose/config/schema.py @@ -2,12 +2,6 @@ Pure data models — no runtime imports (Agent, MCPClient, etc.). Validation catches user errors at parse time with clear messages. - -Key Features: - - Discriminated union for orchestration modes (delegate, swarm, graph) - - Cross-section name collision detection via joint namespaces - - Reference field descriptors for automated name sanitization - - Inline and named model/hook/session_manager resolution """ from __future__ import annotations @@ -29,9 +23,8 @@ class HookDef(BaseModel): """Hook provider reference. ``type`` must be a ``module.path:ClassName`` import path or a - ``./file.py:ClassName`` file-based import path. The resolver raises - ``ValueError`` if there is no colon separator. ``params`` are forwarded - as constructor kwargs. + ``./file.py:ClassName`` file-based import path. A malformed spec raises + ``ImportResolutionError``. ``params`` are forwarded as constructor kwargs. """ type: str @@ -55,9 +48,8 @@ class ConversationManagerDef(BaseModel): """Conversation manager configuration. ``type`` must be a ``module.path:ClassName`` import path or a - ``./file.py:ClassName`` file-based import path. The resolver raises - ``ValueError`` if there is no colon separator. ``params`` are forwarded - as constructor kwargs. + ``./file.py:ClassName`` file-based import path. A malformed spec raises + ``ConfigurationError``. ``params`` are forwarded as constructor kwargs. Built-in strands classes: @@ -91,24 +83,16 @@ class SessionManagerDef(BaseModel): params: dict[str, Any] = Field(default_factory=dict) -class MCPServerDef(BaseModel): - """MCP server definition.""" - - type: str - params: dict[str, Any] = Field(default_factory=dict) - - class MCPClientDef(BaseModel): """MCP client connection definition. - Exactly one of ``server``, ``url``, or ``command`` must be set. + Exactly one of ``url`` or ``command`` must be set. ``params`` are forwarded to strands MCPClient (e.g., startup_timeout, tool_filters, prefix). ``transport_options`` are forwarded to the transport factory (e.g., headers, auth, timeout, http_client). """ - server: str | None = None url: str | None = None command: list[str] | None = None transport: str | None = None @@ -117,17 +101,12 @@ class MCPClientDef(BaseModel): @model_validator(mode="after") def _exactly_one_connection_mode(self) -> MCPClientDef: - """Validate that exactly one of server/url/command is set.""" - modes = [self.server is not None, self.url is not None, self.command is not None] - count = sum(modes) + """Validate that exactly one of url/command is set.""" + count = sum([self.url is not None, self.command is not None]) if count == 0: - raise ValueError( - "MCPClientDef requires exactly one of 'server', 'url', or 'command'; got none." - ) + raise ValueError("MCPClientDef requires exactly one of 'url' or 'command'; got none.") if count > 1: - raise ValueError( - "MCPClientDef requires exactly one of 'server', 'url', or 'command'; got multiple." - ) + raise ValueError("MCPClientDef requires exactly one of 'url' or 'command'; got both.") return self @@ -180,7 +159,6 @@ class AgentDef(BaseModel): hooks: list[HookDef | str] = Field(default_factory=list) plugins: list[PluginDef | str] = Field(default_factory=list) mcp: list[str] = Field(default_factory=list) - tool_labels: dict[str, str] = Field(default_factory=dict) conversation_manager: ConversationManagerDef | None = None session_manager: SessionManagerDef | None = None @@ -196,8 +174,9 @@ class DelegateConnectionDef(BaseModel): preserve_context: bool = True """Whether the delegate keeps its history between calls. - ``False`` resets an agent to its construction-time baseline every call. - Incompatible with a session manager, and unsupported for an Swarm and Graph. + ``False`` resets the agent to its construction-time baseline every call. + Rejected for a Swarm or Graph target (no baseline), and rejected by strands + itself if the agent has a session manager. """ @@ -304,10 +283,10 @@ def reference_fields(cls) -> dict[str, str]: # Sections that hold named dict collections (merged across config sources). # IMPORTANT: these must exactly match the dict field names on AppConfig below. -COLLECTION_KEYS = ("models", "mcp_servers", "mcp_clients", "agents", "orchestrations") +COLLECTION_KEYS = ("models", "mcp_clients", "agents", "orchestrations") # Groups of sections that share a lookup namespace — names must be unique within each group. -# mcp_servers / mcp_clients are independent namespaces and intentionally excluded. +# mcp_clients is an independent namespace and intentionally excluded. JOINT_NAMESPACES: tuple[tuple[str, ...], ...] = (("agents", "orchestrations"),) @@ -325,7 +304,6 @@ class AppConfig(BaseModel): version: str = "1" """Schema version — omit to use the default ``"1"``.""" models: dict[str, ModelDef] = Field(default_factory=dict) - mcp_servers: dict[str, MCPServerDef] = Field(default_factory=dict) mcp_clients: dict[str, MCPClientDef] = Field(default_factory=dict) agents: dict[str, AgentDef] = Field(default_factory=dict) session_manager: SessionManagerDef | None = None @@ -356,6 +334,7 @@ def _validate_no_name_collisions(self) -> AppConfig: raise ValueError( f"Name collision between {section_a} and {section_b}: " f"{sorted(overlap)}.\n" - f"Names must be unique within each section." + f"Names must be unique across {' and '.join(namespace)} — " + f"they share one lookup namespace." ) return self diff --git a/src/strands_compose/hooks/event_publisher.py b/src/strands_compose/hooks/event_publisher.py index 13dc8bb..b5ccd5f 100644 --- a/src/strands_compose/hooks/event_publisher.py +++ b/src/strands_compose/hooks/event_publisher.py @@ -1,11 +1,4 @@ -"""EventPublisher hook for streaming agent activities to external consumers. - -Key Features: - - Unified single-agent and multi-agent event publishing - - Safe callback wrapping that logs instead of propagating exceptions - - Longest-prefix tool label resolution for display names - - Callback handler factory for TOKEN, REASONING, and HANDOFF events -""" +"""EventPublisher hook for streaming agent activities to external consumers.""" from __future__ import annotations @@ -65,26 +58,11 @@ def _extract_result_text(result: Any, max_len: int = _MAX_RESULT_LEN) -> str | N return raw[:max_len] + "..." if len(raw) > max_len else raw -def _resolve_tool_label( - tool_name: str, - labels: dict[str, str] | None = None, -) -> str | None: - """Resolve a tool name to a display label via exact or longest-prefix match.""" - if not labels: - return None - if tool_name in labels: - return labels[tool_name] - best_match = None - best_length = 0 - for prefix, label in labels.items(): - if tool_name.startswith(prefix) and len(prefix) > best_length: - best_match = label - best_length = len(prefix) - return best_match - - def _safe_callback(callback: EventCallback) -> EventCallback: - """Wrap *callback* so exceptions are logged instead of propagated.""" + """Swallow transport failures so a dead consumer cannot kill the agent run. + + Any other exception is re-raised — that means the callback itself is broken. + """ def _wrapper(event: StreamEvent) -> None: try: @@ -115,7 +93,6 @@ def __init__( callback: EventCallback, agent_name: str, *, - tool_labels: dict[str, str] | None = None, max_result_len: int = 600, ) -> None: """Initialize the EventPublisher. @@ -130,7 +107,6 @@ def __init__( Args: callback: Called with each :class:`StreamEvent`. agent_name: Identifier for the agent or orchestrator. - tool_labels: Optional mapping of tool names to display labels. max_result_len: Maximum character length for tool result text in TOOL_END events. Default: 600. @@ -144,7 +120,6 @@ def __init__( """ self._callback = _safe_callback(callback) self._agent_name = agent_name - self._tool_labels = tool_labels or {} self._max_result_len = max_result_len self._errored = False @@ -178,7 +153,6 @@ def _on_agent_start(self, event: BeforeInvocationEvent) -> None: def _on_tool_start(self, event: BeforeToolCallEvent) -> None: """Register a pending tool call and emit a TOOL_START streaming event.""" raw_name = event.tool_use.get("name", "unknown") - tool_label = _resolve_tool_label(raw_name, self._tool_labels) or raw_name tool_use_id = event.tool_use.get("toolUseId", "") self._callback( @@ -187,7 +161,6 @@ def _on_tool_start(self, event: BeforeToolCallEvent) -> None: agent_name=self._agent_name, data={ "tool_name": raw_name, - "tool_label": tool_label, "tool_use_id": tool_use_id, "tool_input": event.tool_use.get("input", {}), }, @@ -197,7 +170,6 @@ def _on_tool_start(self, event: BeforeToolCallEvent) -> None: def _on_tool_end(self, event: AfterToolCallEvent) -> None: """Complete a pending tool call, accumulate the step, and emit TOOL_END.""" raw_name = event.tool_use.get("name", "unknown") - tool_label = _resolve_tool_label(raw_name, self._tool_labels) or raw_name tool_use_id = event.tool_use.get("toolUseId", "") status = "error" if event.exception else "success" @@ -208,7 +180,6 @@ def _on_tool_end(self, event: AfterToolCallEvent) -> None: agent_name=self._agent_name, data={ "tool_name": raw_name, - "tool_label": tool_label, "tool_use_id": tool_use_id, "status": status, "error": str(event.exception) if event.exception else None, diff --git a/src/strands_compose/mcp/README.md b/src/strands_compose/mcp/README.md deleted file mode 100644 index dfe6784..0000000 --- a/src/strands_compose/mcp/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# MCP Module — Developer Guide - -This module manages the full lifecycle of [Model Context Protocol](https://modelcontextprotocol.io/) servers and clients within strands-compose. It bridges the gap between the low-level `mcp` Python SDK (which provides `FastMCP` and transport primitives) and the strands agent framework (which consumes `MCPClient` as a tool provider). - -The compose config layer resolves YAML declarations into the objects defined here; this module knows nothing about YAML — it only deals with constructed Python objects and their lifecycle. - ---- - -## server — `MCPServer` and `create_mcp_server` - -**Responsibility:** Define, build, start, and **gracefully stop** MCP tool servers. - -`MCPServer` is an abstract base class. Subclasses implement `_register_tools(mcp)` to register tool functions, custom routes, or resources on the underlying `FastMCP` instance. Everything else — thread management, readiness probing, and shutdown — is handled by the base. - -`create_mcp_server(name, tools=[...])` is a convenience factory that creates an `MCPServer` without subclassing. It's used by YAML configs that list plain callables. - -### Why we bypass `FastMCP.run()` - -`FastMCP.run(transport="streamable-http")` internally does: - -```python -async def run_streamable_http_async(self): - config = uvicorn.Config(self.streamable_http_app(), ...) - server = uvicorn.Server(config) - await server.serve() # blocks forever -``` - -The `uvicorn.Server` instance is a **local variable** — it is never stored on `self`. When running in a background thread, uvicorn cannot install signal handlers (Python restricts `signal.signal()` to the main thread), so there is no way to trigger shutdown from outside. - -Our solution: call `FastMCP.streamable_http_app()` (or `sse_app()`) ourselves to get the Starlette ASGI app, then create and hold our own `uvicorn.Server`. This gives us access to `uvicorn.Server.should_exit` — a boolean that uvicorn's main loop polls every 100 ms. Setting it from any thread triggers graceful shutdown (stop accepting -> drain -> exit). - -### Shutdown sequence - -`stop()` follows a two-phase escalation: - -1. **Graceful** — set `should_exit = True`, wait `STOP_TIMEOUT` (5 s). Uvicorn stops accepting connections and drains in-flight requests. -2. **Forced** — if still alive, set `force_exit = True`, wait `STOP_FORCE_TIMEOUT` (2 s). Uvicorn skips connection draining and exits immediately. -3. **Abandoned** — if the thread is still alive, log a warning. The thread is a daemon and will be reaped at process exit. - -### Transport types - -Only HTTP transports (`streamable-http`, `sse`) are supported for `MCPServer`. The type alias `MCP_SERVER_TRANSPORT = Literal["sse", "streamable-http"]` enforces this at the type level. - -`stdio` is a **client-side** transport where the client spawns a server subprocess and communicates over stdin/stdout pipes. There is no HTTP server to manage, so it doesn't belong in `MCPServer`. Client-side stdio is fully supported via `create_mcp_client(command=...)` and `stdio_transport()`. - -### Subclass contract - -Subclasses only need to implement `_register_tools(mcp: FastMCP)`. Override `run()` for blocking-mode customisation (e.g. the Postgres server adds `finally: close_pools()`). Override `stop()` if you need cleanup after the server thread exits (e.g. closing database pools). - ---- - -## client — `create_mcp_client` - -**Responsibility:** Create a strands `MCPClient` from one of three connection modes. - -Exactly one of these must be provided: - -| Parameter | Transport | Use case | -|-----------|-----------|----------| -| `server=` | streamable-http (default) or sse | Connect to a managed `MCPServer` running in the same process | -| `url=` | Auto-detected from URL path, or explicit override | Connect to an external MCP server | -| `command=` | stdio | Launch a subprocess MCP server | - -The function auto-detects transport from URL paths (e.g. `/sse` -> SSE, everything else -> streamable-http). Transport-specific options (`headers`, `timeout`, `http_client`, etc.) are forwarded via `transport_options`. - -The returned object is a standard strands `MCPClient` — no wrapping, full strands functionality. Strands auto-starts clients when they're registered on an `Agent`, so client start is not managed here. - ---- - -## transports — Transport factory functions - -**Responsibility:** Create transport callables that strands `MCPClient` accepts as `transport_callable`. - -Each factory captures its configuration in a closure and returns a zero-argument callable that produces an async context manager yielding `(read_stream, write_stream)`. This deferred construction matters because strands creates the transport connection lazily when the agent first needs tools. - -Three factories corresponding to the three MCP transport types: - -- **`streamable_http_transport(url, headers=, http_client=)`** — wraps `mcp.client.streamable_http.streamable_http_client`. Supports pre-configured `httpx.AsyncClient` for custom auth/TLS. -- **`sse_transport(url, headers=, timeout=, auth=)`** — wraps `mcp.client.sse.sse_client`. -- **`stdio_transport(command, env=, cwd=)`** — wraps `mcp.client.stdio.stdio_client`. - ---- - -## lifecycle — `MCPLifecycle` - -**Responsibility:** Enforce startup and shutdown ordering across multiple servers and clients. - -The ordering constraint is: - -1. **Start:** all servers start and become ready (TCP port responds) *before* any client can connect. -2. **Stop:** all clients stop *before* any server stops. - -This prevents clients from connecting to servers that aren't ready, and prevents servers from shutting down while clients still have open sessions. - -### Integration with compose - -The config resolver assembles an `MCPLifecycle` with all declared servers and clients, but does **not** start it. The `load()` function calls `lifecycle.start()` before creating agents. Agents auto-start their MCP clients on construction. - -Shutdown happens via context manager (`with lifecycle:` / `async with lifecycle:`) or explicit `lifecycle.stop()` in a `finally` block. - -### Why clients are not started in `lifecycle.start()` - -Strands `MCPClient` manages its own session lifecycle. It starts automatically when registered on an `Agent`. If we started clients in `lifecycle.start()`, the `Agent` constructor would fail with "session is currently running". So `lifecycle.start()` only starts *servers* — clients are left for strands to manage. - -`lifecycle.stop()` does stop clients explicitly because strands does not auto-stop them on agent destruction. diff --git a/src/strands_compose/mcp/__init__.py b/src/strands_compose/mcp/__init__.py index dcc3ead..e94294a 100644 --- a/src/strands_compose/mcp/__init__.py +++ b/src/strands_compose/mcp/__init__.py @@ -1,26 +1,21 @@ -"""MCP server and client lifecycle management.""" +"""MCP client construction and transports. Clients only — never a server.""" from __future__ import annotations from strands.tools.mcp import MCPClient from .client import create_mcp_client -from .lifecycle import MCPLifecycle -from .server import MCPServer, create_mcp_server from .transports import ( - MCP_SERVER_TRANSPORT, + MCP_TRANSPORT, sse_transport, stdio_transport, streamable_http_transport, ) __all__ = [ - "MCP_SERVER_TRANSPORT", + "MCP_TRANSPORT", "MCPClient", - "MCPLifecycle", - "MCPServer", "create_mcp_client", - "create_mcp_server", "sse_transport", "stdio_transport", "streamable_http_transport", diff --git a/src/strands_compose/mcp/client.py b/src/strands_compose/mcp/client.py index 451731a..9ab2b9c 100644 --- a/src/strands_compose/mcp/client.py +++ b/src/strands_compose/mcp/client.py @@ -2,6 +2,10 @@ Returns the standard strands MCPClient (which is a ToolProvider). No wrapping — full strands functionality is available. + +Clients are not started here: strands starts an ``MCPClient`` when it is +registered as a tool provider on an ``Agent``, and stops it again when the +last consuming agent is torn down. """ from __future__ import annotations @@ -10,7 +14,6 @@ from urllib.parse import urlparse from .transports import ( - DEFAULT_TRANSPORT, MCP_TRANSPORT, sse_transport, stdio_transport, @@ -20,28 +23,25 @@ if TYPE_CHECKING: from strands.tools.mcp import MCPClient - from .server import MCPServer - def create_mcp_client( *, - server: MCPServer | None = None, url: str | None = None, command: list[str] | None = None, - transport: MCP_TRANSPORT = DEFAULT_TRANSPORT, + transport: MCP_TRANSPORT | None = None, transport_options: dict[str, Any] | None = None, **kwargs: Any, ) -> MCPClient: """Create a strands MCPClient from connection configuration. - Exactly one of server, url, or command must be provided. + Exactly one of ``url`` or ``command`` must be provided. Args: - server: A managed MCPServer instance (connects via its URL). - url: External MCP server URL (for SSE or streamable-http). + url: MCP server URL (for SSE or streamable-http). command: Command to start an MCP server subprocess (stdio transport). transport: Override transport type ("stdio", "sse", "streamable-http"). - Auto-detected if not specified. + Leave ``None`` to detect it from the URL path — ``/sse`` selects + SSE, anything else selects streamable-http. transport_options: Extra kwargs forwarded to the transport factory. These are transport-specific — see each transport function for available options: @@ -64,20 +64,17 @@ def create_mcp_client( Raises: ValueError: If connection parameters are ambiguous. """ - modes = sum(x is not None for x in [server, url, command]) + modes = sum(x is not None for x in [url, command]) if modes != 1: raise ValueError( - f"Exactly one of server, url, or command must be provided (got {modes}).\n" - "server=MCPServer for managed servers, url=str for external HTTP, " - "command=list[str] for subprocess stdio." + f"Exactly one of url or command must be provided (got {modes}).\n" + "url=str for an HTTP MCP server, command=list[str] for subprocess stdio." ) opts = transport_options or {} - if server is not None: - transport_callable = _transport_for_http(server.url, transport, opts, allow_stdio=False) - elif url is not None: - transport_callable = _transport_for_http(url, transport, opts, allow_stdio=True) + if url is not None: + transport_callable = _transport_for_http(url, transport, opts) else: # command is guaranteed non-None by the modes == 1 check above. transport_callable = stdio_transport(command, **opts) # ty: ignore @@ -101,26 +98,22 @@ def _make_strands_client(**kwargs: Any) -> MCPClient: def _transport_for_http( url: str, - transport: str | None, + transport: MCP_TRANSPORT | None, opts: dict[str, Any] | None = None, - *, - allow_stdio: bool = True, ) -> Any: """Build a transport callable for an HTTP-based MCP connection. Args: url: The MCP server URL. - transport: Optional transport override. Auto-detected from URL when omitted. + transport: Explicit transport override, or ``None`` to detect it from + the URL path. opts: Transport-specific options forwarded to the transport factory. - allow_stdio: When False, raises ValueError if stdio is requested. - Set to False for managed servers where stdio makes no sense. Returns: A transport callable for strands MCPClient. Raises: - ValueError: If the transport type is unsupported or stdio is requested - when allow_stdio is False. + ValueError: If the transport type is unsupported for an HTTP URL. """ opts = opts or {} effective = transport or _detect_transport(url) @@ -128,12 +121,9 @@ def _transport_for_http( return streamable_http_transport(url, **opts) if effective == "sse": return sse_transport(url, **opts) - if effective == "stdio" and not allow_stdio: - raise ValueError( - "stdio transport not supported for managed servers. Use url or command instead." - ) raise ValueError( - f"HTTP-based connection requires 'sse' or 'streamable-http' transport, got: {effective}." + f"HTTP-based connection requires 'sse' or 'streamable-http' transport, got: {effective}.\n" + "Use command=list[str] for a stdio subprocess server." ) diff --git a/src/strands_compose/mcp/lifecycle.py b/src/strands_compose/mcp/lifecycle.py deleted file mode 100644 index 4e00976..0000000 --- a/src/strands_compose/mcp/lifecycle.py +++ /dev/null @@ -1,233 +0,0 @@ -"""MCP server and client lifecycle ordering. - -Ensures servers are started and ready before clients connect, -and clients are stopped before servers on shutdown. - -Key Features: - - Ordered startup: servers first, then clients - - Ordered shutdown: clients first, then servers - - Idempotent start with sync and async context managers - - Configurable server readiness timeout -""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Self - -if TYPE_CHECKING: - from types import TracebackType - - from strands.tools.mcp import MCPClient as StrandsMCPClient - - from .server import MCPServer - -logger = logging.getLogger(__name__) - - -class MCPLifecycle: - """Manages MCP server and client lifecycle ordering.""" - - def __init__(self, server_ready_timeout: float = 30) -> None: - """Initialize the MCPLifecycle. - - Ensures servers are fully ready before clients connect, and clients - are stopped before servers on shutdown. - - Example:: - - lifecycle = MCPLifecycle() - lifecycle.add_server("postgres", pg_server) - lifecycle.add_client("pg_client", pg_client) - - with lifecycle: - # All servers started and ready, all clients connected - agent = Agent(tools=[lifecycle.get_client("pg_client")]) - agent("Query the database") - - # All cleaned up - - Or without context manager:: - - lifecycle.start() - try: - ... - finally: - lifecycle.stop() - - Args: - server_ready_timeout: Seconds to wait for each server to become ready. - """ - self._servers: dict[str, MCPServer] = {} - self._clients: dict[str, StrandsMCPClient] = {} - self._server_ready_timeout = server_ready_timeout - self._started = False - - def add_server(self, name: str, server: MCPServer) -> None: - """Register an MCP server. - - Args: - name: Unique server identifier. - server: The MCP server instance. - - Raises: - ValueError: If a server with this name is already registered. - """ - if name in self._servers: - raise ValueError(f"MCP server '{name}' is already registered") - self._servers[name] = server - - def add_client(self, name: str, client: StrandsMCPClient) -> None: - """Register an MCP client. - - Args: - name: Unique client identifier. - client: The strands MCP client instance. - - Raises: - ValueError: If a client with this name is already registered. - """ - if name in self._clients: - raise ValueError(f"MCP client '{name}' is already registered") - self._clients[name] = client - - def get_server(self, name: str) -> MCPServer: - """Get a registered server by name. - - Args: - name: Server identifier. - - Returns: - The registered MCP server. - - Raises: - KeyError: If no server with this name is registered. - """ - if name not in self._servers: - raise KeyError(f"MCP server '{name}' not registered.\nAvailable: {list(self._servers)}") - return self._servers[name] - - def get_client(self, name: str) -> StrandsMCPClient: - """Get a registered client by name. - - Args: - name: Client identifier. - - Returns: - The registered strands MCP client. - - Raises: - KeyError: If no client with this name is registered. - """ - if name not in self._clients: - raise KeyError(f"MCP client '{name}' not registered.\nAvailable: {list(self._clients)}") - return self._clients[name] - - def start(self) -> None: - """Start all servers and wait for readiness. - - **Idempotent**: if already started, returns immediately. - ``load()`` calls this before creating agents (so MCP clients can - connect), and the context manager calls it again on enter — the - second call is a no-op. The context manager is still needed for - **graceful shutdown** via ``stop()``. - - Clients are **not** started here — strands automatically starts - MCPClient instances when they are registered as tool providers - on an Agent. Starting them here would cause a "session is currently - running" error when the Agent tries to start them again. - - Raises: - RuntimeError: If any server fails to start or become ready. - """ - if self._started: - return - - # Phase 1: Start all servers - for name, server in self._servers.items(): - logger.info("server=<%s> | starting MCP server", name) - server.start() - - # Phase 2: Wait for all servers to be ready - for name, server in self._servers.items(): - if not server.wait_ready(timeout=self._server_ready_timeout): - raise RuntimeError( - f"MCP server '{name}' did not become ready within {self._server_ready_timeout}s" - ) - logger.info("server=<%s> | MCP server is ready", name) - - self._started = True - - def stop(self) -> None: - """Stop all clients first, then all servers. - - Clients that were never started (e.g., never registered on an Agent) - are skipped gracefully. - """ - if not self._started: - return - - # Phase 1: Stop all clients - for name, client in self._clients.items(): - try: - # Normal shutdown — no exception context (matches __exit__ protocol) - client.stop(exc_type=None, exc_val=None, exc_tb=None) - logger.info("client=<%s> | MCP client stopped", name) - except Exception: - logger.warning("client=<%s> | failed to stop MCP client", name, exc_info=True) - - # Phase 2: Stop all servers - for name, server in self._servers.items(): - try: - server.stop() - logger.info("server=<%s> | MCP server stopped", name) - except Exception: - logger.warning("server=<%s> | failed to stop MCP server", name, exc_info=True) - - self._started = False - - def __enter__(self) -> Self: - """Start lifecycle on context entry.""" - self.start() - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - """Stop lifecycle on context exit.""" - self.stop() - - async def __aenter__(self) -> Self: - """Async context entry — delegates to sync :meth:`start`. - - Useful with Starlette / ASGI lifespan:: - - @asynccontextmanager - async def lifespan(app): - async with lifecycle: - yield - """ - self.start() - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - """Async context exit — delegates to sync :meth:`stop`.""" - self.stop() - - @property - def servers(self) -> dict[str, MCPServer]: - """Read-only view of registered servers.""" - return dict(self._servers) - - @property - def clients(self) -> dict[str, StrandsMCPClient]: - """Read-only view of registered clients.""" - return dict(self._clients) diff --git a/src/strands_compose/mcp/server.py b/src/strands_compose/mcp/server.py deleted file mode 100644 index c5ef65f..0000000 --- a/src/strands_compose/mcp/server.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Abstract MCP server base class. - -Subclasses implement :meth:`_register_tools` to add tools on the -underlying ``FastMCP`` instance. The base handles lifecycle — -start in a background daemon thread, readiness signaling via -``threading.Event``, and graceful shutdown. - -Graceful shutdown ------------------ -The server bypasses ``FastMCP.run()`` — which creates a local -``uvicorn.Server`` that is inaccessible after the call — and instead -obtains the Starlette ASGI app via ``FastMCP.streamable_http_app()`` / -``FastMCP.sse_app()``, then creates and manages its own -``uvicorn.Server``. This gives us access to -``uvicorn.Server.should_exit`` for clean shutdown from any thread. - -Only HTTP transports (``streamable-http``, ``sse``) are supported. -``stdio`` is a client-side transport where the client spawns a server -subprocess — there is no server to manage here. - -Example:: - - class PostgresServer(MCPServer): - def _register_tools(self, mcp: FastMCP) -> None: - mcp.tool()(my_query_func) - - - server = PostgresServer(name="postgres", port=8001) - server.start() - server.wait_ready(timeout=10) - # ... use server ... - server.stop() -""" - -from __future__ import annotations - -import asyncio -import logging -import socket -import threading -import time -from abc import ABC, abstractmethod -from collections.abc import Callable -from typing import TYPE_CHECKING, Any - -from .transports import DEFAULT_TRANSPORT, MCP_SERVER_TRANSPORT - -if TYPE_CHECKING: - import uvicorn - from mcp.server.fastmcp import FastMCP - - -logger = logging.getLogger(__name__) - - -class MCPServer(ABC): - """Abstract base for strands_compose MCP servers.""" - - #: Seconds to wait for uvicorn graceful drain after ``should_exit``. - STOP_TIMEOUT: float = 5 - #: Extra seconds to wait after ``force_exit`` before giving up. - STOP_FORCE_TIMEOUT: float = 2 - - def __init__( - self, - *, - name: str, - host: str = "127.0.0.1", - port: int = 8000, - transport: MCP_SERVER_TRANSPORT = DEFAULT_TRANSPORT, - server_params: dict[str, Any] | None = None, - ) -> None: - """Initialize the MCPServer. - - Subclasses implement ``_register_tools()`` to register tools on the - ``FastMCP`` instance. The base class manages background-thread - lifecycle and readiness signaling. - - Args: - name: Unique server identifier. - host: Bind address for the HTTP transport. - port: Bind port for the HTTP transport. - transport: MCP server transport type (``streamable-http`` or ``sse``). - server_params: Extra keyword arguments forwarded to ``FastMCP()``. - """ - self.name = name - self.host = host - self.port = port - self.transport = transport - self.server_params = server_params or {} - self._mcp: FastMCP | None = None - self._thread: threading.Thread | None = None - self._ready = threading.Event() - self._error: BaseException | None = None - self._uvicorn_server: uvicorn.Server | None = None - - # -- properties ------------------------------------------------- # - - @property - def url(self) -> str: - """Base URL of this server (for client transport).""" - return f"http://{self.host}:{self.port}/mcp" - - @property - def is_running(self) -> bool: - """True if the server thread is alive.""" - return self._thread is not None and self._thread.is_alive() - - # -- server creation -------------------------------------------- # - - def create_server(self) -> FastMCP: - """Build the ``FastMCP`` instance and register tools. - - The result is cached — calling twice returns the same instance. - """ - if self._mcp is not None: - return self._mcp - - from mcp.server.fastmcp import FastMCP as _FastMCP - - mcp = _FastMCP( - self.name, - host=self.host, - port=self.port, - stateless_http=True, - json_response=True, - log_level="WARNING", - **self.server_params, - ) - self._register_tools(mcp) - self._mcp = mcp - return mcp - - # -- lifecycle -------------------------------------------------- # - - def _get_asgi_app(self, mcp: FastMCP) -> Any: - """Return the Starlette ASGI app for the current transport. - - Calls the corresponding public method on ``FastMCP`` which lazily - initialises the session manager and returns a ``Starlette`` - instance. - - Raises: - ValueError: If the transport type is not supported. - """ - if self.transport == "streamable-http": - return mcp.streamable_http_app() - if self.transport == "sse": - return mcp.sse_app() - raise ValueError( - f"Unsupported server transport: {self.transport!r}. " - "MCPServer only supports 'streamable-http' and 'sse'. " - "The 'stdio' transport is a client-side transport where the client " - "spawns the server as a subprocess." - ) - - def run(self) -> None: - """Start the server blocking (for standalone CLI usage). - - In the main thread ``FastMCP.run()`` installs signal handlers so - that Ctrl-C triggers a graceful uvicorn shutdown. - """ - mcp = self.create_server() - mcp.run(transport=self.transport) - - def start(self) -> None: - """Start the server in a background daemon thread. - - Creates its own ``uvicorn.Server`` instead of delegating to - ``FastMCP.run()``. This keeps a reference to the server so that - :meth:`stop` can trigger a graceful shutdown via - ``uvicorn.Server.should_exit``. - """ - if self.is_running: - return - self._ready.clear() - self._error = None - - mcp = self.create_server() - asgi_app = self._get_asgi_app(mcp) - - import uvicorn as _uvicorn - - config = _uvicorn.Config( - asgi_app, - host=self.host, - port=self.port, - log_level="warning", - ) - self._uvicorn_server = _uvicorn.Server(config) - - def _target() -> None: - try: - asyncio.run(self._uvicorn_server.serve()) # ty: ignore - except BaseException as exc: - # Captured here and re-raised by wait_ready() on the caller's - # thread — logged so it is not silently lost if wait_ready() - # is never called. - logger.warning("server=<%s> | MCP server thread crashed", self.name, exc_info=True) - self._error = exc - self._ready.set() - - self._thread = threading.Thread( - target=_target, - name=f"mcp-{self.name}", - daemon=True, - ) - self._thread.start() - - def wait_ready(self, timeout: float = 30) -> bool: - """Wait for the server to be ready by polling the TCP port. - - Returns: - True if server is ready, False if timed out. - - Raises: - RuntimeError: If the server thread died before becoming ready. - """ - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if self._error is not None: - raise RuntimeError( - f"MCP server '{self.name}' failed to start: {self._error}" - ) from self._error - if self._thread is not None and not self._thread.is_alive(): - raise RuntimeError(f"MCP server '{self.name}' thread exited unexpectedly") - try: - with socket.create_connection((self.host, self.port), timeout=1): - self._ready.set() - return True - except OSError: - time.sleep(0.1) - return False - - def stop(self) -> None: - """Stop the server and clean up the background thread. - - Signals ``uvicorn.Server.should_exit`` which triggers a graceful - drain (stop accepting new connections, finish in-flight requests). - If the thread does not exit within :attr:`STOP_TIMEOUT` seconds, - ``force_exit`` is set to skip connection draining. After a - further :attr:`STOP_FORCE_TIMEOUT` seconds the thread is - abandoned as a daemon thread and will be reaped when the process - exits. - """ - if self._thread is not None and self._thread.is_alive(): - if self._uvicorn_server is not None: - # Graceful phase: ask uvicorn to stop accepting and drain. - self._uvicorn_server.should_exit = True - self._thread.join(timeout=self.STOP_TIMEOUT) - - if self._thread.is_alive(): - # Forceful phase: skip connection draining. - logger.info( - "server=<%s>, timeout=<%s> | forcing exit after graceful stop timeout", - self.name, - self.STOP_TIMEOUT, - ) - self._uvicorn_server.force_exit = True - self._thread.join(timeout=self.STOP_FORCE_TIMEOUT) - - if self._thread.is_alive(): - logger.warning( - "server=<%s> | thread did not stop, daemon will be reaped at exit", self.name - ) - - self._uvicorn_server = None - self._mcp = None - self._thread = None - self._ready.clear() - - # -- extension point -------------------------------------------- # - - @abstractmethod - def _register_tools(self, mcp: FastMCP) -> None: - """Register tools, routes, and resources on the FastMCP instance.""" - ... - - -def create_mcp_server( - *, - name: str, - tools: list[Callable[..., Any]], - host: str = "127.0.0.1", - port: int = 8000, - transport: MCP_SERVER_TRANSPORT = DEFAULT_TRANSPORT, - server_params: dict[str, Any] | None = None, -) -> MCPServer: - """Create an MCP server from a list of callables — no subclassing needed. - - Each callable (sync or async) is registered as a tool on the underlying - ``FastMCP`` instance. For advanced use (custom state, routes, resources), - subclass :class:`MCPServer` directly. - - Example:: - - def get_weather(city: str) -> str: - return f"Sunny in {city}" - - - async def query_db(sql: str) -> str: ... - - - server = create_mcp_server(name="weather", tools=[get_weather, query_db], port=8001) - server.start() - - Args: - name: Unique server identifier. - tools: Callables to register as MCP tools. - host: Bind address (default ``127.0.0.1``). - port: Bind port (default ``8000``). - transport: Server transport type (``streamable-http`` or ``sse``). - server_params: Extra kwargs forwarded to ``FastMCP()``. - - Returns: - A ready-to-use :class:`MCPServer` instance. - """ - tool_fns = list(tools) - - class _FactoryServer(MCPServer): - def _register_tools(self, mcp: FastMCP) -> None: - for fn in tool_fns: - mcp.tool()(fn) - - return _FactoryServer( - name=name, - host=host, - port=port, - transport=transport, - server_params=server_params, - ) diff --git a/src/strands_compose/mcp/transports.py b/src/strands_compose/mcp/transports.py index f1780b6..433fad1 100644 --- a/src/strands_compose/mcp/transports.py +++ b/src/strands_compose/mcp/transports.py @@ -20,17 +20,7 @@ logger = logging.getLogger(__name__) MCP_TRANSPORT = Literal["stdio", "sse", "streamable-http"] -"""All MCP transport types (client and server).""" - -MCP_SERVER_TRANSPORT = Literal["sse", "streamable-http"] -"""Transport types valid for :class:`~strands_compose.mcp.server.MCPServer`. - -``stdio`` is excluded because it is a client-side transport where the -client spawns the server as a subprocess and communicates over -stdin/stdout pipes — there is no HTTP server to manage. -""" - -DEFAULT_TRANSPORT: MCP_SERVER_TRANSPORT = "streamable-http" +"""MCP transport types.""" def stdio_transport( diff --git a/src/strands_compose/models.py b/src/strands_compose/models.py index 9783d75..b70b0a0 100644 --- a/src/strands_compose/models.py +++ b/src/strands_compose/models.py @@ -9,6 +9,24 @@ PROVIDERS = ("bedrock", "ollama", "openai", "gemini", "anthropic") +def _missing_extra_error(provider: str, extra: str) -> ImportError: + """Build the ``ImportError`` raised when an optional provider extra is missing. + + Args: + provider: Provider name as passed to ``create_model``, e.g. ``"openai"``. + extra: Name of the pip extra that supplies the dependency, e.g. + ``"openai"``. Usually matches ``provider`` but need not. + + Returns: + An ``ImportError`` with install instructions for the missing extra. + """ + return ImportError( + f"The '{provider}' provider requires the {extra} extra:\n" + f" pip install strands-compose[{extra}]\n" + f"Or install directly: pip install strands-agents[{extra}]" + ) + + def create_model(provider: str, model_id: str, **params: Any) -> Model: """Dispatch to the appropriate model factory by provider name. @@ -34,44 +52,28 @@ def create_model(provider: str, model_id: str, **params: Any) -> Model: try: from strands.models.ollama import OllamaModel except ImportError: - raise ImportError( - "The 'ollama' provider requires the ollama extra:\n" - " pip install strands-compose[ollama]\n" - "Or install directly: pip install strands-agents[ollama]" - ) from None + raise _missing_extra_error("ollama", "ollama") from None return OllamaModel(model_id=model_id, **params) if provider_name == "openai": try: from strands.models.openai import OpenAIModel except ImportError: - raise ImportError( - "The 'openai' provider requires the openai extra:\n" - " pip install strands-compose[openai]\n" - "Or install directly: pip install strands-agents[openai]" - ) from None + raise _missing_extra_error("openai", "openai") from None return OpenAIModel(model_id=model_id, **params) if provider_name == "gemini": try: from strands.models.gemini import GeminiModel except ImportError: - raise ImportError( - "The 'gemini' provider requires the gemini extra:\n" - " pip install strands-compose[gemini]\n" - "Or install directly: pip install strands-agents[gemini]" - ) from None + raise _missing_extra_error("gemini", "gemini") from None return GeminiModel(model_id=model_id, **params) if provider_name == "anthropic": try: from strands.models.anthropic import AnthropicModel except ImportError: - raise ImportError( - "The 'anthropic' provider requires the anthropic extra:\n" - " pip install strands-compose[anthropic]\n" - "Or install directly: pip install strands-agents[anthropic]" - ) from None + raise _missing_extra_error("anthropic", "anthropic") from None return AnthropicModel(model_id=model_id, **params) raise ValueError(f"Unknown model provider '{provider}'.\nAvailable: {', '.join(PROVIDERS)}.") diff --git a/src/strands_compose/renderers/ansi.py b/src/strands_compose/renderers/ansi.py index 6efc473..921007b 100644 --- a/src/strands_compose/renderers/ansi.py +++ b/src/strands_compose/renderers/ansi.py @@ -1,4 +1,4 @@ -"""Zero-dependency ANSI renderer for :class:`~strands_compose.wire.StreamEvent` objects. +"""Zero-dependency ANSI renderer for StreamEvent objects. Colour codes are automatically suppressed when stdout is not a TTY (piped / redirected output). @@ -11,11 +11,6 @@ while (event := await queue.get()) is not None: renderer.render(event) renderer.flush() - -Key Features: - - Automatic TTY detection with color suppression for piped output - - Inline token and reasoning streaming with mode-change separators - - Full event type coverage including multi-agent orchestration events """ from __future__ import annotations @@ -88,6 +83,7 @@ def __init__( EventType.TOOL_START: self._handle_tool_start, EventType.TOOL_END: self._handle_tool_end, EventType.AGENT_COMPLETE: self._handle_agent_complete, + EventType.INTERRUPT: self._handle_interrupt, EventType.ERROR: self._handle_error, EventType.NODE_START: self._handle_node_start, EventType.NODE_STOP: self._handle_node_stop, @@ -169,7 +165,7 @@ def _handle_tool_start(self, event: StreamEvent) -> None: self._mode = None self._active_agent = None data = event.data - label = data.get("tool_label") or data.get("tool_name", "unknown") + label = data.get("tool_name", "unknown") tool_input = data.get("tool_input", {}) preview = str(tool_input)[:80] + ("…" if len(str(tool_input)) > 80 else "") self._out.write(self._separator(event.agent_name, "TOOL USE", color=self._magenta)) @@ -204,20 +200,28 @@ def _handle_agent_complete(self, event: StreamEvent) -> None: ) self._out.flush() + def _handle_interrupt(self, event: StreamEvent) -> None: + self._break() + self._mode = None + self._active_agent = None + data = event.data + name = data.get("name") or "—" + reason = data.get("reason") or "no reason given" + self._out.write(self._separator(event.agent_name, "INTERRUPT", color=self._yellow)) + self._out.write( + f" {self._yellow}⏸{self._reset} [{event.agent_name}] awaiting input for {name!r}: {reason}\n" + f" {self._dim}interrupt_id: {data.get('interrupt_id') or '—'}{self._reset}\n" + ) + self._out.flush() + def _handle_error(self, event: StreamEvent) -> None: self._break() self._mode = None self._out.write(self._separator(event.agent_name, "ERROR", color=self._red)) - msg = event.data.get("message", "unknown error") + msg = event.data.get("text", "unknown error") exc_type = event.data.get("exception_type") - if exc_type and msg.startswith(f"{exc_type}: "): - detail = msg[len(exc_type) + 2 :] - self._out.write( - f" {self._red}✗ [{event.agent_name}] ERROR: {exc_type}:\n" - f" {detail}{self._reset}\n" - ) - else: - self._out.write(f" {self._red}✗ [{event.agent_name}] ERROR: {msg}{self._reset}\n") + prefix = f"{exc_type}: " if exc_type else "" + self._out.write(f" {self._red}✗ [{event.agent_name}] ERROR: {prefix}{msg}{self._reset}\n") self._out.flush() def _handle_node_start(self, event: StreamEvent) -> None: diff --git a/src/strands_compose/startup/__init__.py b/src/strands_compose/startup/__init__.py deleted file mode 100644 index bb36d9d..0000000 --- a/src/strands_compose/startup/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Pre-flight startup validation and health checking. - -Usage:: - - from strands_compose.startup import validate_mcp, StartupReport - - report = await validate_mcp(infra) # or validate_mcp(resolved_config) - report.print_summary() - report.raise_if_critical() -""" - -from __future__ import annotations - -from .report import CheckResult, Severity, StartupError, StartupReport -from .validator import probe_http_health, validate_mcp - -__all__ = [ - "CheckResult", - "Severity", - "StartupError", - "StartupReport", - "probe_http_health", - "validate_mcp", -] diff --git a/src/strands_compose/startup/report.py b/src/strands_compose/startup/report.py deleted file mode 100644 index af9596e..0000000 --- a/src/strands_compose/startup/report.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Startup health-check result types. - -Provides :class:`CheckResult` for individual check outcomes, -:class:`StartupReport` for aggregated results, and -:class:`StartupError` raised when critical checks fail. -""" - -from __future__ import annotations - -import dataclasses -import logging -from typing import Literal - -logger = logging.getLogger(__name__) - -Severity = Literal["critical", "warning", "info"] - - -@dataclasses.dataclass -class CheckResult: - """Result of a single startup validation check. - - Attributes: - ok: ``True`` if the check passed. - category: Validation category — ``"network"``, ``"config"``, or ``"runtime"``. - subject: Identifier for what was checked (e.g. ``"model:bedrock"``, ``"mcp:postgres"``). - message: One-line human-readable description of what was found. - severity: Impact level — ``"critical"`` blocks startup, - ``"warning"`` allows startup with degraded functionality, - ``"info"`` is informational. - hint: Actionable fix suggestion when the check fails. - exception: Original exception that caused a failure. - """ - - ok: bool - category: str - subject: str - message: str - severity: Severity = "info" - hint: str = "" - exception: Exception | None = dataclasses.field(default=None, repr=False) - - def __str__(self) -> str: - """Format the check result as a human-readable string.""" - icon = "\u2713" if self.ok else ("\u26a0" if self.severity == "warning" else "\u2717") - parts = [f"[{self.category:8s}] {icon} {self.subject}: {self.message}"] - if not self.ok and self.hint: - parts.append(f" hint: {self.hint}") - return "\n".join(parts) - - @classmethod - def passed(cls, category: str, subject: str, message: str) -> CheckResult: - """Create a passing ``info`` result.""" - return cls(ok=True, category=category, subject=subject, message=message) - - @classmethod - def warn( - cls, - category: str, - subject: str, - message: str, - *, - hint: str = "", - exception: Exception | None = None, - ) -> CheckResult: - """Create a non-critical ``warning`` result.""" - return cls( - ok=False, - category=category, - subject=subject, - message=message, - severity="warning", - hint=hint, - exception=exception, - ) - - @classmethod - def critical( - cls, - category: str, - subject: str, - message: str, - *, - hint: str = "", - exception: Exception | None = None, - ) -> CheckResult: - """Create a ``critical`` failure result.""" - return cls( - ok=False, - category=category, - subject=subject, - message=message, - severity="critical", - hint=hint, - exception=exception, - ) - - -class StartupError(Exception): - """Raised when critical startup checks fail.""" - - def __init__(self, report: StartupReport) -> None: - """Initialize StartupError with the failing report. - - Args: - report: The startup report containing critical failures. - """ - self.report = report - messages = [f" - {c.subject}: {c.message}" for c in report.critical_checks] - super().__init__("Startup failed:\n" + "\n".join(messages)) - - -@dataclasses.dataclass -class StartupReport: - """Aggregated startup validation results.""" - - checks: list[CheckResult] = dataclasses.field(default_factory=list) - - @property - def ok(self) -> bool: - """``True`` if no critical checks failed.""" - return not any(c.severity == "critical" for c in self.checks) - - @property - def warnings(self) -> list[CheckResult]: - """Checks with ``severity="warning"``.""" - return [c for c in self.checks if c.severity == "warning"] - - @property - def critical_checks(self) -> list[CheckResult]: - """Checks with ``severity="critical"``.""" - return [c for c in self.checks if c.severity == "critical"] - - @property - def passed_checks(self) -> list[CheckResult]: - """Checks that passed (``ok=True``).""" - return [c for c in self.checks if c.ok] - - def raise_if_critical(self) -> None: - """Raise :exc:`StartupError` if any critical checks failed. - - Raises: - StartupError: With a summary of all critical failures. - """ - if not self.ok: - raise StartupError(self) - - def print_summary(self, *, verbose: bool = False) -> None: - """Print a human-readable summary to the log. - - Args: - verbose: If ``True``, also print passing checks. - """ - for check in self.checks: - if verbose or not check.ok: - logger.info("%s", check) - - n_ok = len(self.passed_checks) - n_warn = len(self.warnings) - n_crit = len(self.critical_checks) - total = len(self.checks) - status = "OK" if self.ok else "FAILED" - suffix = "" - if n_warn: - suffix += f", {n_warn} warning(s)" - if n_crit: - suffix += f", {n_crit} critical" - logger.info( - "status=<%s>, passed=<%d>, total=<%d> | startup check summary%s", - status, - n_ok, - total, - suffix, - ) diff --git a/src/strands_compose/startup/validator.py b/src/strands_compose/startup/validator.py deleted file mode 100644 index 5c87d5f..0000000 --- a/src/strands_compose/startup/validator.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Startup validation checks for MCP servers, clients, and model endpoints. - -Runs health probes AFTER config resolution to catch connectivity issues -before the user starts chatting. - -**This module is opt-in** — ``validate_mcp()`` is NOT called automatically by -``load()`` or the serve pipeline. Call it explicitly after -``mcp_lifecycle.start()`` when MCP clients are connected:: - - infra = resolve_infra(app_config) - infra.mcp_lifecycle.start() - report = await validate_mcp(infra) # no agent build needed - report.print_summary() -""" - -from __future__ import annotations - -import asyncio -import logging -import urllib.error -import urllib.request -from typing import TYPE_CHECKING - -from .report import CheckResult, StartupReport - -if TYPE_CHECKING: - from strands.tools.mcp import MCPClient as StrandsMCPClient - - from ..config.resolvers import ResolvedConfig, ResolvedInfra - from ..mcp.server import MCPServer - -logger = logging.getLogger(__name__) - - -async def validate_mcp(target: ResolvedConfig | ResolvedInfra) -> StartupReport: - """Run all startup validation checks. - - Checks: - 1. MCP servers are reachable (HTTP probe). - 2. MCP clients have active sessions. - - Accepts either a :class:`ResolvedConfig` or :class:`ResolvedInfra` — - only the ``mcp_lifecycle`` attribute is used, so agents are **not** - required. This avoids an unnecessary cold-start agent build when - validating during ASGI lifespan startup. - - Args: - target: A resolved config or infrastructure object with - ``mcp_lifecycle``. - - Returns: - StartupReport with all check results. - """ - checks: list[CheckResult] = [] - - server_tasks = [ - _check_mcp_server(name, server) for name, server in target.mcp_lifecycle.servers.items() - ] - client_tasks = [ - _check_mcp_client(name, client) for name, client in target.mcp_lifecycle.clients.items() - ] - - # Run server probes concurrently, then gather client checks (which are fast) - server_results = await asyncio.gather(*server_tasks, return_exceptions=True) - for result in server_results: - if isinstance(result, BaseException): - checks.append( - CheckResult.critical( - "network", - "mcp-server", - f"Unexpected error: {result}", - exception=result if isinstance(result, Exception) else None, - ) - ) - else: - checks.extend(result) - - # The same for clients checks - client_results = await asyncio.gather(*client_tasks, return_exceptions=True) - for result in client_results: - if isinstance(result, BaseException): - checks.append( - CheckResult.warn( - "runtime", - "mcp-client", - f"Unexpected error: {result}", - hint="Client checks failed, but servers may still be healthy", - exception=result if isinstance(result, Exception) else None, - ) - ) - else: - checks.append(result) - - return StartupReport(checks=checks) - - -async def _check_mcp_server(name: str, server: MCPServer) -> list[CheckResult]: - """Probe an MCP server's HTTP endpoint.""" - subject = f"mcp:{name}" - return [await probe_http_health(subject, server.url)] - - -async def _check_mcp_client(name: str, client: StrandsMCPClient) -> CheckResult: - """Check if an MCP client's session is active.""" - subject = f"mcp-client:{name}" - try: - tools = await client.load_tools() - if tools is not None: - return CheckResult.passed("runtime", subject, "Client has tool registry") - return CheckResult.passed("runtime", subject, "Client is available") - except Exception as exc: - logger.debug("client=<%s> | startup check failed", name, exc_info=True) - return CheckResult.warn( - "runtime", - subject, - f"Client check failed: {exc}", - hint=f"Ensure the MCP server for client '{name}' is running", - exception=exc, - ) - - -async def probe_http_health(subject: str, url: str) -> CheckResult: - """Probe an HTTP endpoint for reachability. - - Any HTTP response (including 4xx — e.g. 406 from an MCP endpoint that - only accepts POST) is treated as *reachable*. Only 5xx responses and - connection-level failures (timeout, refused) are reported as problems. - - Args: - subject: Human-readable subject name. - url: URL to probe. - - Returns: - CheckResult with pass/fail. - """ - try: - resp = await asyncio.to_thread( - urllib.request.urlopen, - url, - timeout=5, - ) - status = resp.status - if status < 500: - return CheckResult.passed("network", subject, f"HTTP {status}") - return CheckResult.warn( - "network", - subject, - f"HTTP {status}", - hint=f"Service at {url} returned a server error", - ) - except urllib.error.HTTPError as exc: - # HTTPError is raised for 4xx/5xx but the server *is* reachable. - if exc.code < 500: - return CheckResult.passed("network", subject, f"HTTP {exc.code}") - return CheckResult.warn( - "network", - subject, - f"HTTP {exc.code}", - hint=f"Service at {url} returned a server error", - ) - except Exception as exc: - logger.debug("subject=<%s>, url=<%s> | startup probe failed", subject, url, exc_info=True) - return CheckResult.critical( - "network", - subject, - f"Connection failed: {exc}", - hint=f"Ensure the service is running at {url}", - exception=exc, - ) diff --git a/src/strands_compose/tools/extractors.py b/src/strands_compose/tools/extractors.py index dcdf60d..549875e 100644 --- a/src/strands_compose/tools/extractors.py +++ b/src/strands_compose/tools/extractors.py @@ -80,13 +80,9 @@ def extract_last_message(result: Any) -> Message: if isinstance(result, MultiAgentResult): last_node_id = _resolve_last_node_id(result) if last_node_id and last_node_id in result.results: - message = extract_last_message(result.results[last_node_id]) - if message is not None: - return message - for node_result in reversed(list(result.results.values())): - message = extract_last_message(node_result) - if message is not None: - return message + return extract_last_message(result.results[last_node_id]) + if result.results: + return extract_last_message(list(result.results.values())[-1]) logger.warning("status=<%s> | no message extracted from MultiAgentResult", result.status) return _message_from_text( f"[orchestration completed with status {result.status.value} but produced no message output]" diff --git a/src/strands_compose/tools/loaders.py b/src/strands_compose/tools/loaders.py index 1c67aa8..8f2e572 100644 --- a/src/strands_compose/tools/loaders.py +++ b/src/strands_compose/tools/loaders.py @@ -2,12 +2,6 @@ Provides helpers for loading ``@tool``-decorated functions from files, modules, and directories. - -Key Features: - - Auto-detection of filesystem vs. module-based tool specs - - Automatic @tool wrapping for explicit colon-spec lookups - - Directory scanning with underscore-prefixed file exclusion - - Unified spec resolver supporting files, modules, and directories """ from __future__ import annotations @@ -160,15 +154,15 @@ def load_tool_function(spec: str) -> AgentTool: def load_tools_from_directory(path: str | Path) -> list[AgentTool]: - """Load all @tool functions from .py files in a directory. + """Load all @tool functions from .py files in a directory tree. - Scans all .py files (excluding ``_``-prefixed) and loads their tools. + Scans recursively, skipping any path segment that starts with ``_`` or ``.``. Args: path: Directory path to scan. Returns: - List of AgentTool instances from all files in the directory. + List of AgentTool instances from every file in the tree. Raises: FileNotFoundError: If the directory does not exist. @@ -181,9 +175,10 @@ def load_tools_from_directory(path: str | Path) -> list[AgentTool]: raise NotADirectoryError(f"Path is not a directory: {dir_path}") tools: list[AgentTool] = [] - for py_file in sorted(dir_path.glob("*.py")): - if py_file.name.startswith("_"): - logger.debug("file=<%s> | skipping underscore-prefixed file", py_file.name) + for py_file in sorted(dir_path.rglob("*.py")): + relative = py_file.relative_to(dir_path) + if any(part.startswith(("_", ".")) for part in relative.parts): + logger.debug("file=<%s> | skipping private path segment", relative) continue loaded = load_tools_from_file(py_file) tools.extend(loaded) diff --git a/src/strands_compose/utils.py b/src/strands_compose/utils.py index e910e92..34ffb2e 100644 --- a/src/strands_compose/utils.py +++ b/src/strands_compose/utils.py @@ -46,9 +46,9 @@ def load_object(spec: str, *, target: str = "object") -> Any: This is the **unified entry point** for resolving any ``module.path:ObjectName`` or ``./file.py:ObjectName`` import spec - used throughout the config layer (agent factories, MCP server - factories, model classes, session manager classes, hook classes, - graph-edge conditions, etc.). + used throughout the config layer (agent factories, model classes, + session manager classes, hook classes, plugin classes, graph-edge + conditions, etc.). The ``target`` kwarg is used **only** in error messages so that failures clearly identify what was being loaded. @@ -58,7 +58,7 @@ def load_object(spec: str, *, target: str = "object") -> Any: filesystem path (containing ``/`` or ``\\``) with a colon- separated attribute, e.g. ``"/abs/path/file.py:create"``. target: Human-readable label for error messages, e.g. - ``"agent factory"``, ``"MCP server"``, ``"graph condition"``. + ``"agent factory"``, ``"model class"``, ``"graph condition"``. Returns: The imported Python object. @@ -130,11 +130,8 @@ def load_module_from_file(path: str | Path) -> ModuleType: sys.modules.pop(module_name, None) raise ImportError(f"Failed to load file {file_path}: {exc}") from exc - # Remove from sys.modules to avoid polluting the global module namespace. - # The returned module object remains usable — only the sys.modules entry is dropped. - # This means subsequent ``import `` statements won't resolve, - # It's intentional: these are user-provided files, not library modules. - # For hot-reload, the ``del`` above ensures a fresh exec on every call. + # Drop the sys.modules entry so user-provided files don't pollute the global + # module namespace. The returned module object stays usable. sys.modules.pop(module_name, None) return module diff --git a/src/strands_compose/wire.py b/src/strands_compose/wire.py index dd1ae96..42f548e 100644 --- a/src/strands_compose/wire.py +++ b/src/strands_compose/wire.py @@ -8,9 +8,8 @@ event (carrying the session manifest) and a SESSION_END event. :func:`make_event_queue` attaches :class:`~strands_compose.hooks.EventPublisher` -hooks to every agent so all per-agent events (TOKEN, REASONING, TOOL_START, -TOOL_END, INTERRUPT, AGENT_COMPLETE, and — for Swarm/Graph — NODE_START, NODE_STOP, -HANDOFF, MULTIAGENT_COMPLETE) flow into the shared queue. +hooks to every agent and orchestrator so all agent and multi-agent events flow +into one shared queue. Hooks are wired **once per session**. Between requests on the same session, call :meth:`EventQueue.flush` to discard stale events and reset the @@ -191,23 +190,18 @@ def make_event_queue( agents: dict[str, Agent], *, orchestrators: dict[str, Node] | None = None, - tool_labels: dict[str, str] | None = None, entry_name: str | None = None, session_id: str | None = None, ) -> EventQueue: - """Attach :class:`~strands_compose.hooks.EventPublisher` hooks to agents. + """Attach EventPublisher hooks to agents. - Every agent in *agents* receives an :class:`.EventPublisher` hook and a - matching ``callback_handler`` so all per-agent event types flow into the - returned :class:`EventQueue`. Orchestrators (Swarm / Graph / delegate - Agent) in *orchestrators* also get a publisher for NODE_START, NODE_STOP, - HANDOFF, and MULTIAGENT_COMPLETE events. + Every agent gets a publisher and a matching ``callback_handler`` so its + events flow into the returned queue. Orchestrators (Swarm / Graph / + delegate Agent) additionally emit MULTIAGENT_START, NODE_START, NODE_STOP, + HANDOFF and MULTIAGENT_COMPLETE. - This function does **not** emit SESSION_START. Callers that own a - :class:`~strands_compose.types.SessionManifest` should call - :meth:`EventQueue.emit_session_start` themselves; the common - :class:`ResolvedConfig` workflow does this for you via - :meth:`ResolvedConfig.wire_event_queue`. + Does **not** emit SESSION_START — use ``ResolvedConfig.wire_event_queue`` + for that, or call ``EventQueue.emit_session_start`` yourself. .. warning:: @@ -218,9 +212,6 @@ def make_event_queue( Args: agents: Agents to wire, keyed by name. orchestrators: Built orchestrations keyed by name. - tool_labels: Tool name → display label mapping forwarded to each - :class:`.EventPublisher`. Defaults to - ``{name: "Delegating work to agent: "}`` for every agent. entry_name: The configured name of the entry node. Stored on the EventQueue and used as ``agent_name`` on SESSION_START / SESSION_END events. @@ -236,13 +227,8 @@ def make_event_queue( session_id=session_id, ) - labels = { - **{name: f"Delegating work to agent: {name.title()}" for name in agents}, - **(tool_labels or {}), - } - for name, agent in agents.items(): - pub = EventPublisher(callback=event_queue._put, agent_name=name, tool_labels=labels) + pub = EventPublisher(callback=event_queue._put, agent_name=name) agent.hooks.add_hook(pub) agent.callback_handler = pub.as_callback_handler() logger.debug("agent=<%s> | wired EventPublisher", name) @@ -253,7 +239,6 @@ def make_event_queue( orch_pub = EventPublisher( callback=event_queue._put, agent_name=orch_name, - tool_labels=labels, ) orch.hooks.add_hook(orch_pub) if isinstance(orch, Agent): diff --git a/tasks/README.md b/tasks/README.md index 0f75be9..b61295f 100644 --- a/tasks/README.md +++ b/tasks/README.md @@ -4,6 +4,44 @@ This directory contains Just tasks for automating common project operations. Eac ## Task Groups +### Check Tasks (`check.just`) +Tasks for running code quality checks: + +- `check`: Run all checks (format + code + type + security) + ```bash + uv run just check + ``` + +- `check-format`: Check code formatting (import order + ruff format) + ```bash + uv run just check-format + ``` + +- `check-code`: Run linting checks (ruff check) + ```bash + uv run just check-code + ``` + +- `check-type`: Run type checking (ty) + ```bash + uv run just check-type + ``` + +- `check-security`: Run security scan (bandit) + ```bash + uv run just check-security + ``` + +- `check-test`: Run unit tests (pytest) + ```bash + uv run just check-test + ``` + +- `check-hooks`: Run all pre-commit hooks on all files + ```bash + uv run just check-hooks + ``` + ### Clean Tasks (`clean.just`) Tasks for cleaning project files and caches: @@ -12,9 +50,9 @@ Tasks for cleaning project files and caches: uv run just clean ``` -- `clean-python`: Clean Python cache files (in src, tests, notebooks) +- `clean-build`: Clean build folders (dist, build) ```bash - uv run just clean-python + uv run just clean-build ``` - `clean-cache`: Clean .cache directory @@ -22,6 +60,16 @@ Tasks for cleaning project files and caches: uv run just clean-cache ``` +- `clean-constraints`: Clean constraints.txt + ```bash + uv run just clean-constraints + ``` + +- `clean-coverage`: Clean .coverage files + ```bash + uv run just clean-coverage + ``` + - `clean-ty`: Clean ty cache ```bash uv run just clean-ty @@ -32,6 +80,16 @@ Tasks for cleaning project files and caches: uv run just clean-pytest ``` +- `clean-python`: Clean Python caches (__pycache__ and .pyc/.pyo files) + ```bash + uv run just clean-python + ``` + +- `clean-requirements`: Clean requirements.txt + ```bash + uv run just clean-requirements + ``` + - `clean-ruff`: Clean ruff cache ```bash uv run just clean-ruff @@ -42,40 +100,40 @@ Tasks for cleaning project files and caches: uv run just clean-venv ``` -### Check Tasks (`check.just`) -Tasks for running code quality checks: - -- `check`: Run all checks - ```bash - uv run just check - ``` +### Commit Tasks (`commit.just`) +Tasks for managing commits: -- `check-lint`: Run linting checks +- `commit-bump`: Bump the package version using Commitizen ```bash - uv run just check-lint + uv run just commit-bump ``` -- `check-type`: Run type checking +- `commit-files`: Create a conventional commit using Commitizen ```bash - uv run just check-type + uv run just commit-files ``` -- `check-test`: Run tests +- `commit-info`: Retrieve commit information using Commitizen ```bash - uv run just check-test + uv run just commit-info ``` ### Format Tasks (`format.just`) Tasks for code formatting: -- `format`: Format all code +- `format`: Run all format tasks (import + source) ```bash uv run just format ``` -- `format-check`: Check if code is formatted correctly +- `format-import`: Format import order (ruff check --select=I --fix) + ```bash + uv run just format-import + ``` + +- `format-source`: Format source code (ruff format) ```bash - uv run just format-check + uv run just format-source ``` ### Install Tasks (`install.just`) @@ -96,27 +154,50 @@ Tasks for managing dependencies: uv run just install-hooks ``` -### Commit Tasks (`commit.just`) -Tasks for managing commits: +### Release Tasks (`release.just`) +Tasks for releasing the package: -- **`commit-bump`**: Bump the version of the package using Commitizen. +- `release-dry`: Preview the next version bump (no changes written) ```bash - uv run just commit-bump + uv run just release-dry ``` -- **`commit-files`**: Create a conventional commit using Commitizen. +- `release`: Bump version, update CHANGELOG, and create a git tag ```bash - uv run just commit-files + uv run just release ``` -- **`commit-info`**: Retrieve commit information using Commitizen. +- `release-build`: Build distribution artifacts locally ```bash - uv run just commit-info + uv run just release-build ``` -- **`check-hooks`**: Run all pre-commit hooks to ensure code quality. +- `release-test-publish`: Publish to TestPyPI (dry-run against the test registry) ```bash - uv run just check-hooks + uv run just release-test-publish + ``` + +- `release-next`: Show the next version that commitizen would pick + ```bash + uv run just release-next + ``` + +### Test Tasks (`test.just`) +Tasks for running tests: + +- `test`: Run all test tasks (coverage) + ```bash + uv run just test + ``` + +- `test-coverage`: Run tests with coverage (80% threshold by default) + ```bash + uv run just test-coverage + ``` + +- `test-mutation`: Run mutation testing on a module (requires mutmut) + ```bash + uv run just test-mutation ``` ## Usage @@ -149,4 +230,7 @@ Tasks for managing commits: Some tasks depend on others. For example: - `clean` runs all clean tasks -- `check` runs all check tasks +- `check` runs all check tasks (check-format, check-code, check-type, check-security) +- `format` runs all format tasks (format-import, format-source) +- `test` runs test-coverage +- `release` depends on check and test diff --git a/tasks/test.just b/tasks/test.just index 5559012..41cd815 100644 --- a/tasks/test.just +++ b/tasks/test.just @@ -4,7 +4,7 @@ test: test-coverage # check code coverage [group('test')] -test-coverage cov_fail_under="70": +test-coverage cov_fail_under="80": uv run python -m pytest --numprocesses=2 --cov={{SOURCES}} --cov-fail-under={{cov_fail_under}} {{TESTS}} # run mutation testing (requires: pip install mutmut) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index ee48c6f..052c81e 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -42,11 +42,6 @@ def test_check_json_output_reports_entry_and_agents(tmp_path, monkeypatch, capsy def test_load_minimal_config_exits_zero(tmp_path, monkeypatch): - # No MCP servers configured → validate_mcp does no network probing. + # No MCP clients configured → nothing connects to a network. cfg = write_config(tmp_path, "agents:\n a:\n system_prompt: hi\nentry: a") _run(["load", str(cfg), "--quiet"], monkeypatch) - - -def test_missing_subcommand_errors(monkeypatch): - with pytest.raises(SystemExit): - _run([], monkeypatch) diff --git a/tests/cli/test_startup.py b/tests/cli/test_startup.py deleted file mode 100644 index b2fc925..0000000 --- a/tests/cli/test_startup.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Startup health-check aggregation and the opt-in MCP validator.""" - -from __future__ import annotations - -import pytest - -from strands_compose.config.resolvers import ResolvedInfra -from strands_compose.startup.report import CheckResult, StartupError, StartupReport -from strands_compose.startup.validator import validate_mcp - - -def test_report_ok_when_no_critical_checks(): - report = StartupReport( - checks=[CheckResult.passed("net", "s", "ok"), CheckResult.warn("net", "s", "slow")] - ) - assert report.ok - assert len(report.warnings) == 1 - - -def test_report_not_ok_with_a_critical_check(): - report = StartupReport(checks=[CheckResult.critical("net", "s", "down")]) - assert not report.ok - assert len(report.critical_checks) == 1 - - -def test_raise_if_critical_raises_startup_error(): - report = StartupReport(checks=[CheckResult.critical("net", "s", "down")]) - with pytest.raises(StartupError): - report.raise_if_critical() - - -def test_passed_checks_are_collected(): - report = StartupReport( - checks=[CheckResult.passed("net", "s", "ok"), CheckResult.critical("net", "t", "bad")] - ) - assert len(report.passed_checks) == 1 - - -async def test_validate_mcp_on_empty_infra_reports_ok(): - report = await validate_mcp(ResolvedInfra()) - assert report.ok - assert report.checks == [] diff --git a/tests/conftest.py b/tests/conftest.py index bf077fc..d878a90 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,40 +12,32 @@ import pytest -from tests.fakes import FakeMCPClient, FakeMCPServer, FakeModel +from tests.fakes import FakeMCPClient, FakeModel def pytest_configure(config: pytest.Config) -> None: """Register custom markers.""" config.addinivalue_line("markers", "integration: full-pipeline tests (load over YAML)") - config.addinivalue_line("markers", "ollama: requires local Ollama") - config.addinivalue_line("markers", "bedrock: requires AWS Bedrock") @pytest.fixture def fake_runtime() -> Iterator[None]: """Swap the strands-facing resolver seams for fakes. - Patches ``resolve_model`` / ``resolve_mcp_server`` / ``resolve_mcp_client`` - where ``resolve_infra`` uses them, so ``load`` / ``resolve_infra`` build real - agents and orchestrations with no network and no MCP subprocess. + Patches ``resolve_model`` / ``resolve_mcp_client`` where ``load`` uses + them, so ``load`` builds real agents and orchestrations with no network + and no MCP subprocess. """ with contextlib.ExitStack() as stack: stack.enter_context( patch( - "strands_compose.config.resolvers.config.resolve_model", + "strands_compose.config.loaders.loaders.resolve_model", lambda model_def: FakeModel(), ) ) stack.enter_context( patch( - "strands_compose.config.resolvers.config.resolve_mcp_server", - lambda *a, **k: FakeMCPServer(), - ) - ) - stack.enter_context( - patch( - "strands_compose.config.resolvers.config.resolve_mcp_client", + "strands_compose.config.loaders.loaders.resolve_mcp_client", lambda *a, **k: FakeMCPClient(), ) ) diff --git a/tests/fakes/__init__.py b/tests/fakes/__init__.py index 656c1b8..359d496 100644 --- a/tests/fakes/__init__.py +++ b/tests/fakes/__init__.py @@ -5,7 +5,6 @@ from .strands import ( BoomModel, FakeMCPClient, - FakeMCPServer, FakeModel, FakePlugin, ToolThenTextModel, @@ -15,7 +14,6 @@ __all__ = [ "BoomModel", "FakeMCPClient", - "FakeMCPServer", "FakeModel", "FakePlugin", "ToolThenTextModel", diff --git a/tests/fakes/strands.py b/tests/fakes/strands.py index 408fc2e..f1bb358 100644 --- a/tests/fakes/strands.py +++ b/tests/fakes/strands.py @@ -15,8 +15,6 @@ from strands.models import Model from strands.plugins import Plugin -from strands_compose.mcp.server import MCPServer - class FakeModel(Model): """A strands ``Model`` that streams a fixed text response, no network. @@ -140,53 +138,6 @@ async def stream( yield # pragma: no cover — makes this an async generator -class FakeMCPServer(MCPServer): - """A real ``MCPServer`` subtype that records lifecycle calls, no uvicorn thread. - - Subclasses the ABC so it is accepted by ``MCPLifecycle.add_server`` while - overriding every runtime method to be inert and observable. - """ - - def __init__( - self, - *, - url: str = "http://localhost:0/mcp", - ready: bool = True, - record: list[str] | None = None, - label: str = "server", - ) -> None: - """Store the reported URL, readiness result, and optional shared order log.""" - super().__init__(name=label) - self.calls: list[str] = [] - self._url = url - self._will_be_ready: bool = ready - self._record = record - self._label = label - - def _register_tools(self, mcp: Any) -> None: - """No tools to register on the fake.""" - - def start(self) -> None: - """Record a start.""" - self.calls.append("start") - - def wait_ready(self, timeout: float = 30) -> bool: - """Record a readiness probe and return the configured result.""" - self.calls.append("wait_ready") - return self._will_be_ready - - def stop(self) -> None: - """Record a stop (and its order relative to clients, when a log is shared).""" - self.calls.append("stop") - if self._record is not None: - self._record.append(self._label) - - @property - def url(self) -> str: - """Return the reported URL.""" - return self._url - - class FakePlugin(Plugin): """Minimal Plugin that contributes one identifiable ``@tool``. @@ -211,13 +162,11 @@ def fake_plugin_factory(*, prefix: str = "") -> FakePlugin: class FakeMCPClient: - """Minimal MCP client stand-in for lifecycle ordering tests.""" + """Minimal MCP client stand-in — contributes no tools, hits no network.""" - def __init__(self, *, record: list[str] | None = None, label: str = "client") -> None: - """Initialise an empty call log and optional shared order log.""" + def __init__(self) -> None: + """Initialise an empty call log.""" self.calls: list[str] = [] - self._record = record - self._label = label def start(self) -> None: """Record a start.""" @@ -226,5 +175,3 @@ def start(self) -> None: def stop(self, exc_type: Any = None, exc_val: Any = None, exc_tb: Any = None) -> None: """Record a stop (matches the strands MCPClient stop signature).""" self.calls.append("stop") - if self._record is not None: - self._record.append(self._label) diff --git a/tests/parse/test_helpers.py b/tests/parse/test_helpers.py index 8ca6b73..602182f 100644 --- a/tests/parse/test_helpers.py +++ b/tests/parse/test_helpers.py @@ -3,11 +3,12 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import Any, cast import pytest from strands_compose.config.loaders.helpers import ( + apply_mcp_stdio_cwd_default, is_fs_spec, make_absolute, merge_raw_configs, @@ -24,10 +25,6 @@ def test_sanitize_name_replaces_illegal_characters(): assert sanitize_name("my agent!") == "my_agent" -def test_sanitize_name_truncates_to_64_chars(): - assert len(sanitize_name("a" * 200)) == 64 - - def test_sanitize_collection_keys_renames_and_updates_entry_reference(): raw = {"agents": {"my agent": {"system_prompt": "hi"}}, "entry": "my agent"} sanitize_collection_keys(raw) @@ -38,15 +35,14 @@ def test_sanitize_collection_keys_renames_and_updates_entry_reference(): def test_sanitize_collection_keys_updates_model_and_mcp_references(): raw = { "models": {"fast model": {"provider": "bedrock", "model_id": "m"}}, - "mcp_clients": {"db client": {"server": "db server"}}, - "mcp_servers": {"db server": {"type": "mod:make"}}, + "mcp_clients": {"db client": {"url": "https://example.com/mcp"}}, "agents": {"a": {"model": "fast model", "mcp": ["db client"]}}, "entry": "a", } sanitize_collection_keys(raw) assert raw["agents"]["a"]["model"] == "fast_model" assert raw["agents"]["a"]["mcp"] == ["db_client"] - assert raw["mcp_clients"]["db_client"]["server"] == "db_server" + assert "db_client" in raw["mcp_clients"] def test_sanitize_collection_keys_updates_orchestration_references(): @@ -149,6 +145,62 @@ def test_parse_single_source_applies_per_source_interpolation(tmp_path, monkeypa assert raw["agents"]["a"]["system_prompt"] == "injected" +# ── MCP stdio cwd default ─────────────────────────────────────────────────── + + +def test_apply_mcp_stdio_cwd_default_sets_cwd_for_command_client(tmp_path): + raw = cast("dict[str, Any]", {"mcp_clients": {"calc": {"command": ["python", "server.py"]}}}) + apply_mcp_stdio_cwd_default(raw, tmp_path) + assert raw["mcp_clients"]["calc"]["transport_options"]["cwd"] == str(tmp_path) + + +def test_apply_mcp_stdio_cwd_default_does_not_override_explicit_cwd(tmp_path): + raw: dict[str, Any] = { + "mcp_clients": { + "calc": { + "command": ["python", "server.py"], + "transport_options": {"cwd": "/explicit/dir"}, + } + } + } + apply_mcp_stdio_cwd_default(raw, tmp_path) + assert raw["mcp_clients"]["calc"]["transport_options"]["cwd"] == "/explicit/dir" + + +def test_apply_mcp_stdio_cwd_default_leaves_url_client_untouched(tmp_path): + raw: dict[str, Any] = {"mcp_clients": {"remote": {"url": "https://example.com/mcp"}}} + apply_mcp_stdio_cwd_default(raw, tmp_path) + assert "transport_options" not in raw["mcp_clients"]["remote"] + + +def test_apply_mcp_stdio_cwd_default_does_not_rewrite_command_itself(tmp_path): + raw = cast( + "dict[str, Any]", + {"mcp_clients": {"fs": {"command": ["npx", "-y", "@scope/server", "/tmp"]}}}, + ) + apply_mcp_stdio_cwd_default(raw, tmp_path) + assert raw["mcp_clients"]["fs"]["command"] == ["npx", "-y", "@scope/server", "/tmp"] + assert raw["mcp_clients"]["fs"]["transport_options"]["cwd"] == str(tmp_path) + + +def test_parse_single_source_defaults_mcp_command_cwd_to_config_dir(tmp_path): + path = write_config( + tmp_path, + """ + mcp_clients: + calc: + command: ["python", "server.py"] + agents: + a: + system_prompt: hi + entry: a + """, + ) + raw = parse_single_source(path) + cwd = raw["mcp_clients"]["calc"]["transport_options"]["cwd"] + assert Path(cwd) == tmp_path.resolve() + + # ── Multi-source merge ───────────────────────────────────────────────────── diff --git a/tests/pipeline/fixtures/delegate.yaml b/tests/pipeline/fixtures/delegate.yaml deleted file mode 100644 index 0167372..0000000 --- a/tests/pipeline/fixtures/delegate.yaml +++ /dev/null @@ -1,13 +0,0 @@ -agents: - researcher: - system_prompt: "You are a research assistant." - writer: - system_prompt: "You are a technical writer." -entry: coordinator -orchestrations: - coordinator: - mode: delegate - entry_name: writer - connections: - - agent: researcher - description: "Research a topic in depth." diff --git a/tests/pipeline/fixtures/graph.yaml b/tests/pipeline/fixtures/graph.yaml deleted file mode 100644 index c4a3a2a..0000000 --- a/tests/pipeline/fixtures/graph.yaml +++ /dev/null @@ -1,17 +0,0 @@ -agents: - collector: - system_prompt: "You collect data." - analyzer: - system_prompt: "You analyze data." - summarizer: - system_prompt: "You summarize results." -entry: pipeline -orchestrations: - pipeline: - mode: graph - entry_name: collector - edges: - - from: collector - to: analyzer - - from: analyzer - to: summarizer diff --git a/tests/pipeline/fixtures/nested.yaml b/tests/pipeline/fixtures/nested.yaml deleted file mode 100644 index 0260123..0000000 --- a/tests/pipeline/fixtures/nested.yaml +++ /dev/null @@ -1,21 +0,0 @@ -agents: - researcher: - system_prompt: "You research topics." - writer: - system_prompt: "You write content." - reviewer: - system_prompt: "You review final content." -entry: full_pipeline -orchestrations: - writing_team: - mode: delegate - entry_name: writer - connections: - - agent: researcher - description: "Research a topic." - full_pipeline: - mode: delegate - entry_name: reviewer - connections: - - agent: writing_team - description: "Run the full writing pipeline." diff --git a/tests/pipeline/fixtures/swarm.yaml b/tests/pipeline/fixtures/swarm.yaml deleted file mode 100644 index c6cedbd..0000000 --- a/tests/pipeline/fixtures/swarm.yaml +++ /dev/null @@ -1,12 +0,0 @@ -agents: - analyst: - system_prompt: "You are a data analyst." - reporter: - system_prompt: "You are a reporter." -entry: team -orchestrations: - team: - mode: swarm - agents: [analyst, reporter] - entry_name: analyst - max_handoffs: 10 diff --git a/tests/pipeline/test_examples.py b/tests/pipeline/test_examples.py index b667ba0..fab24b7 100644 --- a/tests/pipeline/test_examples.py +++ b/tests/pipeline/test_examples.py @@ -39,4 +39,3 @@ def test_example_config_loads(config_input, fake_runtime): resolved = load(config_input) assert isinstance(resolved, ResolvedConfig) assert resolved.entry is not None - resolved.mcp_lifecycle.stop() diff --git a/tests/pipeline/test_load.py b/tests/pipeline/test_load.py index db24258..e3cd5d2 100644 --- a/tests/pipeline/test_load.py +++ b/tests/pipeline/test_load.py @@ -8,11 +8,8 @@ import pytest from strands import Agent -from strands.multiagent import Swarm -from strands.multiagent.graph import Graph from strands_compose.config import ResolvedConfig, load -from strands_compose.mcp import MCPLifecycle pytestmark = pytest.mark.integration @@ -24,35 +21,8 @@ def test_minimal_config_wires_entry_agent(fixture_path): assert "greeter" in resolved.agents -def test_delegate_entry_is_the_orchestrator(fixture_path): - resolved = load(fixture_path("delegate.yaml")) - assert resolved.entry is resolved.orchestrators["coordinator"] - assert {"researcher", "writer"} <= set(resolved.agents) - - -def test_swarm_entry_is_a_swarm(fixture_path): - resolved = load(fixture_path("swarm.yaml")) - assert isinstance(resolved.orchestrators["team"], Swarm) - - -def test_graph_entry_is_a_graph(fixture_path): - resolved = load(fixture_path("graph.yaml")) - assert isinstance(resolved.orchestrators["pipeline"], Graph) - - -def test_nested_orchestration_entry_is_outer(fixture_path): - resolved = load(fixture_path("nested.yaml")) - assert resolved.entry is resolved.orchestrators["full_pipeline"] - - def test_multiple_sources_are_merged(fixture_path): resolved = load( [fixture_path("multi_source_base.yaml"), fixture_path("multi_source_extra.yaml")] ) assert {"planner", "helper"} <= set(resolved.agents) - - -def test_resolved_config_carries_a_lifecycle(fixture_path): - resolved = load(fixture_path("minimal.yaml")) - assert isinstance(resolved.mcp_lifecycle, MCPLifecycle) - resolved.mcp_lifecycle.stop() diff --git a/tests/resolve/test_delegation.py b/tests/resolve/test_delegation.py index 94f3326..671897f 100644 --- a/tests/resolve/test_delegation.py +++ b/tests/resolve/test_delegation.py @@ -74,12 +74,6 @@ def test_orchestration_connection_falls_back_to_the_multiagent_wrapper(): assert tool.tool_name == "team" -def test_tool_name_tracks_the_connection_not_the_node_id(): - """The LLM sees the name the YAML used to reference the target.""" - tool = _delegate_tool("team", _conn("helper"), _agent("helper")) - assert tool.tool_name == "helper" - - # ── preserve_context ───────────────────────────────────────────────────────── @@ -103,11 +97,6 @@ async def test_preserve_context_false_resets_between_calls(): assert len(agent.messages) == 2 -def test_preserve_context_defaults_to_preserving_history(): - """Compose defaults to true; strands' Agent.as_tool defaults to false.""" - assert _conn("helper").preserve_context is True - - # ── rejected combinations ──────────────────────────────────────────────────── @@ -118,7 +107,7 @@ def test_preserve_context_false_with_a_session_manager_is_rejected(tmp_path): session_manager=FileSessionManager(session_id="s1", storage_dir=str(tmp_path)), ) - with pytest.raises(ValueError, match="cannot be used with an agent that has a session manager"): + with pytest.raises(ValueError, match="session manager"): _delegate_tool("team", _conn("helper", preserve_context=False), agent) diff --git a/tests/resolve/test_mcp.py b/tests/resolve/test_mcp.py index 5b36502..7ccbb26 100644 --- a/tests/resolve/test_mcp.py +++ b/tests/resolve/test_mcp.py @@ -1,19 +1,93 @@ -"""MCPServerDef / MCPClientDef resolution — result-type validation and ref checks.""" +"""MCPClientDef resolution — connection-mode dispatch, transport choice, validation.""" from __future__ import annotations +import contextlib +from collections.abc import Generator +from unittest.mock import patch + import pytest +from pydantic import ValidationError +from strands.tools.mcp import MCPClient + +from strands_compose.config.resolvers.mcp import resolve_mcp_client +from strands_compose.config.schema import MCPClientDef + + +def test_url_client_resolves_to_strands_mcp_client(): + client = resolve_mcp_client(MCPClientDef(url="https://example.com/mcp")) + + assert isinstance(client, MCPClient) + + +@contextlib.contextmanager +def record_chosen_transport() -> Generator[list[str]]: + """Record which transport factory ``create_mcp_client`` reaches for. + + Which transport a client ends up on is the observable contract here, and the + two factories are our own seam, so swapping them is the cheapest way to see + the decision without touching the strands client's internals. + """ + chosen: list[str] = [] + + def _factory(name: str): + def build(url: str, **kwargs: object): + chosen.append(name) + return lambda: None + + return build + + with ( + patch("strands_compose.mcp.client.sse_transport", _factory("sse")), + patch( + "strands_compose.mcp.client.streamable_http_transport", + _factory("streamable-http"), + ), + ): + yield chosen + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + pytest.param("https://example.com/sse", "sse", id="sse-path"), + pytest.param("https://example.com/sse/", "sse", id="sse-path-trailing-slash"), + pytest.param("https://example.com/mcp", "streamable-http", id="other-path"), + ], +) +def test_transport_is_detected_from_the_url_path(url: str, expected: str): + # No transport in the YAML — the URL path has to decide on its own. + with record_chosen_transport() as chosen: + resolve_mcp_client(MCPClientDef(url=url)) + + assert chosen == [expected] + + +def test_explicit_transport_overrides_url_detection(): + with record_chosen_transport() as chosen: + resolve_mcp_client(MCPClientDef(url="https://example.com/sse", transport="streamable-http")) + + assert chosen == ["streamable-http"] + + +def test_command_client_resolves_to_strands_mcp_client(): + client = resolve_mcp_client(MCPClientDef(command=["python", "-m", "myserver"])) -from strands_compose.config.resolvers.mcp import resolve_mcp_client, resolve_mcp_server -from strands_compose.config.schema import MCPClientDef, MCPServerDef + assert isinstance(client, MCPClient) -def test_server_factory_returning_non_server_raises_type_error(): - # builtins:dict is importable and returns a dict, not an MCPServer. - with pytest.raises(TypeError): - resolve_mcp_server(MCPServerDef(type="builtins:dict"), name="s") +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param({}, id="neither"), + pytest.param({"url": "https://example.com/mcp", "command": ["x"]}, id="both"), + ], +) +def test_client_requires_exactly_one_connection_mode(kwargs: dict): + with pytest.raises(ValidationError): + MCPClientDef(**kwargs) -def test_client_referencing_unknown_server_raises_value_error(): - with pytest.raises(ValueError, match="phantom"): - resolve_mcp_client(MCPClientDef(server="phantom"), servers={}, name="c") +def test_stdio_transport_on_a_url_client_raises_value_error(): + with pytest.raises(ValueError, match="stdio"): + resolve_mcp_client(MCPClientDef(url="https://example.com/mcp", transport="stdio")) diff --git a/tests/resolve/test_orchestrations.py b/tests/resolve/test_orchestrations.py index d53bab2..cd4c0ae 100644 --- a/tests/resolve/test_orchestrations.py +++ b/tests/resolve/test_orchestrations.py @@ -1,6 +1,6 @@ """Orchestration wiring — delegate forks, swarm/graph build, node-type is enforced. -Happy paths go through the real load_session seam; the type-guard goes through the +Happy paths go through the real ``load`` seam; the type-guard goes through the builder directly for control. Agents use the default (offline) model — no network. """ @@ -11,7 +11,7 @@ from strands.multiagent import Swarm from strands.multiagent.graph import Graph -from strands_compose.config import load_session, resolve_infra +from strands_compose.config import load from strands_compose.config.resolvers.orchestrations.builders import build_swarm from strands_compose.config.schema import AppConfig from strands_compose.exceptions import ConfigurationError @@ -23,18 +23,13 @@ ) -def _resolve(config: AppConfig): - infra = resolve_infra(config) - return load_session(config, infra) - - def test_delegate_entry_is_a_forked_agent_not_the_original(): config = AppConfig( agents={"writer": agent_def(), "researcher": agent_def()}, orchestrations={"coord": delegate_orchestration("writer", {"researcher": "research"})}, entry="coord", ) - resolved = _resolve(config) + resolved = load(config) assert isinstance(resolved.orchestrators["coord"], Agent) assert resolved.entry is resolved.orchestrators["coord"] @@ -49,7 +44,7 @@ def test_swarm_orchestration_builds_a_swarm(): orchestrations={"team": swarm_orchestration("analyst", ["analyst", "reporter"])}, entry="team", ) - resolved = _resolve(config) + resolved = load(config) assert isinstance(resolved.orchestrators["team"], Swarm) @@ -59,7 +54,7 @@ def test_graph_orchestration_builds_a_graph(): orchestrations={"pipe": graph_orchestration("a", [("a", "b")])}, entry="pipe", ) - resolved = _resolve(config) + resolved = load(config) assert isinstance(resolved.orchestrators["pipe"], Graph) @@ -72,12 +67,19 @@ def test_nested_delegate_entry_wires_the_outer_orchestration(): }, entry="full", ) - resolved = _resolve(config) + resolved = load(config) assert resolved.entry is resolved.orchestrators["full"] def test_swarm_node_that_is_not_a_plain_agent_raises(): - graph = build_graph_stub() + # A graph is a valid node elsewhere, but not inside a swarm. + config = AppConfig( + agents={"a": agent_def(), "b": agent_def()}, + orchestrations={"g": graph_orchestration("a", [("a", "b")])}, + entry="g", + ) + graph = load(config).orchestrators["g"] + with pytest.raises(ConfigurationError): build_swarm( "team", @@ -89,12 +91,3 @@ def test_swarm_node_that_is_not_a_plain_agent_raises(): def _bare_agent() -> Agent: return Agent(system_prompt="x") - - -def build_graph_stub() -> Graph: - config = AppConfig( - agents={"a": agent_def(), "b": agent_def()}, - orchestrations={"g": graph_orchestration("a", [("a", "b")])}, - entry="g", - ) - return _resolve(config).orchestrators["g"] diff --git a/tests/resolve/test_session_manager.py b/tests/resolve/test_session_manager.py index 5bade6d..0d33122 100644 --- a/tests/resolve/test_session_manager.py +++ b/tests/resolve/test_session_manager.py @@ -6,7 +6,7 @@ from strands.session import FileSessionManager from strands.session.session_manager import SessionManager -from strands_compose.config import load_session, resolve_infra +from strands_compose.config import load from strands_compose.config.resolvers.session_manager import ( resolve_leaf_session_manager, resolve_session_manager, @@ -80,10 +80,10 @@ def test_no_leaf_no_global_returns_none(): assert result is None -# ── Infra/session split — load-level composition ─────────────────────────── +# ── load-level composition ───────────────────────────────────────────────── -def test_global_agentcore_provider_is_rejected_by_resolve_infra(): +def test_global_agentcore_provider_is_rejected(): # agentcore needs a unique actor_id per agent, so it can't be a global default. config = AppConfig( agents={"a": agent_def()}, @@ -91,7 +91,7 @@ def test_global_agentcore_provider_is_rejected_by_resolve_infra(): session_manager=SessionManagerDef(provider="agentcore"), ) with pytest.raises(ValueError, match="agentcore"): - resolve_infra(config) + load(config) def test_global_session_manager_propagates_to_the_built_entry_agent(tmp_path, fake_runtime): @@ -101,7 +101,7 @@ def test_global_session_manager_propagates_to_the_built_entry_agent(tmp_path, fa entry="a", session_manager=SessionManagerDef(provider="file", params={"storage_dir": str(tmp_path)}), ) - resolved = load_session(config, resolve_infra(config), session_id="s1") + resolved = load(config, session_id="s1") # Observe via the manifest (public introspection), not private agent state. manifest = build_manifest(resolved.agents, resolved.orchestrators, resolved.entry) @@ -109,17 +109,16 @@ def test_global_session_manager_propagates_to_the_built_entry_agent(tmp_path, fa assert manifest.agents[0].session_manager.provider == "file" -def test_two_sessions_over_one_infra_build_isolated_agents(tmp_path, fake_runtime): - # The whole point of the split: reuse infra, create fresh agents per session. +def test_each_load_builds_isolated_agents(tmp_path, fake_runtime): + # One parsed config, many sessions — each gets its own agents. config = AppConfig( models={"m": model_def()}, agents={"a": agent_def(model="m")}, entry="a", session_manager=SessionManagerDef(provider="file", params={"storage_dir": str(tmp_path)}), ) - infra = resolve_infra(config) - r1 = load_session(config, infra, session_id="s1") - r2 = load_session(config, infra, session_id="s2") + r1 = load(config, session_id="s1") + r2 = load(config, session_id="s2") assert r1.agents["a"] is not r2.agents["a"] diff --git a/tests/resolve/test_tools.py b/tests/resolve/test_tools.py index 5038ee3..9317bbb 100644 --- a/tests/resolve/test_tools.py +++ b/tests/resolve/test_tools.py @@ -46,6 +46,30 @@ def add(a: int, b: int) -> int: """) ) (d / "_ignored.py").write_text("SECRET = 1\n") + nested = d / "nested" + nested.mkdir() + (nested / "deep.py").write_text( + textwrap.dedent("""\ + from strands import tool + + @tool + def deep(value: str) -> str: + \"\"\"Deep.\"\"\" + return value + """) + ) + private = d / "_private" + private.mkdir() + (private / "hidden.py").write_text( + textwrap.dedent("""\ + from strands import tool + + @tool + def hidden() -> str: + \"\"\"Hidden.\"\"\" + return "no" + """) + ) return d @@ -61,6 +85,35 @@ def test_load_from_directory_collects_across_files_and_skips_underscore(tools_di assert "SECRET" not in names +def test_load_from_directory_recurses_into_subdirectories(tools_dir): + names = {t.tool_name for t in load_tools_from_directory(tools_dir)} + assert "deep" in names + + +def test_load_from_directory_skips_private_subdirectories(tools_dir): + names = {t.tool_name for t in load_tools_from_directory(tools_dir)} + assert "hidden" not in names + + +def test_load_from_directory_loads_same_stem_in_two_subdirs(tmp_path): + """Recursion must not let two files with the same name shadow each other.""" + root = tmp_path / "dup" + for sub, tool_name in (("a", "alpha"), ("b", "beta")): + (root / sub).mkdir(parents=True) + (root / sub / "shared.py").write_text( + textwrap.dedent(f"""\ + from strands import tool + + @tool + def {tool_name}() -> str: + \"\"\"Tool.\"\"\" + return "{tool_name}" + """) + ) + names = {t.tool_name for t in load_tools_from_directory(root)} + assert names == {"alpha", "beta"} + + def test_load_tool_function_without_colon_raises(): with pytest.raises(ValueError, match="tool spec"): load_tool_function("no_colon") diff --git a/tests/runtime/test_event_stream.py b/tests/runtime/test_event_stream.py index 1d5aa29..404851f 100644 --- a/tests/runtime/test_event_stream.py +++ b/tests/runtime/test_event_stream.py @@ -11,7 +11,7 @@ from strands import Agent, tool -from strands_compose.config import load_session, resolve_infra +from strands_compose.config import load from strands_compose.config.schema import AppConfig from strands_compose.types import EventType from strands_compose.wire import make_event_queue @@ -70,13 +70,6 @@ async def test_agent_complete_includes_model_id_and_provider(): assert complete.data["model"]["provider"] == f"{FakeModel.__module__}.{FakeModel.__qualname__}" -async def test_stream_is_bracketed_by_session_end(): - agent = Agent(model=FakeModel(["hi"])) - eq = make_event_queue({"a": agent}, entry_name="a") - events = await _run_agent("hi", agent, eq) - assert events[-1].type == EventType.SESSION_END - - async def test_tool_call_emits_tool_start_and_success_end(): @tool def greet(name: str) -> str: @@ -109,7 +102,7 @@ async def test_model_error_emits_error_and_suppresses_complete(): async def test_wire_event_queue_emits_session_start_with_manifest(): config = AppConfig(agents={"a": agent_def()}, entry="a") - resolved = load_session(config, resolve_infra(config)) + resolved = load(config) eq = resolved.wire_event_queue() first = await eq.get() diff --git a/tests/runtime/test_manifest.py b/tests/runtime/test_manifest.py index 0426d43..25a3fa5 100644 --- a/tests/runtime/test_manifest.py +++ b/tests/runtime/test_manifest.py @@ -5,7 +5,7 @@ import pytest from strands import Agent -from strands_compose.config import load_session, resolve_infra +from strands_compose.config import load from strands_compose.config.schema import AppConfig from strands_compose.manifest import build_manifest from tests.factories import agent_def, graph_orchestration @@ -33,7 +33,7 @@ def test_graph_orchestration_topology_is_described(): orchestrations={"pipe": graph_orchestration("a", [("a", "b")])}, entry="pipe", ) - resolved = load_session(config, resolve_infra(config)) + resolved = load(config) manifest = build_manifest(resolved.agents, resolved.orchestrators, resolved.entry) pipe = next(o for o in manifest.orchestrations if o.name == "pipe") @@ -67,7 +67,7 @@ def test_delegate_orchestration_agent_is_listed_in_manifest_agents(): orchestrations={"coord": delegate_orchestration("writer", {"researcher": "d"})}, entry="coord", ) - resolved = load_session(config, resolve_infra(config)) + resolved = load(config) manifest = build_manifest(resolved.agents, resolved.orchestrators, resolved.entry) # The forked delegate agent reports usage under its own name, so it appears in agents. assert "coord" in {d.name for d in manifest.agents} @@ -81,7 +81,7 @@ def test_swarm_topology_reports_nodes_and_entry(): orchestrations={"team": swarm_orchestration("a", ["a", "b"])}, entry="team", ) - resolved = load_session(config, resolve_infra(config)) + resolved = load(config) manifest = build_manifest(resolved.agents, resolved.orchestrators, resolved.entry) team = next(o for o in manifest.orchestrations if o.name == "team") assert team.kind == "swarm" diff --git a/tests/runtime/test_mcp_lifecycle.py b/tests/runtime/test_mcp_lifecycle.py deleted file mode 100644 index f1ad530..0000000 --- a/tests/runtime/test_mcp_lifecycle.py +++ /dev/null @@ -1,90 +0,0 @@ -"""MCP lifecycle — ordering and idempotency observed via owned fakes. - -Asserts the contract through the fake's recorded calls, never private flags. -""" - -from __future__ import annotations - -import threading - -import pytest - -from strands_compose.mcp.lifecycle import MCPLifecycle -from tests.fakes import FakeMCPClient, FakeMCPServer - - -def test_start_starts_server_and_probes_readiness(): - lc = MCPLifecycle() - server = FakeMCPServer() - lc.add_server("s", server) - - lc.start() - - assert server.calls == ["start", "wait_ready"] - - -def test_start_is_idempotent(): - lc = MCPLifecycle() - server = FakeMCPServer() - lc.add_server("s", server) - - lc.start() - lc.start() - - assert server.calls.count("start") == 1 - - -def test_stop_stops_clients_before_servers(): - lc = MCPLifecycle() - order: list[str] = [] - server = FakeMCPServer(record=order, label="server") - client = FakeMCPClient(record=order, label="client") - lc.add_server("s", server) - lc.add_client("c", client) # ty: ignore[invalid-argument-type] - - lc.start() - lc.stop() - - assert order == ["client", "server"] - - -def test_stop_before_start_is_a_noop(): - lc = MCPLifecycle() - server = FakeMCPServer() - lc.add_server("s", server) - lc.stop() - assert "stop" not in server.calls - - -def test_duplicate_server_registration_raises(): - lc = MCPLifecycle() - lc.add_server("s", FakeMCPServer()) - with pytest.raises(ValueError): - lc.add_server("s", FakeMCPServer()) - - -def test_unready_server_fails_start(): - lc = MCPLifecycle(server_ready_timeout=0.01) - lc.add_server("s", FakeMCPServer(ready=False)) - with pytest.raises(RuntimeError): - lc.start() - - -def test_get_missing_client_raises_key_error(): - lc = MCPLifecycle() - with pytest.raises(KeyError): - lc.get_client("nope") - - -def test_concurrent_start_starts_server_once(): - lc = MCPLifecycle() - server = FakeMCPServer() - lc.add_server("s", server) - - threads = [threading.Thread(target=lc.start) for _ in range(5)] - for t in threads: - t.start() - for t in threads: - t.join() - - assert server.calls.count("start") == 1 diff --git a/tests/runtime/test_renderers.py b/tests/runtime/test_renderers.py index c1b356e..df8437c 100644 --- a/tests/runtime/test_renderers.py +++ b/tests/runtime/test_renderers.py @@ -8,6 +8,8 @@ import io +import pytest + from strands_compose.renderers import AnsiRenderer from strands_compose.types import EntryDescriptor, EventType, SessionManifest, StreamEvent @@ -41,22 +43,11 @@ def test_leading_whitespace_reasoning_does_not_open_reasoning_section(): assert _render(_ev(EventType.REASONING, text=" \t")) == "" -def test_whitespace_after_content_is_written(): - assert "hello\n" in _render( - _ev(EventType.TOKEN, text="hello"), - _ev(EventType.TOKEN, text="\n"), - ) - assert "thinking\n" in _render( - _ev(EventType.REASONING, text="thinking"), - _ev(EventType.REASONING, text="\n"), - ) - - def test_agent_start_shows_agent_name(): assert "worker" in _render(_ev(EventType.AGENT_START)) -def test_tool_start_shows_tool_label(): +def test_tool_start_shows_tool_name(): out = _render(_ev(EventType.TOOL_START, tool_name="search", tool_input={"q": "x"})) assert "search" in out @@ -66,22 +57,14 @@ def test_tool_end_error_shows_error_marker(): assert "boom" in out -def test_tool_end_success_renders(): - assert _render(_ev(EventType.TOOL_END, status="success")) != "" - - def test_agent_complete_shows_token_usage(): out = _render(_ev(EventType.AGENT_COMPLETE, usage={"input_tokens": 3, "output_tokens": 2})) assert "3" in out and "2" in out -def test_error_event_is_rendered(): - assert "ERROR" in _render(_ev(EventType.ERROR, message="bad")) - - def test_node_events_show_node_id(): out = _render(_ev(EventType.NODE_START, node_id="n1"), _ev(EventType.NODE_STOP, node_id="n1")) - assert out.count("n1") == 2 + assert "n1" in out def test_handoff_shows_target_nodes(): @@ -93,7 +76,7 @@ def test_multiagent_start_and_complete_render_kind(): _ev(EventType.MULTIAGENT_START, multiagent_type="swarm"), _ev(EventType.MULTIAGENT_COMPLETE, multiagent_type="swarm"), ) - assert out.count("swarm") == 2 + assert "swarm" in out def test_session_start_lists_entry_and_agents(): @@ -114,3 +97,47 @@ def test_session_end_shows_session_id(): def test_mode_switch_between_reasoning_and_responding_renders_both(): out = _render(_ev(EventType.REASONING, text="think"), _ev(EventType.TOKEN, text="answer")) assert "think" in out and "answer" in out + + +def test_interrupt_shows_name_reason_and_id(): + out = _render( + _ev( + EventType.INTERRUPT, + interrupt_id="int-1", + name="approve_refund", + reason="needs a human", + ) + ) + assert "approve_refund" in out + assert "needs a human" in out + assert "int-1" in out + + +# Minimal payload per event type — a new EventType with no entry fails the test +# below, which is the point: every type must reach a handler. +_MINIMAL_DATA: dict[str, dict] = { + EventType.SESSION_START: { + "manifest": SessionManifest(entry=EntryDescriptor(name="root", kind="agent")).model_dump() + }, + EventType.SESSION_END: {"session_id": "s1"}, + EventType.TOKEN: {"text": "hi"}, + EventType.REASONING: {"text": "think"}, + EventType.AGENT_START: {}, + EventType.TOOL_START: {"tool_name": "search", "tool_input": {}}, + EventType.TOOL_END: {"status": "success"}, + EventType.AGENT_COMPLETE: {"usage": {}}, + EventType.INTERRUPT: {"interrupt_id": "i1", "name": "ask", "reason": "why"}, + EventType.ERROR: {"text": "boom"}, + EventType.NODE_START: {"node_id": "n1"}, + EventType.NODE_STOP: {"node_id": "n1"}, + EventType.HANDOFF: {"to_node_ids": ["analyst"]}, + EventType.MULTIAGENT_START: {"multiagent_type": "swarm"}, + EventType.MULTIAGENT_COMPLETE: {"multiagent_type": "swarm"}, +} + + +@pytest.mark.parametrize("kind", list(EventType)) +def test_every_event_type_renders_something(kind): + """No event type may be silently dropped by the renderer.""" + out = _render(StreamEvent(type=kind, agent_name="worker", data=_MINIMAL_DATA[kind])) + assert out != "" diff --git a/tests/runtime/test_result_extraction.py b/tests/runtime/test_result_extraction.py index d09b703..ad12bf9 100644 --- a/tests/runtime/test_result_extraction.py +++ b/tests/runtime/test_result_extraction.py @@ -2,7 +2,7 @@ Simple cases run on hand-built messages/``AgentResult``. The multi-agent cases drive a real ``GraphResult`` / ``SwarmResult`` produced by invoking a real -orchestration through the public ``load_session`` seam (strands faked only at the +orchestration through the public ``load`` seam (strands faked only at the model resolver). Asserts the *shape* of the serialized dict and the extracted message — the public contract of ``serialize_multiagent_result`` / ``extract_last_message`` / ``extract_text``. @@ -14,7 +14,7 @@ from strands.telemetry.metrics import EventLoopMetrics from strands.types.content import Message -from strands_compose.config import load_session, resolve_infra +from strands_compose.config import load from strands_compose.config.schema import AppConfig from strands_compose.tools import serialize_multiagent_result from strands_compose.tools.extractors import extract_last_message, extract_text @@ -33,10 +33,6 @@ def test_extract_text_returns_last_text_block(): assert extract_text(message) == "final answer" -def test_extract_text_of_empty_message_is_empty_string(): - assert extract_text(None) == "" - - def test_extract_last_message_returns_agent_result_message(): message: Message = {"role": "assistant", "content": [{"text": "hi"}]} result = AgentResult( @@ -64,7 +60,7 @@ async def _invoke(orch): orchestrations={"o": orch}, entry="o", ) - resolved = load_session(config, resolve_infra(config)) + resolved = load(config) return await resolved.entry.invoke_async("hi") diff --git a/tests/schema/test_planner.py b/tests/schema/test_planner.py index f931c9d..bf01505 100644 --- a/tests/schema/test_planner.py +++ b/tests/schema/test_planner.py @@ -6,7 +6,7 @@ from strands_compose.config.resolvers.orchestrations.planner import topological_sort from strands_compose.exceptions import CircularDependencyError -from tests.factories import delegate_orchestration, swarm_orchestration +from tests.factories import delegate_orchestration, graph_orchestration, swarm_orchestration def test_dependencies_are_ordered_before_dependents(): @@ -39,3 +39,13 @@ def test_self_reference_raises_circular(): configs = {"loop": swarm_orchestration("loop", ["loop"])} with pytest.raises(CircularDependencyError): topological_sort(configs) + + +def test_graph_entry_orchestration_is_ordered_first_when_no_edge_names_it(): + """A graph's entry is a node even when no edge mentions it.""" + configs = { + "pipeline": graph_orchestration("inner", [("reviewer", "editor")]), + "inner": delegate_orchestration("writer", {"researcher": "research"}), + } + order = topological_sort(configs) + assert order.index("inner") < order.index("pipeline") diff --git a/tests/schema/test_references.py b/tests/schema/test_references.py index ba2ef33..94d4a4c 100644 --- a/tests/schema/test_references.py +++ b/tests/schema/test_references.py @@ -8,7 +8,7 @@ import pytest from strands_compose.config.loaders.validators import validate_references -from strands_compose.config.schema import AppConfig, MCPClientDef, MCPServerDef +from strands_compose.config.schema import AppConfig, MCPClientDef from strands_compose.exceptions import UnresolvedReferenceError from tests.factories import ( agent_def, @@ -41,21 +41,10 @@ def test_missing_mcp_client_reference_raises(): validate_references(config) -def test_missing_mcp_server_reference_raises(): +def test_mcp_client_reference_resolves_when_present(): config = AppConfig( - agents={"a": agent_def()}, - mcp_clients={"c": MCPClientDef(server="phantom")}, - entry="a", - ) - with pytest.raises(UnresolvedReferenceError, match="phantom"): - validate_references(config) - - -def test_mcp_server_reference_resolves_when_present(): - config = AppConfig( - agents={"a": agent_def()}, - mcp_servers={"srv": MCPServerDef(type="mod:make")}, - mcp_clients={"c": MCPClientDef(server="srv")}, + agents={"a": agent_def(mcp=["c"])}, + mcp_clients={"c": MCPClientDef(url="https://example.com/mcp")}, entry="a", ) validate_references(config) # does not raise diff --git a/tests/schema/test_validation.py b/tests/schema/test_validation.py index cf0b88b..a66067b 100644 --- a/tests/schema/test_validation.py +++ b/tests/schema/test_validation.py @@ -9,7 +9,7 @@ from pydantic import ValidationError from strands_compose.config.loaders import load_config -from strands_compose.config.schema import AppConfig, MCPClientDef, OrchestrationDef +from strands_compose.config.schema import AppConfig, OrchestrationDef from strands_compose.exceptions import SchemaValidationError from tests.factories import agent_def, app_config @@ -37,23 +37,6 @@ def test_name_collision_across_agents_and_orchestrations_raises(): ) -# ── MCPClientDef connection-mode validator ───────────────────────────────── - - -def test_mcp_client_requires_exactly_one_connection_mode_none_set(): - with pytest.raises(ValidationError): - MCPClientDef() - - -def test_mcp_client_rejects_multiple_connection_modes(): - with pytest.raises(ValidationError): - MCPClientDef(server="s", url="http://x") - - -def test_mcp_client_accepts_single_connection_mode(): - assert MCPClientDef(server="s").server == "s" - - # ── Orchestration discriminated union ──────────────────────────────────────