mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-15 20:52:21 +02:00
572 lines
21 KiB
Python
572 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""Expanded live Odysseus eval for compact Qwen tool-router models.
|
|
|
|
The important distinction for this project is exact native emission vs.
|
|
app-level success after parser repair. This script records both.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import json
|
|
import re
|
|
import signal
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
try:
|
|
# Works when imported by the test suite from the repository root.
|
|
from scripts.eval_odysseus_tool_use import _cookie, _reported_model, run_case
|
|
except ModuleNotFoundError:
|
|
# Preserve direct script execution from the scripts directory.
|
|
from eval_odysseus_tool_use import _cookie, _reported_model, run_case
|
|
|
|
|
|
DEFAULT_CASES: list[dict[str, Any]] = [
|
|
{
|
|
"case": "general_hi",
|
|
"message": "hi",
|
|
"expected_tool": "",
|
|
"expected_action": "",
|
|
"kind": "no_tool",
|
|
},
|
|
{
|
|
"case": "general_map_no_tool",
|
|
"message": "where is Sweden on a map?",
|
|
"expected_tool": "",
|
|
"expected_action": "",
|
|
"kind": "no_tool",
|
|
},
|
|
{
|
|
"case": "notes_list",
|
|
"message": "what are my notes?",
|
|
"expected_tool": "manage_notes",
|
|
"expected_action": "list",
|
|
},
|
|
{
|
|
"case": "notes_search",
|
|
"message": "find my note called Japan",
|
|
"expected_tool": "manage_notes",
|
|
"expected_action": "search",
|
|
},
|
|
{
|
|
"case": "notes_create",
|
|
"message": "create a note titled ODY-EVAL-EXT-CREATE with body live eval create body",
|
|
"expected_tool": "manage_notes",
|
|
"expected_action": "add",
|
|
"mutates": True,
|
|
},
|
|
{
|
|
"case": "notes_delete_title",
|
|
"message": "delete the note titled ODY-EVAL-EXT-DELETE-TITLE",
|
|
"expected_tool": "manage_notes",
|
|
"expected_action": "delete",
|
|
# Title deletes may safely resolve the title before the destructive
|
|
# call; score the first lookup as valid only when a delete executes.
|
|
"acceptable_first_actions": ["delete", "search"],
|
|
"seed_note_title": "ODY-EVAL-EXT-DELETE-TITLE",
|
|
"seed_note_content": "delete title seed",
|
|
"mutates": True,
|
|
},
|
|
{
|
|
"case": "notes_delete_id",
|
|
"message_template": "delete note {note_id}",
|
|
"expected_tool": "manage_notes",
|
|
"expected_action": "delete",
|
|
"seed_note_title": "ODY-EVAL-EXT-DELETE-ID",
|
|
"seed_note_content": "delete id seed",
|
|
"mutates": True,
|
|
},
|
|
{
|
|
"case": "calendar_list",
|
|
"message": "what is on my calendar?",
|
|
"expected_tool": "manage_calendar",
|
|
"expected_action": "list_events",
|
|
},
|
|
{
|
|
"case": "email_latest",
|
|
"message": "what is my latest email?",
|
|
"expected_tool": "mcp__email__list_emails",
|
|
"expected_action": "",
|
|
},
|
|
{
|
|
"case": "email_search",
|
|
"message": "find emails from Runpod",
|
|
"expected_tool": "mcp__email__search_emails",
|
|
"expected_action": "",
|
|
},
|
|
{
|
|
"case": "tasks_list",
|
|
"message": "list my tasks",
|
|
"expected_tool": "manage_tasks",
|
|
"expected_action": "list",
|
|
},
|
|
{
|
|
"case": "documents_list",
|
|
"message": "list my documents",
|
|
"expected_tool": "manage_documents",
|
|
"expected_action": "list",
|
|
},
|
|
{
|
|
"case": "memory_list",
|
|
"message": "list my saved memories",
|
|
"expected_tool": "manage_memory",
|
|
"expected_action": "list",
|
|
},
|
|
{
|
|
"case": "memory_search",
|
|
"message": "what do you remember about my nationality?",
|
|
"expected_tool": "manage_memory",
|
|
"expected_action": "search",
|
|
},
|
|
{
|
|
"case": "sessions_list",
|
|
"message": "list my chat sessions",
|
|
"expected_tool": "list_sessions",
|
|
"expected_action": "",
|
|
},
|
|
{
|
|
"case": "contacts_list",
|
|
"message": "list my contacts",
|
|
"expected_tool": "manage_contact",
|
|
"expected_action": "list",
|
|
},
|
|
{
|
|
"case": "research_list",
|
|
"message": "list my saved research reports",
|
|
"expected_tool": "manage_research",
|
|
"expected_action": "list",
|
|
},
|
|
]
|
|
|
|
|
|
WEB_CASE = {
|
|
"case": "web_search",
|
|
"message": "search the web for current public domain art websites",
|
|
"expected_tool": "web_search",
|
|
"expected_action": "",
|
|
}
|
|
|
|
|
|
TOOL_ALIASES = {
|
|
"mcp_email_list_emails": "mcp__email__list_emails",
|
|
"mcp_email_search_emails": "mcp__email__search_emails",
|
|
"search_chats": "list_sessions",
|
|
}
|
|
|
|
|
|
def cleanup_notes(client: httpx.Client, base_url: str) -> None:
|
|
try:
|
|
response = client.get(base_url.rstrip("/") + "/api/notes", timeout=20)
|
|
response.raise_for_status()
|
|
notes = response.json().get("notes", [])
|
|
except Exception as exc:
|
|
# Cleanup is auxiliary. A slow scheduler or unavailable notes route
|
|
# must not erase the checkpoint containing the actual eval results.
|
|
print(json.dumps({"cleanup_warning": repr(exc)}), flush=True)
|
|
return
|
|
for note in notes:
|
|
title = str(note.get("title") or "")
|
|
note_id = str(note.get("id") or "")
|
|
if title.startswith("ODY-EVAL-EXT-") and note_id:
|
|
try:
|
|
client.delete(base_url.rstrip("/") + f"/api/notes/{note_id}", timeout=20)
|
|
except Exception as exc:
|
|
print(json.dumps({"cleanup_warning": repr(exc), "note_id": note_id}), flush=True)
|
|
|
|
|
|
def seed_note(client: httpx.Client, base_url: str, title: str, content: str) -> str:
|
|
response = client.post(
|
|
base_url.rstrip("/") + "/api/notes",
|
|
json={
|
|
"title": title,
|
|
"content": content,
|
|
"note_type": "note",
|
|
"pinned": False,
|
|
"archived": False,
|
|
"source": "agent",
|
|
},
|
|
timeout=20,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()["id"]
|
|
|
|
|
|
def _raw_round_text(record: dict[str, Any]) -> str:
|
|
metrics = record.get("metrics") or {}
|
|
round_texts = metrics.get("round_texts") or []
|
|
return "\n---ROUND---\n".join(str(item) for item in round_texts)
|
|
|
|
|
|
def _extract_raw_tool(raw: str) -> str | None:
|
|
patterns = [
|
|
r"<function=([A-Za-z0-9_]+)>",
|
|
r"\bfunction=([A-Za-z0-9_]+)",
|
|
r'"function"\s*:\s*"([^"]+)"',
|
|
r'"tool"\s*:\s*"([^"]+)"',
|
|
]
|
|
for pattern in patterns:
|
|
match = re.search(pattern, raw)
|
|
if match:
|
|
return match.group(1)
|
|
return None
|
|
|
|
|
|
def _extract_raw_action(raw: str) -> str | None:
|
|
patterns = [
|
|
r"parameter=action\s*\n([^\n<]+)",
|
|
r"<parameter=action>\s*([^<]+)",
|
|
r'"action"\s*:\s*"([^"]+)"',
|
|
]
|
|
for pattern in patterns:
|
|
match = re.search(pattern, raw)
|
|
if match:
|
|
return match.group(1).strip()
|
|
return None
|
|
|
|
|
|
def _canonical_tool(tool: str | None) -> str | None:
|
|
if not tool:
|
|
return tool
|
|
return TOOL_ALIASES.get(tool, tool)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def hard_timeout(seconds: float | None, label: str):
|
|
if not seconds or seconds <= 0:
|
|
yield
|
|
return
|
|
|
|
def _raise_timeout(signum, frame): # type: ignore[no-untyped-def]
|
|
raise TimeoutError(f"{label} exceeded hard timeout {seconds}s")
|
|
|
|
previous = signal.signal(signal.SIGALRM, _raise_timeout)
|
|
signal.setitimer(signal.ITIMER_REAL, seconds)
|
|
try:
|
|
yield
|
|
finally:
|
|
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
signal.signal(signal.SIGALRM, previous)
|
|
|
|
|
|
def timeout_record(case: dict[str, Any], exc: BaseException) -> dict[str, Any]:
|
|
return {
|
|
"case": case["case"],
|
|
"message": case.get("message") or case.get("message_template") or "",
|
|
"expected_tool": case["expected_tool"],
|
|
"first_tool": None,
|
|
"native_call_ok": False,
|
|
"tool_count": 0,
|
|
"execution_ok": False,
|
|
"duplicate_textual_call": False,
|
|
"stream_errors": [{"type": "hard_timeout", "error": repr(exc)}],
|
|
"stream_exception": repr(exc),
|
|
"tool_outputs": [],
|
|
"metrics": None,
|
|
"elapsed_seconds": None,
|
|
"response": "",
|
|
}
|
|
|
|
|
|
def _discover_router_model(endpoint: str) -> str:
|
|
"""Choose the advertised Qwen router when the eval caller omits a model."""
|
|
probe_urls = [endpoint.rstrip("/") + "/models"]
|
|
if "host.docker.internal" in endpoint:
|
|
probe_urls.append(endpoint.replace("host.docker.internal", "127.0.0.1").rstrip("/") + "/models")
|
|
response = None
|
|
last_error: Exception | None = None
|
|
for probe_url in probe_urls:
|
|
try:
|
|
response = httpx.get(probe_url, timeout=15)
|
|
break
|
|
except httpx.HTTPError as exc:
|
|
last_error = exc
|
|
if response is None:
|
|
raise SystemExit(f"Could not discover models from {probe_urls}: {last_error}")
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
model_ids = [
|
|
str(item.get("id") or "").strip()
|
|
for item in (payload.get("data") or [])
|
|
if isinstance(item, dict) and str(item.get("id") or "").strip()
|
|
]
|
|
candidates = [
|
|
model_id for model_id in model_ids
|
|
if "qwen35-9b-tool-router" in model_id.lower()
|
|
]
|
|
if not candidates:
|
|
raise SystemExit(
|
|
"No advertised qwen35-9b-tool-router model found; "
|
|
f"available={model_ids}"
|
|
)
|
|
return candidates[0]
|
|
|
|
|
|
def annotate(record: dict[str, Any], case: dict[str, Any]) -> dict[str, Any]:
|
|
raw = _raw_round_text(record)
|
|
raw_tool = _extract_raw_tool(raw)
|
|
raw_action = _extract_raw_action(raw)
|
|
expected_tool = case["expected_tool"]
|
|
expected_action = case.get("expected_action") or ""
|
|
acceptable_first_actions = set(case.get("acceptable_first_actions") or [])
|
|
if expected_action and not acceptable_first_actions:
|
|
acceptable_first_actions = {expected_action}
|
|
no_tool = case.get("kind") == "no_tool"
|
|
metrics = record.get("metrics") or {}
|
|
round_texts = metrics.get("round_texts") or []
|
|
final_round_text = str(round_texts[-1]) if round_texts else ""
|
|
response = record.get("response") or ""
|
|
tool_events = metrics.get("tool_events") or []
|
|
executed_actions: list[str] = []
|
|
structured_tool = None
|
|
structured_action = None
|
|
for event in tool_events:
|
|
raw_command = event.get("command") or ""
|
|
try:
|
|
command = json.loads(raw_command or "{}")
|
|
except Exception:
|
|
command = raw_command
|
|
if structured_tool is None:
|
|
structured_tool = event.get("tool")
|
|
if isinstance(command, dict):
|
|
action = str(command.get("action") or "")
|
|
executed_actions.append(action)
|
|
if structured_action is None:
|
|
structured_action = action
|
|
elif isinstance(command, str) and command.strip():
|
|
action = command.strip().splitlines()[0]
|
|
executed_actions.append(action)
|
|
if structured_action is None:
|
|
structured_action = action
|
|
visible_tool = _canonical_tool(raw_tool)
|
|
structured_tool = _canonical_tool(structured_tool or record.get("first_tool"))
|
|
visible_action = raw_action
|
|
exact_tool_ok = (visible_tool is None and no_tool) or (
|
|
(visible_tool or structured_tool) == expected_tool
|
|
)
|
|
exact_action_ok = not expected_action or (
|
|
(visible_action or structured_action) in acceptable_first_actions
|
|
and (
|
|
"search" not in acceptable_first_actions
|
|
or "delete" not in acceptable_first_actions
|
|
or "delete" in executed_actions
|
|
)
|
|
)
|
|
raw_visible_exact_ok = bool(
|
|
((raw_tool is None and no_tool) or visible_tool == expected_tool)
|
|
and (not expected_action or visible_action in acceptable_first_actions)
|
|
)
|
|
structured_native_ok = bool(
|
|
((structured_tool is None and no_tool) or structured_tool == expected_tool)
|
|
and (not expected_action or structured_action in acceptable_first_actions)
|
|
)
|
|
if no_tool:
|
|
behavior_ok = record.get("tool_count") == 0 and bool(response or final_round_text)
|
|
# No-tool turns have no execution artifact by design. Treat a clean
|
|
# final response as the successful execution of the case so the
|
|
# matrix's aggregate execution score remains meaningful.
|
|
if behavior_ok and not record.get("stream_errors"):
|
|
record["execution_ok"] = True
|
|
elif case.get("mutates") and expected_action:
|
|
behavior_ok = bool(record.get("execution_ok")) and expected_action in executed_actions
|
|
else:
|
|
behavior_ok = bool(record.get("execution_ok"))
|
|
record.update(
|
|
{
|
|
"expected_action": expected_action,
|
|
"raw_tool": raw_tool,
|
|
"raw_action": raw_action,
|
|
"structured_tool": structured_tool,
|
|
"structured_action": structured_action,
|
|
"raw_round_text": raw[:2000],
|
|
"raw_visible_exact_ok": raw_visible_exact_ok,
|
|
"structured_native_ok": structured_native_ok,
|
|
"exact_tool_ok": bool(exact_tool_ok),
|
|
"exact_action_ok": bool(exact_action_ok),
|
|
"exact_native_ok": bool(exact_tool_ok and exact_action_ok),
|
|
"behavior_ok": bool(behavior_ok),
|
|
"response_or_round_text_present": bool(response or final_round_text.strip()),
|
|
"input_tokens": metrics.get("input_tokens"),
|
|
"output_tokens": metrics.get("output_tokens"),
|
|
"tokens_per_second": metrics.get("tokens_per_second"),
|
|
}
|
|
)
|
|
return record
|
|
|
|
|
|
def write_checkpoint(output: Path, records: list[dict[str, Any]], model: str) -> None:
|
|
"""Persist a usable matrix result after each case, including interruptions."""
|
|
summary = {
|
|
"model": model,
|
|
"cases": len(records),
|
|
"exact_native_success": sum(r["exact_native_ok"] for r in records),
|
|
"structured_native_success": sum(r["structured_native_ok"] for r in records),
|
|
"raw_visible_exact_success": sum(r["raw_visible_exact_ok"] for r in records),
|
|
"behavior_success": sum(r["behavior_ok"] for r in records),
|
|
"execution_success": sum(r["execution_ok"] for r in records),
|
|
"response_present": sum(r["response_or_round_text_present"] for r in records),
|
|
"response_quality_success": sum(r.get("response_quality_ok", True) for r in records),
|
|
"stream_errors": sum(bool(r["stream_errors"]) for r in records),
|
|
"records": records,
|
|
}
|
|
temporary = output.with_name(output.name + ".tmp")
|
|
temporary.write_text(json.dumps(summary, indent=2, ensure_ascii=True) + "\n")
|
|
temporary.replace(output)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--base-url", default="http://127.0.0.1:7011")
|
|
parser.add_argument("--endpoint", default="http://host.docker.internal:18048/v1")
|
|
parser.add_argument("--endpoint-id", default="ca27bdc1")
|
|
parser.add_argument(
|
|
"--model",
|
|
default="",
|
|
help="Advertised router model; omitted means discover it from --endpoint.",
|
|
)
|
|
parser.add_argument("--selected-endpoint-url", default="http://host.docker.internal:18048/v1")
|
|
parser.add_argument("--selected-model", default="")
|
|
parser.add_argument(
|
|
"--client-runtime-context",
|
|
default="",
|
|
help="JSON object passed as the TUI client_runtime_context form field.",
|
|
)
|
|
parser.add_argument("--cookie-file", default="data/sessions.json")
|
|
parser.add_argument("--output", required=True)
|
|
parser.add_argument("--prompt-mode", default="auto")
|
|
parser.add_argument("--timeout", type=float, default=180.0)
|
|
parser.add_argument(
|
|
"--hard-case-timeout",
|
|
type=float,
|
|
default=0.0,
|
|
help="Optional SIGALRM watchdog per case. Use for wedgy tools like web.",
|
|
)
|
|
parser.add_argument("--include-web", action="store_true")
|
|
parser.add_argument("--cases", default="")
|
|
args = parser.parse_args()
|
|
if args.client_runtime_context:
|
|
try:
|
|
args.client_runtime_context = json.loads(args.client_runtime_context)
|
|
except json.JSONDecodeError as exc:
|
|
raise SystemExit(f"--client-runtime-context must be valid JSON: {exc}") from exc
|
|
if not isinstance(args.client_runtime_context, dict):
|
|
raise SystemExit("--client-runtime-context must decode to a JSON object")
|
|
else:
|
|
args.client_runtime_context = None
|
|
|
|
if not args.model:
|
|
# A caller that already selected the model should not trigger a probe
|
|
# against the evaluator's unrelated default endpoint. This matters
|
|
# for local tunnels, where /models may be unavailable even though the
|
|
# selected chat endpoint is healthy.
|
|
args.model = args.selected_model or _discover_router_model(args.endpoint)
|
|
if not args.selected_model:
|
|
args.selected_model = args.model
|
|
|
|
selected = {item.strip() for item in args.cases.split(",") if item.strip()}
|
|
available_cases = list(DEFAULT_CASES)
|
|
if args.include_web:
|
|
available_cases.append(WEB_CASE)
|
|
cases = [case for case in available_cases if not selected or case["case"] in selected]
|
|
unknown = selected - {case["case"] for case in available_cases}
|
|
if unknown:
|
|
raise SystemExit(f"Unknown case(s): {', '.join(sorted(unknown))}")
|
|
|
|
output = Path(args.output)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
client = httpx.Client(
|
|
cookies={"odysseus_session": _cookie(Path(args.cookie_file))},
|
|
follow_redirects=False,
|
|
)
|
|
records: list[dict[str, Any]] = []
|
|
try:
|
|
cleanup_notes(client, args.base_url)
|
|
for case in cases:
|
|
case = dict(case)
|
|
if case.get("seed_note_title"):
|
|
note_id = seed_note(
|
|
client,
|
|
args.base_url,
|
|
case["seed_note_title"],
|
|
case["seed_note_content"],
|
|
)
|
|
if case.get("message_template"):
|
|
case["message"] = case["message_template"].format(note_id=note_id[:8])
|
|
case["seed_note_id"] = note_id
|
|
try:
|
|
with hard_timeout(args.hard_case_timeout, case["case"]):
|
|
record = run_case(
|
|
client,
|
|
args,
|
|
case["case"],
|
|
case["message"],
|
|
case["expected_tool"],
|
|
)
|
|
except TimeoutError as exc:
|
|
record = timeout_record(case, exc)
|
|
record = annotate(record, case)
|
|
if case.get("seed_note_id"):
|
|
record["seed_note_id"] = case["seed_note_id"]
|
|
records.append(record)
|
|
write_checkpoint(output, records, args.model)
|
|
print(
|
|
json.dumps(
|
|
{
|
|
k: record.get(k)
|
|
for k in (
|
|
"case",
|
|
"expected_tool",
|
|
"expected_action",
|
|
"raw_tool",
|
|
"raw_action",
|
|
"structured_tool",
|
|
"structured_action",
|
|
"first_tool",
|
|
"raw_visible_exact_ok",
|
|
"structured_native_ok",
|
|
"exact_native_ok",
|
|
"behavior_ok",
|
|
"execution_ok",
|
|
"response_quality_ok",
|
|
"tool_count",
|
|
"input_tokens",
|
|
"output_tokens",
|
|
"elapsed_seconds",
|
|
"stream_errors",
|
|
)
|
|
},
|
|
ensure_ascii=True,
|
|
),
|
|
flush=True,
|
|
)
|
|
finally:
|
|
try:
|
|
cleanup_notes(client, args.base_url)
|
|
finally:
|
|
client.close()
|
|
|
|
summary = {
|
|
"model": _reported_model(args),
|
|
"cases": len(records),
|
|
"exact_native_success": sum(r["exact_native_ok"] for r in records),
|
|
"structured_native_success": sum(r["structured_native_ok"] for r in records),
|
|
"raw_visible_exact_success": sum(r["raw_visible_exact_ok"] for r in records),
|
|
"behavior_success": sum(r["behavior_ok"] for r in records),
|
|
"execution_success": sum(r["execution_ok"] for r in records),
|
|
"response_present": sum(r["response_or_round_text_present"] for r in records),
|
|
"response_quality_success": sum(r.get("response_quality_ok", True) for r in records),
|
|
"stream_errors": sum(bool(r["stream_errors"]) for r in records),
|
|
"records": records,
|
|
}
|
|
write_checkpoint(output, records, args.model)
|
|
print("SUMMARY", json.dumps({k: v for k, v in summary.items() if k != "records"}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|