#!/usr/bin/env python3 """Run live-style Odysseus hard examples against the current agent route. This is eval-first by design: it calls the same stream_agent_loop path used by the app, records actual tool calls and mutations, and writes JSON/Markdown results. It does not train, launch a server, or call the model endpoint directly. """ from __future__ import annotations import argparse import asyncio import json import re import sys import time import uuid from pathlib import Path from types import SimpleNamespace from typing import Any REPO_ROOT = Path(__file__).resolve().parents[1] if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) from core.database import CalendarEvent, Document, SessionLocal from scripts.ody_eval_email_fixture import email_fixture from src.agent_loop import stream_agent_loop from src.user_time import ( current_datetime_context_message, set_user_tz_name, set_user_tz_offset, ) DEFAULT_ENDPOINT = "http://host.docker.internal:18055/v1" DEFAULT_MODEL = "qwen35-9b-tool-router-v44-fixture-followthrough-repair" DEFAULT_OWNER = "pewds" DEFAULT_TZ = "Asia/Tokyo" DEFAULT_TZ_OFFSET_MIN = 540 CASES: list[dict[str, Any]] = [ { "id": "calendar_tomorrow_8am", "kind": "calendar", "user": "Add event tomorrow for meeting 8am", "expect_first_tool": "manage_calendar", "forbidden_tools": ["web_search", "mcp__email__list_emails", "update_document"], "expect_args_subset": { "action": "create_event", "summary": "Meeting", "dtstart": "2026-08-22T08:00:00", "dtend": "2026-08-22T09:00:00", }, "forbidden_answer_fragments": ["2025-09-10", "2024-06-10"], }, { "id": "latest_emails_personal_domain", "kind": "email", "user": "What's my latest emails", "expect_first_tool_any": ["mcp__email__list_emails", "list_emails"], "forbidden_tools": ["web_search", "web_fetch"], "expect_args_subset": { "folder": "INBOX", "max_results": 1, "unread_only": False, }, }, { "id": "web_snails_synthesis", "kind": "web", "user": "Look up why snails bubble up sometimes", "expect_first_tool": "web_search", "forbidden_repeat_tools": ["web_search"], "required_final_any": ["mucus", "foam", "bubbles"], "required_final_any_2": ["stress", "irritant", "salt", "predator", "dehydration", "moisture"], "forbidden_final_patterns": [ r"^\s*\d+\s+Web sources", r"WEB SEARCH RESULTS AND FETCHED CONTENT", r"```sources", r"Here are links for that topic", ], }, { "id": "active_email_draft_update", "kind": "draft", "user": "Write a response to it saying 8am works for me", "active_document": { "title": "Manual email draft probe", "language": "email", "content": ( "To: test@example.com\n" "Subject: Re: Test manual draft\n" "In-Reply-To: \n" "References: \n" "X-Source-UID: 999999\n" "X-Source-Folder: INBOX\n" "X-Attachments: []\n" "---\n\n" "---------- Previous message ----------\n" "From: Test Sender \n" "Can you confirm the meeting time?\n" ), }, "expect_first_tool_any": ["update_document", "edit_document"], "forbidden_tools": [ "manage_calendar", "web_search", "mcp__email__list_emails", "mcp__email__read_email", "list_emails", "read_email", ], "doc_must_contain": ["8am works"], "doc_must_preserve": ["To:", "Subject:", "In-Reply-To:", "References:", "X-Source-UID:", "---"], }, ] def _parse_sse(chunk: str) -> dict[str, Any] | None: if not chunk.startswith("data: "): return None payload = chunk[6:].strip() if payload == "[DONE]": return {"type": "done"} try: return json.loads(payload) except json.JSONDecodeError: return {"type": "parse_error", "payload": payload[:500]} def _parse_tool_args(command: Any) -> Any: if not isinstance(command, str): return command text = command.strip() if not text: return text try: return json.loads(text) except json.JSONDecodeError: return text def _tool_name_matches(actual: str | None, expected: str) -> bool: if actual == expected: return True aliases = { "list_emails": {"mcp__email__list_emails", "list_emails"}, "mcp__email__list_emails": {"mcp__email__list_emails", "list_emails"}, "read_email": {"mcp__email__read_email", "read_email"}, "mcp__email__read_email": {"mcp__email__read_email", "read_email"}, } return actual in aliases.get(expected, set()) def _contains_all_subset(actual: Any, expected: dict[str, Any]) -> bool: if not isinstance(actual, dict): return False for key, value in expected.items(): if actual.get(key) != value: return False return True def _score_case(case: dict[str, Any], result: dict[str, Any]) -> tuple[bool, list[str]]: failures: list[str] = [] first_tool = result.get("first_tool") tool_names = result.get("tool_names") or [] first_args = result.get("first_tool_args") final_answer = result.get("final_answer") or "" final_lower = final_answer.lower() if "expect_first_tool" in case and not _tool_name_matches(first_tool, case["expect_first_tool"]): failures.append(f"first_tool expected {case['expect_first_tool']!r}, got {first_tool!r}") if "expect_first_tool_any" in case: expected_any = case["expect_first_tool_any"] if not any(_tool_name_matches(first_tool, expected) for expected in expected_any): failures.append(f"first_tool expected one of {expected_any!r}, got {first_tool!r}") for forbidden in case.get("forbidden_tools", []): if any(_tool_name_matches(name, forbidden) for name in tool_names): failures.append(f"forbidden tool called: {forbidden}") for repeated in case.get("forbidden_repeat_tools", []): count = sum(1 for name in tool_names if _tool_name_matches(name, repeated)) if count > 1: failures.append(f"tool repeated {count} times: {repeated}") expected_subset = case.get("expect_args_subset") if expected_subset and not _contains_all_subset(first_args, expected_subset): failures.append(f"first tool args missing expected subset: {expected_subset!r}; got {first_args!r}") for fragment in case.get("forbidden_answer_fragments", []): if fragment in final_answer: failures.append(f"forbidden answer fragment present: {fragment}") if "required_final_any" in case and not any(s.lower() in final_lower for s in case["required_final_any"]): failures.append(f"final answer missing any of {case['required_final_any']!r}") if "required_final_any_2" in case and not any(s.lower() in final_lower for s in case["required_final_any_2"]): failures.append(f"final answer missing any of {case['required_final_any_2']!r}") for pattern in case.get("forbidden_final_patterns", []): if re.search(pattern, final_answer, re.IGNORECASE | re.DOTALL): failures.append(f"forbidden final pattern matched: {pattern}") after_doc = result.get("active_document_after") or "" before_doc = result.get("active_document_before") or "" if case.get("doc_must_contain"): if after_doc == before_doc: failures.append("active document was not mutated") for fragment in case["doc_must_contain"]: if fragment.lower() not in after_doc.lower(): failures.append(f"active document missing: {fragment}") for fragment in case.get("doc_must_preserve", []): if fragment not in after_doc: failures.append(f"active document did not preserve: {fragment}") return not failures, failures async def _run_case(case: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]: set_user_tz_name(args.timezone) set_user_tz_offset(args.tz_offset_min) db = SessionLocal() active_document = None active_doc_row = None active_before = "" if case.get("active_document"): fixture = case["active_document"] doc_id = f"ody-live-hard-{case['id']}-{uuid.uuid4().hex[:8]}" active_before = fixture["content"] active_doc_row = Document( id=doc_id, owner=args.owner, session_id=None, title=fixture["title"], language=fixture["language"], current_content=fixture["content"], version_count=1, is_active=True, ) db.add(active_doc_row) db.commit() db.refresh(active_doc_row) active_document = SimpleNamespace( id=active_doc_row.id, title=active_doc_row.title, language=active_doc_row.language, current_content=active_doc_row.current_content, ) messages = [ current_datetime_context_message(), {"role": "user", "content": case["user"]}, ] started = time.time() text_parts: list[str] = [] final_replacements: list[str] = [] tool_calls: list[dict[str, Any]] = [] tool_outputs: list[dict[str, Any]] = [] metrics: dict[str, Any] = {} stream_errors: list[dict[str, Any]] = [] try: async for chunk in stream_agent_loop( args.endpoint, args.model, messages, temperature=args.temperature, max_tokens=args.max_tokens, max_rounds=args.max_rounds, max_tool_calls=args.max_tool_calls, active_document=active_document, session_id=f"ody-live-hard-{case['id']}", owner=args.owner, client_runtime_context={ "timezone": args.timezone, "tz_offset_min": args.tz_offset_min, }, ): event = _parse_sse(chunk) if not event: continue if event.get("type") == "done": break if event.get("type") == "parse_error": stream_errors.append(event) continue if "delta" in event and not event.get("thinking"): text_parts.append(str(event.get("delta") or "")) elif event.get("type") == "final_response": final_replacements.append(str(event.get("content") or "")) elif event.get("type") == "tool_start": tool_calls.append({ "tool": event.get("tool"), "command": event.get("command"), "args": _parse_tool_args(event.get("full_command") or event.get("command")), "round": event.get("round"), "call_id": event.get("call_id") or event.get("tool_call_id"), }) elif event.get("type") == "tool_output": tool_outputs.append({ "tool": event.get("tool"), "command": event.get("command"), "output": event.get("output"), "exit_code": event.get("exit_code"), "call_id": event.get("call_id") or event.get("tool_call_id"), }) elif event.get("type") == "metrics": metrics = event.get("data") or {} elif event.get("type") == "error": stream_errors.append(event) finally: active_after = "" if active_doc_row is not None: db.refresh(active_doc_row) active_after = active_doc_row.current_content or "" active_doc_row.archived = True active_doc_row.is_active = False db.commit() created_event_uids: list[str] = [] for output in tool_outputs: if output.get("tool") != "manage_calendar": continue for uid in re.findall(r"#event-([A-Za-z0-9_.:-]+)", str(output.get("output") or "")): created_event_uids.append(uid) if created_event_uids and not args.keep_mutations: db.query(CalendarEvent).filter(CalendarEvent.uid.in_(created_event_uids)).delete( synchronize_session=False ) db.commit() db.close() final_answer = "".join(text_parts) if final_replacements: final_answer = final_replacements[-1] result = { "id": case["id"], "kind": case["kind"], "user": case["user"], "first_tool": tool_calls[0]["tool"] if tool_calls else None, "first_tool_args": tool_calls[0]["args"] if tool_calls else None, "tool_names": [call["tool"] for call in tool_calls], "tool_calls": tool_calls, "tool_outputs": tool_outputs, "final_answer": final_answer, "active_document_before": active_before, "active_document_after": active_after, "active_document_changed": bool(active_before and active_after != active_before), "created_calendar_event_uids": created_event_uids, "created_calendar_events_deleted": bool(created_event_uids and not args.keep_mutations), "metrics": metrics, "stream_errors": stream_errors, "elapsed_seconds": round(time.time() - started, 3), } passed, failures = _score_case(case, result) result["pass"] = passed result["failures"] = failures return result def _write_markdown(path: Path, payload: dict[str, Any]) -> None: rows = payload["results"] lines = [ "# Odysseus Live Hard-Example Eval Results", "", f"- Generated: `{payload['generated_at']}`", f"- Model: `{payload['model']}`", f"- Endpoint: `{payload['endpoint']}`", f"- Cases: `{payload['summary']['passed']}/{payload['summary']['total']}` passed", "", "## Summary", "", "| Case | Pass | First tool | Failures |", "| --- | --- | --- | --- |", ] for row in rows: failures = "; ".join(row["failures"]) if row["failures"] else "" lines.append( f"| `{row['id']}` | `{row['pass']}` | `{row['first_tool']}` | {failures} |" ) lines.extend(["", "## Details", ""]) for row in rows: lines.extend([ f"### {row['id']}", "", f"- User: `{row['user']}`", f"- Pass: `{row['pass']}`", f"- First tool: `{row['first_tool']}`", f"- All tools: `{', '.join(row['tool_names'])}`", f"- Active document changed: `{row['active_document_changed']}`", f"- Calendar event UIDs: `{', '.join(row['created_calendar_event_uids'])}`", "", "Final answer:", "", "```text", (row["final_answer"] or "")[:2000], "```", "", ]) if row["failures"]: lines.extend(["Failures:", ""]) lines.extend(f"- {failure}" for failure in row["failures"]) lines.append("") path.write_text("\n".join(lines) + "\n", encoding="utf-8") async def _amain(args: argparse.Namespace) -> int: out_dir = Path(args.out_dir) out_dir.mkdir(parents=True, exist_ok=True) results = [] with email_fixture(args.email_fixture, owner=args.owner): for case in CASES: results.append(await _run_case(case, args)) summary = { "total": len(results), "passed": sum(1 for row in results if row["pass"]), "failed": sum(1 for row in results if not row["pass"]), } payload = { "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "endpoint": args.endpoint, "model": args.model, "owner": args.owner, "timezone": args.timezone, "tz_offset_min": args.tz_offset_min, "summary": summary, "cases": CASES, "results": results, } json_path = out_dir / "actual_results.json" md_path = out_dir / "actual_results.md" json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") _write_markdown(md_path, payload) print(json.dumps({"summary": summary, "json": str(json_path), "md": str(md_path)}, indent=2)) return 0 if summary["failed"] == 0 else 1 def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--endpoint", default=DEFAULT_ENDPOINT) parser.add_argument("--model", default=DEFAULT_MODEL) parser.add_argument("--owner", default=DEFAULT_OWNER) parser.add_argument("--timezone", default=DEFAULT_TZ) parser.add_argument("--tz-offset-min", type=int, default=DEFAULT_TZ_OFFSET_MIN) parser.add_argument("--temperature", type=float, default=0) parser.add_argument("--max-tokens", type=int, default=768) parser.add_argument("--max-rounds", type=int, default=3) parser.add_argument("--max-tool-calls", type=int, default=6) parser.add_argument("--keep-mutations", action="store_true") parser.add_argument("--email-fixture", action="store_true", help="Use deterministic fixture email MCP for local eval runs.") parser.add_argument( "--out-dir", default=str(REPO_ROOT / "data/evals/ody_live_hard_examples_current"), ) return asyncio.run(_amain(parser.parse_args())) if __name__ == "__main__": raise SystemExit(main())