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.
- agent_exam/__init__.py +0 -0
- agent_exam/_models.py +120 -0
- agent_exam/_validate.py +117 -0
- agent_exam/artifacts.py +47 -0
- agent_exam/assertions/__init__.py +0 -0
- agent_exam/assertions/_shared.py +34 -0
- agent_exam/assertions/file_contains.py +59 -0
- agent_exam/assertions/file_exists.py +26 -0
- agent_exam/assertions/first_skill.py +44 -0
- agent_exam/assertions/judge.py +72 -0
- agent_exam/assertions/judge_agent.py +109 -0
- agent_exam/assertions/no_permission_errors.py +93 -0
- agent_exam/assertions/registry.py +131 -0
- agent_exam/assertions/skill_invoked.py +70 -0
- agent_exam/assertions/skill_not_invoked.py +37 -0
- agent_exam/assertions/tool_called.py +26 -0
- agent_exam/assertions/tool_count.py +61 -0
- agent_exam/assertions/tool_not_called.py +30 -0
- agent_exam/cli.py +299 -0
- agent_exam/commands/__init__.py +0 -0
- agent_exam/commands/_format.py +195 -0
- agent_exam/commands/_loader.py +161 -0
- agent_exam/commands/diff.py +133 -0
- agent_exam/commands/doctor.py +435 -0
- agent_exam/commands/history.py +208 -0
- agent_exam/commands/rescore.py +236 -0
- agent_exam/commands/runs.py +97 -0
- agent_exam/commands/show.py +718 -0
- agent_exam/config.py +240 -0
- agent_exam/errors.py +49 -0
- agent_exam/hooks.py +41 -0
- agent_exam/ids.py +56 -0
- agent_exam/judge/__init__.py +29 -0
- agent_exam/judge/cache.py +140 -0
- agent_exam/judge/dispatch.py +95 -0
- agent_exam/judge/format_trajectory.py +102 -0
- agent_exam/judge/parse.py +34 -0
- agent_exam/judge/prompt.py +63 -0
- agent_exam/pool.py +477 -0
- agent_exam/providers/__init__.py +37 -0
- agent_exam/providers/base.py +170 -0
- agent_exam/providers/child_env.py +87 -0
- agent_exam/providers/claude_code/__init__.py +17 -0
- agent_exam/providers/claude_code/blocked_plugins.py +83 -0
- agent_exam/providers/claude_code/doctor_probes.py +165 -0
- agent_exam/providers/claude_code/provider.py +449 -0
- agent_exam/providers/claude_code/session_checks.py +74 -0
- agent_exam/providers/claude_code/skill_detect.py +51 -0
- agent_exam/providers/claude_code/stream_parser.py +143 -0
- agent_exam/providers/claude_code/transcripts.py +549 -0
- agent_exam/providers/codex_cli/__init__.py +3 -0
- agent_exam/providers/codex_cli/hermetic_skills.py +21 -0
- agent_exam/providers/codex_cli/paths.py +17 -0
- agent_exam/providers/codex_cli/provider.py +599 -0
- agent_exam/providers/codex_cli/stream_parser.py +294 -0
- agent_exam/providers/codex_cli/transcripts.py +994 -0
- agent_exam/providers/copilot_cli/__init__.py +5 -0
- agent_exam/providers/copilot_cli/doctor_probes.py +53 -0
- agent_exam/providers/copilot_cli/provider.py +266 -0
- agent_exam/providers/copilot_cli/stream_parser.py +152 -0
- agent_exam/providers/copilot_cli/transcripts.py +367 -0
- agent_exam/providers/dummy.py +82 -0
- agent_exam/providers/opencode/__init__.py +3 -0
- agent_exam/providers/opencode/doctor_probes.py +96 -0
- agent_exam/providers/opencode/provider.py +348 -0
- agent_exam/providers/opencode/stream_parser.py +121 -0
- agent_exam/providers/opencode/transcripts.py +701 -0
- agent_exam/providers/process_utils.py +35 -0
- agent_exam/providers/skill_staging.py +184 -0
- agent_exam/ratelimit.py +62 -0
- agent_exam/report.py +217 -0
- agent_exam/report_compare.py +251 -0
- agent_exam/run_modes.py +60 -0
- agent_exam/runner.py +646 -0
- agent_exam/schemas.py +113 -0
- agent_exam/scoring_context.py +46 -0
- agent_exam/serde.py +147 -0
- agent_exam/tasks.py +492 -0
- agent_exam/trajectory_walk.py +81 -0
- agent_exam/validation.py +354 -0
- agent_exam-0.1.0.dist-info/METADATA +139 -0
- agent_exam-0.1.0.dist-info/RECORD +85 -0
- agent_exam-0.1.0.dist-info/WHEEL +4 -0
- agent_exam-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|
agent_exam/_validate.py
ADDED
|
@@ -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})")
|
agent_exam/artifacts.py
ADDED
|
@@ -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
|
+
)
|