strands-compose-agentcore 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.
@@ -0,0 +1,39 @@
1
+ """strands-compose-agentcore — toolkit for deploying strands-compose agents on AgentCore.
2
+
3
+ Install this package and use :func:`create_app` to wrap a strands-compose
4
+ YAML config as a ``BedrockAgentCoreApp``. Assign the result to a
5
+ module-level ``app`` in your entry script for ``agentcore deploy`` to
6
+ discover, or call ``app.run()`` for local development.
7
+
8
+ Example::
9
+
10
+ from pathlib import Path
11
+
12
+ from strands_compose_agentcore import create_app
13
+
14
+ app = create_app(Path(__file__).parent / "config.yaml")
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .app import create_app
20
+ from .client import (
21
+ AccessDeniedError,
22
+ AgentCoreClient,
23
+ AgentCoreClientError,
24
+ ClientConnectionError,
25
+ LocalClient,
26
+ RetryConfig,
27
+ ThrottledError,
28
+ )
29
+
30
+ __all__ = [
31
+ "AccessDeniedError",
32
+ "AgentCoreClient",
33
+ "AgentCoreClientError",
34
+ "ClientConnectionError",
35
+ "LocalClient",
36
+ "RetryConfig",
37
+ "ThrottledError",
38
+ "create_app",
39
+ ]
@@ -0,0 +1,39 @@
1
+ """Internal shared utilities — ANSI helpers and TTY detection.
2
+
3
+ This module provides terminal colour helpers used by both the CLI and
4
+ client subpackages. It is private (underscore prefix) and should not
5
+ be imported by external code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from typing import TextIO
12
+
13
+
14
+ def _stream_is_tty(stream: TextIO) -> bool:
15
+ """Check whether a stream is connected to a terminal.
16
+
17
+ Args:
18
+ stream: File-like object to check.
19
+
20
+ Returns:
21
+ True if the stream is a TTY.
22
+ """
23
+ return hasattr(stream, "isatty") and stream.isatty()
24
+
25
+
26
+ def ansi(code: str, stream: TextIO = sys.stderr) -> str:
27
+ """Return an ANSI escape sequence if *stream* is a TTY, else empty string.
28
+
29
+ Evaluates TTY status at call time — safe to use in tests where
30
+ streams may be redirected after import.
31
+
32
+ Args:
33
+ code: ANSI escape code (e.g. ``"31"`` for red).
34
+ stream: Stream to check for TTY support.
35
+
36
+ Returns:
37
+ The escape sequence or an empty string.
38
+ """
39
+ return f"\033[{code}m" if _stream_is_tty(stream) else ""
@@ -0,0 +1,263 @@
1
+ """BedrockAgentCore app factory for strands-compose agents.
2
+
3
+ Provides :func:`create_app` — the main API for building a
4
+ ``BedrockAgentCoreApp`` from a strands-compose YAML config.
5
+ Install this package and call the factory from your own entry script.
6
+
7
+ The app uses two-phase resolution:
8
+
9
+ - **Infrastructure** (models, MCP, session managers) is resolved once
10
+ at boot via ``resolve_infra()``.
11
+ - **Session** (agents, orchestrations, entry point) is resolved once
12
+ per session via ``load_session(config, infra, session_id=...)``.
13
+ The session ID comes from the AgentCore runtime header
14
+ ``X-Amzn-Bedrock-AgentCore-Runtime-Session-Id``.
15
+ - Follow-up prompts within the same session reuse the same agents and
16
+ ``EventQueue`` — only the queue is flushed between turns.
17
+
18
+ Example::
19
+
20
+ from strands_compose_agentcore import create_app
21
+
22
+ app = create_app("config.yaml")
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ from collections.abc import AsyncIterator
29
+ from contextlib import asynccontextmanager
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from bedrock_agentcore import BedrockAgentCoreApp
35
+ from bedrock_agentcore.runtime.context import BedrockAgentCoreContext
36
+ from starlette.middleware.cors import CORSMiddleware
37
+ from starlette.types import StatelessLifespan
38
+ from strands_compose import (
39
+ AppConfig,
40
+ ResolvedInfra,
41
+ StreamEvent,
42
+ load_config,
43
+ resolve_infra,
44
+ )
45
+ from strands_compose.startup import validate_mcp
46
+
47
+ from .session import SessionState, resolve_session, stream_invocation, validate_session_id
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Lifespan
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def _make_lifespan(
58
+ app_config: AppConfig,
59
+ infra: ResolvedInfra,
60
+ ) -> StatelessLifespan[BedrockAgentCoreApp]:
61
+ """Return an ASGI lifespan that starts MCP infrastructure.
62
+
63
+ On startup the MCP lifecycle is entered (servers started, connectivity
64
+ probed). Agents are **not** created here — they are created lazily on
65
+ the first invocation so the session ID from the AgentCore header can be
66
+ forwarded to ``load_session``.
67
+
68
+ On shutdown the MCP lifecycle context manager stops clients first, then
69
+ servers.
70
+
71
+ Args:
72
+ app_config: Validated AppConfig from YAML.
73
+ infra: Resolved infrastructure (models, MCP, session manager).
74
+
75
+ Returns:
76
+ An ASGI lifespan context manager.
77
+ """
78
+
79
+ @asynccontextmanager
80
+ async def _lifespan(app: BedrockAgentCoreApp) -> AsyncIterator[None]:
81
+ async with infra.mcp_lifecycle:
82
+ report = await validate_mcp(infra)
83
+ report.print_summary()
84
+
85
+ app.state.app_config = app_config
86
+ app.state.infra = infra
87
+ app.state.session = None # lazily populated on first invoke
88
+ app.state.session_id = None # bound on first invoke
89
+
90
+ logger.info("infrastructure ready, waiting for first invocation")
91
+ yield
92
+
93
+ return _lifespan
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # App factory
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ def create_app(
102
+ config: str | Path | list[str | Path] | AppConfig,
103
+ infra: ResolvedInfra | None = None,
104
+ *,
105
+ cors_origins: list[str] | None = None,
106
+ suppress_runtime_logging: bool = False,
107
+ invocation_timeout: float | None = None,
108
+ ) -> BedrockAgentCoreApp:
109
+ """Create a BedrockAgentCoreApp with full event streaming.
110
+
111
+ This is the main API of strands-compose-agentcore. Pass a YAML config
112
+ path (or list of paths) and the factory handles ``load_config`` and
113
+ ``resolve_infra`` internally. For advanced use, pass a pre-built
114
+ ``AppConfig`` and optional ``ResolvedInfra``.
115
+
116
+ Infrastructure is resolved once at boot. Session state (agents,
117
+ orchestrations) is resolved lazily on the first invocation using
118
+ the session ID from the ``X-Amzn-Bedrock-AgentCore-Runtime-Session-Id``
119
+ header. Follow-up prompts reuse the same session state.
120
+
121
+ Args:
122
+ config: YAML file path, raw YAML string, list of either, or
123
+ a pre-built AppConfig. Strings are auto-detected as file
124
+ paths if the file exists, otherwise parsed as inline YAML.
125
+ infra: Pre-resolved infrastructure. When ``None`` (the default),
126
+ ``resolve_infra()`` is called automatically.
127
+ cors_origins: List of allowed CORS origins.
128
+ suppress_runtime_logging: Remove the JSON log handler that
129
+ ``BedrockAgentCoreApp`` installs on the
130
+ ``bedrock_agentcore.app`` logger. Useful in local
131
+ development to avoid duplicate log lines. In production
132
+ on AgentCore Runtime, leave this ``False`` so CloudWatch
133
+ receives structured JSON logs.
134
+ invocation_timeout: Maximum seconds to wait for the agent to
135
+ finish a single invocation. ``None`` (the default) means
136
+ no timeout — the agent runs until completion or failure.
137
+
138
+ Returns:
139
+ Configured BedrockAgentCoreApp ready to run.
140
+ """
141
+ # Resolve config from path/string if needed.
142
+ if isinstance(config, (str, Path, list)):
143
+ app_config = load_config(config)
144
+ else:
145
+ app_config = config
146
+
147
+ # Validate that an entry point is defined.
148
+ if not getattr(app_config, "entry", None):
149
+ raise ValueError(
150
+ "config has no 'entry' defined — set 'entry: <agent_name>' in your YAML config"
151
+ )
152
+
153
+ # Resolve infrastructure if not provided.
154
+ if infra is None:
155
+ infra = resolve_infra(app_config)
156
+
157
+ _invocation_timeout = invocation_timeout # capture for closure
158
+
159
+ app = BedrockAgentCoreApp(
160
+ lifespan=_make_lifespan(app_config, infra),
161
+ )
162
+
163
+ if suppress_runtime_logging:
164
+ logging.getLogger("bedrock_agentcore.app").handlers.clear()
165
+
166
+ if cors_origins:
167
+ app.add_middleware(
168
+ CORSMiddleware, # type: ignore[arg-type]
169
+ allow_origins=cors_origins,
170
+ allow_methods=["*"],
171
+ allow_headers=["*"],
172
+ )
173
+
174
+ @app.entrypoint
175
+ async def invoke(payload: dict[str, Any]) -> AsyncIterator[dict[str, Any]]:
176
+ """Entrypoint for ``/invocations`` POST requests.
177
+
178
+ On the first call, resolves agents using the session ID from the
179
+ AgentCore runtime header and caches the result. Subsequent calls
180
+ reuse the same agents — only the event queue is flushed.
181
+
182
+ Concurrent invocations within the same session are rejected with
183
+ an error event. The ``/ping`` endpoint reports ``HEALTHY_BUSY``
184
+ while an invocation is in progress so AgentCore Runtime can
185
+ back off.
186
+
187
+ Args:
188
+ payload: Request payload. Required key: ``prompt``.
189
+
190
+ Yields:
191
+ JSON-serializable dicts, one per StreamEvent.
192
+ """
193
+ prompt = payload.get("prompt")
194
+ if not prompt:
195
+ yield StreamEvent(
196
+ type="error",
197
+ agent_name="",
198
+ timestamp=datetime.now(tz=timezone.utc),
199
+ data={"message": "missing or empty required field: prompt"},
200
+ ).asdict()
201
+ return
202
+
203
+ session_id = BedrockAgentCoreContext.get_session_id()
204
+
205
+ try:
206
+ validate_session_id(session_id)
207
+ except ValueError as exc:
208
+ logger.warning("session_id=<%s> | %s", session_id, exc)
209
+ yield StreamEvent(
210
+ type="error",
211
+ agent_name="",
212
+ timestamp=datetime.now(tz=timezone.utc),
213
+ data={"message": str(exc)},
214
+ ).asdict()
215
+ return
216
+
217
+ # Resolve session first (sync — no await, no context switch).
218
+ session: SessionState | None = app.state.session
219
+ if session is None or app.state.session_id != session_id:
220
+ if app.state.session_id is not None:
221
+ logger.info(
222
+ "session_id=<%s> | new session replaces previous session_id=<%s>",
223
+ session_id,
224
+ app.state.session_id,
225
+ )
226
+ session = resolve_session(
227
+ app.state.app_config,
228
+ app.state.infra,
229
+ session_id,
230
+ )
231
+ app.state.session = session
232
+ app.state.session_id = session_id
233
+
234
+ # SAFETY: asyncio is single-threaded. No await exists between
235
+ # locked() and the async-with acquire below, so no other
236
+ # coroutine can acquire the lock in between.
237
+ if session.invocation_lock.locked():
238
+ logger.warning(
239
+ "session_id=<%s> | invocation rejected, agent already running",
240
+ session_id,
241
+ )
242
+ yield StreamEvent(
243
+ type="error",
244
+ agent_name="",
245
+ timestamp=datetime.now(tz=timezone.utc),
246
+ data={"message": "agent is already running, try again later"},
247
+ ).asdict()
248
+ return
249
+
250
+ task_id = app.add_async_task("invoke")
251
+ try:
252
+ async with session.invocation_lock:
253
+ async for event in stream_invocation(
254
+ session.resolved,
255
+ session.events,
256
+ prompt,
257
+ invocation_timeout=_invocation_timeout,
258
+ ):
259
+ yield event.asdict()
260
+ finally:
261
+ app.complete_async_task(task_id)
262
+
263
+ return app
@@ -0,0 +1,141 @@
1
+ """CLI entry point for strands-compose-agentcore.
2
+
3
+ Provides:
4
+ - ``dev`` — server + REPL in one terminal
5
+ - ``client local|remote`` — REPL for local or deployed agents
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from typing import NoReturn
13
+
14
+ from .client import cmd_client
15
+ from .dev import cmd_dev
16
+ from .utils import CLIError, red, reset
17
+
18
+
19
+ class _ColorArgumentParser(argparse.ArgumentParser):
20
+ """ArgumentParser that prints errors in red with surrounding newlines."""
21
+
22
+ def error(self, message: str) -> NoReturn:
23
+ """Print a red error message and exit.
24
+
25
+ Args:
26
+ message: Error description from argparse.
27
+ """
28
+ print(
29
+ f"\n{red()}{self.prog}: error:\n {message}{reset()}\n",
30
+ file=sys.stderr,
31
+ )
32
+ sys.exit(2)
33
+
34
+
35
+ def _build_parser() -> argparse.ArgumentParser:
36
+ """Build the argument parser with all subcommands.
37
+
38
+ Returns:
39
+ Configured ArgumentParser.
40
+ """
41
+ parser = _ColorArgumentParser(
42
+ prog="sca",
43
+ description="CLI toolkit for strands-compose agents on AgentCore.",
44
+ )
45
+ subparsers = parser.add_subparsers(dest="command")
46
+
47
+ # -- dev (own implementation) ------------------------------------------
48
+ dev_parser = subparsers.add_parser(
49
+ "dev",
50
+ help="Start server + REPL in one terminal.",
51
+ )
52
+ dev_parser.add_argument(
53
+ "--config",
54
+ default=None,
55
+ help="Path to strands-compose YAML config (default: ./config.yaml).",
56
+ )
57
+ dev_parser.add_argument(
58
+ "--port",
59
+ type=int,
60
+ default=8080,
61
+ help="Port for the HTTP server (default: 8080).",
62
+ )
63
+ dev_parser.add_argument(
64
+ "--session-id",
65
+ default=None,
66
+ help="Session ID for the REPL client",
67
+ )
68
+
69
+ # -- client (own implementation) ---------------------------------------
70
+ client_parser = subparsers.add_parser(
71
+ "client",
72
+ help="Interactive REPL client for local or remote agents.",
73
+ )
74
+ client_sub = client_parser.add_subparsers(dest="client_command")
75
+
76
+ local_parser = client_sub.add_parser(
77
+ "local",
78
+ help="Connect to a local server.",
79
+ )
80
+ local_parser.add_argument(
81
+ "--url",
82
+ default="http://localhost:8080/invocations",
83
+ help="URL of the /invocations endpoint.",
84
+ )
85
+ local_parser.add_argument(
86
+ "--session-id",
87
+ default=None,
88
+ help="Session ID for the AgentCore header.",
89
+ )
90
+ remote_parser = client_sub.add_parser(
91
+ "remote",
92
+ help="Connect to a deployed AgentCore Runtime agent.",
93
+ )
94
+ remote_parser.add_argument(
95
+ "--arn",
96
+ required=True,
97
+ help="Full ARN of the deployed agent runtime.",
98
+ )
99
+ remote_parser.add_argument(
100
+ "--region",
101
+ default=None,
102
+ help="AWS region override.",
103
+ )
104
+ remote_parser.add_argument(
105
+ "--session-id",
106
+ default=None,
107
+ help="AgentCore session ID.",
108
+ )
109
+
110
+ return parser
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> None:
114
+ """Parse arguments and dispatch to the appropriate handler.
115
+
116
+ Args:
117
+ argv: Command-line arguments. Defaults to ``sys.argv[1:]``.
118
+ """
119
+ if argv is None:
120
+ argv = sys.argv[1:]
121
+
122
+ parser = _build_parser()
123
+ args = parser.parse_args(argv)
124
+
125
+ try:
126
+ if args.command == "dev":
127
+ cmd_dev(args)
128
+
129
+ elif args.command == "client":
130
+ cmd_client(args, parser)
131
+
132
+ else:
133
+ parser.print_help()
134
+ sys.exit(1)
135
+ except CLIError as exc:
136
+ print(f"\n{red()}{exc.message}{reset()}\n", file=sys.stderr)
137
+ sys.exit(exc.code)
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
@@ -0,0 +1,26 @@
1
+ """Client subcommands — local and remote REPL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from ..client import AgentCoreClient, LocalClient
8
+
9
+
10
+ def cmd_client(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
11
+ """Handle the ``client`` subcommand.
12
+
13
+ Args:
14
+ args: Parsed CLI arguments.
15
+ parser: Root parser (for --help fallback).
16
+ """
17
+ if args.client_command == "local":
18
+ client = LocalClient(url=args.url, session_id=args.session_id)
19
+ client.repl()
20
+
21
+ elif args.client_command == "remote":
22
+ client = AgentCoreClient(args.arn, region=args.region)
23
+ client.repl(session_id=args.session_id)
24
+
25
+ else:
26
+ parser.parse_args(["client", "--help"])
@@ -0,0 +1,125 @@
1
+ """Dev command — server + REPL in one terminal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import socket
8
+ import threading
9
+ import time
10
+ import urllib.error
11
+ import urllib.request
12
+ from pathlib import Path
13
+
14
+ from .. import create_app
15
+ from ..client.local import LocalClient
16
+ from .utils import CLIError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _SERVER_STARTUP_TIMEOUT = 30.0
21
+ _SERVER_POLL_INTERVAL = 0.5
22
+
23
+
24
+ def cmd_dev(args: argparse.Namespace) -> None:
25
+ """Handle the ``dev`` subcommand.
26
+
27
+ Args:
28
+ args: Parsed CLI arguments (config, port, session_id).
29
+
30
+ Raises:
31
+ CLIError: Config file not found.
32
+ """
33
+ config_path = args.config or "config.yaml"
34
+ if not Path(config_path).is_file():
35
+ raise CLIError(f"Error: config not found: {config_path}")
36
+
37
+ run_dev(config_path, port=args.port, session_id=args.session_id)
38
+
39
+
40
+ def run_dev(
41
+ config: str | Path,
42
+ *,
43
+ session_id: str | None = None,
44
+ port: int = 8080,
45
+ ) -> None:
46
+ """Start ASGI server in a daemon thread and run the REPL in main thread.
47
+
48
+ Uses ``app.run()`` which internally calls ``uvicorn.run(self, ...)``
49
+ with sensible defaults (host auto-detection, log levels, etc.).
50
+ The daemon thread auto-terminates when the process exits.
51
+
52
+ Args:
53
+ config: Path to strands-compose YAML config file.
54
+ port: Port for the HTTP server.
55
+ session_id: Session ID for the REPL client. When ``None``
56
+ (the default), ``LocalClient`` uses ``DEFAULT_SESSION_ID``.
57
+
58
+ Raises:
59
+ CLIError: Port already in use or server startup timeout.
60
+ """
61
+ app = create_app(
62
+ config,
63
+ cors_origins=["*"],
64
+ suppress_runtime_logging=True,
65
+ )
66
+
67
+ if _port_in_use(port):
68
+ raise CLIError(
69
+ f"Error: port {port} is already in use.\n"
70
+ " Stop the other process or use --port <number>."
71
+ )
72
+
73
+ # Start the server in a daemon thread so it doesn't block the REPL
74
+ server_thread = threading.Thread(
75
+ target=app.run,
76
+ kwargs={"port": port},
77
+ daemon=True,
78
+ name="dev-server",
79
+ )
80
+ server_thread.start()
81
+
82
+ ping_url = f"http://localhost:{port}/ping"
83
+ if not _wait_for_server(ping_url, timeout=_SERVER_STARTUP_TIMEOUT):
84
+ raise CLIError(f"Error: server did not start within {_SERVER_STARTUP_TIMEOUT} seconds")
85
+
86
+ url = f"http://localhost:{port}/invocations"
87
+ client = LocalClient(url=url, session_id=session_id)
88
+ client.repl()
89
+
90
+
91
+ def _wait_for_server(ping_url: str, timeout: float) -> bool:
92
+ """Poll the /ping endpoint until the server responds or timeout expires.
93
+
94
+ Args:
95
+ ping_url: URL of the server's health-check endpoint.
96
+ timeout: Maximum seconds to wait.
97
+
98
+ Returns:
99
+ True if server responded, False on timeout.
100
+ """
101
+ deadline = time.monotonic() + timeout
102
+ while time.monotonic() < deadline:
103
+ try:
104
+ # This urlopen call is safe - we just ping our local server
105
+ # Security alert is false positive
106
+ with urllib.request.urlopen(ping_url, timeout=2): # nosec: B310
107
+ return True
108
+ except (OSError, urllib.error.URLError):
109
+ time.sleep(_SERVER_POLL_INTERVAL)
110
+ return False
111
+
112
+
113
+ def _port_in_use(port: int, host: str = "127.0.0.1") -> bool:
114
+ """Check whether a TCP port is already bound.
115
+
116
+ Args:
117
+ port: Port number to check.
118
+ host: Address to probe.
119
+
120
+ Returns:
121
+ True if the port is in use.
122
+ """
123
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
124
+ sock.settimeout(1)
125
+ return sock.connect_ex((host, port)) == 0
@@ -0,0 +1,44 @@
1
+ """Shared CLI utilities: ANSI colour helpers and exception types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .._utils import ansi # noqa: F401 — re-exported for cli consumers
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Colours — call-time TTY detection via ansi()
11
+ # ---------------------------------------------------------------------------
12
+
13
+
14
+ def red() -> str:
15
+ """Return ANSI red escape for stderr."""
16
+ return ansi("31", sys.stderr)
17
+
18
+
19
+ def reset() -> str:
20
+ """Return ANSI reset escape for stderr."""
21
+ return ansi("0", sys.stderr)
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Exceptions
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ class CLIError(Exception):
30
+ """Raised by CLI commands to signal a user-facing error.
31
+
32
+ ``main()`` catches this, prints the message to stderr, and calls
33
+ ``sys.exit(code)`` — keeping command handlers testable without
34
+ catching ``SystemExit``.
35
+
36
+ Args:
37
+ message: Human-readable error message.
38
+ code: Exit code (default ``1``).
39
+ """
40
+
41
+ def __init__(self, message: str, code: int = 1) -> None:
42
+ super().__init__(message)
43
+ self.message = message
44
+ self.code = code