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.
Files changed (339) hide show
  1. evalrx/__init__.py +139 -0
  2. evalrx/agent_assets/__init__.py +2 -0
  3. evalrx/agent_assets/skills/README.md +28 -0
  4. evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
  5. evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
  6. evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
  7. evalrx/agent_assets/skills/nature-figure/README.md +412 -0
  8. evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
  9. evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
  10. evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
  11. evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
  12. evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
  13. evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
  14. evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
  15. evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
  16. evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
  17. evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
  18. evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
  19. evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
  20. evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
  21. evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
  22. evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
  23. evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
  24. evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
  25. evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
  26. evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
  27. evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
  28. evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
  29. evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
  30. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
  31. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
  32. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
  33. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
  34. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
  35. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
  36. evalrx/agent_assets/skills.py +27 -0
  37. evalrx/agent_runtime/__init__.py +78 -0
  38. evalrx/agent_runtime/_docker_runner.py +89 -0
  39. evalrx/agent_runtime/cli_runtime.py +103 -0
  40. evalrx/agent_runtime/cli_transcript.py +138 -0
  41. evalrx/agent_runtime/cli_types.py +68 -0
  42. evalrx/agent_runtime/codegen/__init__.py +5 -0
  43. evalrx/agent_runtime/codegen/runner.py +94 -0
  44. evalrx/agent_runtime/experiment_harness.py +117 -0
  45. evalrx/agent_runtime/factory.py +102 -0
  46. evalrx/agent_runtime/json_shape.py +44 -0
  47. evalrx/agent_runtime/judges/__init__.py +28 -0
  48. evalrx/agent_runtime/judges/agy.py +179 -0
  49. evalrx/agent_runtime/judges/autodetect.py +135 -0
  50. evalrx/agent_runtime/judges/claude.py +159 -0
  51. evalrx/agent_runtime/judges/codex.py +120 -0
  52. evalrx/agent_runtime/providers/__init__.py +21 -0
  53. evalrx/agent_runtime/providers/antigravity.py +31 -0
  54. evalrx/agent_runtime/providers/base.py +145 -0
  55. evalrx/agent_runtime/providers/claude_code.py +49 -0
  56. evalrx/agent_runtime/providers/codex.py +37 -0
  57. evalrx/agent_runtime/providers/gemini_cli.py +26 -0
  58. evalrx/agent_runtime/providers/kimi_cli.py +27 -0
  59. evalrx/agent_runtime/providers/opencode.py +27 -0
  60. evalrx/agent_runtime/providers/registry.py +58 -0
  61. evalrx/agent_runtime/sandbox.py +517 -0
  62. evalrx/agent_runtime/skill_audit.py +143 -0
  63. evalrx/agent_runtime/skills/__init__.py +19 -0
  64. evalrx/agent_runtime/skills/installer.py +68 -0
  65. evalrx/agent_runtime/skills/prompt_policy.py +86 -0
  66. evalrx/agent_runtime/skills/resolver.py +19 -0
  67. evalrx/analysis/__init__.py +132 -0
  68. evalrx/analysis/adjudicate.py +154 -0
  69. evalrx/analysis/analysis_module.py +361 -0
  70. evalrx/analysis/api.py +171 -0
  71. evalrx/analysis/case_studio.py +651 -0
  72. evalrx/analysis/cli.py +114 -0
  73. evalrx/analysis/dashboard.py +350 -0
  74. evalrx/analysis/eval_case_matrix.py +118 -0
  75. evalrx/analysis/eval_viz_theme.py +833 -0
  76. evalrx/analysis/explore_run.py +333 -0
  77. evalrx/analysis/explorer.py +1276 -0
  78. evalrx/analysis/failure_modes.py +607 -0
  79. evalrx/analysis/fused_pipeline.py +489 -0
  80. evalrx/analysis/holdout.py +300 -0
  81. evalrx/analysis/hypothesis_agent.py +230 -0
  82. evalrx/analysis/narration.py +177 -0
  83. evalrx/analysis/operationalize.py +442 -0
  84. evalrx/analysis/plain_language.py +42 -0
  85. evalrx/analysis/planner.py +283 -0
  86. evalrx/analysis/probe_search.py +203 -0
  87. evalrx/analysis/profile.py +268 -0
  88. evalrx/analysis/prompts/__init__.py +0 -0
  89. evalrx/analysis/prompts/explorer.py +417 -0
  90. evalrx/analysis/prompts/failure_modes.py +33 -0
  91. evalrx/analysis/prompts/holdout.py +27 -0
  92. evalrx/analysis/prompts/hypothesis_agent.py +78 -0
  93. evalrx/analysis/prompts/run_codebase.py +47 -0
  94. evalrx/analysis/prompts/stats_agent.py +72 -0
  95. evalrx/analysis/prompts/stats_tool_generator.py +43 -0
  96. evalrx/analysis/result_marker.py +47 -0
  97. evalrx/analysis/run_codebase.py +242 -0
  98. evalrx/analysis/run_view.py +205 -0
  99. evalrx/analysis/stage_views.py +93 -0
  100. evalrx/analysis/stats_agent.py +944 -0
  101. evalrx/analysis/stats_tool_agent.py +261 -0
  102. evalrx/analysis/stats_tool_generator.py +415 -0
  103. evalrx/analysis/stats_tools.py +1153 -0
  104. evalrx/analysis/trajectory_records.py +193 -0
  105. evalrx/analysis/workbench.py +431 -0
  106. evalrx/analyzers/__init__.py +42 -0
  107. evalrx/analyzers/agent/__init__.py +25 -0
  108. evalrx/analyzers/agent/counterfactual.py +84 -0
  109. evalrx/analyzers/agent/first_error_judge.py +96 -0
  110. evalrx/analyzers/agent/ignored_obs.py +81 -0
  111. evalrx/analyzers/agent/loop_detect.py +79 -0
  112. evalrx/analyzers/agent/reliability.py +165 -0
  113. evalrx/analyzers/agent/tool_shap.py +225 -0
  114. evalrx/analyzers/agent/trajectory_rubric.py +168 -0
  115. evalrx/analyzers/attention/__init__.py +19 -0
  116. evalrx/analyzers/attention/relative_attn.py +610 -0
  117. evalrx/analyzers/attention/rollout.py +73 -0
  118. evalrx/analyzers/attention/sink.py +56 -0
  119. evalrx/analyzers/attention/summary.py +190 -0
  120. evalrx/analyzers/attribution/__init__.py +6 -0
  121. evalrx/analyzers/attribution/generic_attn.py +31 -0
  122. evalrx/analyzers/attribution/gradcam.py +30 -0
  123. evalrx/analyzers/base.py +12 -0
  124. evalrx/analyzers/geometry/__init__.py +6 -0
  125. evalrx/analyzers/geometry/cka.py +70 -0
  126. evalrx/analyzers/geometry/linear_probe.py +157 -0
  127. evalrx/analyzers/hallucination/__init__.py +9 -0
  128. evalrx/analyzers/hallucination/chair.py +78 -0
  129. evalrx/analyzers/hallucination/opera.py +29 -0
  130. evalrx/analyzers/hallucination/pope.py +119 -0
  131. evalrx/analyzers/hallucination/selfcheck.py +155 -0
  132. evalrx/analyzers/hallucination/vcd.py +29 -0
  133. evalrx/analyzers/lens/__init__.py +7 -0
  134. evalrx/analyzers/lens/layer_contrast.py +133 -0
  135. evalrx/analyzers/lens/logit_lens.py +138 -0
  136. evalrx/analyzers/lens/tuned_lens.py +30 -0
  137. evalrx/analyzers/patching/__init__.py +5 -0
  138. evalrx/analyzers/patching/causal_trace.py +30 -0
  139. evalrx/analyzers/perturbation/__init__.py +23 -0
  140. evalrx/analyzers/perturbation/_shapley.py +54 -0
  141. evalrx/analyzers/perturbation/context_shap.py +174 -0
  142. evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
  143. evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
  144. evalrx/analyzers/perturbation/mm_shap.py +146 -0
  145. evalrx/analyzers/perturbation/modality_ablation.py +196 -0
  146. evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
  147. evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
  148. evalrx/analyzers/perturbation/rise.py +94 -0
  149. evalrx/analyzers/perturbation/vl_shap.py +102 -0
  150. evalrx/analyzers/reasoning/__init__.py +33 -0
  151. evalrx/analyzers/reasoning/_text.py +328 -0
  152. evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
  153. evalrx/analyzers/reasoning/arith_audit.py +226 -0
  154. evalrx/analyzers/reasoning/contamination.py +214 -0
  155. evalrx/analyzers/reasoning/knowledge_split.py +253 -0
  156. evalrx/analyzers/reasoning/self_repair.py +246 -0
  157. evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
  158. evalrx/analyzers/reasoning/termination_audit.py +258 -0
  159. evalrx/analyzers/uncertainty/__init__.py +18 -0
  160. evalrx/analyzers/uncertainty/calibration.py +174 -0
  161. evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
  162. evalrx/analyzers/uncertainty/entropy.py +90 -0
  163. evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
  164. evalrx/analyzers/uncertainty/self_consistency.py +204 -0
  165. evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
  166. evalrx/cli.py +411 -0
  167. evalrx/config.py +77 -0
  168. evalrx/contract/__init__.py +179 -0
  169. evalrx/contract/common.py +452 -0
  170. evalrx/contract/emit.py +948 -0
  171. evalrx/contract/export.py +237 -0
  172. evalrx/contract/m1.py +325 -0
  173. evalrx/contract/m2.py +317 -0
  174. evalrx/contract/m3.py +165 -0
  175. evalrx/contract/m4.py +130 -0
  176. evalrx/contract/m5.py +292 -0
  177. evalrx/contract/methodology.py +76 -0
  178. evalrx/contract/pre_m1.py +58 -0
  179. evalrx/contract/typescript.py +140 -0
  180. evalrx/core/__init__.py +85 -0
  181. evalrx/core/analyzer.py +174 -0
  182. evalrx/core/capability.py +54 -0
  183. evalrx/core/case.py +443 -0
  184. evalrx/core/experiment.py +106 -0
  185. evalrx/core/model.py +198 -0
  186. evalrx/core/pipeline.py +42 -0
  187. evalrx/core/registry.py +142 -0
  188. evalrx/core/result.py +64 -0
  189. evalrx/core/spec.py +173 -0
  190. evalrx/core/tokentype.py +165 -0
  191. evalrx/core/tool.py +92 -0
  192. evalrx/datasets/__init__.py +41 -0
  193. evalrx/datasets/base.py +68 -0
  194. evalrx/datasets/gui_os.py +52 -0
  195. evalrx/datasets/llm_qa.py +57 -0
  196. evalrx/datasets/pure_qa.py +12 -0
  197. evalrx/datasets/vlm_qa.py +695 -0
  198. evalrx/datasets/web_search_qa.py +52 -0
  199. evalrx/eval_agent/__init__.py +341 -0
  200. evalrx/eval_agent/_tools.py +81 -0
  201. evalrx/eval_agent/ab_runner.py +50 -0
  202. evalrx/eval_agent/agentic/__init__.py +43 -0
  203. evalrx/eval_agent/agentic/actions.py +216 -0
  204. evalrx/eval_agent/agentic/board.py +107 -0
  205. evalrx/eval_agent/agentic/loop.py +190 -0
  206. evalrx/eval_agent/agentic/tools.py +538 -0
  207. evalrx/eval_agent/checkpoint.py +57 -0
  208. evalrx/eval_agent/cli_agent.py +59 -0
  209. evalrx/eval_agent/cli_skills.py +5 -0
  210. evalrx/eval_agent/evolution.py +396 -0
  211. evalrx/eval_agent/git_manager.py +215 -0
  212. evalrx/eval_agent/hypothesis.py +172 -0
  213. evalrx/eval_agent/label_quarantine.py +209 -0
  214. evalrx/eval_agent/legacy.py +530 -0
  215. evalrx/eval_agent/log_schema.py +497 -0
  216. evalrx/eval_agent/loop.py +2159 -0
  217. evalrx/eval_agent/loop_reports.py +116 -0
  218. evalrx/eval_agent/model_instrumentation.py +282 -0
  219. evalrx/eval_agent/narration.py +193 -0
  220. evalrx/eval_agent/nl_runner.py +460 -0
  221. evalrx/eval_agent/orchestrator.py +61 -0
  222. evalrx/eval_agent/preregister.py +93 -0
  223. evalrx/eval_agent/prompts/__init__.py +1 -0
  224. evalrx/eval_agent/prompts/agentic.py +46 -0
  225. evalrx/eval_agent/prompts/case_discovery.py +25 -0
  226. evalrx/eval_agent/prompts/diagnosis.py +125 -0
  227. evalrx/eval_agent/prompts/experiment_writer.py +265 -0
  228. evalrx/eval_agent/prompts/explore_step.py +37 -0
  229. evalrx/eval_agent/prompts/fix_agent.py +257 -0
  230. evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
  231. evalrx/eval_agent/prompts/nl_runner.py +38 -0
  232. evalrx/eval_agent/prompts/probe_agent.py +25 -0
  233. evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
  234. evalrx/eval_agent/prompts/probe_generator.py +35 -0
  235. evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
  236. evalrx/eval_agent/report.py +58 -0
  237. evalrx/eval_agent/run_context.py +354 -0
  238. evalrx/eval_agent/run_log.schema.json +1215 -0
  239. evalrx/eval_agent/run_logger_v2.py +1764 -0
  240. evalrx/eval_agent/run_metadata.py +208 -0
  241. evalrx/eval_agent/stages/__init__.py +56 -0
  242. evalrx/eval_agent/stages/case_discovery.py +293 -0
  243. evalrx/eval_agent/stages/diagnosis.py +1017 -0
  244. evalrx/eval_agent/stages/experiment_writer.py +1634 -0
  245. evalrx/eval_agent/stages/fix_agent.py +3916 -0
  246. evalrx/eval_agent/stages/fix_internals.py +499 -0
  247. evalrx/eval_agent/stages/fix_pipeline.py +725 -0
  248. evalrx/eval_agent/stages/fix_tiers.py +187 -0
  249. evalrx/eval_agent/stages/fix_tools.py +1034 -0
  250. evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
  251. evalrx/eval_agent/stages/probe.py +439 -0
  252. evalrx/eval_agent/stages/probe_agent.py +1079 -0
  253. evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
  254. evalrx/eval_agent/stages/probe_generator.py +326 -0
  255. evalrx/eval_agent/stages/probe_search_agent.py +106 -0
  256. evalrx/eval_agent/stages/protocol.py +112 -0
  257. evalrx/eval_agent/stages/repair_catalog.py +273 -0
  258. evalrx/eval_agent/stages/surgery.py +524 -0
  259. evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
  260. evalrx/eval_agent/store.py +231 -0
  261. evalrx/logging_utils.py +112 -0
  262. evalrx/models/__init__.py +161 -0
  263. evalrx/models/_discover.py +101 -0
  264. evalrx/models/agent.py +380 -0
  265. evalrx/models/backends/__init__.py +58 -0
  266. evalrx/models/backends/api.py +169 -0
  267. evalrx/models/backends/base.py +57 -0
  268. evalrx/models/backends/gemini_compat.py +579 -0
  269. evalrx/models/backends/hf_local.py +2074 -0
  270. evalrx/models/backends/openai_compat.py +301 -0
  271. evalrx/models/backends/vllm_offline.py +116 -0
  272. evalrx/models/base.py +24 -0
  273. evalrx/models/blackbox/__init__.py +4 -0
  274. evalrx/models/blackbox/agent.py +31 -0
  275. evalrx/models/blackbox/base.py +29 -0
  276. evalrx/models/blackbox/gemini.py +279 -0
  277. evalrx/models/blackbox/llm_api.py +17 -0
  278. evalrx/models/blackbox/vlm_api.py +17 -0
  279. evalrx/models/compose.py +66 -0
  280. evalrx/models/inference.py +88 -0
  281. evalrx/models/paper_methods/__init__.py +8 -0
  282. evalrx/models/paper_methods/aad.py +53 -0
  283. evalrx/models/paper_methods/ifcd.py +204 -0
  284. evalrx/models/paper_methods/pai.py +164 -0
  285. evalrx/models/paper_methods/tcd.py +202 -0
  286. evalrx/models/paper_methods/vcd.py +45 -0
  287. evalrx/models/paper_methods/vicrop.py +137 -0
  288. evalrx/models/toolcodec.py +143 -0
  289. evalrx/models/tools/__init__.py +20 -0
  290. evalrx/models/tools/perception.py +300 -0
  291. evalrx/models/tools/visual.py +174 -0
  292. evalrx/models/whitebox/__init__.py +26 -0
  293. evalrx/models/whitebox/agent.py +31 -0
  294. evalrx/models/whitebox/base.py +24 -0
  295. evalrx/models/whitebox/qwen.py +61 -0
  296. evalrx/models/whitebox/qwen2_5_omni.py +29 -0
  297. evalrx/models/whitebox/qwen2_audio.py +25 -0
  298. evalrx/models/whitebox/qwen_omni.py +53 -0
  299. evalrx/models/whitebox/qwen_vl.py +62 -0
  300. evalrx/observability/__init__.py +21 -0
  301. evalrx/observability/envelope.py +122 -0
  302. evalrx/observability/outbox.py +111 -0
  303. evalrx/observability/tracer.py +882 -0
  304. evalrx/reporting/__init__.py +28 -0
  305. evalrx/reporting/case_study.py +947 -0
  306. evalrx/reporting/compiler.py +587 -0
  307. evalrx/reporting/dynamic.py +1882 -0
  308. evalrx/reporting/html_report.py +2225 -0
  309. evalrx/reporting/langfuse_exporter.py +38 -0
  310. evalrx/reporting/langfuse_source.py +155 -0
  311. evalrx/reporting/model.py +151 -0
  312. evalrx/reporting/run_events.py +184 -0
  313. evalrx/reporting/server.py +557 -0
  314. evalrx/reporting/stages.py +58 -0
  315. evalrx/reporting/static_export.py +142 -0
  316. evalrx/reporting/web_dist/index.html +146 -0
  317. evalrx/specs.py +727 -0
  318. evalrx/stats/__init__.py +47 -0
  319. evalrx/stats/api.py +192 -0
  320. evalrx/stats/bootstrap.py +86 -0
  321. evalrx/stats/ebh.py +27 -0
  322. evalrx/stats/evalue.py +98 -0
  323. evalrx/stats/friedman.py +138 -0
  324. evalrx/stats/mcnemar.py +40 -0
  325. evalrx/stats/multiplicity.py +159 -0
  326. evalrx/stats/subset_sampling.py +55 -0
  327. evalrx/term_links.py +43 -0
  328. evalrx/viz/__init__.py +7 -0
  329. evalrx/viz/labels.py +77 -0
  330. evalrx/viz/prompts.py +39 -0
  331. evalrx/viz/renderer.py +590 -0
  332. evalrx/viz/schema.py +36 -0
  333. evalrx/viz/style.py +134 -0
  334. evalrx-0.1.2.dist-info/METADATA +532 -0
  335. evalrx-0.1.2.dist-info/RECORD +339 -0
  336. evalrx-0.1.2.dist-info/WHEEL +5 -0
  337. evalrx-0.1.2.dist-info/entry_points.txt +3 -0
  338. evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
  339. evalrx-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,161 @@
1
+ """Model construction — one engine: ``compose(spec, backend)``.
2
+
3
+ ``evalrx.specs`` is the single source of truth for *which* models exist;
4
+ ``models/backends`` provides the runtimes. Everything funnels through
5
+ :func:`load` (friendly, key-based) and :func:`load_model` (``ModelConfig``-based),
6
+ both of which build via ``compose`` — so there is exactly one way a model is
7
+ constructed, and the capability set always reflects the chosen backend.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import replace
13
+ from typing import Any
14
+
15
+ from evalrx.config import ModelConfig
16
+ from evalrx.core.capability import Capability
17
+ from evalrx.core.model import Model
18
+
19
+ # Backend layer + agent loop (torch-free at import; heavy deps are lazy in build/load).
20
+ from evalrx.core.tool import ChatTurn, Tool, ToolCall
21
+ from evalrx.models.agent import Agent, APIToolHandlerExecutor, ToolExecutor
22
+ from evalrx.models.backends import BACKENDS, RuntimeConfig, call_vision_api_chat_fn
23
+ from evalrx.models.base import BaseAgent
24
+ from evalrx.models.compose import compose
25
+ from evalrx.models.toolcodec import OpenAIToolCodec, QwenToolCodec, codec_for
26
+
27
+ # Legacy concrete white-box model (now a thin deprecated alias) — optional on the
28
+ # light, pure-API install (its module imports nothing heavy at top level).
29
+ try:
30
+ from evalrx.models import whitebox as _whitebox # noqa: F401
31
+ except ImportError: # pragma: no cover - light install
32
+ _whitebox = None
33
+
34
+ __all__ = [
35
+ "Model",
36
+ "BaseAgent",
37
+ "load",
38
+ "load_model",
39
+ "wrap",
40
+ "compose",
41
+ "RuntimeConfig",
42
+ "BACKENDS",
43
+ "resolve_spec_key",
44
+ "Agent",
45
+ "ToolExecutor",
46
+ "APIToolHandlerExecutor",
47
+ "Tool",
48
+ "ToolCall",
49
+ "ChatTurn",
50
+ "OpenAIToolCodec",
51
+ "QwenToolCodec",
52
+ "codec_for",
53
+ "call_vision_api_chat_fn",
54
+ ]
55
+
56
+ # Legacy name → canonical spec key. Keeps old configs/code working.
57
+ _SPEC_ALIASES: dict[str, str] = {
58
+ "qwen": "qwen2.5-7b-instruct",
59
+ "qwen2.5-7b": "qwen2.5-7b-instruct",
60
+ }
61
+
62
+
63
+ def resolve_spec_key(name: str) -> str:
64
+ """Resolve a spec key or legacy alias to a canonical :mod:`evalrx.specs` key."""
65
+ from evalrx.specs import REGISTRY
66
+
67
+ key = name.lower()
68
+ if key in REGISTRY:
69
+ return key
70
+ if key in _SPEC_ALIASES:
71
+ return _SPEC_ALIASES[key]
72
+ raise ValueError(
73
+ f"Unknown model {name!r}. Known specs: {sorted(REGISTRY)}; "
74
+ f"aliases: {sorted(_SPEC_ALIASES)}."
75
+ )
76
+
77
+
78
+ def load(
79
+ key: str,
80
+ *,
81
+ backend: str = "hf_local",
82
+ want: "list[str] | set[Capability] | tuple" = (),
83
+ checkpoint: str | None = None,
84
+ **runtime: Any,
85
+ ) -> Model:
86
+ """Build a model by spec key — the friendly one-liner.
87
+
88
+ ``evalrx.load("qwen2.5-7b-instruct")`` is the modern replacement for the
89
+ old ``QwenLLM()``. It wraps ``compose(get_spec(key), backend, RuntimeConfig(**runtime))``
90
+ and negotiates capabilities up front (``want``).
91
+
92
+ Args:
93
+ key: Spec key or legacy alias (see :func:`resolve_spec_key`).
94
+ backend: ``"hf_local"`` (internals) / ``"api"`` / ``"vllm_offline"``.
95
+ Forced to ``"api"`` for ``api_only`` (closed-weight) specs.
96
+ want: Capabilities the backend must provide (names or ``Capability``).
97
+ checkpoint: Optional override of the spec's ``hf_repo``.
98
+ **runtime: Forwarded to :class:`RuntimeConfig` (``device``, ``dtype``, …).
99
+ """
100
+ from evalrx.specs import get_spec
101
+
102
+ spec = get_spec(resolve_spec_key(key))
103
+ if checkpoint:
104
+ spec = replace(spec, hf_repo=checkpoint)
105
+ chosen = "api" if spec.api_only else backend
106
+ caps = {w if isinstance(w, Capability) else Capability(w) for w in want}
107
+ return compose(spec, chosen, RuntimeConfig(**runtime), caps)
108
+
109
+
110
+ def load_model(cfg: ModelConfig) -> Model:
111
+ """Instantiate a model from a :class:`~evalrx.config.ModelConfig` via :func:`load`."""
112
+ return load(
113
+ cfg.name,
114
+ backend=cfg.backend,
115
+ want=cfg.want,
116
+ checkpoint=cfg.checkpoint,
117
+ device=cfg.device,
118
+ dtype=cfg.dtype,
119
+ )
120
+
121
+
122
+ def wrap(
123
+ model: Any,
124
+ tokenizer: Any = None,
125
+ *,
126
+ want: "list[str] | set[Capability] | tuple" = (),
127
+ **runtime: Any,
128
+ ) -> Model:
129
+ """Wrap an ALREADY-LOADED HF causal LM + tokenizer into an analyzable model.
130
+
131
+ The captum-style public on-ramp: the user brings their own model, no registry
132
+ key needed. The result is the same :class:`~evalrx.models.backends.hf_local.HFLocalModel`
133
+ that ``evalrx.load("qwen...")`` produces, so every capability-compatible
134
+ analyzer works on it::
135
+
136
+ from transformers import AutoModelForCausalLM, AutoTokenizer
137
+ m = AutoModelForCausalLM.from_pretrained("my-org/my-llama")
138
+ tok = AutoTokenizer.from_pretrained("my-org/my-llama")
139
+
140
+ wrapped = evalrx.wrap(m, tok)
141
+ LogitLensAnalyzer().run(wrapped, "The capital of France is")
142
+
143
+ Capabilities are inferred from the live model; attention capture needs eager
144
+ attention, which ``wrap`` enables when it can (see ``HFLocalModel.from_loaded``).
145
+
146
+ Args:
147
+ model: an instantiated ``transformers`` decoder-only model.
148
+ tokenizer: its tokenizer or processor (positional, recommended).
149
+ want: capabilities to assert up front (names or ``Capability``); raises
150
+ :class:`~evalrx.core.capability.CapabilityError` if unavailable.
151
+ **runtime: forwarded to :class:`RuntimeConfig` (``max_new_tokens``, ``device``…).
152
+
153
+ Raises:
154
+ NotImplementedError: if *model* looks like a VLM (Stage 2; text-only for now).
155
+ """
156
+ from evalrx.models.backends.hf_local import HFLocalModel
157
+ from evalrx.models.compose import negotiate
158
+
159
+ handle = HFLocalModel.from_loaded(model, tokenizer, runtime=RuntimeConfig(**runtime))
160
+ caps = {w if isinstance(w, Capability) else Capability(w) for w in want}
161
+ return negotiate(handle, caps, model_id=handle.spec.key, where="wrap")
@@ -0,0 +1,101 @@
1
+ """Runtime module-path discovery for white-box hooking.
2
+
3
+ Hardcoded dotted paths (``model.model.language_model.layers``) are the #1 source
4
+ of silent white-box bugs: they differ per model AND per transformers release (the
5
+ doubled-``.model.`` / Llama4-no-``.model`` / v5 fused-experts traps). So instead
6
+ of trusting ``ModelSpec.module_paths``, we DISCOVER the decoder-layer
7
+ ``ModuleList`` from the live module tree and derive everything relative to it.
8
+
9
+ ``torch`` is imported lazily inside the functions, so importing this module on the
10
+ light (pure-API) install does not pull torch.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+
18
+ def find_decoder_layers(model: Any, num_hidden_layers: int | None = None) -> tuple[Any, str]:
19
+ """Find the decoder-layer ``ModuleList`` and its dotted path.
20
+
21
+ Strategy: among all ``nn.ModuleList`` modules, prefer the one whose length
22
+ equals ``num_hidden_layers`` (read from config when not given) and whose
23
+ children look like decoder layers (have a ``self_attn`` attr or a
24
+ ``*DecoderLayer`` class name). Ties broken by the *shallowest* path so we
25
+ pick the language tower, not a vision encoder's block list.
26
+ """
27
+ import torch.nn as nn
28
+
29
+ if num_hidden_layers is None:
30
+ cfg = getattr(model, "config", None)
31
+ text_cfg = cfg.get_text_config() if hasattr(cfg, "get_text_config") else cfg
32
+ num_hidden_layers = getattr(text_cfg, "num_hidden_layers", None)
33
+
34
+ candidates: list[tuple[str, Any]] = []
35
+ for name, mod in model.named_modules():
36
+ if not isinstance(mod, nn.ModuleList) or len(mod) == 0:
37
+ continue
38
+ looks_like_layer = hasattr(mod[0], "self_attn") or "DecoderLayer" in type(mod[0]).__name__
39
+ if not looks_like_layer:
40
+ continue
41
+ if num_hidden_layers is not None and len(mod) != num_hidden_layers:
42
+ continue
43
+ candidates.append((name, mod))
44
+
45
+ if not candidates:
46
+ raise RuntimeError(
47
+ "Could not discover the decoder-layer ModuleList"
48
+ + (f" (expected length {num_hidden_layers})" if num_hidden_layers else "")
49
+ + f". Tree top-level: {[n for n, _ in model.named_children()]}"
50
+ )
51
+ name, layers = min(candidates, key=lambda kv: kv[0].count("."))
52
+ return layers, name
53
+
54
+
55
+ def get_unembed(model: Any) -> Any:
56
+ """Return the unembedding (lm_head), handling tied embeddings.
57
+
58
+ Uses transformers' own ``get_output_embeddings()`` so we never hardcode the
59
+ ``lm_head`` path and the tied-weights case is handled by the model itself.
60
+ """
61
+ head = model.get_output_embeddings() if hasattr(model, "get_output_embeddings") else None
62
+ if head is None: # tied / unusual — fall back to input embeddings' weight
63
+ emb = model.get_input_embeddings()
64
+ return emb
65
+ return head
66
+
67
+
68
+ def get_final_norm(model: Any) -> Any:
69
+ """Return the final normalization module applied after the last decoder layer.
70
+
71
+ Found relative to the discovered decoder-layer list: its parent module's
72
+ norm child (``norm`` / ``final_layernorm`` / ``ln_f``, else any direct
73
+ child whose class name contains "Norm"). RMSNorm-family models (Llama,
74
+ Qwen, ...) need this applied before unembedding or logit-lens trajectories
75
+ distort. Returns ``None`` when nothing can be discovered (lens callers
76
+ must degrade gracefully).
77
+ """
78
+ try:
79
+ _, layers_path = find_decoder_layers(model)
80
+ except Exception:
81
+ return None
82
+ parent = model if "." not in layers_path else resolve(model, layers_path.rsplit(".", 1)[0])
83
+ layers_attr = layers_path.rsplit(".", 1)[-1]
84
+ for attr in ("norm", "final_layernorm", "ln_f"):
85
+ mod = getattr(parent, attr, None)
86
+ if mod is not None and "norm" in type(mod).__name__.lower():
87
+ return mod
88
+ for name, mod in parent.named_children():
89
+ if name != layers_attr and "norm" in type(mod).__name__.lower():
90
+ return mod
91
+ return None
92
+
93
+
94
+ def resolve(model: Any, dotted: str) -> Any:
95
+ """Resolve a dotted attribute path (``a.b.c``) against *model*, raising clearly."""
96
+ obj = model
97
+ for part in dotted.split("."):
98
+ if not hasattr(obj, part):
99
+ raise AttributeError(f"path {dotted!r} broke at {part!r}")
100
+ obj = getattr(obj, part)
101
+ return obj
evalrx/models/agent.py ADDED
@@ -0,0 +1,380 @@
1
+ """Agent — a backend-AGNOSTIC tool-calling loop over any handle.
2
+
3
+ ``Agent(wraps=handle)`` works identically on an API model and a local model: the
4
+ loop only needs ``GENERATE`` + ``TOOL_CALLS`` (verified up front), never model
5
+ internals. The single backend/model-specific piece is the
6
+ :class:`~evalrx.models.toolcodec.ToolCallCodec` (auto-selected). Tool
7
+ execution goes through a pluggable :class:`ToolExecutor` — swap in the existing
8
+ ``APIToolHandler`` for production (image handling, etc.).
9
+
10
+ White-box backends additionally let an analyzer capture ONE step's internals via
11
+ ``handle.forward(...)``, but the trajectory production here is the same loop.
12
+ Torch-free.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import time
18
+ from typing import Any, Callable, Iterable, Optional
19
+
20
+ from evalrx.core.capability import Capability, CapabilityError
21
+ from evalrx.core.case import (
22
+ FailureCase,
23
+ Inputs,
24
+ Label,
25
+ Step,
26
+ StepRole,
27
+ Trajectory,
28
+ )
29
+ from evalrx.core.tool import Tool, ToolCall, ToolResult
30
+ from evalrx.models.toolcodec import ToolCallCodec, codec_for
31
+
32
+
33
+ def _user_content(case: FailureCase) -> "str | list":
34
+ """Build the first user message's content: plain text, or content blocks
35
+ (transformers-style ``{"type": "image", "image": ...}``) when the case
36
+ carries an image — the multimodal chat convention every ``Model.chat``
37
+ implementation speaks (hf_local consumes it natively; API adapters convert
38
+ image blocks to their wire format).
39
+ """
40
+ image = case.inputs.image
41
+ if image is None:
42
+ return case.inputs.prompt
43
+ return [
44
+ {"type": "image", "image": image},
45
+ {"type": "text", "text": case.inputs.prompt},
46
+ ]
47
+
48
+
49
+ def _observation_record(observation: Any) -> Any:
50
+ """What lands in ``Step.observation`` — structured for :class:`ToolResult`
51
+ (text + image count + host meta), the raw value otherwise."""
52
+ if isinstance(observation, ToolResult):
53
+ record: dict = {"text": observation.text, "n_images": len(observation.images)}
54
+ if observation.meta:
55
+ record["meta"] = observation.meta
56
+ return record
57
+ return observation
58
+
59
+
60
+ class ToolExecutor:
61
+ """Run a decoded :class:`ToolCall` against the registered tools.
62
+
63
+ The simple default; production swaps in the existing ``APIToolHandler``
64
+ (which also handles tool-output images, retries, etc.).
65
+ """
66
+
67
+ def __init__(self, tools: Iterable[Tool]) -> None:
68
+ self._by_name = {t.name: t for t in tools}
69
+
70
+ def execute(self, call: ToolCall) -> Any:
71
+ tool = self._by_name.get(call.name)
72
+ if tool is None:
73
+ return f"[error: unknown tool {call.name!r}]"
74
+ if tool.fn is None:
75
+ return f"[error: tool {call.name!r} has no implementation]"
76
+ try:
77
+ return tool.fn(**(call.args or {}))
78
+ except Exception as exc: # surface tool errors to the model, don't crash the loop
79
+ return f"[tool error in {call.name!r}: {exc}]"
80
+
81
+
82
+ class APIToolHandlerExecutor:
83
+ """Bridge the Agent's tool execution to the XSkill engine's ``APIToolHandler``.
84
+
85
+ The handler is INJECTED (not imported here), keeping evalrx decoupled from
86
+ the engine repo. It faithfully calls the real signature
87
+ ``execute_tool_call(tool_name, parameters, node, turn_idx, tool_call_id)`` and
88
+ returns the processed text; tool-output images accumulate in ``self.new_images``
89
+ for the caller to attach to the trajectory.
90
+
91
+ Usage::
92
+
93
+ from engine.api_tool_handler import APIToolHandler # your repo
94
+ handler = APIToolHandler(args, save_dir)
95
+ agent = Agent(handle, tools, executor=APIToolHandlerExecutor(handler, node))
96
+
97
+ ``node`` is the engine's ``SearchNode`` (the handler mutates it, e.g. image_map);
98
+ pass the same node the engine drives so multi-turn image/state threading works.
99
+ """
100
+
101
+ def __init__(self, handler: Any, node: Any = None, *, result_key: str = "processed_result") -> None:
102
+ self.handler = handler
103
+ self.node = node
104
+ self.result_key = result_key
105
+ self._turn = 0
106
+ self.new_images: list = []
107
+ self.feedback_messages: list = []
108
+
109
+ def execute(self, call: ToolCall) -> Any:
110
+ self._turn += 1
111
+ out = self.handler.execute_tool_call(
112
+ call.name, call.args or {}, self.node, self._turn, getattr(call, "id", None)
113
+ )
114
+ if isinstance(out, dict):
115
+ self.new_images.extend(out.get("new_images") or [])
116
+ self.feedback_messages.extend(out.get("feedback_messages") or [])
117
+ return out.get(self.result_key) or out.get("tool_result") or ""
118
+ return out
119
+
120
+
121
+ def _as_case(data: Any) -> FailureCase:
122
+ if isinstance(data, FailureCase):
123
+ return data
124
+ if isinstance(data, Inputs):
125
+ return FailureCase(inputs=data)
126
+ if isinstance(data, str):
127
+ return FailureCase.from_prompt(data)
128
+ raise TypeError(f"Agent.run expects str | Inputs | FailureCase, got {type(data).__name__}")
129
+
130
+
131
+ # Loop-policy nudges (L2 scaffold messages, not stage prompts).
132
+ _REPEAT_NUDGE = (
133
+ "[repeat blocked] You already made this exact tool call; the result would "
134
+ "be identical to what you already received. Choose DIFFERENT arguments or "
135
+ "give your final answer now."
136
+ )
137
+ _FINAL_NUDGE = (
138
+ "You have used your entire tool budget. Based on everything you have "
139
+ "already seen, give your final answer NOW as plain text."
140
+ )
141
+
142
+
143
+ def _call_signature(call: ToolCall) -> "tuple[str, str]":
144
+ import json
145
+
146
+ return call.name, json.dumps(call.args or {}, sort_keys=True, default=str)
147
+
148
+
149
+ class Agent:
150
+ """A tool-calling agent composed over a model handle (any backend).
151
+
152
+ Loop-policy options (both default OFF — they are candidate L2 fixes, made
153
+ available as configuration so a validated repair is deployable):
154
+
155
+ * ``block_repeat_calls`` — a tool call identical to the immediately
156
+ preceding one is not executed; the model gets a nudge observation
157
+ instead (repeated identical calls return identical results and only
158
+ burn budget).
159
+ * ``force_final_answer`` — when ``max_turns`` runs out without a final
160
+ answer, ask once more WITHOUT tools so the run ends with an answer
161
+ instead of an empty trajectory (``terminated="forced_final"``).
162
+ """
163
+
164
+ requires = frozenset({Capability.GENERATE, Capability.TOOL_CALLS})
165
+
166
+ def __init__(
167
+ self,
168
+ handle,
169
+ tools: Iterable[Tool],
170
+ *,
171
+ codec: Optional[ToolCallCodec] = None,
172
+ executor: Optional[ToolExecutor] = None,
173
+ max_turns: int = 10,
174
+ system: Optional[str] = None,
175
+ block_repeat_calls: bool = False,
176
+ force_final_answer: bool = False,
177
+ ) -> None:
178
+ missing = self.requires - set(getattr(handle, "capabilities", frozenset()))
179
+ if missing:
180
+ raise CapabilityError(analyzer="Agent", model=repr(handle), missing=missing)
181
+ if not hasattr(handle, "chat"):
182
+ raise TypeError(
183
+ f"{type(handle).__name__} has no chat(); agent mode needs a tool-aware chat method."
184
+ )
185
+ self.handle = handle
186
+ self.tools = list(tools)
187
+ self.codec = codec or codec_for(handle)
188
+ self.executor = executor or ToolExecutor(self.tools)
189
+ self.max_turns = max_turns
190
+ self.system = system
191
+ self.block_repeat_calls = block_repeat_calls
192
+ self.force_final_answer = force_final_answer
193
+ # An Agent still exposes the underlying model's capabilities (pure-model
194
+ # analysis remains available on self.handle).
195
+ self.capabilities = handle.capabilities
196
+
197
+ def run(self, data: Any) -> Trajectory:
198
+ """Drive the tool loop to completion and return a :class:`Trajectory`.
199
+
200
+ Multimodal cases work end-to-end: ``case.inputs.image`` goes into the
201
+ first user message as a content block, and a :class:`ToolResult` whose
202
+ ``images`` are non-empty gets them re-injected as a follow-up user
203
+ message — so the model *sees* what its tool produced (zoom crops etc.).
204
+ """
205
+ case = _as_case(data)
206
+ goal = case.inputs.prompt
207
+ encoded = self.codec.encode(self.tools)
208
+
209
+ messages: list[dict] = []
210
+ if self.system:
211
+ messages.append({"role": "system", "content": self.system})
212
+ messages.append({"role": "user", "content": _user_content(case)})
213
+
214
+ steps: list[Step] = [
215
+ Step(
216
+ idx=0,
217
+ role=StepRole.USER,
218
+ content=goal,
219
+ span={"has_image": case.inputs.image is not None},
220
+ )
221
+ ]
222
+ final_answer: Optional[str] = None
223
+ terminated = "max_turns"
224
+ prev_sig: "Optional[tuple[str, str]]" = None
225
+
226
+ turn = 0
227
+ for turn in range(1, self.max_turns + 1):
228
+ t0 = time.perf_counter()
229
+ chat = self.handle.chat(messages, tools=encoded)
230
+ latency_ms = round((time.perf_counter() - t0) * 1000, 1)
231
+ call = self.codec.decode(chat)
232
+ span: dict = {"turn": turn, "latency_ms": latency_ms}
233
+ if chat.usage:
234
+ span.update(chat.usage)
235
+ steps.append(
236
+ Step(
237
+ idx=len(steps),
238
+ role=StepRole.ACTOR,
239
+ content=chat.text,
240
+ tool_call=call.to_dict() if call else None,
241
+ span=span,
242
+ )
243
+ )
244
+ messages.append(self.codec.assistant_message(chat, call))
245
+
246
+ if call is None:
247
+ final_answer = self.codec.final_text(chat) # strips <think> etc.
248
+ terminated = "final"
249
+ break
250
+
251
+ sig = _call_signature(call)
252
+ blocked = self.block_repeat_calls and sig == prev_sig
253
+ prev_sig = sig
254
+ observation: Any = _REPEAT_NUDGE if blocked else self.executor.execute(call)
255
+ tool_span: dict = {"turn": turn}
256
+ if blocked:
257
+ tool_span["repeat_blocked"] = True
258
+ steps.append(
259
+ Step(
260
+ idx=len(steps),
261
+ role=StepRole.TOOL,
262
+ content=call.name,
263
+ observation=_observation_record(observation),
264
+ span=tool_span,
265
+ )
266
+ )
267
+ messages.append(self.codec.tool_message(call, str(observation)))
268
+ images = observation.images if isinstance(observation, ToolResult) else []
269
+ if images:
270
+ blocks: list = [{"type": "image", "image": im} for im in images]
271
+ blocks.append(
272
+ {
273
+ "type": "text",
274
+ "text": f"The image{'s' if len(images) > 1 else ''} above "
275
+ f"{'were' if len(images) > 1 else 'was'} returned by the "
276
+ f"{call.name!r} tool call.",
277
+ }
278
+ )
279
+ messages.append({"role": "user", "content": blocks})
280
+
281
+ if self.force_final_answer and final_answer is None:
282
+ messages.append({"role": "user", "content": _FINAL_NUDGE})
283
+ t0 = time.perf_counter()
284
+ chat = self.handle.chat(messages, tools=None) # no tools: the reply IS the answer
285
+ span = {"forced_final": True, "latency_ms": round((time.perf_counter() - t0) * 1000, 1)}
286
+ if chat.usage:
287
+ span.update(chat.usage)
288
+ final_answer = self.codec.final_text(chat)
289
+ steps.append(Step(idx=len(steps), role=StepRole.ACTOR, content=chat.text, span=span))
290
+ terminated = "forced_final"
291
+
292
+ metrics: dict = {
293
+ "n_steps": len(steps),
294
+ "n_turns": turn,
295
+ "n_tool_calls": sum(1 for s in steps if s.tool_call),
296
+ "terminated": terminated,
297
+ "total_latency_ms": round(
298
+ sum(s.span.get("latency_ms", 0.0) for s in steps if s.role is StepRole.ACTOR), 1
299
+ ),
300
+ }
301
+ for key in ("prompt_tokens", "completion_tokens"):
302
+ total = sum(int(s.span.get(key) or 0) for s in steps if s.role is StepRole.ACTOR)
303
+ if total:
304
+ metrics[f"total_{key}"] = total
305
+ return Trajectory(
306
+ sample_id=case.id,
307
+ goal=goal,
308
+ steps=steps,
309
+ final_answer=final_answer,
310
+ ground_truth=case.expected,
311
+ outcome=Label.UNKNOWN, # correctness is a separate analyzer's job
312
+ metrics=metrics,
313
+ )
314
+
315
+ def __repr__(self) -> str:
316
+ return f"Agent(handle={self.handle!r}, tools={[t.name for t in self.tools]}, codec={self.codec.name})"
317
+
318
+
319
+ def run_batch(
320
+ handle,
321
+ cases: Iterable[Any],
322
+ *,
323
+ tools_factory: "Callable[[FailureCase], Iterable[Tool]]",
324
+ system: Optional[str] = None,
325
+ max_turns: int = 10,
326
+ codec: Optional[ToolCallCodec] = None,
327
+ concurrency: int = 4,
328
+ on_result: "Optional[Callable[[FailureCase, Trajectory], None]]" = None,
329
+ agent_kwargs: Optional[dict] = None,
330
+ ) -> list[Trajectory]:
331
+ """Run the tool loop over many cases — the M1-scale batch driver.
332
+
333
+ *tools_factory* builds the per-case tool set (visual tools bind to the
334
+ case's image, so each case needs its own instances). Concurrency uses
335
+ threads and is only safe for API-backed handles (HTTP is reentrant); for
336
+ local backends the loop is forced sequential — one GPU forward at a time.
337
+
338
+ A case whose run raises (endpoint down, tool crash outside the executor
339
+ envelope) yields a stub trajectory with ``metrics["terminated"] == "error"``
340
+ instead of killing the batch; ``on_result`` fires per finished case (e.g.
341
+ incremental JSONL persistence). Results keep the input order.
342
+ """
343
+ from concurrent.futures import ThreadPoolExecutor
344
+
345
+ from evalrx.models.backends.api import APIModel
346
+
347
+ normalized = [_as_case(c) for c in cases]
348
+ if concurrency > 1 and not isinstance(handle, APIModel):
349
+ import warnings
350
+
351
+ warnings.warn(
352
+ f"run_batch: {type(handle).__name__} is not an API handle; forcing "
353
+ "concurrency=1 (local backends are not thread-safe).",
354
+ stacklevel=2,
355
+ )
356
+ concurrency = 1
357
+
358
+ def _one(case: FailureCase) -> Trajectory:
359
+ try:
360
+ agent = Agent(
361
+ handle, tools_factory(case), codec=codec, max_turns=max_turns, system=system,
362
+ **(agent_kwargs or {}),
363
+ )
364
+ trajectory = agent.run(case)
365
+ except Exception as exc: # keep the batch alive; the error IS the observation
366
+ trajectory = Trajectory(
367
+ sample_id=case.id,
368
+ goal=case.inputs.prompt,
369
+ steps=[Step(idx=0, role=StepRole.USER, content=case.inputs.prompt)],
370
+ ground_truth=case.expected,
371
+ metrics={"terminated": "error", "error": repr(exc)},
372
+ )
373
+ if on_result is not None:
374
+ on_result(case, trajectory)
375
+ return trajectory
376
+
377
+ if concurrency <= 1:
378
+ return [_one(c) for c in normalized]
379
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
380
+ return list(pool.map(_one, normalized))