agent-exam 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.
Files changed (85) hide show
  1. agent_exam/__init__.py +0 -0
  2. agent_exam/_models.py +120 -0
  3. agent_exam/_validate.py +117 -0
  4. agent_exam/artifacts.py +47 -0
  5. agent_exam/assertions/__init__.py +0 -0
  6. agent_exam/assertions/_shared.py +34 -0
  7. agent_exam/assertions/file_contains.py +59 -0
  8. agent_exam/assertions/file_exists.py +26 -0
  9. agent_exam/assertions/first_skill.py +44 -0
  10. agent_exam/assertions/judge.py +72 -0
  11. agent_exam/assertions/judge_agent.py +109 -0
  12. agent_exam/assertions/no_permission_errors.py +93 -0
  13. agent_exam/assertions/registry.py +131 -0
  14. agent_exam/assertions/skill_invoked.py +70 -0
  15. agent_exam/assertions/skill_not_invoked.py +37 -0
  16. agent_exam/assertions/tool_called.py +26 -0
  17. agent_exam/assertions/tool_count.py +61 -0
  18. agent_exam/assertions/tool_not_called.py +30 -0
  19. agent_exam/cli.py +299 -0
  20. agent_exam/commands/__init__.py +0 -0
  21. agent_exam/commands/_format.py +195 -0
  22. agent_exam/commands/_loader.py +161 -0
  23. agent_exam/commands/diff.py +133 -0
  24. agent_exam/commands/doctor.py +435 -0
  25. agent_exam/commands/history.py +208 -0
  26. agent_exam/commands/rescore.py +236 -0
  27. agent_exam/commands/runs.py +97 -0
  28. agent_exam/commands/show.py +718 -0
  29. agent_exam/config.py +240 -0
  30. agent_exam/errors.py +49 -0
  31. agent_exam/hooks.py +41 -0
  32. agent_exam/ids.py +56 -0
  33. agent_exam/judge/__init__.py +29 -0
  34. agent_exam/judge/cache.py +140 -0
  35. agent_exam/judge/dispatch.py +95 -0
  36. agent_exam/judge/format_trajectory.py +102 -0
  37. agent_exam/judge/parse.py +34 -0
  38. agent_exam/judge/prompt.py +63 -0
  39. agent_exam/pool.py +477 -0
  40. agent_exam/providers/__init__.py +37 -0
  41. agent_exam/providers/base.py +170 -0
  42. agent_exam/providers/child_env.py +87 -0
  43. agent_exam/providers/claude_code/__init__.py +17 -0
  44. agent_exam/providers/claude_code/blocked_plugins.py +83 -0
  45. agent_exam/providers/claude_code/doctor_probes.py +165 -0
  46. agent_exam/providers/claude_code/provider.py +449 -0
  47. agent_exam/providers/claude_code/session_checks.py +74 -0
  48. agent_exam/providers/claude_code/skill_detect.py +51 -0
  49. agent_exam/providers/claude_code/stream_parser.py +143 -0
  50. agent_exam/providers/claude_code/transcripts.py +549 -0
  51. agent_exam/providers/codex_cli/__init__.py +3 -0
  52. agent_exam/providers/codex_cli/hermetic_skills.py +21 -0
  53. agent_exam/providers/codex_cli/paths.py +17 -0
  54. agent_exam/providers/codex_cli/provider.py +599 -0
  55. agent_exam/providers/codex_cli/stream_parser.py +294 -0
  56. agent_exam/providers/codex_cli/transcripts.py +994 -0
  57. agent_exam/providers/copilot_cli/__init__.py +5 -0
  58. agent_exam/providers/copilot_cli/doctor_probes.py +53 -0
  59. agent_exam/providers/copilot_cli/provider.py +266 -0
  60. agent_exam/providers/copilot_cli/stream_parser.py +152 -0
  61. agent_exam/providers/copilot_cli/transcripts.py +367 -0
  62. agent_exam/providers/dummy.py +82 -0
  63. agent_exam/providers/opencode/__init__.py +3 -0
  64. agent_exam/providers/opencode/doctor_probes.py +96 -0
  65. agent_exam/providers/opencode/provider.py +348 -0
  66. agent_exam/providers/opencode/stream_parser.py +121 -0
  67. agent_exam/providers/opencode/transcripts.py +701 -0
  68. agent_exam/providers/process_utils.py +35 -0
  69. agent_exam/providers/skill_staging.py +184 -0
  70. agent_exam/ratelimit.py +62 -0
  71. agent_exam/report.py +217 -0
  72. agent_exam/report_compare.py +251 -0
  73. agent_exam/run_modes.py +60 -0
  74. agent_exam/runner.py +646 -0
  75. agent_exam/schemas.py +113 -0
  76. agent_exam/scoring_context.py +46 -0
  77. agent_exam/serde.py +147 -0
  78. agent_exam/tasks.py +492 -0
  79. agent_exam/trajectory_walk.py +81 -0
  80. agent_exam/validation.py +354 -0
  81. agent_exam-0.1.0.dist-info/METADATA +139 -0
  82. agent_exam-0.1.0.dist-info/RECORD +85 -0
  83. agent_exam-0.1.0.dist-info/WHEEL +4 -0
  84. agent_exam-0.1.0.dist-info/entry_points.txt +2 -0
  85. agent_exam-0.1.0.dist-info/licenses/LICENSE +201 -0
agent_exam/__init__.py ADDED
File without changes
agent_exam/_models.py ADDED
@@ -0,0 +1,120 @@
1
+ """Pydantic foundations for assertion config models.
2
+
3
+ Each assertion declares a `<Foo>Config` pydantic model; `_parse_assertion`
4
+ constructs the model from raw YAML and surfaces a clean UsageError if
5
+ the config is malformed. `check` then receives the typed config directly
6
+ — no preamble, no re-extraction, types flow.
7
+
8
+ Two shared base classes cover what most assertions need:
9
+
10
+ - `_StrictModel`: rejects unknown keys (`extra="forbid"`) — the anti-typo
11
+ default for every config in the codebase.
12
+ - `_ScalarShorthandModel`: enables `<assertion>: <scalar>` shorthand
13
+ alongside the full `<assertion>: {<key>: <scalar>}` form. Subclasses
14
+ set `_shorthand_key: ClassVar[str]` to declare which field the scalar
15
+ populates.
16
+
17
+ `render_validation_error` turns a pydantic `ValidationError` into a flat
18
+ CLI message — one line per error, no `pydantic.dev` URL, with the
19
+ "Value error," wrapping that pydantic adds to validator-raised
20
+ `ValueError`s stripped. Used by the registry to wrap pydantic errors as
21
+ `UsageError` so they read like every other config error in the system.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from typing import Annotated, Any, ClassVar
28
+
29
+ from pydantic import (
30
+ BaseModel,
31
+ BeforeValidator,
32
+ ConfigDict,
33
+ Field,
34
+ ValidationError,
35
+ model_validator,
36
+ )
37
+
38
+ # Strips pydantic's `... or instance of <ModelClass>` suffix from
39
+ # type-mismatch messages — internal class names mean nothing to a YAML
40
+ # author and just make errors look noisy.
41
+ _MODEL_CLASS_SUFFIX = re.compile(r" or instance of _?[A-Z]\w+")
42
+
43
+
44
+ class _StrictModel(BaseModel):
45
+ """Reject unknown keys — every config model in the codebase wants this."""
46
+
47
+ model_config = ConfigDict(extra="forbid")
48
+
49
+
50
+ class _ScalarShorthandModel(_StrictModel):
51
+ """Enable the `<assertion>: <scalar>` shorthand form.
52
+
53
+ Subclasses set `_shorthand_key: ClassVar[str]` to declare which
54
+ field the scalar value populates. Any non-dict input is treated as
55
+ the shorthand value — `model_validate(x)` becomes
56
+ `model_validate({<key>: x})`. The full mapping form is unchanged.
57
+
58
+ The wrap is type-agnostic: if `x` is the wrong type for the field,
59
+ pydantic surfaces a per-field message (e.g. `path: Input should be a
60
+ valid string`) instead of the misleading top-level
61
+ `Input should be a valid dictionary`.
62
+ """
63
+
64
+ _shorthand_key: ClassVar[str]
65
+
66
+ @model_validator(mode="before")
67
+ @classmethod
68
+ def _apply_shorthand(cls, v: Any) -> Any:
69
+ if isinstance(v, dict):
70
+ return v
71
+ return {cls._shorthand_key: v}
72
+
73
+
74
+ def _reject_bool_and_str(v: Any) -> Any:
75
+ if isinstance(v, bool):
76
+ raise ValueError("must be a number, not a bool")
77
+ if isinstance(v, str):
78
+ raise ValueError("must be a number, not a string")
79
+ return v
80
+
81
+
82
+ # A numeric field that accepts int OR float and rejects bool (otherwise
83
+ # an int subclass) and strings (otherwise lax-coerced). Single-error
84
+ # message — avoids pydantic's two-branch verbose `StrictInt | StrictFloat`
85
+ # union error.
86
+ Number = Annotated[int | float, BeforeValidator(_reject_bool_and_str)]
87
+
88
+ # Number plus a positivity constraint (`> 0`). Type-mismatch errors come
89
+ # from the BeforeValidator; sign errors are pydantic's
90
+ # "Input should be greater than 0".
91
+ PositiveNumber = Annotated[
92
+ int | float, BeforeValidator(_reject_bool_and_str), Field(gt=0)
93
+ ]
94
+
95
+
96
+ def render_validation_error(label: str, exc: ValidationError) -> str:
97
+ """Convert a pydantic `ValidationError` into a flat CLI message.
98
+
99
+ Single error: `<label>: [<loc>: ]<msg>` on one line.
100
+ Multiple errors: `<label>: invalid config (N errors)` header, then
101
+ `<loc>: <msg>` per error.
102
+
103
+ `include_url=False` drops pydantic's "see docs at ..." appendage,
104
+ and the "Value error, " prefix pydantic adds around validator-raised
105
+ `ValueError` messages is stripped — both are noise for CLI use.
106
+ """
107
+ errors = exc.errors(include_url=False)
108
+ if len(errors) == 1:
109
+ err = errors[0]
110
+ loc = ".".join(str(x) for x in err["loc"])
111
+ msg = _MODEL_CLASS_SUFFIX.sub("", err["msg"].removeprefix("Value error, "))
112
+ if loc:
113
+ return f"{label}: {loc}: {msg}"
114
+ return f"{label}: {msg}"
115
+ lines = [f"{label}: invalid config ({len(errors)} errors)"]
116
+ for err in errors:
117
+ loc = ".".join(str(x) for x in err["loc"]) or "<root>"
118
+ msg = _MODEL_CLASS_SUFFIX.sub("", err["msg"].removeprefix("Value error, "))
119
+ lines.append(f" {loc}: {msg}")
120
+ return "\n".join(lines)
@@ -0,0 +1,117 @@
1
+ """Small shared validation primitives.
2
+
3
+ These collapse the repetitive shape/type checks that recur across the
4
+ task loader (`tasks.py`) and the assertion validators (`assertions/*`).
5
+ Each raises `UsageError` with a uniform message so callers don't
6
+ hand-roll — and drift — their own.
7
+
8
+ Deliberately not a schema DSL: the genuinely per-field logic (cross-field
9
+ rules like `tool_count`'s min/max, shorthand forms, the str-or-dict judge
10
+ config) stays as plain Python in the caller. Only the boilerplate lives
11
+ here.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from .errors import UsageError
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Iterable
22
+
23
+
24
+ def reject_unknown_keys(
25
+ items: Iterable[Any],
26
+ allowed: Iterable[str],
27
+ *,
28
+ label: str,
29
+ noun: str = "key",
30
+ ) -> None:
31
+ """Raise UsageError if `items` (a mapping — iterated as its keys — or
32
+ any other iterable) contains an entry outside `allowed`. Catches
33
+ typos that would otherwise be silently ignored.
34
+ """
35
+ allowed_set = set(allowed)
36
+ unknown = sorted(str(k) for k in items if k not in allowed_set)
37
+ if unknown:
38
+ raise UsageError(
39
+ f"{label}: unknown {noun}(s) {unknown}; "
40
+ f"valid: {', '.join(sorted(allowed_set))}"
41
+ )
42
+
43
+
44
+ def require_str(
45
+ value: Any,
46
+ label: str,
47
+ *,
48
+ allow_none: bool = False,
49
+ non_empty: bool = False,
50
+ ) -> None:
51
+ """Raise UsageError unless `value` is a string — optionally non-empty
52
+ (after strip), optionally None."""
53
+ if value is None and allow_none:
54
+ return
55
+ if not isinstance(value, str) or (non_empty and not value.strip()):
56
+ kind = "a non-empty string" if non_empty else "a string"
57
+ raise UsageError(f"{label} must be {kind} (got {value!r})")
58
+
59
+
60
+ def require_str_list(
61
+ value: Any,
62
+ label: str,
63
+ *,
64
+ allow_none: bool = False,
65
+ non_empty: bool = False,
66
+ ) -> None:
67
+ """Raise UsageError unless `value` is a list of non-empty strings —
68
+ optionally itself non-empty, optionally None."""
69
+ if value is None and allow_none:
70
+ return
71
+ if (
72
+ not isinstance(value, list)
73
+ or (non_empty and len(value) == 0)
74
+ or not all(isinstance(x, str) and x for x in value)
75
+ ):
76
+ kind = "a non-empty list of strings" if non_empty else "a list of strings"
77
+ raise UsageError(f"{label} must be {kind} (got {value!r})")
78
+
79
+
80
+ def require_int(
81
+ value: Any,
82
+ label: str,
83
+ *,
84
+ allow_none: bool = False,
85
+ minimum: int | None = None,
86
+ ) -> None:
87
+ """Raise UsageError unless `value` is an integer — optionally >=
88
+ `minimum`, optionally None. `bool` is rejected: it's an `int`
89
+ subclass, but `timeout_seconds: true` is a typo, not a 1."""
90
+ if value is None and allow_none:
91
+ return
92
+ if (
93
+ not isinstance(value, int)
94
+ or isinstance(value, bool)
95
+ or (minimum is not None and value < minimum)
96
+ ):
97
+ bound = f" >= {minimum}" if minimum is not None else ""
98
+ raise UsageError(f"{label} must be an integer{bound} (got {value!r})")
99
+
100
+
101
+ def require_number(
102
+ value: Any,
103
+ label: str,
104
+ *,
105
+ allow_none: bool = False,
106
+ positive: bool = False,
107
+ ) -> None:
108
+ """Raise UsageError unless `value` is a number (int or float —
109
+ `bool` rejected), optionally strictly positive, optionally None.
110
+ Use this for fields where sub-integer precision is meaningful
111
+ (e.g. `timeout_seconds: 0.5`); use `require_int` for counts."""
112
+ if value is None and allow_none:
113
+ return
114
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
115
+ raise UsageError(f"{label} must be a number (got {value!r})")
116
+ if positive and value <= 0:
117
+ raise UsageError(f"{label} must be a positive number (got {value!r})")
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class RunPaths:
9
+ """All on-disk paths for a single run, derived from evals_dir + run_id."""
10
+
11
+ evals_dir: Path
12
+ run_id: str
13
+
14
+ @property
15
+ def run_dir(self) -> Path:
16
+ return self.evals_dir / "runs" / self.run_id
17
+
18
+ @property
19
+ def run_json(self) -> Path:
20
+ return self.run_dir / "run.json"
21
+
22
+ @property
23
+ def artifacts_dir(self) -> Path:
24
+ return self.run_dir / "artifacts"
25
+
26
+ @property
27
+ def reports_dir(self) -> Path:
28
+ return self.run_dir / "reports"
29
+
30
+ @property
31
+ def judge_cache(self) -> Path:
32
+ return self.run_dir / "judge-cache.json"
33
+
34
+ def attempt_dir(self, suite: str, task: str, attempt_n: int) -> Path:
35
+ return self.artifacts_dir / suite / task / f"attempt-{attempt_n}"
36
+
37
+ def attempt_json(self, suite: str, task: str, attempt_n: int) -> Path:
38
+ return self.attempt_dir(suite, task, attempt_n) / "attempt.json"
39
+
40
+ def trajectory_json(self, suite: str, task: str, attempt_n: int) -> Path:
41
+ return self.attempt_dir(suite, task, attempt_n) / "trajectory.json"
42
+
43
+ def attempt_cwd(self, suite: str, task: str, attempt_n: int) -> Path:
44
+ return self.attempt_dir(suite, task, attempt_n) / "cwd"
45
+
46
+ def report_file(self, report_ts: str) -> Path:
47
+ return self.reports_dir / f"{report_ts}.json"
File without changes
@@ -0,0 +1,34 @@
1
+ """Config models shared across multiple assertions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from pydantic import Field, StrictBool, field_validator
8
+
9
+ from .._models import _ScalarShorthandModel
10
+
11
+
12
+ class JudgeConfigBase(_ScalarShorthandModel):
13
+ """Shared config for `judge` and `judge_agent`: a criterion plus
14
+ optional `include_trajectory` / `pass_on`. Accepts the scalar
15
+ shorthand `judge: <criterion>` and the full mapping form."""
16
+
17
+ _shorthand_key: ClassVar[str] = "criterion"
18
+ criterion: str
19
+ include_trajectory: StrictBool = True
20
+ pass_on: list[str] | None = Field(default=None, min_length=1)
21
+
22
+ @field_validator("criterion")
23
+ @classmethod
24
+ def _non_whitespace(cls, v: str) -> str:
25
+ if not v.strip():
26
+ raise ValueError("must not be empty or whitespace")
27
+ return v
28
+
29
+ @field_validator("pass_on")
30
+ @classmethod
31
+ def _no_empty_entries(cls, v: list[str] | None) -> list[str] | None:
32
+ if v is not None and not all(s for s in v):
33
+ raise ValueError("entries must be non-empty strings")
34
+ return v
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import TYPE_CHECKING
5
+
6
+ from pydantic import StrictBool, model_validator
7
+
8
+ from .._models import _StrictModel
9
+ from ..schemas import AssertionResult, RunResult
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+
15
+ class FileContainsConfig(_StrictModel):
16
+ """`file_contains: {path: ..., pattern: ..., regex?: false}`."""
17
+
18
+ path: str
19
+ pattern: str
20
+ # StrictBool — lax `bool` would coerce YAML-style "yes"/"no" strings.
21
+ regex: StrictBool = False
22
+
23
+ @model_validator(mode="after")
24
+ def _validate_regex(self) -> FileContainsConfig:
25
+ if self.regex:
26
+ try:
27
+ re.compile(self.pattern)
28
+ except re.error as exc:
29
+ raise ValueError(
30
+ f"invalid regex pattern {self.pattern!r}: {exc}"
31
+ ) from exc
32
+ return self
33
+
34
+
35
+ def check(config: FileContainsConfig, result: RunResult, cwd: Path) -> AssertionResult:
36
+ full = cwd / config.path
37
+ if not full.is_file():
38
+ return AssertionResult(
39
+ pass_=False,
40
+ reason=f"missing {config.path}",
41
+ details={"path": str(full)},
42
+ )
43
+ text = full.read_text(errors="replace")
44
+ if config.regex:
45
+ found = re.search(config.pattern, text) is not None
46
+ else:
47
+ found = config.pattern in text
48
+ return AssertionResult(
49
+ pass_=found,
50
+ reason=(
51
+ f"{'matched' if found else 'no match for'} "
52
+ f"{config.pattern!r} in {config.path}"
53
+ ),
54
+ details={
55
+ "path": str(full),
56
+ "pattern": config.pattern,
57
+ "regex": config.regex,
58
+ },
59
+ )
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar
4
+
5
+ from .._models import _ScalarShorthandModel
6
+ from ..schemas import AssertionResult, RunResult
7
+
8
+ if TYPE_CHECKING:
9
+ from pathlib import Path
10
+
11
+
12
+ class FileExistsConfig(_ScalarShorthandModel):
13
+ """`file_exists: out/x.py` or `file_exists: {path: out/x.py}`."""
14
+
15
+ _shorthand_key: ClassVar[str] = "path"
16
+ path: str
17
+
18
+
19
+ def check(config: FileExistsConfig, result: RunResult, cwd: Path) -> AssertionResult:
20
+ full = cwd / config.path
21
+ exists = full.is_file()
22
+ return AssertionResult(
23
+ pass_=exists,
24
+ reason=f"{'found' if exists else 'missing'} {config.path}",
25
+ details={"path": str(full)},
26
+ )
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, ClassVar
4
+
5
+ from .._models import _ScalarShorthandModel
6
+ from ..schemas import AssertionResult, RunResult
7
+ from ..trajectory_walk import first_skill_invocation
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+
13
+ class FirstSkillConfig(_ScalarShorthandModel):
14
+ """`first_skill: scrape-codegen` or `first_skill: {skill: scrape-codegen}`."""
15
+
16
+ _shorthand_key: ClassVar[str] = "skill"
17
+ skill: str
18
+
19
+
20
+ def check(
21
+ config: FirstSkillConfig, result: RunResult, cwd: Path, context: Any
22
+ ) -> AssertionResult:
23
+ expected = config.skill
24
+ first = first_skill_invocation(result.trajectory)
25
+ if first is None:
26
+ return AssertionResult(
27
+ pass_=False,
28
+ reason=f"no skill invocation in trajectory (expected {expected!r})",
29
+ details={"expected": expected},
30
+ )
31
+ ok = context.provider.is_same_skill(first.skill_name, expected)
32
+ return AssertionResult(
33
+ pass_=ok,
34
+ reason=(
35
+ f"first skill was {first.skill_name!r}"
36
+ if ok
37
+ else f"first skill was {first.skill_name!r}, expected {expected!r}"
38
+ ),
39
+ details={
40
+ "expected": expected,
41
+ "actual": first.skill_name,
42
+ "trigger_kind": first.trigger_kind,
43
+ },
44
+ )
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ..judge import (
6
+ agent_output_hash,
7
+ build_prompt,
8
+ call_judge,
9
+ format_trajectory,
10
+ key_for,
11
+ parse_verdict,
12
+ )
13
+ from ..judge.format_trajectory import final_output_text
14
+ from ..schemas import AssertionResult, RunResult
15
+ from ._shared import JudgeConfigBase
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+
21
+ class JudgeConfig(JudgeConfigBase):
22
+ """`judge: <criterion>` or
23
+ `judge: {criterion: ..., include_trajectory?: ..., pass_on?: [...]}`."""
24
+
25
+
26
+ def check(
27
+ config: JudgeConfig,
28
+ result: RunResult,
29
+ cwd: Path,
30
+ context=None,
31
+ ) -> AssertionResult:
32
+ if context is None or context.judge_call is None:
33
+ return AssertionResult(
34
+ pass_=False,
35
+ reason="judge: no judge_call in scoring context "
36
+ "(runner did not configure a judge)",
37
+ )
38
+
39
+ pass_on = config.pass_on or context.judge_pass_on or ["YES"]
40
+ output_hash = agent_output_hash(result.trajectory)
41
+ cache_key = key_for(config.criterion, output_hash, context.judge_call.judge_model)
42
+
43
+ cached = context.judge_cache.get(cache_key) if context.judge_cache else None
44
+ if cached is not None:
45
+ verdict = cached["verdict"]
46
+ reasoning = cached.get("reasoning", "")
47
+ cache_hit = True
48
+ else:
49
+ final_output = final_output_text(result.trajectory)
50
+ traj_text = (
51
+ format_trajectory(result.trajectory) if config.include_trajectory else ""
52
+ )
53
+ prompt = build_prompt(
54
+ config.criterion, final_output, traj_text, config.include_trajectory
55
+ )
56
+ raw_response = call_judge(context.judge_call, prompt)
57
+ verdict, reasoning = parse_verdict(raw_response)
58
+ if context.judge_cache is not None:
59
+ context.judge_cache.put(cache_key, config.criterion, verdict, reasoning)
60
+ cache_hit = False
61
+
62
+ passed = verdict in pass_on
63
+ return AssertionResult(
64
+ pass_=passed,
65
+ reason=f"judge said {verdict}",
66
+ details={
67
+ "verdict": verdict,
68
+ "reasoning": reasoning,
69
+ "criterion": config.criterion,
70
+ "cache": "hit" if cache_hit else "miss",
71
+ },
72
+ )
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ..judge import (
6
+ agent_output_hash,
7
+ build_prompt,
8
+ call_judge_agent,
9
+ format_trajectory,
10
+ key_for_judge_agent,
11
+ parse_verdict,
12
+ tools_signature,
13
+ )
14
+ from ..judge.format_trajectory import final_output_text
15
+ from ..schemas import AssertionResult, RunResult
16
+ from ._shared import JudgeConfigBase
17
+
18
+ if TYPE_CHECKING:
19
+ from pathlib import Path
20
+
21
+
22
+ class JudgeAgentConfig(JudgeConfigBase):
23
+ """`judge_agent: <criterion>` or
24
+ `judge_agent: {criterion: ..., include_trajectory?: ..., pass_on?: [...]}`.
25
+
26
+ Same config shape as `judge`; the difference is the runtime — the
27
+ judge runs against the attempt's archived cwd with read-only file
28
+ tools available."""
29
+
30
+
31
+ def check(
32
+ config: JudgeAgentConfig,
33
+ result: RunResult,
34
+ cwd: Path,
35
+ context=None,
36
+ ) -> AssertionResult:
37
+ """Agentic judge — same config shape as the plain ``judge`` assertion
38
+ but the judge runs against the attempt's archived cwd with read-only
39
+ file tools available, so it can verify criteria that depend on the
40
+ artifacts the skill produced.
41
+
42
+ Cache key extends ``judge``'s key with the cwd content hash and the
43
+ safe-tools signature: any change to the criterion, the agent's
44
+ output, the cwd, the toolset, or the judge model invalidates the
45
+ cached verdict.
46
+ """
47
+ if context is None or context.judge_call is None:
48
+ return AssertionResult(
49
+ pass_=False,
50
+ reason="judge_agent: no judge_call in scoring context "
51
+ "(runner did not configure a judge)",
52
+ )
53
+
54
+ provider = context.judge_call.provider
55
+ if not provider.safe_judge_tools:
56
+ return AssertionResult(
57
+ pass_=False,
58
+ reason=(
59
+ f"judge_agent: provider {provider.name!r} has no "
60
+ "safe_judge_tools configured"
61
+ ),
62
+ )
63
+
64
+ pass_on = config.pass_on or context.judge_pass_on or ["YES"]
65
+ output_hash = agent_output_hash(result.trajectory)
66
+ cwd_h = context.cwd_hash_for(cwd)
67
+ tools_sig = tools_signature(provider.safe_judge_tools)
68
+ cache_key = key_for_judge_agent(
69
+ config.criterion,
70
+ output_hash,
71
+ cwd_h,
72
+ tools_sig,
73
+ context.judge_call.judge_model,
74
+ )
75
+
76
+ cached = context.judge_cache.get(cache_key) if context.judge_cache else None
77
+ if cached is not None:
78
+ verdict = cached["verdict"]
79
+ reasoning = cached.get("reasoning", "")
80
+ cache_hit = True
81
+ else:
82
+ final_output = final_output_text(result.trajectory)
83
+ traj_text = (
84
+ format_trajectory(result.trajectory) if config.include_trajectory else ""
85
+ )
86
+ prompt = build_prompt(
87
+ config.criterion,
88
+ final_output,
89
+ traj_text,
90
+ config.include_trajectory,
91
+ inspect_cwd=True,
92
+ )
93
+ raw_response = call_judge_agent(context.judge_call, prompt, cwd)
94
+ verdict, reasoning = parse_verdict(raw_response)
95
+ if context.judge_cache is not None:
96
+ context.judge_cache.put(cache_key, config.criterion, verdict, reasoning)
97
+ cache_hit = False
98
+
99
+ passed = verdict in pass_on
100
+ return AssertionResult(
101
+ pass_=passed,
102
+ reason=f"judge_agent said {verdict}",
103
+ details={
104
+ "verdict": verdict,
105
+ "reasoning": reasoning,
106
+ "criterion": config.criterion,
107
+ "cache": "hit" if cache_hit else "miss",
108
+ },
109
+ )