haru-cli 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.
- haru/__init__.py +1 -0
- haru/__main__.py +12 -0
- haru/agents/__init__.py +18 -0
- haru/agents/factory.py +76 -0
- haru/agents/orchestration.py +169 -0
- haru/auth/__init__.py +14 -0
- haru/auth/cache.py +82 -0
- haru/auth/pkce.py +21 -0
- haru/auth/session.py +95 -0
- haru/auth/sso.py +206 -0
- haru/cli.py +20 -0
- haru/commands/__init__.py +1 -0
- haru/commands/chat.py +102 -0
- haru/commands/login.py +34 -0
- haru/commands/run.py +60 -0
- haru/commands/session.py +36 -0
- haru/commands/streaming.py +34 -0
- haru/config/__init__.py +6 -0
- haru/config/loader.py +163 -0
- haru/config/schema.py +261 -0
- haru/errors.py +25 -0
- haru/models/__init__.py +5 -0
- haru/models/bedrock.py +77 -0
- haru/observability/__init__.py +6 -0
- haru/observability/guardrails.py +36 -0
- haru/observability/telemetry.py +34 -0
- haru/sessions/__init__.py +5 -0
- haru/sessions/manager.py +70 -0
- haru/steering/__init__.py +5 -0
- haru/steering/prompts.py +42 -0
- haru/tools/__init__.py +12 -0
- haru/tools/mcp.py +137 -0
- haru/tools/registry.py +41 -0
- haru_cli-0.1.0.dist-info/METADATA +120 -0
- haru_cli-0.1.0.dist-info/RECORD +38 -0
- haru_cli-0.1.0.dist-info/WHEEL +4 -0
- haru_cli-0.1.0.dist-info/entry_points.txt +2 -0
- haru_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
haru/auth/sso.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""OAuth 2.0 Authorization Code + PKCE login against IAM Identity Center.
|
|
2
|
+
|
|
3
|
+
Implements the same flow ``aws sso login`` uses by default since AWS CLI
|
|
4
|
+
v2.22.0: register a public OIDC client, open the browser to the authorize
|
|
5
|
+
endpoint, capture the code on a 127.0.0.1 loopback listener, and exchange it
|
|
6
|
+
with ``CreateToken``. Only loopback redirect URIs are accepted.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import secrets
|
|
10
|
+
import time
|
|
11
|
+
import webbrowser
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
16
|
+
from typing import Any, cast
|
|
17
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
18
|
+
|
|
19
|
+
import boto3
|
|
20
|
+
from botocore.exceptions import ClientError
|
|
21
|
+
|
|
22
|
+
from haru.auth.pkce import generate_pkce_pair
|
|
23
|
+
from haru.config.schema import AuthConfig
|
|
24
|
+
from haru.errors import AuthError
|
|
25
|
+
|
|
26
|
+
_LOOPBACK_HOST = "127.0.0.1"
|
|
27
|
+
_CALLBACK_PATH = "/oauth/callback"
|
|
28
|
+
_AUTHORIZE_SCOPES = "sso:account:access"
|
|
29
|
+
_CALLBACK_RESPONSE = (
|
|
30
|
+
b"<html><body><p>Sign-in complete. You can close this window and return to haru.</p>"
|
|
31
|
+
b"</body></html>"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ClientRegistration:
|
|
37
|
+
"""A registered public OIDC client."""
|
|
38
|
+
|
|
39
|
+
client_id: str
|
|
40
|
+
client_secret: str
|
|
41
|
+
expires_at: datetime
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class SsoToken:
|
|
46
|
+
"""An SSO access token plus the registration needed to refresh it."""
|
|
47
|
+
|
|
48
|
+
access_token: str
|
|
49
|
+
refresh_token: str | None
|
|
50
|
+
expires_at: datetime
|
|
51
|
+
registration: ClientRegistration
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def register_client(
|
|
55
|
+
oidc: Any, client_name: str, start_url: str, redirect_uri: str
|
|
56
|
+
) -> ClientRegistration:
|
|
57
|
+
"""Register a public OIDC client for the authorization-code + PKCE flow."""
|
|
58
|
+
host = urlsplit(redirect_uri).hostname
|
|
59
|
+
if host != _LOOPBACK_HOST:
|
|
60
|
+
raise AuthError(f"Redirect URI must be a {_LOOPBACK_HOST} loopback address, got {host!r}")
|
|
61
|
+
try:
|
|
62
|
+
response = oidc.register_client(
|
|
63
|
+
clientName=client_name,
|
|
64
|
+
clientType="public",
|
|
65
|
+
scopes=[_AUTHORIZE_SCOPES],
|
|
66
|
+
redirectUris=[redirect_uri],
|
|
67
|
+
issuerUrl=start_url,
|
|
68
|
+
grantTypes=["authorization_code", "refresh_token"],
|
|
69
|
+
)
|
|
70
|
+
except ClientError as exc:
|
|
71
|
+
raise AuthError(f"OIDC client registration failed: {exc}") from exc
|
|
72
|
+
return ClientRegistration(
|
|
73
|
+
client_id=response["clientId"],
|
|
74
|
+
client_secret=response["clientSecret"],
|
|
75
|
+
expires_at=datetime.fromtimestamp(response["clientSecretExpiresAt"], tz=UTC),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_authorize_url(
|
|
80
|
+
region: str, client_id: str, redirect_uri: str, state: str, code_challenge: str
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Build the IAM Identity Center authorize URL for the PKCE flow."""
|
|
83
|
+
query = urlencode(
|
|
84
|
+
{
|
|
85
|
+
"response_type": "code",
|
|
86
|
+
"client_id": client_id,
|
|
87
|
+
"redirect_uri": redirect_uri,
|
|
88
|
+
"state": state,
|
|
89
|
+
"code_challenge": code_challenge,
|
|
90
|
+
"code_challenge_method": "S256",
|
|
91
|
+
"scopes": _AUTHORIZE_SCOPES,
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
return f"https://oidc.{region}.amazonaws.com/authorize?{query}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def exchange_code(
|
|
98
|
+
oidc: Any, registration: ClientRegistration, code: str, verifier: str, redirect_uri: str
|
|
99
|
+
) -> SsoToken:
|
|
100
|
+
"""Exchange an authorization code (plus PKCE verifier) for an SSO token."""
|
|
101
|
+
try:
|
|
102
|
+
response = oidc.create_token(
|
|
103
|
+
clientId=registration.client_id,
|
|
104
|
+
clientSecret=registration.client_secret,
|
|
105
|
+
grantType="authorization_code",
|
|
106
|
+
code=code,
|
|
107
|
+
codeVerifier=verifier,
|
|
108
|
+
redirectUri=redirect_uri,
|
|
109
|
+
)
|
|
110
|
+
except ClientError as exc:
|
|
111
|
+
raise AuthError(f"Token exchange failed: {exc}") from exc
|
|
112
|
+
return SsoToken(
|
|
113
|
+
access_token=response["accessToken"],
|
|
114
|
+
refresh_token=response.get("refreshToken"),
|
|
115
|
+
expires_at=datetime.now(UTC) + timedelta(seconds=response["expiresIn"]),
|
|
116
|
+
registration=registration,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_login(
|
|
121
|
+
config: AuthConfig,
|
|
122
|
+
*,
|
|
123
|
+
oidc: Any | None = None,
|
|
124
|
+
opener: Callable[[str], object] | None = None,
|
|
125
|
+
timeout_seconds: float = 300.0,
|
|
126
|
+
) -> SsoToken:
|
|
127
|
+
"""Run the interactive browser login and return the resulting SSO token.
|
|
128
|
+
|
|
129
|
+
Orchestrates client registration, PKCE pair generation, opening the
|
|
130
|
+
authorize URL, capturing the code on a loopback listener (validating the
|
|
131
|
+
``state`` parameter), and the CreateToken exchange.
|
|
132
|
+
"""
|
|
133
|
+
sso = config.sso
|
|
134
|
+
if oidc is None:
|
|
135
|
+
oidc = boto3.Session().client("sso-oidc", region_name=sso.sso_region)
|
|
136
|
+
open_url = opener if opener is not None else webbrowser.open
|
|
137
|
+
|
|
138
|
+
server = _start_loopback_server(sso.callback_port)
|
|
139
|
+
try:
|
|
140
|
+
port = server.server_address[1]
|
|
141
|
+
redirect_uri = f"http://{_LOOPBACK_HOST}:{port}{_CALLBACK_PATH}"
|
|
142
|
+
registration = register_client(oidc, sso.client_name, sso.start_url, redirect_uri)
|
|
143
|
+
verifier, challenge = generate_pkce_pair()
|
|
144
|
+
state = secrets.token_urlsafe(32)
|
|
145
|
+
open_url(
|
|
146
|
+
build_authorize_url(
|
|
147
|
+
sso.sso_region, registration.client_id, redirect_uri, state, challenge
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
code, returned_state = _await_callback(server, timeout_seconds)
|
|
151
|
+
if not secrets.compare_digest(returned_state, state):
|
|
152
|
+
raise AuthError("State mismatch in OAuth callback; aborting login")
|
|
153
|
+
return exchange_code(oidc, registration, code, verifier, redirect_uri)
|
|
154
|
+
finally:
|
|
155
|
+
server.server_close()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class _CallbackServer(HTTPServer):
|
|
159
|
+
"""Loopback HTTP server that records the OAuth callback query parameters."""
|
|
160
|
+
|
|
161
|
+
callback_params: dict[str, list[str]] | None = None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
165
|
+
"""Handles the single OAuth redirect request on the loopback listener."""
|
|
166
|
+
|
|
167
|
+
def do_GET(self) -> None:
|
|
168
|
+
parsed = urlsplit(self.path)
|
|
169
|
+
if parsed.path != _CALLBACK_PATH:
|
|
170
|
+
self.send_error(404)
|
|
171
|
+
return
|
|
172
|
+
server = cast(_CallbackServer, self.server)
|
|
173
|
+
server.callback_params = parse_qs(parsed.query)
|
|
174
|
+
self.send_response(200)
|
|
175
|
+
self.send_header("Content-Type", "text/html")
|
|
176
|
+
self.end_headers()
|
|
177
|
+
self.wfile.write(_CALLBACK_RESPONSE)
|
|
178
|
+
|
|
179
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
180
|
+
"""Silence default request logging; callback URLs must never be logged."""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _start_loopback_server(port: int) -> _CallbackServer:
|
|
184
|
+
"""Bind the loopback callback server (port 0 selects an ephemeral port)."""
|
|
185
|
+
try:
|
|
186
|
+
return _CallbackServer((_LOOPBACK_HOST, port), _CallbackHandler)
|
|
187
|
+
except OSError as exc:
|
|
188
|
+
raise AuthError(f"Could not bind loopback callback port {port}: {exc}") from exc
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _await_callback(server: _CallbackServer, timeout_seconds: float) -> tuple[str, str]:
|
|
192
|
+
"""Wait for the OAuth redirect and return its ``(code, state)`` pair."""
|
|
193
|
+
deadline = time.monotonic() + timeout_seconds
|
|
194
|
+
server.timeout = 1.0
|
|
195
|
+
while server.callback_params is None:
|
|
196
|
+
if time.monotonic() >= deadline:
|
|
197
|
+
raise AuthError("Timed out waiting for the browser sign-in to complete")
|
|
198
|
+
server.handle_request()
|
|
199
|
+
params = server.callback_params
|
|
200
|
+
if "error" in params:
|
|
201
|
+
raise AuthError(f"Authorization failed: {params['error'][0]}")
|
|
202
|
+
codes = params.get("code")
|
|
203
|
+
states = params.get("state")
|
|
204
|
+
if not codes or not states:
|
|
205
|
+
raise AuthError("OAuth callback is missing the code or state parameter")
|
|
206
|
+
return codes[0], states[0]
|
haru/cli.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Click command group for the haru CLI."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from haru.commands.chat import chat
|
|
6
|
+
from haru.commands.login import login
|
|
7
|
+
from haru.commands.run import run
|
|
8
|
+
from haru.commands.session import session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
@click.version_option(package_name="haru-cli", prog_name="haru")
|
|
13
|
+
def cli() -> None:
|
|
14
|
+
"""haru: a governed CLI for Amazon Bedrock agents."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
cli.add_command(chat)
|
|
18
|
+
cli.add_command(login)
|
|
19
|
+
cli.add_command(run)
|
|
20
|
+
cli.add_command(session)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Click command implementations for the haru CLI."""
|
haru/commands/chat.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The ``haru chat`` command: an interactive streaming REPL."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from haru.agents.factory import build_agent
|
|
10
|
+
from haru.auth.session import build_boto3_session
|
|
11
|
+
from haru.commands.streaming import collect_response, surface_guardrail
|
|
12
|
+
from haru.config import load_config
|
|
13
|
+
from haru.config.schema import HaruConfig
|
|
14
|
+
from haru.errors import HaruError
|
|
15
|
+
from haru.observability.telemetry import configure_telemetry
|
|
16
|
+
from haru.sessions.manager import build_session_manager
|
|
17
|
+
from haru.tools.mcp import started_mcp_clients
|
|
18
|
+
|
|
19
|
+
_EXIT_WORDS = frozenset({"exit", "quit"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_chat( # noqa: PLR0913 - keyword-only wiring points, all optional
|
|
23
|
+
config: HaruConfig,
|
|
24
|
+
agent_name: str | None,
|
|
25
|
+
*,
|
|
26
|
+
console: Console,
|
|
27
|
+
read_input: Callable[[str], str] = input,
|
|
28
|
+
prompts_root: Path | None = None,
|
|
29
|
+
session_id: str | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Run the interactive chat loop until the user exits."""
|
|
32
|
+
session = build_boto3_session(config.auth)
|
|
33
|
+
session_manager = (
|
|
34
|
+
build_session_manager(config, session_id, boto_session=session)
|
|
35
|
+
if session_id is not None
|
|
36
|
+
else None
|
|
37
|
+
)
|
|
38
|
+
with started_mcp_clients(config.mcp) as mcp_clients:
|
|
39
|
+
agent = build_agent(
|
|
40
|
+
config,
|
|
41
|
+
agent_name,
|
|
42
|
+
session,
|
|
43
|
+
prompts_root=prompts_root,
|
|
44
|
+
mcp_clients=mcp_clients,
|
|
45
|
+
session_manager=session_manager,
|
|
46
|
+
)
|
|
47
|
+
console.print("haru chat - type 'exit' or press Ctrl-D to leave.")
|
|
48
|
+
while True:
|
|
49
|
+
try:
|
|
50
|
+
line = read_input("you> ").strip()
|
|
51
|
+
except (EOFError, KeyboardInterrupt):
|
|
52
|
+
console.print("\nGoodbye.")
|
|
53
|
+
return
|
|
54
|
+
if not line:
|
|
55
|
+
continue
|
|
56
|
+
if line.lower() in _EXIT_WORDS:
|
|
57
|
+
console.print("Goodbye.")
|
|
58
|
+
return
|
|
59
|
+
try:
|
|
60
|
+
result = collect_response(
|
|
61
|
+
agent,
|
|
62
|
+
line,
|
|
63
|
+
lambda text: console.print(text, end="", markup=False, highlight=False),
|
|
64
|
+
)
|
|
65
|
+
except KeyboardInterrupt:
|
|
66
|
+
console.print("\n[interrupted]")
|
|
67
|
+
continue
|
|
68
|
+
console.print()
|
|
69
|
+
surface_guardrail(result, console)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@click.command()
|
|
73
|
+
@click.option(
|
|
74
|
+
"--config",
|
|
75
|
+
"config_path",
|
|
76
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
77
|
+
default=None,
|
|
78
|
+
help="Path to haru.yaml (default: config/haru.yaml).",
|
|
79
|
+
)
|
|
80
|
+
@click.option("--agent", "agent_name", default=None, help="Agent name from configuration.")
|
|
81
|
+
@click.option(
|
|
82
|
+
"--session-id",
|
|
83
|
+
"session_id",
|
|
84
|
+
default=None,
|
|
85
|
+
help="Persist and restore this conversation under the given session id.",
|
|
86
|
+
)
|
|
87
|
+
def chat(config_path: Path | None, agent_name: str | None, session_id: str | None) -> None:
|
|
88
|
+
"""Chat interactively with a Bedrock agent (streaming)."""
|
|
89
|
+
console = Console()
|
|
90
|
+
try:
|
|
91
|
+
config = load_config(config_path)
|
|
92
|
+
configure_telemetry(config.observability)
|
|
93
|
+
prompts_root = config_path.parent / "prompts" if config_path is not None else None
|
|
94
|
+
run_chat(
|
|
95
|
+
config,
|
|
96
|
+
agent_name,
|
|
97
|
+
console=console,
|
|
98
|
+
prompts_root=prompts_root,
|
|
99
|
+
session_id=session_id,
|
|
100
|
+
)
|
|
101
|
+
except HaruError as exc:
|
|
102
|
+
raise click.ClickException(str(exc)) from exc
|
haru/commands/login.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""The ``haru login`` command: interactive IAM Identity Center sign-in."""
|
|
2
|
+
|
|
3
|
+
import webbrowser
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from haru.auth.cache import write_token_cache
|
|
9
|
+
from haru.auth.sso import run_login
|
|
10
|
+
from haru.config import load_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command()
|
|
14
|
+
@click.option(
|
|
15
|
+
"--config",
|
|
16
|
+
"config_path",
|
|
17
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
18
|
+
default=None,
|
|
19
|
+
help="Path to haru.yaml (default: config/haru.yaml).",
|
|
20
|
+
)
|
|
21
|
+
def login(config_path: Path | None) -> None:
|
|
22
|
+
"""Sign in to AWS IAM Identity Center via the browser (PKCE)."""
|
|
23
|
+
config = load_config(config_path, with_includes=False)
|
|
24
|
+
auth = config.auth
|
|
25
|
+
|
|
26
|
+
def _open(url: str) -> None:
|
|
27
|
+
click.echo("Complete sign-in in your browser:")
|
|
28
|
+
click.echo(url)
|
|
29
|
+
if auth.sso.browser:
|
|
30
|
+
webbrowser.open(url)
|
|
31
|
+
|
|
32
|
+
token = run_login(auth, opener=_open)
|
|
33
|
+
path = write_token_cache(token, auth.sso.start_url, auth.sso.sso_region)
|
|
34
|
+
click.echo(f"Login successful. Token cached at {path}.")
|
haru/commands/run.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The ``haru run`` command: one-shot prompt execution."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from haru.agents.factory import build_agent
|
|
9
|
+
from haru.auth.session import build_boto3_session
|
|
10
|
+
from haru.commands.streaming import collect_response, surface_guardrail
|
|
11
|
+
from haru.config import load_config
|
|
12
|
+
from haru.config.schema import HaruConfig
|
|
13
|
+
from haru.errors import HaruError
|
|
14
|
+
from haru.observability.telemetry import configure_telemetry
|
|
15
|
+
from haru.tools.mcp import started_mcp_clients
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_prompt(
|
|
19
|
+
config: HaruConfig,
|
|
20
|
+
prompt: str,
|
|
21
|
+
agent_name: str | None = None,
|
|
22
|
+
*,
|
|
23
|
+
console: Console | None = None,
|
|
24
|
+
prompts_root: Path | None = None,
|
|
25
|
+
) -> str:
|
|
26
|
+
"""Execute ``prompt`` once and return the full response text.
|
|
27
|
+
|
|
28
|
+
Guardrail interventions are surfaced on ``console`` (stderr by default).
|
|
29
|
+
"""
|
|
30
|
+
session = build_boto3_session(config.auth)
|
|
31
|
+
with started_mcp_clients(config.mcp) as mcp_clients:
|
|
32
|
+
agent = build_agent(
|
|
33
|
+
config, agent_name, session, prompts_root=prompts_root, mcp_clients=mcp_clients
|
|
34
|
+
)
|
|
35
|
+
chunks: list[str] = []
|
|
36
|
+
result = collect_response(agent, prompt, chunks.append)
|
|
37
|
+
surface_guardrail(result, console if console is not None else Console(stderr=True))
|
|
38
|
+
return "".join(chunks)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.command()
|
|
42
|
+
@click.argument("prompt")
|
|
43
|
+
@click.option(
|
|
44
|
+
"--config",
|
|
45
|
+
"config_path",
|
|
46
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
47
|
+
default=None,
|
|
48
|
+
help="Path to haru.yaml (default: config/haru.yaml).",
|
|
49
|
+
)
|
|
50
|
+
@click.option("--agent", "agent_name", default=None, help="Agent name from configuration.")
|
|
51
|
+
def run(prompt: str, config_path: Path | None, agent_name: str | None) -> None:
|
|
52
|
+
"""Run a single prompt and print the answer."""
|
|
53
|
+
try:
|
|
54
|
+
config = load_config(config_path)
|
|
55
|
+
configure_telemetry(config.observability)
|
|
56
|
+
prompts_root = config_path.parent / "prompts" if config_path is not None else None
|
|
57
|
+
answer = run_prompt(config, prompt, agent_name, prompts_root=prompts_root)
|
|
58
|
+
except HaruError as exc:
|
|
59
|
+
raise click.ClickException(str(exc)) from exc
|
|
60
|
+
click.echo(answer)
|
haru/commands/session.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The ``haru session`` command group: inspect persisted conversations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from haru.config import load_config
|
|
8
|
+
from haru.errors import HaruError
|
|
9
|
+
from haru.sessions.manager import list_sessions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
def session() -> None:
|
|
14
|
+
"""Manage persisted chat sessions."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@session.command(name="list")
|
|
18
|
+
@click.option(
|
|
19
|
+
"--config",
|
|
20
|
+
"config_path",
|
|
21
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
22
|
+
default=None,
|
|
23
|
+
help="Path to haru.yaml (default: config/haru.yaml).",
|
|
24
|
+
)
|
|
25
|
+
def list_command(config_path: Path | None) -> None:
|
|
26
|
+
"""List stored session ids."""
|
|
27
|
+
try:
|
|
28
|
+
config = load_config(config_path, with_includes=False) if config_path else None
|
|
29
|
+
session_ids = list_sessions(config)
|
|
30
|
+
except HaruError as exc:
|
|
31
|
+
raise click.ClickException(str(exc)) from exc
|
|
32
|
+
if not session_ids:
|
|
33
|
+
click.echo("No stored sessions.")
|
|
34
|
+
return
|
|
35
|
+
for session_id in session_ids:
|
|
36
|
+
click.echo(session_id)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared streaming helpers for the chat and run commands."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
_GUARDRAIL_STOPS = frozenset({"guardrail_intervened", "content_filtered"})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def collect_response(agent: Any, prompt: str, on_text: Callable[[str], None]) -> Any:
|
|
13
|
+
"""Stream a response, forwarding text chunks to ``on_text``.
|
|
14
|
+
|
|
15
|
+
Returns the final AgentResult (or None if the stream produced no result
|
|
16
|
+
event). Delegates the agent loop entirely to Strands.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
async def _consume() -> Any:
|
|
20
|
+
result: Any = None
|
|
21
|
+
async for event in agent.stream_async(prompt):
|
|
22
|
+
if "data" in event:
|
|
23
|
+
on_text(event["data"])
|
|
24
|
+
if "result" in event:
|
|
25
|
+
result = event["result"]
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
return asyncio.run(_consume())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def surface_guardrail(result: Any, console: Console) -> None:
|
|
32
|
+
"""Print a warning when the response was blocked or redacted by guardrails."""
|
|
33
|
+
if result is not None and getattr(result, "stop_reason", None) in _GUARDRAIL_STOPS:
|
|
34
|
+
console.print("[bold yellow]Guardrail intervened: the response was blocked or redacted.[/]")
|
haru/config/__init__.py
ADDED
haru/config/loader.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Load and validate layered YAML configuration.
|
|
2
|
+
|
|
3
|
+
The loader enforces three invariants before any value reaches typed models:
|
|
4
|
+
|
|
5
|
+
1. No inline secrets: keys named ``secret``, ``password``, or ``token`` must
|
|
6
|
+
hold environment references (``${env:VAR}`` or ``${VAR}``), never values.
|
|
7
|
+
2. Environment references resolve at load time; a missing variable fails fast.
|
|
8
|
+
3. Every document validates into the frozen schema, rejecting unknown keys.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from pydantic import BaseModel, ValidationError
|
|
18
|
+
|
|
19
|
+
from haru.config.schema import (
|
|
20
|
+
AgentsConfig,
|
|
21
|
+
GuardrailsFile,
|
|
22
|
+
HaruConfig,
|
|
23
|
+
LoggingFile,
|
|
24
|
+
MCPConfig,
|
|
25
|
+
ModelsConfig,
|
|
26
|
+
)
|
|
27
|
+
from haru.errors import ConfigError
|
|
28
|
+
|
|
29
|
+
DEFAULT_CONFIG_PATH = Path("config") / "haru.yaml"
|
|
30
|
+
|
|
31
|
+
_ENV_PATTERN = re.compile(r"\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}|\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
32
|
+
_ENV_REFERENCE = re.compile(r"^\$\{(?:env:)?[A-Za-z_][A-Za-z0-9_]*\}$")
|
|
33
|
+
_SECRET_KEY_NAMES = frozenset({"secret", "password", "token"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_env(value: str) -> str:
|
|
37
|
+
"""Expand ``${env:VAR}`` and ``${VAR}`` references from the environment.
|
|
38
|
+
|
|
39
|
+
Raises ConfigError if a referenced variable is not set.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def _replace(match: re.Match[str]) -> str:
|
|
43
|
+
name = match.group(1) or match.group(2)
|
|
44
|
+
resolved = os.environ.get(name)
|
|
45
|
+
if resolved is None:
|
|
46
|
+
raise ConfigError(f"Environment variable {name!r} referenced in configuration is unset")
|
|
47
|
+
return resolved
|
|
48
|
+
|
|
49
|
+
return _ENV_PATTERN.sub(_replace, value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_config(path: Path | None = None, *, with_includes: bool = True) -> HaruConfig:
|
|
53
|
+
"""Load the full configuration from ``path`` (default ``config/haru.yaml``).
|
|
54
|
+
|
|
55
|
+
Parses the base document, then resolves every declared include relative to
|
|
56
|
+
the base file's directory. Pass ``with_includes=False`` to load only the
|
|
57
|
+
base document (commands that need just the ``app``/``auth`` sections).
|
|
58
|
+
Raises ConfigError on any missing file, malformed YAML, inline secret,
|
|
59
|
+
unset environment reference, or schema violation.
|
|
60
|
+
"""
|
|
61
|
+
config_path = path if path is not None else DEFAULT_CONFIG_PATH
|
|
62
|
+
base = _validate(HaruConfig, _load_yaml(config_path), config_path)
|
|
63
|
+
if not with_includes:
|
|
64
|
+
return base
|
|
65
|
+
config = load_includes(base, config_path.parent)
|
|
66
|
+
_check_cross_references(config)
|
|
67
|
+
return config
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_includes(base: HaruConfig, root: Path) -> HaruConfig:
|
|
71
|
+
"""Return ``base`` with every declared include file loaded and validated.
|
|
72
|
+
|
|
73
|
+
Include paths are resolved relative to ``root``. Raises ConfigError with
|
|
74
|
+
the offending path when an include is missing or invalid.
|
|
75
|
+
"""
|
|
76
|
+
includes = base.includes
|
|
77
|
+
if includes is None:
|
|
78
|
+
return base
|
|
79
|
+
|
|
80
|
+
updates: dict[str, Any] = {}
|
|
81
|
+
if includes.models is not None:
|
|
82
|
+
source = root / includes.models
|
|
83
|
+
updates["models"] = _validate(ModelsConfig, _load_yaml(source), source)
|
|
84
|
+
if includes.agents is not None:
|
|
85
|
+
source = root / includes.agents
|
|
86
|
+
updates["agents"] = _validate(AgentsConfig, _load_yaml(source), source)
|
|
87
|
+
if includes.mcp is not None:
|
|
88
|
+
source = root / includes.mcp
|
|
89
|
+
updates["mcp"] = _validate(MCPConfig, _load_yaml(source), source)
|
|
90
|
+
if includes.guardrails is not None:
|
|
91
|
+
source = root / includes.guardrails
|
|
92
|
+
updates["guardrails"] = _validate(GuardrailsFile, _load_yaml(source), source).guardrails
|
|
93
|
+
if includes.logging is not None:
|
|
94
|
+
source = root / includes.logging
|
|
95
|
+
logging_file = _validate(LoggingFile, _load_yaml(source), source)
|
|
96
|
+
updates["logging"] = logging_file.logging
|
|
97
|
+
updates["observability"] = logging_file.observability
|
|
98
|
+
return base.model_copy(update=updates)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
102
|
+
"""Read a YAML mapping, reject inline secrets, and resolve env references."""
|
|
103
|
+
if not path.is_file():
|
|
104
|
+
raise ConfigError(f"Missing configuration file: {path}")
|
|
105
|
+
try:
|
|
106
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
107
|
+
except yaml.YAMLError as exc:
|
|
108
|
+
raise ConfigError(f"Malformed YAML in {path}: {exc}") from exc
|
|
109
|
+
if not isinstance(data, dict):
|
|
110
|
+
raise ConfigError(f"Top-level YAML in {path} must be a mapping")
|
|
111
|
+
_reject_inline_secrets(data, source=path)
|
|
112
|
+
resolved: dict[str, Any] = _resolve_tree(data)
|
|
113
|
+
return resolved
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _resolve_tree(node: Any) -> Any:
|
|
117
|
+
"""Recursively expand environment references in every string value."""
|
|
118
|
+
if isinstance(node, str):
|
|
119
|
+
return resolve_env(node)
|
|
120
|
+
if isinstance(node, dict):
|
|
121
|
+
return {key: _resolve_tree(value) for key, value in node.items()}
|
|
122
|
+
if isinstance(node, list):
|
|
123
|
+
return [_resolve_tree(item) for item in node]
|
|
124
|
+
return node
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _reject_inline_secrets(node: Any, *, source: Path, key_path: str = "") -> None:
|
|
128
|
+
"""Fail if a secret-named key holds anything but an environment reference."""
|
|
129
|
+
if isinstance(node, dict):
|
|
130
|
+
for key, value in node.items():
|
|
131
|
+
child = f"{key_path}.{key}" if key_path else str(key)
|
|
132
|
+
if str(key).lower() in _SECRET_KEY_NAMES and not (
|
|
133
|
+
isinstance(value, str) and _ENV_REFERENCE.match(value)
|
|
134
|
+
):
|
|
135
|
+
raise ConfigError(
|
|
136
|
+
f"Inline secret at {child!r} in {source}: use an environment"
|
|
137
|
+
" reference such as ${env:VAR} instead"
|
|
138
|
+
)
|
|
139
|
+
_reject_inline_secrets(value, source=source, key_path=child)
|
|
140
|
+
elif isinstance(node, list):
|
|
141
|
+
for index, item in enumerate(node):
|
|
142
|
+
_reject_inline_secrets(item, source=source, key_path=f"{key_path}[{index}]")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _validate[ModelT: BaseModel](model: type[ModelT], data: dict[str, Any], source: Path) -> ModelT:
|
|
146
|
+
"""Validate ``data`` into ``model``, converting pydantic errors to ConfigError."""
|
|
147
|
+
try:
|
|
148
|
+
return model.model_validate(data)
|
|
149
|
+
except ValidationError as exc:
|
|
150
|
+
raise ConfigError(f"Invalid configuration in {source}: {exc}") from exc
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _check_cross_references(config: HaruConfig) -> None:
|
|
154
|
+
"""Validate references that span include files (agents -> models/MCP)."""
|
|
155
|
+
if config.agents is None:
|
|
156
|
+
return
|
|
157
|
+
for name, agent in config.agents.agents.items():
|
|
158
|
+
if config.models is not None and agent.model not in config.models.models:
|
|
159
|
+
raise ConfigError(f"Agent {name!r} references unknown model {agent.model!r}")
|
|
160
|
+
if config.mcp is not None:
|
|
161
|
+
for server in agent.mcp_servers:
|
|
162
|
+
if server not in config.mcp.mcp_servers:
|
|
163
|
+
raise ConfigError(f"Agent {name!r} references unknown MCP server {server!r}")
|