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,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))
|