strands-compose 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. strands_compose/__init__.py +64 -0
  2. strands_compose/cli.py +427 -0
  3. strands_compose/config/__init__.py +53 -0
  4. strands_compose/config/interpolation.py +196 -0
  5. strands_compose/config/loaders/__init__.py +12 -0
  6. strands_compose/config/loaders/helpers.py +406 -0
  7. strands_compose/config/loaders/loaders.py +270 -0
  8. strands_compose/config/loaders/validators.py +124 -0
  9. strands_compose/config/resolvers/__init__.py +28 -0
  10. strands_compose/config/resolvers/agents.py +203 -0
  11. strands_compose/config/resolvers/config.py +166 -0
  12. strands_compose/config/resolvers/conversation_manager.py +54 -0
  13. strands_compose/config/resolvers/hooks.py +68 -0
  14. strands_compose/config/resolvers/mcp.py +106 -0
  15. strands_compose/config/resolvers/models.py +47 -0
  16. strands_compose/config/resolvers/orchestrations/__init__.py +80 -0
  17. strands_compose/config/resolvers/orchestrations/builders.py +354 -0
  18. strands_compose/config/resolvers/orchestrations/planner.py +102 -0
  19. strands_compose/config/resolvers/session_manager.py +149 -0
  20. strands_compose/config/schema.py +337 -0
  21. strands_compose/converters/__init__.py +13 -0
  22. strands_compose/converters/base.py +54 -0
  23. strands_compose/converters/openai.py +221 -0
  24. strands_compose/converters/raw.py +31 -0
  25. strands_compose/exceptions.py +40 -0
  26. strands_compose/hooks/__init__.py +17 -0
  27. strands_compose/hooks/event_publisher.py +378 -0
  28. strands_compose/hooks/max_calls_guard.py +90 -0
  29. strands_compose/hooks/stop_guard.py +113 -0
  30. strands_compose/hooks/tool_name_sanitizer.py +184 -0
  31. strands_compose/mcp/README.md +105 -0
  32. strands_compose/mcp/__init__.py +27 -0
  33. strands_compose/mcp/client.py +170 -0
  34. strands_compose/mcp/lifecycle.py +233 -0
  35. strands_compose/mcp/server.py +327 -0
  36. strands_compose/mcp/transports.py +188 -0
  37. strands_compose/models.py +69 -0
  38. strands_compose/py.typed +0 -0
  39. strands_compose/renderers/__init__.py +9 -0
  40. strands_compose/renderers/ansi.py +237 -0
  41. strands_compose/renderers/base.py +36 -0
  42. strands_compose/startup/__init__.py +24 -0
  43. strands_compose/startup/report.py +174 -0
  44. strands_compose/startup/validator.py +167 -0
  45. strands_compose/tools/__init__.py +33 -0
  46. strands_compose/tools/extractors.py +182 -0
  47. strands_compose/tools/loaders.py +238 -0
  48. strands_compose/tools/wrappers.py +100 -0
  49. strands_compose/types.py +110 -0
  50. strands_compose/utils.py +210 -0
  51. strands_compose/wire.py +196 -0
  52. strands_compose-0.1.0.dist-info/METADATA +445 -0
  53. strands_compose-0.1.0.dist-info/RECORD +56 -0
  54. strands_compose-0.1.0.dist-info/WHEEL +4 -0
  55. strands_compose-0.1.0.dist-info/entry_points.txt +2 -0
  56. strands_compose-0.1.0.dist-info/licenses/LICENSE +174 -0
@@ -0,0 +1,64 @@
1
+ """strands-compose — Zero-code YAML-driven agent orchestration over strands-agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .config import (
6
+ AppConfig,
7
+ ConfigInput,
8
+ ResolvedConfig,
9
+ ResolvedInfra,
10
+ load,
11
+ load_config,
12
+ load_session,
13
+ resolve_infra,
14
+ )
15
+ from .config.resolvers.orchestrations import OrchestrationBuilder
16
+ from .exceptions import (
17
+ CircularDependencyError,
18
+ ConfigurationError,
19
+ ImportResolutionError,
20
+ SchemaValidationError,
21
+ UnresolvedReferenceError,
22
+ )
23
+ from .hooks import EventPublisher, MaxToolCallsGuard, StopGuard, ToolNameSanitizer
24
+ from .mcp import MCPLifecycle, create_mcp_client, create_mcp_server
25
+ from .renderers import AnsiRenderer
26
+ from .tools import (
27
+ node_as_async_tool,
28
+ node_as_tool,
29
+ )
30
+ from .types import EventType, StreamEvent
31
+ from .utils import cli_errors
32
+ from .wire import EventQueue, make_event_queue
33
+
34
+ __all__ = [
35
+ "AnsiRenderer",
36
+ "AppConfig",
37
+ "CircularDependencyError",
38
+ "ConfigInput",
39
+ "ConfigurationError",
40
+ "EventPublisher",
41
+ "EventQueue",
42
+ "EventType",
43
+ "ImportResolutionError",
44
+ "MCPLifecycle",
45
+ "MaxToolCallsGuard",
46
+ "OrchestrationBuilder",
47
+ "ResolvedConfig",
48
+ "ResolvedInfra",
49
+ "SchemaValidationError",
50
+ "StopGuard",
51
+ "StreamEvent",
52
+ "ToolNameSanitizer",
53
+ "UnresolvedReferenceError",
54
+ "cli_errors",
55
+ "create_mcp_client",
56
+ "create_mcp_server",
57
+ "load",
58
+ "load_config",
59
+ "load_session",
60
+ "make_event_queue",
61
+ "node_as_async_tool",
62
+ "node_as_tool",
63
+ "resolve_infra",
64
+ ]
strands_compose/cli.py ADDED
@@ -0,0 +1,427 @@
1
+ """Command-line interface for strands-compose.
2
+
3
+ Exposes two sub-commands:
4
+
5
+ ``check``
6
+ Parse and validate a YAML config via :func:`load_config`.
7
+ Pure, fast, zero side-effects — safe to run in CI and pre-deploy hooks.
8
+
9
+ ``load``
10
+ Full pipeline via :func:`load` followed by an async MCP health check
11
+ via :func:`validate_mcp`. Starts MCP server processes; always cleans
12
+ them up before exiting.
13
+
14
+ Usage::
15
+
16
+ strands-compose check config.yaml
17
+ strands-compose check base.yaml agents.yaml # multi-file merge
18
+ strands-compose load config.yaml [--json]
19
+ strands-compose load config.yaml [--quiet]
20
+
21
+ Exit codes: ``0`` on success, ``1`` on any error or critical health failure.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import asyncio
28
+ import json
29
+ import sys
30
+ import textwrap
31
+ from importlib.metadata import version as pkg_version
32
+ from typing import TYPE_CHECKING
33
+
34
+ from .config import AppConfig, ConfigInput, load, load_config
35
+ from .startup.report import CheckResult, StartupReport
36
+ from .startup.validator import validate_mcp
37
+ from .utils import cli_errors
38
+
39
+ if TYPE_CHECKING:
40
+ from .config.resolvers import ResolvedConfig
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # ANSI helpers
44
+ # ---------------------------------------------------------------------------
45
+
46
+ _GREEN = "\033[32m"
47
+ _RED = "\033[31m"
48
+ _YELLOW = "\033[33m"
49
+ _DIM = "\033[2m"
50
+ _BOLD = "\033[1m"
51
+ _RESET = "\033[0m"
52
+
53
+
54
+ def _colour(text: str, code: str) -> str:
55
+ """Wrap *text* in an ANSI colour code when stdout is a TTY.
56
+
57
+ Args:
58
+ text: The string to colour.
59
+ code: An ANSI escape code string (e.g. ``_GREEN``).
60
+
61
+ Returns:
62
+ Coloured string if stdout is a TTY, plain string otherwise.
63
+ """
64
+ if sys.stdout.isatty():
65
+ return f"{code}{text}{_RESET}"
66
+ return text
67
+
68
+
69
+ def _get_version() -> str:
70
+ """Return the installed package version.
71
+
72
+ Returns:
73
+ Version string from package metadata, or ``"unknown"`` as fallback.
74
+ """
75
+ try:
76
+ return pkg_version("strands-compose")
77
+ except Exception:
78
+ return "unknown"
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # check sub-command
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ def _count_hooks(app_config: AppConfig) -> int:
87
+ """Count total hook entries across all agents and orchestrations.
88
+
89
+ Args:
90
+ app_config: The validated application config.
91
+
92
+ Returns:
93
+ Total number of hook entries.
94
+ """
95
+ total = 0
96
+ for agent_def in app_config.agents.values():
97
+ total += len(agent_def.hooks)
98
+ for orch_def in app_config.orchestrations.values():
99
+ total += len(orch_def.hooks)
100
+ return total
101
+
102
+
103
+ def _render_check_success_ansi(app_config: AppConfig) -> None:
104
+ """Print a human-readable success summary for the ``check`` sub-command.
105
+
106
+ Args:
107
+ app_config: The validated :class:`AppConfig` returned by
108
+ :func:`load_config`.
109
+ """
110
+ agent_names = list(app_config.agents)
111
+ orch_names = list(app_config.orchestrations)
112
+ mcp_server_names = list(app_config.mcp_servers)
113
+ mcp_client_names = list(app_config.mcp_clients)
114
+
115
+ agent_str = f"{len(agent_names)} agent{'s' if len(agent_names) != 1 else ''}"
116
+ if agent_names:
117
+ agent_str += f" ({', '.join(agent_names)})"
118
+
119
+ # Collect rows as (label, value) pairs, then align on the colon.
120
+ rows: list[tuple[str, str]] = [
121
+ ("entry", str(app_config.entry)),
122
+ ("agents", agent_str),
123
+ ]
124
+ if app_config.models:
125
+ rows.append(("models", ", ".join(app_config.models)))
126
+ if mcp_server_names:
127
+ rows.append(("mcp servers", ", ".join(mcp_server_names)))
128
+ if mcp_client_names:
129
+ rows.append(("mcp clients", ", ".join(mcp_client_names)))
130
+ if orch_names:
131
+ rows.append(("orchestrations", ", ".join(orch_names)))
132
+ if app_config.session_manager:
133
+ rows.append(("session", str(app_config.session_manager.type)))
134
+
135
+ hook_count = _count_hooks(app_config)
136
+ if hook_count:
137
+ rows.append(("hooks", f"{hook_count} total"))
138
+
139
+ width = max(len(label) for label, _ in rows)
140
+ parts = [_colour("✓ Config valid", _GREEN + _BOLD)]
141
+ for label, value in rows:
142
+ parts.append(f" {label.ljust(width)} : {value}")
143
+
144
+ print("\n".join(parts)) # noqa: T201
145
+
146
+
147
+ def _render_check_success_json(app_config: AppConfig) -> None:
148
+ """Print a JSON success payload for the ``check`` sub-command.
149
+
150
+ Args:
151
+ app_config: The validated :class:`AppConfig`.
152
+ """
153
+ payload = {
154
+ "ok": True,
155
+ "stage": "check",
156
+ "version": _get_version(),
157
+ "entry": app_config.entry,
158
+ "agents": list(app_config.agents),
159
+ "models": list(app_config.models),
160
+ "mcp_clients": list(app_config.mcp_clients),
161
+ "mcp_servers": list(app_config.mcp_servers),
162
+ "orchestrations": list(app_config.orchestrations),
163
+ "session_manager": app_config.session_manager.type if app_config.session_manager else None,
164
+ "hooks": _count_hooks(app_config),
165
+ }
166
+ print(json.dumps(payload)) # noqa: T201
167
+
168
+
169
+ def _cmd_check(configs: list[ConfigInput], *, json_output: bool, quiet: bool) -> None:
170
+ """Run the ``check`` sub-command.
171
+
172
+ Calls :func:`load_config` and prints a success summary or exits with
173
+ code 1 on any validation error (via :func:`cli_errors`).
174
+
175
+ Args:
176
+ configs: Paths to one or more YAML configuration files.
177
+ json_output: When ``True``, emit JSON instead of ANSI output.
178
+ quiet: When ``True``, suppress output on success (exit code only).
179
+ """
180
+ with cli_errors():
181
+ config_input = configs[0] if len(configs) == 1 else configs
182
+ app_config = load_config(config_input)
183
+
184
+ if quiet:
185
+ return
186
+
187
+ if json_output:
188
+ _render_check_success_json(app_config)
189
+ else:
190
+ _render_check_success_ansi(app_config)
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # load sub-command
195
+ # ---------------------------------------------------------------------------
196
+
197
+
198
+ def _render_check_result_ansi(check: CheckResult) -> str:
199
+ """Format one :class:`CheckResult` as a coloured ANSI line.
200
+
201
+ Args:
202
+ check: The check result to format.
203
+
204
+ Returns:
205
+ A single (possibly multi-line) string ready for printing.
206
+ """
207
+ if check.ok:
208
+ icon = _colour("✓", _GREEN)
209
+ elif check.severity == "warning":
210
+ icon = _colour("⚠", _YELLOW)
211
+ else:
212
+ icon = _colour("✗", _RED)
213
+
214
+ line = f"[{check.category:8s}] {icon} {check.subject}: {check.message}"
215
+ if not check.ok and check.hint:
216
+ indent = " " * 14
217
+ line += f"\n{indent}{_colour('hint:', _DIM)} {check.hint}"
218
+ return line
219
+
220
+
221
+ def _render_report_ansi(report: StartupReport) -> None:
222
+ """Print a human-readable MCP health report to stdout.
223
+
224
+ Args:
225
+ report: The :class:`StartupReport` from :func:`validate_mcp`.
226
+ """
227
+ for check in report.checks:
228
+ print(_render_check_result_ansi(check)) # noqa: T201
229
+
230
+ n_ok = len(report.passed_checks)
231
+ n_warn = len(report.warnings)
232
+ n_crit = len(report.critical_checks)
233
+ total = len(report.checks)
234
+
235
+ if total == 0:
236
+ print(_colour("✓ Load OK", _GREEN + _BOLD) + " (no MCP servers/clients configured)") # noqa: T201
237
+ return
238
+
239
+ summary = f"{n_ok}/{total} passed"
240
+ if n_warn:
241
+ summary += f", {n_warn} warning(s)"
242
+ if n_crit:
243
+ summary += f", {n_crit} critical"
244
+
245
+ if report.ok:
246
+ print(_colour(f"✓ Load OK — {summary}", _GREEN + _BOLD)) # noqa: T201
247
+ else:
248
+ print(_colour(f"✗ Load FAILED — {summary}", _RED + _BOLD)) # noqa: T201
249
+
250
+
251
+ def _render_report_json(report: StartupReport) -> None:
252
+ """Print a JSON health report to stdout.
253
+
254
+ Args:
255
+ report: The :class:`StartupReport` from :func:`validate_mcp`.
256
+ """
257
+ payload = {
258
+ "ok": report.ok,
259
+ "stage": "load",
260
+ "version": _get_version(),
261
+ "checks": [
262
+ {
263
+ "ok": c.ok,
264
+ "category": c.category,
265
+ "subject": c.subject,
266
+ "message": c.message,
267
+ "severity": c.severity,
268
+ "hint": c.hint,
269
+ }
270
+ for c in report.checks
271
+ ],
272
+ }
273
+ print(json.dumps(payload)) # noqa: T201
274
+
275
+
276
+ async def _cmd_load_async(configs: list[ConfigInput], *, json_output: bool, quiet: bool) -> None:
277
+ """Async body of the ``load`` sub-command.
278
+
279
+ Calls :func:`load`, runs :func:`validate_mcp`, prints the health
280
+ report, and always stops the MCP lifecycle before returning.
281
+
282
+ Args:
283
+ configs: Paths to one or more YAML configuration files.
284
+ json_output: When ``True``, emit JSON instead of ANSI output.
285
+ quiet: When ``True``, suppress output on success (exit code only).
286
+
287
+ Raises:
288
+ SystemExit: With code 1 when any critical MCP health check fails.
289
+ """
290
+ resolved: ResolvedConfig | None = None
291
+ try:
292
+ with cli_errors():
293
+ config_input = configs[0] if len(configs) == 1 else configs
294
+ resolved = load(config_input)
295
+
296
+ report = await validate_mcp(resolved)
297
+
298
+ if not quiet or not report.ok:
299
+ if json_output:
300
+ _render_report_json(report)
301
+ else:
302
+ _render_report_ansi(report)
303
+
304
+ if not report.ok:
305
+ sys.exit(1)
306
+ finally:
307
+ if resolved is not None:
308
+ resolved.mcp_lifecycle.stop()
309
+
310
+
311
+ def _cmd_load(configs: list[ConfigInput], *, json_output: bool, quiet: bool) -> None:
312
+ """Run the ``load`` sub-command.
313
+
314
+ Delegates to :func:`_cmd_load_async` via :func:`asyncio.run`.
315
+
316
+ Args:
317
+ configs: Paths to one or more YAML configuration files.
318
+ json_output: When ``True``, emit JSON instead of ANSI output.
319
+ quiet: When ``True``, suppress output on success (exit code only).
320
+ """
321
+ asyncio.run(_cmd_load_async(configs, json_output=json_output, quiet=quiet))
322
+
323
+
324
+ # ---------------------------------------------------------------------------
325
+ # Argument parser
326
+ # ---------------------------------------------------------------------------
327
+
328
+
329
+ def _add_common_flags(parser: argparse.ArgumentParser) -> None:
330
+ """Add ``--json`` and ``--quiet`` flags shared by both sub-commands.
331
+
332
+ Args:
333
+ parser: The sub-command parser to extend.
334
+ """
335
+ parser.add_argument(
336
+ "--json", dest="json_output", action="store_true", help="Emit JSON output instead of ANSI"
337
+ )
338
+ parser.add_argument(
339
+ "-q",
340
+ "--quiet",
341
+ action="store_true",
342
+ help="Suppress output on success (exit code only, useful for CI)",
343
+ )
344
+
345
+
346
+ def _build_parser() -> argparse.ArgumentParser:
347
+ """Build and return the top-level argument parser.
348
+
349
+ Returns:
350
+ Configured :class:`argparse.ArgumentParser`.
351
+ """
352
+ parser = argparse.ArgumentParser(
353
+ prog="strands-compose",
354
+ description=textwrap.dedent(
355
+ """\
356
+ strands-compose — YAML-driven multi-agent orchestration
357
+
358
+ Sub-commands:
359
+ check Validate config (no side-effects, safe for CI)
360
+ load Full load + MCP health check
361
+ """
362
+ ),
363
+ formatter_class=argparse.RawDescriptionHelpFormatter,
364
+ )
365
+ parser.add_argument(
366
+ "-V",
367
+ "--version",
368
+ action="version",
369
+ version=f"%(prog)s {_get_version()}",
370
+ )
371
+
372
+ subparsers = parser.add_subparsers(dest="command", metavar="<command>")
373
+ subparsers.required = True
374
+
375
+ # -- check --
376
+ check_parser = subparsers.add_parser(
377
+ "check",
378
+ help="Parse and validate a YAML config (no side-effects)",
379
+ description="Load and validate the config via load_config(). "
380
+ "Checks YAML syntax, schema, variable interpolation, and "
381
+ "cross-references. Exits 0 on success, 1 on any error.",
382
+ )
383
+ check_parser.add_argument(
384
+ "config",
385
+ metavar="CONFIG",
386
+ nargs="+",
387
+ help="Path(s) to YAML config file(s). Multiple files are merged left-to-right.",
388
+ )
389
+ _add_common_flags(check_parser)
390
+
391
+ # -- load --
392
+ load_parser = subparsers.add_parser(
393
+ "load",
394
+ help="Full load pipeline + MCP health check",
395
+ description="Run the full load() pipeline (starts MCP servers, builds agents) "
396
+ "then probe MCP connectivity. Always stops MCP servers on exit. "
397
+ "Exits 0 on success, 1 on any error or critical health failure.",
398
+ )
399
+ load_parser.add_argument(
400
+ "config",
401
+ metavar="CONFIG",
402
+ nargs="+",
403
+ help="Path(s) to YAML config file(s). Multiple files are merged left-to-right.",
404
+ )
405
+ _add_common_flags(load_parser)
406
+
407
+ return parser
408
+
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # Entry point
412
+ # ---------------------------------------------------------------------------
413
+
414
+
415
+ def main() -> None:
416
+ """CLI entry point for the ``strands-compose`` command.
417
+
418
+ Dispatches to :func:`_cmd_check` or :func:`_cmd_load` based on the
419
+ sub-command supplied on the command line.
420
+ """
421
+ parser = _build_parser()
422
+ args = parser.parse_args()
423
+
424
+ if args.command == "check":
425
+ _cmd_check(args.config, json_output=args.json_output, quiet=args.quiet)
426
+ else:
427
+ _cmd_load(args.config, json_output=args.json_output, quiet=args.quiet)
@@ -0,0 +1,53 @@
1
+ """YAML configuration loading, validation, and resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .interpolation import interpolate, strip_anchors
6
+ from .loaders import ConfigInput, load, load_config, load_session
7
+ from .resolvers import ResolvedConfig, ResolvedInfra, resolve_infra
8
+ from .schema import (
9
+ COLLECTION_KEYS,
10
+ JOINT_NAMESPACES,
11
+ AgentDef,
12
+ AppConfig,
13
+ ConversationManagerDef,
14
+ DelegateConnectionDef,
15
+ DelegateOrchestrationDef,
16
+ GraphEdgeDef,
17
+ GraphOrchestrationDef,
18
+ HookDef,
19
+ MCPClientDef,
20
+ MCPServerDef,
21
+ ModelDef,
22
+ OrchestrationDef,
23
+ SessionManagerDef,
24
+ SwarmOrchestrationDef,
25
+ )
26
+
27
+ __all__ = [
28
+ "AgentDef",
29
+ "AppConfig",
30
+ "COLLECTION_KEYS",
31
+ "ConversationManagerDef",
32
+ "JOINT_NAMESPACES",
33
+ "ConfigInput",
34
+ "DelegateConnectionDef",
35
+ "DelegateOrchestrationDef",
36
+ "GraphEdgeDef",
37
+ "GraphOrchestrationDef",
38
+ "HookDef",
39
+ "MCPClientDef",
40
+ "MCPServerDef",
41
+ "ModelDef",
42
+ "OrchestrationDef",
43
+ "SessionManagerDef",
44
+ "SwarmOrchestrationDef",
45
+ "ResolvedConfig",
46
+ "ResolvedInfra",
47
+ "interpolate",
48
+ "load",
49
+ "load_config",
50
+ "load_session",
51
+ "resolve_infra",
52
+ "strip_anchors",
53
+ ]