1035 lines
38 KiB
Python
1035 lines
38 KiB
Python
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import pexpect
|
|
from datasets import load_dataset
|
|
from git import Repo
|
|
|
|
DATASET_NAME = os.environ.get("DATASET_NAME", "Contextbench/ContextBench")
|
|
DATASET_SPLIT = os.environ.get("DATASET_SPLIT", "train")
|
|
|
|
|
|
# Parse integer environment variables with validation and defaults.
|
|
def _get_int_env(name: str, default: int, min_value: int = 1) -> int:
|
|
raw = os.environ.get(name, "").strip()
|
|
if not raw:
|
|
return default
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
print(f"⚠️ Invalid {name}={raw!r}; falling back to {default}")
|
|
return default
|
|
if value < min_value:
|
|
print(f"⚠️ {name} must be >= {min_value}; falling back to {default}")
|
|
return default
|
|
return value
|
|
|
|
|
|
LEANN_TOP_K = _get_int_env("LEANN_TOP_K", 5, min_value=1)
|
|
USAGE_LOG_FILE = os.environ.get("USAGE_LOG_FILE", "task_session_usage.log").strip()
|
|
TASK_METRICS_FILE = os.environ.get("TASK_METRICS_FILE", "task_metrics.jsonl").strip()
|
|
PREFETCH_STRICT = os.environ.get("PREFETCH_STRICT", "1").strip() != "0"
|
|
|
|
|
|
# Get uncommitted changes compared to the current HEAD commit.
|
|
def get_git_diff(repo_dir: Path) -> str:
|
|
try:
|
|
repo = Repo(repo_dir)
|
|
return repo.git.diff(repo.head.commit)
|
|
except Exception as e:
|
|
print(f"⚠️ Diff Error: {e}")
|
|
return ""
|
|
|
|
|
|
# Prepare repository state for one ContextBench task instance.
|
|
def setup_task_environment(
|
|
instance_id: str, repo_url: str, work_root: Path, task: Optional[dict] = None
|
|
) -> Path:
|
|
print(f"🚀 [1/4] Preparing environment: {instance_id}")
|
|
if not task:
|
|
ds = load_dataset(DATASET_NAME, split=DATASET_SPLIT)
|
|
task = next((x for x in ds if x["instance_id"] == instance_id), None)
|
|
if not task:
|
|
raise RuntimeError(f"Instance {instance_id} not found in dataset.")
|
|
|
|
target_dir = work_root / instance_id
|
|
if not target_dir.exists():
|
|
Repo.clone_from(repo_url, target_dir)
|
|
|
|
repo = Repo(target_dir)
|
|
repo.git.reset("--hard")
|
|
# Preserve LEANN indexes if present.
|
|
repo.git.clean("-fdx", "-e", ".leann/")
|
|
repo.git.checkout(task["base_commit"])
|
|
(target_dir / "PROBLEM.md").write_text(task["problem_statement"], encoding="utf-8")
|
|
return target_dir
|
|
|
|
|
|
# Pre-download all task repositories before model execution starts.
|
|
def prefetch_task_repositories(tasks: list[dict], work_root: Path) -> None:
|
|
if not tasks:
|
|
print("📦 [Prefetch] No tasks to download.")
|
|
return
|
|
|
|
print(f"📦 [Prefetch] Downloading repositories for {len(tasks)} tasks...")
|
|
failures: list[str] = []
|
|
|
|
for i, task in enumerate(tasks, start=1):
|
|
instance_id = task.get("instance_id", "").strip()
|
|
repo_url = task.get("repo_url", "").strip()
|
|
target_dir = work_root / instance_id
|
|
|
|
if not instance_id or not repo_url:
|
|
failures.append(f"task[{i}] missing instance_id/repo_url")
|
|
print(f" [{i}/{len(tasks)}] ❌ Invalid task metadata; skipping.")
|
|
continue
|
|
|
|
if target_dir.exists():
|
|
# A previous interrupted clone can leave a broken .git dir.
|
|
# Treat only repos with a valid HEAD as reusable.
|
|
try:
|
|
repo = Repo(target_dir)
|
|
repo.git.rev_parse("HEAD")
|
|
print(f" [{i}/{len(tasks)}] ✅ Already present: {instance_id}")
|
|
continue
|
|
except Exception:
|
|
print(f" [{i}/{len(tasks)}] ♻️ Found incomplete repo, re-cloning: {instance_id}")
|
|
shutil.rmtree(target_dir, ignore_errors=True)
|
|
|
|
cloned = False
|
|
last_error: Optional[Exception] = None
|
|
for attempt in range(1, 3 + 1):
|
|
try:
|
|
print(f" [{i}/{len(tasks)}] ⬇️ Cloning: {instance_id} (attempt {attempt}/3)")
|
|
Repo.clone_from(repo_url, target_dir)
|
|
print(f" [{i}/{len(tasks)}] ✅ Cloned: {instance_id}")
|
|
cloned = True
|
|
break
|
|
except Exception as e:
|
|
last_error = e
|
|
print(f" [{i}/{len(tasks)}] ❌ Clone attempt failed: {instance_id} -> {e}")
|
|
shutil.rmtree(target_dir, ignore_errors=True)
|
|
if attempt < 3:
|
|
sleep_s = 4 * attempt
|
|
print(f" [{i}/{len(tasks)}] ⏳ Retrying in {sleep_s}s...")
|
|
time.sleep(sleep_s)
|
|
|
|
if not cloned:
|
|
failures.append(f"{instance_id} ({last_error})")
|
|
|
|
if failures:
|
|
sample = ", ".join(failures[:3])
|
|
extra = f" (+{len(failures) - 3} more)" if len(failures) > 3 else ""
|
|
message = f"Repository prefetch failed for {len(failures)} task(s): {sample}{extra}"
|
|
if PREFETCH_STRICT:
|
|
raise RuntimeError(message)
|
|
print(f"⚠️ {message}")
|
|
print("⚠️ PREFETCH_STRICT=0; continuing with available repositories.")
|
|
print("⚠️ [Prefetch] Completed with partial failures.")
|
|
return
|
|
|
|
print("✅ [Prefetch] All repositories are ready.")
|
|
|
|
|
|
# Wait until a local TCP port starts accepting connections.
|
|
def wait_for_port(port: int, timeout: int = 10):
|
|
start = time.time()
|
|
while time.time() - start < timeout:
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
|
return
|
|
except OSError:
|
|
time.sleep(0.1)
|
|
print(f"⚠️ Warning: Proxy port {port} did not open in {timeout}s")
|
|
|
|
|
|
# Resolve mitmproxy CA cert path for Python HTTPS clients behind proxy.
|
|
def _resolve_mitm_ca_cert() -> Optional[Path]:
|
|
env_cert = os.environ.get("MITM_CA_CERT_PATH", "").strip()
|
|
candidates = []
|
|
if env_cert:
|
|
candidates.append(Path(env_cert).expanduser())
|
|
candidates.extend(
|
|
[
|
|
Path.home() / ".mitmproxy" / "mitmproxy-ca-cert.pem",
|
|
Path.home() / ".mitmproxy" / "mitmproxy-ca-cert.cer",
|
|
]
|
|
)
|
|
for cert_path in candidates:
|
|
if cert_path.exists():
|
|
return cert_path
|
|
return None
|
|
|
|
|
|
def _resolve_mitmdump_bin() -> str:
|
|
env_bin = os.environ.get("MITMDUMP_BIN", "").strip()
|
|
if env_bin:
|
|
return env_bin
|
|
|
|
# Prefer a dedicated local mitmproxy venv to avoid dependency conflicts
|
|
# with the main project environment.
|
|
project_root = Path(__file__).resolve().parents[1]
|
|
local_bin = project_root / ".mitmproxy-venv" / "bin" / "mitmdump"
|
|
if local_bin.exists():
|
|
return str(local_bin)
|
|
|
|
return "mitmdump"
|
|
|
|
|
|
# Start mitmproxy traffic recorder for the current task.
|
|
def start_mitmproxy(instance_id: str, mitm_script_path: str) -> subprocess.Popen:
|
|
print("🕵️ [2/4] Starting Traffic Recorder...")
|
|
env = os.environ.copy()
|
|
env["TASK_INSTANCE"] = instance_id
|
|
mitmdump_bin = _resolve_mitmdump_bin()
|
|
cmd = [mitmdump_bin, "--no-http2", "-s", mitm_script_path, "-q"]
|
|
try:
|
|
process = subprocess.Popen(cmd, env=env, preexec_fn=os.setsid)
|
|
except FileNotFoundError as exc:
|
|
raise RuntimeError(
|
|
"mitmdump not found. Install mitmproxy in a dedicated env with:\n"
|
|
" python3 -m venv .mitmproxy-venv\n"
|
|
" .mitmproxy-venv/bin/python -m pip install mitmproxy\n"
|
|
"or set MITMDUMP_BIN to an existing mitmdump path."
|
|
) from exc
|
|
wait_for_port(8080)
|
|
return process
|
|
|
|
|
|
# Check whether a server name exists in dict/list MCP server containers.
|
|
def _server_in_container(servers_obj, server_name: str) -> bool:
|
|
if isinstance(servers_obj, dict):
|
|
return server_name in servers_obj
|
|
if isinstance(servers_obj, list):
|
|
for item in servers_obj:
|
|
if isinstance(item, dict) and item.get("name") == server_name:
|
|
return True
|
|
return False
|
|
|
|
|
|
# Recursively scan a config tree for a specific MCP server entry.
|
|
def _has_mcp_server_config(node, server_name: str, parent_key: str = "") -> bool:
|
|
if isinstance(node, dict):
|
|
for key, value in node.items():
|
|
key_l = key.lower()
|
|
if key_l in {"mcpservers", "mcp_servers"} and _server_in_container(value, server_name):
|
|
return True
|
|
if (
|
|
parent_key.lower() == "mcp"
|
|
and key_l == "servers"
|
|
and _server_in_container(value, server_name)
|
|
):
|
|
return True
|
|
if _has_mcp_server_config(value, server_name, key):
|
|
return True
|
|
elif isinstance(node, list):
|
|
for item in node:
|
|
if _has_mcp_server_config(item, server_name, parent_key):
|
|
return True
|
|
return False
|
|
|
|
|
|
# Resolve Claude settings path that defines the required MCP server.
|
|
def resolve_claude_mcp_config(server_name: str) -> Optional[Path]:
|
|
env_path = os.environ.get("CLAUDE_MCP_CONFIG_PATH", "").strip()
|
|
candidates = []
|
|
if env_path:
|
|
candidates.append(Path(env_path).expanduser())
|
|
candidates.extend(
|
|
[
|
|
Path.home() / ".claude" / "settings.json",
|
|
Path.home() / ".config" / "claude" / "settings.json",
|
|
Path.home() / ".config" / "claude-code" / "settings.json",
|
|
Path.home() / ".claude.json",
|
|
]
|
|
)
|
|
|
|
seen = set()
|
|
for cfg_path in candidates:
|
|
cfg_path = cfg_path.resolve()
|
|
if str(cfg_path) in seen:
|
|
continue
|
|
seen.add(str(cfg_path))
|
|
if not cfg_path.exists():
|
|
continue
|
|
try:
|
|
data = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
continue
|
|
if _has_mcp_server_config(data, server_name):
|
|
return cfg_path
|
|
return None
|
|
|
|
|
|
def _normalize_mcp_servers(servers_obj) -> dict[str, dict]:
|
|
servers: dict[str, dict] = {}
|
|
if isinstance(servers_obj, dict):
|
|
for name, config in servers_obj.items():
|
|
if isinstance(name, str) and isinstance(config, dict):
|
|
servers[name] = config
|
|
elif isinstance(servers_obj, list):
|
|
for item in servers_obj:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
name = item.get("name")
|
|
if not isinstance(name, str) or not name:
|
|
continue
|
|
config = dict(item)
|
|
config.pop("name", None)
|
|
servers[name] = config
|
|
return servers
|
|
|
|
|
|
def _extract_mcp_servers(node, parent_key: str = "") -> dict[str, dict]:
|
|
servers: dict[str, dict] = {}
|
|
if isinstance(node, dict):
|
|
for key, value in node.items():
|
|
key_l = key.lower()
|
|
if key_l in {"mcpservers", "mcp_servers"}:
|
|
servers.update(_normalize_mcp_servers(value))
|
|
continue
|
|
if parent_key.lower() == "mcp" and key_l == "servers":
|
|
servers.update(_normalize_mcp_servers(value))
|
|
continue
|
|
servers.update(_extract_mcp_servers(value, key))
|
|
elif isinstance(node, list):
|
|
for item in node:
|
|
servers.update(_extract_mcp_servers(item, parent_key))
|
|
return servers
|
|
|
|
|
|
def build_strict_mcp_config_without_server(server_name: str) -> Optional[str]:
|
|
cfg_path = resolve_claude_mcp_config(server_name)
|
|
if not cfg_path:
|
|
return None
|
|
try:
|
|
data = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return None
|
|
servers = _extract_mcp_servers(data)
|
|
servers.pop(server_name, None)
|
|
return json.dumps({"mcpServers": servers}, separators=(",", ":"))
|
|
|
|
|
|
# Decide whether LEANN/MCP integration is available for this task repo.
|
|
def resolve_leann_integration(target_dir: Path) -> dict[str, str]:
|
|
leann_enabled = os.environ.get("LEANN_ENABLED", "1") != "0"
|
|
use_mcp = os.environ.get("LEANN_USE_MCP", "1") != "0"
|
|
mcp_server_name = os.environ.get("LEANN_MCP_SERVER", "leann-server")
|
|
leann_index_exists = (target_dir / ".leann").exists()
|
|
|
|
mode = "none"
|
|
if leann_enabled and leann_index_exists:
|
|
if use_mcp:
|
|
mode = "mcp"
|
|
print(
|
|
f" -> 🔍 LEANN MCP enabled (server: {mcp_server_name}, "
|
|
f"forced top_k={LEANN_TOP_K})"
|
|
)
|
|
else:
|
|
print(" -> ⚠️ LEANN_USE_MCP=0 but CLI mode is disabled; continuing without LEANN")
|
|
elif leann_enabled:
|
|
print(" -> ⚠️ LEANN enabled but no .leann index found; continuing without LEANN")
|
|
|
|
print(mode)
|
|
return {
|
|
"mode": mode,
|
|
"mcp_server_name": mcp_server_name,
|
|
}
|
|
|
|
|
|
# Build the initial Claude prompt from PROBLEM.md plus optional LEANN hints.
|
|
def build_initial_prompt(
|
|
target_dir: Path, leann_info: dict[str, str], instance_id: str = ""
|
|
) -> str:
|
|
problem_file = target_dir / "PROBLEM.md"
|
|
try:
|
|
problem_text = problem_file.read_text(encoding="utf-8").strip()
|
|
if leann_info.get("mode") == "mcp":
|
|
leann_info.get("mcp_server_name", "leann-server")
|
|
mcp_hint = (
|
|
f"Your LEANN index name is: {instance_id}\n"
|
|
"Use LEANN MCP as a cost-aware semantic entry-point router. "
|
|
"Before broad exploration, call leann_search once. "
|
|
"Use top_k=5 by default, top_k=10 only for clearly multi-hop tasks spanning multiple subsystems; never use top_k>10. "
|
|
"Use show_metadata=false.\n"
|
|
"After LEANN, open the top 1-3 likely implementation/source files first, preferring source files over tests/docs/examples/generated files. "
|
|
"Do not open many retrieved files just because they were returned. "
|
|
"After identifying the likely fix location, use targeted Grep to find existing callers, API/server entry points, tests, and config files that may need to be updated. "
|
|
"This targeted Grep is preferred over additional LEANN once concrete symbols, functions, file paths, config keys, or error strings are known.\n"
|
|
"Run at most one additional leann_search only if no plausible implementation entry point is found or a required subsystem is clearly missing. "
|
|
"The second query must be more specific, using concrete identifiers/literals found so far. "
|
|
"Use show_metadata=false unless metadata is necessary to disambiguate files. "
|
|
"Never run more than two leann_search calls total.\n"
|
|
)
|
|
return f"{mcp_hint}\n\n{problem_text}"
|
|
return problem_text
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to read problem statement from {problem_file}: {e}")
|
|
|
|
|
|
# Run Claude CLI autonomously in the prepared repository.
|
|
def run_claude_autonomous(
|
|
target_dir: Path,
|
|
model: str,
|
|
leann_info: Optional[dict[str, str]] = None,
|
|
instance_id: str = "",
|
|
):
|
|
print("🤖 [3/4] Launching Claude Code")
|
|
if (target_dir / ".claude").exists():
|
|
shutil.rmtree(target_dir / ".claude")
|
|
|
|
if leann_info is None:
|
|
leann_info = resolve_leann_integration(target_dir)
|
|
server_name = leann_info.get("mcp_server_name", "leann-server")
|
|
if leann_info.get("mode") == "mcp":
|
|
cfg_path = resolve_claude_mcp_config(server_name)
|
|
if not cfg_path:
|
|
raise RuntimeError(
|
|
f"LEANN_USE_MCP=1 but MCP server '{server_name}' was not found in Claude config. "
|
|
"Set CLAUDE_MCP_CONFIG_PATH or configure this server in Claude settings."
|
|
)
|
|
print(f" -> ✅ Claude MCP config found for '{server_name}': {cfg_path}")
|
|
initial_prompt = build_initial_prompt(target_dir, leann_info, instance_id=instance_id)
|
|
|
|
env = os.environ.copy()
|
|
env.update(
|
|
{
|
|
"HTTP_PROXY": "http://127.0.0.1:8080",
|
|
"HTTPS_PROXY": "http://127.0.0.1:8080",
|
|
"NODE_TLS_REJECT_UNAUTHORIZED": "0",
|
|
}
|
|
)
|
|
|
|
claude_args = ["-p", initial_prompt, "--dangerously-skip-permissions"]
|
|
if model:
|
|
claude_args.extend(["--model", model])
|
|
if leann_info.get("mode") != "mcp":
|
|
filtered_mcp_config = build_strict_mcp_config_without_server(server_name)
|
|
if filtered_mcp_config is not None:
|
|
claude_args.extend(
|
|
[
|
|
"--strict-mcp-config",
|
|
"--mcp-config",
|
|
filtered_mcp_config,
|
|
]
|
|
)
|
|
print(
|
|
f" -> 🚫 Non-LEANN mode; removed MCP server '{server_name}' from strict MCP config"
|
|
)
|
|
|
|
child = pexpect.spawn(
|
|
"claude", claude_args, cwd=target_dir, env=env, encoding="utf-8", timeout=1200
|
|
)
|
|
|
|
try:
|
|
while True:
|
|
index = child.expect(
|
|
[r"\[y/n\]", r"Allow execution", pexpect.EOF, pexpect.TIMEOUT], timeout=5
|
|
)
|
|
if index in [0, 1]:
|
|
child.sendline("y")
|
|
elif index == 2:
|
|
break
|
|
except Exception as e:
|
|
print(f"❌ Interaction error: {e}")
|
|
finally:
|
|
if child.isalive():
|
|
child.terminate(force=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Trajectory extraction: parse mitmproxy trace → ContextBench traj_data
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _parse_sse_tool_uses(text: str) -> list[dict]:
|
|
"""Reconstruct tool_use calls from a streamed SSE response body."""
|
|
tool_blocks: dict[int, dict] = {} # index -> {name, partial_json}
|
|
completed: list[dict] = []
|
|
|
|
for line in text.split("\n"):
|
|
if not line.startswith("data: "):
|
|
continue
|
|
data_str = line[6:]
|
|
try:
|
|
event = json.loads(data_str)
|
|
except Exception:
|
|
continue
|
|
|
|
event_type = event.get("type", "")
|
|
|
|
if event_type == "content_block_start":
|
|
idx = event.get("index", 0)
|
|
block = event.get("content_block", {})
|
|
if block.get("type") == "tool_use":
|
|
tool_blocks[idx] = {"name": block.get("name", ""), "partial_json": ""}
|
|
|
|
elif event_type == "content_block_delta":
|
|
idx = event.get("index", 0)
|
|
delta = event.get("delta", {})
|
|
if idx in tool_blocks and delta.get("type") == "input_json_delta":
|
|
tool_blocks[idx]["partial_json"] += delta.get("partial_json", "")
|
|
|
|
elif event_type == "content_block_stop":
|
|
idx = event.get("index", 0)
|
|
if idx in tool_blocks:
|
|
tool = tool_blocks.pop(idx)
|
|
try:
|
|
input_data = json.loads(tool["partial_json"]) if tool["partial_json"] else {}
|
|
except Exception:
|
|
input_data = {}
|
|
completed.append({"name": tool["name"], "input": input_data})
|
|
|
|
return completed
|
|
|
|
|
|
def _parse_json_tool_uses(text: str) -> list[dict]:
|
|
"""Extract tool_use calls from a non-streamed JSON response body."""
|
|
try:
|
|
resp = json.loads(text)
|
|
except Exception:
|
|
return []
|
|
completed = []
|
|
for block in resp.get("content", []):
|
|
if block.get("type") == "tool_use":
|
|
completed.append({"name": block.get("name", ""), "input": block.get("input", {})})
|
|
return completed
|
|
|
|
|
|
def _make_relative(file_path: str, target_dir: Path) -> str:
|
|
"""Convert an absolute file path to one relative to target_dir."""
|
|
try:
|
|
return str(Path(file_path).relative_to(target_dir.resolve()))
|
|
except ValueError:
|
|
return file_path
|
|
|
|
|
|
def _is_leann_search_tool(name: str) -> bool:
|
|
return bool(name) and (
|
|
name == "leann_search" or name.endswith("__leann_search") or "leann_search" in name
|
|
)
|
|
|
|
|
|
def _extract_trace_artifacts(
|
|
trace_path: Path,
|
|
target_dir: Path,
|
|
since_timestamp: Optional[float] = None,
|
|
) -> dict:
|
|
"""
|
|
Parse the mitmproxy JSONL trace once and derive both ContextBench trajectory data
|
|
and LEANN usage statistics.
|
|
|
|
Each Anthropic /messages response that contains tool_use calls becomes one pred_step.
|
|
Read calls with line offsets are mapped to line spans; other file-touching tool calls
|
|
(Glob, Grep, Bash cat/head) contribute to pred_files only.
|
|
"""
|
|
pred_steps: list[dict] = []
|
|
leann_search_calls = 0
|
|
|
|
if not trace_path.exists():
|
|
print(f"⚠️ Trace file not found: {trace_path}")
|
|
return {
|
|
"traj_data": _empty_traj_data(),
|
|
"leann_tool_used": False,
|
|
"leann_search_calls": 0,
|
|
}
|
|
|
|
with open(trace_path, encoding="utf-8") as f:
|
|
for raw_line in f:
|
|
try:
|
|
entry = json.loads(raw_line)
|
|
except Exception:
|
|
continue
|
|
|
|
ts = entry.get("timestamp")
|
|
if (
|
|
since_timestamp is not None
|
|
and isinstance(ts, (int, float))
|
|
and ts < since_timestamp
|
|
):
|
|
continue
|
|
|
|
url = entry.get("request", {}).get("url", "")
|
|
if "anthropic.com" not in url or "/messages" not in url:
|
|
continue
|
|
|
|
response_text = entry.get("response", {}).get("text", "") or ""
|
|
if not response_text:
|
|
continue
|
|
|
|
# Support both streaming (SSE) and non-streaming responses.
|
|
if "data: " in response_text:
|
|
tool_uses = _parse_sse_tool_uses(response_text)
|
|
else:
|
|
tool_uses = _parse_json_tool_uses(response_text)
|
|
|
|
if not tool_uses:
|
|
continue
|
|
|
|
step_files: list[str] = []
|
|
step_spans: dict[str, list[dict]] = {}
|
|
|
|
for tool in tool_uses:
|
|
name = tool.get("name", "")
|
|
inp = tool.get("input", {}) or {}
|
|
|
|
if _is_leann_search_tool(name):
|
|
leann_search_calls += 1
|
|
|
|
if name == "Read":
|
|
raw_path = inp.get("file_path", "")
|
|
if not raw_path:
|
|
continue
|
|
rel = _make_relative(raw_path, target_dir)
|
|
if rel not in step_files:
|
|
step_files.append(rel)
|
|
offset = inp.get("offset")
|
|
limit = inp.get("limit")
|
|
if offset is not None:
|
|
start = int(offset)
|
|
end = start + int(limit) if limit is not None else start + 2000
|
|
step_spans.setdefault(rel, []).append({"start": start, "end": end})
|
|
|
|
elif name in ("Grep", "Glob"):
|
|
# These don't return exact spans; record any explicit path argument.
|
|
raw_path = inp.get("path", "")
|
|
if raw_path and not raw_path.endswith(("*", "/")):
|
|
rel = _make_relative(raw_path, target_dir)
|
|
if rel not in step_files:
|
|
step_files.append(rel)
|
|
|
|
elif name == "Bash":
|
|
# Heuristic: detect `cat`, `head`, `tail` calls on files.
|
|
cmd = inp.get("command", "") or ""
|
|
for m in re.finditer(
|
|
r"(?:cat|head|tail|sed|awk)\s+(?:-[^\s]+\s+)*([^\s|><;]+)", cmd
|
|
):
|
|
candidate = m.group(1)
|
|
if "/" in candidate or candidate.endswith(
|
|
(".py", ".go", ".ts", ".js", ".java", ".c", ".cpp", ".rs")
|
|
):
|
|
rel = _make_relative(candidate, target_dir)
|
|
if rel not in step_files:
|
|
step_files.append(rel)
|
|
|
|
if step_files:
|
|
pred_steps.append(
|
|
{
|
|
"files": step_files,
|
|
"spans": step_spans,
|
|
"symbols": {},
|
|
}
|
|
)
|
|
|
|
# Aggregate across all steps.
|
|
all_files: list[str] = []
|
|
all_spans: dict[str, list[dict]] = {}
|
|
for step in pred_steps:
|
|
for f in step["files"]:
|
|
if f not in all_files:
|
|
all_files.append(f)
|
|
for f, spans in step["spans"].items():
|
|
all_spans.setdefault(f, []).extend(spans)
|
|
|
|
return {
|
|
"traj_data": {
|
|
"pred_steps": pred_steps,
|
|
"pred_files": all_files,
|
|
"pred_spans": all_spans,
|
|
"pred_symbols": {},
|
|
},
|
|
"leann_tool_used": leann_search_calls > 0,
|
|
"leann_search_calls": leann_search_calls,
|
|
}
|
|
|
|
|
|
def extract_trajectory_from_traces(
|
|
trace_path: Path,
|
|
target_dir: Path,
|
|
since_timestamp: Optional[float] = None,
|
|
) -> dict:
|
|
return _extract_trace_artifacts(
|
|
trace_path,
|
|
target_dir,
|
|
since_timestamp=since_timestamp,
|
|
)["traj_data"]
|
|
|
|
|
|
def detect_messages_api_error(
|
|
trace_path: Path,
|
|
since_timestamp: Optional[float] = None,
|
|
) -> Optional[str]:
|
|
"""Return a concise API error message if /v1/messages requests failed."""
|
|
if not trace_path.exists():
|
|
return None
|
|
try:
|
|
with open(trace_path, encoding="utf-8") as f:
|
|
for raw_line in f:
|
|
try:
|
|
entry = json.loads(raw_line)
|
|
except Exception:
|
|
continue
|
|
ts = entry.get("timestamp")
|
|
if (
|
|
since_timestamp is not None
|
|
and isinstance(ts, (int, float))
|
|
and ts < since_timestamp
|
|
):
|
|
continue
|
|
|
|
url = entry.get("request", {}).get("url", "")
|
|
if "/v1/messages" not in url:
|
|
continue
|
|
response = entry.get("response", {}) or {}
|
|
status_code = response.get("status_code")
|
|
if not isinstance(status_code, int) or status_code < 400:
|
|
continue
|
|
text = response.get("text", "") or ""
|
|
err_type = ""
|
|
err_message = ""
|
|
try:
|
|
payload = json.loads(text)
|
|
error_obj = payload.get("error", {}) if isinstance(payload, dict) else {}
|
|
if isinstance(error_obj, dict):
|
|
err_type = str(error_obj.get("type", "") or "")
|
|
err_message = str(error_obj.get("message", "") or "")
|
|
except Exception:
|
|
pass
|
|
detail = f"/v1/messages returned HTTP {status_code}"
|
|
if err_type:
|
|
detail += f" ({err_type})"
|
|
if err_message:
|
|
detail += f": {err_message}"
|
|
return detail
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
def _empty_traj_data() -> dict:
|
|
return {"pred_steps": [], "pred_files": [], "pred_spans": {}, "pred_symbols": {}}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Usage tracking (mirrored from swebench runner)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _blank_usage() -> dict:
|
|
return {
|
|
"input_tokens": 0,
|
|
"output_tokens": 0,
|
|
"cache_creation_tokens": 0,
|
|
"cache_read_tokens": 0,
|
|
"total_tokens": 0,
|
|
"cost_usd": 0.0,
|
|
}
|
|
|
|
|
|
def _normalize_usage(raw: Optional[dict]) -> dict:
|
|
base = _blank_usage()
|
|
if not isinstance(raw, dict):
|
|
return base
|
|
base["input_tokens"] = int(raw.get("inputTokens", 0) or 0)
|
|
base["output_tokens"] = int(raw.get("outputTokens", 0) or 0)
|
|
base["cache_creation_tokens"] = int(raw.get("cacheCreationTokens", 0) or 0)
|
|
base["cache_read_tokens"] = int(raw.get("cacheReadTokens", 0) or 0)
|
|
base["total_tokens"] = int(raw.get("totalTokens", 0) or 0)
|
|
base["cost_usd"] = float(raw.get("totalCost", 0) or 0.0)
|
|
return base
|
|
|
|
|
|
def _usage_delta(before: dict, after: dict) -> dict:
|
|
return {
|
|
"input_tokens": after["input_tokens"] - before["input_tokens"],
|
|
"output_tokens": after["output_tokens"] - before["output_tokens"],
|
|
"cache_creation_tokens": after["cache_creation_tokens"] - before["cache_creation_tokens"],
|
|
"cache_read_tokens": after["cache_read_tokens"] - before["cache_read_tokens"],
|
|
"total_tokens": after["total_tokens"] - before["total_tokens"],
|
|
"cost_usd": round(after["cost_usd"] - before["cost_usd"], 6),
|
|
}
|
|
|
|
|
|
def _has_positive_delta(usage: dict) -> bool:
|
|
return (
|
|
usage["total_tokens"] > 0
|
|
or usage["input_tokens"] > 0
|
|
or usage["output_tokens"] > 0
|
|
or usage["cache_creation_tokens"] > 0
|
|
or usage["cache_read_tokens"] > 0
|
|
or usage["cost_usd"] > 0
|
|
)
|
|
|
|
|
|
def _format_usage(usage: dict) -> str:
|
|
return (
|
|
f"input: {usage['input_tokens']:,}, output: {usage['output_tokens']:,}, "
|
|
f"cache_read: {usage['cache_read_tokens']:,}, cache_create: {usage['cache_creation_tokens']:,} "
|
|
f"total: {usage['total_tokens']:,}, cost: ${usage['cost_usd']:.4f}"
|
|
)
|
|
|
|
|
|
def append_usage_log(line: str) -> None:
|
|
if not USAGE_LOG_FILE:
|
|
return
|
|
log_path = Path(USAGE_LOG_FILE).expanduser()
|
|
try:
|
|
if log_path.parent and str(log_path.parent) != ".":
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with log_path.open("a", encoding="utf-8") as f:
|
|
f.write(f"{line}\n")
|
|
except Exception as e:
|
|
print(f"⚠️ Failed to write usage log {log_path}: {e}")
|
|
|
|
|
|
def append_task_metrics(entry: dict) -> None:
|
|
if not TASK_METRICS_FILE:
|
|
return
|
|
metrics_path = Path(TASK_METRICS_FILE).expanduser()
|
|
try:
|
|
if metrics_path.parent and str(metrics_path.parent) != ".":
|
|
metrics_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with metrics_path.open("a", encoding="utf-8") as f:
|
|
f.write(json.dumps(entry) + "\n")
|
|
except Exception as e:
|
|
print(f"⚠️ Failed to write task metrics {metrics_path}: {e}")
|
|
|
|
|
|
def get_ccusage_sessions() -> Optional[dict[str, dict]]:
|
|
try:
|
|
result = subprocess.run(
|
|
["npx", "ccusage", "session", "--json", "--offline"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
if result.returncode != 0:
|
|
stderr = (result.stderr or "").strip()
|
|
raise RuntimeError(stderr or "ccusage returned non-zero exit code")
|
|
data = json.loads(result.stdout or "{}")
|
|
sessions = data.get("sessions", [])
|
|
if not isinstance(sessions, list):
|
|
return {}
|
|
by_session: dict[str, dict] = {}
|
|
for row in sessions:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
session_id = str(row.get("sessionId", "")).strip()
|
|
if not session_id:
|
|
continue
|
|
by_session[session_id] = _normalize_usage(row)
|
|
return by_session
|
|
except Exception as e:
|
|
print(f"⚠️ Failed to get ccusage sessions: {e}")
|
|
return None
|
|
|
|
|
|
def compute_usage_diff(
|
|
before_sessions: Optional[dict[str, dict]],
|
|
after_sessions: Optional[dict[str, dict]],
|
|
instance_id: str,
|
|
) -> Optional[dict]:
|
|
if before_sessions is None or after_sessions is None:
|
|
return None
|
|
|
|
changed_sessions: dict[str, dict] = {}
|
|
all_session_ids = set(before_sessions.keys()) | set(after_sessions.keys())
|
|
for session_id in all_session_ids:
|
|
before = before_sessions.get(session_id, _blank_usage())
|
|
after = after_sessions.get(session_id, _blank_usage())
|
|
delta = _usage_delta(before, after)
|
|
if _has_positive_delta(delta):
|
|
changed_sessions[session_id] = delta
|
|
|
|
if not changed_sessions:
|
|
return None
|
|
|
|
instance_lower = instance_id.lower()
|
|
subagent_ids = [
|
|
sid for sid in changed_sessions if sid.lower() == "subagents" or "subagent" in sid.lower()
|
|
]
|
|
non_subagent_items = [
|
|
(sid, usage) for sid, usage in changed_sessions.items() if sid not in subagent_ids
|
|
]
|
|
matched_non_subagent = [
|
|
(sid, usage)
|
|
for sid, usage in non_subagent_items
|
|
if sid.lower() in instance_lower or instance_lower in sid.lower()
|
|
]
|
|
primary_candidates = matched_non_subagent or non_subagent_items
|
|
if not primary_candidates:
|
|
return None
|
|
|
|
primary_session_id, session_usage = max(
|
|
primary_candidates, key=lambda x: (x[1]["total_tokens"], x[1]["cost_usd"])
|
|
)
|
|
return {
|
|
"session_id": primary_session_id,
|
|
"input_tokens": session_usage["input_tokens"],
|
|
"output_tokens": session_usage["output_tokens"],
|
|
"cache_creation_tokens": session_usage["cache_creation_tokens"],
|
|
"cache_read_tokens": session_usage["cache_read_tokens"],
|
|
"total_tokens": session_usage["total_tokens"],
|
|
"cost_usd": session_usage["cost_usd"],
|
|
"session_usage": session_usage,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main task runner
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def run_single_task(
|
|
instance_id: str,
|
|
repo_url: str,
|
|
work_root: Path,
|
|
mitm_script_path: str,
|
|
trace_dir: Path,
|
|
model: str = "",
|
|
task: Optional[dict] = None,
|
|
) -> tuple:
|
|
"""
|
|
Run one ContextBench instance end-to-end.
|
|
|
|
Returns: (patch, latency_seconds, agent_seconds, traj_data, usage)
|
|
- patch : git diff string (the model's code changes)
|
|
- latency_seconds : end-to-end wall-clock seconds
|
|
- agent_seconds : Claude agent runtime only
|
|
- traj_data : ContextBench trajectory dict (pred_steps, pred_files, …)
|
|
- usage : token/cost usage dict, or None
|
|
"""
|
|
print(f"\n🌊 Starting Claude Pipeline for {instance_id}")
|
|
work_root = Path(work_root)
|
|
trace_dir = Path(trace_dir)
|
|
mitm_proc = None
|
|
start_time = time.time()
|
|
usage_before: Optional[dict[str, dict]] = None
|
|
usage: Optional[dict] = None
|
|
status = "failed"
|
|
error_message: Optional[str] = None
|
|
claude_start_ts: Optional[float] = None
|
|
claude_end_ts: Optional[float] = None
|
|
leann_mode: Optional[str] = None
|
|
leann_tool_used = False
|
|
leann_search_calls = 0
|
|
|
|
try:
|
|
target_dir = setup_task_environment(instance_id, repo_url, work_root, task=task)
|
|
leann_info = resolve_leann_integration(target_dir)
|
|
leann_mode = leann_info.get("mode")
|
|
mitm_proc = start_mitmproxy(instance_id, str(mitm_script_path))
|
|
|
|
usage_before = get_ccusage_sessions()
|
|
claude_start_ts = time.time()
|
|
run_claude_autonomous(target_dir, model, leann_info=leann_info, instance_id=instance_id)
|
|
claude_end_ts = time.time()
|
|
usage_after = get_ccusage_sessions()
|
|
|
|
trace_path = trace_dir / f"{instance_id}_trace.jsonl"
|
|
api_error = detect_messages_api_error(trace_path, since_timestamp=claude_start_ts)
|
|
if api_error:
|
|
raise RuntimeError(
|
|
f"Claude API request failed for {instance_id}: {api_error}. "
|
|
"Set MODEL/CLAUDE_MODEL to an available model for your account."
|
|
)
|
|
|
|
usage = compute_usage_diff(usage_before, usage_after, instance_id=instance_id)
|
|
if usage:
|
|
task_usage_line = (
|
|
f"💰 Task session usage ({usage['session_id']}) — "
|
|
f"{_format_usage(usage['session_usage'])}"
|
|
)
|
|
print(task_usage_line)
|
|
|
|
print(f"📍 [4/4] Extracting trajectory from trace: {trace_path.name}")
|
|
trace_artifacts = _extract_trace_artifacts(
|
|
trace_path,
|
|
target_dir,
|
|
since_timestamp=claude_start_ts,
|
|
)
|
|
traj_data = trace_artifacts["traj_data"]
|
|
leann_tool_used = bool(trace_artifacts["leann_tool_used"])
|
|
leann_search_calls = int(trace_artifacts["leann_search_calls"])
|
|
print(
|
|
f" -> {len(traj_data['pred_steps'])} steps, "
|
|
f"{len(traj_data['pred_files'])} unique files, "
|
|
f"leann_calls={leann_search_calls}"
|
|
)
|
|
|
|
patch = get_git_diff(target_dir)
|
|
elapsed = time.time() - start_time
|
|
agent_elapsed = (
|
|
max(0.0, claude_end_ts - claude_start_ts)
|
|
if claude_start_ts is not None and claude_end_ts is not None
|
|
else None
|
|
)
|
|
print(f"⏱️ Task completed in {elapsed:.1f}s ({elapsed / 60:.1f}min)")
|
|
status = "succeeded"
|
|
return patch, elapsed, agent_elapsed, traj_data, usage
|
|
except Exception as e:
|
|
error_message = str(e)
|
|
raise
|
|
|
|
finally:
|
|
elapsed = time.time() - start_time
|
|
agent_elapsed = None
|
|
if claude_start_ts is not None:
|
|
agent_end = claude_end_ts if claude_end_ts is not None else time.time()
|
|
agent_elapsed = max(0.0, agent_end - claude_start_ts)
|
|
if usage is None and usage_before is not None:
|
|
usage_after = get_ccusage_sessions()
|
|
usage = compute_usage_diff(usage_before, usage_after, instance_id=instance_id)
|
|
|
|
metrics_entry: dict = {
|
|
"instance_id": instance_id,
|
|
"repo_url": repo_url,
|
|
"model": model or "cli-default",
|
|
"status": status,
|
|
"latency_seconds": round(elapsed, 1),
|
|
"timestamp_unix": int(time.time()),
|
|
"token_usage": usage,
|
|
"leann_mode": leann_mode,
|
|
"leann_tool_used": leann_tool_used,
|
|
"leann_search_calls": leann_search_calls,
|
|
}
|
|
if agent_elapsed is not None:
|
|
metrics_entry["agent_seconds"] = round(agent_elapsed, 1)
|
|
if error_message:
|
|
metrics_entry["error"] = error_message
|
|
append_task_metrics(metrics_entry)
|
|
|
|
metric_line = (
|
|
f"📊 Task metrics ({instance_id}) — status={status}, "
|
|
f"agent={agent_elapsed:.1f}s, latency={elapsed:.1f}s"
|
|
if agent_elapsed is not None
|
|
else f"📊 Task metrics ({instance_id}) — status={status}, latency={elapsed:.1f}s"
|
|
)
|
|
if usage:
|
|
metric_line += (
|
|
f", session={usage['session_id']}, {_format_usage(usage['session_usage'])}"
|
|
)
|
|
else:
|
|
metric_line += ", token usage unavailable"
|
|
metric_line += (
|
|
f", leann_mode={leann_mode}, "
|
|
f"leann_tool_used={str(leann_tool_used).lower()}, "
|
|
f"leann_search_calls={leann_search_calls}"
|
|
)
|
|
if error_message:
|
|
metric_line += f", error={error_message}"
|
|
print(metric_line)
|
|
append_usage_log(metric_line)
|
|
|
|
if mitm_proc:
|
|
os.killpg(os.getpgid(mitm_proc.pid), signal.SIGTERM)
|