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/config/schema.py ADDED
@@ -0,0 +1,261 @@
1
+ """Typed, immutable configuration schema for haru-cli.
2
+
3
+ Every YAML document under ``config/`` validates into one of these models at
4
+ load time. Models are frozen and reject unknown keys so configuration errors
5
+ surface immediately with the offending path.
6
+ """
7
+
8
+ from typing import Literal, Self
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
11
+
12
+
13
+ class _FrozenModel(BaseModel):
14
+ """Base for all configuration models: immutable, strict about keys."""
15
+
16
+ model_config = ConfigDict(frozen=True, extra="forbid", protected_namespaces=())
17
+
18
+
19
+ class AppConfig(_FrozenModel):
20
+ """Top-level application settings."""
21
+
22
+ name: str
23
+ default_command: str = "chat"
24
+
25
+
26
+ class SsoConfig(_FrozenModel):
27
+ """IAM Identity Center (SSO) login settings."""
28
+
29
+ start_url: str
30
+ sso_region: str
31
+ registration_scopes: tuple[str, ...] = ("sso:account:access",)
32
+ client_name: str = "haru-cli"
33
+ account_id_env: str
34
+ role_name: str
35
+ callback_port: int = Field(default=0, ge=0, le=65535)
36
+ browser: bool = True
37
+
38
+
39
+ class AuthConfig(_FrozenModel):
40
+ """Authentication and AWS session settings."""
41
+
42
+ sso: SsoConfig
43
+ bedrock_region: str
44
+
45
+
46
+ class IncludesConfig(_FrozenModel):
47
+ """Relative paths of the include files that complete the configuration."""
48
+
49
+ models: str | None = None
50
+ agents: str | None = None
51
+ mcp: str | None = None
52
+ guardrails: str | None = None
53
+ logging: str | None = None
54
+
55
+
56
+ class ModelConfig(_FrozenModel):
57
+ """A single Bedrock model entry."""
58
+
59
+ model_id: str
60
+ region: str
61
+ max_tokens: int = Field(ge=1)
62
+ temperature: float = Field(ge=0.0, le=1.0)
63
+ streaming: bool = True
64
+
65
+
66
+ class ModelsConfig(_FrozenModel):
67
+ """The model catalogue and the default model key."""
68
+
69
+ default_model: str
70
+ models: dict[str, ModelConfig]
71
+
72
+ @model_validator(mode="after")
73
+ def _default_model_exists(self) -> Self:
74
+ if self.default_model not in self.models:
75
+ raise ValueError(f"default_model {self.default_model!r} is not a configured model")
76
+ return self
77
+
78
+
79
+ class AgentConfig(_FrozenModel):
80
+ """A single agent definition."""
81
+
82
+ model: str
83
+ system_prompt_ref: str | None = None
84
+ tools: tuple[str, ...] = ()
85
+ mcp_servers: tuple[str, ...] = ()
86
+
87
+
88
+ class SwarmConfig(_FrozenModel):
89
+ """Swarm orchestration settings."""
90
+
91
+ members: tuple[str, ...]
92
+ max_handoffs: int = Field(default=8, ge=1)
93
+ execution_timeout_seconds: int = Field(default=300, ge=1)
94
+
95
+
96
+ class GraphNode(_FrozenModel):
97
+ """A node in a graph orchestration."""
98
+
99
+ id: str
100
+ agent: str
101
+
102
+
103
+ class GraphEdge(_FrozenModel):
104
+ """A directed edge in a graph orchestration."""
105
+
106
+ source: str = Field(alias="from")
107
+ target: str = Field(alias="to")
108
+
109
+
110
+ class GraphConfig(_FrozenModel):
111
+ """Graph orchestration settings."""
112
+
113
+ nodes: tuple[GraphNode, ...]
114
+ edges: tuple[GraphEdge, ...]
115
+
116
+ @model_validator(mode="after")
117
+ def _edges_reference_nodes(self) -> Self:
118
+ node_ids = {node.id for node in self.nodes}
119
+ for edge in self.edges:
120
+ for endpoint in (edge.source, edge.target):
121
+ if endpoint not in node_ids:
122
+ raise ValueError(f"graph edge references unknown node {endpoint!r}")
123
+ return self
124
+
125
+
126
+ class OrchestrationConfig(_FrozenModel):
127
+ """Multi-agent orchestration settings."""
128
+
129
+ default_pattern: Literal["supervisor", "swarm", "graph"] = "supervisor"
130
+ swarm: SwarmConfig | None = None
131
+ graph: GraphConfig | None = None
132
+
133
+
134
+ class AgentsConfig(_FrozenModel):
135
+ """The agent roster and orchestration configuration."""
136
+
137
+ agents: dict[str, AgentConfig]
138
+ orchestration: OrchestrationConfig | None = None
139
+
140
+ @model_validator(mode="after")
141
+ def _references_are_defined(self) -> Self:
142
+ if self.orchestration is None:
143
+ return self
144
+ if self.orchestration.swarm is not None:
145
+ for member in self.orchestration.swarm.members:
146
+ if member not in self.agents:
147
+ raise ValueError(f"swarm member {member!r} is not a configured agent")
148
+ if self.orchestration.graph is not None:
149
+ for node in self.orchestration.graph.nodes:
150
+ if node.agent not in self.agents:
151
+ raise ValueError(f"graph node {node.id!r} references unknown agent")
152
+ return self
153
+
154
+
155
+ class MCPServerConfig(_FrozenModel):
156
+ """A single MCP server connection."""
157
+
158
+ transport: Literal["stdio", "streamable-http"]
159
+ command: str | None = None
160
+ args: tuple[str, ...] = ()
161
+ url: str | None = None
162
+ headers: dict[str, str] = Field(default_factory=dict)
163
+ disabled: bool = False
164
+ continue_on_error: bool = False
165
+
166
+ @model_validator(mode="after")
167
+ def _transport_fields(self) -> Self:
168
+ if self.transport == "stdio" and self.command is None:
169
+ raise ValueError("stdio MCP servers require a 'command'")
170
+ if self.transport == "streamable-http" and self.url is None:
171
+ raise ValueError("streamable-http MCP servers require a 'url'")
172
+ return self
173
+
174
+
175
+ class MCPConfig(_FrozenModel):
176
+ """The MCP server registry."""
177
+
178
+ mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
179
+
180
+
181
+ class SessionsConfig(_FrozenModel):
182
+ """Conversation persistence settings.
183
+
184
+ The file backend always uses an explicit project-local directory; the OS
185
+ temp directory is never used.
186
+ """
187
+
188
+ backend: Literal["file", "s3"] = "file"
189
+ storage_dir: str = "./.haru/sessions"
190
+ bucket: str | None = None
191
+ prefix: str = "haru/sessions"
192
+ region: str | None = None
193
+
194
+ @model_validator(mode="after")
195
+ def _s3_requires_bucket(self) -> Self:
196
+ if self.backend == "s3" and self.bucket is None:
197
+ raise ValueError("s3 session backend requires a 'bucket'")
198
+ return self
199
+
200
+
201
+ class GuardrailsConfig(_FrozenModel):
202
+ """Bedrock Guardrails settings."""
203
+
204
+ enabled: bool = True
205
+ guardrail_id: str | None = None
206
+ guardrail_version: str = "DRAFT"
207
+ trace: Literal["enabled", "disabled", "enabled_full"] = "enabled"
208
+ redact_input: bool = True
209
+ redact_input_message: str = "[redacted]"
210
+ redact_output: bool = False
211
+
212
+
213
+ class LoggingConfig(_FrozenModel):
214
+ """Application logging settings."""
215
+
216
+ level: str = "INFO"
217
+ format: Literal["json", "text"] = "json"
218
+ file: str | None = None
219
+
220
+
221
+ class OtelConfig(_FrozenModel):
222
+ """OpenTelemetry exporter settings."""
223
+
224
+ enabled: bool = False
225
+ endpoint: str | None = None
226
+ service_name: str = "haru-cli"
227
+ console_export: bool = False
228
+
229
+
230
+ class ObservabilityConfig(_FrozenModel):
231
+ """Observability settings."""
232
+
233
+ otel: OtelConfig
234
+
235
+
236
+ class GuardrailsFile(_FrozenModel):
237
+ """Shape of the guardrails include file."""
238
+
239
+ guardrails: GuardrailsConfig
240
+
241
+
242
+ class LoggingFile(_FrozenModel):
243
+ """Shape of the logging include file."""
244
+
245
+ logging: LoggingConfig
246
+ observability: ObservabilityConfig
247
+
248
+
249
+ class HaruConfig(_FrozenModel):
250
+ """The complete haru-cli configuration after includes are resolved."""
251
+
252
+ app: AppConfig
253
+ auth: AuthConfig
254
+ sessions: SessionsConfig | None = None
255
+ includes: IncludesConfig | None = None
256
+ models: ModelsConfig | None = None
257
+ agents: AgentsConfig | None = None
258
+ mcp: MCPConfig | None = None
259
+ guardrails: GuardrailsConfig | None = None
260
+ logging: LoggingConfig | None = None
261
+ observability: ObservabilityConfig | None = None
haru/errors.py ADDED
@@ -0,0 +1,25 @@
1
+ """Typed exceptions for haru-cli.
2
+
3
+ Third-party errors are converted to these types at module boundaries so that
4
+ callers only ever handle haru-cli exceptions.
5
+ """
6
+
7
+
8
+ class HaruError(Exception):
9
+ """Base class for all haru-cli errors."""
10
+
11
+
12
+ class ConfigError(HaruError):
13
+ """Raised when configuration is missing, malformed, or insecure."""
14
+
15
+
16
+ class AuthError(HaruError):
17
+ """Raised when authentication fails or is misconfigured."""
18
+
19
+
20
+ class AuthExpiredError(AuthError):
21
+ """Raised when cached credentials are missing or expired; re-login is required."""
22
+
23
+
24
+ class ToolError(HaruError):
25
+ """Raised when a tool or MCP server cannot be constructed or listed."""
@@ -0,0 +1,5 @@
1
+ """Strands BedrockModel factories."""
2
+
3
+ from haru.models.bedrock import build_model, get_model_config, list_models, resolve_model_id
4
+
5
+ __all__ = ["build_model", "get_model_config", "list_models", "resolve_model_id"]
haru/models/bedrock.py ADDED
@@ -0,0 +1,77 @@
1
+ """Build Strands BedrockModel instances from typed configuration.
2
+
3
+ Model transport stays entirely inside Strands; this module only maps validated
4
+ configuration onto ``BedrockModel`` construction. Bare model identifiers get
5
+ the ``us.`` geographic inference-profile prefix so data residency defaults to
6
+ the US; any explicitly configured prefix (including ``global.``) is respected
7
+ as written, since configuration is the approval surface.
8
+ """
9
+
10
+ import boto3
11
+ from strands.models import BedrockModel
12
+
13
+ from haru.config.schema import GuardrailsConfig, HaruConfig, ModelConfig, ModelsConfig
14
+ from haru.errors import ConfigError
15
+ from haru.observability.guardrails import apply_guardrail
16
+
17
+ _GEO_PREFIXES = ("us.", "eu.", "ap.", "apac.", "au.", "jp.", "global.")
18
+ _DEFAULT_GEO_PREFIX = "us."
19
+
20
+
21
+ def resolve_model_id(model_id: str) -> str:
22
+ """Return ``model_id`` with the default ``us.`` prefix applied when bare.
23
+
24
+ Identifiers that already carry a geographic prefix, or that are full ARNs,
25
+ are returned unchanged.
26
+ """
27
+ if model_id.startswith("arn:") or model_id.startswith(_GEO_PREFIXES):
28
+ return model_id
29
+ return f"{_DEFAULT_GEO_PREFIX}{model_id}"
30
+
31
+
32
+ def build_model(
33
+ model_cfg: ModelConfig,
34
+ session: boto3.Session,
35
+ guardrails: GuardrailsConfig | None = None,
36
+ ) -> BedrockModel:
37
+ """Build a Strands BedrockModel from ``model_cfg`` and a boto3 session.
38
+
39
+ When ``guardrails`` is enabled, the Bedrock Guardrails parameters are
40
+ attached to the model.
41
+ """
42
+ return BedrockModel(
43
+ boto_session=session,
44
+ region_name=model_cfg.region,
45
+ model_id=resolve_model_id(model_cfg.model_id),
46
+ max_tokens=model_cfg.max_tokens,
47
+ temperature=model_cfg.temperature,
48
+ streaming=model_cfg.streaming,
49
+ # Rich tool schemas can fail ConverseStream's strict validation.
50
+ strict_tools=False,
51
+ **apply_guardrail(model_cfg, guardrails),
52
+ )
53
+
54
+
55
+ def get_model_config(config: HaruConfig, name: str | None = None) -> ModelConfig:
56
+ """Return the model entry for ``name`` (or the configured default).
57
+
58
+ Raises ConfigError when no models are configured or the key is unknown.
59
+ """
60
+ models = _require_models(config)
61
+ key = name if name is not None else models.default_model
62
+ entry = models.models.get(key)
63
+ if entry is None:
64
+ available = ", ".join(sorted(models.models))
65
+ raise ConfigError(f"Unknown model {key!r}; configured models: {available}")
66
+ return entry
67
+
68
+
69
+ def list_models(config: HaruConfig) -> list[str]:
70
+ """Return the configured model keys, sorted."""
71
+ return sorted(_require_models(config).models)
72
+
73
+
74
+ def _require_models(config: HaruConfig) -> ModelsConfig:
75
+ if config.models is None:
76
+ raise ConfigError("No models configured; add a models include to config/haru.yaml")
77
+ return config.models
@@ -0,0 +1,6 @@
1
+ """OpenTelemetry configuration and Bedrock Guardrails mapping."""
2
+
3
+ from haru.observability.guardrails import apply_guardrail
4
+ from haru.observability.telemetry import configure_telemetry
5
+
6
+ __all__ = ["apply_guardrail", "configure_telemetry"]
@@ -0,0 +1,36 @@
1
+ """Map guardrail configuration onto Strands BedrockModel keyword arguments.
2
+
3
+ Guardrails fail closed: when enabled, a guardrail id is mandatory, and input
4
+ redaction defaults on. Disabling guardrails must be an explicit configuration
5
+ choice, never a fallback.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from haru.config.schema import GuardrailsConfig, ModelConfig
11
+ from haru.errors import ConfigError
12
+
13
+
14
+ def apply_guardrail(
15
+ model_cfg: ModelConfig, guardrail_cfg: GuardrailsConfig | None
16
+ ) -> dict[str, Any]:
17
+ """Return the guardrail kwargs for BedrockModel construction.
18
+
19
+ Returns an empty mapping when guardrails are absent or explicitly
20
+ disabled. Raises ConfigError when guardrails are enabled without an id.
21
+ """
22
+ if guardrail_cfg is None or not guardrail_cfg.enabled:
23
+ return {}
24
+ if not guardrail_cfg.guardrail_id:
25
+ raise ConfigError(
26
+ f"Guardrails are enabled but no guardrail_id is configured"
27
+ f" (model {model_cfg.model_id!r}); set guardrails.guardrail_id"
28
+ )
29
+ return {
30
+ "guardrail_id": guardrail_cfg.guardrail_id,
31
+ "guardrail_version": guardrail_cfg.guardrail_version,
32
+ "guardrail_trace": guardrail_cfg.trace,
33
+ "guardrail_redact_input": guardrail_cfg.redact_input,
34
+ "guardrail_redact_input_message": guardrail_cfg.redact_input_message,
35
+ "guardrail_redact_output": guardrail_cfg.redact_output,
36
+ }
@@ -0,0 +1,34 @@
1
+ """Configure OpenTelemetry tracing via the Strands telemetry helper.
2
+
3
+ Telemetry is a strict no-op when disabled; when enabled, the OTLP exporter is
4
+ initialised through ``StrandsTelemetry``, with the endpoint and service name
5
+ published via the standard OTel environment variables.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+
11
+ from strands.telemetry import StrandsTelemetry
12
+
13
+ from haru.config.schema import ObservabilityConfig
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _OTEL_ENDPOINT_ENV = "OTEL_EXPORTER_OTLP_ENDPOINT"
18
+ _OTEL_SERVICE_NAME_ENV = "OTEL_SERVICE_NAME"
19
+
20
+
21
+ def configure_telemetry(obs_cfg: ObservabilityConfig | None) -> None:
22
+ """Initialise OTel exporters when enabled; do nothing when disabled."""
23
+ if obs_cfg is None or not obs_cfg.otel.enabled:
24
+ return
25
+ otel = obs_cfg.otel
26
+ if otel.endpoint is not None:
27
+ os.environ.setdefault(_OTEL_ENDPOINT_ENV, otel.endpoint)
28
+ os.environ.setdefault(_OTEL_SERVICE_NAME_ENV, otel.service_name)
29
+
30
+ telemetry = StrandsTelemetry()
31
+ telemetry.setup_otlp_exporter()
32
+ if otel.console_export:
33
+ telemetry.setup_console_exporter()
34
+ logger.debug("OpenTelemetry configured (service=%s)", otel.service_name)
@@ -0,0 +1,5 @@
1
+ """Conversation session persistence."""
2
+
3
+ from haru.sessions.manager import build_session_manager, list_sessions
4
+
5
+ __all__ = ["build_session_manager", "list_sessions"]
@@ -0,0 +1,70 @@
1
+ """Build Strands session managers from typed configuration.
2
+
3
+ The file backend persists to an explicit project-local directory
4
+ (``./.haru/sessions`` by default) — never the OS temp directory. The S3
5
+ backend stores sessions under a configurable bucket and prefix.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ import boto3
11
+ from strands.session import FileSessionManager, S3SessionManager
12
+ from strands.session.session_manager import SessionManager
13
+
14
+ from haru.config.schema import HaruConfig, SessionsConfig
15
+
16
+ _SESSION_DIR_PREFIX = "session_"
17
+
18
+ _DEFAULT_SESSIONS = SessionsConfig()
19
+
20
+
21
+ def build_session_manager(
22
+ config: HaruConfig, session_id: str, *, boto_session: boto3.Session | None = None
23
+ ) -> SessionManager:
24
+ """Build the configured session manager (file by default) for ``session_id``."""
25
+ sessions = config.sessions if config.sessions is not None else _DEFAULT_SESSIONS
26
+ if sessions.backend == "s3":
27
+ return S3SessionManager(
28
+ session_id,
29
+ bucket=str(sessions.bucket),
30
+ prefix=sessions.prefix,
31
+ boto_session=boto_session,
32
+ region_name=sessions.region,
33
+ )
34
+ storage_dir = Path(sessions.storage_dir)
35
+ storage_dir.mkdir(parents=True, exist_ok=True)
36
+ return FileSessionManager(session_id, storage_dir=str(storage_dir))
37
+
38
+
39
+ def list_sessions(
40
+ config: HaruConfig | None = None, *, boto_session: boto3.Session | None = None
41
+ ) -> list[str]:
42
+ """Return the stored session ids for the configured backend, sorted."""
43
+ sessions = (
44
+ config.sessions if config is not None and config.sessions is not None else _DEFAULT_SESSIONS
45
+ )
46
+ if sessions.backend == "s3":
47
+ return _list_s3_sessions(sessions, boto_session)
48
+ storage_dir = Path(sessions.storage_dir)
49
+ if not storage_dir.is_dir():
50
+ return []
51
+ return sorted(
52
+ entry.name.removeprefix(_SESSION_DIR_PREFIX)
53
+ for entry in storage_dir.iterdir()
54
+ if entry.is_dir() and entry.name.startswith(_SESSION_DIR_PREFIX)
55
+ )
56
+
57
+
58
+ def _list_s3_sessions(sessions: SessionsConfig, boto_session: boto3.Session | None) -> list[str]:
59
+ session = boto_session if boto_session is not None else boto3.Session()
60
+ client = session.client("s3", region_name=sessions.region)
61
+ prefix = f"{sessions.prefix.rstrip('/')}/{_SESSION_DIR_PREFIX}" if sessions.prefix else ""
62
+ found: set[str] = set()
63
+ paginator = client.get_paginator("list_objects_v2")
64
+ for page in paginator.paginate(Bucket=str(sessions.bucket), Prefix=prefix):
65
+ for item in page.get("Contents", []):
66
+ remainder = item["Key"].removeprefix(prefix)
67
+ session_id = remainder.split("/", 1)[0]
68
+ if session_id:
69
+ found.add(session_id)
70
+ return sorted(found)
@@ -0,0 +1,5 @@
1
+ """Reusable system prompt management."""
2
+
3
+ from haru.steering.prompts import load_prompts, resolve_prompt
4
+
5
+ __all__ = ["load_prompts", "resolve_prompt"]
@@ -0,0 +1,42 @@
1
+ """Load and resolve reusable system prompts.
2
+
3
+ Prompts live in versioned markdown files under ``config/prompts/`` and are
4
+ referenced by agents via ``system_prompt_ref`` — never inlined in code. A ref
5
+ may compose several prompts with ``+`` (for example ``base+researcher``),
6
+ which concatenates them in order: a shared base plus a role overlay.
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ from haru.errors import ConfigError
12
+
13
+ DEFAULT_PROMPTS_ROOT = Path("config") / "prompts"
14
+
15
+ _COMPOSE_SEPARATOR = "+"
16
+
17
+
18
+ def load_prompts(root: Path) -> dict[str, str]:
19
+ """Load every ``*.md`` prompt under ``root``, keyed by file stem."""
20
+ if not root.is_dir():
21
+ raise ConfigError(f"Prompts directory not found: {root}")
22
+ return {
23
+ path.stem: path.read_text(encoding="utf-8").strip() for path in sorted(root.glob("*.md"))
24
+ }
25
+
26
+
27
+ def resolve_prompt(ref: str, prompts: dict[str, str]) -> str:
28
+ """Resolve ``ref`` (optionally composite, ``a+b``) to prompt text.
29
+
30
+ Raises ConfigError when any referenced prompt is missing.
31
+ """
32
+ parts = [part.strip() for part in ref.split(_COMPOSE_SEPARATOR)]
33
+ if not all(parts):
34
+ raise ConfigError(f"Malformed prompt reference {ref!r}")
35
+ missing = [part for part in parts if part not in prompts]
36
+ if missing:
37
+ available = ", ".join(sorted(prompts)) or "none"
38
+ raise ConfigError(
39
+ f"Unknown prompt reference(s) {', '.join(missing)!s} in {ref!r};"
40
+ f" available prompts: {available}"
41
+ )
42
+ return "\n\n".join(prompts[part] for part in parts)
haru/tools/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Built-in tool registry and MCP client construction."""
2
+
3
+ from haru.tools.mcp import build_mcp_clients, collect_tools, started_mcp_clients
4
+ from haru.tools.registry import available_builtin_tools, resolve_builtin_tools
5
+
6
+ __all__ = [
7
+ "available_builtin_tools",
8
+ "build_mcp_clients",
9
+ "collect_tools",
10
+ "resolve_builtin_tools",
11
+ "started_mcp_clients",
12
+ ]