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/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."""
|
haru/models/__init__.py
ADDED
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,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)
|
haru/sessions/manager.py
ADDED
|
@@ -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)
|
haru/steering/prompts.py
ADDED
|
@@ -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
|
+
]
|