Skip to content

Commit 1e1e3ff

Browse files
authored
Merge pull request #51 from chiruu12/feat/agentos-evals
AgentOS Phase 3: evals harness (accuracy / reliability / performance)
2 parents 3382c30 + 366a6a1 commit 1e1e3ff

12 files changed

Lines changed: 670 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ src/hive/
9999
| Custom tool | Subclass `Toolkit`, `@tool()` methods | `src/hive/tools/base.py` |
100100
| Gated tool (HITL) | `@tool(requires_approval=True)` or `ApprovalGate` protocol | `src/hive/runtime/approval.py` |
101101
| Custom guardrail | `Guardrail` protocol, `GuardrailRegistry.default().register(...)` | `src/hive/runtime/guardrails.py` |
102+
| Custom evaluator | `Evaluator` protocol, add to `EvalSuite` | `src/hive/evals/types.py` |
102103
| Custom model provider | Subclass `BaseProvider` | `src/hive/models/base.py` |
103104
| Custom stressor | `StressorRegistry.default().register(name, rate, desc)` | `src/hive/agents/suffering.py` |
104105
| Custom A2A pattern | Subclass `A2APattern`, `PatternRegistry.default().register(name, instance)` | `src/hive/interactions/registry.py` |

docs/changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@
2424
jailbreak phrasing). Config-driven (`guardrails.enabled`, per-guardrail `flag`/
2525
`redact`/`block` actions); `GuardrailRegistry` and `GuardrailPipeline` compose custom
2626
guardrails. Off by default. `Agent(guardrails=...)`.
27+
- **Evals harness** (`hive.evals`): score agent runs for **accuracy** (LLM-as-judge
28+
vs an expected answer), **reliability** (asserts expected tool calls fired), and
29+
**performance** (latency/cost SLOs). `EvalCase`/`EvalSuite`/`AgentEvalRunner` run
30+
each case once and score it with every evaluator; `Evaluator` protocol for custom
31+
checks. `Agent.observe_tools(...)` captures tool-call traces; `TaskResult` now
32+
carries `cost_usd`/`total_tokens`.
2733

2834
## [0.6.1] -- 2026-06-03
2935

docs/guide/evals.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Evals
2+
3+
The evals harness scores agent runs so you can measure quality and catch
4+
regressions. Each case runs through the agent once and is judged on three axes:
5+
6+
- **Accuracy** -- LLM-as-judge scores the output against an expected answer (0..1).
7+
- **Reliability** -- asserts the agent actually called the expected tools.
8+
- **Performance** -- checks latency and cost against SLOs.
9+
10+
## Quick start
11+
12+
```python
13+
from hive.evals import (
14+
AgentEvalRunner, EvalSuite, EvalCase,
15+
AccuracyEval, ReliabilityEval, PerformanceEval,
16+
)
17+
from hive.runtime import Agent
18+
from hive.models.anthropic import Anthropic
19+
20+
agent = Agent(name="solver", model=Anthropic.lite(), toolkits=[...])
21+
22+
suite = EvalSuite(
23+
AgentEvalRunner(agent),
24+
[
25+
AccuracyEval(judge=Anthropic.standard(), threshold=0.6),
26+
ReliabilityEval(), # expected tools must be called
27+
PerformanceEval(max_seconds=10, max_cost_usd=0.02),
28+
],
29+
)
30+
31+
reports = await suite.run([
32+
EvalCase(
33+
"What is 17 * 23?",
34+
expected_answer="391",
35+
expected_tools=["calculator"],
36+
),
37+
])
38+
39+
for name, report in reports.items():
40+
print(report.summary())
41+
# {'evaluator': 'accuracy', 'total': 1, 'scored': 1, 'skipped': 0,
42+
# 'passed': 1, 'pass_rate': 1.0, 'mean_score': 0.9}
43+
```
44+
45+
Cases an evaluator can't judge (e.g. accuracy with no `expected_answer`) are marked
46+
**skipped** and excluded from `pass_rate` / `mean_score`, so they never inflate the
47+
metrics; `summary()` reports the `scored` and `skipped` counts separately.
48+
49+
## Cases
50+
51+
An `EvalCase` carries the instruction plus what a good run should produce. Fields are
52+
optional -- an evaluator with nothing to check (e.g. accuracy with no
53+
`expected_answer`) passes the case as skipped.
54+
55+
| Field | Used by | Meaning |
56+
|-------|---------|---------|
57+
| `instruction` | all | The task given to the agent |
58+
| `expected_answer` | accuracy | Reference answer the judge grades against |
59+
| `expected_tools` | reliability | Tool names the agent should call |
60+
| `context` | all | Extra context passed into the task |
61+
62+
## Evaluators
63+
64+
| Evaluator | Pass condition | Score |
65+
|-----------|----------------|-------|
66+
| `AccuracyEval(judge, threshold=0.6)` | judge score ≥ threshold | judge's 0..10 rating / 10 |
67+
| `ReliabilityEval(mode="all")` | expected tools called (`"exact"` = exact set) | fraction of expected tools hit |
68+
| `PerformanceEval(max_seconds, max_cost_usd)` | within both budgets | headroom against the tighter budget |
69+
70+
## Reports
71+
72+
`suite.run(cases)` returns `{evaluator_name: EvalReport}`. An `EvalReport` exposes
73+
`total`, `passed`, `pass_rate`, `mean_score`, and `summary()`, plus the per-case
74+
`results` (each with `passed`, `score`, `detail`, and the captured `run`).
75+
76+
## Custom evaluators
77+
78+
Implement the `Evaluator` protocol (a `name` and an async `evaluate(run, case)`
79+
returning a `CaseResult`) and drop it into the suite alongside the built-ins.
80+
81+
```python
82+
from hive.evals.types import CaseResult, EvalCase, EvalRun
83+
84+
class NonEmptyEval:
85+
name = "non_empty"
86+
87+
async def evaluate(self, run: EvalRun, case: EvalCase) -> CaseResult:
88+
ok = bool(run.output.strip())
89+
return CaseResult(case, self.name, ok, 1.0 if ok else 0.0, "", run)
90+
```

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ nav:
6565
- Persona System: guide/persona.md
6666
- CLI Reference: guide/cli-reference.md
6767
- REST API: guide/rest-api.md
68+
- Evals: guide/evals.md
6869
- Architecture: guide/architecture.md
6970
- Extending:
7071
- Extension Points: extending/index.md

src/hive/evals/__init__.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Evals harness -- score agent runs for accuracy, reliability, and performance.
2+
3+
```python
4+
from hive.evals import AgentEvalRunner, EvalSuite, EvalCase
5+
from hive.evals import AccuracyEval, ReliabilityEval, PerformanceEval
6+
7+
suite = EvalSuite(
8+
AgentEvalRunner(agent),
9+
[AccuracyEval(judge=provider), ReliabilityEval(), PerformanceEval(max_seconds=10)],
10+
)
11+
reports = await suite.run([
12+
EvalCase("What is 2+2?", expected_answer="4", expected_tools=["calculator"]),
13+
])
14+
print(reports["accuracy"].summary())
15+
```
16+
"""
17+
18+
from hive.evals.evaluators import AccuracyEval, PerformanceEval, ReliabilityEval
19+
from hive.evals.runner import AgentEvalRunner, EvalSuite
20+
from hive.evals.types import CaseResult, EvalCase, EvalReport, EvalRun, Evaluator
21+
22+
__all__ = [
23+
"AccuracyEval",
24+
"PerformanceEval",
25+
"ReliabilityEval",
26+
"AgentEvalRunner",
27+
"EvalSuite",
28+
"EvalCase",
29+
"EvalRun",
30+
"CaseResult",
31+
"EvalReport",
32+
"Evaluator",
33+
]

src/hive/evals/evaluators.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Built-in evaluators: accuracy (LLM judge), reliability (tools), performance (SLOs)."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from typing import TYPE_CHECKING
7+
8+
from hive.evals.types import CaseResult, EvalCase, EvalRun
9+
from hive.runtime.types import Message
10+
11+
if TYPE_CHECKING:
12+
from hive.models.base import BaseProvider
13+
14+
_JUDGE_RUBRIC = (
15+
"Rate how well the actual answer satisfies the expected answer on a scale of 0 to "
16+
"10 (10 = fully correct and complete, 0 = wrong or missing). Reply with ONLY the "
17+
"number on the first line, then a one-sentence justification."
18+
)
19+
20+
21+
def _build_judge_prompt(instruction: str, expected: str, actual: str) -> str:
22+
"""Build the judge prompt by concatenation.
23+
24+
Avoids ``str.format``/f-string substitution so brace characters in the instruction,
25+
expected answer, or actual output (e.g. JSON like ``{"k": 1}``) can't raise.
26+
"""
27+
return (
28+
"You are grading an AI assistant's answer.\n\n"
29+
f"Task given to the assistant:\n{instruction}\n\n"
30+
f"Expected answer (reference):\n{expected}\n\n"
31+
f"Assistant's actual answer:\n{actual}\n\n"
32+
f"{_JUDGE_RUBRIC}"
33+
)
34+
35+
36+
class AccuracyEval:
37+
"""LLM-as-judge: score the agent's output against an expected answer (0..1)."""
38+
39+
name = "accuracy"
40+
41+
def __init__(self, judge: BaseProvider, threshold: float = 0.6):
42+
self._judge = judge
43+
self._threshold = threshold
44+
45+
async def evaluate(self, run: EvalRun, case: EvalCase) -> CaseResult:
46+
if case.expected_answer is None:
47+
return CaseResult(
48+
case, self.name, True, 1.0, "no expected_answer (skipped)", run, skipped=True
49+
)
50+
prompt = _build_judge_prompt(case.instruction, case.expected_answer, run.output)
51+
result = await self._judge.generate_with_metadata(messages=[Message.user(prompt)])
52+
score, detail = self._parse(result.message.content)
53+
return CaseResult(case, self.name, score >= self._threshold, score, detail, run)
54+
55+
@staticmethod
56+
def _parse(text: str) -> tuple[float, str]:
57+
match = re.search(r"\b(10|\d)(?:\.\d+)?\b", text)
58+
if not match:
59+
return 0.0, f"unparseable judge reply: {text[:80]}"
60+
score = min(1.0, float(match.group(0)) / 10.0)
61+
return score, text.strip()[:200]
62+
63+
64+
class ReliabilityEval:
65+
"""Assert the agent actually called the expected tools.
66+
67+
``mode="all"`` (default): every expected tool must appear. ``mode="exact"``: the
68+
set of called tools must equal the expected set.
69+
"""
70+
71+
name = "reliability"
72+
73+
def __init__(self, mode: str = "all"):
74+
if mode not in ("all", "exact"):
75+
raise ValueError("mode must be 'all' or 'exact'")
76+
self._mode = mode
77+
78+
async def evaluate(self, run: EvalRun, case: EvalCase) -> CaseResult:
79+
expected = set(case.expected_tools or [])
80+
actual = set(run.tool_calls)
81+
if not expected:
82+
return CaseResult(
83+
case, self.name, True, 1.0, "no expected_tools (skipped)", run, skipped=True
84+
)
85+
hit = expected & actual
86+
score = len(hit) / len(expected)
87+
if self._mode == "exact":
88+
passed = actual == expected
89+
else:
90+
passed = expected <= actual
91+
missing = sorted(expected - actual)
92+
extra = sorted(actual - expected)
93+
detail = f"called={sorted(actual)} missing={missing} extra={extra}"
94+
return CaseResult(case, self.name, passed, score, detail, run)
95+
96+
97+
class PerformanceEval:
98+
"""Check a run against latency and/or cost SLOs."""
99+
100+
name = "performance"
101+
102+
def __init__(self, max_seconds: float | None = None, max_cost_usd: float | None = None):
103+
self._max_seconds = max_seconds
104+
self._max_cost_usd = max_cost_usd
105+
106+
async def evaluate(self, run: EvalRun, case: EvalCase) -> CaseResult:
107+
ok_time = self._max_seconds is None or run.duration_seconds <= self._max_seconds
108+
ok_cost = self._max_cost_usd is None or run.cost_usd <= self._max_cost_usd
109+
passed = ok_time and ok_cost
110+
# Score is the worse of the two budget ratios (1.0 = well within budget).
111+
# Guard with `is not None` (not truthiness) so a 0.0 budget is still scored --
112+
# otherwise passed=False could pair with an empty-ratios score of 1.0.
113+
ratios = []
114+
if self._max_seconds is not None:
115+
ratios.append(
116+
max(0.0, 1.0 - run.duration_seconds / self._max_seconds)
117+
if self._max_seconds > 0
118+
else (0.0 if run.duration_seconds > 0 else 1.0)
119+
)
120+
if self._max_cost_usd is not None:
121+
ratios.append(
122+
max(0.0, 1.0 - run.cost_usd / self._max_cost_usd)
123+
if self._max_cost_usd > 0
124+
else (0.0 if run.cost_usd > 0 else 1.0)
125+
)
126+
score = min(ratios) if ratios else 1.0
127+
detail = f"{run.duration_seconds:.2f}s, ${run.cost_usd:.4f}"
128+
return CaseResult(case, self.name, passed, score, detail, run)

src/hive/evals/runner.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Run eval cases through an agent and score them with evaluators."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
from typing import TYPE_CHECKING
7+
8+
from hive.evals.types import EvalCase, EvalReport, EvalRun, Evaluator
9+
from hive.runtime.types import Task
10+
11+
if TYPE_CHECKING:
12+
from hive.runtime.agent import Agent
13+
14+
15+
class AgentEvalRunner:
16+
"""Runs an ``EvalCase`` through an ``Agent``, capturing its tool-call trace.
17+
18+
The same agent runs many cases, so the runner mutates shared agent state (the
19+
tool observer and per-run counters). A lock serializes ``run`` calls on the same
20+
runner: concurrent calls (e.g. ``asyncio.gather(runner.run(a), runner.run(b))``)
21+
would otherwise overwrite each other's observer and interleave tool traces. To
22+
evaluate cases in parallel, use one runner (and Agent) per concurrent run.
23+
"""
24+
25+
def __init__(self, agent: Agent):
26+
self._agent = agent
27+
self._lock = asyncio.Lock()
28+
29+
async def run(self, case: EvalCase) -> EvalRun:
30+
async with self._lock:
31+
calls: list[str] = []
32+
# Snapshot and restore any pre-existing observer instead of clearing it,
33+
# so an agent built with Agent(on_tool=...) keeps its callback afterward.
34+
previous_on_tool = self._agent._on_tool
35+
self._agent.observe_tools(lambda name, args, ok: calls.append(name))
36+
try:
37+
result = await self._agent.run(
38+
Task(instruction=case.instruction, context=case.context)
39+
)
40+
finally:
41+
self._agent.observe_tools(previous_on_tool)
42+
return EvalRun(
43+
instruction=case.instruction,
44+
output=result.output,
45+
status=result.status.value,
46+
tool_calls=calls,
47+
duration_seconds=result.duration_seconds,
48+
cost_usd=result.cost_usd,
49+
steps=result.steps_taken,
50+
)
51+
52+
53+
class EvalSuite:
54+
"""Run a list of cases once each, scoring every run with every evaluator."""
55+
56+
def __init__(self, runner: AgentEvalRunner, evaluators: list[Evaluator]):
57+
names = [e.name for e in evaluators]
58+
dupes = sorted({n for n in names if names.count(n) > 1})
59+
if dupes:
60+
raise ValueError(
61+
f"duplicate evaluator name(s) {dupes}: each evaluator needs a unique "
62+
f"name, else their reports collide"
63+
)
64+
self._runner = runner
65+
self._evaluators = evaluators
66+
67+
async def run(self, cases: list[EvalCase]) -> dict[str, EvalReport]:
68+
reports = {e.name: EvalReport(evaluator=e.name) for e in self._evaluators}
69+
for case in cases:
70+
run = await self._runner.run(case)
71+
for evaluator in self._evaluators:
72+
result = await evaluator.evaluate(run, case)
73+
reports[evaluator.name].results.append(result)
74+
return reports

0 commit comments

Comments
 (0)