arcade-mcp/libs/arcade-evals/arcade_evals/capture.py
jottakka fe8ddfd500
[TOO-326] Windows papercuts (#768)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> **Medium Risk**
> Touches authentication/login flow, credentials-file permissions, and
subprocess lifecycle behavior across platforms; while mostly defensive,
regressions could impact login or process management on Windows/macOS
runners.
> 
> **Overview**
> Improves Windows/cross-platform reliability across the CLI and MCP
server: OAuth login now binds the callback server to `127.0.0.1`, avoids
slow loopback reverse-DNS, adds a configurable callback timeout
(`--timeout` + env default), and opens URLs via a Windows-friendly
`_open_browser` to avoid flashing console windows.
> 
> Centralizes CLI output via a shared `console` that forces UTF-8 on
Windows, standardizes UTF-8 file reads/writes throughout, tightens
credentials-file permissions on Windows using `icacls`, and adds shared
Windows subprocess helpers for **no-window** process creation and
graceful termination (used by `deploy`, MCP reload, and usage-tracking
worker).
> 
> Updates client configuration UX/robustness (Windows AppData resolution
via `platformdirs`, Cursor config path fallbacks + compatibility writes,
overwrite warnings, absolute `uv` path for GUI clients, safer path
display) and improves `deploy` child-process handling to avoid
pipe-buffer deadlocks while giving better debug-aware error messages.
> 
> Expands CI to run tests on Linux/Windows/macOS, adds a no-auth CLI
integration workflow, disables usage tracking in toolkits CI, and adds
extensive regression tests for Windows signals, subprocess cleanup,
UTF-8, and config-path edge cases; bumps `arcade-core` to `4.4.2` and
`arcade-mcp-server` to `1.17.2` (with updated dependency pin).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
0fabd8ca1cd647039ba6ddbdf3f7809c330bab9e. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-25 13:18:16 -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", encoding="utf-8") 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,
)