Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 44 additions & 38 deletions .kiro/skills/library-development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand All @@ -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.

---

Expand All @@ -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
Expand All @@ -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`.

---

Expand Down Expand Up @@ -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.
Expand All @@ -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]`).
Expand All @@ -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.
Expand All @@ -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=<value>`, comma-separated), human-readable
Expand Down Expand Up @@ -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`.

---

Expand All @@ -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
Expand Down
46 changes: 23 additions & 23 deletions .kiro/skills/library-development/references/project-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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) |
Expand All @@ -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

Expand All @@ -91,31 +88,34 @@ 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
API consumers import from.

## 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 …`.
Expand Down
Loading