harnessweaver-cli 0.7.1__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.
- harnessweaver_cli/__init__.py +7 -0
- harnessweaver_cli/_load.py +119 -0
- harnessweaver_cli/_yaml.py +49 -0
- harnessweaver_cli/add.py +238 -0
- harnessweaver_cli/dev.py +141 -0
- harnessweaver_cli/doctor.py +185 -0
- harnessweaver_cli/evals.py +276 -0
- harnessweaver_cli/gateway.py +301 -0
- harnessweaver_cli/main.py +62 -0
- harnessweaver_cli/plugins.py +105 -0
- harnessweaver_cli/policy.py +143 -0
- harnessweaver_cli/py.typed +0 -0
- harnessweaver_cli/replay.py +75 -0
- harnessweaver_cli/scaffold.py +158 -0
- harnessweaver_cli/sidecar.py +48 -0
- harnessweaver_cli/spec.py +160 -0
- harnessweaver_cli/templates/_dev/compose.yaml +183 -0
- harnessweaver_cli/templates/_dev/otel-collector.yaml +25 -0
- harnessweaver_cli/templates/_plugin/README.md +26 -0
- harnessweaver_cli/templates/_plugin/pyproject.toml +26 -0
- harnessweaver_cli/templates/_plugin/src/__ns___harness/__init__.py +20 -0
- harnessweaver_cli/templates/_plugin/src/__ns___harness/guards.py +48 -0
- harnessweaver_cli/templates/_plugin/src/__ns___harness/py.typed +0 -0
- harnessweaver_cli/templates/_plugin/tests/test_guards.py +26 -0
- harnessweaver_cli/templates/agent/README.md +18 -0
- harnessweaver_cli/templates/agent/app/__init__.py +1 -0
- harnessweaver_cli/templates/agent/app/agent.py +12 -0
- harnessweaver_cli/templates/agent/app/main.py +19 -0
- harnessweaver_cli/templates/agent/app/tools.py +28 -0
- harnessweaver_cli/templates/agent/evals/redteam.yaml +17 -0
- harnessweaver_cli/templates/agent/harness.offline.yaml +16 -0
- harnessweaver_cli/templates/agent/harness.yaml +38 -0
- harnessweaver_cli/templates/agent/pyproject.toml +13 -0
- harnessweaver_cli/templates/agent/tests/test_agent.py +54 -0
- harnessweaver_cli/templates/agent/tools/lookup_order.tool.yaml +15 -0
- harnessweaver_cli/templates/agent/tools/refund_payment.tool.yaml +17 -0
- harnessweaver_cli/templates/chat/README.md +15 -0
- harnessweaver_cli/templates/chat/app/__init__.py +1 -0
- harnessweaver_cli/templates/chat/app/chat.py +32 -0
- harnessweaver_cli/templates/chat/app/main.py +33 -0
- harnessweaver_cli/templates/chat/evals/golden.yaml +10 -0
- harnessweaver_cli/templates/chat/harness.offline.yaml +12 -0
- harnessweaver_cli/templates/chat/harness.yaml +39 -0
- harnessweaver_cli/templates/chat/pyproject.toml +13 -0
- harnessweaver_cli/templates/chat/tests/test_chat.py +63 -0
- harnessweaver_cli/templates/extraction/README.md +16 -0
- harnessweaver_cli/templates/extraction/app/__init__.py +1 -0
- harnessweaver_cli/templates/extraction/app/main.py +21 -0
- harnessweaver_cli/templates/extraction/app/models.py +20 -0
- harnessweaver_cli/templates/extraction/evals/golden.yaml +12 -0
- harnessweaver_cli/templates/extraction/harness.offline.yaml +18 -0
- harnessweaver_cli/templates/extraction/harness.yaml +33 -0
- harnessweaver_cli/templates/extraction/prompts/extract@1.md +7 -0
- harnessweaver_cli/templates/extraction/pyproject.toml +13 -0
- harnessweaver_cli/templates/extraction/tests/test_extract.py +56 -0
- harnessweaver_cli/templates/rag/README.md +17 -0
- harnessweaver_cli/templates/rag/app/__init__.py +1 -0
- harnessweaver_cli/templates/rag/app/flow.py +29 -0
- harnessweaver_cli/templates/rag/app/main.py +19 -0
- harnessweaver_cli/templates/rag/data/kb.yaml +13 -0
- harnessweaver_cli/templates/rag/evals/golden.yaml +14 -0
- harnessweaver_cli/templates/rag/harness.offline.yaml +13 -0
- harnessweaver_cli/templates/rag/harness.yaml +50 -0
- harnessweaver_cli/templates/rag/prompts/answer@1.md +9 -0
- harnessweaver_cli/templates/rag/pyproject.toml +13 -0
- harnessweaver_cli/templates/rag/tests/test_answer.py +50 -0
- harnessweaver_cli/templates/workflow/README.md +16 -0
- harnessweaver_cli/templates/workflow/app/__init__.py +1 -0
- harnessweaver_cli/templates/workflow/app/main.py +20 -0
- harnessweaver_cli/templates/workflow/app/steps.py +49 -0
- harnessweaver_cli/templates/workflow/evals/golden.yaml +9 -0
- harnessweaver_cli/templates/workflow/harness.offline.yaml +12 -0
- harnessweaver_cli/templates/workflow/harness.yaml +39 -0
- harnessweaver_cli/templates/workflow/prompts/classify@1.md +4 -0
- harnessweaver_cli/templates/workflow/prompts/reply@1.md +5 -0
- harnessweaver_cli/templates/workflow/prompts/summarize@1.md +3 -0
- harnessweaver_cli/templates/workflow/pyproject.toml +13 -0
- harnessweaver_cli/templates/workflow/tests/test_pipeline.py +32 -0
- harnessweaver_cli/tools.py +72 -0
- harnessweaver_cli-0.7.1.dist-info/METADATA +83 -0
- harnessweaver_cli-0.7.1.dist-info/RECORD +84 -0
- harnessweaver_cli-0.7.1.dist-info/WHEEL +4 -0
- harnessweaver_cli-0.7.1.dist-info/entry_points.txt +2 -0
- harnessweaver_cli-0.7.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""``harnessweaver gateway litellm|mcp``: gateway configs from the same spec (ADR-0039).
|
|
2
|
+
|
|
3
|
+
An app that calls models and tools straight through a gateway, with no SDK,
|
|
4
|
+
still gets the spec's model aliases and its tool allow-list. A gateway can't see
|
|
5
|
+
a run, though: what it has read, who approves, which tenant it is for. So a tool
|
|
6
|
+
is let through only when its manifest asks for nothing that needs the run
|
|
7
|
+
(approvals, egress and the trifecta, tenants, rate limits); the others are
|
|
8
|
+
withheld and run only through the harness (in process, or harnessd). Policy
|
|
9
|
+
rules, budgets and guards stay with the harness too.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Annotated, Any
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from harnessweaver.core.errors import HarnessError
|
|
21
|
+
from harnessweaver.core.gateway import AllowedTool, GatewayMetadata, ToolAllowList, WithheldTool
|
|
22
|
+
from harnessweaver.core.manifests import ToolManifest, definition_digest
|
|
23
|
+
from harnessweaver.modules.tools import load_manifests
|
|
24
|
+
from harnessweaver.spec import EffectiveSpec, load_spec
|
|
25
|
+
from harnessweaver.spec.models import HarnessSpec
|
|
26
|
+
from harnessweaver_cli._load import fail, spec_path
|
|
27
|
+
from harnessweaver_cli.spec import Env, Path_, ProfileDirs
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"gateway_app",
|
|
31
|
+
"gateway_tools",
|
|
32
|
+
"litellm_config",
|
|
33
|
+
"render_allow_list",
|
|
34
|
+
"render_litellm",
|
|
35
|
+
"upstream_env",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
gateway_app = typer.Typer(
|
|
39
|
+
help="Gateway configs from the spec: LiteLLM Proxy, and a tool allow-list for MCP gateways.",
|
|
40
|
+
no_args_is_help=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
Out = Annotated[
|
|
44
|
+
Path | None, typer.Option("--out", "-o", help="Write to this file instead of printing.")
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
RETRIES = 2
|
|
48
|
+
"""LiteLLM's retries: the gateway owns them, and SDK clients have theirs off (R5)."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def upstream_env(model: str) -> str:
|
|
52
|
+
"""The variable that names the provider model behind a gateway model name, such as
|
|
53
|
+
``HARNESSWEAVER_UPSTREAM_FAST_CHAT`` for ``fast-chat``. The spec names only the
|
|
54
|
+
gateway's models; which provider serves each is the gateway owner's choice."""
|
|
55
|
+
return "HARNESSWEAVER_UPSTREAM_" + re.sub(r"[^A-Za-z0-9]", "_", model).upper()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _withheld(manifest: ToolManifest, spec: HarnessSpec, approved: set[str]) -> str | None:
|
|
59
|
+
"""Why only the harness may run this tool, or ``None`` when a gateway may.
|
|
60
|
+
|
|
61
|
+
Each reason is a manifest control that needs the run, so a gateway that
|
|
62
|
+
allowed the tool would skip it.
|
|
63
|
+
"""
|
|
64
|
+
body = manifest.spec
|
|
65
|
+
if not body.enabled:
|
|
66
|
+
return "it is disabled"
|
|
67
|
+
digest = definition_digest(manifest.name, body.description, body.input_schema)
|
|
68
|
+
if body.definition_digest is not None and body.definition_digest != digest:
|
|
69
|
+
return "its pinned digest doesn't match its definition (S2)"
|
|
70
|
+
if (
|
|
71
|
+
body.approval is not None
|
|
72
|
+
or spec.tools.approval_by_risk.get(body.risk) == "required"
|
|
73
|
+
or manifest.name in approved
|
|
74
|
+
):
|
|
75
|
+
return "it can need a person's approval, which only the harness can wait for"
|
|
76
|
+
if body.egress:
|
|
77
|
+
return "it has egress, and the trifecta check needs what the run has seen (S3)"
|
|
78
|
+
if body.tenants:
|
|
79
|
+
return "it is enabled for some tenants only"
|
|
80
|
+
if body.rate_limit is not None:
|
|
81
|
+
return "its rate limits are counted by the harness"
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _manifests(effective: EffectiveSpec) -> list[ToolManifest]:
|
|
86
|
+
base = effective.path.parent if effective.path is not None else Path.cwd()
|
|
87
|
+
found = load_manifests(effective.spec.tools.manifests, base)
|
|
88
|
+
return sorted(found, key=lambda manifest: manifest.name)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def gateway_tools(effective: EffectiveSpec) -> ToolAllowList:
|
|
92
|
+
"""The tools a gateway may let through, and the ones it must withhold (ADR-0039).
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
SpecError: For a manifest that doesn't load.
|
|
96
|
+
"""
|
|
97
|
+
spec = effective.spec
|
|
98
|
+
approved = {
|
|
99
|
+
tool for task in spec.tasks.values() if task.approvals for tool in task.approvals.tools
|
|
100
|
+
}
|
|
101
|
+
allowed: list[AllowedTool] = []
|
|
102
|
+
withheld: list[WithheldTool] = []
|
|
103
|
+
for manifest in _manifests(effective):
|
|
104
|
+
reason = _withheld(manifest, spec, approved)
|
|
105
|
+
if reason is not None:
|
|
106
|
+
withheld.append(WithheldTool(tool=manifest.name, reason=reason))
|
|
107
|
+
continue
|
|
108
|
+
body = manifest.spec
|
|
109
|
+
allowed.append(
|
|
110
|
+
AllowedTool(
|
|
111
|
+
tool=manifest.name,
|
|
112
|
+
transport=body.transport,
|
|
113
|
+
server=body.server,
|
|
114
|
+
# An unpinned manifest is pinned to what it says now; doctor warns about it.
|
|
115
|
+
digest=body.definition_digest
|
|
116
|
+
or definition_digest(manifest.name, body.description, body.input_schema),
|
|
117
|
+
scopes=body.scopes,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
return ToolAllowList(
|
|
121
|
+
metadata=GatewayMetadata(name=spec.metadata.name),
|
|
122
|
+
tools=tuple(allowed),
|
|
123
|
+
withheld=tuple(withheld),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _param_patterns(manifest: ToolManifest) -> dict[str, str]:
|
|
128
|
+
"""Regexes for the top-level string arguments the schema constrains: its ``pattern``,
|
|
129
|
+
or its ``enum`` as alternatives. Other constraints stay with the governor (step 2)."""
|
|
130
|
+
properties = manifest.spec.input_schema.get("properties")
|
|
131
|
+
patterns: dict[str, str] = {}
|
|
132
|
+
if not isinstance(properties, dict):
|
|
133
|
+
return patterns
|
|
134
|
+
for name, schema in sorted(properties.items()):
|
|
135
|
+
if not isinstance(schema, dict):
|
|
136
|
+
continue
|
|
137
|
+
pattern, enum = schema.get("pattern"), schema.get("enum")
|
|
138
|
+
if isinstance(pattern, str):
|
|
139
|
+
patterns[name] = pattern
|
|
140
|
+
elif isinstance(enum, list) and enum and all(isinstance(v, str) for v in enum):
|
|
141
|
+
patterns[name] = "^(?:" + "|".join(re.escape(str(v)) for v in enum) + ")$"
|
|
142
|
+
return patterns
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _timeout(effective: EffectiveSpec) -> int | None:
|
|
146
|
+
"""The longest deadline a run can have, in whole seconds: the gateway shouldn't keep
|
|
147
|
+
a request going after the harness has given up on it."""
|
|
148
|
+
spec = effective.spec
|
|
149
|
+
deadlines = [spec.budgets.per_run.deadline] + [
|
|
150
|
+
effective.run_budget(task).deadline for task in spec.tasks
|
|
151
|
+
]
|
|
152
|
+
found = [deadline.total_seconds() for deadline in deadlines if deadline is not None]
|
|
153
|
+
return math.ceil(max(found)) if found else None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def litellm_config(effective: EffectiveSpec, tools: ToolAllowList) -> dict[str, Any]:
|
|
157
|
+
"""A LiteLLM Proxy config: the gateway's models, the spec's aliases, retries and a
|
|
158
|
+
timeout, content kept out of its logs, and a tool-permission guardrail on every
|
|
159
|
+
request that allows only the allow-list's tools."""
|
|
160
|
+
spec = effective.spec
|
|
161
|
+
models = spec.models
|
|
162
|
+
allowed_aliases = models.allowed_aliases
|
|
163
|
+
aliases = {
|
|
164
|
+
alias: target
|
|
165
|
+
for alias, target in sorted(models.aliases.items())
|
|
166
|
+
if allowed_aliases is None or alias in allowed_aliases
|
|
167
|
+
}
|
|
168
|
+
names = set(aliases.values())
|
|
169
|
+
if models.default is not None:
|
|
170
|
+
names.add(models.aliases.get(models.default, models.default))
|
|
171
|
+
router: dict[str, Any] = {"num_retries": RETRIES}
|
|
172
|
+
if aliases:
|
|
173
|
+
router["model_group_alias"] = aliases
|
|
174
|
+
timeout = _timeout(effective)
|
|
175
|
+
if timeout is not None:
|
|
176
|
+
router["timeout"] = timeout
|
|
177
|
+
observability = spec.observability
|
|
178
|
+
settings: dict[str, Any] = {"turn_off_message_logging": not observability.capture_content}
|
|
179
|
+
config: dict[str, Any] = {
|
|
180
|
+
"model_list": [
|
|
181
|
+
{"model_name": name, "litellm_params": {"model": f"os.environ/{upstream_env(name)}"}}
|
|
182
|
+
for name in sorted(names)
|
|
183
|
+
],
|
|
184
|
+
"router_settings": router,
|
|
185
|
+
"litellm_settings": settings,
|
|
186
|
+
}
|
|
187
|
+
if observability.exporter == "otlp":
|
|
188
|
+
settings["callbacks"] = ["otel"]
|
|
189
|
+
config["environment_variables"] = {
|
|
190
|
+
"OTEL_EXPORTER": "otlp_http",
|
|
191
|
+
"OTEL_ENDPOINT": observability.endpoint or "http://localhost:4318",
|
|
192
|
+
}
|
|
193
|
+
manifests = {manifest.name: manifest for manifest in _manifests(effective)}
|
|
194
|
+
rules: list[dict[str, Any]] = []
|
|
195
|
+
for tool in tools.tools:
|
|
196
|
+
rule: dict[str, Any] = {
|
|
197
|
+
"id": f"allow-{tool.tool}",
|
|
198
|
+
"tool_name": f"^{re.escape(tool.tool)}$",
|
|
199
|
+
"decision": "allow",
|
|
200
|
+
}
|
|
201
|
+
patterns = _param_patterns(manifests[tool.tool])
|
|
202
|
+
if patterns:
|
|
203
|
+
rule["allowed_param_patterns"] = patterns
|
|
204
|
+
rules.append(rule)
|
|
205
|
+
rules += [
|
|
206
|
+
{"id": f"withhold-{w.tool}", "tool_name": f"^{re.escape(w.tool)}$", "decision": "deny"}
|
|
207
|
+
for w in tools.withheld
|
|
208
|
+
]
|
|
209
|
+
config["guardrails"] = [
|
|
210
|
+
{
|
|
211
|
+
"guardrail_name": "harnessweaver-tools",
|
|
212
|
+
"litellm_params": {
|
|
213
|
+
"guardrail": "tool_permission",
|
|
214
|
+
"mode": "post_call",
|
|
215
|
+
"default_on": True,
|
|
216
|
+
"rules": rules,
|
|
217
|
+
"default_action": "deny", # no tool runs without a manifest (S2)
|
|
218
|
+
"on_disallowed_action": "block",
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
]
|
|
222
|
+
config["general_settings"] = {"master_key": "os.environ/LITELLM_MASTER_KEY"}
|
|
223
|
+
return config
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _dump(data: Any) -> str:
|
|
227
|
+
return yaml.safe_dump(data, sort_keys=False, default_flow_style=False, width=100)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _withheld_lines(tools: ToolAllowList) -> list[str]:
|
|
231
|
+
if not tools.withheld:
|
|
232
|
+
return []
|
|
233
|
+
lines = ["Tools only the harness runs (denied here; run them through harnessd):"]
|
|
234
|
+
return lines + [f" {w.tool}: {w.reason}" for w in tools.withheld]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _comment(lines: list[str]) -> str:
|
|
238
|
+
return "".join(f"# {line}".rstrip() + "\n" for line in lines)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def render_litellm(effective: EffectiveSpec) -> str:
|
|
242
|
+
"""The LiteLLM Proxy config as YAML, with a header saying what to set and what is
|
|
243
|
+
withheld."""
|
|
244
|
+
tools = gateway_tools(effective)
|
|
245
|
+
names = [entry["model_name"] for entry in litellm_config(effective, tools)["model_list"]]
|
|
246
|
+
header = [
|
|
247
|
+
f"LiteLLM Proxy config for {effective.spec.metadata.name}, generated by",
|
|
248
|
+
"`harnessweaver gateway litellm` from the spec and its tool manifests (ADR-0039).",
|
|
249
|
+
"Regenerate it when they change. In the gateway's environment (S1), set",
|
|
250
|
+
"LITELLM_MASTER_KEY, the provider keys, and the provider model behind each name:",
|
|
251
|
+
*[f" {upstream_env(name)}" for name in names],
|
|
252
|
+
"Policy rules, budgets, guards and approvals stay with the harness.",
|
|
253
|
+
*_withheld_lines(tools),
|
|
254
|
+
]
|
|
255
|
+
return _comment(header) + _dump(litellm_config(effective, tools))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def render_allow_list(effective: EffectiveSpec) -> str:
|
|
259
|
+
"""The neutral tool allow-list as YAML (schema ``tool-allow-list``)."""
|
|
260
|
+
tools = gateway_tools(effective)
|
|
261
|
+
header = [
|
|
262
|
+
f"Tool allow-list for {effective.spec.metadata.name}, generated by",
|
|
263
|
+
"`harnessweaver gateway mcp` (ADR-0039; schema tool-allow-list). An MCP gateway",
|
|
264
|
+
"allows these tools only, pins each digest, and requires its scopes.",
|
|
265
|
+
]
|
|
266
|
+
return _comment(header) + _dump(tools.model_dump(mode="json", by_alias=True))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _emit(text: str, out: Path | None) -> None:
|
|
270
|
+
if out is None:
|
|
271
|
+
typer.echo(text, nl=False)
|
|
272
|
+
return
|
|
273
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
out.write_text(text, encoding="utf-8")
|
|
275
|
+
typer.echo(f"wrote {out}")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@gateway_app.command("litellm")
|
|
279
|
+
def litellm(
|
|
280
|
+
path: Path_ = None, env: Env = None, profile_dir: ProfileDirs = None, out: Out = None
|
|
281
|
+
) -> None:
|
|
282
|
+
"""A LiteLLM Proxy config: model aliases, router settings and tool permissions."""
|
|
283
|
+
try:
|
|
284
|
+
effective = load_spec(spec_path(path), env=env, profile_dirs=profile_dir or [])
|
|
285
|
+
text = render_litellm(effective)
|
|
286
|
+
except HarnessError as exc:
|
|
287
|
+
raise fail(exc) from None
|
|
288
|
+
_emit(text, out)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@gateway_app.command("mcp")
|
|
292
|
+
def mcp(
|
|
293
|
+
path: Path_ = None, env: Env = None, profile_dir: ProfileDirs = None, out: Out = None
|
|
294
|
+
) -> None:
|
|
295
|
+
"""A neutral tool allow-list for an MCP gateway: tool, server, digest and scopes."""
|
|
296
|
+
try:
|
|
297
|
+
effective = load_spec(spec_path(path), env=env, profile_dirs=profile_dir or [])
|
|
298
|
+
text = render_allow_list(effective)
|
|
299
|
+
except HarnessError as exc:
|
|
300
|
+
raise fail(exc) from None
|
|
301
|
+
_emit(text, out)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Entry point for the ``harnessweaver`` command."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
import harnessweaver
|
|
8
|
+
from harnessweaver_cli import __version__
|
|
9
|
+
from harnessweaver_cli.add import add_app
|
|
10
|
+
from harnessweaver_cli.dev import dev
|
|
11
|
+
from harnessweaver_cli.doctor import doctor
|
|
12
|
+
from harnessweaver_cli.evals import eval_app
|
|
13
|
+
from harnessweaver_cli.gateway import gateway_app
|
|
14
|
+
from harnessweaver_cli.plugins import plugin_app
|
|
15
|
+
from harnessweaver_cli.policy import policy_app
|
|
16
|
+
from harnessweaver_cli.replay import replay
|
|
17
|
+
from harnessweaver_cli.scaffold import init, run_tests
|
|
18
|
+
from harnessweaver_cli.sidecar import sidecar
|
|
19
|
+
from harnessweaver_cli.spec import contracts_app, hooks, spec_app
|
|
20
|
+
from harnessweaver_cli.tools import tools_app
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="harnessweaver",
|
|
24
|
+
help="HarnessWeaver developer tooling: weave, check and test your AI harness.",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
add_completion=False,
|
|
27
|
+
)
|
|
28
|
+
app.command()(init)
|
|
29
|
+
app.add_typer(spec_app, name="spec")
|
|
30
|
+
app.command()(doctor)
|
|
31
|
+
app.command()(hooks)
|
|
32
|
+
app.command(
|
|
33
|
+
name="test", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
|
|
34
|
+
)(run_tests)
|
|
35
|
+
app.add_typer(contracts_app, name="contracts")
|
|
36
|
+
app.add_typer(eval_app, name="eval")
|
|
37
|
+
app.add_typer(policy_app, name="policy")
|
|
38
|
+
app.command()(replay)
|
|
39
|
+
app.add_typer(add_app, name="add")
|
|
40
|
+
app.add_typer(tools_app, name="tools")
|
|
41
|
+
app.add_typer(plugin_app, name="plugin")
|
|
42
|
+
app.command()(sidecar)
|
|
43
|
+
app.add_typer(gateway_app, name="gateway")
|
|
44
|
+
app.command()(dev)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _print_version(value: bool) -> None:
|
|
48
|
+
if value:
|
|
49
|
+
typer.echo(f"harnessweaver-cli {__version__} (harnessweaver {harnessweaver.__version__})")
|
|
50
|
+
raise typer.Exit()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.callback()
|
|
54
|
+
def main(
|
|
55
|
+
version: Annotated[
|
|
56
|
+
bool,
|
|
57
|
+
typer.Option(
|
|
58
|
+
"--version", callback=_print_version, is_eager=True, help="Show versions and exit."
|
|
59
|
+
),
|
|
60
|
+
] = False,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""HarnessWeaver developer tooling."""
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""``harnessweaver plugin new`` and ``harnessweaver plugin list`` (DESIGNS No. 13).
|
|
2
|
+
|
|
3
|
+
``plugin new`` scaffolds a standalone plugin package, meant to live in its own
|
|
4
|
+
repository: it builds on the public extension surface only and passes the
|
|
5
|
+
conformance suite as written. ``plugin list`` shows what is installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich import box
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from harnessweaver import CONTRACT_VERSION, RESERVED_NAMESPACES
|
|
19
|
+
from harnessweaver.core.errors import HarnessError
|
|
20
|
+
from harnessweaver.core.plugins.names import NAMESPACE_PATTERN
|
|
21
|
+
from harnessweaver.core.registry import PluginCatalog
|
|
22
|
+
from harnessweaver_cli._load import fail
|
|
23
|
+
from harnessweaver_cli.scaffold import copy_tree
|
|
24
|
+
from harnessweaver_cli.spec import Format
|
|
25
|
+
|
|
26
|
+
__all__ = ["plugin_app"]
|
|
27
|
+
|
|
28
|
+
plugin_app = typer.Typer(help="Create and inspect plugins.", no_args_is_help=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@plugin_app.command("new")
|
|
32
|
+
def new(
|
|
33
|
+
namespace: Annotated[str, typer.Argument(help="The plugin's namespace, such as acme.")],
|
|
34
|
+
directory: Annotated[
|
|
35
|
+
Path | None, typer.Argument(help="Where to create it (default: ./<namespace>-harness).")
|
|
36
|
+
] = None,
|
|
37
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite existing files.")] = False,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Scaffold a standalone plugin package: entry point, ``hw.Plugin``, a sample guard
|
|
40
|
+
and tests that also run the conformance suite (T5)."""
|
|
41
|
+
if not NAMESPACE_PATTERN.match(namespace):
|
|
42
|
+
raise fail(
|
|
43
|
+
f"invalid namespace {namespace!r}: use lowercase letters, digits and underscores, "
|
|
44
|
+
"starting with a letter"
|
|
45
|
+
)
|
|
46
|
+
if namespace in RESERVED_NAMESPACES:
|
|
47
|
+
raise fail(f"namespace {namespace!r} is reserved for the built-in plugin")
|
|
48
|
+
dist = f"{namespace.replace('_', '-')}-harness"
|
|
49
|
+
target = directory or Path(dist)
|
|
50
|
+
root = resources.files("harnessweaver_cli").joinpath("templates", "_plugin")
|
|
51
|
+
written = copy_tree(root, target, {"__dist__": dist, "__ns__": namespace}, force=force)
|
|
52
|
+
typer.echo(f"created {len(written)} files in {target} for the plugin {namespace!r} ({dist})")
|
|
53
|
+
typer.echo("next:")
|
|
54
|
+
typer.echo(f" cd {target}")
|
|
55
|
+
typer.echo(" uv sync && uv run pytest # its tests and the conformance suite")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _row(catalog: PluginCatalog, namespace: str, sources: tuple[Any, ...]) -> dict[str, Any]:
|
|
59
|
+
"""What ``plugin list`` shows for one namespace. Importing a plugin does no I/O (S8)."""
|
|
60
|
+
source = sources[0]
|
|
61
|
+
row: dict[str, Any] = {
|
|
62
|
+
"namespace": namespace,
|
|
63
|
+
"distribution": source.distribution,
|
|
64
|
+
"target": source.target,
|
|
65
|
+
}
|
|
66
|
+
try:
|
|
67
|
+
plugin = catalog.load(namespace).plugin
|
|
68
|
+
except HarnessError as exc: # several claim it, or it doesn't import
|
|
69
|
+
row.update(version=None, contract=None, compatible=False, problem=exc.message)
|
|
70
|
+
return row
|
|
71
|
+
row.update(
|
|
72
|
+
version=plugin.version,
|
|
73
|
+
contract=plugin.contract,
|
|
74
|
+
compatible=plugin.contract_range.contains(CONTRACT_VERSION),
|
|
75
|
+
contributions=len(plugin.contributions()),
|
|
76
|
+
problem=None,
|
|
77
|
+
)
|
|
78
|
+
return row
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@plugin_app.command("list")
|
|
82
|
+
def list_plugins(output: Format = "text") -> None:
|
|
83
|
+
"""Installed plugins: namespace, distribution, version, contract range and whether it
|
|
84
|
+
is compatible with this harnessweaver (V5)."""
|
|
85
|
+
catalog = PluginCatalog.default()
|
|
86
|
+
rows = [_row(catalog, ns, sources) for ns, sources in catalog.sources().items()]
|
|
87
|
+
if output == "json":
|
|
88
|
+
typer.echo(json.dumps({"plugins": rows, "problems": catalog.problems()}, indent=2))
|
|
89
|
+
return
|
|
90
|
+
table = Table(box=box.SIMPLE, show_header=True)
|
|
91
|
+
for column in ("namespace", "distribution", "version", "contract", "compatible"):
|
|
92
|
+
table.add_column(column)
|
|
93
|
+
for row in rows:
|
|
94
|
+
compatible = "yes" if row["compatible"] else f"no: {row['problem'] or 'contract range'}"
|
|
95
|
+
table.add_row(
|
|
96
|
+
row["namespace"],
|
|
97
|
+
row["distribution"] or "-",
|
|
98
|
+
row["version"] or "-",
|
|
99
|
+
row["contract"] or "-",
|
|
100
|
+
compatible,
|
|
101
|
+
)
|
|
102
|
+
Console(width=120, highlight=False).print(table)
|
|
103
|
+
for problem in catalog.problems():
|
|
104
|
+
typer.echo(f"warning: {problem}")
|
|
105
|
+
typer.echo(f"harnessweaver contract {CONTRACT_VERSION}")
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""``harnessweaver policy test``: table-driven tests of policy rules and tool approvals.
|
|
2
|
+
|
|
3
|
+
Each row names a tool call, who makes it and what the governor must decide::
|
|
4
|
+
|
|
5
|
+
cases:
|
|
6
|
+
- id: small-refunds-pass
|
|
7
|
+
tool: refund_payment
|
|
8
|
+
args: { amount_usd: 20 }
|
|
9
|
+
principal: { id: alice, roles: [support] }
|
|
10
|
+
expect: allow
|
|
11
|
+
- id: big-refunds-need-finance
|
|
12
|
+
tool: refund_payment
|
|
13
|
+
args: { amount_usd: 900 }
|
|
14
|
+
expect: require_approval
|
|
15
|
+
approver_role: finance
|
|
16
|
+
|
|
17
|
+
The governor's steps 1 to 7 decide each row as a dry run (``Harness.authorize``):
|
|
18
|
+
the spec's policy engine, manifests, approval rules, the trifecta and the judge,
|
|
19
|
+
with no rate limit used up and no tool executed. Exits 1 when a row's verdict
|
|
20
|
+
differs and 2 when the tests can't run.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Annotated
|
|
26
|
+
|
|
27
|
+
import anyio
|
|
28
|
+
import typer
|
|
29
|
+
from pydantic import ValidationError
|
|
30
|
+
|
|
31
|
+
from harnessweaver import Harness
|
|
32
|
+
from harnessweaver.core.errors import HarnessError, SpecError
|
|
33
|
+
from harnessweaver.core.policy import PolicyCase, PolicyTests
|
|
34
|
+
from harnessweaver.core.tools import ToolVerdict
|
|
35
|
+
from harnessweaver.modules.state import MemoryStateStore
|
|
36
|
+
from harnessweaver.spec.yaml import load_yaml_file
|
|
37
|
+
from harnessweaver_cli._load import fail, load_harness
|
|
38
|
+
from harnessweaver_cli.spec import Env, Format, Path_, ProfileDirs
|
|
39
|
+
|
|
40
|
+
__all__ = ["policy_app"]
|
|
41
|
+
|
|
42
|
+
policy_app = typer.Typer(help="Test policy rules and tool approvals.", no_args_is_help=True)
|
|
43
|
+
|
|
44
|
+
CANT_RUN = 2
|
|
45
|
+
DEFAULT_CASES = Path("policy_tests.yaml")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read(path: Path) -> PolicyTests:
|
|
49
|
+
if not path.is_file():
|
|
50
|
+
raise SpecError(f"no policy tests at {path}")
|
|
51
|
+
try:
|
|
52
|
+
return PolicyTests.model_validate(load_yaml_file(path))
|
|
53
|
+
except ValidationError as exc:
|
|
54
|
+
problems = "; ".join(
|
|
55
|
+
f"{'.'.join(str(p) for p in e['loc']) or '(file)'}: {e['msg']}" for e in exc.errors()
|
|
56
|
+
)
|
|
57
|
+
raise SpecError(f"{path}: {problems}") from None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _mismatch(case: PolicyCase, verdict: ToolVerdict) -> str | None:
|
|
61
|
+
wanted = [
|
|
62
|
+
("decision", case.expect, verdict.decision),
|
|
63
|
+
("code", case.code, verdict.code),
|
|
64
|
+
("step", case.step, verdict.step),
|
|
65
|
+
("approver_role", case.approver_role, verdict.approver_role),
|
|
66
|
+
]
|
|
67
|
+
wrong = [
|
|
68
|
+
f"{name} {got!r}, not {want!r}"
|
|
69
|
+
for name, want, got in wanted
|
|
70
|
+
if want is not None and want != got
|
|
71
|
+
]
|
|
72
|
+
return "; ".join(wrong) or None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _check(harness: Harness, tests: PolicyTests) -> list[tuple[PolicyCase, ToolVerdict]]:
|
|
76
|
+
return [
|
|
77
|
+
(
|
|
78
|
+
case,
|
|
79
|
+
await harness.authorize(
|
|
80
|
+
case.tool,
|
|
81
|
+
case.args,
|
|
82
|
+
principal=case.principal,
|
|
83
|
+
tenant=case.tenant,
|
|
84
|
+
task=case.task,
|
|
85
|
+
labels=case.labels,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
for case in tests.cases
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@policy_app.command("test")
|
|
93
|
+
def run_policy_tests(
|
|
94
|
+
path: Path_ = None,
|
|
95
|
+
env: Env = None,
|
|
96
|
+
profile_dir: ProfileDirs = None,
|
|
97
|
+
cases: Annotated[
|
|
98
|
+
list[Path] | None,
|
|
99
|
+
typer.Option(
|
|
100
|
+
"--cases", "-c", help="Test files (default: policy_tests.yaml next to the spec)."
|
|
101
|
+
),
|
|
102
|
+
] = None,
|
|
103
|
+
output: Format = "text",
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Check each row's tool call against the governor, without running anything.
|
|
106
|
+
|
|
107
|
+
Exits 1 when a verdict differs from the row's expectation; 2 when the tests
|
|
108
|
+
can't run.
|
|
109
|
+
"""
|
|
110
|
+
harness = load_harness(path, env, profile_dir or [], state=MemoryStateStore(), code=CANT_RUN)
|
|
111
|
+
base = harness.spec.path.parent if harness.spec.path is not None else Path.cwd()
|
|
112
|
+
files = cases or [base / DEFAULT_CASES]
|
|
113
|
+
results: list[tuple[Path, PolicyCase, ToolVerdict]] = []
|
|
114
|
+
try:
|
|
115
|
+
for file in files:
|
|
116
|
+
checked = anyio.run(_check, harness, _read(file))
|
|
117
|
+
results += [(file, case, verdict) for case, verdict in checked]
|
|
118
|
+
except HarnessError as exc:
|
|
119
|
+
raise fail(exc, CANT_RUN) from None
|
|
120
|
+
failures = [(f, c, v) for f, c, v in results if _mismatch(c, v) is not None]
|
|
121
|
+
if output == "json":
|
|
122
|
+
body = [
|
|
123
|
+
{
|
|
124
|
+
"file": str(file),
|
|
125
|
+
"id": case.id,
|
|
126
|
+
"ok": _mismatch(case, verdict) is None,
|
|
127
|
+
"expect": case.expect,
|
|
128
|
+
"verdict": verdict.model_dump(mode="json"),
|
|
129
|
+
}
|
|
130
|
+
for file, case, verdict in results
|
|
131
|
+
]
|
|
132
|
+
typer.echo(json.dumps(body, indent=2))
|
|
133
|
+
else:
|
|
134
|
+
for _, case, verdict in results:
|
|
135
|
+
problem = _mismatch(case, verdict)
|
|
136
|
+
shown = f"{verdict.decision} at step {verdict.step}"
|
|
137
|
+
if problem is None:
|
|
138
|
+
typer.echo(f"ok {case.id}: {shown}")
|
|
139
|
+
else:
|
|
140
|
+
typer.echo(f"FAIL {case.id}: {problem} ({verdict.reason})")
|
|
141
|
+
typer.echo(f"{len(results) - len(failures)} of {len(results)} policy tests passed")
|
|
142
|
+
if failures:
|
|
143
|
+
raise typer.Exit(code=1)
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""``harnessweaver replay <run_id>``: run a recorded run again against the current code
|
|
2
|
+
and spec (ADR-0035).
|
|
3
|
+
|
|
4
|
+
The run must have been recorded (``state.record: true``). Its model answers and
|
|
5
|
+
tool results come from the recording, so nothing calls out and no tool runs;
|
|
6
|
+
the replay's own runs stay in memory. Exits 1 when the status or the output
|
|
7
|
+
changed, and 2 when the run can't be replayed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
import anyio
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from harnessweaver import Harness
|
|
16
|
+
from harnessweaver.core.errors import HarnessError
|
|
17
|
+
from harnessweaver.core.replay import ReplayReport
|
|
18
|
+
from harnessweaver_cli._load import fail, load_harness
|
|
19
|
+
from harnessweaver_cli.spec import Env, Format, Path_, ProfileDirs
|
|
20
|
+
|
|
21
|
+
__all__ = ["replay"]
|
|
22
|
+
|
|
23
|
+
CANT_RUN = 2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _replay(harness: Harness, run_id: str) -> ReplayReport:
|
|
27
|
+
try:
|
|
28
|
+
return await harness.replay(run_id)
|
|
29
|
+
finally:
|
|
30
|
+
await harness.aclose()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _print(report: ReplayReport) -> None:
|
|
34
|
+
before, after = report.original, report.replayed
|
|
35
|
+
typer.echo(
|
|
36
|
+
f"{'changed' if report.differs else 'same'}: run {report.run_id} ({report.task}) "
|
|
37
|
+
f"ended {before.status.value}; the replay ended {after.status.value}"
|
|
38
|
+
)
|
|
39
|
+
typer.echo(f" output: {'same' if report.same_output else 'different'}")
|
|
40
|
+
typer.echo(
|
|
41
|
+
f" model answers used: {report.model_answers_used} of {report.model_answers}"
|
|
42
|
+
+ (f"; {report.changed_requests} request(s) changed" if report.changed_requests else "")
|
|
43
|
+
)
|
|
44
|
+
if report.missing_tool_results:
|
|
45
|
+
typer.echo(f" tool calls not in the recording: {report.missing_tool_results}")
|
|
46
|
+
for change in report.changes:
|
|
47
|
+
sign = "+" if change.change == "added" else "-"
|
|
48
|
+
where = change.hook.value if change.hook is not None else "-"
|
|
49
|
+
what = change.code or (change.outcome.value if change.outcome else "")
|
|
50
|
+
typer.echo(f" {sign} {where} {change.source} {what}: {change.reason or ''}".rstrip(": "))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def replay(
|
|
54
|
+
run_id: str = typer.Argument(help="The recorded run."),
|
|
55
|
+
path: Path_ = None,
|
|
56
|
+
env: Env = None,
|
|
57
|
+
profile_dir: ProfileDirs = None,
|
|
58
|
+
output: Format = "text",
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Replay a recorded run against the current code and spec, and show what changed.
|
|
61
|
+
|
|
62
|
+
Exits 1 when the status or output changed; 2 when the run can't be replayed.
|
|
63
|
+
"""
|
|
64
|
+
harness = load_harness(path, env, profile_dir or [], code=CANT_RUN)
|
|
65
|
+
try:
|
|
66
|
+
report = anyio.run(_replay, harness, run_id)
|
|
67
|
+
except HarnessError as exc:
|
|
68
|
+
raise fail(exc, CANT_RUN) from None
|
|
69
|
+
if output == "json":
|
|
70
|
+
body = {**report.model_dump(mode="json"), "differs": report.differs}
|
|
71
|
+
typer.echo(json.dumps(body, indent=2))
|
|
72
|
+
else:
|
|
73
|
+
_print(report)
|
|
74
|
+
if report.differs:
|
|
75
|
+
raise typer.Exit(code=1)
|