Add Handbook.md benchmark tasks

This commit is contained in:
DerekSurge
2026-06-24 12:44:34 -07:00
commit 72f9a65917
1800 changed files with 323589 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# agent-harness
Internal Harbor agent package for the SOP benchmark distribution. See the
top-level [README](../README.md) for setup and smoke-test instructions.
+21
View File
@@ -0,0 +1,21 @@
[project]
name = "agent-harness"
version = "0.1.0"
description = "Harbor agent harness for Syntara tasks"
requires-python = ">=3.12"
dependencies = [
"harbor",
"deepdiff",
"litellm",
# NOTE: openhands-sdk is NOT a host dep. openhands_agent.py runs the agent
# loop in-container via the vendored openhands_runner.py, installed into an
# isolated venv baked into the syntara image (see docker/Dockerfile, which
# pins the SDK). The host side only does exec/upload/download.
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/agent_harness"]
@@ -0,0 +1,254 @@
"""Harbor agent that runs the OpenHands SDK agent loop *inside* the task
container.
It execs the vendored Python OpenHands runner (``/app/openhands-runner``)
in-container, driving the agent loop through the ``BaseEnvironment``
exec/upload/download contract. That makes it work on any harbor environment —
including Modal sandboxes, where the host can't reach the in-sandbox MCP port.
This agent targets the **single-container** task shape only (as staged for
Modal, and its local equivalent). It
launches the MCP proxy in-container itself; it does not handle the compose
host-side path.
Container prerequisites (baked into the ``syntara`` base image at build time):
- the runner at ``/app/openhands-runner/openhands_runner.py``
- an isolated venv with ``openhands-sdk`` at ``/app/openhands-runner/.venv``
- the syntara MCP proxy launcher at ``/app/scripts/start.sh``
Usage:
harbor run -p .modal_tasks/some_task \
--agent-import-path agent_harness.openhands_agent:OpenHandsAgent \
-m anthropic/claude-opus-4-8 -e modal --ek registry_secret=aws-ecr-secret
"""
from __future__ import annotations
import asyncio
import json
import os
import shlex
import time
from pathlib import Path
from harbor.agents.base import BaseAgent
from harbor.environments.base import BaseEnvironment
from harbor.models.agent.context import AgentContext
MAX_TOOL_CALLS = 200
# In-container locations.
RUNNER_DIR = "/app/openhands-runner"
RUNNER_PATH = f"{RUNNER_DIR}/openhands_runner.py"
RUNNER_PYTHON = f"{RUNNER_DIR}/.venv/bin/python"
PROXY_START_CMD = "/app/scripts/start.sh --method http --port 8000"
MCP_URL = "http://localhost:8000/mcp"
HEALTH_URL = "http://localhost:8000/health"
REMOTE_LOG_DIR = "/logs/agent"
REMOTE_CONFIG_PATH = "/tmp/openhands_run_config.json"
REMOTE_TRAJECTORY_PATH = f"{REMOTE_LOG_DIR}/trajectory.json"
REMOTE_RUNNER_LOG_PATH = f"{REMOTE_LOG_DIR}/run-openhands.log"
REMOTE_PROXY_LOG_PATH = f"{REMOTE_LOG_DIR}/mcp-proxy.log"
# Timeouts (seconds).
PROBE_TIMEOUT = 15
HEALTH_TIMEOUT = 180
# Generous ceiling for the runner exec; the effective limit is harbor's [agent]
# timeout_sec (× --agent-timeout-multiplier).
AGENT_EXEC_TIMEOUT = 7200
# Host env vars forwarded into the container for the runner. The runner resolves
# LLM credentials from these per provider (see openhands_runner._resolve_llm_auth).
FORWARDED_ENV_VARS = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"OPENROUTER_API_KEY",
"GEMINI_API_KEY",
)
def _forwarded_env() -> dict[str, str]:
return {k: v for k in FORWARDED_ENV_VARS if (v := os.environ.get(k))}
FORWARDED_LLM_KWARGS = (
"reasoning_effort",
"reasoning_summary",
# Opt-in: --ak log_completions=true makes the runner dump each LLM call's
# request payload (including the kwargs actually sent, e.g. reasoning_effort)
# into the trial's agent/ log dir for verification.
"log_completions",
)
class OpenHandsAgent(BaseAgent):
"""Runs a single OpenHands SDK agent loop inside the task container."""
def __init__(self, *args, **kwargs) -> None:
# Pull the allow-listed LLM kwargs out of harbor's ``--ak`` kwargs;
# BaseAgent would otherwise drop them into **kwargs and discard them.
# Forwarded to the runner via the run config (see run()).
self.llm_kwargs = {
k: kwargs.pop(k) for k in FORWARDED_LLM_KWARGS if k in kwargs
}
super().__init__(*args, **kwargs)
@staticmethod
def name() -> str:
return "openhands-agent"
def version(self) -> str | None:
return "0.2.0"
async def setup(self, environment: BaseEnvironment) -> None:
probe = await environment.exec(
f"test -f {shlex.quote(RUNNER_PATH)} && test -x {shlex.quote(RUNNER_PYTHON)}",
timeout_sec=PROBE_TIMEOUT,
)
if probe.return_code != 0:
raise RuntimeError(
f"container is missing the OpenHands runner at {RUNNER_DIR}. "
"Rebuild the base image so the runner is staged into it."
)
if not await self._proxy_healthy(environment):
self.logger.info("Launching MCP proxy in-container")
launch = (
f"mkdir -p {REMOTE_LOG_DIR} && "
f"nohup {PROXY_START_CMD} </dev/null "
f">>{REMOTE_PROXY_LOG_PATH} 2>&1 &"
)
result = await environment.exec(launch, timeout_sec=PROBE_TIMEOUT)
if result.return_code != 0:
raise RuntimeError(
f"failed to launch MCP proxy (rc={result.return_code}): "
f"{(result.stderr or '').strip()}"
)
await self._wait_for_proxy(environment)
async def run(
self,
instruction: str,
environment: BaseEnvironment,
context: AgentContext,
) -> None:
task_dir = Path(environment.environment_dir).parent
sp_path = task_dir / "system_prompt.md"
system_prompt = sp_path.read_text()
config = {
"instruction": instruction,
"systemPrompt": system_prompt,
"model": self.model_name,
"mcpUrl": MCP_URL,
"maxToolCalls": MAX_TOOL_CALLS,
"llmKwargs": self.llm_kwargs,
# Host-visible log dir, so opt-in completion logs land where the
# trial syncs them back (see openhands_runner log_completions).
"logDir": REMOTE_LOG_DIR,
}
config_path = (self.logs_dir / "openhands_run_config.json").resolve()
config_path.write_text(json.dumps(config, indent=2))
await environment.upload_file(str(config_path), REMOTE_CONFIG_PATH)
self.logger.info("Running OpenHands runner in-container (model=%s)", self.model_name)
# The runner writes the trajectory JSON to a dedicated file (argv[2]);
# the OpenHands SDK's human-readable transcript + our logs go to stdout/
# stderr, which we capture together in the runner log. Keeping the two
# streams separate is essential — the SDK prints to stdout, so a stdout
# redirect would corrupt the machine-readable trajectory.
command = (
f"mkdir -p {REMOTE_LOG_DIR} && "
f"{shlex.quote(RUNNER_PYTHON)} {shlex.quote(RUNNER_PATH)} "
f"{shlex.quote(REMOTE_CONFIG_PATH)} {shlex.quote(REMOTE_TRAJECTORY_PATH)} "
f"> {REMOTE_RUNNER_LOG_PATH} 2>&1"
)
result = await environment.exec(
command=command,
env=_forwarded_env(),
timeout_sec=AGENT_EXEC_TIMEOUT,
)
runner_log = await self._download_quiet(
environment, REMOTE_RUNNER_LOG_PATH, "run-openhands.log"
)
trajectory = await self._download_quiet(
environment, REMOTE_TRAJECTORY_PATH, "trajectory.json"
)
if result.return_code != 0:
log_tail = "<runner log unavailable>"
if runner_log is not None:
log_tail = runner_log.read_text(errors="replace")[-2000:]
raise RuntimeError(
f"OpenHands runner exited {result.return_code}\n"
f"--- exec stderr (last 2000) ---\n{(result.stderr or '')[-2000:]}\n"
f"--- runner log tail ---\n{log_tail}"
)
if trajectory is None or trajectory.stat().st_size == 0:
raise RuntimeError(
"OpenHands runner produced no trajectory output; "
f"see {self.logs_dir} for the runner log"
)
traj = json.loads(trajectory.read_text())
context.n_input_tokens = traj.get("input_tokens")
context.n_output_tokens = traj.get("output_tokens")
context.n_cache_tokens = traj.get("cache_tokens")
context.cost_usd = traj.get("cost_usd")
context.metadata = {
"agent_id": traj.get("agent_id"),
"model": traj.get("model"),
"n_tool_calls": traj.get("n_tool_calls"),
"stopped_reason": traj.get("stopped_reason"),
"final_output_chars": len(traj.get("final_output") or ""),
"error_message": traj.get("error_message"),
}
# Surface genuine agent-loop errors as a trial exception so broken runs
# aren't averaged into results.
if traj.get("stopped_reason") == "error":
raise RuntimeError(
"OpenHands agent loop ended with an error after "
f"{traj.get('n_tool_calls') or 0} tool call(s): "
f"{traj.get('error_message') or '<no error message>'}"
)
async def _proxy_healthy(self, environment: BaseEnvironment) -> bool:
result = await environment.exec(
f"curl -fsS -o /dev/null --max-time 5 {shlex.quote(HEALTH_URL)}",
timeout_sec=PROBE_TIMEOUT,
)
return result.return_code == 0
async def _wait_for_proxy(self, environment: BaseEnvironment) -> None:
deadline = time.monotonic() + HEALTH_TIMEOUT
while time.monotonic() < deadline:
if await self._proxy_healthy(environment):
self.logger.info("MCP proxy healthy")
return
await asyncio.sleep(2)
tail = await environment.exec(
f"tail -c 2000 {REMOTE_PROXY_LOG_PATH} 2>/dev/null || true",
timeout_sec=PROBE_TIMEOUT,
)
raise RuntimeError(
f"MCP proxy at {HEALTH_URL} never became healthy within {HEALTH_TIMEOUT}s"
f"\n--- proxy log tail ---\n{(tail.stdout or '').strip() or '<no log>'}"
)
async def _download_quiet(
self, environment: BaseEnvironment, remote_path: str, filename: str
) -> Path | None:
"""Download a container file into logs_dir; None if unavailable."""
target = self.logs_dir / filename
try:
await environment.download_file(remote_path, target)
except Exception as e:
self.logger.warning("could not download %s: %s", remote_path, e)
return None
return target if target.exists() else None
@@ -0,0 +1,359 @@
"""In-container OpenHands agent runner.
Runs the OpenHands SDK agent loop *inside* the task container, talking to the
syntara MCP proxy at ``localhost:8000/mcp`` (reachable in-container on every
harbor environment, including Modal sandboxes where the host can't reach the
port).
Baked into the ``syntara`` image at build time (this
single file is staged to ``/app/openhands-runner/openhands_runner.py`` next to an
isolated venv holding ``openhands-sdk``). The host-side ``OpenHandsAgent`` uploads
a config JSON, execs this script, and downloads the trajectory it writes.
Contract:
- argv[1] = path to a config JSON: {instruction, systemPrompt, model, mcpUrl,
maxToolCalls}.
- argv[2] = path to write the result/trajectory JSON to. This is a **dedicated
file**, NOT stdout: the OpenHands SDK prints a human-readable transcript to
stdout, so the machine-readable result must go to its own file for the host
to parse it.
- The process exits 0 even when the agent loop errored — the error is recorded
in the trajectory's ``stopped_reason``/``error_message`` so the host decides
whether to fail the trial.
"""
from __future__ import annotations
import json
import os
import sys
from typing import Any
MCP_TOOL_TIMEOUT = 300
WORKSPACE_DIR = "/tmp/openhands_workspace"
STATE_DIR = "/tmp/openhands_state"
def _log(msg: str) -> None:
print(msg, file=sys.stderr, flush=True)
def _resolve_llm_kwargs(config: dict[str, Any]) -> tuple[dict[str, Any], str | None]:
"""Split LLM kwargs into ``(constructor_kwargs, reasoning_effort)``.
``reasoning_effort`` is pulled out so it can be applied post-construction.
openhands.sdk otherwise won't accept `max` as a value.
"""
llm_kwargs = dict(config.get("llmKwargs") or {})
effort = llm_kwargs.pop("reasoning_effort", None)
if effort is not None:
effort = str(effort).strip().lower() or None
return llm_kwargs, effort
def _resolve_llm_auth(model_name: str) -> tuple[str, str | None, str | None]:
"""Map harbor's ``provider/model`` id to a litellm (model, api_key, base_url).
``openrouter/anthropic/<model>`` is rewritten to ``anthropic/<model>`` (dots
-> dashes) so Anthropic models route through the Anthropic provider, never
OpenRouter. To send Anthropic traffic through an LLM proxy, point
``ANTHROPIC_BASE_URL`` at the proxy endpoint and set ``ANTHROPIC_API_KEY`` to
the proxy token.
"""
if "/" not in model_name:
raise ValueError(
f"model must be in 'provider/model' form; got {model_name!r}"
)
provider, model = model_name.split("/", 1)
if provider == "openrouter" and model.startswith("anthropic/"):
provider = "anthropic"
model = model.split("/", 1)[1].replace(".", "-")
if provider == "anthropic":
litellm_model = f"anthropic/{model}"
return (
litellm_model,
os.environ.get("ANTHROPIC_API_KEY"),
os.environ.get("ANTHROPIC_BASE_URL"),
)
_provider_key_env = {
"openai": "OPENAI_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
"google": "GEMINI_API_KEY",
"gemini": "GEMINI_API_KEY",
}
api_key = os.environ.get(_provider_key_env.get(provider, ""))
base_url = os.environ.get("OPENAI_BASE_URL") if provider == "openai" else None
return model_name, api_key, base_url
def _usage(*llms: Any) -> dict[str, int | float | None]:
"""Pull token/cost totals from one or more LLMs' metrics, tolerant of API drift.
Sums across every LLM passed in (e.g. the agent loop plus the condenser) so
the reported cost and cache usage reflect *all* model traffic for the trial,
not just the main agent call. Each field stays ``None`` if no LLM exposed it,
so a metrics-shape change degrades to null rather than crashing the trial.
"""
input_tokens = output_tokens = cache_tokens = None
cost: float | None = None
def _accumulate(current: int | float | None, value: Any) -> int | float | None:
if not isinstance(value, (int, float)):
return current
return value if current is None else current + value
for llm in llms:
metrics = getattr(llm, "metrics", None)
if metrics is None:
continue
cost = _accumulate(cost, getattr(metrics, "accumulated_cost", None))
token_usage = getattr(metrics, "accumulated_token_usage", None)
if token_usage is None:
continue
input_tokens = _accumulate(input_tokens, getattr(token_usage, "prompt_tokens", None))
output_tokens = _accumulate(output_tokens, getattr(token_usage, "completion_tokens", None))
cache_tokens = _accumulate(cache_tokens, getattr(token_usage, "cache_read_tokens", None))
return {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"cache_tokens": cache_tokens,
# accumulated_cost is 0.0 when litellm has no pricing for the model
# (e.g. an aliased model behind a proxy); treat that as "unknown" so
# downstream cost reporting can fall back to provider dashboards.
"cost_usd": cost if cost else None,
}
# Tool observations larger than this (in chars) are kept intact. The OpenHands
# SDK otherwise hard-truncates every tool result at DEFAULT_TEXT_CONTENT_LIMIT
# (50_000) — see openhands/sdk/utils/truncate.py — which silently drops the tail
# of large reads (a 24-page SOP PDF is ~56 KB, big spreadsheet dumps similar).
# The worldbench baseline feeds tool outputs to the model untruncated (it only
# shortens them when building compaction summaries), so an aggressive per-call
# cap is a parity gap that costs OpenHands rubric points on SOP-heavy tasks.
# 1 MB clears every realistic task read with headroom while still guarding
# against a pathological multi-MB blob blowing the context window.
TOOL_OBSERVATION_CHAR_LIMIT = 1_000_000
def _raise_tool_observation_limit() -> None:
"""Lift the SDK's hardcoded 50K tool-observation truncation cap.
The limit is a module-level constant (the per-message ``enable_truncation``
field is deprecated), and ``message.py`` binds it at import time, so both the
source module and that imported reference must be patched.
"""
import openhands.sdk.llm.message as _message
import openhands.sdk.utils.truncate as _truncate
_truncate.DEFAULT_TEXT_CONTENT_LIMIT = TOOL_OBSERVATION_CHAR_LIMIT
_message.DEFAULT_TEXT_CONTENT_LIMIT = TOOL_OBSERVATION_CHAR_LIMIT
def run(config: dict[str, Any]) -> dict[str, Any]:
from pydantic import SecretStr
from openhands.sdk import (
LLM,
Agent,
Conversation,
ConversationExecutionStatus,
Event,
LLMSummarizingCondenser,
MessageEvent,
)
from openhands.sdk.event import ActionEvent, AgentErrorEvent
from openhands.sdk.llm.message import content_to_str
_raise_tool_observation_limit()
model_name = config["model"]
litellm_model, api_key, base_url = _resolve_llm_auth(model_name)
llm_kwargs, reasoning_effort = _resolve_llm_kwargs(config)
# Opt-in per-call request logging (--ak log_completions=true). Point the
# folder at the trajectory's log dir so it's downloaded with the trial. Each
# logged payload records the kwargs actually sent to the model (e.g.
# reasoning_effort), giving a verifiable record of what was requested.
if llm_kwargs.pop("log_completions", False):
log_root = config.get("logDir") or WORKSPACE_DIR
llm_kwargs["log_completions"] = True
llm_kwargs["log_completions_folder"] = os.path.join(log_root, "completions")
_log(f"completion logging enabled -> {llm_kwargs['log_completions_folder']}")
_log(
f"Running OpenHands loop (model={litellm_model}, "
f"base_url={base_url or 'default'})"
)
# timeout=600 matches worldbench's ~10-min per-call ceiling (anthropic-sdk.ts
# uses `timeout: 600000 - 1`). The OpenHands LLM default is 300s, and
# litellm.Timeout isn't in its retryable set, so a slow proxy call surfaces as
# a fatal RuntimeError and drops the trial — doubling the ceiling matches the
# baseline and cuts those spurious exclusions.
llm = LLM(
usage_id="agent",
model=litellm_model,
api_key=SecretStr(api_key) if api_key else None,
base_url=base_url,
timeout=600,
**llm_kwargs,
)
# Context compaction, for parity with worldbench (which summarizes old turns
# to stay within the window). We use the SDK defaults (event-count trigger at
# max_size=240, keep_first=2): for these tasks neither harness's compaction
# actually fires, but this gives the *reactive* recovery path — on a real
# context-window overflow the SDK emits a CondensationRequest and retries with
# a summarized history instead of hard-erroring the trial. The summarizer runs
# on the same model/proxy as the agent (worldbench summarizes with its agent
# model too); a separate LLM instance just keeps its metrics distinct.
condenser_llm = LLM(
usage_id="condenser",
model=litellm_model,
api_key=SecretStr(api_key) if api_key else None,
base_url=base_url,
timeout=600,
**llm_kwargs,
)
if reasoning_effort is not None:
if litellm_model.startswith("anthropic/"):
llm.reasoning_effort = reasoning_effort
condenser_llm.reasoning_effort = reasoning_effort
else:
rb = {"reasoning": {"effort": reasoning_effort}}
llm.litellm_extra_body = {**llm.litellm_extra_body, **rb}
condenser_llm.litellm_extra_body = {**condenser_llm.litellm_extra_body, **rb}
# Pass the office-assistant prompt as `system_prompt` (verbatim full
# replacement) rather than an AgentContext suffix — otherwise OpenHands'
# default SWE-coding system prompt dominates and diverges from worldbench,
# which uses the office-assistant prompt as *the* system message.
agent = Agent(
llm=llm,
tools=[],
mcp_config={
"mcpServers": {
"syntara": {
"url": config["mcpUrl"],
"timeout": MCP_TOOL_TIMEOUT,
}
}
},
system_prompt=config["systemPrompt"],
condenser=LLMSummarizingCondenser(llm=condenser_llm),
)
n_tool_calls = 0
n_agent_errors = 0
final_output = ""
error_message: str | None = None
def callback(event: Event) -> None:
nonlocal n_tool_calls, n_agent_errors, final_output, error_message
if isinstance(event, ActionEvent):
n_tool_calls += 1
elif isinstance(event, AgentErrorEvent):
n_agent_errors += 1
error_message = getattr(event, "error", None) or str(event)
elif isinstance(event, MessageEvent) and event.source == "agent":
text = "".join(content_to_str(event.to_llm_message().content))
if text:
final_output = text
conversation = Conversation(
agent=agent,
callbacks=[callback],
workspace=WORKSPACE_DIR,
persistence_dir=STATE_DIR,
max_iteration_per_run=int(config["maxToolCalls"]),
)
saw_error = False
try:
conversation.send_message(config["instruction"])
conversation.run()
except Exception as e: # provider/transport/loop failure
saw_error = True
error_message = error_message or str(e)
_log(f"OpenHands conversation.run() raised: {e}")
status = getattr(conversation.state, "execution_status", None)
status_val = getattr(status, "value", status)
infra_failure = saw_error or status_val == ConversationExecutionStatus.ERROR.value
did_work = n_tool_calls > 0 or bool(final_output)
if saw_error:
# conversation.run() raised (e.g. litellm.Timeout / provider error): the
# loop was cut short and never completed, so this is a transport/infra
# failure even if the agent had already made tool calls.
stopped_reason = "error"
elif infra_failure and not did_work:
# ERROR status with nothing produced — genuine failure.
stopped_reason = "error"
elif status_val == ConversationExecutionStatus.STUCK.value:
stopped_reason = "stuck"
elif status_val == ConversationExecutionStatus.FINISHED.value:
stopped_reason = "end_turn"
else:
stopped_reason = "max_tool_calls"
usage = _usage(llm, condenser_llm)
return {
"agent_id": "openhands_sdk",
"model": litellm_model,
"final_output": final_output,
"n_tool_calls": n_tool_calls,
"n_agent_errors": n_agent_errors,
"stopped_reason": stopped_reason,
"error_message": error_message,
"input_tokens": usage["input_tokens"],
"output_tokens": usage["output_tokens"],
"cache_tokens": usage["cache_tokens"],
"cost_usd": usage["cost_usd"],
}
def main() -> None:
if len(sys.argv) != 3:
_log("usage: openhands_runner.py <config.json> <output.json>")
sys.exit(2)
config_path, output_path = sys.argv[1], sys.argv[2]
config = json.loads(open(config_path).read())
try:
result = run(config)
except Exception as e:
# Hard failure before/around the loop — still emit a trajectory so the
# host can surface it as an errored (excluded) trial rather than a crash.
result = {
"agent_id": "openhands_sdk",
"model": config.get("model"),
"final_output": "",
"n_tool_calls": 0,
"stopped_reason": "error",
"error_message": str(e),
"input_tokens": None,
"output_tokens": None,
"cache_tokens": None,
"cost_usd": None,
}
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with open(output_path, "w") as f:
json.dump(result, f, indent=2)
_log(
f"wrote trajectory to {output_path} "
f"(stopped_reason={result.get('stopped_reason')}, "
f"n_tool_calls={result.get('n_tool_calls')})"
)
if __name__ == "__main__":
main()
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Run bundled SOP Python verifiers inside a Harbor task container."""
from __future__ import annotations
import json
import shutil
import sys
import tempfile
import traceback
from pathlib import Path
from typing import Any
WORKDIR = Path("/workdir")
DATA_DIR = Path("/data")
INITIAL_DATA_DIR = Path("/initial_data")
TESTS_DIR = Path("/tests")
VERIFIER_DIR = Path("/logs/verifier")
SERVICE_COMPAT_FILES: dict[str, tuple[str, tuple[str, ...]]] = {
"slack": ("slack.json", ("slack.json", "slack_data.json")),
"google_mail": ("inbox.json", ("inbox.json", "mailbox.json")),
"google_calendar": ("calendar_data.json", ("calendar_data.json", "calendar.json")),
"jira": ("jira_state.json", ("jira_state.json", "jira_data.json")),
"shopify": ("shopify_data.json", ("shopify_data.json",)),
}
def _state_path(service: str, seed_name: str) -> Path | None:
candidates = [
DATA_DIR / service / "final.json",
DATA_DIR / service / seed_name,
INITIAL_DATA_DIR / service / seed_name,
]
return next((p for p in candidates if p.is_file()), None)
def _build_compat_external_services(dest: Path) -> None:
dest.mkdir(parents=True, exist_ok=True)
for service, (seed_name, compat_names) in SERVICE_COMPAT_FILES.items():
src = _state_path(service, seed_name)
if src is not None:
for compat_name in compat_names:
shutil.copy2(src, dest / compat_name)
def _coerce_result(raw: Any) -> dict[str, Any]:
if isinstance(raw, dict):
passed = bool(raw.get("pass", raw.get("passed", False)))
score = raw.get("score", 1.0 if passed else 0.0)
try:
score = float(score)
except (TypeError, ValueError):
score = 1.0 if passed else 0.0
return {
"pass": passed,
"score": max(0.0, min(1.0, score)),
"feedback": str(raw.get("feedback", "")),
}
passed = bool(raw)
return {"pass": passed, "score": 1.0 if passed else 0.0, "feedback": str(raw)}
def _run_one(rubric: dict[str, Any], external_services_path: Path) -> dict[str, Any]:
rubric_id = str(rubric.get("id") or "rubric")
code = rubric.get("verifier_code")
if not isinstance(code, str) or not code.strip():
return {
"id": rubric_id,
"pass": False,
"score": 0.0,
"feedback": "rubric has no verifier_code",
}
namespace: dict[str, Any] = {"__builtins__": __builtins__}
try:
exec(compile(code, f"<{rubric_id}>", "exec"), namespace)
verify = namespace.get("verify")
if not callable(verify):
raise RuntimeError("verifier_code did not define verify()")
result = _coerce_result(verify(str(WORKDIR), str(external_services_path)))
return {"id": rubric_id, **result}
except Exception:
return {
"id": rubric_id,
"pass": False,
"score": 0.0,
"feedback": traceback.format_exc(),
}
def main() -> None:
rubrics_path = TESTS_DIR / "rubrics.json"
if not rubrics_path.is_file():
print("[sop-verifier] ERROR: rubrics.json not found", file=sys.stderr)
sys.exit(1)
rubrics = json.loads(rubrics_path.read_text())
if not isinstance(rubrics, list):
print("[sop-verifier] ERROR: rubrics.json must be a list", file=sys.stderr)
sys.exit(1)
with tempfile.TemporaryDirectory(prefix="sop-external-services-") as tmp:
compat_dir = Path(tmp)
_build_compat_external_services(compat_dir)
results = [_run_one(r, compat_dir) for r in rubrics]
total = len(results)
passed = sum(1 for r in results if r.get("pass"))
average_score = round(
sum(float(r.get("score", 0.0)) for r in results) / total,
4,
) if total else 0.0
print(f"[sop-verifier] {passed}/{total} rubrics passed; score={average_score:.2f}")
for result in results:
status = "PASS" if result.get("pass") else "FAIL"
feedback = str(result.get("feedback", "")).replace("\n", " ")[:500]
print(f" [{status}] {result.get('id')}: {feedback}")
output = {
"passed": passed == total,
"rubrics_passed": passed,
"rubrics_total": total,
"score": average_score,
"rubric_results": results,
}
(TESTS_DIR / "results.json").write_text(json.dumps(output, indent=2) + "\n")
VERIFIER_DIR.mkdir(parents=True, exist_ok=True)
(VERIFIER_DIR / "reward.txt").write_text(str(average_score))
if __name__ == "__main__":
main()
+3099
View File
File diff suppressed because it is too large Load Diff