feat: instance manifest, tool profiles, metrics, and budget enforcement
Land HARNESS-WP-0001 T01/T02/T04/T05: extend ADR-005 schedule.yml with harness fields, named tool-profile registry, ADR-004 metrics writes, and BudgetTracker wiring. CLI gains validate/profiles; task-file path kept.
This commit is contained in:
parent
16f5c29c08
commit
4144eba160
17 changed files with 1387 additions and 60 deletions
215
tests/test_manifest.py
Normal file
215
tests/test_manifest.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from agent_harness.cli import main
|
||||
from agent_harness.manifest import (
|
||||
HARNESS_MAJOR,
|
||||
ManifestError,
|
||||
load_manifest,
|
||||
parse_manifest,
|
||||
resolve_run_policy,
|
||||
validate_manifest,
|
||||
)
|
||||
from agent_harness.profiles import UnknownToolProfileError, get_profile, list_profiles
|
||||
|
||||
|
||||
def _write_manifest(tmp_path: Path, data: dict) -> Path:
|
||||
kaizen = tmp_path / ".kaizen"
|
||||
kaizen.mkdir()
|
||||
path = kaizen / "schedule.yml"
|
||||
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_parse_and_validate_adr005_only() -> None:
|
||||
manifest = parse_manifest(
|
||||
{
|
||||
"version": "1",
|
||||
"timezone": "Europe/Berlin",
|
||||
"agents": {
|
||||
"coach": {"cadence": "weekly", "enabled": True},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert validate_manifest(manifest) == []
|
||||
assert validate_manifest(manifest, require_harness_fields=True)
|
||||
|
||||
|
||||
def test_validate_harness_extensions() -> None:
|
||||
manifest = parse_manifest(
|
||||
{
|
||||
"version": "1",
|
||||
"harness": HARNESS_MAJOR,
|
||||
"agents": {
|
||||
"coach": {
|
||||
"cadence": "daily",
|
||||
"enabled": True,
|
||||
"lane": "green",
|
||||
"tool_profile": "green-commit-only",
|
||||
"budget": 50000,
|
||||
},
|
||||
"mail": {
|
||||
"cadence": "weekly",
|
||||
"lane": "blue",
|
||||
"tool_profile": "blue-mail-triage",
|
||||
"budget": 10000,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert validate_manifest(manifest) == []
|
||||
assert validate_manifest(manifest, require_harness_fields=True) == []
|
||||
|
||||
|
||||
def test_unknown_tool_profile_errors() -> None:
|
||||
manifest = parse_manifest(
|
||||
{
|
||||
"version": "1",
|
||||
"agents": {
|
||||
"coach": {
|
||||
"cadence": "daily",
|
||||
"tool_profile": "does-not-exist",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
errors = validate_manifest(manifest)
|
||||
assert any("unknown tool_profile" in e for e in errors)
|
||||
|
||||
|
||||
def test_lane_profile_mismatch() -> None:
|
||||
manifest = parse_manifest(
|
||||
{
|
||||
"version": "1",
|
||||
"agents": {
|
||||
"coach": {
|
||||
"cadence": "daily",
|
||||
"lane": "blue",
|
||||
"tool_profile": "green-commit-only",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
errors = validate_manifest(manifest)
|
||||
assert any("does not match tool_profile" in e for e in errors)
|
||||
|
||||
|
||||
def test_budget_must_be_positive() -> None:
|
||||
with pytest.raises(ManifestError, match="budget"):
|
||||
parse_manifest(
|
||||
{
|
||||
"version": "1",
|
||||
"agents": {"coach": {"cadence": "daily", "budget": 0}},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_harness_major_mismatch() -> None:
|
||||
manifest = parse_manifest(
|
||||
{
|
||||
"version": "1",
|
||||
"harness": HARNESS_MAJOR + 1,
|
||||
"agents": {"coach": {"cadence": "daily"}},
|
||||
}
|
||||
)
|
||||
errors = validate_manifest(manifest)
|
||||
assert any("does not match this runtime" in e for e in errors)
|
||||
|
||||
|
||||
def test_resolve_run_policy_defaults(tmp_path: Path) -> None:
|
||||
profile, budget, lane = resolve_run_policy(tmp_path, "coach")
|
||||
assert profile == "green-commit-only"
|
||||
assert budget is None
|
||||
assert lane is None
|
||||
|
||||
|
||||
def test_resolve_run_policy_from_manifest(tmp_path: Path) -> None:
|
||||
_write_manifest(
|
||||
tmp_path,
|
||||
{
|
||||
"version": "1",
|
||||
"harness": HARNESS_MAJOR,
|
||||
"agents": {
|
||||
"coach": {
|
||||
"cadence": "daily",
|
||||
"tool_profile": "blue-mail-triage",
|
||||
"lane": "blue",
|
||||
"budget": 12000,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
profile, budget, lane = resolve_run_policy(tmp_path, "coach")
|
||||
assert profile == "blue-mail-triage"
|
||||
assert budget == 12000
|
||||
assert lane == "blue"
|
||||
|
||||
|
||||
def test_resolve_unknown_profile_refuses(tmp_path: Path) -> None:
|
||||
_write_manifest(
|
||||
tmp_path,
|
||||
{
|
||||
"version": "1",
|
||||
"agents": {
|
||||
"coach": {
|
||||
"cadence": "daily",
|
||||
"tool_profile": "nope",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
with pytest.raises(UnknownToolProfileError):
|
||||
resolve_run_policy(tmp_path, "coach")
|
||||
|
||||
|
||||
def test_load_manifest_missing(tmp_path: Path) -> None:
|
||||
with pytest.raises(ManifestError, match="not found"):
|
||||
load_manifest(tmp_path / ".kaizen" / "schedule.yml")
|
||||
|
||||
|
||||
def test_profiles_registry() -> None:
|
||||
names = {p.name for p in list_profiles()}
|
||||
assert names == {"green-commit-only", "blue-mail-triage"}
|
||||
green = get_profile("green-commit-only")
|
||||
assert "git commit" in green.allowed_tools
|
||||
assert "git push" not in green.allowed_tools
|
||||
|
||||
|
||||
def test_cli_validate_ok(tmp_path: Path) -> None:
|
||||
_write_manifest(
|
||||
tmp_path,
|
||||
{
|
||||
"version": "1",
|
||||
"harness": HARNESS_MAJOR,
|
||||
"agents": {
|
||||
"coach": {
|
||||
"cadence": "daily",
|
||||
"lane": "green",
|
||||
"tool_profile": "green-commit-only",
|
||||
"budget": 1000,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
assert main(["validate", "--target", str(tmp_path), "--strict"]) == 0
|
||||
|
||||
|
||||
def test_cli_validate_fails_unknown_profile(tmp_path: Path) -> None:
|
||||
_write_manifest(
|
||||
tmp_path,
|
||||
{
|
||||
"version": "1",
|
||||
"agents": {
|
||||
"coach": {"cadence": "daily", "tool_profile": "missing"}
|
||||
},
|
||||
},
|
||||
)
|
||||
assert main(["validate", "--target", str(tmp_path)]) == 1
|
||||
|
||||
|
||||
def test_cli_profiles() -> None:
|
||||
assert main(["profiles"]) == 0
|
||||
51
tests/test_metrics.py
Normal file
51
tests/test_metrics.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from agent_harness.metrics import record_execution, regenerate_summary
|
||||
|
||||
|
||||
def test_record_execution_writes_jsonl_and_summary(tmp_path: Path) -> None:
|
||||
path = record_execution(
|
||||
tmp_path,
|
||||
"coach",
|
||||
success=True,
|
||||
execution_time_s=12.5,
|
||||
tokens=100,
|
||||
committed=True,
|
||||
head_after="abc123",
|
||||
reason=None,
|
||||
metadata={"task_title": "hello"},
|
||||
)
|
||||
assert path.is_file()
|
||||
lines = path.read_text(encoding="utf-8").strip().splitlines()
|
||||
assert len(lines) == 1
|
||||
rec = json.loads(lines[0])
|
||||
assert rec["agent"] == "coach"
|
||||
assert rec["success"] is True
|
||||
assert rec["tokens"] == 100
|
||||
assert rec["harness"] == "agent-harness"
|
||||
assert rec["metadata"]["task_title"] == "hello"
|
||||
|
||||
summary = json.loads(
|
||||
(tmp_path / ".kaizen" / "metrics" / "coach" / "summary.json").read_text()
|
||||
)
|
||||
assert summary["execution_count"] == 1
|
||||
assert summary["success_rate"] == 1.0
|
||||
assert summary["avg_execution_time_s"] == 12.5
|
||||
|
||||
|
||||
def test_summary_aggregates_multiple(tmp_path: Path) -> None:
|
||||
record_execution(tmp_path, "coach", success=True, execution_time_s=10)
|
||||
record_execution(tmp_path, "coach", success=False, execution_time_s=20)
|
||||
summary = json.loads(
|
||||
(tmp_path / ".kaizen" / "metrics" / "coach" / "summary.json").read_text()
|
||||
)
|
||||
assert summary["execution_count"] == 2
|
||||
assert summary["success_rate"] == 0.5
|
||||
assert summary["avg_execution_time_s"] == 15.0
|
||||
|
||||
|
||||
def test_regenerate_summary_empty() -> None:
|
||||
assert regenerate_summary("x", [])["execution_count"] == 0
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from agent_harness.manifest import HARNESS_MAJOR
|
||||
from agent_harness.runner import RunResult, run_task
|
||||
from agent_harness.taskspec import TaskSpec, TaskSpecError
|
||||
|
||||
|
|
@ -23,15 +26,26 @@ def _make_repo(tmp_path: Path) -> Path:
|
|||
return repo
|
||||
|
||||
|
||||
def _write_manifest(repo: Path, agents: dict) -> None:
|
||||
kaizen = repo / ".kaizen"
|
||||
kaizen.mkdir(exist_ok=True)
|
||||
data = {"version": "1", "harness": HARNESS_MAJOR, "agents": agents}
|
||||
(kaizen / "schedule.yml").write_text(
|
||||
yaml.safe_dump(data, sort_keys=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
class CommittingAdapter:
|
||||
"""Fake adapter that simulates a session which commits."""
|
||||
|
||||
def __init__(self, repo: Path):
|
||||
self.repo = repo
|
||||
self.prompts: list[str] = []
|
||||
self.configs: list = []
|
||||
|
||||
def execute_prompt(self, prompt, config):
|
||||
self.prompts.append(prompt)
|
||||
self.configs.append(config)
|
||||
(self.repo / "HELLO.md").write_text("hello\n")
|
||||
subprocess.run(["git", "add", "."], cwd=self.repo, check=True)
|
||||
subprocess.run(
|
||||
|
|
@ -51,34 +65,140 @@ class IdleAdapter:
|
|||
return LLMResponse(content="nothing to do", model="fake", usage={}, finish_reason="stop")
|
||||
|
||||
|
||||
def _spec(repo: Path) -> TaskSpec:
|
||||
return TaskSpec(title="write hello", description="create HELLO.md", target_repo=repo)
|
||||
class BudgetBlowingAdapter:
|
||||
def execute_prompt(self, prompt, config):
|
||||
from llm_connect.exceptions import LLMBudgetExceededError
|
||||
|
||||
if config.budget_tracker is not None:
|
||||
# Simulate preflight/exhaustion the way adapters do.
|
||||
config.budget_tracker.consume(config.budget_tracker.total)
|
||||
config.budget_tracker.consume(1)
|
||||
raise LLMBudgetExceededError(
|
||||
"Token budget exceeded",
|
||||
total=1,
|
||||
spent=1,
|
||||
requested=1,
|
||||
)
|
||||
|
||||
|
||||
def _spec(repo: Path, agent: str = "coach") -> TaskSpec:
|
||||
return TaskSpec(
|
||||
title="write hello",
|
||||
description="create HELLO.md",
|
||||
target_repo=repo,
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
|
||||
def test_run_task_success_when_session_commits(tmp_path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
adapter = CommittingAdapter(repo)
|
||||
|
||||
result = run_task(_spec(repo), adapter=adapter, report_to_hub=False)
|
||||
result = run_task(
|
||||
_spec(repo), adapter=adapter, report_to_hub=False, write_metrics=True
|
||||
)
|
||||
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.ok is True
|
||||
assert result.committed is True
|
||||
assert result.head_before != result.head_after
|
||||
assert result.tool_profile == "green-commit-only"
|
||||
assert "write hello" in adapter.prompts[0]
|
||||
assert "Never push" in adapter.prompts[0]
|
||||
assert "Tool profile for this run: green-commit-only" in adapter.prompts[0]
|
||||
|
||||
metrics_path = repo / ".kaizen" / "metrics" / "coach" / "executions.jsonl"
|
||||
assert metrics_path.is_file()
|
||||
rec = json.loads(metrics_path.read_text().strip().splitlines()[-1])
|
||||
assert rec["success"] is True
|
||||
assert rec["committed"] is True
|
||||
|
||||
|
||||
def test_run_task_fails_without_commit(tmp_path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
|
||||
result = run_task(_spec(repo), adapter=IdleAdapter(), report_to_hub=False)
|
||||
result = run_task(
|
||||
_spec(repo), adapter=IdleAdapter(), report_to_hub=False, write_metrics=False
|
||||
)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.committed is False
|
||||
assert result.reason == "session completed without committing"
|
||||
|
||||
|
||||
def test_run_task_refuses_unknown_tool_profile(tmp_path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
_write_manifest(
|
||||
repo,
|
||||
{
|
||||
"coach": {
|
||||
"cadence": "daily",
|
||||
"tool_profile": "not-a-real-profile",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
result = run_task(
|
||||
_spec(repo), adapter=IdleAdapter(), report_to_hub=False, write_metrics=False
|
||||
)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.reason.startswith("refused:")
|
||||
assert "not-a-real-profile" in result.reason
|
||||
|
||||
|
||||
def test_run_task_resolves_manifest_profile_and_budget(tmp_path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
_write_manifest(
|
||||
repo,
|
||||
{
|
||||
"coach": {
|
||||
"cadence": "daily",
|
||||
"lane": "blue",
|
||||
"tool_profile": "blue-mail-triage",
|
||||
"budget": 25000,
|
||||
}
|
||||
},
|
||||
)
|
||||
adapter = CommittingAdapter(repo)
|
||||
|
||||
result = run_task(
|
||||
_spec(repo), adapter=adapter, report_to_hub=False, write_metrics=False
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.tool_profile == "blue-mail-triage"
|
||||
assert result.budget_tokens == 25000
|
||||
assert adapter.configs[0].budget_tracker is not None
|
||||
assert adapter.configs[0].budget_tracker.total == 25000
|
||||
assert "blue-mail-triage" in adapter.prompts[0]
|
||||
|
||||
|
||||
def test_run_task_budget_exhaustion_fails(tmp_path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
_write_manifest(
|
||||
repo,
|
||||
{
|
||||
"coach": {
|
||||
"cadence": "daily",
|
||||
"tool_profile": "green-commit-only",
|
||||
"budget": 10,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
result = run_task(
|
||||
_spec(repo),
|
||||
adapter=BudgetBlowingAdapter(),
|
||||
report_to_hub=False,
|
||||
write_metrics=False,
|
||||
)
|
||||
|
||||
assert result.ok is False
|
||||
assert "session failed" in result.reason
|
||||
assert result.budget_tokens == 10
|
||||
|
||||
|
||||
def test_taskspec_rejects_non_repo(tmp_path) -> None:
|
||||
spec_file = tmp_path / "task.json"
|
||||
spec_file.write_text(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue