aehf 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aehf/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("aehf")
5
+ except PackageNotFoundError: # not installed (e.g. running from a raw checkout)
6
+ __version__ = "0.0.0"
File without changes
@@ -0,0 +1,92 @@
1
+ import time
2
+
3
+ from anthropic import AsyncAnthropic
4
+ from anthropic.types import MessageParam, ToolParam, ToolResultBlockParam
5
+
6
+ from aehf.core.case import EvalCase
7
+ from aehf.core.transcript import Step, Termination, ToolCall, Transcript
8
+ from aehf.tools.base import ToolProviderFactory
9
+
10
+
11
+ class AnthropicAdapter:
12
+
13
+ def __init__(self, client: AsyncAnthropic, provider_factory: ToolProviderFactory, model: str, max_tokens: int) -> None:
14
+ self.client = client
15
+ self.provider_factory = provider_factory
16
+ self.model = model
17
+ self.max_tokens = max_tokens
18
+
19
+ async def run(self, case: EvalCase) -> Transcript:
20
+ toolprovider = self.provider_factory(case)
21
+ tool_spec: list[ToolParam] = [
22
+ ToolParam(name=t.name, description=t.description or "", input_schema=t.parameters)
23
+ for t in case.tools
24
+ ]
25
+ messages: list[MessageParam] = [{"role": "user", "content": case.task_prompt}]
26
+ steps: list[Step] = []
27
+ total_tokens = 0
28
+ final_text = ""
29
+ start_time = time.monotonic()
30
+
31
+ while True:
32
+ # enforce budgets before spending, not after
33
+ if len(steps) >= case.max_steps:
34
+ termination_reason = Termination.max_steps
35
+ break
36
+ if total_tokens >= case.token_budget:
37
+ termination_reason = Termination.budget_exceeded
38
+ break
39
+
40
+ response = await self.client.messages.create(
41
+ model=self.model,
42
+ max_tokens=self.max_tokens,
43
+ tools=tool_spec,
44
+ messages=messages,
45
+ )
46
+ messages.append({"role": "assistant", "content": response.content})
47
+ # input tokens count every turn: each call re-reads the whole conversation
48
+ step_tokens = response.usage.input_tokens + response.usage.output_tokens
49
+ total_tokens += step_tokens
50
+
51
+ step_text = ""
52
+ toolcalls: list[ToolCall] = []
53
+ tool_results: list[ToolResultBlockParam] = []
54
+ for block in response.content:
55
+ if block.type == "text":
56
+ step_text = block.text
57
+ elif block.type == "tool_use":
58
+ tool_start_time = time.monotonic()
59
+ result = await toolprovider.execute(block.name, block.input)
60
+ tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": result})
61
+ toolcalls.append(ToolCall(
62
+ toolname=block.name,
63
+ arguments=block.input,
64
+ result=result,
65
+ latency=time.monotonic() - tool_start_time,
66
+ ))
67
+
68
+ steps.append(Step(model_output=step_text, tool_calls=toolcalls, token_usage=step_tokens))
69
+ if step_text:
70
+ final_text = step_text
71
+ if tool_results:
72
+ messages.append({"role": "user", "content": tool_results})
73
+
74
+ if response.stop_reason == "end_turn":
75
+ termination_reason = Termination.finished
76
+ break
77
+ if response.stop_reason == "refusal":
78
+ termination_reason = Termination.refused
79
+ break
80
+ if response.stop_reason != "tool_use":
81
+ # max_tokens, pause_turn, or anything the API adds later: stop, don't spin
82
+ termination_reason = Termination.unexpected_stop
83
+ break
84
+
85
+ return Transcript(
86
+ id=case.id,
87
+ ordered_steps=steps,
88
+ final_answer=final_text,
89
+ total_tokens=total_tokens,
90
+ duration_seconds=time.monotonic() - start_time,
91
+ termination_reason=termination_reason,
92
+ )
aehf/adapters/base.py ADDED
@@ -0,0 +1,14 @@
1
+ from typing import Protocol, runtime_checkable
2
+
3
+ from aehf.core.case import EvalCase
4
+ from aehf.core.transcript import Transcript
5
+
6
+
7
+ @runtime_checkable
8
+ class Agent(Protocol):
9
+
10
+ async def run(self, case: EvalCase) -> Transcript:
11
+ ...
12
+
13
+
14
+
aehf/adapters/fake.py ADDED
@@ -0,0 +1,12 @@
1
+
2
+ from aehf.core.case import EvalCase
3
+ from aehf.core.transcript import Transcript
4
+
5
+
6
+ class FakeAgent:
7
+ def __init__(self, transcripts:dict[str,Transcript]) -> None:
8
+ self.transcripts = transcripts
9
+
10
+ async def run(self, case : EvalCase) -> Transcript:
11
+
12
+ return self.transcripts[case.id]
aehf/cli.py ADDED
@@ -0,0 +1,295 @@
1
+ import asyncio
2
+ import os
3
+ import subprocess
4
+ from enum import Enum
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from anthropic import AsyncAnthropic
9
+ from dotenv import load_dotenv
10
+
11
+ from aehf.adapters.anthropic import AnthropicAdapter
12
+ from aehf.adapters.base import Agent
13
+ from aehf.core.loader import SuiteLoadError, load_suite
14
+ from aehf.core.results import SuiteResult, Verdict, load_suite_result, save_suite_result
15
+ from aehf.core.runner import run_suite
16
+ from aehf.core.transcript import Termination
17
+ from aehf.judges.assertionjudge import AssertionJudge
18
+ from aehf.judges.base import Judge
19
+ from aehf.judges.calibration import (
20
+ LabeledTranscript,
21
+ LabelLoadError,
22
+ cohen_kappa,
23
+ export_unlabeled,
24
+ load_labeled,
25
+ render_for_label,
26
+ save_labeled,
27
+ )
28
+ from aehf.judges.llmjudge import LLMJudge
29
+ from aehf.judges.runner import judge_suite
30
+ from aehf.regression.diff import DiffResult, diff_runs, is_regression
31
+ from aehf.regression.store import RunNotFoundError, load_run, save_run
32
+ from aehf.reporting.markdown import render_markdown
33
+ from aehf.stats.aggregate import CaseStats, aggregate
34
+ from aehf.stats.compare import ComparisonResult, compare_result
35
+ from aehf.tools.base import ToolProviderFactory
36
+ from aehf.tools.mock import mock_provider_factory
37
+ from aehf.tools.replay import record_provider_factory, replay_provider_factory
38
+
39
+ app = typer.Typer()
40
+
41
+
42
+ class AgentChoice(Enum):
43
+ anthropic = "anthropic"
44
+
45
+ class ToolChoice(Enum):
46
+ mock = "mock"
47
+ record = "record"
48
+ replay = "replay"
49
+
50
+ class JudgeChoice(Enum):
51
+ assertion = "assertion"
52
+ llm = "llm"
53
+ def build_agent(agent: AgentChoice, tools : ToolChoice, recordings_dir:Path, model : str, max_tokens:int) -> Agent:
54
+
55
+ if tools == ToolChoice.mock:
56
+ factory : ToolProviderFactory = mock_provider_factory
57
+ elif tools == ToolChoice.record:
58
+ factory = record_provider_factory(recordings_dir,mock_provider_factory)
59
+ elif tools == ToolChoice.replay:
60
+ factory = replay_provider_factory(recordings_dir)
61
+ else:
62
+ raise ValueError(f"unknown tools choice {tools}")
63
+
64
+ if agent == AgentChoice.anthropic:
65
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
66
+ if not api_key:
67
+ print("ANTHROPIC_API_KEY not set")
68
+ raise typer.Exit(2)
69
+ client = AsyncAnthropic(api_key = api_key)
70
+ return AnthropicAdapter(client = client,provider_factory = factory,model = model, max_tokens = max_tokens)
71
+ raise ValueError(f"unknown agent choice {agent}")
72
+
73
+ def build_judge(judgechoice: JudgeChoice, prompt_version:str,model :str,max_tokens:int) -> Judge:
74
+
75
+ if judgechoice == JudgeChoice.assertion:
76
+ judge = AssertionJudge()
77
+ elif judgechoice == JudgeChoice.llm:
78
+
79
+ key = os.environ.get("ANTHROPIC_API_KEY")
80
+ if not key:
81
+ print("ANTHROPIC_API_KEY not set")
82
+ raise typer.Exit(2)
83
+ return LLMJudge(client=AsyncAnthropic(api_key=key), prompt_version=prompt_version, model=model, max_tokens=max_tokens)
84
+ else:
85
+ raise ValueError(f"unknown Judge choice : {judgechoice}")
86
+
87
+ return judge
88
+
89
+ def _current_sha() -> str:
90
+ # CI hands us the SHA directly; fall back to git for local runs
91
+ sha = os.environ.get("GITHUB_SHA")
92
+ if sha:
93
+ return sha
94
+ try:
95
+ return subprocess.run(
96
+ ["git", "rev-parse", "HEAD"], capture_output=True, text=True, check=True
97
+ ).stdout.strip()
98
+ except (subprocess.CalledProcessError, FileNotFoundError):
99
+ return "unknown"
100
+
101
+
102
+ async def _score_labeled(judge:Judge, records : list[LabeledTranscript], concurrency : int) -> list[Verdict]:
103
+ sem = asyncio.Semaphore(concurrency)
104
+
105
+ async def one(record: LabeledTranscript) -> Verdict:
106
+ async with sem:
107
+ return await judge.score(record.case,record.transcript)
108
+ return await asyncio.gather(*(one(r) for r in records))
109
+
110
+
111
+
112
+
113
+ @app.command()
114
+ def run(path: Path, agent : AgentChoice, tools: ToolChoice, n_samples:int = 1, concurrency: int = 5, max_tokens : int = 1024, recordings_dir : Path = Path("output/"), model : str = "claude-haiku-4-5-20251001", prompt_version:str = "v1",judge_model :str = "claude-haiku-4-5-20251001",judgechoice : JudgeChoice = JudgeChoice.assertion, output :Path | None = None, store: Path | None = None) ->None:
115
+ load_dotenv()
116
+ try:
117
+ suite = load_suite(path)
118
+ except (SuiteLoadError, FileNotFoundError) as e:
119
+ print(e)
120
+ raise typer.Exit(2)
121
+ if n_samples == 0:
122
+ raise ValueError("n_samples value must be greater than 0")
123
+ if output is not None:
124
+ output.mkdir(parents=True, exist_ok=True)
125
+ adapter = build_agent(agent,tools,recordings_dir,model,max_tokens)
126
+ judge = build_judge(judgechoice,prompt_version,judge_model,max_tokens)
127
+ runs : list[SuiteResult] = []
128
+ for _ in range(n_samples):
129
+ suite_result = asyncio.run(run_suite(adapter,suite,concurrency))
130
+ runs.append(asyncio.run(judge_suite(judge,suite,suite_result,concurrency)))
131
+
132
+ if store is not None:
133
+ # regression store holds ONE canonical run per (sha, model, judge_version);
134
+ # diff compares single runs. ponytail: store runs[0]; per-sample storage is
135
+ # future work if diff ever consumes n-sampled data.
136
+ save_run(store, _current_sha(), model, prompt_version, runs[0])
137
+ if n_samples == 1:
138
+ if output is not None:
139
+ save_suite_result(suite_result, output / f"{suite_result.suite_name}-{suite_result.run_id}.json")
140
+ failed = [r for r in suite_result.results if r.transcript.termination_reason != Termination.finished]
141
+ print(f"{len(suite_result.results)} cases: {len(suite_result.results) - len(failed)} completed, {len(failed)} failed")
142
+ print(f"{'id':>23} {'reason':<12} {'steps':>5} {'tokens':>7} {'secs':>6}")
143
+ for r in suite_result.results:
144
+ t = r.transcript
145
+
146
+ print(f"{r.case_id:>23} {t.termination_reason.value:<12} {len(t.ordered_steps):>5} {t.total_tokens:>7} {t.duration_seconds:>6.2f}")
147
+ if failed:
148
+ raise typer.Exit(1)
149
+ else:
150
+ if output is not None:
151
+ for sr in runs:
152
+ save_suite_result(sr, output / f"{sr.suite_name}-{sr.run_id}.json")
153
+ aggregate_results:list[CaseStats] = aggregate(runs)
154
+ print(f"{'id':>23} {'n':>5} {'pass':>5} {'rate':>7} {'95% CI':>16} {'flaky':>6}")
155
+ for case_stats in aggregate_results:
156
+ ci = f"[{case_stats.ci_low:.2f}, {case_stats.ci_high:.2f}]"
157
+ flag = "flaky" if case_stats.flaky else ""
158
+ print(f"{case_stats.case_id:>23} {case_stats.n:>5} {case_stats.passes:>5} {case_stats.pass_rate:>7.2f} {ci:>16} {flag:>6}")
159
+
160
+
161
+
162
+ @app.command()
163
+ def calibrate(label: Path,judgechoice : JudgeChoice, prompt_version :str = "v1", judge_model :str = "claude-haiku-4-5-20251001", max_tokens : int = 1024 ,concurrency : int = 5) -> None:
164
+ load_dotenv()
165
+ try:
166
+ records = load_labeled(label)
167
+ except (LabelLoadError , FileNotFoundError) as e:
168
+ print(e)
169
+ raise typer.Exit(2)
170
+
171
+ if not records:
172
+ print("No labeled records found")
173
+ raise typer.Exit(2)
174
+ judge = build_judge(judgechoice,prompt_version,judge_model,max_tokens)
175
+ verdicts = asyncio.run(_score_labeled(judge, records,concurrency))
176
+ judge_labels = [v.passed for v in verdicts]
177
+ human_labels = [r.human_label for r in records]
178
+ kappa = cohen_kappa(judge_labels,human_labels)
179
+ agreement = sum(j == h for j,h in zip(judge_labels,human_labels,strict = True))
180
+
181
+ print(f"records : {len(records)}")
182
+ print(f"raw agreement : {agreement}/{len(records)}")
183
+ print(f"cohen kappa : {kappa:.3f}")
184
+
185
+ jp_hp = sum(v.passed and r.human_label for v, r in zip(verdicts, records, strict=True))
186
+ jp_hf = sum(v.passed and not r.human_label for v, r in zip(verdicts, records, strict=True))
187
+ jf_hp = sum(not v.passed and r.human_label for v, r in zip(verdicts, records, strict=True))
188
+ jf_hf = sum(not v.passed and not r.human_label for v, r in zip(verdicts, records, strict=True))
189
+ print()
190
+ print(f"{'':12}{'human_pass':>12}{'human_fail':>12}")
191
+ print(f"{'judge_pass':12}{jp_hp:>12}{jp_hf:>12}")
192
+ print(f"{'judge_fail':12}{jf_hp:>12}{jf_hf:>12}")
193
+
194
+
195
+ disagreements = [(v, r) for v, r in zip(verdicts, records, strict=True) if v.passed != r.human_label]
196
+ print(f"\nDISAGREEMENTS ({len(disagreements)}):")
197
+ for v, r in disagreements:
198
+ human = "PASS" if r.human_label else "FAIL"
199
+ judge_label = "PASS" if v.passed else "FAIL"
200
+ conf = f"{v.score:.2f}" if v.score is not None else "n/a"
201
+ print(f" {r.case.id:12} human={human} judge={judge_label} conf={conf}")
202
+ print(f" judge: {v.reasoning}")
203
+
204
+ @app.command()
205
+ def export_labels(suite_path: Path, out: Path, results: list[Path]) -> None:
206
+ load_dotenv()
207
+ try:
208
+ suite = load_suite(suite_path)
209
+ except (SuiteLoadError, FileNotFoundError) as e:
210
+ print(e)
211
+ raise typer.Exit(2)
212
+
213
+ records: list[LabeledTranscript] = []
214
+ for path in results:
215
+ try:
216
+ suite_result = load_suite_result(path)
217
+ except FileNotFoundError as e:
218
+ print(e)
219
+ raise typer.Exit(2)
220
+ records.extend(export_unlabeled(suite, suite_result))
221
+
222
+ save_labeled(records, out)
223
+ print(f"wrote {len(records)} unlabeled records to {out}")
224
+
225
+ @app.command()
226
+ def compare(path_a: Path, path_b: Path) -> None:
227
+ try:
228
+ a = load_suite_result(path_a)
229
+ b = load_suite_result(path_b)
230
+ result : ComparisonResult = compare_result(a,b)
231
+ print(f"n_cases : {result.n_cases}")
232
+ print(f"a passed : {result.a_passed}")
233
+ print(f"b passed : {result.b_passed}")
234
+ print(f"both pass : {result.both_pass}")
235
+ print(f"both fail : {result.both_fail}")
236
+ print(f" only a : {result.a_only}")
237
+ print(f" only b : {result.b_only}")
238
+ verdict = "significant" if result.p_value < 0.05 else "not significant"
239
+ print(f" P value : {result.p_value:.4f} → {verdict} at alpha=0.05")
240
+ except (FileNotFoundError,ValueError) as e:
241
+ print(e)
242
+ raise typer.Exit(2)
243
+
244
+ @app.command()
245
+ def diff(base_sha:str, head_sha:str, model: str = "claude-haiku-4-5-20251001", judge_version: str = "v1", store: Path = Path(".aehf"), out: Path = Path("scorecard.md") ) -> None:
246
+
247
+ try:
248
+ base = load_run(base = store,sha = base_sha,model = model, judge_version =judge_version)
249
+ head = load_run(base = store,sha = head_sha,model = model, judge_version =judge_version)
250
+ diff_result : DiffResult = diff_runs(base,head)
251
+ except RunNotFoundError as e:
252
+ print(e)
253
+ raise typer.Exit(2)
254
+ except ValueError as e:
255
+ print(e)
256
+ raise typer.Exit(2)
257
+
258
+ markdown_string = render_markdown(diff_result,base_sha,head_sha)
259
+ print(markdown_string) # terminal user sees the scorecard
260
+ out.parent.mkdir(parents = True, exist_ok = True)
261
+ out.write_text(markdown_string, encoding = "utf-8")
262
+
263
+ if is_regression(diff_result):
264
+ raise typer.Exit(1)
265
+ @app.command()
266
+ def label(path: Path, only_provisional: bool = False) -> None:
267
+ """Interactively set human_label and note on each record, saving after each."""
268
+ try:
269
+ records = load_labeled(path)
270
+ except (LabelLoadError, FileNotFoundError) as e:
271
+ print(e)
272
+ raise typer.Exit(2)
273
+
274
+ for i, rec in enumerate(records):
275
+ if only_provisional and not rec.note.startswith("provisional"):
276
+ continue
277
+ print("\n" + render_for_label(rec))
278
+ current = "PASS" if rec.human_label else "FAIL"
279
+ print(f"[{i + 1}/{len(records)}] current label: {current} note={rec.note!r}")
280
+
281
+ choice = typer.prompt("[p]ass / [f]ail / [s]kip / [q]uit", default="s")
282
+ if choice == "q":
283
+ break
284
+ if choice not in ("p", "f"):
285
+ continue # skip: leave the record as-is
286
+
287
+ note = typer.prompt("note", default="reviewed")
288
+ records[i] = rec.model_copy(update={"human_label": choice == "p", "note": note})
289
+ save_labeled(records, path) # persist after every label so quitting is safe
290
+
291
+ print("saved.")
292
+
293
+
294
+ if __name__== "__main__":
295
+ app()
aehf/core/__init__.py ADDED
File without changes
aehf/core/case.py ADDED
@@ -0,0 +1,47 @@
1
+ import re
2
+ from typing import Any
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
5
+
6
+
7
+ class SuccessCriteria(BaseModel):
8
+ model_config = ConfigDict(frozen = True)
9
+
10
+ answer_regex : str | None = None
11
+ required_tools: list[str] = Field(default_factory=list)
12
+ forbidden_tools: list[str] = Field(default_factory=list)
13
+ rubric : str | None = None
14
+ @field_validator("answer_regex")
15
+ @classmethod
16
+ def _must_compile(cls, v: str | None) -> str | None:
17
+ #
18
+ if v is not None:
19
+ try:
20
+ re.compile(v)
21
+ except re.error as e:
22
+ raise ValueError(f"invalid answer_regex {v!r}: {e}") from e
23
+ return v
24
+
25
+ class ToolSpec(BaseModel):
26
+ model_config = ConfigDict(frozen=True)
27
+ name: str
28
+ description: str | None = None
29
+ parameters: dict[str,Any] = Field(default_factory = dict[str,Any])
30
+
31
+
32
+ class EvalCase(BaseModel):
33
+ model_config = ConfigDict(frozen=True)
34
+ id: str
35
+ task_prompt: str
36
+ tools : list[ToolSpec]
37
+ success_criteria: SuccessCriteria
38
+ max_steps: int = Field(..., description = "Maximum Number of Steps")
39
+ timeout_seconds: float = Field(..., description = "Time out Seconds")
40
+ token_budget: int = Field(..., description = "Token Budget")
41
+ tags: str | None = None
42
+ tool_fixtures: dict[str,str] | None = None
43
+
44
+ class Suite(BaseModel):
45
+ model_config = ConfigDict(frozen=True)
46
+ name: str
47
+ eval: list[EvalCase]
aehf/core/loader.py ADDED
@@ -0,0 +1,31 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+ from pydantic import ValidationError
5
+
6
+ from aehf.core.case import Suite
7
+
8
+
9
+ class SuiteLoadError(Exception):
10
+ ...
11
+
12
+ def load_suite(path: Path) -> Suite:
13
+
14
+
15
+ with open(path,encoding = "utf-8") as file:
16
+ try:
17
+ data = yaml.safe_load(file)
18
+ except yaml.YAMLError as e:
19
+ raise SuiteLoadError(f"{path}: {e}") from e
20
+
21
+
22
+ try:
23
+ result = Suite.model_validate(data)
24
+
25
+ except ValidationError as e:
26
+ raise SuiteLoadError(f"{path}: {e}") from e
27
+
28
+ return result
29
+
30
+
31
+
aehf/core/results.py ADDED
@@ -0,0 +1,36 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+ from aehf.core.transcript import Transcript
6
+
7
+
8
+ class Verdict(BaseModel):
9
+ model_config = ConfigDict(frozen=True)
10
+ passed : bool
11
+ score: float | None = None
12
+ reasoning: str
13
+ judge_name: str
14
+ version: str
15
+
16
+
17
+ class CaseResult(BaseModel):
18
+ model_config = ConfigDict(frozen=True)
19
+ case_id : str
20
+ transcript : Transcript
21
+ verdicts: list[Verdict]
22
+ run_metadata: dict[str,str]
23
+
24
+ class SuiteResult(BaseModel):
25
+ model_config = ConfigDict(frozen=True)
26
+ suite_name: str
27
+ results: list[CaseResult]
28
+ run_id: str
29
+
30
+ def save_suite_result(result: SuiteResult, path: Path) -> None:
31
+ path.write_text(result.model_dump_json(indent=2))
32
+ def load_suite_result(path: Path) -> SuiteResult:
33
+ return SuiteResult.model_validate_json(path.read_text())
34
+
35
+ def case_passed(cr: CaseResult) -> bool:
36
+ return bool(cr.verdicts) and cr.verdicts[0].passed
aehf/core/runner.py ADDED
@@ -0,0 +1,51 @@
1
+ import asyncio
2
+ import time
3
+ import traceback
4
+ from typing import Any
5
+ from uuid import uuid4
6
+
7
+ from aehf.adapters.base import Agent
8
+ from aehf.core.case import EvalCase, Suite
9
+ from aehf.core.results import CaseResult, SuiteResult
10
+ from aehf.core.transcript import Termination, Transcript
11
+
12
+
13
+ async def run_case(agent: Agent, case:EvalCase) -> CaseResult:
14
+ run_metadata : dict[str,Any] = {}
15
+ start_time = time.monotonic()
16
+ try:
17
+ transcript = await asyncio.wait_for(agent.run(case),timeout = case.timeout_seconds)
18
+ duration = time.monotonic()-start_time
19
+ transcript = transcript.model_copy(update = {"duration_seconds" : duration})
20
+ if len(transcript.ordered_steps) > case.max_steps and transcript.total_tokens >case.token_budget:
21
+ transcript = transcript.model_copy(update ={"termination_reason" : Termination.budget_and_steps_exceeded})
22
+ elif transcript.total_tokens >case.token_budget:
23
+ transcript = transcript.model_copy(update ={"termination_reason" : Termination.budget_exceeded})
24
+
25
+ elif len(transcript.ordered_steps) > case.max_steps:
26
+ transcript = transcript.model_copy(update ={"termination_reason" :Termination.max_steps})
27
+ except TimeoutError:
28
+ transcript = Transcript(id = case.id,ordered_steps = [], final_answer ="",total_tokens= -1,duration_seconds = time.monotonic()-start_time,termination_reason = Termination.timeout)
29
+ except Exception as exc:
30
+ transcript = Transcript(id = case.id,ordered_steps = [], final_answer ="",total_tokens= -1,duration_seconds = time.monotonic() - start_time,termination_reason = Termination.crashed)
31
+ run_metadata['error_type'] = type(exc).__name__
32
+ run_metadata['error_message'] = str(exc)
33
+ run_metadata['error_traceback'] = traceback.format_exc()
34
+
35
+ case_result = CaseResult(case_id = case.id,transcript = transcript, verdicts = [], run_metadata = run_metadata)
36
+ return case_result
37
+
38
+ async def _run_case_bounded(agent:Agent, case:EvalCase, sem:asyncio.Semaphore) -> CaseResult:
39
+ async with sem:
40
+ return await run_case(agent,case)
41
+ async def run_suite(agent:Agent, suite:Suite, max_concurrency :int = 5) -> SuiteResult:
42
+ sem = asyncio.Semaphore(max_concurrency)
43
+
44
+ tasks = [_run_case_bounded(agent,c,sem) for c in suite.eval]
45
+ result = await asyncio.gather(*tasks)
46
+
47
+ suiteresult = SuiteResult(suite_name = suite.name, results = result,run_id = uuid4().hex[:8])
48
+ return suiteresult
49
+
50
+
51
+
@@ -0,0 +1,39 @@
1
+
2
+ from enum import Enum
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ class Termination(Enum):
9
+ finished = "finished"
10
+ max_steps = "max_steps"
11
+ timeout = "timeout"
12
+ crashed= "crashed"
13
+ refused = "refused"
14
+ unexpected_stop = "unexpected_stop"
15
+ budget_exceeded = "budget_exceeded"
16
+ budget_and_steps_exceeded = "budget_and_steps_exceeded"
17
+
18
+ class ToolCall(BaseModel):
19
+ model_config = ConfigDict(frozen=True)
20
+ toolname: str
21
+ arguments: dict[str,Any]
22
+ result: str
23
+ error_flag: str | None =None
24
+ latency: float
25
+
26
+ class Step(BaseModel):
27
+ model_config = ConfigDict(frozen=True)
28
+ model_output:str
29
+ tool_calls: list[ToolCall] | None=None
30
+ token_usage: int= Field(..., description ="Token Usage")
31
+
32
+ class Transcript(BaseModel):
33
+ model_config = ConfigDict(frozen=True)
34
+ id: str
35
+ ordered_steps: list[Step]
36
+ final_answer: str
37
+ total_tokens: int
38
+ duration_seconds: float
39
+ termination_reason: Termination
@@ -0,0 +1,42 @@
1
+ import re
2
+
3
+ from aehf.core.case import EvalCase
4
+ from aehf.core.results import Verdict
5
+ from aehf.core.transcript import Transcript
6
+
7
+
8
+ class AssertionJudge:
9
+
10
+ async def score(self, case : EvalCase, transcript: Transcript) -> Verdict:
11
+ criteria = case.success_criteria
12
+ reasons: list[str] = []
13
+ passed = True
14
+
15
+ if criteria.answer_regex is not None:
16
+ if re.search(criteria.answer_regex, transcript.final_answer):
17
+ reasons.append("answer matched")
18
+ else:
19
+ reasons.append("answer did not match")
20
+ passed = False
21
+
22
+ called = [tc.toolname for step in transcript.ordered_steps for tc in (step.tool_calls or [])]
23
+
24
+ forbidden_hits = sorted({t for t in called if t in criteria.forbidden_tools})
25
+ if forbidden_hits:
26
+ reasons.append(f"forbidden tool(s) called: {', '.join(forbidden_hits)}")
27
+ passed = False
28
+
29
+ missing = sorted(t for t in criteria.required_tools if t not in called)
30
+ if missing:
31
+ reasons.append(f"required tool(s) not called: {', '.join(missing)}")
32
+ passed = False
33
+ elif criteria.required_tools:
34
+ reasons.append("all required tools called")
35
+
36
+ return Verdict(
37
+ passed=passed,
38
+ score=1.0 if passed else 0.0,
39
+ reasoning="; ".join(reasons) or "no criteria specified",
40
+ judge_name="AssertionJudge",
41
+ version="1",
42
+ )