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,119 @@
|
|
|
1
|
+
"""Finding and loading specs for the CLI, including ``git:<ref>:<path>`` sources."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tarfile
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from harnessweaver import Harness
|
|
13
|
+
from harnessweaver.core.errors import HarnessError, SpecError
|
|
14
|
+
from harnessweaver.core.ports import StateStore
|
|
15
|
+
from harnessweaver.spec import EffectiveSpec, load_spec
|
|
16
|
+
|
|
17
|
+
__all__ = ["fail", "load_effective", "load_harness", "spec_path"]
|
|
18
|
+
|
|
19
|
+
DEFAULT_SPEC = Path("harness.yaml")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fail(error: HarnessError | str, code: int = 1) -> typer.Exit:
|
|
23
|
+
"""Print an error the way every command does and return the exit to raise.
|
|
24
|
+
|
|
25
|
+
Commands that report checks (``eval run``, ``policy test``, ``replay``) exit 1
|
|
26
|
+
when a check fails and 2 when they can't run, so CI can tell the two apart.
|
|
27
|
+
"""
|
|
28
|
+
message = error.message if isinstance(error, HarnessError) else error
|
|
29
|
+
typer.echo(f"error: {message}", err=True)
|
|
30
|
+
return typer.Exit(code=code)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def spec_path(path: Path | None, code: int = 1) -> Path:
|
|
34
|
+
"""The spec to load. Its directory becomes importable, as when the app runs from
|
|
35
|
+
there, so ``tasks.*.input`` and handler paths resolve."""
|
|
36
|
+
found = path or DEFAULT_SPEC
|
|
37
|
+
if not found.is_file():
|
|
38
|
+
raise fail(f"no spec at {found}; pass the path to harness.yaml", code)
|
|
39
|
+
directory = str(found.resolve().parent)
|
|
40
|
+
if directory not in sys.path:
|
|
41
|
+
sys.path.insert(0, directory)
|
|
42
|
+
return found
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_harness(
|
|
46
|
+
path: Path | None,
|
|
47
|
+
env: str | None,
|
|
48
|
+
profile_dirs: list[Path],
|
|
49
|
+
*,
|
|
50
|
+
state: StateStore | None = None,
|
|
51
|
+
code: int = 1,
|
|
52
|
+
) -> Harness:
|
|
53
|
+
"""Load and build the harness, the same way an app does (every check runs).
|
|
54
|
+
``state`` replaces the spec's state store (evals keep their runs in memory)."""
|
|
55
|
+
try:
|
|
56
|
+
effective = load_spec(spec_path(path, code), env=env, profile_dirs=profile_dirs)
|
|
57
|
+
return Harness.from_spec(effective, state=state)
|
|
58
|
+
except HarnessError as exc:
|
|
59
|
+
raise fail(exc, code) from None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _git(*args: str) -> bytes | None:
|
|
63
|
+
"""Run git and return its output, or ``None`` if it fails or isn't installed."""
|
|
64
|
+
try:
|
|
65
|
+
done = subprocess.run(["git", *args], capture_output=True, check=False)
|
|
66
|
+
except OSError:
|
|
67
|
+
return None
|
|
68
|
+
return done.stdout if done.returncode == 0 else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_from_git(ref: str, raw_path: str, env: str | None, dirs: list[Path]) -> EffectiveSpec:
|
|
72
|
+
"""Load a spec as it stands at ``ref``, with the profiles that were committed with it.
|
|
73
|
+
|
|
74
|
+
The whole tree at the ref is extracted, so ``.harness/profiles`` and file references come
|
|
75
|
+
from the ref and not the working tree: otherwise a pull request that weakens only a
|
|
76
|
+
profile would show no difference. A ``--profile-dir`` inside the repository is read from
|
|
77
|
+
the ref too. One outside it, like ``HARNESSWEAVER_PROFILE_PATH``, isn't in git, so both
|
|
78
|
+
sides of a diff see the same files.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
SpecError: If the ref or the path can't be read.
|
|
82
|
+
"""
|
|
83
|
+
missing = SpecError(f"can't read {raw_path} at {ref} from git")
|
|
84
|
+
# --end-of-options keeps a ref from being read as an option of git's.
|
|
85
|
+
commit = _git("rev-parse", "--verify", "--quiet", "--end-of-options", f"{ref}^{{commit}}")
|
|
86
|
+
top = _git("rev-parse", "--show-toplevel")
|
|
87
|
+
if commit is None or top is None:
|
|
88
|
+
raise missing
|
|
89
|
+
root = Path(top.decode().strip()).resolve()
|
|
90
|
+
archive = _git("-C", str(root), "archive", "--format=tar", commit.decode().strip())
|
|
91
|
+
if archive is None:
|
|
92
|
+
raise missing
|
|
93
|
+
|
|
94
|
+
def inside(tree: Path, path: Path) -> Path | None:
|
|
95
|
+
"""Where ``path`` is in the extracted tree, if it's in the repository at all."""
|
|
96
|
+
resolved = path.resolve()
|
|
97
|
+
return tree / resolved.relative_to(root) if resolved.is_relative_to(root) else None
|
|
98
|
+
|
|
99
|
+
with tempfile.TemporaryDirectory(prefix="harnessweaver-git-") as temporary:
|
|
100
|
+
tree = Path(temporary).resolve()
|
|
101
|
+
with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
|
|
102
|
+
tar.extractall(tree, filter="data") # the data filter refuses paths out of the tree
|
|
103
|
+
path = inside(tree, Path(raw_path))
|
|
104
|
+
if path is None or not path.is_file():
|
|
105
|
+
raise missing
|
|
106
|
+
return load_spec(path, env=env, profile_dirs=[inside(tree, d) or d for d in dirs])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_effective(source: str, env: str | None, profile_dirs: list[Path]) -> EffectiveSpec:
|
|
110
|
+
"""Load a spec from a file, or from git as ``git:<ref>:<path>`` (for ``spec diff`` in CI)."""
|
|
111
|
+
try:
|
|
112
|
+
if not source.startswith("git:"):
|
|
113
|
+
return load_spec(spec_path(Path(source)), env=env, profile_dirs=profile_dirs)
|
|
114
|
+
ref, _, raw_path = source.removeprefix("git:").partition(":")
|
|
115
|
+
if not ref or not raw_path:
|
|
116
|
+
raise SpecError(f"{source!r} must look like git:<ref>:<path>")
|
|
117
|
+
return _load_from_git(ref, raw_path, env, profile_dirs)
|
|
118
|
+
except HarnessError as exc:
|
|
119
|
+
raise fail(exc) from None
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Comment-preserving reads and writes of the YAML files the CLI edits (ruamel.yaml, ADR-0016).
|
|
2
|
+
|
|
3
|
+
The spec loader reads YAML 1.2 with PyYAML (ADR-0017). Editing commands
|
|
4
|
+
(``add guard``, ``add tool``, ``tools pin``) use ruamel.yaml's round-trip mode
|
|
5
|
+
instead, so the comments, key order and flow style a person wrote survive the
|
|
6
|
+
edit. ruamel.yaml reads YAML 1.2 too, so both see the same values.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ruamel.yaml import YAML
|
|
14
|
+
from ruamel.yaml.comments import CommentedMap
|
|
15
|
+
|
|
16
|
+
__all__ = ["dump", "flow", "read", "write"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _yaml() -> YAML:
|
|
20
|
+
yaml = YAML() # round-trip mode: comments, order and quoting are kept
|
|
21
|
+
yaml.preserve_quotes = True
|
|
22
|
+
yaml.width = 4096 # never re-wrap a long line someone wrote on purpose
|
|
23
|
+
# The repository's style: list items indented two spaces under their key.
|
|
24
|
+
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
25
|
+
return yaml
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read(path: Path) -> Any:
|
|
29
|
+
"""The document at ``path``, as ruamel's comment-keeping maps and lists."""
|
|
30
|
+
return _yaml().load(path.read_text(encoding="utf-8"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def dump(document: Any) -> str:
|
|
34
|
+
"""``document`` as YAML text, with its comments."""
|
|
35
|
+
out = io.StringIO()
|
|
36
|
+
_yaml().dump(document, out)
|
|
37
|
+
return out.getvalue()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write(path: Path, document: Any) -> None:
|
|
41
|
+
"""Write ``document`` to ``path``, comments included."""
|
|
42
|
+
path.write_text(dump(document), encoding="utf-8")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def flow(**items: Any) -> CommentedMap:
|
|
46
|
+
"""A map written on one line, ``{ id: x, use: y }``, as spec list items usually are."""
|
|
47
|
+
found = CommentedMap(items)
|
|
48
|
+
found.fa.set_flow_style()
|
|
49
|
+
return found
|
harnessweaver_cli/add.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""``harnessweaver add guard`` and ``harnessweaver add tool``: grow a spec without hand-editing it.
|
|
2
|
+
|
|
3
|
+
Both edit files in place with comments kept (``_yaml``), then load the spec the
|
|
4
|
+
way the app would. A guard whose edit doesn't load is taken back out, so a
|
|
5
|
+
failed ``add`` leaves the spec as it was.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
import re
|
|
10
|
+
import typing
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Any, Literal
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from pydantic import Field, ValidationError, create_model
|
|
16
|
+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
|
|
17
|
+
|
|
18
|
+
from harnessweaver import Harness, Risk, SideEffect, ToolManifest
|
|
19
|
+
from harnessweaver.core.errors import HarnessError
|
|
20
|
+
from harnessweaver.core.jsonschema import SchemaError, check_schema
|
|
21
|
+
from harnessweaver.spec import load_spec
|
|
22
|
+
from harnessweaver.spec.yaml import load_yaml
|
|
23
|
+
from harnessweaver_cli import _yaml
|
|
24
|
+
from harnessweaver_cli._load import fail, spec_path
|
|
25
|
+
from harnessweaver_cli.spec import Env, Path_, ProfileDirs
|
|
26
|
+
|
|
27
|
+
__all__ = ["add_app", "input_schema"]
|
|
28
|
+
|
|
29
|
+
add_app = typer.Typer(help="Add a guard or a tool to the spec.", no_args_is_help=True)
|
|
30
|
+
|
|
31
|
+
Stage = Literal["input", "context", "tool_result", "output", "stream"]
|
|
32
|
+
|
|
33
|
+
_PARAM = re.compile(r"^(\w+)\s*(?:\([^)]*\))?\s*:\s*(.*)$")
|
|
34
|
+
"""A docstring ``Args:`` entry: ``name: text`` or ``name (type): text``."""
|
|
35
|
+
|
|
36
|
+
_IDEMPOTENCY_KEY = "idempotency_key"
|
|
37
|
+
"""A native tool's parameter the governor fills in; the model never sees it (DESIGNS No. 17)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _settings(items: list[str]) -> dict[str, Any]:
|
|
41
|
+
"""``--set KEY=VALUE`` options as provider settings, each value read as YAML."""
|
|
42
|
+
found: dict[str, Any] = {}
|
|
43
|
+
for item in items:
|
|
44
|
+
key, sep, raw = item.partition("=")
|
|
45
|
+
if not sep or not key:
|
|
46
|
+
raise fail(f"--set {item!r} must look like KEY=VALUE")
|
|
47
|
+
found[key] = load_yaml(raw, f"--set {key}") if raw else ""
|
|
48
|
+
return found
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _check(path: Path, env: str | None, profile_dirs: list[Path]) -> None:
|
|
52
|
+
"""Load and build the harness from ``path``, as ``spec validate`` does."""
|
|
53
|
+
Harness.from_spec(load_spec(path, env=env, profile_dirs=profile_dirs))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@add_app.command("guard")
|
|
57
|
+
def add_guard(
|
|
58
|
+
use: Annotated[str, typer.Argument(help="The guard provider, such as pii.regex or acme.pii.")],
|
|
59
|
+
path: Path_ = None,
|
|
60
|
+
stage: Annotated[Stage, typer.Option("--stage", help="Where it checks content.")] = "input",
|
|
61
|
+
guard_id: Annotated[
|
|
62
|
+
str | None, typer.Option("--id", help="Its id (default: the provider name with dashes).")
|
|
63
|
+
] = None,
|
|
64
|
+
action: Annotated[
|
|
65
|
+
str | None, typer.Option("--action", help="What it does when it fires (overrides its own).")
|
|
66
|
+
] = None,
|
|
67
|
+
settings: Annotated[
|
|
68
|
+
list[str] | None, typer.Option("--set", help="A provider setting, KEY=VALUE; repeatable.")
|
|
69
|
+
] = None,
|
|
70
|
+
env: Env = None,
|
|
71
|
+
profile_dir: ProfileDirs = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Add a guard in shadow mode (``mode: log_only``): it records what it would do and
|
|
74
|
+
changes nothing until a reviewed edit promotes it to ``enforce`` (R8)."""
|
|
75
|
+
target = spec_path(path)
|
|
76
|
+
original = target.read_text(encoding="utf-8")
|
|
77
|
+
document = _yaml.read(target)
|
|
78
|
+
if not isinstance(document, CommentedMap):
|
|
79
|
+
raise fail(f"{target} must be a YAML map")
|
|
80
|
+
name = guard_id or use.replace(".", "-")
|
|
81
|
+
guards = document.setdefault("guardrails", CommentedMap()).setdefault(stage, CommentedSeq())
|
|
82
|
+
if any(isinstance(g, dict) and g.get("id") == name for g in guards):
|
|
83
|
+
raise fail(f"guardrails.{stage} already has a guard {name!r}; pick another --id")
|
|
84
|
+
item = _yaml.flow(id=name, use=use, mode="log_only")
|
|
85
|
+
if action is not None:
|
|
86
|
+
item["action"] = action
|
|
87
|
+
item.update(_settings(settings or []))
|
|
88
|
+
guards.append(item)
|
|
89
|
+
_yaml.write(target, document)
|
|
90
|
+
try:
|
|
91
|
+
_check(target, env, profile_dir or [])
|
|
92
|
+
except HarnessError as exc:
|
|
93
|
+
target.write_text(original, encoding="utf-8") # leave the spec as it was
|
|
94
|
+
raise fail(exc) from None
|
|
95
|
+
typer.echo(f"added guardrails.{stage}[{name}] ({use}) in shadow mode (mode: log_only)")
|
|
96
|
+
typer.echo(" promote it with mode: enforce once the eval shadow report backs it (R8)")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --- add tool ----------------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _docs(function: Any) -> tuple[str, dict[str, str]]:
|
|
103
|
+
"""A function's summary and its parameter descriptions, from a Google-style docstring.
|
|
104
|
+
|
|
105
|
+
The summary is the text before the first section header (``Args:``,
|
|
106
|
+
``Returns:``, ...). Parameter descriptions are the ``name: text`` lines under
|
|
107
|
+
``Args:``, with indented continuation lines joined on.
|
|
108
|
+
"""
|
|
109
|
+
lines = (inspect.getdoc(function) or "").splitlines()
|
|
110
|
+
summary: list[str] = []
|
|
111
|
+
params: dict[str, str] = {}
|
|
112
|
+
section: str | None = None
|
|
113
|
+
current: str | None = None
|
|
114
|
+
for line in lines:
|
|
115
|
+
stripped = line.strip()
|
|
116
|
+
if stripped.endswith(":") and stripped[:-1] in ("Args", "Arguments", "Returns", "Raises"):
|
|
117
|
+
section, current = stripped[:-1], None
|
|
118
|
+
continue
|
|
119
|
+
if section is None:
|
|
120
|
+
summary.append(stripped)
|
|
121
|
+
elif section in ("Args", "Arguments") and stripped:
|
|
122
|
+
# Entries sit one indent under "Args:"; deeper lines continue the last one.
|
|
123
|
+
entry = None if line.startswith(" ") else _PARAM.match(stripped)
|
|
124
|
+
if entry is not None:
|
|
125
|
+
current = str(entry[1])
|
|
126
|
+
params[current] = str(entry[2]).strip()
|
|
127
|
+
elif current is not None:
|
|
128
|
+
params[current] = f"{params[current]} {stripped}".strip()
|
|
129
|
+
return " ".join(s for s in summary if s).strip(), params
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def input_schema(function: Any) -> dict[str, Any]:
|
|
133
|
+
"""The JSON Schema of a function's parameters, as a tool manifest's ``input_schema``.
|
|
134
|
+
|
|
135
|
+
Pydantic builds it from the type hints and defaults; ``idempotency_key``
|
|
136
|
+
is left out because the governor supplies it. Titles are dropped, since
|
|
137
|
+
only the property names and descriptions mean anything to a model.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
typer.Exit: For ``*args`` or ``**kwargs``, which a schema can't describe.
|
|
141
|
+
"""
|
|
142
|
+
_, described = _docs(function)
|
|
143
|
+
hints = typing.get_type_hints(function)
|
|
144
|
+
fields: dict[str, Any] = {}
|
|
145
|
+
for name, parameter in inspect.signature(function).parameters.items():
|
|
146
|
+
if name == _IDEMPOTENCY_KEY:
|
|
147
|
+
continue
|
|
148
|
+
if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD):
|
|
149
|
+
raise fail(f"{function.__name__}: *args and **kwargs can't be described as a tool")
|
|
150
|
+
default = ... if parameter.default is parameter.empty else parameter.default
|
|
151
|
+
fields[name] = (hints.get(name, Any), Field(default, description=described.get(name)))
|
|
152
|
+
schema = create_model(f"{function.__name__}_args", **fields).model_json_schema()
|
|
153
|
+
schema.pop("title", None)
|
|
154
|
+
for prop in schema.get("properties", {}).values():
|
|
155
|
+
prop.pop("title", None)
|
|
156
|
+
return schema
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _manifest_globs(document: CommentedMap) -> CommentedSeq:
|
|
160
|
+
"""The spec's ``tools.manifests`` as a list, made one if it was a single glob or missing."""
|
|
161
|
+
tools = document.setdefault("tools", CommentedMap())
|
|
162
|
+
globs = tools.get("manifests")
|
|
163
|
+
if isinstance(globs, CommentedSeq):
|
|
164
|
+
return globs
|
|
165
|
+
found = CommentedSeq([globs] if isinstance(globs, str) else list(globs or []))
|
|
166
|
+
tools["manifests"] = found
|
|
167
|
+
return found
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@add_app.command("tool")
|
|
171
|
+
def add_tool(
|
|
172
|
+
handler: Annotated[str, typer.Argument(help="The tool's function, 'package.module:function'.")],
|
|
173
|
+
path: Path_ = None,
|
|
174
|
+
directory: Annotated[
|
|
175
|
+
Path, typer.Option("--dir", help="Where manifests live, relative to the spec.")
|
|
176
|
+
] = Path("tools"),
|
|
177
|
+
risk: Annotated[Risk, typer.Option("--risk", help="How much harm a misused call does.")] = (
|
|
178
|
+
Risk.LOW
|
|
179
|
+
),
|
|
180
|
+
side_effect: Annotated[
|
|
181
|
+
SideEffect, typer.Option("--side-effect", help="What a call changes.")
|
|
182
|
+
] = SideEffect.NONE,
|
|
183
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite an existing manifest.")] = False,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Write a tool manifest from a function's signature and docstring (S2).
|
|
186
|
+
|
|
187
|
+
The name is the function's, the description its docstring summary, and the
|
|
188
|
+
input schema comes from its parameters. Review it, then pin it with
|
|
189
|
+
``harnessweaver tools pin``.
|
|
190
|
+
"""
|
|
191
|
+
target = spec_path(path)
|
|
192
|
+
module, _, attribute = handler.partition(":")
|
|
193
|
+
if not module or not attribute:
|
|
194
|
+
raise fail(f"{handler!r} isn't 'package.module:function'")
|
|
195
|
+
try:
|
|
196
|
+
function = getattr(__import__(module, fromlist=[attribute]), attribute)
|
|
197
|
+
except (ImportError, AttributeError) as exc:
|
|
198
|
+
raise fail(f"can't import {handler!r} ({type(exc).__name__})") from None
|
|
199
|
+
if not callable(function):
|
|
200
|
+
raise fail(f"{handler!r} isn't a function")
|
|
201
|
+
description, _ = _docs(function)
|
|
202
|
+
if not description:
|
|
203
|
+
raise fail(f"{handler} has no docstring; the model needs a description of the tool")
|
|
204
|
+
name = function.__name__
|
|
205
|
+
manifest = {
|
|
206
|
+
"apiVersion": "harnessweaver/v1",
|
|
207
|
+
"kind": "Tool",
|
|
208
|
+
"metadata": {"name": name, "version": "1.0.0"},
|
|
209
|
+
"spec": {
|
|
210
|
+
"transport": "native",
|
|
211
|
+
"handler": handler,
|
|
212
|
+
"description": description,
|
|
213
|
+
"input_schema": input_schema(function),
|
|
214
|
+
"side_effect": side_effect.value,
|
|
215
|
+
"risk": risk.value,
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
try:
|
|
219
|
+
check_schema(ToolManifest.model_validate(manifest).spec.input_schema)
|
|
220
|
+
except (ValidationError, SchemaError) as exc:
|
|
221
|
+
raise fail(f"{handler}: its manifest isn't valid: {exc}") from None
|
|
222
|
+
base = target.resolve().parent
|
|
223
|
+
file = base / directory / f"{name}.tool.yaml"
|
|
224
|
+
if file.exists() and not force:
|
|
225
|
+
raise fail(f"{file} already exists; use --force to overwrite")
|
|
226
|
+
file.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
header = f"# {name}: written by harnessweaver add tool. Review it, then pin it (tools pin).\n"
|
|
228
|
+
file.write_text(header + _yaml.dump(manifest), encoding="utf-8")
|
|
229
|
+
typer.echo(f"wrote {file.relative_to(base)}")
|
|
230
|
+
# Make sure the spec reads the new manifest; add the directory's glob if it doesn't.
|
|
231
|
+
document = _yaml.read(target)
|
|
232
|
+
globs = _manifest_globs(document)
|
|
233
|
+
relative = file.relative_to(base)
|
|
234
|
+
if not any(relative.match(str(g)) for g in globs):
|
|
235
|
+
globs.append(f"{directory.as_posix()}/*.tool.yaml")
|
|
236
|
+
_yaml.write(target, document)
|
|
237
|
+
typer.echo(f" added {directory.as_posix()}/*.tool.yaml to tools.manifests")
|
|
238
|
+
typer.echo(" next: review the description and schema, then run harnessweaver tools pin")
|
harnessweaver_cli/dev.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""``harnessweaver dev``: a local stack for the app, pre-wired to its spec (ADR-0039).
|
|
2
|
+
|
|
3
|
+
It writes ``.harness/dev/`` next to the spec: the LiteLLM config ``gateway
|
|
4
|
+
litellm`` generates, an OpenTelemetry collector config, a Docker Compose file
|
|
5
|
+
with LiteLLM, the collector, Langfuse and harnessd, and a ``.env`` of local
|
|
6
|
+
secrets. The ``.env`` is written once and then kept, so its secrets and the
|
|
7
|
+
values the developer fills in survive the next run; the other files are
|
|
8
|
+
regenerated each time. ``--up`` starts the stack.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import secrets
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from importlib import resources
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Annotated
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from harnessweaver.core.errors import HarnessError
|
|
22
|
+
from harnessweaver.spec import EffectiveSpec, load_spec
|
|
23
|
+
from harnessweaver_cli._load import fail, spec_path
|
|
24
|
+
from harnessweaver_cli.gateway import gateway_tools, litellm_config, render_litellm, upstream_env
|
|
25
|
+
from harnessweaver_cli.spec import Env, Path_, ProfileDirs
|
|
26
|
+
|
|
27
|
+
__all__ = ["compose_file", "dev", "env_file"]
|
|
28
|
+
|
|
29
|
+
PROVIDER_KEYS = ("OPENAI_API_KEY", "ANTHROPIC_API_KEY")
|
|
30
|
+
"""Provider keys LiteLLM reads from its environment; only the gateway holds them (S1)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _token(prefix: str = "") -> str:
|
|
34
|
+
return prefix + secrets.token_hex(16)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def env_file(effective: EffectiveSpec) -> str:
|
|
38
|
+
"""A ``.env`` for the stack: random local secrets, and empty lines for what only
|
|
39
|
+
the developer knows (the provider model behind each gateway model, and the keys).
|
|
40
|
+
|
|
41
|
+
The app's own key variable (``models.api_key_env``) gets LiteLLM's master key,
|
|
42
|
+
so the app, harnessd and the gateway agree without further setup.
|
|
43
|
+
"""
|
|
44
|
+
names = [
|
|
45
|
+
entry["model_name"]
|
|
46
|
+
for entry in litellm_config(effective, gateway_tools(effective))["model_list"]
|
|
47
|
+
]
|
|
48
|
+
master = _token("sk-dev-")
|
|
49
|
+
public, secret = _token("pk-lf-"), _token("sk-lf-")
|
|
50
|
+
app_key = effective.spec.models.provider_settings().get("api_key_env", "LITELLM_API_KEY")
|
|
51
|
+
lines = [
|
|
52
|
+
"# Local secrets for `harnessweaver dev`, generated once. Never commit this file.",
|
|
53
|
+
"#",
|
|
54
|
+
"# The provider model behind each gateway model, as LiteLLM names it (provider/model):",
|
|
55
|
+
*[f"{upstream_env(name)}=" for name in names],
|
|
56
|
+
"# Provider keys, which only the gateway holds (S1):",
|
|
57
|
+
*[f"{key}=" for key in PROVIDER_KEYS],
|
|
58
|
+
"",
|
|
59
|
+
f"LITELLM_MASTER_KEY={master}",
|
|
60
|
+
f"{app_key}={master}",
|
|
61
|
+
f"HARNESSD_TOKEN={_token()}",
|
|
62
|
+
f"POSTGRES_PASSWORD={_token()}",
|
|
63
|
+
f"CLICKHOUSE_PASSWORD={_token()}",
|
|
64
|
+
f"REDIS_AUTH={_token()}",
|
|
65
|
+
f"MINIO_ROOT_PASSWORD={_token()}",
|
|
66
|
+
f"SALT={_token()}",
|
|
67
|
+
f"ENCRYPTION_KEY={secrets.token_hex(32)}",
|
|
68
|
+
f"NEXTAUTH_SECRET={_token()}",
|
|
69
|
+
f"LANGFUSE_PUBLIC_KEY={public}",
|
|
70
|
+
f"LANGFUSE_SECRET_KEY={secret}",
|
|
71
|
+
f"LANGFUSE_OTLP_AUTH={base64.b64encode(f'{public}:{secret}'.encode()).decode()}",
|
|
72
|
+
"LANGFUSE_USER_EMAIL=dev@example.com",
|
|
73
|
+
f"LANGFUSE_USER_PASSWORD={_token()}",
|
|
74
|
+
]
|
|
75
|
+
return "\n".join(lines) + "\n"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def compose_file(spec: Path, env: str | None, profile_dirs: list[Path]) -> str:
|
|
79
|
+
"""The Compose file, for this spec and overlay. Each ``--profile-dir`` folder is
|
|
80
|
+
mounted read-only into harnessd's container and put on its profile path, so it
|
|
81
|
+
loads the spec as the developer's machine does."""
|
|
82
|
+
mounts = [f"/profiles/{index}" for index in range(len(profile_dirs))]
|
|
83
|
+
volumes = "".join(
|
|
84
|
+
f" - {folder.resolve()}:{mount}:ro\n"
|
|
85
|
+
for folder, mount in zip(profile_dirs, mounts, strict=True)
|
|
86
|
+
)
|
|
87
|
+
return (
|
|
88
|
+
_template("compose.yaml")
|
|
89
|
+
.replace("__spec__", spec.name)
|
|
90
|
+
.replace("__env__", env or "")
|
|
91
|
+
.replace("__profile_path__", ":".join(mounts))
|
|
92
|
+
.replace("__profile_volumes__", volumes)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _template(name: str) -> str:
|
|
97
|
+
return (
|
|
98
|
+
resources.files("harnessweaver_cli")
|
|
99
|
+
.joinpath("templates", "_dev", name)
|
|
100
|
+
.read_text(encoding="utf-8")
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def dev(
|
|
105
|
+
path: Path_ = None,
|
|
106
|
+
env: Env = None,
|
|
107
|
+
profile_dir: ProfileDirs = None,
|
|
108
|
+
up: Annotated[bool, typer.Option("--up", help="Start it: docker compose up -d.")] = False,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Write a local stack: LiteLLM (from the spec), an OTel collector, Langfuse and harnessd."""
|
|
111
|
+
found = spec_path(path)
|
|
112
|
+
try:
|
|
113
|
+
effective = load_spec(found, env=env, profile_dirs=profile_dir or [])
|
|
114
|
+
litellm = render_litellm(effective)
|
|
115
|
+
except HarnessError as exc:
|
|
116
|
+
raise fail(exc) from None
|
|
117
|
+
directory = found.resolve().parent / ".harness" / "dev"
|
|
118
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
(directory / ".gitignore").write_text("*\n", encoding="utf-8") # nothing here is committed
|
|
120
|
+
(directory / "litellm.yaml").write_text(litellm, encoding="utf-8")
|
|
121
|
+
(directory / "otel-collector.yaml").write_text(_template("otel-collector.yaml"), "utf-8")
|
|
122
|
+
(directory / "compose.yaml").write_text(compose_file(found, env, profile_dir or []), "utf-8")
|
|
123
|
+
secrets_file = directory / ".env"
|
|
124
|
+
if not secrets_file.exists():
|
|
125
|
+
secrets_file.write_text(env_file(effective), encoding="utf-8")
|
|
126
|
+
typer.echo(f"wrote {secrets_file}: fill in the provider models and keys")
|
|
127
|
+
typer.echo(f"wrote the dev stack to {directory}")
|
|
128
|
+
if not up:
|
|
129
|
+
typer.echo(
|
|
130
|
+
f"start it with: harnessweaver dev --up (or docker compose up -d in {directory})"
|
|
131
|
+
)
|
|
132
|
+
return
|
|
133
|
+
docker = shutil.which("docker")
|
|
134
|
+
if docker is None:
|
|
135
|
+
raise fail("docker isn't installed; install Docker to start the stack")
|
|
136
|
+
done = subprocess.run([docker, "compose", "up", "-d"], cwd=directory, check=False)
|
|
137
|
+
if done.returncode != 0:
|
|
138
|
+
raise typer.Exit(code=done.returncode)
|
|
139
|
+
typer.echo(
|
|
140
|
+
"LiteLLM http://127.0.0.1:4000, harnessd http://127.0.0.1:7070, Langfuse http://127.0.0.1:3000"
|
|
141
|
+
)
|