evalrx 0.1.2__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.
- evalrx/__init__.py +139 -0
- evalrx/agent_assets/__init__.py +2 -0
- evalrx/agent_assets/skills/README.md +28 -0
- evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
- evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
- evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
- evalrx/agent_assets/skills/nature-figure/README.md +412 -0
- evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
- evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
- evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
- evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
- evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
- evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
- evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
- evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
- evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
- evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
- evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
- evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
- evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
- evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
- evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
- evalrx/agent_assets/skills.py +27 -0
- evalrx/agent_runtime/__init__.py +78 -0
- evalrx/agent_runtime/_docker_runner.py +89 -0
- evalrx/agent_runtime/cli_runtime.py +103 -0
- evalrx/agent_runtime/cli_transcript.py +138 -0
- evalrx/agent_runtime/cli_types.py +68 -0
- evalrx/agent_runtime/codegen/__init__.py +5 -0
- evalrx/agent_runtime/codegen/runner.py +94 -0
- evalrx/agent_runtime/experiment_harness.py +117 -0
- evalrx/agent_runtime/factory.py +102 -0
- evalrx/agent_runtime/json_shape.py +44 -0
- evalrx/agent_runtime/judges/__init__.py +28 -0
- evalrx/agent_runtime/judges/agy.py +179 -0
- evalrx/agent_runtime/judges/autodetect.py +135 -0
- evalrx/agent_runtime/judges/claude.py +159 -0
- evalrx/agent_runtime/judges/codex.py +120 -0
- evalrx/agent_runtime/providers/__init__.py +21 -0
- evalrx/agent_runtime/providers/antigravity.py +31 -0
- evalrx/agent_runtime/providers/base.py +145 -0
- evalrx/agent_runtime/providers/claude_code.py +49 -0
- evalrx/agent_runtime/providers/codex.py +37 -0
- evalrx/agent_runtime/providers/gemini_cli.py +26 -0
- evalrx/agent_runtime/providers/kimi_cli.py +27 -0
- evalrx/agent_runtime/providers/opencode.py +27 -0
- evalrx/agent_runtime/providers/registry.py +58 -0
- evalrx/agent_runtime/sandbox.py +517 -0
- evalrx/agent_runtime/skill_audit.py +143 -0
- evalrx/agent_runtime/skills/__init__.py +19 -0
- evalrx/agent_runtime/skills/installer.py +68 -0
- evalrx/agent_runtime/skills/prompt_policy.py +86 -0
- evalrx/agent_runtime/skills/resolver.py +19 -0
- evalrx/analysis/__init__.py +132 -0
- evalrx/analysis/adjudicate.py +154 -0
- evalrx/analysis/analysis_module.py +361 -0
- evalrx/analysis/api.py +171 -0
- evalrx/analysis/case_studio.py +651 -0
- evalrx/analysis/cli.py +114 -0
- evalrx/analysis/dashboard.py +350 -0
- evalrx/analysis/eval_case_matrix.py +118 -0
- evalrx/analysis/eval_viz_theme.py +833 -0
- evalrx/analysis/explore_run.py +333 -0
- evalrx/analysis/explorer.py +1276 -0
- evalrx/analysis/failure_modes.py +607 -0
- evalrx/analysis/fused_pipeline.py +489 -0
- evalrx/analysis/holdout.py +300 -0
- evalrx/analysis/hypothesis_agent.py +230 -0
- evalrx/analysis/narration.py +177 -0
- evalrx/analysis/operationalize.py +442 -0
- evalrx/analysis/plain_language.py +42 -0
- evalrx/analysis/planner.py +283 -0
- evalrx/analysis/probe_search.py +203 -0
- evalrx/analysis/profile.py +268 -0
- evalrx/analysis/prompts/__init__.py +0 -0
- evalrx/analysis/prompts/explorer.py +417 -0
- evalrx/analysis/prompts/failure_modes.py +33 -0
- evalrx/analysis/prompts/holdout.py +27 -0
- evalrx/analysis/prompts/hypothesis_agent.py +78 -0
- evalrx/analysis/prompts/run_codebase.py +47 -0
- evalrx/analysis/prompts/stats_agent.py +72 -0
- evalrx/analysis/prompts/stats_tool_generator.py +43 -0
- evalrx/analysis/result_marker.py +47 -0
- evalrx/analysis/run_codebase.py +242 -0
- evalrx/analysis/run_view.py +205 -0
- evalrx/analysis/stage_views.py +93 -0
- evalrx/analysis/stats_agent.py +944 -0
- evalrx/analysis/stats_tool_agent.py +261 -0
- evalrx/analysis/stats_tool_generator.py +415 -0
- evalrx/analysis/stats_tools.py +1153 -0
- evalrx/analysis/trajectory_records.py +193 -0
- evalrx/analysis/workbench.py +431 -0
- evalrx/analyzers/__init__.py +42 -0
- evalrx/analyzers/agent/__init__.py +25 -0
- evalrx/analyzers/agent/counterfactual.py +84 -0
- evalrx/analyzers/agent/first_error_judge.py +96 -0
- evalrx/analyzers/agent/ignored_obs.py +81 -0
- evalrx/analyzers/agent/loop_detect.py +79 -0
- evalrx/analyzers/agent/reliability.py +165 -0
- evalrx/analyzers/agent/tool_shap.py +225 -0
- evalrx/analyzers/agent/trajectory_rubric.py +168 -0
- evalrx/analyzers/attention/__init__.py +19 -0
- evalrx/analyzers/attention/relative_attn.py +610 -0
- evalrx/analyzers/attention/rollout.py +73 -0
- evalrx/analyzers/attention/sink.py +56 -0
- evalrx/analyzers/attention/summary.py +190 -0
- evalrx/analyzers/attribution/__init__.py +6 -0
- evalrx/analyzers/attribution/generic_attn.py +31 -0
- evalrx/analyzers/attribution/gradcam.py +30 -0
- evalrx/analyzers/base.py +12 -0
- evalrx/analyzers/geometry/__init__.py +6 -0
- evalrx/analyzers/geometry/cka.py +70 -0
- evalrx/analyzers/geometry/linear_probe.py +157 -0
- evalrx/analyzers/hallucination/__init__.py +9 -0
- evalrx/analyzers/hallucination/chair.py +78 -0
- evalrx/analyzers/hallucination/opera.py +29 -0
- evalrx/analyzers/hallucination/pope.py +119 -0
- evalrx/analyzers/hallucination/selfcheck.py +155 -0
- evalrx/analyzers/hallucination/vcd.py +29 -0
- evalrx/analyzers/lens/__init__.py +7 -0
- evalrx/analyzers/lens/layer_contrast.py +133 -0
- evalrx/analyzers/lens/logit_lens.py +138 -0
- evalrx/analyzers/lens/tuned_lens.py +30 -0
- evalrx/analyzers/patching/__init__.py +5 -0
- evalrx/analyzers/patching/causal_trace.py +30 -0
- evalrx/analyzers/perturbation/__init__.py +23 -0
- evalrx/analyzers/perturbation/_shapley.py +54 -0
- evalrx/analyzers/perturbation/context_shap.py +174 -0
- evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
- evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
- evalrx/analyzers/perturbation/mm_shap.py +146 -0
- evalrx/analyzers/perturbation/modality_ablation.py +196 -0
- evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
- evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
- evalrx/analyzers/perturbation/rise.py +94 -0
- evalrx/analyzers/perturbation/vl_shap.py +102 -0
- evalrx/analyzers/reasoning/__init__.py +33 -0
- evalrx/analyzers/reasoning/_text.py +328 -0
- evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
- evalrx/analyzers/reasoning/arith_audit.py +226 -0
- evalrx/analyzers/reasoning/contamination.py +214 -0
- evalrx/analyzers/reasoning/knowledge_split.py +253 -0
- evalrx/analyzers/reasoning/self_repair.py +246 -0
- evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
- evalrx/analyzers/reasoning/termination_audit.py +258 -0
- evalrx/analyzers/uncertainty/__init__.py +18 -0
- evalrx/analyzers/uncertainty/calibration.py +174 -0
- evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
- evalrx/analyzers/uncertainty/entropy.py +90 -0
- evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
- evalrx/analyzers/uncertainty/self_consistency.py +204 -0
- evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
- evalrx/cli.py +411 -0
- evalrx/config.py +77 -0
- evalrx/contract/__init__.py +179 -0
- evalrx/contract/common.py +452 -0
- evalrx/contract/emit.py +948 -0
- evalrx/contract/export.py +237 -0
- evalrx/contract/m1.py +325 -0
- evalrx/contract/m2.py +317 -0
- evalrx/contract/m3.py +165 -0
- evalrx/contract/m4.py +130 -0
- evalrx/contract/m5.py +292 -0
- evalrx/contract/methodology.py +76 -0
- evalrx/contract/pre_m1.py +58 -0
- evalrx/contract/typescript.py +140 -0
- evalrx/core/__init__.py +85 -0
- evalrx/core/analyzer.py +174 -0
- evalrx/core/capability.py +54 -0
- evalrx/core/case.py +443 -0
- evalrx/core/experiment.py +106 -0
- evalrx/core/model.py +198 -0
- evalrx/core/pipeline.py +42 -0
- evalrx/core/registry.py +142 -0
- evalrx/core/result.py +64 -0
- evalrx/core/spec.py +173 -0
- evalrx/core/tokentype.py +165 -0
- evalrx/core/tool.py +92 -0
- evalrx/datasets/__init__.py +41 -0
- evalrx/datasets/base.py +68 -0
- evalrx/datasets/gui_os.py +52 -0
- evalrx/datasets/llm_qa.py +57 -0
- evalrx/datasets/pure_qa.py +12 -0
- evalrx/datasets/vlm_qa.py +695 -0
- evalrx/datasets/web_search_qa.py +52 -0
- evalrx/eval_agent/__init__.py +341 -0
- evalrx/eval_agent/_tools.py +81 -0
- evalrx/eval_agent/ab_runner.py +50 -0
- evalrx/eval_agent/agentic/__init__.py +43 -0
- evalrx/eval_agent/agentic/actions.py +216 -0
- evalrx/eval_agent/agentic/board.py +107 -0
- evalrx/eval_agent/agentic/loop.py +190 -0
- evalrx/eval_agent/agentic/tools.py +538 -0
- evalrx/eval_agent/checkpoint.py +57 -0
- evalrx/eval_agent/cli_agent.py +59 -0
- evalrx/eval_agent/cli_skills.py +5 -0
- evalrx/eval_agent/evolution.py +396 -0
- evalrx/eval_agent/git_manager.py +215 -0
- evalrx/eval_agent/hypothesis.py +172 -0
- evalrx/eval_agent/label_quarantine.py +209 -0
- evalrx/eval_agent/legacy.py +530 -0
- evalrx/eval_agent/log_schema.py +497 -0
- evalrx/eval_agent/loop.py +2159 -0
- evalrx/eval_agent/loop_reports.py +116 -0
- evalrx/eval_agent/model_instrumentation.py +282 -0
- evalrx/eval_agent/narration.py +193 -0
- evalrx/eval_agent/nl_runner.py +460 -0
- evalrx/eval_agent/orchestrator.py +61 -0
- evalrx/eval_agent/preregister.py +93 -0
- evalrx/eval_agent/prompts/__init__.py +1 -0
- evalrx/eval_agent/prompts/agentic.py +46 -0
- evalrx/eval_agent/prompts/case_discovery.py +25 -0
- evalrx/eval_agent/prompts/diagnosis.py +125 -0
- evalrx/eval_agent/prompts/experiment_writer.py +265 -0
- evalrx/eval_agent/prompts/explore_step.py +37 -0
- evalrx/eval_agent/prompts/fix_agent.py +257 -0
- evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
- evalrx/eval_agent/prompts/nl_runner.py +38 -0
- evalrx/eval_agent/prompts/probe_agent.py +25 -0
- evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
- evalrx/eval_agent/prompts/probe_generator.py +35 -0
- evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
- evalrx/eval_agent/report.py +58 -0
- evalrx/eval_agent/run_context.py +354 -0
- evalrx/eval_agent/run_log.schema.json +1215 -0
- evalrx/eval_agent/run_logger_v2.py +1764 -0
- evalrx/eval_agent/run_metadata.py +208 -0
- evalrx/eval_agent/stages/__init__.py +56 -0
- evalrx/eval_agent/stages/case_discovery.py +293 -0
- evalrx/eval_agent/stages/diagnosis.py +1017 -0
- evalrx/eval_agent/stages/experiment_writer.py +1634 -0
- evalrx/eval_agent/stages/fix_agent.py +3916 -0
- evalrx/eval_agent/stages/fix_internals.py +499 -0
- evalrx/eval_agent/stages/fix_pipeline.py +725 -0
- evalrx/eval_agent/stages/fix_tiers.py +187 -0
- evalrx/eval_agent/stages/fix_tools.py +1034 -0
- evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
- evalrx/eval_agent/stages/probe.py +439 -0
- evalrx/eval_agent/stages/probe_agent.py +1079 -0
- evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
- evalrx/eval_agent/stages/probe_generator.py +326 -0
- evalrx/eval_agent/stages/probe_search_agent.py +106 -0
- evalrx/eval_agent/stages/protocol.py +112 -0
- evalrx/eval_agent/stages/repair_catalog.py +273 -0
- evalrx/eval_agent/stages/surgery.py +524 -0
- evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
- evalrx/eval_agent/store.py +231 -0
- evalrx/logging_utils.py +112 -0
- evalrx/models/__init__.py +161 -0
- evalrx/models/_discover.py +101 -0
- evalrx/models/agent.py +380 -0
- evalrx/models/backends/__init__.py +58 -0
- evalrx/models/backends/api.py +169 -0
- evalrx/models/backends/base.py +57 -0
- evalrx/models/backends/gemini_compat.py +579 -0
- evalrx/models/backends/hf_local.py +2074 -0
- evalrx/models/backends/openai_compat.py +301 -0
- evalrx/models/backends/vllm_offline.py +116 -0
- evalrx/models/base.py +24 -0
- evalrx/models/blackbox/__init__.py +4 -0
- evalrx/models/blackbox/agent.py +31 -0
- evalrx/models/blackbox/base.py +29 -0
- evalrx/models/blackbox/gemini.py +279 -0
- evalrx/models/blackbox/llm_api.py +17 -0
- evalrx/models/blackbox/vlm_api.py +17 -0
- evalrx/models/compose.py +66 -0
- evalrx/models/inference.py +88 -0
- evalrx/models/paper_methods/__init__.py +8 -0
- evalrx/models/paper_methods/aad.py +53 -0
- evalrx/models/paper_methods/ifcd.py +204 -0
- evalrx/models/paper_methods/pai.py +164 -0
- evalrx/models/paper_methods/tcd.py +202 -0
- evalrx/models/paper_methods/vcd.py +45 -0
- evalrx/models/paper_methods/vicrop.py +137 -0
- evalrx/models/toolcodec.py +143 -0
- evalrx/models/tools/__init__.py +20 -0
- evalrx/models/tools/perception.py +300 -0
- evalrx/models/tools/visual.py +174 -0
- evalrx/models/whitebox/__init__.py +26 -0
- evalrx/models/whitebox/agent.py +31 -0
- evalrx/models/whitebox/base.py +24 -0
- evalrx/models/whitebox/qwen.py +61 -0
- evalrx/models/whitebox/qwen2_5_omni.py +29 -0
- evalrx/models/whitebox/qwen2_audio.py +25 -0
- evalrx/models/whitebox/qwen_omni.py +53 -0
- evalrx/models/whitebox/qwen_vl.py +62 -0
- evalrx/observability/__init__.py +21 -0
- evalrx/observability/envelope.py +122 -0
- evalrx/observability/outbox.py +111 -0
- evalrx/observability/tracer.py +882 -0
- evalrx/reporting/__init__.py +28 -0
- evalrx/reporting/case_study.py +947 -0
- evalrx/reporting/compiler.py +587 -0
- evalrx/reporting/dynamic.py +1882 -0
- evalrx/reporting/html_report.py +2225 -0
- evalrx/reporting/langfuse_exporter.py +38 -0
- evalrx/reporting/langfuse_source.py +155 -0
- evalrx/reporting/model.py +151 -0
- evalrx/reporting/run_events.py +184 -0
- evalrx/reporting/server.py +557 -0
- evalrx/reporting/stages.py +58 -0
- evalrx/reporting/static_export.py +142 -0
- evalrx/reporting/web_dist/index.html +146 -0
- evalrx/specs.py +727 -0
- evalrx/stats/__init__.py +47 -0
- evalrx/stats/api.py +192 -0
- evalrx/stats/bootstrap.py +86 -0
- evalrx/stats/ebh.py +27 -0
- evalrx/stats/evalue.py +98 -0
- evalrx/stats/friedman.py +138 -0
- evalrx/stats/mcnemar.py +40 -0
- evalrx/stats/multiplicity.py +159 -0
- evalrx/stats/subset_sampling.py +55 -0
- evalrx/term_links.py +43 -0
- evalrx/viz/__init__.py +7 -0
- evalrx/viz/labels.py +77 -0
- evalrx/viz/prompts.py +39 -0
- evalrx/viz/renderer.py +590 -0
- evalrx/viz/schema.py +36 -0
- evalrx/viz/style.py +134 -0
- evalrx-0.1.2.dist-info/METADATA +532 -0
- evalrx-0.1.2.dist-info/RECORD +339 -0
- evalrx-0.1.2.dist-info/WHEEL +5 -0
- evalrx-0.1.2.dist-info/entry_points.txt +3 -0
- evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
- evalrx-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Sandbox factory — create the appropriate sandbox backend from config.
|
|
2
|
+
|
|
3
|
+
Mirrors ``researchclaw/experiment/factory.py``.
|
|
4
|
+
|
|
5
|
+
Supported modes:
|
|
6
|
+
``"subprocess"`` (default) — :class:`ExperimentSandbox` running in a local
|
|
7
|
+
subprocess. No extra dependencies required.
|
|
8
|
+
``"docker"`` — Docker-isolated sandbox. Falls back to the subprocess
|
|
9
|
+
backend when Docker is not available or the DockerSandbox import fails.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from evalrx.agent_runtime.sandbox import ExperimentSandbox, SandboxProtocol
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Configuration dataclasses
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class SandboxConfig:
|
|
30
|
+
"""Configuration for the subprocess sandbox backend.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
python_path: Python executable to use. Empty string → ``sys.executable``.
|
|
34
|
+
timeout_sec: Default timeout for sandbox runs (seconds).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
python_path: str = ""
|
|
38
|
+
timeout_sec: int = 60
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class SandboxFactoryConfig:
|
|
43
|
+
"""Top-level factory configuration.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
mode: Backend to use: ``"subprocess"`` (default) or ``"docker"``.
|
|
47
|
+
sandbox: Settings for the subprocess backend.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
mode: str = "subprocess"
|
|
51
|
+
sandbox: SandboxConfig = field(default_factory=SandboxConfig)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Factory
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_sandbox(config: SandboxFactoryConfig, workdir: Path) -> SandboxProtocol:
|
|
60
|
+
"""Instantiate the sandbox backend specified by *config.mode*.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
config: Factory configuration.
|
|
64
|
+
workdir: Directory where the sandbox writes scripts and project copies.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A :class:`~evalrx.agent_runtime.sandbox.SandboxProtocol` implementation.
|
|
68
|
+
Falls back to :class:`ExperimentSandbox` if the requested backend is
|
|
69
|
+
unavailable.
|
|
70
|
+
"""
|
|
71
|
+
if config.mode == "docker":
|
|
72
|
+
sandbox = _try_create_docker_sandbox(workdir)
|
|
73
|
+
if sandbox is not None:
|
|
74
|
+
return sandbox
|
|
75
|
+
logger.warning(
|
|
76
|
+
"Docker sandbox unavailable — falling back to subprocess sandbox"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
python_path = config.sandbox.python_path or None
|
|
80
|
+
return ExperimentSandbox(workdir=workdir, python_path=python_path)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _try_create_docker_sandbox(workdir: Path) -> SandboxProtocol | None:
|
|
84
|
+
"""Attempt to create a DockerSandbox; return None if not available."""
|
|
85
|
+
try:
|
|
86
|
+
import subprocess as _sp
|
|
87
|
+
result = _sp.run(
|
|
88
|
+
["docker", "info"],
|
|
89
|
+
capture_output=True,
|
|
90
|
+
timeout=5,
|
|
91
|
+
check=False,
|
|
92
|
+
)
|
|
93
|
+
if result.returncode != 0:
|
|
94
|
+
logger.debug("Docker not running (docker info returned %s)", result.returncode)
|
|
95
|
+
return None
|
|
96
|
+
except Exception as exc: # noqa: BLE001
|
|
97
|
+
logger.debug("Docker check failed: %s", exc)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
# DockerSandbox is not yet implemented in evalrx — stub falls through.
|
|
101
|
+
logger.debug("Docker is available but DockerSandbox is not yet implemented")
|
|
102
|
+
return None
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Minimal JSON schema validator shared by anything that parses LLM JSON output.
|
|
2
|
+
|
|
3
|
+
No external deps (not even ``jsonschema``) — only handles type/required/
|
|
4
|
+
minLength, which is enough to catch the common LLM mistakes (missing field,
|
|
5
|
+
empty string, wrong top-level type). Used by M3's hypothesis parser, the
|
|
6
|
+
agentic loop's action parser, and failure-mode cluster naming.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_json_shape(data: object, schema: dict) -> list[str]:
|
|
13
|
+
"""Returns a list of error strings; empty means valid."""
|
|
14
|
+
errors: list[str] = []
|
|
15
|
+
|
|
16
|
+
def _check(node: object, s: dict, path: str) -> None:
|
|
17
|
+
expected_type = s.get("type")
|
|
18
|
+
if expected_type == "array":
|
|
19
|
+
if not isinstance(node, list):
|
|
20
|
+
errors.append(f"{path}: expected array, got {type(node).__name__}")
|
|
21
|
+
return
|
|
22
|
+
item_schema = s.get("items", {})
|
|
23
|
+
for i, item in enumerate(node):
|
|
24
|
+
_check(item, item_schema, f"{path}[{i}]")
|
|
25
|
+
elif expected_type == "object":
|
|
26
|
+
if not isinstance(node, dict):
|
|
27
|
+
errors.append(f"{path}: expected object, got {type(node).__name__}")
|
|
28
|
+
return
|
|
29
|
+
for req in s.get("required", []):
|
|
30
|
+
if req not in node:
|
|
31
|
+
errors.append(f"{path}: missing required field '{req}'")
|
|
32
|
+
for prop, prop_schema in s.get("properties", {}).items():
|
|
33
|
+
if prop in node:
|
|
34
|
+
_check(node[prop], prop_schema, f"{path}.{prop}")
|
|
35
|
+
elif expected_type == "string":
|
|
36
|
+
if not isinstance(node, str):
|
|
37
|
+
errors.append(f"{path}: expected string, got {type(node).__name__}")
|
|
38
|
+
elif "minLength" in s and len(node) < s["minLength"]:
|
|
39
|
+
errors.append(
|
|
40
|
+
f"{path}: string too short ({len(node)} < {s['minLength']})"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_check(data, schema, "$")
|
|
44
|
+
return errors
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""CLI-backed judge model wrappers."""
|
|
2
|
+
|
|
3
|
+
from evalrx.agent_runtime.judges.agy import AgyModel, scan_agy_log
|
|
4
|
+
from evalrx.agent_runtime.judges.autodetect import (
|
|
5
|
+
DEFAULT_AGY_CANDIDATES,
|
|
6
|
+
DEFAULT_CLAUDE_CANDIDATES,
|
|
7
|
+
ResolvedJudge,
|
|
8
|
+
pick_agy_model,
|
|
9
|
+
pick_claude_model,
|
|
10
|
+
pick_live_model,
|
|
11
|
+
resolve_cli_judge,
|
|
12
|
+
)
|
|
13
|
+
from evalrx.agent_runtime.judges.claude import ClaudeModel
|
|
14
|
+
from evalrx.agent_runtime.judges.codex import CodexModel
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AgyModel",
|
|
18
|
+
"ClaudeModel",
|
|
19
|
+
"CodexModel",
|
|
20
|
+
"scan_agy_log",
|
|
21
|
+
"DEFAULT_AGY_CANDIDATES",
|
|
22
|
+
"DEFAULT_CLAUDE_CANDIDATES",
|
|
23
|
+
"ResolvedJudge",
|
|
24
|
+
"pick_agy_model",
|
|
25
|
+
"pick_claude_model",
|
|
26
|
+
"pick_live_model",
|
|
27
|
+
"resolve_cli_judge",
|
|
28
|
+
]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Antigravity CLI wrapped as a text-generation judge model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import warnings
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
AGY_ERROR_MARKERS = (
|
|
15
|
+
"RESOURCE_EXHAUSTED",
|
|
16
|
+
"code 429",
|
|
17
|
+
"quota",
|
|
18
|
+
"ineligible",
|
|
19
|
+
"PERMISSION_DENIED",
|
|
20
|
+
"UNAUTHENTICATED",
|
|
21
|
+
"exhausted",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def safe_unlink(path: str) -> None:
|
|
26
|
+
try:
|
|
27
|
+
os.unlink(path)
|
|
28
|
+
except OSError:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def scan_agy_log(path: str) -> str:
|
|
33
|
+
"""Return the last agy-log error line, or ``""`` if none."""
|
|
34
|
+
try:
|
|
35
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
36
|
+
lines = fh.readlines()[-300:]
|
|
37
|
+
except OSError:
|
|
38
|
+
return ""
|
|
39
|
+
for line in reversed(lines):
|
|
40
|
+
if any(marker.lower() in line.lower() for marker in AGY_ERROR_MARKERS):
|
|
41
|
+
msg = line.strip()
|
|
42
|
+
idx = msg.rfind("] ")
|
|
43
|
+
return (msg[idx + 2:] if idx != -1 else msg)[:240]
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AgyModel:
|
|
48
|
+
"""agy CLI wrapped as a judge model."""
|
|
49
|
+
|
|
50
|
+
key = "agy"
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
binary_path: str = "",
|
|
55
|
+
timeout_sec: int = 120,
|
|
56
|
+
model: str = "",
|
|
57
|
+
) -> None:
|
|
58
|
+
from evalrx.core.capability import Capability
|
|
59
|
+
|
|
60
|
+
binary = binary_path or shutil.which("agy") or ""
|
|
61
|
+
if not binary or not os.path.isfile(binary) or not os.access(binary, os.X_OK):
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"AgyModel: 'agy' binary not found or not executable. "
|
|
64
|
+
"Set AGY_PATH=$(which agy) and re-run, or pass binary_path= explicitly."
|
|
65
|
+
)
|
|
66
|
+
self._binary = binary
|
|
67
|
+
self._timeout_sec = timeout_sec
|
|
68
|
+
self._model = model
|
|
69
|
+
self.capabilities = frozenset({Capability.GENERATE})
|
|
70
|
+
self.modalities = frozenset({"text"})
|
|
71
|
+
|
|
72
|
+
# ``-p`` passes the prompt as a literal argv element. M2/M3 judge prompts
|
|
73
|
+
# in this framework routinely embed a full per-case evidence dump (every
|
|
74
|
+
# M1 analyzer's findings across every EXPLORE-split case) and can exceed
|
|
75
|
+
# the kernel's argv+envp size limit -- observed in practice as
|
|
76
|
+
# ``OSError: [Errno 7] Argument list too long`` on exec, which the caller
|
|
77
|
+
# (StatsAnalysisAgent/DiagnosisAgent) then silently treats as "judge
|
|
78
|
+
# returned nothing" and falls back to a threshold-only conclusion / zero
|
|
79
|
+
# hypotheses. Conservative threshold: well under any real ARG_MAX
|
|
80
|
+
# (2 MiB is typical on Linux, shared with the environment).
|
|
81
|
+
_LARGE_PROMPT_BYTES = 60_000
|
|
82
|
+
|
|
83
|
+
def generate(
|
|
84
|
+
self,
|
|
85
|
+
inputs: object,
|
|
86
|
+
*,
|
|
87
|
+
images: "list[Path] | None" = None,
|
|
88
|
+
**kwargs: object,
|
|
89
|
+
) -> str:
|
|
90
|
+
"""Run ``agy -p <inputs>`` and return the text response."""
|
|
91
|
+
fd, log_path = tempfile.mkstemp(prefix="agy_", suffix=".log")
|
|
92
|
+
os.close(fd)
|
|
93
|
+
workspace_dir: str | None = None
|
|
94
|
+
try:
|
|
95
|
+
prompt_text = str(inputs)
|
|
96
|
+
img_names: list[str] = []
|
|
97
|
+
if images:
|
|
98
|
+
valid = [p for p in images if isinstance(p, pathlib.Path) and p.exists()]
|
|
99
|
+
if valid:
|
|
100
|
+
workspace_dir = workspace_dir or tempfile.mkdtemp(prefix="agy_ws_")
|
|
101
|
+
for path in valid:
|
|
102
|
+
shutil.copy2(path, pathlib.Path(workspace_dir) / path.name)
|
|
103
|
+
img_names = [path.name for path in valid]
|
|
104
|
+
|
|
105
|
+
# Above the threshold, spill the prompt to a file in the same
|
|
106
|
+
# isolated per-call workspace instead of passing it inline --
|
|
107
|
+
# this exposes nothing the inline prompt didn't already contain,
|
|
108
|
+
# it just relocates it out of argv.
|
|
109
|
+
if len(prompt_text.encode("utf-8", errors="replace")) > self._LARGE_PROMPT_BYTES:
|
|
110
|
+
workspace_dir = workspace_dir or tempfile.mkdtemp(prefix="agy_ws_")
|
|
111
|
+
prompt_path = pathlib.Path(workspace_dir) / "prompt.txt"
|
|
112
|
+
prompt_path.write_text(prompt_text, encoding="utf-8")
|
|
113
|
+
cli_prompt = (
|
|
114
|
+
"Your full task instructions are in the file `prompt.txt` "
|
|
115
|
+
"in this workspace (too large to pass inline). Read it "
|
|
116
|
+
"completely and respond exactly as it instructs -- do not "
|
|
117
|
+
"summarize, truncate, or skip any part of it."
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
cli_prompt = prompt_text
|
|
121
|
+
if img_names:
|
|
122
|
+
names = ", ".join(img_names)
|
|
123
|
+
cli_prompt = f"Images available in workspace: {names}\n\n{cli_prompt}"
|
|
124
|
+
|
|
125
|
+
cmd = [
|
|
126
|
+
self._binary,
|
|
127
|
+
"-p",
|
|
128
|
+
cli_prompt,
|
|
129
|
+
# A judge receives the evidence explicitly in its prompt. It
|
|
130
|
+
# must not inspect repository files, benchmark manifests, or
|
|
131
|
+
# held-out labels through terminal tools.
|
|
132
|
+
"--sandbox",
|
|
133
|
+
"--dangerously-skip-permissions",
|
|
134
|
+
"--log-file",
|
|
135
|
+
log_path,
|
|
136
|
+
]
|
|
137
|
+
if workspace_dir:
|
|
138
|
+
cmd += ["--add-dir", workspace_dir]
|
|
139
|
+
if self._model:
|
|
140
|
+
cmd += ["--model", self._model]
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
proc = subprocess.run(
|
|
144
|
+
cmd,
|
|
145
|
+
capture_output=True,
|
|
146
|
+
timeout=self._timeout_sec,
|
|
147
|
+
text=True,
|
|
148
|
+
encoding="utf-8",
|
|
149
|
+
errors="replace",
|
|
150
|
+
env={**os.environ},
|
|
151
|
+
)
|
|
152
|
+
except subprocess.TimeoutExpired as exc:
|
|
153
|
+
raise RuntimeError(
|
|
154
|
+
f"AgyModel: agy timed out after {self._timeout_sec}s"
|
|
155
|
+
) from exc
|
|
156
|
+
|
|
157
|
+
output = proc.stdout.strip()
|
|
158
|
+
if proc.returncode != 0 and not output:
|
|
159
|
+
reason = scan_agy_log(log_path) or (proc.stderr or "").strip()[:240]
|
|
160
|
+
raise RuntimeError(f"AgyModel: agy exited {proc.returncode}: {reason}")
|
|
161
|
+
|
|
162
|
+
output = re.sub(r"<think>.*?</think>", "", output, flags=re.DOTALL).strip()
|
|
163
|
+
if not output:
|
|
164
|
+
reason = scan_agy_log(log_path)
|
|
165
|
+
warnings.warn(
|
|
166
|
+
"AgyModel: agy returned an empty response"
|
|
167
|
+
+ (f" -- {reason}" if reason else "")
|
|
168
|
+
+ ". agy is likely rate-limited or quota-exhausted; the caller "
|
|
169
|
+
"will fall back to a non-LLM path.",
|
|
170
|
+
stacklevel=2,
|
|
171
|
+
)
|
|
172
|
+
return output
|
|
173
|
+
finally:
|
|
174
|
+
safe_unlink(log_path)
|
|
175
|
+
if workspace_dir:
|
|
176
|
+
shutil.rmtree(workspace_dir, ignore_errors=True)
|
|
177
|
+
|
|
178
|
+
def __repr__(self) -> str:
|
|
179
|
+
return f"AgyModel(binary={self._binary!r})"
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Judge liveness-probe autodetection: find a CLI judge that actually responds.
|
|
2
|
+
|
|
3
|
+
A quota-exhausted CLI model often exits 0 with an empty response rather than
|
|
4
|
+
an error, so a tiny generation probe is the only reliable availability check.
|
|
5
|
+
Promoted from the ``_pick_agy_model``/``_pick_claude_model``/``_resolve_judge``
|
|
6
|
+
helpers duplicated across the ``VLDiagnoseLoop`` examples (``examples/m1_m3/*``,
|
|
7
|
+
``examples/m1_m5/*``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import warnings
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Callable, Sequence
|
|
15
|
+
|
|
16
|
+
from evalrx.agent_runtime.judges.agy import AgyModel
|
|
17
|
+
from evalrx.agent_runtime.judges.claude import ClaudeModel
|
|
18
|
+
|
|
19
|
+
_DEFAULT_PROBE_PROMPT = "Reply with exactly the word OK"
|
|
20
|
+
|
|
21
|
+
# Probe order: cheap/fast candidates first; quotas are per-model and reset on
|
|
22
|
+
# independent clocks, so whichever responds first is "the available version".
|
|
23
|
+
DEFAULT_AGY_CANDIDATES: tuple[str, ...] = (
|
|
24
|
+
"Gemini 3.1 Pro (Low)",
|
|
25
|
+
"Claude Sonnet 4.6 (Thinking)",
|
|
26
|
+
"GPT-OSS 120B (Medium)",
|
|
27
|
+
"Gemini 3.5 Flash (Low)",
|
|
28
|
+
"Claude Opus 4.6 (Thinking)",
|
|
29
|
+
)
|
|
30
|
+
# Claude judge candidates: the user's session model first (Fable), then
|
|
31
|
+
# cheaper aliases.
|
|
32
|
+
DEFAULT_CLAUDE_CANDIDATES: tuple[str, ...] = ("claude-fable-5", "sonnet", "haiku")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def pick_live_model(
|
|
36
|
+
factory: "Callable[[str], Any]",
|
|
37
|
+
candidates: "Sequence[str]",
|
|
38
|
+
*,
|
|
39
|
+
probe_prompt: str = _DEFAULT_PROBE_PROMPT,
|
|
40
|
+
) -> str:
|
|
41
|
+
"""Return the first candidate name whose model actually answers *probe_prompt*.
|
|
42
|
+
|
|
43
|
+
*factory* builds a model from a candidate name (e.g. ``lambda name:
|
|
44
|
+
AgyModel(model=name, timeout_sec=60)``). A quota-exhausted model typically
|
|
45
|
+
returns an empty string rather than raising, so an empty response is
|
|
46
|
+
treated the same as a failed candidate. Raises ``RuntimeError`` (with every
|
|
47
|
+
attempt's failure reason) when all candidates are dead.
|
|
48
|
+
"""
|
|
49
|
+
errors: list[str] = []
|
|
50
|
+
for name in candidates:
|
|
51
|
+
try:
|
|
52
|
+
probe = factory(name)
|
|
53
|
+
with warnings.catch_warnings():
|
|
54
|
+
warnings.simplefilter("ignore") # quota warnings are expected here
|
|
55
|
+
out = probe.generate(probe_prompt)
|
|
56
|
+
if str(out).strip():
|
|
57
|
+
return name
|
|
58
|
+
errors.append(f"{name!r}: empty response (likely quota-exhausted)")
|
|
59
|
+
except RuntimeError as exc:
|
|
60
|
+
errors.append(f"{name!r}: {exc}")
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
f"no model responded to the availability probe ({len(candidates)} tried): "
|
|
63
|
+
+ "; ".join(errors)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def pick_agy_model(
|
|
68
|
+
candidates: "Sequence[str]" = DEFAULT_AGY_CANDIDATES, *, timeout_sec: int = 60,
|
|
69
|
+
) -> str:
|
|
70
|
+
"""Return the first agy model name that answers a probe prompt."""
|
|
71
|
+
return pick_live_model(
|
|
72
|
+
lambda name: AgyModel(model=name, timeout_sec=timeout_sec), candidates,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def pick_claude_model(
|
|
77
|
+
candidates: "Sequence[str]" = DEFAULT_CLAUDE_CANDIDATES, *, timeout_sec: int = 90,
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Return the first claude model name that answers a probe prompt."""
|
|
80
|
+
return pick_live_model(
|
|
81
|
+
lambda name: ClaudeModel(model=name, timeout_sec=timeout_sec), candidates,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class ResolvedJudge:
|
|
87
|
+
"""A CLI judge resolved by :func:`resolve_cli_judge`, with its provenance."""
|
|
88
|
+
|
|
89
|
+
judge: Any
|
|
90
|
+
provider: str # "agy" | "claude"
|
|
91
|
+
model: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_cli_judge(
|
|
95
|
+
provider: str = "auto",
|
|
96
|
+
model: str = "auto",
|
|
97
|
+
*,
|
|
98
|
+
effort: str = "",
|
|
99
|
+
timeout_sec: int = 240,
|
|
100
|
+
) -> ResolvedJudge:
|
|
101
|
+
"""Resolve a live CLI judge.
|
|
102
|
+
|
|
103
|
+
``provider="auto"`` tries agy first (its quota errors fail fast), then
|
|
104
|
+
claude. ``model="auto"`` probes :data:`DEFAULT_AGY_CANDIDATES` /
|
|
105
|
+
:data:`DEFAULT_CLAUDE_CANDIDATES` for a live one; an explicit model name
|
|
106
|
+
is used directly (constructed, not probed). Raises ``RuntimeError``
|
|
107
|
+
(aggregating every attempt's failure) when nothing is available; raises
|
|
108
|
+
``ValueError`` for an unrecognized *provider*.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def _try_agy() -> ResolvedJudge:
|
|
112
|
+
name = pick_agy_model() if model == "auto" else model
|
|
113
|
+
return ResolvedJudge(AgyModel(model=name, timeout_sec=timeout_sec), "agy", name)
|
|
114
|
+
|
|
115
|
+
def _try_claude() -> ResolvedJudge:
|
|
116
|
+
name = pick_claude_model() if model == "auto" else model
|
|
117
|
+
return ResolvedJudge(
|
|
118
|
+
ClaudeModel(model=name, timeout_sec=timeout_sec, effort=effort), "claude", name,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
chain = {"agy": (_try_agy,), "claude": (_try_claude,),
|
|
123
|
+
"auto": (_try_agy, _try_claude)}[provider]
|
|
124
|
+
except KeyError:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"resolve_cli_judge: unknown provider {provider!r} (agy/claude/auto)"
|
|
127
|
+
) from None
|
|
128
|
+
|
|
129
|
+
errors: list[str] = []
|
|
130
|
+
for attempt in chain:
|
|
131
|
+
try:
|
|
132
|
+
return attempt()
|
|
133
|
+
except RuntimeError as exc:
|
|
134
|
+
errors.append(str(exc))
|
|
135
|
+
raise RuntimeError("no CLI judge available: " + "; ".join(errors))
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Claude Code CLI wrapped as a text-generation judge model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import warnings
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
#: The Claude Code CLI's limit notices (plan / session / spend / usage), which
|
|
15
|
+
#: arrive on stdout with exit 0 exactly like a real answer.
|
|
16
|
+
_LIMIT_RE = re.compile(
|
|
17
|
+
r"hit your (monthly |weekly |session )?(spend|usage|rate) limit|"
|
|
18
|
+
r"(spend|usage) limit (reached|exceeded)|out of (extra )?usage|"
|
|
19
|
+
r"limit resets", re.I)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClaudeModel:
|
|
23
|
+
"""Claude Code CLI wrapped as a judge model."""
|
|
24
|
+
|
|
25
|
+
key = "claude"
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
binary_path: str = "",
|
|
30
|
+
timeout_sec: int = 240,
|
|
31
|
+
model: str = "",
|
|
32
|
+
effort: str = "",
|
|
33
|
+
) -> None:
|
|
34
|
+
from evalrx.core.capability import Capability
|
|
35
|
+
|
|
36
|
+
binary = binary_path or shutil.which("claude") or ""
|
|
37
|
+
if not binary or not os.path.isfile(binary) or not os.access(binary, os.X_OK):
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
"ClaudeModel: 'claude' binary not found or not executable. "
|
|
40
|
+
"Set CLAUDE_PATH=$(which claude) and re-run, or pass "
|
|
41
|
+
"binary_path= explicitly."
|
|
42
|
+
)
|
|
43
|
+
self._binary = binary
|
|
44
|
+
self._timeout_sec = timeout_sec
|
|
45
|
+
self._model = model
|
|
46
|
+
self._effort = effort
|
|
47
|
+
self.capabilities = frozenset({Capability.GENERATE})
|
|
48
|
+
self.modalities = frozenset({"text"})
|
|
49
|
+
|
|
50
|
+
def generate(
|
|
51
|
+
self,
|
|
52
|
+
inputs: object,
|
|
53
|
+
*,
|
|
54
|
+
images: "list[Path] | None" = None,
|
|
55
|
+
**kwargs: object,
|
|
56
|
+
) -> str:
|
|
57
|
+
"""Run ``claude -p`` with the prompt on STDIN and return the response.
|
|
58
|
+
|
|
59
|
+
The prompt must not be an argv entry. Linux caps a SINGLE argument at
|
|
60
|
+
``MAX_ARG_STRLEN`` = 32 pages = 131,072 bytes — a limit ``ulimit -s`` and
|
|
61
|
+
``ARG_MAX`` do not describe and no configuration raises — and execve
|
|
62
|
+
fails outright with ``OSError: [Errno 7] Argument list too long``.
|
|
63
|
+
|
|
64
|
+
M2's prompt is the analyzers' findings JSON, so its size grows with how
|
|
65
|
+
many analyzers M1 selected. Measured on qwen3.5-2b / bbh_word_sorting:
|
|
66
|
+
9 analyzers produced 127,856 bytes and worked, 12 analyzers produced
|
|
67
|
+
~137,600 and did not. The size that fails is therefore a property of one
|
|
68
|
+
judge's analyzer selection, which is exactly the thing that varies run to
|
|
69
|
+
run — the same batch, the same code, and one run crosses the cliff.
|
|
70
|
+
|
|
71
|
+
Stdin has no equivalent limit; 144,050 bytes round-trips fine.
|
|
72
|
+
"""
|
|
73
|
+
img_dir: str | None = None
|
|
74
|
+
try:
|
|
75
|
+
prompt_text = str(inputs)
|
|
76
|
+
if images:
|
|
77
|
+
valid = [p for p in images if isinstance(p, pathlib.Path) and p.exists()]
|
|
78
|
+
if valid:
|
|
79
|
+
img_dir = tempfile.mkdtemp(prefix="claude_imgs_")
|
|
80
|
+
for path in valid:
|
|
81
|
+
shutil.copy2(path, pathlib.Path(img_dir) / path.name)
|
|
82
|
+
names = ", ".join(path.name for path in valid)
|
|
83
|
+
prompt_text = f"Images available in workspace: {names}\n\n{prompt_text}"
|
|
84
|
+
|
|
85
|
+
# A judge generates text; it does not act. Asking for the blanket
|
|
86
|
+
# permission bypass was more than that needs, and it made the judge
|
|
87
|
+
# unrunnable anywhere nothing wraps it: the CLI refuses
|
|
88
|
+
# `--dangerously-skip-permissions` under root unless IS_SANDBOX=1,
|
|
89
|
+
# which our compose files set (see _common/compose/base.yml) and a
|
|
90
|
+
# bare root box — Colab, a plain container, CI — does not. There
|
|
91
|
+
# every judge call exited 1 before reaching the model.
|
|
92
|
+
#
|
|
93
|
+
# `--tools ""` is the accurate request for a text completion: no
|
|
94
|
+
# tools, therefore nothing to permission. An image-bearing call is
|
|
95
|
+
# the one exception — the figures are staged in a temp dir and the
|
|
96
|
+
# model has to Read them — so that call allows exactly Read, scoped
|
|
97
|
+
# to the directory just added, and nothing else.
|
|
98
|
+
cmd = [
|
|
99
|
+
self._binary,
|
|
100
|
+
"-p",
|
|
101
|
+
"--output-format",
|
|
102
|
+
"text",
|
|
103
|
+
]
|
|
104
|
+
if img_dir:
|
|
105
|
+
cmd += ["--add-dir", img_dir, "--allowed-tools", "Read"]
|
|
106
|
+
else:
|
|
107
|
+
cmd += ["--tools", ""]
|
|
108
|
+
if self._model:
|
|
109
|
+
cmd += ["--model", self._model]
|
|
110
|
+
if self._effort:
|
|
111
|
+
cmd += ["--effort", self._effort]
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
proc = subprocess.run(
|
|
115
|
+
cmd,
|
|
116
|
+
input=prompt_text,
|
|
117
|
+
capture_output=True,
|
|
118
|
+
timeout=self._timeout_sec,
|
|
119
|
+
text=True,
|
|
120
|
+
encoding="utf-8",
|
|
121
|
+
errors="replace",
|
|
122
|
+
env={**os.environ},
|
|
123
|
+
)
|
|
124
|
+
except subprocess.TimeoutExpired as exc:
|
|
125
|
+
raise RuntimeError(
|
|
126
|
+
f"ClaudeModel: claude timed out after {self._timeout_sec}s"
|
|
127
|
+
) from exc
|
|
128
|
+
|
|
129
|
+
output = (proc.stdout or "").strip()
|
|
130
|
+
# The CLI reports an exhausted plan/session/spend limit as a one-line
|
|
131
|
+
# *answer* on stdout with exit 0 ("You've hit your monthly spend
|
|
132
|
+
# limit ..."). Returned as a judge reply it parses to zero
|
|
133
|
+
# hypotheses and the run "finishes" with an empty diagnosis
|
|
134
|
+
# (qwen2-audio/mmau 2026-09-07, all three demo128 cells
|
|
135
|
+
# 2026-09-09). Fail loudly instead.
|
|
136
|
+
if len(output) < 400 and _LIMIT_RE.search(output):
|
|
137
|
+
raise RuntimeError(f"ClaudeModel: account limit reached, not a judge answer: {output!r}")
|
|
138
|
+
if proc.returncode != 0 and not output:
|
|
139
|
+
reason = (proc.stderr or "").strip()[:240]
|
|
140
|
+
raise RuntimeError(
|
|
141
|
+
f"ClaudeModel: claude exited {proc.returncode}: {reason}"
|
|
142
|
+
)
|
|
143
|
+
if not output:
|
|
144
|
+
warnings.warn(
|
|
145
|
+
"ClaudeModel: claude returned an empty response -- likely "
|
|
146
|
+
"rate-limited or out of quota; the caller will fall back "
|
|
147
|
+
"to a non-LLM path.",
|
|
148
|
+
stacklevel=2,
|
|
149
|
+
)
|
|
150
|
+
return output
|
|
151
|
+
finally:
|
|
152
|
+
if img_dir:
|
|
153
|
+
shutil.rmtree(img_dir, ignore_errors=True)
|
|
154
|
+
|
|
155
|
+
def __repr__(self) -> str:
|
|
156
|
+
return (
|
|
157
|
+
f"ClaudeModel(binary={self._binary!r}, model={self._model!r}, "
|
|
158
|
+
f"effort={self._effort!r})"
|
|
159
|
+
)
|