arcade-mcp/libs/arcade-evals/arcade_evals/capture.py
jottakka 7472b18106
Fixing bug with multiple providers + stats for multiple runs (#752)
@EricGustin you can use this cli command:
```
uv run arcade evals mcp_building_evals_results/eval_toolkit_iteration_dict.py \
    -p openai:gpt-4o,gpt-4o-mini \
    -p anthropic:claude-sonnet-4-20250514 \
    -k openai:$OPENAI_API_KEY \
    -k anthropic:$ANTHROPIC_API_KEY \
    -d \
    --num-runs 3 \
    --seed random \
    --multi-run-pass-rule majority \
    --max-concurrent 6 \
    -o mcp_building_evals_results/results

```

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches core eval execution and all result formatters while adding new
CLI inputs and output schema (`run_stats`/`critic_stats` and capture
`runs`), so regressions could affect evaluation results and report
compatibility despite being additive and validated.
> 
> **Overview**
> Adds **multi-run evaluation support** to `arcade evals` via new flags
`--num-runs`, `--seed`, and `--multi-run-pass-rule`, with upfront
validation and plumbing through the CLI runner into eval/capture suite
execution.
> 
> Fixes provider selection UX/bug by making `--use-provider/-p`
**repeatable** (instead of a space-delimited string), updates
docs/examples accordingly, and extends capture mode to optionally record
**per-run tool calls** (`CapturedRun`) when `num_runs > 1`.
> 
> Enhances all output formatters (HTML/Markdown/Text/JSON) to
**propagate and display** per-case `run_stats` and `critic_stats`,
including new HTML UI for run tabs/cards and comparative tables showing
mean ± stddev when multi-run data is present.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
2ee1654b7d1fbb9538373507355636164b16a066. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-09 14:25:28 -03:00

226 lines
7.5 KiB
Python

"""
Capture mode for EvalSuite.
Capture mode runs evaluation cases and records tool calls from the model
without scoring or evaluating them. This is useful for:
- Generating expected tool calls for new test cases
- Debugging model behavior
- Creating baseline recordings
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from openai import AsyncOpenAI
if TYPE_CHECKING:
from arcade_evals.eval import EvalSuite
@dataclass
class CapturedToolCall:
"""
A captured tool call from the model during capture mode.
Attributes:
name: The name of the tool that was called.
args: The arguments passed to the tool.
"""
name: str
args: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {"name": self.name, "args": self.args}
@dataclass
class CapturedRun:
"""
A single capture run for a case, containing tool calls.
Attributes:
tool_calls: List of tool calls made by the model in this run.
"""
tool_calls: list[CapturedToolCall] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {"tool_calls": [tc.to_dict() for tc in self.tool_calls]}
@dataclass
class CapturedCase:
"""
Result of running a single case in capture mode.
Attributes:
case_name: The name of the evaluation case.
user_message: The user message that triggered the tool calls.
tool_calls: List of tool calls made by the model.
system_message: The system message (included if include_context is True).
additional_messages: Additional messages (included if include_context is True).
track_name: The track name for comparative captures (None for regular cases).
runs: Optional list of runs (populated when num_runs > 1).
"""
case_name: str
user_message: str
tool_calls: list[CapturedToolCall] = field(default_factory=list)
system_message: str | None = None
additional_messages: list[dict[str, Any]] | None = None
track_name: str | None = None
runs: list[CapturedRun] = field(default_factory=list)
@staticmethod
def _try_parse_json(value: str) -> Any:
"""Try to parse a JSON string, returning the original string if parsing fails."""
try:
return json.loads(value)
except json.JSONDecodeError:
return value
@staticmethod
def _normalize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Normalize additional_messages by parsing JSON strings into proper objects.
OpenAI returns:
- Tool call arguments as JSON strings in assistant messages
- Tool response content as JSON strings in tool messages
For cleaner output, we parse these into proper objects.
"""
normalized = []
for msg in messages:
msg_copy = dict(msg)
# Parse tool call arguments in assistant messages
if "tool_calls" in msg_copy and isinstance(msg_copy["tool_calls"], list):
normalized_tool_calls = []
for tc in msg_copy["tool_calls"]:
tc_copy = dict(tc)
if "function" in tc_copy and isinstance(tc_copy["function"], dict):
func = dict(tc_copy["function"])
if "arguments" in func and isinstance(func["arguments"], str):
func["arguments"] = CapturedCase._try_parse_json(func["arguments"])
tc_copy["function"] = func
normalized_tool_calls.append(tc_copy)
msg_copy["tool_calls"] = normalized_tool_calls
# Parse content in tool response messages
if msg_copy.get("role") == "tool" and isinstance(msg_copy.get("content"), str):
msg_copy["content"] = CapturedCase._try_parse_json(msg_copy["content"])
normalized.append(msg_copy)
return normalized
def to_dict(self, include_context: bool = False) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result: dict[str, Any] = {
"case_name": self.case_name,
"user_message": self.user_message,
"tool_calls": [tc.to_dict() for tc in self.tool_calls],
}
if self.runs:
result["runs"] = [run.to_dict() for run in self.runs]
if self.track_name:
result["track_name"] = self.track_name
if include_context:
result["system_message"] = self.system_message
# Normalize additional_messages to parse JSON string arguments
raw_messages = self.additional_messages or []
result["additional_messages"] = self._normalize_messages(raw_messages)
return result
@dataclass
class CaptureResult:
"""
Result of running an EvalSuite in capture mode.
Attributes:
suite_name: The name of the evaluation suite.
model: The model used for capture.
provider: The provider used (openai, anthropic).
captured_cases: List of captured cases with tool calls.
"""
suite_name: str
model: str
provider: str
captured_cases: list[CapturedCase] = field(default_factory=list)
def to_dict(self, include_context: bool = False) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"suite_name": self.suite_name,
"model": self.model,
"provider": self.provider,
"captured_cases": [c.to_dict(include_context) for c in self.captured_cases],
}
def to_json(self, include_context: bool = False, indent: int = 2) -> str:
"""Convert to JSON string."""
return json.dumps(self.to_dict(include_context), indent=indent)
def write_to_file(self, file_path: str, include_context: bool = False, indent: int = 2) -> None:
"""Write capture results to a JSON file."""
with open(file_path, "w") as f:
f.write(self.to_json(include_context, indent))
# --- Helper functions for running capture mode ---
async def _capture_with_openai(
suite: EvalSuite,
api_key: str,
model: str,
include_context: bool = False,
num_runs: int = 1,
seed: str | int | None = "constant",
) -> CaptureResult:
"""Run capture mode with OpenAI client."""
async with AsyncOpenAI(api_key=api_key) as client:
return await suite.capture(
client,
model,
provider="openai",
include_context=include_context,
num_runs=num_runs,
seed=seed,
)
async def _capture_with_anthropic(
suite: EvalSuite,
api_key: str,
model: str,
include_context: bool = False,
num_runs: int = 1,
seed: str | int | None = "constant",
) -> CaptureResult:
"""Run capture mode with Anthropic client."""
try:
from anthropic import AsyncAnthropic
except ImportError as e:
raise ImportError(
"The 'anthropic' package is required for Anthropic provider. "
"Install it with: pip install anthropic"
) from e
async with AsyncAnthropic(api_key=api_key) as client:
return await suite.capture(
client,
model,
provider="anthropic",
include_context=include_context,
num_runs=num_runs,
seed=seed,
)