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.
- strands_compose_agentcore/__init__.py +39 -0
- strands_compose_agentcore/_utils.py +39 -0
- strands_compose_agentcore/app.py +263 -0
- strands_compose_agentcore/cli/__init__.py +141 -0
- strands_compose_agentcore/cli/client.py +26 -0
- strands_compose_agentcore/cli/dev.py +125 -0
- strands_compose_agentcore/cli/utils.py +44 -0
- strands_compose_agentcore/client/__init__.py +37 -0
- strands_compose_agentcore/client/agentcore.py +333 -0
- strands_compose_agentcore/client/local.py +164 -0
- strands_compose_agentcore/client/repl.py +84 -0
- strands_compose_agentcore/client/utils.py +127 -0
- strands_compose_agentcore/py.typed +0 -0
- strands_compose_agentcore/session.py +159 -0
- strands_compose_agentcore-0.1.0.dist-info/METADATA +292 -0
- strands_compose_agentcore-0.1.0.dist-info/RECORD +19 -0
- strands_compose_agentcore-0.1.0.dist-info/WHEEL +4 -0
- strands_compose_agentcore-0.1.0.dist-info/entry_points.txt +3 -0
- strands_compose_agentcore-0.1.0.dist-info/licenses/LICENSE +190 -0
|
@@ -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
|