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 ADDED
@@ -0,0 +1 @@
1
+ """haru: a governed CLI for Amazon Bedrock agents built on the Strands Agents SDK."""
haru/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Module entry point for ``python -m haru`` and the ``haru`` console script."""
2
+
3
+ from haru.cli import cli
4
+
5
+
6
+ def main() -> None:
7
+ """Run the haru CLI."""
8
+ cli()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
@@ -0,0 +1,18 @@
1
+ """Agent factories and multi-agent orchestration."""
2
+
3
+ from haru.agents.factory import build_agent, resolve_agent_parts
4
+ from haru.agents.orchestration import (
5
+ build_graph,
6
+ build_supervisor,
7
+ build_swarm,
8
+ run_orchestration,
9
+ )
10
+
11
+ __all__ = [
12
+ "build_agent",
13
+ "build_graph",
14
+ "build_supervisor",
15
+ "build_swarm",
16
+ "resolve_agent_parts",
17
+ "run_orchestration",
18
+ ]
haru/agents/factory.py ADDED
@@ -0,0 +1,76 @@
1
+ """Strands agent factories built from typed configuration.
2
+
3
+ Factories return fully-wired Strands agents: model, steering prompt, built-in
4
+ tools, and MCP tools. Multi-agent orchestration composes these in
5
+ ``haru.agents.orchestration``.
6
+ """
7
+
8
+ from collections.abc import Mapping
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import boto3
13
+ from strands import Agent
14
+ from strands.models import BedrockModel
15
+ from strands.session.session_manager import SessionManager
16
+ from strands.tools.mcp import MCPClient
17
+
18
+ from haru.config.schema import HaruConfig
19
+ from haru.errors import ConfigError
20
+ from haru.models.bedrock import build_model, get_model_config
21
+ from haru.steering.prompts import DEFAULT_PROMPTS_ROOT, load_prompts, resolve_prompt
22
+ from haru.tools.mcp import collect_tools
23
+
24
+
25
+ def build_agent( # noqa: PLR0913 - keyword-only wiring points, all optional
26
+ config: HaruConfig,
27
+ agent_name: str | None,
28
+ session: boto3.Session,
29
+ *,
30
+ prompts_root: Path | None = None,
31
+ mcp_clients: Mapping[str, MCPClient] | None = None,
32
+ session_manager: SessionManager | None = None,
33
+ ) -> Agent:
34
+ """Build the named agent from configuration (default model when unnamed).
35
+
36
+ Raises ConfigError for unknown agents, prompt references, or tools.
37
+ """
38
+ if agent_name is None:
39
+ model = build_model(get_model_config(config), session, guardrails=config.guardrails)
40
+ return Agent(model=model, session_manager=session_manager)
41
+ model, system_prompt, tools = resolve_agent_parts(
42
+ config, agent_name, session, prompts_root=prompts_root, mcp_clients=mcp_clients
43
+ )
44
+ return Agent(
45
+ model=model,
46
+ system_prompt=system_prompt,
47
+ tools=tools,
48
+ name=agent_name,
49
+ session_manager=session_manager,
50
+ )
51
+
52
+
53
+ def resolve_agent_parts(
54
+ config: HaruConfig,
55
+ agent_name: str,
56
+ session: boto3.Session,
57
+ *,
58
+ prompts_root: Path | None = None,
59
+ mcp_clients: Mapping[str, MCPClient] | None = None,
60
+ ) -> tuple[BedrockModel, str | None, list[Any]]:
61
+ """Resolve an agent's model, steering prompt, and tool list from config."""
62
+ if config.agents is None or agent_name not in config.agents.agents:
63
+ available = ", ".join(sorted(config.agents.agents)) if config.agents else "none"
64
+ raise ConfigError(f"Unknown agent {agent_name!r}; configured agents: {available}")
65
+ agent_cfg = config.agents.agents[agent_name]
66
+ model = build_model(
67
+ get_model_config(config, agent_cfg.model), session, guardrails=config.guardrails
68
+ )
69
+
70
+ system_prompt: str | None = None
71
+ if agent_cfg.system_prompt_ref is not None:
72
+ root = prompts_root if prompts_root is not None else DEFAULT_PROMPTS_ROOT
73
+ system_prompt = resolve_prompt(agent_cfg.system_prompt_ref, load_prompts(root))
74
+
75
+ tools = collect_tools(agent_cfg, None, mcp_clients or {}, mcp_cfg=config.mcp)
76
+ return model, system_prompt, tools
@@ -0,0 +1,169 @@
1
+ """Multi-agent orchestration: supervisor (agents-as-tools), swarm, and graph.
2
+
3
+ Each pattern is wired from the ``orchestration`` section of the agents
4
+ configuration; the agent loop, handoffs, and graph execution are delegated
5
+ entirely to Strands.
6
+ """
7
+
8
+ from collections.abc import Mapping
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import boto3
13
+ from strands import Agent, tool
14
+ from strands.multiagent import GraphBuilder, Swarm
15
+ from strands.multiagent.graph import Graph
16
+ from strands.tools.mcp import MCPClient
17
+
18
+ from haru.agents.factory import build_agent, resolve_agent_parts
19
+ from haru.auth.session import build_boto3_session
20
+ from haru.config.schema import AgentsConfig, HaruConfig, OrchestrationConfig
21
+ from haru.errors import ConfigError
22
+
23
+ SUPERVISOR_AGENT_NAME = "supervisor"
24
+
25
+
26
+ def build_supervisor(
27
+ config: HaruConfig,
28
+ session: boto3.Session,
29
+ *,
30
+ prompts_root: Path | None = None,
31
+ mcp_clients: Mapping[str, MCPClient] | None = None,
32
+ ) -> Agent:
33
+ """Build the supervisor agent with every other agent exposed as a tool."""
34
+ agents_cfg = _require_agents(config)
35
+ if SUPERVISOR_AGENT_NAME not in agents_cfg.agents:
36
+ raise ConfigError(
37
+ f"Supervisor orchestration requires an agent named {SUPERVISOR_AGENT_NAME!r}"
38
+ )
39
+ model, system_prompt, tools = resolve_agent_parts(
40
+ config, SUPERVISOR_AGENT_NAME, session, prompts_root=prompts_root, mcp_clients=mcp_clients
41
+ )
42
+ worker_tools = [
43
+ _agent_as_tool(
44
+ name,
45
+ build_agent(config, name, session, prompts_root=prompts_root, mcp_clients=mcp_clients),
46
+ )
47
+ for name in sorted(agents_cfg.agents)
48
+ if name != SUPERVISOR_AGENT_NAME
49
+ ]
50
+ return Agent(
51
+ model=model,
52
+ system_prompt=system_prompt,
53
+ tools=[*tools, *worker_tools],
54
+ name=SUPERVISOR_AGENT_NAME,
55
+ )
56
+
57
+
58
+ def build_swarm(
59
+ config: HaruConfig,
60
+ session: boto3.Session,
61
+ *,
62
+ prompts_root: Path | None = None,
63
+ mcp_clients: Mapping[str, MCPClient] | None = None,
64
+ ) -> Swarm:
65
+ """Build a Swarm over the configured member agents."""
66
+ orchestration = _require_orchestration(config)
67
+ if orchestration.swarm is None:
68
+ raise ConfigError("Swarm orchestration requested but no swarm section is configured")
69
+ members = [
70
+ build_agent(config, name, session, prompts_root=prompts_root, mcp_clients=mcp_clients)
71
+ for name in orchestration.swarm.members
72
+ ]
73
+ return Swarm(
74
+ members,
75
+ max_handoffs=orchestration.swarm.max_handoffs,
76
+ execution_timeout=float(orchestration.swarm.execution_timeout_seconds),
77
+ )
78
+
79
+
80
+ def build_graph(
81
+ config: HaruConfig,
82
+ session: boto3.Session,
83
+ *,
84
+ prompts_root: Path | None = None,
85
+ mcp_clients: Mapping[str, MCPClient] | None = None,
86
+ ) -> Graph:
87
+ """Build a Graph from the configured nodes and edges via GraphBuilder."""
88
+ orchestration = _require_orchestration(config)
89
+ if orchestration.graph is None:
90
+ raise ConfigError("Graph orchestration requested but no graph section is configured")
91
+ builder = GraphBuilder()
92
+ for node in orchestration.graph.nodes:
93
+ builder.add_node(
94
+ build_agent(
95
+ config, node.agent, session, prompts_root=prompts_root, mcp_clients=mcp_clients
96
+ ),
97
+ node.id,
98
+ )
99
+ for edge in orchestration.graph.edges:
100
+ builder.add_edge(edge.source, edge.target)
101
+ return builder.build()
102
+
103
+
104
+ def run_orchestration( # noqa: PLR0913 - three keyword-only injection points for tests/callers
105
+ config: HaruConfig,
106
+ prompt: str,
107
+ pattern: str | None = None,
108
+ *,
109
+ session: boto3.Session | None = None,
110
+ prompts_root: Path | None = None,
111
+ mcp_clients: Mapping[str, MCPClient] | None = None,
112
+ ) -> str:
113
+ """Run ``prompt`` through the requested (or default) orchestration pattern."""
114
+ orchestration = _require_agents(config).orchestration
115
+ selected = pattern if pattern is not None else _default_pattern(orchestration)
116
+ if session is None:
117
+ session = build_boto3_session(config.auth)
118
+
119
+ if selected == "supervisor":
120
+ supervisor = build_supervisor(
121
+ config, session, prompts_root=prompts_root, mcp_clients=mcp_clients
122
+ )
123
+ return str(supervisor(prompt))
124
+ if selected == "swarm":
125
+ swarm = build_swarm(config, session, prompts_root=prompts_root, mcp_clients=mcp_clients)
126
+ return _final_text(swarm(prompt))
127
+ if selected == "graph":
128
+ graph = build_graph(config, session, prompts_root=prompts_root, mcp_clients=mcp_clients)
129
+ return _final_text(graph(prompt))
130
+ raise ConfigError(f"Unknown orchestration pattern {selected!r}")
131
+
132
+
133
+ def _default_pattern(orchestration: OrchestrationConfig | None) -> str:
134
+ return orchestration.default_pattern if orchestration is not None else "supervisor"
135
+
136
+
137
+ def _agent_as_tool(name: str, agent: Agent) -> Any:
138
+ """Expose ``agent`` as a tool the supervisor can delegate tasks to."""
139
+
140
+ @tool(name=name, description=f"Delegate a task to the {name} specialist agent.")
141
+ def delegate(task: str) -> str:
142
+ """Send a task to the specialist agent and return its answer."""
143
+ return str(agent(task))
144
+
145
+ return delegate
146
+
147
+
148
+ def _final_text(result: Any) -> str:
149
+ """Extract the final node's text from a swarm or graph result."""
150
+ order = getattr(result, "node_history", None) or getattr(result, "execution_order", None)
151
+ if order:
152
+ node_id = getattr(order[-1], "node_id", order[-1])
153
+ node_result = result.results.get(node_id)
154
+ if node_result is not None:
155
+ return str(node_result.result)
156
+ return "\n".join(str(node.result) for node in result.results.values())
157
+
158
+
159
+ def _require_agents(config: HaruConfig) -> AgentsConfig:
160
+ if config.agents is None:
161
+ raise ConfigError("Orchestration requires an agents section in configuration")
162
+ return config.agents
163
+
164
+
165
+ def _require_orchestration(config: HaruConfig) -> OrchestrationConfig:
166
+ orchestration = _require_agents(config).orchestration
167
+ if orchestration is None:
168
+ raise ConfigError("No orchestration section configured")
169
+ return orchestration
haru/auth/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """IAM Identity Center authentication: PKCE login, token cache, boto3 sessions."""
2
+
3
+ from haru.auth.cache import read_token_cache, write_token_cache
4
+ from haru.auth.pkce import generate_pkce_pair
5
+ from haru.auth.session import build_boto3_session
6
+ from haru.auth.sso import run_login
7
+
8
+ __all__ = [
9
+ "build_boto3_session",
10
+ "generate_pkce_pair",
11
+ "read_token_cache",
12
+ "run_login",
13
+ "write_token_cache",
14
+ ]
haru/auth/cache.py ADDED
@@ -0,0 +1,82 @@
1
+ """Botocore-compatible SSO token cache under ``~/.aws/sso/cache``.
2
+
3
+ The cache file name is the SHA-1 of the start URL, and the JSON schema matches
4
+ what botocore's ``SSOTokenProvider`` reads, so boto3 tooling can consume and
5
+ refresh the same cache. Files are written with 0600 permissions.
6
+ """
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+
14
+ from haru.auth.sso import ClientRegistration, SsoToken
15
+ from haru.errors import AuthError
16
+
17
+ _DEFAULT_CACHE_DIR = Path("~") / ".aws" / "sso" / "cache"
18
+ _TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
19
+
20
+
21
+ def cache_path(start_url: str, cache_dir: Path | None = None) -> Path:
22
+ """Return the cache file path for ``start_url`` (SHA-1 hex name, .json)."""
23
+ directory = cache_dir if cache_dir is not None else _DEFAULT_CACHE_DIR.expanduser()
24
+ digest = hashlib.sha1(start_url.encode("utf-8"), usedforsecurity=False).hexdigest()
25
+ return directory / f"{digest}.json"
26
+
27
+
28
+ def write_token_cache(
29
+ token: SsoToken, start_url: str, region: str, cache_dir: Path | None = None
30
+ ) -> Path:
31
+ """Write ``token`` to the botocore-compatible cache and return the path."""
32
+ path = cache_path(start_url, cache_dir)
33
+ payload: dict[str, str] = {
34
+ "startUrl": start_url,
35
+ "region": region,
36
+ "accessToken": token.access_token,
37
+ "expiresAt": _format_timestamp(token.expires_at),
38
+ "clientId": token.registration.client_id,
39
+ "clientSecret": token.registration.client_secret,
40
+ "registrationExpiresAt": _format_timestamp(token.registration.expires_at),
41
+ }
42
+ if token.refresh_token is not None:
43
+ payload["refreshToken"] = token.refresh_token
44
+
45
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
46
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
47
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
48
+ json.dump(payload, handle)
49
+ path.chmod(0o600)
50
+ return path
51
+
52
+
53
+ def read_token_cache(start_url: str, cache_dir: Path | None = None) -> SsoToken | None:
54
+ """Read the cached token for ``start_url``; return None when absent."""
55
+ path = cache_path(start_url, cache_dir)
56
+ if not path.is_file():
57
+ return None
58
+ try:
59
+ payload = json.loads(path.read_text(encoding="utf-8"))
60
+ registration = ClientRegistration(
61
+ client_id=payload["clientId"],
62
+ client_secret=payload["clientSecret"],
63
+ expires_at=_parse_timestamp(payload["registrationExpiresAt"]),
64
+ )
65
+ return SsoToken(
66
+ access_token=payload["accessToken"],
67
+ refresh_token=payload.get("refreshToken"),
68
+ expires_at=_parse_timestamp(payload["expiresAt"]),
69
+ registration=registration,
70
+ )
71
+ except (json.JSONDecodeError, KeyError, ValueError) as exc:
72
+ raise AuthError(f"Corrupt SSO token cache at {path}; run 'haru login'") from exc
73
+
74
+
75
+ def _format_timestamp(value: datetime) -> str:
76
+ """Format a datetime as the UTC timestamp string botocore expects."""
77
+ return value.astimezone(UTC).strftime(_TIMESTAMP_FORMAT)
78
+
79
+
80
+ def _parse_timestamp(value: str) -> datetime:
81
+ """Parse a botocore UTC timestamp string."""
82
+ return datetime.strptime(value, _TIMESTAMP_FORMAT).replace(tzinfo=UTC)
haru/auth/pkce.py ADDED
@@ -0,0 +1,21 @@
1
+ """RFC 7636 PKCE helpers for the IAM Identity Center login flow."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import secrets
6
+
7
+
8
+ def generate_pkce_pair() -> tuple[str, str]:
9
+ """Return a ``(code_verifier, code_challenge)`` pair using the S256 method.
10
+
11
+ The verifier is 32 random bytes encoded as unpadded URL-safe base64; the
12
+ challenge is the unpadded URL-safe base64 SHA-256 of the verifier.
13
+ """
14
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode("ascii")
15
+ return verifier, code_challenge_from(verifier)
16
+
17
+
18
+ def code_challenge_from(verifier: str) -> str:
19
+ """Derive the S256 code challenge for ``verifier`` (RFC 7636, section 4.2)."""
20
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
21
+ return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
haru/auth/session.py ADDED
@@ -0,0 +1,95 @@
1
+ """Construct boto3 sessions from the cached SSO token, refreshing when needed."""
2
+
3
+ import os
4
+ from datetime import UTC, datetime, timedelta
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import boto3
9
+ from botocore.exceptions import (
10
+ ClientError,
11
+ SSOTokenLoadError,
12
+ TokenRetrievalError,
13
+ UnauthorizedSSOTokenError,
14
+ )
15
+
16
+ from haru.auth.cache import read_token_cache, write_token_cache
17
+ from haru.auth.sso import SsoToken
18
+ from haru.config.schema import AuthConfig
19
+ from haru.errors import AuthExpiredError, ConfigError
20
+
21
+ _REFRESH_WINDOW = timedelta(minutes=15)
22
+ _RELOGIN_HINT = "run 'haru login'"
23
+
24
+
25
+ def build_boto3_session(
26
+ config: AuthConfig,
27
+ *,
28
+ oidc: Any | None = None,
29
+ sso_client: Any | None = None,
30
+ cache_dir: Path | None = None,
31
+ ) -> boto3.Session:
32
+ """Build a boto3 session with role credentials from the cached SSO token.
33
+
34
+ Refreshes the access token via the ``refresh_token`` grant when it is
35
+ within the refresh window. Raises AuthExpiredError whenever a fresh
36
+ interactive login is required.
37
+ """
38
+ sso = config.sso
39
+ token = read_token_cache(sso.start_url, cache_dir=cache_dir)
40
+ if token is None:
41
+ raise AuthExpiredError(f"No cached SSO token; {_RELOGIN_HINT}")
42
+ if token.expires_at <= datetime.now(UTC) + _REFRESH_WINDOW:
43
+ token = _refresh_token(config, token, oidc=oidc, cache_dir=cache_dir)
44
+
45
+ account_id = os.environ.get(sso.account_id_env)
46
+ if account_id is None:
47
+ raise ConfigError(
48
+ f"Environment variable {sso.account_id_env!r} must hold the target AWS account id"
49
+ )
50
+ if sso_client is None:
51
+ sso_client = boto3.Session().client("sso", region_name=sso.sso_region)
52
+ try:
53
+ response = sso_client.get_role_credentials(
54
+ roleName=sso.role_name, accountId=account_id, accessToken=token.access_token
55
+ )
56
+ except (ClientError, SSOTokenLoadError, UnauthorizedSSOTokenError, TokenRetrievalError) as exc:
57
+ raise AuthExpiredError(f"SSO credentials rejected; {_RELOGIN_HINT}") from exc
58
+
59
+ credentials = response["roleCredentials"]
60
+ return boto3.Session(
61
+ aws_access_key_id=credentials["accessKeyId"],
62
+ aws_secret_access_key=credentials["secretAccessKey"],
63
+ aws_session_token=credentials["sessionToken"],
64
+ region_name=config.bedrock_region,
65
+ )
66
+
67
+
68
+ def _refresh_token(
69
+ config: AuthConfig, token: SsoToken, *, oidc: Any | None, cache_dir: Path | None
70
+ ) -> SsoToken:
71
+ """Refresh an expiring token via the ``refresh_token`` grant and re-cache it."""
72
+ sso = config.sso
73
+ now = datetime.now(UTC)
74
+ if token.refresh_token is None or token.registration.expires_at <= now:
75
+ raise AuthExpiredError(f"SSO token expired and cannot be refreshed; {_RELOGIN_HINT}")
76
+ if oidc is None:
77
+ oidc = boto3.Session().client("sso-oidc", region_name=sso.sso_region)
78
+ try:
79
+ response = oidc.create_token(
80
+ clientId=token.registration.client_id,
81
+ clientSecret=token.registration.client_secret,
82
+ grantType="refresh_token",
83
+ refreshToken=token.refresh_token,
84
+ )
85
+ except ClientError as exc:
86
+ raise AuthExpiredError(f"SSO token refresh failed; {_RELOGIN_HINT}") from exc
87
+
88
+ refreshed = SsoToken(
89
+ access_token=response["accessToken"],
90
+ refresh_token=response.get("refreshToken", token.refresh_token),
91
+ expires_at=now + timedelta(seconds=response["expiresIn"]),
92
+ registration=token.registration,
93
+ )
94
+ write_token_cache(refreshed, sso.start_url, sso.sso_region, cache_dir=cache_dir)
95
+ return refreshed