Skip to content

Commit 8586dd7

Browse files
halleriteclaude
andcommitted
fix(v1): config errors name the flag the user typed, and predictable
mistakes get pointers instead of tracebacks - Errors raised inside the [env] block re-surface with their full loc, so the CLI renders --env.solver.model, not a bogus root-level --solver with an absurd did-you-mean (prefix_validation_error, applied in resolve_env_field and narrow_plugin_field). - Curated refusals for the predictable mistakes: conflicting positional taskset vs --env.taskset.id (first-narrowed/last-parsed nonsense before); a dangling harness id (was a raw TypeError); legacy --id combined with a v1 taskset (was silently inert); env-level `harness` (points at the seat); the retired `default` harness id (renamed to bash — stale namespace dir deleted); unknown taskset/env/harness ids print their one-line message instead of a 35-71-line traceback, and unknown harnesses list the built-ins. - The usage gate lets --env.* argv through to the typed parse, so a typo'd --env.takset gets a real suggestion instead of the bare usage line. - The role guard keys on the default instance like the role machinery does, so AgentConfig | None and Annotated forms can't shadow base fields or skip the default-instance requirement. - dry_run is excluded from saved configs (eval, replay, gepa) — re-running `@ config.toml` no longer silently dry-runs; --server with an unset --rich now just runs non-rich instead of demanding --no-rich. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 61159d6 commit 8586dd7

12 files changed

Lines changed: 348 additions & 71 deletions

File tree

tests/v1/test_configs.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,109 @@ def test_output_path_compounds_env_id():
3939
assert output_path(paired).parent.name.startswith("best-of-n+echo-v1--")
4040

4141

42+
def test_conflicting_cli_taskset_ids_are_refused():
43+
"""Two `--env.taskset.id` occurrences naming different ids (the positional
44+
shorthand counts as the first) are refused up front — the narrowing pins the
45+
first while the config merge takes the last, so letting them through yields a
46+
baffling wrong-config-type error."""
47+
from verifiers.v1.cli.resolve import narrow_config, with_positional_taskset
48+
49+
argv = with_positional_taskset(["echo-v1", "--env.taskset.id", "other-v1"])
50+
with pytest.raises(SystemExit, match="'echo-v1' and 'other-v1'"):
51+
narrow_config(EvalConfig, argv)
52+
# The same id twice is not a conflict.
53+
narrow_config(
54+
EvalConfig, with_positional_taskset(["echo-v1", "--env.taskset.id", "echo-v1"])
55+
)
56+
57+
58+
def test_dangling_harness_id_is_refused_with_the_builtins():
59+
"""A dangling `--env.agent.harness.id` (no value) parses as boolean True; the
60+
narrowing must answer with the field path and the built-in harness ids, not a
61+
raw TypeError from deep inside the loader."""
62+
from pydantic import ValidationError
63+
64+
with pytest.raises(ValidationError, match=r"harness\.id needs an id.*bash"):
65+
EvalConfig.model_validate(
66+
{"env": {"taskset": {"id": "echo-v1"}, "agent": {"harness": {"id": True}}}}
67+
)
68+
69+
70+
def test_nested_env_errors_keep_their_flag_path():
71+
"""Sub-models validate inside `mode=\"before\"` narrowing validators, which
72+
would surface their error locs without the `env` (and `harness`) segments —
73+
the CLI would render a flag the user never typed."""
74+
from pydantic import ValidationError
75+
76+
with pytest.raises(ValidationError) as e:
77+
EvalConfig.model_validate(
78+
{
79+
"env": {
80+
"taskset": {"id": "echo-v1"},
81+
"agent": {"harness": {"id": "bash", "runtime": {"type": "dockr"}}},
82+
}
83+
}
84+
)
85+
assert e.value.errors()[0]["loc"][:4] == ("env", "agent", "harness", "runtime")
86+
87+
88+
def test_role_guard_keys_on_the_default_instance():
89+
"""The definition-time role guard must use the machinery's membership test
90+
(`isinstance(field.default, AgentConfig)`): `timeout: AgentConfig | None =
91+
AgentConfig()` IS a role to `_declared_agent_configs`, so it must be refused
92+
as shadowing; an AgentConfig annotation without a default instance is never a
93+
role, so it must be refused as missing one — whatever the annotation form."""
94+
import verifiers.v1 as vf
95+
96+
with pytest.raises(TypeError, match="shadow"):
97+
98+
class Shadowing(vf.EnvConfig):
99+
timeout: vf.AgentConfig | None = vf.AgentConfig()
100+
101+
with pytest.raises(TypeError, match="default"):
102+
103+
class Uninstantiated(vf.EnvConfig):
104+
solver: vf.AgentConfig | None = None
105+
106+
107+
def test_legacy_id_with_v1_taskset_is_refused():
108+
"""A legacy `--id` next to a v1 `env.taskset` used to be silently inert
109+
(`is_legacy` is False, the v0 env never loads); the mix is refused with a
110+
pointer at `--env.id`, the likely intent."""
111+
from pydantic import ValidationError
112+
113+
with pytest.raises(ValidationError, match="--env.id"):
114+
EvalConfig.model_validate(
115+
{"id": "wordle", "env": {"taskset": {"id": "echo-v1"}}}
116+
)
117+
118+
119+
def test_env_level_harness_key_points_at_the_seat():
120+
"""`--env.harness.*` (v0 muscle-memory, one level up from the seat) gets a
121+
pointer home instead of a bare extra_forbidden."""
122+
from pydantic import ValidationError
123+
124+
with pytest.raises(ValidationError, match=r"--env\.agent\.harness"):
125+
EvalConfig.model_validate(
126+
{"env": {"taskset": {"id": "echo-v1"}, "harness": {"id": "bash"}}}
127+
)
128+
129+
130+
def test_server_flips_an_unset_rich_default():
131+
"""`--rich` defaults on but is in-process only: an unset `rich` defaults off
132+
under `--server`; only an explicitly set `rich` is refused with it."""
133+
from pydantic import ValidationError
134+
135+
served = EvalConfig.model_validate(
136+
{"env": {"taskset": {"id": "echo-v1"}}, "server": True}
137+
)
138+
assert served.rich is False
139+
with pytest.raises(ValidationError, match="--rich"):
140+
EvalConfig.model_validate(
141+
{"env": {"taskset": {"id": "echo-v1"}}, "server": True, "rich": True}
142+
)
143+
144+
42145
def test_replay_lifts_the_saved_eval_taskset():
43146
"""Replay layers the source run's saved config as its base. An eval run keeps
44147
its taskset on the [env] block — replay's root taskset must pick it up; an

verifiers/v1/cli/debug.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from verifiers.v1.cli.output import append_trace, save_config
1919
from verifiers.v1.cli.resolve import (
2020
extract_id,
21+
plugin_errors,
2122
references_config_file,
2223
with_positional_taskset,
2324
)
@@ -309,14 +310,16 @@ def main(argv: list[str] | None = None) -> None:
309310
if not argv or any(arg in ("-h", "--help") for arg in argv):
310311
print(USAGE)
311312
sys.argv = [sys.argv[0], "--help"]
312-
cli(_narrow(argv))
313+
with plugin_errors():
314+
cli(_narrow(argv))
313315
return
314316
if not extract_id(argv, "taskset") and not references_config_file(argv):
315317
raise SystemExit(USAGE)
316318

317-
config_type = _narrow(argv)
318-
sys.argv = [sys.argv[0], *argv]
319-
config = cli(config_type)
319+
with plugin_errors():
320+
config_type = _narrow(argv)
321+
sys.argv = [sys.argv[0], *argv]
322+
config = cli(config_type)
320323
setup_logging("DEBUG" if config.verbose else "INFO")
321324
# Graceful shutdown: first Ctrl-C/SIGTERM unwinds each task's teardown `finally`;
322325
# a second is swallowed so it can't orphan containers/sandboxes mid-cleanup.

verifiers/v1/cli/eval/main.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from verifiers.v1.cli.resolve import (
1414
extract_id,
1515
narrow_config,
16+
plugin_errors,
1617
references_config_file,
1718
with_positional_taskset,
1819
)
@@ -34,9 +35,10 @@ def main(argv: list[str] | None = None) -> None:
3435
if not argv or any(arg in ("-h", "--help") for arg in argv):
3536
print(USAGE)
3637
sys.argv = [sys.argv[0], "--help"]
37-
cli(
38-
narrow_config(EvalConfig, argv)
39-
) # full option help, narrowed to the given ids
38+
with plugin_errors():
39+
cli(
40+
narrow_config(EvalConfig, argv)
41+
) # full option help, narrowed to the given ids
4042
return
4143
resume_dir, rest = split_resume(argv)
4244
# re-run a previous run's missing/errored rollouts, in place
@@ -48,22 +50,29 @@ def main(argv: list[str] | None = None) -> None:
4850
config = load_resume_config(resume_dir)
4951
else:
5052
legacy_id = any(a == "--id" or a.startswith("--id=") for a in argv) # v0 env id
51-
# A retired flat axis (--taskset.*/--harness.*) skips the usage gate so the
52-
# parse renders its pointer to the new flags instead of a bare usage line.
53-
retired_axis = any(a.startswith(("--taskset.", "--harness.")) for a in argv)
53+
# An env-block flag (or a retired flat axis) skips the usage gate so the
54+
# typed parse renders its did-you-mean / pointer to the new flags instead
55+
# of a bare usage line.
56+
typed_axis = any(
57+
a.startswith(("--env.", "--taskset.", "--harness.")) for a in argv
58+
)
5459
if (
5560
not extract_id(argv, "env.taskset")
5661
and not legacy_id
5762
and not references_config_file(argv)
58-
and not retired_axis
63+
and not typed_axis
5964
):
6065
raise SystemExit(
6166
USAGE
6267
) # need a taskset (positional / --env.taskset.id), a legacy --id, or a @ file.toml
6368

64-
config_type = narrow_config(EvalConfig, argv)
65-
sys.argv = [sys.argv[0], *argv] # let prime-pydantic-config render help/errors
66-
config = cli(config_type)
69+
with plugin_errors():
70+
config_type = narrow_config(EvalConfig, argv)
71+
sys.argv = [
72+
sys.argv[0],
73+
*argv,
74+
] # let prime-pydantic-config render help/errors
75+
config = cli(config_type)
6776
if config.dry_run: # resolved + validated; write it to the output dir and exit
6877
setup_logging("DEBUG" if config.verbose else "INFO")
6978
logger.info("wrote config to %s", write_config(config, output_path(config)))

verifiers/v1/cli/gepa.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from verifiers.v1.cli.resolve import (
2020
extract_id,
2121
narrow_config,
22+
plugin_errors,
2223
references_config_file,
2324
with_positional_taskset,
2425
)
@@ -37,28 +38,30 @@ def main(argv: list[str] | None = None) -> None:
3738
if not argv or any(arg in ("-h", "--help") for arg in argv):
3839
print(USAGE)
3940
sys.argv = [sys.argv[0], "--help"]
40-
cli(
41-
narrow_config(GEPAConfig, argv)
42-
) # full option help, narrowed to the given ids
41+
with plugin_errors():
42+
cli(
43+
narrow_config(GEPAConfig, argv)
44+
) # full option help, narrowed to the given ids
4345
return
4446
if any(a == "--id" or a.startswith("--id=") for a in argv): # v0 env id
4547
raise SystemExit(
4648
"gepa optimizes native v1 tasksets; run a legacy (v0) environment through "
4749
"`vf-gepa` instead of `gepa`."
4850
)
49-
retired_axis = any(a.startswith(("--taskset.", "--harness.")) for a in argv)
51+
typed_axis = any(a.startswith(("--env.", "--taskset.", "--harness.")) for a in argv)
5052
if (
5153
not extract_id(argv, "env.taskset")
5254
and not references_config_file(argv)
53-
and not retired_axis
55+
and not typed_axis
5456
):
5557
raise SystemExit(
5658
USAGE
5759
) # need a taskset (positional / --env.taskset.id) or a @ file.toml
5860

59-
config_type = narrow_config(GEPAConfig, argv)
60-
sys.argv = [sys.argv[0], *argv] # let prime-pydantic-config render help/errors
61-
config = cli(config_type)
61+
with plugin_errors():
62+
config_type = narrow_config(GEPAConfig, argv)
63+
sys.argv = [sys.argv[0], *argv] # let prime-pydantic-config render help/errors
64+
config = cli(config_type)
6265
setup_logging("DEBUG" if config.verbose else "INFO")
6366
if config.dry_run: # resolved + validated; write it to the output dir and exit
6467
logger.info("wrote config to %s", write_config(config, output_path(config)))

verifiers/v1/cli/resolve.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,23 @@
77
states explicitly is pre-narrowed — never to a type a config file could contradict.
88
"""
99

10+
import contextlib
11+
1012
import verifiers.v1 as vf
1113

1214

15+
@contextlib.contextmanager
16+
def plugin_errors():
17+
"""Surface a plugin-resolution failure (unknown taskset/env/harness id, a bad
18+
`__all__` export) as the clean one-line exit the CLI's config errors get,
19+
instead of a raw traceback. Wrap the id-driven narrowing and the typed parse;
20+
a `SystemExit` from inside passes through untouched."""
21+
try:
22+
yield
23+
except (ModuleNotFoundError, AttributeError, TypeError, ValueError) as e:
24+
raise SystemExit(str(e)) from e
25+
26+
1327
def with_positional_taskset(
1428
argv: list[str], flag: str = "--env.taskset.id"
1529
) -> list[str]:
@@ -29,16 +43,29 @@ def references_config_file(argv: list[str]) -> bool:
2943

3044
def extract_id(argv: list[str], field: str, default: str = "") -> str:
3145
"""The chosen `<field>.id` from `--<field>.id <x>` (or `=<x>`) on the CLI, before
32-
the typed parse (the positional taskset shorthand is applied upstream). Absent
33-
here, the id can still come from a `@ file.toml` — validation resolves it from
34-
the parsed data."""
46+
the typed parse (the positional taskset shorthand is applied upstream). Two
47+
occurrences naming different ids are refused — narrowing would pin the first
48+
while the config merge takes the last. Absent here, the id can still come from
49+
a `@ file.toml` — validation resolves it from the parsed data."""
3550
flag = f"--{field}.id"
51+
found: list[str] = []
3652
for i, arg in enumerate(argv):
3753
if arg == flag and i + 1 < len(argv):
38-
return argv[i + 1]
39-
if arg.startswith(flag + "="):
40-
return arg.split("=", 1)[1]
41-
return default
54+
found.append(argv[i + 1])
55+
elif arg.startswith(flag + "="):
56+
found.append(arg.split("=", 1)[1])
57+
distinct = list(dict.fromkeys(found))
58+
if len(distinct) > 1:
59+
hint = (
60+
" — the positional <taskset-id> shorthand sets it too"
61+
if field.endswith("taskset")
62+
else ""
63+
)
64+
raise SystemExit(
65+
f"{flag} is set twice, with different ids: {distinct[0]!r} and "
66+
f"{distinct[1]!r}{hint}; drop one"
67+
)
68+
return found[0] if found else default
4269

4370

4471
def narrow_config(base: type, argv: list[str]) -> type:

verifiers/v1/cli/serve.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from verifiers.v1.cli.resolve import (
1010
extract_id,
1111
narrow_config,
12+
plugin_errors,
1213
references_config_file,
1314
with_positional_taskset,
1415
)
@@ -25,25 +26,28 @@ def main(argv: list[str] | None = None) -> None:
2526
if not argv or any(arg in ("-h", "--help") for arg in argv):
2627
print(USAGE)
2728
sys.argv = [sys.argv[0], "--help"]
28-
cli(narrow_config(ServeConfig, argv))
29+
with plugin_errors():
30+
cli(narrow_config(ServeConfig, argv))
2931
return
3032
legacy_id = any(a == "--id" or a.startswith("--id=") for a in argv) # v0 env id
31-
# A retired flat axis (--taskset.*/--harness.*) skips the usage gate so the
32-
# parse renders its pointer to the new flags instead of a bare usage line.
33-
retired_axis = any(a.startswith(("--taskset.", "--harness.")) for a in argv)
33+
# An env-block flag (or a retired flat axis) skips the usage gate so the typed
34+
# parse renders its did-you-mean / pointer to the new flags instead of a bare
35+
# usage line.
36+
typed_axis = any(a.startswith(("--env.", "--taskset.", "--harness.")) for a in argv)
3437
if (
3538
not extract_id(argv, "env.taskset")
3639
and not legacy_id
3740
and not references_config_file(argv)
38-
and not retired_axis
41+
and not typed_axis
3942
):
4043
raise SystemExit(
4144
USAGE
4245
) # need a taskset (positional / --env.taskset.id), a legacy --id, or @ file.toml
4346

44-
config_type = narrow_config(ServeConfig, argv)
45-
sys.argv = [sys.argv[0], *argv]
46-
config = cli(config_type)
47+
with plugin_errors():
48+
config_type = narrow_config(ServeConfig, argv)
49+
sys.argv = [sys.argv[0], *argv]
50+
config = cli(config_type)
4751
if config.dry_run:
4852
print(config.model_dump_json(indent=2, exclude_none=True))
4953
return

verifiers/v1/cli/validate.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from verifiers.v1.cli.dashboard import TaskProgress, validate_dashboard
1515
from verifiers.v1.cli.resolve import (
1616
extract_id,
17+
plugin_errors,
1718
references_config_file,
1819
with_positional_taskset,
1920
)
@@ -255,16 +256,21 @@ def main(argv: list[str] | None = None) -> None:
255256
if not argv or any(arg in ("-h", "--help") for arg in argv):
256257
print(USAGE)
257258
sys.argv = [sys.argv[0], "--help"]
258-
cli(_narrow(argv)) # full option help, narrowed to the given taskset
259+
with plugin_errors():
260+
cli(_narrow(argv)) # full option help, narrowed to the given taskset
259261
return
260262
if not extract_id(argv, "taskset") and not references_config_file(argv):
261263
raise SystemExit(
262264
USAGE
263265
) # need a taskset (positional / --taskset.id) or a @ file.toml
264266

265-
config_type = _narrow(argv)
266-
sys.argv = [sys.argv[0], *argv] # let prime-pydantic-config render help/errors
267-
config = cli(config_type)
267+
with plugin_errors():
268+
config_type = _narrow(argv)
269+
sys.argv = [
270+
sys.argv[0],
271+
*argv,
272+
] # let prime-pydantic-config render help/errors
273+
config = cli(config_type)
268274
# Nothing is persisted, so logs are the whole output. Under `--rich` the dashboard owns the
269275
# screen, so keep logs off the console (else stray records print over the UI).
270276
setup_logging("DEBUG" if config.verbose else "INFO", console=not config.rich)

0 commit comments

Comments
 (0)