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,137 @@
1
+ """LLaVA relative-attention ViCrop executor from *MLLMs Know Where to Look*.
2
+
3
+ The implementation is intentionally limited to the paper's LLaVA-style
4
+ attention selector: task/general attention ratio, adaptive local-contrast
5
+ window, then answer from original image plus crop. It is used by the local
6
+ backend and can therefore be proposed as a read-only L3a auto-fix candidate.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ import numpy as np
15
+
16
+ from evalrx.analyzers.attention.relative_attn import attention_heatmap
17
+ from evalrx.core.case import FailureCase, Inputs
18
+
19
+ if TYPE_CHECKING:
20
+ from evalrx.models.backends.hf_local import HFLocalModel
21
+
22
+
23
+ GENERAL_PROMPT = "Describe the image generally. Do not focus on any particular question or detail."
24
+
25
+
26
+ def relative_attention_map(model: "HFLocalModel", inputs: Inputs, layer: int | float) -> np.ndarray:
27
+ """Paper-style ratio of task-specific to general image-patch attention."""
28
+ image = getattr(inputs, "image", None)
29
+ if image is None or isinstance(image, (list, tuple)):
30
+ raise ValueError("ViCrop requires exactly one source image")
31
+ prompt = str(getattr(inputs, "prompt", ""))
32
+ specific = attention_heatmap(
33
+ model, FailureCase(id="vicrop-specific", inputs=Inputs(prompt, image)), layer=layer
34
+ )
35
+ general = attention_heatmap(
36
+ model, FailureCase(id="vicrop-general", inputs=Inputs(GENERAL_PROMPT, image)), layer=layer
37
+ )
38
+ if specific is None or general is None or specific.shape != general.shape:
39
+ raise RuntimeError("could not obtain compatible task/general image attention maps")
40
+ return specific / np.maximum(general, np.finfo(np.float64).eps)
41
+
42
+
43
+ def sliding_window_box(
44
+ relative_map: np.ndarray,
45
+ image_size: tuple[int, int],
46
+ *,
47
+ bbox_size: int,
48
+ ) -> tuple[float, float, float, float]:
49
+ """Released ViCrop adaptive-window rule, returned as normalized xyxy."""
50
+ if relative_map.ndim != 2 or min(relative_map.shape) < 2:
51
+ raise ValueError("relative attention must be a 2-D patch grid")
52
+ h, w = relative_map.shape
53
+ width, height = image_size
54
+ block_width, block_height = width / w, height / h
55
+ candidates: list[tuple[float, tuple[int, int], tuple[int, int], float]] = []
56
+ for ratio in (1, 1.2, 1.4, 1.6, 1.8, 2):
57
+ box_w = min(int(bbox_size * ratio / block_width), w)
58
+ box_h = min(int(bbox_size * ratio / block_height), h)
59
+ if w - box_w < 1 and h - box_h < 1:
60
+ if ratio == 1:
61
+ return 0.0, 0.0, 1.0, 1.0
62
+ continue
63
+ scores = np.empty((h - box_h + 1, w - box_w + 1), dtype=float)
64
+ for top in range(scores.shape[0]):
65
+ for left in range(scores.shape[1]):
66
+ scores[top, left] = relative_map[top : top + box_h, left : left + box_w].sum()
67
+ top, left = np.unravel_index(np.argmax(scores), scores.shape)
68
+ neighbors = [
69
+ float(scores[neighbor_top, neighbor_left])
70
+ for neighbor_top, neighbor_left in (
71
+ (top, left - 1), (top, left + 1), (top - 1, left), (top + 1, left)
72
+ )
73
+ if 0 <= neighbor_top < scores.shape[0] and 0 <= neighbor_left < scores.shape[1]
74
+ ]
75
+ contrast = (float(scores[top, left]) - float(np.mean(neighbors))) / (box_w * box_h)
76
+ candidates.append((contrast, (left, top), (box_w, box_h), bbox_size * ratio))
77
+ _, (left, top), (box_w, box_h), selected_size = max(candidates, key=lambda item: item[0])
78
+ center_x = int(left * block_width + block_width * box_w / 2)
79
+ center_y = int(top * block_height + block_height * box_h / 2)
80
+ half = selected_size // 2
81
+ center_x = min(max(center_x, half), width - half)
82
+ center_y = min(max(center_y, half), height - half)
83
+ return (
84
+ max(0, center_x - half) / width,
85
+ max(0, center_y - half) / height,
86
+ min(width, center_x + half) / width,
87
+ min(height, center_y + half) / height,
88
+ )
89
+
90
+
91
+ def crop_from_box(image: Any, box: tuple[float, float, float, float]):
92
+ """Load one source image and return its selected crop."""
93
+ from PIL import Image
94
+
95
+ if isinstance(image, Image.Image):
96
+ rgb = image.convert("RGB")
97
+ else:
98
+ with Image.open(image) as opened:
99
+ rgb = opened.convert("RGB")
100
+ width, height = rgb.size
101
+ left, top, right, bottom = box
102
+ x1 = max(0, min(width - 1, math.floor(left * width)))
103
+ y1 = max(0, min(height - 1, math.floor(top * height)))
104
+ x2 = max(x1 + 1, min(width, math.ceil(right * width)))
105
+ y2 = max(y1 + 1, min(height, math.ceil(bottom * height)))
106
+ return rgb.crop((x1, y1, x2, y2))
107
+
108
+
109
+ def prepare_views(
110
+ model: "HFLocalModel", inputs: Inputs, *, layer: int | float = 14
111
+ ) -> tuple[Any, Any, str]:
112
+ """Return source image, selected crop, and ViCrop's original+crop prompt."""
113
+ image = getattr(inputs, "image", None)
114
+ if image is None or isinstance(image, (list, tuple)):
115
+ raise ValueError("ViCrop requires exactly one source image")
116
+ relative = relative_attention_map(model, inputs, layer)
117
+ from PIL import Image
118
+
119
+ if isinstance(image, Image.Image):
120
+ image_size = image.size
121
+ else:
122
+ with Image.open(image) as opened:
123
+ image_size = opened.size
124
+ box = sliding_window_box(relative, image_size, bbox_size=336)
125
+ crop = crop_from_box(image, box)
126
+ prompt = (
127
+ "The first image is the full scene and the second is a task-relative visual crop. "
128
+ "Use both views, prioritizing visible detail in the crop, then answer the question.\n\n"
129
+ + str(getattr(inputs, "prompt", ""))
130
+ )
131
+ return image, crop, prompt
132
+
133
+
134
+ def generate(model: "HFLocalModel", inputs: Inputs, *, layer: int | float = 14) -> str:
135
+ """Answer from original image and a task-relative ViCrop crop."""
136
+ image, crop, prompt = prepare_views(model, inputs, layer=layer)
137
+ return str(model.generate(Inputs(prompt, [image, crop])))
@@ -0,0 +1,143 @@
1
+ """ToolCallCodec — the ONLY backend/model-specific piece of agent tool-calling.
2
+
3
+ The agent loop is identical across backends; what differs is how tools are
4
+ encoded into the request and how a tool call is decoded out of the model's
5
+ reply. Two implementations:
6
+
7
+ * :class:`OpenAIToolCodec` — native structured tool_calls (API / ``vllm serve``).
8
+ * :class:`QwenToolCodec` — Hermes-style ``<tool_call>{...}</tool_call>`` text
9
+ parsing (Qwen2.5/Qwen3 and many template-based local models).
10
+
11
+ ``encode`` is shared (OpenAI tool schema, which HF ``apply_chat_template`` also
12
+ accepts). Message-threading helpers let each codec put the assistant turn and
13
+ the tool result back into history in the form its backend expects. Torch-free.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ from abc import ABC, abstractmethod
21
+ from typing import Optional
22
+
23
+ from evalrx.core.tool import ChatTurn, Tool, ToolCall
24
+
25
+
26
+ class ToolCallCodec(ABC):
27
+ """Encode tool schemas into a request; decode a tool call out of a reply."""
28
+
29
+ name: str = "codec"
30
+
31
+ def encode(self, tools: list[Tool]) -> list[dict]:
32
+ """OpenAI tool schema — accepted by both APIs and HF apply_chat_template."""
33
+ return [t.to_openai_schema() for t in tools]
34
+
35
+ @abstractmethod
36
+ def decode(self, turn: ChatTurn) -> Optional[ToolCall]:
37
+ """Return the FIRST tool call in *turn*, or ``None`` if it's a final answer."""
38
+
39
+ def decode_all(self, turn: ChatTurn) -> list[ToolCall]:
40
+ """All tool calls in *turn* (for parallel calling). Default: the first only."""
41
+ call = self.decode(turn)
42
+ return [call] if call else []
43
+
44
+ def final_text(self, turn: ChatTurn) -> str:
45
+ """The clean assistant text to record as a final answer (codecs may strip reasoning)."""
46
+ return turn.text or ""
47
+
48
+ # -- message threading (sensible defaults; OpenAI overrides) -------
49
+ def assistant_message(self, turn: ChatTurn, call: Optional[ToolCall]) -> dict:
50
+ return {"role": "assistant", "content": turn.text}
51
+
52
+ def tool_message(self, call: ToolCall, result) -> dict:
53
+ return {"role": "tool", "content": str(result)}
54
+
55
+
56
+ class OpenAIToolCodec(ToolCallCodec):
57
+ """Native OpenAI-compatible tool calling (structured tool_calls)."""
58
+
59
+ name = "openai"
60
+
61
+ def decode(self, turn: ChatTurn) -> Optional[ToolCall]:
62
+ if not turn.raw_tool_calls:
63
+ return None
64
+ tc = turn.raw_tool_calls[0]
65
+ fn = tc.get("function", tc)
66
+ args = fn.get("arguments", {})
67
+ if isinstance(args, str):
68
+ try:
69
+ args = json.loads(args) if args.strip() else {}
70
+ except json.JSONDecodeError:
71
+ args = {"_raw": args}
72
+ return ToolCall(name=fn.get("name", ""), args=args or {}, id=tc.get("id"))
73
+
74
+ def assistant_message(self, turn: ChatTurn, call: Optional[ToolCall]) -> dict:
75
+ msg: dict = {"role": "assistant", "content": turn.text or None}
76
+ if turn.raw_tool_calls:
77
+ # The loop executes ONE call per turn; replaying more tool_calls than
78
+ # have matching tool results is a protocol violation on OpenAI-style APIs.
79
+ msg["tool_calls"] = turn.raw_tool_calls[:1]
80
+ return msg
81
+
82
+ def tool_message(self, call: ToolCall, result) -> dict:
83
+ return {"role": "tool", "tool_call_id": call.id or "call_0", "content": str(result)}
84
+
85
+
86
+ class QwenToolCodec(ToolCallCodec):
87
+ """Qwen2.5 / Qwen3 tool-calling: Hermes-style ``<tool_call>…</tool_call>`` blocks.
88
+
89
+ Robust to Qwen's real output:
90
+ * captures the FULL JSON between the tags (handles **nested** ``arguments``,
91
+ unlike a ``{...}`` regex that stops at the first brace),
92
+ * supports **multiple** ``<tool_call>`` blocks (parallel calls) via ``decode_all``,
93
+ * strips ``<think>…</think>`` reasoning from the final answer.
94
+
95
+ Other families differ (Llama 3.1, etc.) — add a sibling codec and route it in
96
+ :func:`codec_for`.
97
+ """
98
+
99
+ name = "qwen"
100
+ # capture everything between the tags (non-greedy stops at the first </tool_call>,
101
+ # which nested JSON never contains) -> nested arguments parse correctly.
102
+ _BLOCK = re.compile(r"<tool_call>\s*(.*?)\s*</tool_call>", re.DOTALL)
103
+ _THINK = re.compile(r"<think>.*?</think>\s*", re.DOTALL)
104
+
105
+ def decode(self, turn: ChatTurn) -> Optional[ToolCall]:
106
+ calls = self.decode_all(turn)
107
+ return calls[0] if calls else None
108
+
109
+ def decode_all(self, turn: ChatTurn) -> list[ToolCall]:
110
+ out: list[ToolCall] = []
111
+ for block in self._BLOCK.findall(turn.text or ""):
112
+ try:
113
+ payload = json.loads(block)
114
+ except json.JSONDecodeError:
115
+ continue
116
+ args = payload.get("arguments", payload.get("args", {})) or {}
117
+ out.append(ToolCall(name=payload.get("name", ""), args=args))
118
+ return out
119
+
120
+ def final_text(self, turn: ChatTurn) -> str:
121
+ return self._THINK.sub("", turn.text or "").strip()
122
+
123
+
124
+ # family -> local codec (default Hermes/Qwen style covers most template models)
125
+ _LOCAL_CODECS = {
126
+ "qwen2": QwenToolCodec,
127
+ "qwen3": QwenToolCodec,
128
+ "qwen3_moe": QwenToolCodec,
129
+ "qwen3_vl": QwenToolCodec,
130
+ "qwen3_vl_moe": QwenToolCodec,
131
+ }
132
+
133
+
134
+ def codec_for(handle) -> ToolCallCodec:
135
+ """Pick a codec for *handle*: OpenAI for native-tool-call handles, family-routed for local."""
136
+ from evalrx.models.backends.api import APIModel
137
+
138
+ # APIModel, and any handle declaring native structured calls (e.g. GeminiModel).
139
+ if isinstance(handle, APIModel) or getattr(handle, "tool_call_style", "") == "native":
140
+ return OpenAIToolCodec()
141
+ spec = getattr(handle, "spec", None)
142
+ family = getattr(spec, "family", "")
143
+ return _LOCAL_CODECS.get(family, QwenToolCodec)()
@@ -0,0 +1,20 @@
1
+ """Subject-side tool implementations for the :class:`~evalrx.models.agent.Agent` loop.
2
+
3
+ These are the tools an agent-under-test may call — distinct from
4
+ ``eval_agent.agentic`` (the host's *diagnosis* tools) and ``agent_runtime``
5
+ (the analysis-side CLI coding agents). Design constraints, because these runs
6
+ are diagnosed, not just executed:
7
+
8
+ * **deterministic** — no sampling, versioned weights, so the tool layer stays a
9
+ controlled variable under held-out re-evaluation and fix-vs-baseline pairing;
10
+ * **model-agnostic schemas** — normalized ``[0,1]`` coordinates, conventions
11
+ spelled out in the tool description (the description is an L1 fix surface);
12
+ * **structured results** — :class:`~evalrx.core.tool.ToolResult` with
13
+ model-visible text, re-injectable images, and host-side ``meta`` that lands
14
+ on the trajectory step.
15
+ """
16
+
17
+ from evalrx.models.tools.perception import detect_tool, ocr_tool
18
+ from evalrx.models.tools.visual import zoom_in_tool
19
+
20
+ __all__ = ["zoom_in_tool", "ocr_tool", "detect_tool"]
@@ -0,0 +1,300 @@
1
+ """Structured-perception tools — the T1 tier: ``image_ocr`` and ``image_detect``.
2
+
3
+ Both follow the house tool constraints: deterministic engines (versioned
4
+ weights, no sampling), normalized ``[0, 1]`` coordinates in results, engine
5
+ failures surfaced through the standard executor error envelope, and
6
+ :class:`~evalrx.core.tool.ToolResult` outputs whose ``meta`` records what
7
+ actually ran.
8
+
9
+ Engines are **injectable** (any callable with the documented signature) so the
10
+ tools stay dependency-light and unit-testable; the default engines are built
11
+ lazily on first call:
12
+
13
+ * OCR — ``easyocr`` (reuses the already-installed torch stack),
14
+ * detect — open-vocabulary Grounding DINO via ``transformers``
15
+ (``IDEA-Research/grounding-dino-tiny``).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, Callable, Optional
21
+
22
+ from evalrx.core.tool import Tool, ToolResult
23
+ from evalrx.models.tools.visual import _sanitize_bbox, _to_pil
24
+
25
+ # ----------------------------------------------------------------------
26
+ # OCR
27
+ # ----------------------------------------------------------------------
28
+ OCR_DESCRIPTION = (
29
+ "Read the text in the CURRENT image (optical character recognition). "
30
+ "Optionally pass bbox=[x0, y0, x1, y1] (fractions of width/height in "
31
+ "[0, 1]) to read only that region. Returns the recognized text lines with "
32
+ "their locations. Use this for signs, labels, documents, or any question "
33
+ "about what text says."
34
+ )
35
+
36
+ OCR_PARAMETERS = {
37
+ "type": "object",
38
+ "properties": {
39
+ "bbox": {
40
+ "type": "array",
41
+ "items": {"type": "number"},
42
+ "minItems": 4,
43
+ "maxItems": 4,
44
+ "description": "Optional region [x0, y0, x1, y1], fractions in [0, 1].",
45
+ },
46
+ },
47
+ "required": [],
48
+ }
49
+
50
+
51
+ def default_ocr_engine(languages: "list[str] | None" = None, device: str = "cpu") -> Callable:
52
+ """Build the default OCR engine on ``easyocr`` (deterministic, local weights).
53
+
54
+ Returns ``engine(pil_image) -> list[(bbox_px, text, confidence)]`` where
55
+ ``bbox_px`` is a 4-point polygon in pixels (easyocr's native shape).
56
+ """
57
+ try:
58
+ import easyocr
59
+ except ImportError as exc:
60
+ raise ImportError(
61
+ "image_ocr's default engine needs easyocr (pip install easyocr), "
62
+ "or inject your own engine via ocr_tool(..., engine=...)."
63
+ ) from exc
64
+ import numpy as np
65
+
66
+ reader = easyocr.Reader(languages or ["en"], gpu=device != "cpu", verbose=False)
67
+
68
+ def _engine(pil_image):
69
+ return reader.readtext(np.array(pil_image))
70
+
71
+ return _engine
72
+
73
+
74
+ def ocr_tool(
75
+ image: Any,
76
+ *,
77
+ engine: Optional[Callable] = None,
78
+ max_lines: int = 40,
79
+ ) -> Tool:
80
+ """Build the ``image_ocr`` tool bound to *image*.
81
+
82
+ *engine* is ``callable(pil_image) -> list[(polygon_px, text, confidence)]``;
83
+ defaults to :func:`default_ocr_engine` built lazily on first call.
84
+ """
85
+ base = _to_pil(image)
86
+ width, height = base.size
87
+ state = {"engine": engine}
88
+
89
+ def _get_engine() -> Callable:
90
+ if state["engine"] is None:
91
+ state["engine"] = default_ocr_engine()
92
+ return state["engine"]
93
+
94
+ def _ocr(bbox=None) -> ToolResult:
95
+ region = base
96
+ offset = (0, 0)
97
+ meta: dict = {"engine": "injected" if engine else "easyocr", "region": "full"}
98
+ if bbox is not None:
99
+ frac, mode = _sanitize_bbox(bbox, width, height)
100
+ px = (
101
+ int(round(frac[0] * width)),
102
+ int(round(frac[1] * height)),
103
+ int(round(frac[2] * width)),
104
+ int(round(frac[3] * height)),
105
+ )
106
+ region = base.crop(px)
107
+ offset = (px[0], px[1])
108
+ meta.update({"region": "bbox", "bbox_frac": [round(v, 4) for v in frac], "coord_mode": mode})
109
+
110
+ detections = _get_engine()(region)
111
+ lines: list[str] = []
112
+ for det in detections[:max_lines]:
113
+ poly, text, conf = det[0], det[1], (det[2] if len(det) > 2 else None)
114
+ xs = [p[0] + offset[0] for p in poly]
115
+ ys = [p[1] + offset[1] for p in poly]
116
+ loc = (
117
+ f"[{min(xs) / width:.2f}, {min(ys) / height:.2f}, "
118
+ f"{max(xs) / width:.2f}, {max(ys) / height:.2f}]"
119
+ )
120
+ conf_s = f" (conf {conf:.2f})" if isinstance(conf, (int, float)) else ""
121
+ lines.append(f"- {text!r} at {loc}{conf_s}")
122
+ n_total = len(detections)
123
+ meta["n_detections"] = n_total
124
+ if not lines:
125
+ text_out = "No text detected in the requested region."
126
+ else:
127
+ clipped = f" (showing {max_lines} of {n_total})" if n_total > max_lines else ""
128
+ text_out = (
129
+ f"Detected {n_total} text line(s){clipped}, locations as "
130
+ f"[x0, y0, x1, y1] fractions of the full image:\n" + "\n".join(lines)
131
+ )
132
+ return ToolResult(text=text_out, meta=meta)
133
+
134
+ return Tool(name="image_ocr", description=OCR_DESCRIPTION, parameters=OCR_PARAMETERS, fn=_ocr)
135
+
136
+
137
+ # ----------------------------------------------------------------------
138
+ # Open-vocabulary detection
139
+ # ----------------------------------------------------------------------
140
+ DETECT_DESCRIPTION = (
141
+ "Detect objects in the CURRENT image by name (open-vocabulary detection). "
142
+ "Pass query as the object name(s) to look for, separated by ' . ' for "
143
+ "multiple (e.g. 'dog . frisbee'). Returns each detected instance with a "
144
+ "confidence score and its box as [x0, y0, x1, y1] fractions of the image, "
145
+ "plus an annotated copy of the image with the boxes drawn. Use this for "
146
+ "counting, presence checks, and locating objects."
147
+ )
148
+
149
+ DETECT_PARAMETERS = {
150
+ "type": "object",
151
+ "properties": {
152
+ "query": {
153
+ "type": "string",
154
+ "description": "Object name(s) to detect, ' . '-separated for multiple.",
155
+ },
156
+ },
157
+ "required": ["query"],
158
+ }
159
+
160
+ DEFAULT_DETECTOR_ID = "IDEA-Research/grounding-dino-tiny"
161
+
162
+
163
+ def default_detect_engine(
164
+ model_id: str = DEFAULT_DETECTOR_ID,
165
+ device: str = "cpu",
166
+ threshold: float = 0.3,
167
+ text_threshold: float = 0.25,
168
+ ) -> Callable:
169
+ """Build the default detector on Grounding DINO via transformers.
170
+
171
+ Returns ``engine(pil_image, query) -> list[{"label", "score", "box_px"}]``
172
+ (deterministic: pure forward pass, fixed thresholds, versioned weights).
173
+ """
174
+ try:
175
+ import torch
176
+ from transformers import AutoModelForZeroShotObjectDetection, AutoProcessor
177
+ except ImportError as exc:
178
+ raise ImportError(
179
+ "image_detect's default engine needs torch+transformers "
180
+ "(pip install 'evalrx[local]'), or inject an engine via "
181
+ "detect_tool(..., engine=...)."
182
+ ) from exc
183
+
184
+ processor = AutoProcessor.from_pretrained(model_id)
185
+ model = AutoModelForZeroShotObjectDetection.from_pretrained(model_id).to(device).eval()
186
+
187
+ def _engine(pil_image, query: str):
188
+ text = query.strip().rstrip(".") + "." # grounding-dino wants lowercase, dot-terminated
189
+ inputs = processor(images=pil_image, text=text.lower(), return_tensors="pt").to(device)
190
+ with torch.no_grad():
191
+ outputs = model(**inputs)
192
+ results = processor.post_process_grounded_object_detection(
193
+ outputs,
194
+ inputs.input_ids,
195
+ threshold=threshold,
196
+ text_threshold=text_threshold,
197
+ target_sizes=[pil_image.size[::-1]],
198
+ )[0]
199
+ out = []
200
+ for label, score, box in zip(
201
+ results["text_labels" if "text_labels" in results else "labels"],
202
+ results["scores"],
203
+ results["boxes"],
204
+ ):
205
+ out.append(
206
+ {
207
+ "label": str(label),
208
+ "score": float(score),
209
+ "box_px": [float(v) for v in box.tolist()],
210
+ }
211
+ )
212
+ return out
213
+
214
+ return _engine
215
+
216
+
217
+ def _annotate(image, detections: list) -> Any:
218
+ """Draw detection boxes + labels on a copy of *image*."""
219
+ from PIL import ImageDraw
220
+
221
+ annotated = image.copy()
222
+ draw = ImageDraw.Draw(annotated)
223
+ for i, det in enumerate(detections):
224
+ x0, y0, x1, y1 = det["box_px"]
225
+ draw.rectangle([x0, y0, x1, y1], outline=(208, 59, 59), width=3)
226
+ draw.text((x0 + 3, max(0, y0 - 12)), f"{i}: {det['label']} {det['score']:.2f}", fill=(208, 59, 59))
227
+ return annotated
228
+
229
+
230
+ def detect_tool(
231
+ image: Any,
232
+ *,
233
+ engine: Optional[Callable] = None,
234
+ annotate: bool = True,
235
+ max_detections: int = 20,
236
+ ) -> Tool:
237
+ """Build the ``image_detect`` tool bound to *image*.
238
+
239
+ *engine* is ``callable(pil_image, query) -> list[{"label", "score",
240
+ "box_px"}]``; defaults to :func:`default_detect_engine` built lazily on
241
+ first call (weights load once per tool instance).
242
+ """
243
+ base = _to_pil(image)
244
+ width, height = base.size
245
+ state = {"engine": engine}
246
+
247
+ def _get_engine() -> Callable:
248
+ if state["engine"] is None:
249
+ state["engine"] = default_detect_engine()
250
+ return state["engine"]
251
+
252
+ def _detect(query: str) -> ToolResult:
253
+ if not str(query).strip():
254
+ raise ValueError("query must name at least one object to detect")
255
+ detections = _get_engine()(base, str(query))
256
+ detections = sorted(detections, key=lambda d: -d["score"])[:max_detections]
257
+ meta: dict = {
258
+ "engine": "injected" if engine else DEFAULT_DETECTOR_ID,
259
+ "query": str(query),
260
+ "n_detections": len(detections),
261
+ "detections": [
262
+ {
263
+ "label": d["label"],
264
+ "score": round(d["score"], 3),
265
+ "box_frac": [
266
+ round(d["box_px"][0] / width, 4),
267
+ round(d["box_px"][1] / height, 4),
268
+ round(d["box_px"][2] / width, 4),
269
+ round(d["box_px"][3] / height, 4),
270
+ ],
271
+ }
272
+ for d in detections
273
+ ],
274
+ }
275
+ if not detections:
276
+ return ToolResult(
277
+ text=f"No instances of {query!r} detected (score threshold not reached).",
278
+ meta=meta,
279
+ )
280
+ lines = [
281
+ f"- #{i} {d['label']} (score {d['score']:.2f}) at "
282
+ f"[{d['box_frac'][0]:.2f}, {d['box_frac'][1]:.2f}, {d['box_frac'][2]:.2f}, {d['box_frac'][3]:.2f}]"
283
+ for i, d in enumerate(meta["detections"])
284
+ ]
285
+ images = []
286
+ text_out = (
287
+ f"Detected {len(detections)} instance(s) of {query!r}, boxes as "
288
+ f"[x0, y0, x1, y1] fractions of the image:\n" + "\n".join(lines)
289
+ )
290
+ if annotate:
291
+ images.append(_annotate(base, detections))
292
+ text_out += "\nAn annotated copy of the image with numbered boxes is attached."
293
+ return ToolResult(text=text_out, images=images, meta=meta)
294
+
295
+ return Tool(
296
+ name="image_detect",
297
+ description=DETECT_DESCRIPTION,
298
+ parameters=DETECT_PARAMETERS,
299
+ fn=_detect,
300
+ )