feat(criteria): make async the primary criterion-checking surface - #60
feat(criteria): make async the primary criterion-checking surface#60akshaylive wants to merge 6 commits into
Conversation
…rride-contract gap Addresses the review on PR #60: - BaseCriterion.__init_subclass__ now enforces the "_check_impl or _check_impl_async" override contract at class-definition time, for every entry point (register_criterion decorator, or CriterionRegistry.register called directly) — previously only register_criterion checked, and the fallback failure mode was a silent mutual-recursion RuntimeError instead of a clear TypeError. - handle_criterion_errors / handle_criterion_errors_async no longer erase their wrapped method's signature to `(...) -> CriterionResult`; typed with PEP 695 ParamSpec + Concatenate so a caller passing the wrong criterion model or extra args is still caught statically. - check_all_async now runs the sync-criteria to_thread batch to completion BEFORE starting the native-async (judge) batch, instead of overlapping them — several first-party sync criteria mutate the sandbox (run_command, uipath_eval) while judge criteria read it, so the previous overlap could make a judge's score depend on how far a concurrent sandbox-mutating command happened to get. Judge criteria still run concurrently with EACH OTHER, which is the actual GH #55 fix. - The native-async gather now uses return_exceptions=True and re-raises only after every sibling settles, so a JudgeInfrastructureError no longer orphans sibling judge work (e.g. an agent_judge sub-agent left running against a sandbox already being torn down). - Added/repaired tests: native-async dispatch is now pinned against the *real* registered llm_judge/agent_judge checkers (not just fakes); check_all_async's reference_code/reference_dir persistence has a regression test; a two-criterion test covers the no-orphaned-siblings gather semantics; test_registry's context-kwarg conformance check now inspects whichever of _check_impl/_check_impl_async a checker actually overrides instead of vacuously passing for llm_judge/agent_judge. - Fixed stale docstrings/docs naming the old sync invoke_*_judge symbols or describing check_all() as the orchestrator's entry point; briefly documented the dual-surface contract in docs/EXTENDING.md and CLAUDE.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ck event loop, harden dispatch Second pass over the PR #60 review, covering the remaining nits and "what's missing" items not yet handled by the previous commit: - llm_judge / agent_judge now offload JudgeContextBuilder.build() (sandbox + reference file reads) to a worker thread via asyncio.to_thread instead of doing that synchronous file I/O directly on the event loop — the whole point of these checkers being "native async" is that they never block the loop, and this closed the one place they still did. - SubAgentRunner's finally-block cleanup no longer awaits shutil.rmtree via asyncio.to_thread — run_async is awaited directly on the orchestrator's loop (not under its own asyncio.run on a worker thread), so that await was reachable by cancellation (e.g. the task_timeout watchdog) and could skip cleanup, leaking the mkdtemp sandbox copy. It's now a plain synchronous rmtree (best-effort, bounded) that cancellation can't interrupt mid-copy, with a regression test that cancels run_async mid-communicate and asserts judge_dir is gone. - _is_native_async now classifies via the registered CLASS (CriterionRegistry.get_checker), not a constructed instance — it runs before any per-criterion error boundary in check_all_async, so a checker constructor failure no longer escapes and aborts the whole task instead of scoring that one criterion 0. - Collapsed the sync/async twin duplication in SuccessChecker: the reference/turn-record persist preamble (previously copy-pasted into check/check_all/check_all_async) is now one _resolve_refs() helper: the result-shaping + logging tail of _check_single/_check_single_async (previously a ~30-line verbatim clone apart from one line) is now shared via _finalize_result/_missing_checker_result/_error_result, so the two methods differ only in checker.check(...) vs await checker.check_async(...). - check_all_async's dead early-return-on-empty guard removed (the two dispatch batches already no-op on empty input) and the O(n^2) index-membership scan now uses a set; the [None]*len prefill is resolved with an assert + cast instead of a blanket # type: ignore. - Replaced the two wall-clock-timing concurrency assertions in test_check_all_async.py with a deterministic rendezvous barrier (_ConcurrencyProbe) / ordered event markers, and added an end-to-end regression test using the REAL registered run_command + llm_judge checkers (not fakes) proving the sequencing fix: a judge now reliably sees a file a concurrent run_command wrote, where it flakily wouldn't before. - Added check_all_async's missing Args/Returns docstring block (parity with its sync twin). Deliberately NOT done (flagged in review as "Harness & Lint Improvements", scoped as new repo-wide tooling/policy investments rather than defects in this PR's diff — better suited to a follow-up issue than bundled in): new CE0xx lint rules (CE026/032-038), dropping tests/ from the pyright exclude, reportImplicitOverride / reportUnnecessaryTypeIgnoreComment pyright config, ruff PGH lint selection, a diff-cover patch-coverage gate, a duplicate-code detector step, and a global judge-concurrency semaphore for nightly batch runs (task-level --max-parallel already caps total fan-out; the concurrency added here is only across one task's own 1-3 judge criteria, not an unbounded multiplier). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Addressed the review across two commits (7dcb8b6 → d4e6f87): Blockers
Non-blocking / nits
Deliberately not done — flagged as new repo-wide tooling/policy investments rather than defects in this diff, better suited to a follow-up issue: the ~13 new
|
…and silent misuse scoring Addresses the second review pass on PR #60 — three real scoring/correctness blockers plus the contract/API-surface follow-ups: - check_all_async now splits criteria into maximal CONTIGUOUS runs of the same kind (sync vs native-async) and executes the runs strictly in declaration order, instead of always running the whole sync batch before any async criterion. The previous scheme inverted declaration order for e.g. [llm_judge, run_command] — the judge, though declared first, always ran after run_command, silently grading post-mutation sandbox state instead of the pre-mutation state check_all's serial order would give it. Adjacent judges in the same run still gather concurrently (the actual GH #55 fix); results are built from a dict proven total over all runs, replacing the previous assert-guarded cast with a real KeyError on any future gap. Every captured sibling exception in a run is now logged (not silently dropped) before the first is re-raised. - SubAgentRunner.run_async no longer leaks a full sandbox copy when cancelled mid-copytree: judge_dir is bound with a plain synchronous tempfile.mkdtemp (one syscall, not cancellable, not worth to_thread's cancellation-window cost), and the copytree/rmtree to_thread calls are wrapped in asyncio.shield + tracked in a pending list that `finally` awaits BEFORE its own rmtree — so an orphaned worker thread can no longer recreate files after cleanup already ran. Applied the same await-in-finally fix to simulation/user_simulator.py's scratch-dir teardown (same class of leak, previously only fixed in sub_agent.py). - BaseCriterion._check_impl's derived asyncio.run bridge now detects a running event loop and raises a new CheckerMisuseError (which handle_criterion_errors(_async) escalate, like JudgeInfrastructureError) instead of letting asyncio.run's RuntimeError get silently swallowed into a scored-0.0 CriterionResult — a library/embedder calling the public sync check()/check_all() on an async-only checker from async host code now gets a loud, named error instead of a wrong score. - __init_subclass__ now enforces "exactly one", not just "at least one": a checker overriding BOTH _check_impl and _check_impl_async is also rejected (two live implementations that could drift into different scores depending on which entry point ran). Added an `abstract=True` class-kwarg escape hatch for intentional abstract intermediate bases, and a __new__ guard so BaseCriterion itself can't be instantiated directly (lost when @AbstractMethod was dropped). - Promoted the native-async capability check to a public, typed BaseCriterion.is_native_async() classmethod; SuccessChecker._is_native_async and test_registry.py now call it instead of comparing `_check_impl_async` identity across package boundaries. Decorated check()/check_async() with @typing.final so the "these are FINAL" docstring contract is pyright-enforced. Extracted the duplicated 18-line error-capture tail out of handle_criterion_errors/_async into one shared _failed_result() helper. - Tests: reversed-declaration-order regression test (real llm_judge + run_command checkers) proving the contiguous-run fix; a cancel-during-copy test on SubAgentRunner reproducing and closing the leak; a loud-CheckerMisuseError-from-a-running-loop test; both-overridden / abstract=True / direct-instantiation tests for the tightened __init_subclass__ contract; a thread-affinity assertion (not just score) for the derived async bridge; replaced two inert TestNativeAsyncDetection tests (injected into _checker_instances, which classification never reads) with registry-based ones that actually exercise the class-based dispatch rule; a checker-__init__-failure test closing the last uncovered branch in _check_single_async; one check_all_async happy-path test each for llm_judge/agent_judge (production's actual entry point — every other test in both files still drives the derived sync bridge). - Fixed stale ``check_all`` (vs check_all_async) mentions in CLAUDE.md, early_stop.py, reports.py, and orchestrator.py; documented the must-not-block-the-loop obligation and the CheckerMisuseError caveat in docs/EXTENDING.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Addressed the second review pass (commit a91cf5c → 23929f4). Blockers fixed
Contract/API-surface follow-ups
Deliberately not implemented — the "Harness & Lint Improvements" follow-up comment proposed ~8 new |
BaseCriterion previously required every checker to implement the sync _check_impl, with async only a to-thread-wrapped afterthought — so the prior fix for judge-criteria serialization (llm_judge/agent_judge) had to hand-maintain two near-duplicate implementations (a sync client + an async client) side by side. Flip the relationship: _check_impl_async is now the primary surface, with each of _check_impl / _check_impl_async deriving a default for the other (to_thread for sync-only checkers, asyncio.run for async-only ones). register_criterion enforces overriding at least one. llm_judge and agent_judge now implement ONLY _check_impl_async (AsyncAnthropic, httpx.AsyncClient, SubAgentRunner.run_async) — no sync client duplicated. SubAgentRunner.run() (sync) is removed since nothing calls it anymore; run_async() is the sole entrypoint. SuccessChecker.check_all_async detects "native async" checkers by introspecting whether _check_impl_async is overridden (no more explicit supports_native_async flag) and gathers them concurrently with each other while the remaining sync criteria still share one to_thread slot — fixing GH #55 (judge criteria serializing / pinning threads inside check_all). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rride-contract gap Addresses the review on PR #60: - BaseCriterion.__init_subclass__ now enforces the "_check_impl or _check_impl_async" override contract at class-definition time, for every entry point (register_criterion decorator, or CriterionRegistry.register called directly) — previously only register_criterion checked, and the fallback failure mode was a silent mutual-recursion RuntimeError instead of a clear TypeError. - handle_criterion_errors / handle_criterion_errors_async no longer erase their wrapped method's signature to `(...) -> CriterionResult`; typed with PEP 695 ParamSpec + Concatenate so a caller passing the wrong criterion model or extra args is still caught statically. - check_all_async now runs the sync-criteria to_thread batch to completion BEFORE starting the native-async (judge) batch, instead of overlapping them — several first-party sync criteria mutate the sandbox (run_command, uipath_eval) while judge criteria read it, so the previous overlap could make a judge's score depend on how far a concurrent sandbox-mutating command happened to get. Judge criteria still run concurrently with EACH OTHER, which is the actual GH #55 fix. - The native-async gather now uses return_exceptions=True and re-raises only after every sibling settles, so a JudgeInfrastructureError no longer orphans sibling judge work (e.g. an agent_judge sub-agent left running against a sandbox already being torn down). - Added/repaired tests: native-async dispatch is now pinned against the *real* registered llm_judge/agent_judge checkers (not just fakes); check_all_async's reference_code/reference_dir persistence has a regression test; a two-criterion test covers the no-orphaned-siblings gather semantics; test_registry's context-kwarg conformance check now inspects whichever of _check_impl/_check_impl_async a checker actually overrides instead of vacuously passing for llm_judge/agent_judge. - Fixed stale docstrings/docs naming the old sync invoke_*_judge symbols or describing check_all() as the orchestrator's entry point; briefly documented the dual-surface contract in docs/EXTENDING.md and CLAUDE.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ck event loop, harden dispatch Second pass over the PR #60 review, covering the remaining nits and "what's missing" items not yet handled by the previous commit: - llm_judge / agent_judge now offload JudgeContextBuilder.build() (sandbox + reference file reads) to a worker thread via asyncio.to_thread instead of doing that synchronous file I/O directly on the event loop — the whole point of these checkers being "native async" is that they never block the loop, and this closed the one place they still did. - SubAgentRunner's finally-block cleanup no longer awaits shutil.rmtree via asyncio.to_thread — run_async is awaited directly on the orchestrator's loop (not under its own asyncio.run on a worker thread), so that await was reachable by cancellation (e.g. the task_timeout watchdog) and could skip cleanup, leaking the mkdtemp sandbox copy. It's now a plain synchronous rmtree (best-effort, bounded) that cancellation can't interrupt mid-copy, with a regression test that cancels run_async mid-communicate and asserts judge_dir is gone. - _is_native_async now classifies via the registered CLASS (CriterionRegistry.get_checker), not a constructed instance — it runs before any per-criterion error boundary in check_all_async, so a checker constructor failure no longer escapes and aborts the whole task instead of scoring that one criterion 0. - Collapsed the sync/async twin duplication in SuccessChecker: the reference/turn-record persist preamble (previously copy-pasted into check/check_all/check_all_async) is now one _resolve_refs() helper: the result-shaping + logging tail of _check_single/_check_single_async (previously a ~30-line verbatim clone apart from one line) is now shared via _finalize_result/_missing_checker_result/_error_result, so the two methods differ only in checker.check(...) vs await checker.check_async(...). - check_all_async's dead early-return-on-empty guard removed (the two dispatch batches already no-op on empty input) and the O(n^2) index-membership scan now uses a set; the [None]*len prefill is resolved with an assert + cast instead of a blanket # type: ignore. - Replaced the two wall-clock-timing concurrency assertions in test_check_all_async.py with a deterministic rendezvous barrier (_ConcurrencyProbe) / ordered event markers, and added an end-to-end regression test using the REAL registered run_command + llm_judge checkers (not fakes) proving the sequencing fix: a judge now reliably sees a file a concurrent run_command wrote, where it flakily wouldn't before. - Added check_all_async's missing Args/Returns docstring block (parity with its sync twin). Deliberately NOT done (flagged in review as "Harness & Lint Improvements", scoped as new repo-wide tooling/policy investments rather than defects in this PR's diff — better suited to a follow-up issue than bundled in): new CE0xx lint rules (CE026/032-038), dropping tests/ from the pyright exclude, reportImplicitOverride / reportUnnecessaryTypeIgnoreComment pyright config, ruff PGH lint selection, a diff-cover patch-coverage gate, a duplicate-code detector step, and a global judge-concurrency semaphore for nightly batch runs (task-level --max-parallel already caps total fan-out; the concurrency added here is only across one task's own 1-3 judge criteria, not an unbounded multiplier). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- handle_criterion_errors / handle_criterion_errors_async: revert from the
PEP 695 `def f[**P](...)` type-parameter form back to a module-level
`P = ParamSpec("P")` (with a targeted `# noqa: UP047` for ruff) — CodeQL's
Python extractor doesn't parse PEP 695 type params referenced via
`P.args`/`P.kwargs` and was flagging `P` as "potentially uninitialized".
- test_check_all_async.py: build the both-defaults-unimplemented checker via
`types.new_class(...)` instead of a `class _NeitherChecker(...):` statement
inside `pytest.raises` — the name was never usable afterward anyway
(`__init_subclass__` raises before the class object exists), so CodeQL
flagged it as an unused local. `type(name, bases, ns)` doesn't support MRO
entry resolution for the generic alias `BaseCriterion[FileExistsCriterion]`,
hence `types.new_class` rather than a bare `type(...)` call.
- test_sub_agent_runner.py: `_ = await task` instead of a bare `await task`
statement inside the cancellation regression test — CodeQL flagged the
bare await as a no-op statement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…and silent misuse scoring Addresses the second review pass on PR #60 — three real scoring/correctness blockers plus the contract/API-surface follow-ups: - check_all_async now splits criteria into maximal CONTIGUOUS runs of the same kind (sync vs native-async) and executes the runs strictly in declaration order, instead of always running the whole sync batch before any async criterion. The previous scheme inverted declaration order for e.g. [llm_judge, run_command] — the judge, though declared first, always ran after run_command, silently grading post-mutation sandbox state instead of the pre-mutation state check_all's serial order would give it. Adjacent judges in the same run still gather concurrently (the actual GH #55 fix); results are built from a dict proven total over all runs, replacing the previous assert-guarded cast with a real KeyError on any future gap. Every captured sibling exception in a run is now logged (not silently dropped) before the first is re-raised. - SubAgentRunner.run_async no longer leaks a full sandbox copy when cancelled mid-copytree: judge_dir is bound with a plain synchronous tempfile.mkdtemp (one syscall, not cancellable, not worth to_thread's cancellation-window cost), and the copytree/rmtree to_thread calls are wrapped in asyncio.shield + tracked in a pending list that `finally` awaits BEFORE its own rmtree — so an orphaned worker thread can no longer recreate files after cleanup already ran. Applied the same await-in-finally fix to simulation/user_simulator.py's scratch-dir teardown (same class of leak, previously only fixed in sub_agent.py). - BaseCriterion._check_impl's derived asyncio.run bridge now detects a running event loop and raises a new CheckerMisuseError (which handle_criterion_errors(_async) escalate, like JudgeInfrastructureError) instead of letting asyncio.run's RuntimeError get silently swallowed into a scored-0.0 CriterionResult — a library/embedder calling the public sync check()/check_all() on an async-only checker from async host code now gets a loud, named error instead of a wrong score. - __init_subclass__ now enforces "exactly one", not just "at least one": a checker overriding BOTH _check_impl and _check_impl_async is also rejected (two live implementations that could drift into different scores depending on which entry point ran). Added an `abstract=True` class-kwarg escape hatch for intentional abstract intermediate bases, and a __new__ guard so BaseCriterion itself can't be instantiated directly (lost when @AbstractMethod was dropped). - Promoted the native-async capability check to a public, typed BaseCriterion.is_native_async() classmethod; SuccessChecker._is_native_async and test_registry.py now call it instead of comparing `_check_impl_async` identity across package boundaries. Decorated check()/check_async() with @typing.final so the "these are FINAL" docstring contract is pyright-enforced. Extracted the duplicated 18-line error-capture tail out of handle_criterion_errors/_async into one shared _failed_result() helper. - Tests: reversed-declaration-order regression test (real llm_judge + run_command checkers) proving the contiguous-run fix; a cancel-during-copy test on SubAgentRunner reproducing and closing the leak; a loud-CheckerMisuseError-from-a-running-loop test; both-overridden / abstract=True / direct-instantiation tests for the tightened __init_subclass__ contract; a thread-affinity assertion (not just score) for the derived async bridge; replaced two inert TestNativeAsyncDetection tests (injected into _checker_instances, which classification never reads) with registry-based ones that actually exercise the class-based dispatch rule; a checker-__init__-failure test closing the last uncovered branch in _check_single_async; one check_all_async happy-path test each for llm_judge/agent_judge (production's actual entry point — every other test in both files still drives the derived sync bridge). - Fixed stale ``check_all`` (vs check_all_async) mentions in CLAUDE.md, early_stop.py, reports.py, and orchestrator.py; documented the must-not-block-the-loop obligation and the CheckerMisuseError caveat in docs/EXTENDING.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cy to a follow-up PR check_all_async now runs every criterion strictly sequentially, in declaration order — identical order/isolation semantics to the sync check_all — instead of gathering adjacent native-async (judge) criteria concurrently. The concurrent-dispatch scheduling change (the original GH #55 motivation) is deferred to a follow-up PR so it can be reviewed on its own. What's kept from the async-primary refactor: BaseCriterion's async-primary _check_impl/_check_impl_async derivation, is_native_async(), the __init_subclass__ override contract (exactly one, abstract=True escape hatch), CheckerMisuseError for a misused sync bridge, and native-async criteria (llm_judge/agent_judge) being awaited directly on the loop instead of pinning a thread-pool thread — none of that requires concurrent dispatch to be worthwhile on its own. check_all_async's docstring, judge_anthropic.py/judge_bedrock.py/ sub_agent.py's "concurrently with other judges" claims, and docs/EXTENDING.md's native-async guidance are updated to describe the current sequential behavior and note where the follow-up PR picks up. Tests: the concurrency-proving tests in test_check_all_async.py (rendezvous barrier, gather-return_exceptions sibling-settling) are replaced with sequential-execution tests (ordered event markers proving NO overlap, and a test that a JudgeInfrastructureError stops remaining criteria outright rather than letting siblings finish, since nothing runs concurrently anymore). Also fixes tests/test_route_seam_exhaustiveness.py, which the main-branch LiteLLM routing PR added against the pre-refactor sync invoke_bedrock_judge/ invoke_anthropic_judge names and a sync _invoke_tool_channel — rebased onto this branch's renamed *_async functions and async _invoke_tool_channel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
23929f4 to
d42e19d
Compare
Summary
Redesign of the fix for #55, replacing PR #58. That PR made judge criteria (
llm_judge/agent_judge) concurrent by bolting an async path onto a sync-firstBaseCriterion— each judge checker ended up with two near-duplicate implementations (a sync client + an async client) plus asupports_native_asyncmarker flag to tellSuccessCheckerwhich path to dispatch through. This PR inverts that relationship instead of layering on top of it:BaseCriterion._check_impl_asyncis now the primary implementation surface. A checker overrides exactly one of_check_impl(sync) or_check_impl_async(async) — whichever is its natural form — and the base class derives the other automatically:file_exists,command_executed, ...) keep overriding_check_implonly; the default_check_impl_asyncoffloads it viaasyncio.to_thread.llm_judge/agent_judgenow override only_check_impl_async, usingAsyncAnthropic,httpx.AsyncClient, and a newSubAgentRunner.run_async— no sync client duplicated. The default_check_implderives a sync call viaasyncio.run(...)for any caller that still wants one (tests, etc.).register_criterionenforces that a checker overrides at least one of the two (overriding neither would recurse forever between the defaults).SuccessChecker.check_all_asyncno longer needs an explicit "is this checker async-native" flag — it detects it by introspecting whether_check_impl_asyncis overridden, then gathers those checkers concurrently with each other while the remaining sync checkers still share oneasyncio.to_threadslot (unchanged behavior for the 12 non-judge criteria).SubAgentRunner.run()(sync) is removed — nothing calls it anymore now thatagent_judgeis async-only;run_async()is the sole entrypoint.Net effect on behavior is identical to #58 (judge criteria run concurrently instead of serializing, and don't pin a thread-pool thread for the network wait) — this PR just gets there with one implementation per criterion instead of two.
Test plan
make check/make typecheck/make lintcleanmake verify— 3582 passed, 2 skipped, 90.99% coveragetests/test_check_all_async.py— judge criteria overlap instead of serializing, sync/async batches overlap, ordering preserved, error handling parity,_is_native_asyncdetection,register_criterionguard, and direct sync↔async derivation onBaseCriteriontests/test_judge_anthropic.py/tests/test_judge_bedrock.pyrewritten against the async invokerstests/test_sub_agent_runner.pyconverted torun_asyncCloses #55 (supersedes #58 — closing that PR in favor of this one)
🤖 Generated with Claude Code