#!/usr/bin/env python3 """Execute generated SFT workflows through Odysseus with rollback and gating.""" from __future__ import annotations import argparse import contextlib import json import re import shutil import signal import time import uuid from pathlib import Path from typing import Any import httpx ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in __import__("sys").path: __import__("sys").path.insert(0, str(ROOT)) from core.database import ( # noqa: E402 CalendarCal, CalendarEvent, Document, DocumentVersion, Memory, Note, ScheduledTask, SessionLocal, ) from scripts.eval_odysseus_tool_use import ( # noqa: E402 _raise_for_status_with_body, _sse_events, _visible_event_text, ) DATA_DIR = ROOT / "data" BAD_ANSWER_RE = re.compile( r"\b(?:can't|cannot|don't have|do not have|not available|no .*tool|enable .*integration|" r"invalid credentials|not authenticated|i can only|i'm unable)\b", re.I, ) TOOL_FAILURE_RE = re.compile(r"(?:tool (?:failed|error)|exit_code[^\d]*[1-9]|permission denied|not found)", re.I) INTERNAL_NARRATION_RE = re.compile( r"(?:^|\n)(?:The user (?:asks|asked|wants)|I (?:should|need to|can see)|Let me (?:call|use|retry|try))\b", re.I, ) class CaseTimeoutError(TimeoutError): pass def timeout_handler(signum, frame): raise CaseTimeoutError("case exceeded wall-clock timeout") def atomic_json(path: Path, payload: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) temp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") temp.replace(path) def login(client: httpx.Client, base_url: str, owner: str, password: str) -> None: response = client.post( base_url.rstrip("/") + "/api/auth/login", json={"username": owner, "password": password, "remember": True}, timeout=30, ) _raise_for_status_with_body(response) if not response.json().get("ok"): raise RuntimeError(f"login failed for {owner}") def create_session(client: httpx.Client, args: argparse.Namespace, case: dict[str, Any]) -> str: response = client.post( args.base_url.rstrip("/") + "/api/session", data={ "name": f"SFT expansion {case['case_id']} {case['title']}", "endpoint_url": args.endpoint, "endpoint_id": args.endpoint_id, "model": args.model, "skip_validation": "true", "rag": "false", }, timeout=30, ) _raise_for_status_with_body(response) return str(response.json()["id"]) def stream_turn( client: httpx.Client, args: argparse.Namespace, session_id: str, prompt: str ) -> tuple[list[dict[str, Any]], str]: events: list[dict[str, Any]] = [] text: list[str] = [] form = { "message": prompt, "session": session_id, "mode": "agent", "agent_prompt_mode": "auto", "selected_endpoint_id": args.endpoint_id, "selected_endpoint_url": args.endpoint, "selected_model": args.model, "client_runtime_context": json.dumps( {"timezone": args.timezone, "tz_offset_min": args.tz_offset_min}, separators=(",", ":") ), } with client.stream( "POST", args.base_url.rstrip("/") + "/api/chat_stream", data=form, headers={ "Accept": "text/event-stream", "X-Tz-Name": args.timezone, "X-Tz-Offset": str(args.tz_offset_min), }, timeout=args.turn_timeout, ) as response: _raise_for_status_with_body(response) for event in _sse_events(response): events.append(event) if event.get("thinking") is True or event.get("type") in {"thinking", "reasoning"}: continue visible = _visible_event_text(event) if visible: if event.get("type") == "final_response": text[:] = [visible] else: text.append(visible) return events, "".join(text).strip() def normalized_tool(name: str) -> str: value = name.removeprefix("mcp__").split("__")[-1] if name.startswith("mcp__builtin_browser__") or value.startswith("browser_"): return "private_browser" return value def tool_names(events: list[dict[str, Any]]) -> list[str]: names = [] for event in events: if event.get("type") == "tool_start" and event.get("tool"): names.append(normalized_tool(str(event["tool"]))) return names def tool_outputs(events: list[dict[str, Any]]) -> str: return "\n".join(str(e.get("output") or "") for e in events if e.get("type") == "tool_output") def tool_actions(events: list[dict[str, Any]], tool_name: str) -> set[str]: actions: set[str] = set() for event in events: if event.get("type") != "tool_start" or normalized_tool(str(event.get("tool") or "")) != tool_name: continue command = str(event.get("full_command") or event.get("command") or "").strip() try: parsed = json.loads(command) except (TypeError, ValueError, json.JSONDecodeError): parsed = None action = ( str(parsed.get("action") or "").strip().lower() if isinstance(parsed, dict) else command.splitlines()[0].strip().lower().split(maxsplit=1)[0] ) if action: actions.add(action) return actions def inferred_expected_actions(turn: dict[str, Any]) -> dict[str, set[str]]: explicit = turn.get("expected_actions") or {} if isinstance(explicit, dict) and explicit: return { normalized_tool(str(tool)): {str(action).lower() for action in actions} for tool, actions in explicit.items() if isinstance(actions, list) } prompt = str(turn.get("prompt") or "").lower() if "manage_calendar" not in set(turn.get("expected_tools") or []): return {} if re.search(r"\b(?:add|create|schedule|book|set up)\b", prompt): return {"manage_calendar": {"create", "create_event", "add", "add_event"}} if re.search(r"\b(?:delete|remove|cancel|get rid of)\b", prompt): return {"manage_calendar": {"delete", "delete_event", "remove", "remove_event", "cancel"}} if re.search(r"\b(?:move|shift|reschedule|change|update|edit|rename|tag|retag)\b", prompt): return {"manage_calendar": {"update", "update_event", "move", "reschedule", "edit_event"}} if re.search(r"\b(?:show|list|check|find|what|when|confirm|verify|pull up)\b", prompt): return {"manage_calendar": {"list", "list_events", "search", "find", "view"}} return {} def score_turn(turn: dict[str, Any], events: list[dict[str, Any]], answer: str) -> list[str]: failures: list[str] = [] names = tool_names(events) expected = {normalized_tool(str(name)) for name in turn.get("expected_tools") or []} if expected and not expected.intersection(names): failures.append(f"missing_acceptable_tool expected={sorted(expected)} got={names}") for tool_name, expected_actions in inferred_expected_actions(turn).items(): observed_actions = tool_actions(events, tool_name) if expected_actions and not expected_actions.intersection(observed_actions): failures.append( f"missing_tool_action tool={tool_name} expected={sorted(expected_actions)} " f"got={sorted(observed_actions)}" ) if any(e.get("type") in {"error", "parse_error"} for e in events): failures.append("stream_error") if BAD_ANSWER_RE.search(answer): failures.append("tool_unavailable_answer") output = tool_outputs(events) if TOOL_FAILURE_RE.search(output): failures.append("tool_output_failure") if INTERNAL_NARRATION_RE.search(answer): failures.append("internal_narration_leaked") if not answer.strip() and "ask_user" not in names: failures.append("empty_final_answer") return failures def row_dict(row: Any) -> dict[str, Any]: return {column.name: getattr(row, column.name) for column in row.__table__.columns} class OwnerSnapshot: MODELS = (Note, Memory, ScheduledTask, Document) def __init__(self, owner: str, tools: set[str], marker: str): self.owner = owner self.tools = tools self.marker = marker.lower() self.rows: dict[str, list[dict[str, Any]]] = {} self.prefs: Any = None self.email_rows: list[dict[str, Any]] | None = None self.blocked_senders: Any = None def capture(self) -> None: db = SessionLocal() try: selected = [] has_email_tools = any(tool.startswith("mcp__email__") for tool in self.tools) if "manage_notes" in self.tools: selected.append(Note) if "manage_memory" in self.tools: selected.append(Memory) if "manage_tasks" in self.tools: selected.append(ScheduledTask) if has_email_tools or { "manage_documents", "create_document", "edit_document", "update_document", "suggest_document" } & self.tools: selected.append(Document) for model in selected: values = db.query(model).filter(model.owner == self.owner).all() self.rows[model.__tablename__] = [row_dict(row) for row in values] document_ids = [row["id"] for row in self.rows.get(Document.__tablename__, [])] versions = db.query(DocumentVersion).filter(DocumentVersion.document_id.in_(document_ids)).all() if document_ids else [] self.rows[DocumentVersion.__tablename__] = [row_dict(row) for row in versions] calendars = db.query(CalendarCal).filter(CalendarCal.owner == self.owner).all() if "manage_calendar" in self.tools else [] self.rows[CalendarCal.__tablename__] = [row_dict(row) for row in calendars] calendar_ids = [row.id for row in calendars] events = db.query(CalendarEvent).filter(CalendarEvent.calendar_id.in_(calendar_ids)).all() if calendar_ids else [] self.rows[CalendarEvent.__tablename__] = [row_dict(row) for row in events] finally: db.close() prefs_path = DATA_DIR / "user_prefs.json" prefs = json.loads(prefs_path.read_text(encoding="utf-8")) if prefs_path.exists() else {"_users": {}} if "ui_control" in self.tools: self.prefs = (prefs.get("_users") or {}).get(self.owner, None) if any(tool.startswith("mcp__email__") for tool in self.tools): email_path = DATA_DIR / "fixture_email_messages.json" if email_path.exists(): payload = json.loads(email_path.read_text(encoding="utf-8")) values = payload.get("messages") if isinstance(payload, dict) else payload self.email_rows = [ row for row in (values if isinstance(values, list) else []) if isinstance(row, dict) and str(row.get("owner") or "") == self.owner ] blocked_path = DATA_DIR / "email_blocked_senders.json" if blocked_path.exists(): blocked = json.loads(blocked_path.read_text(encoding="utf-8")) self.blocked_senders = (blocked.get("owners") or {}).get(self.owner) def restore(self) -> None: db = SessionLocal() try: if Document.__tablename__ in self.rows: document_ids = [value[0] for value in db.query(Document.id).filter(Document.owner == self.owner).all()] if document_ids: db.query(DocumentVersion).filter(DocumentVersion.document_id.in_(document_ids)).delete(synchronize_session=False) db.query(Document).filter(Document.owner == self.owner).delete(synchronize_session=False) if Note.__tablename__ in self.rows: db.query(Note).filter(Note.owner == self.owner).delete(synchronize_session=False) if Memory.__tablename__ in self.rows: db.query(Memory).filter(Memory.owner == self.owner).delete(synchronize_session=False) if ScheduledTask.__tablename__ in self.rows: db.query(ScheduledTask).filter(ScheduledTask.owner == self.owner).delete(synchronize_session=False) if CalendarCal.__tablename__ in self.rows: calendar_ids = [value[0] for value in db.query(CalendarCal.id).filter(CalendarCal.owner == self.owner).all()] if calendar_ids: db.query(CalendarEvent).filter(CalendarEvent.calendar_id.in_(calendar_ids)).delete(synchronize_session=False) db.query(CalendarCal).filter(CalendarCal.owner == self.owner).delete(synchronize_session=False) db.flush() for model in (Note, Memory, ScheduledTask, Document, DocumentVersion, CalendarCal, CalendarEvent): for values in self.rows.get(model.__tablename__, []): db.add(model(**values)) db.commit() except Exception: db.rollback() raise finally: db.close() if "manage_skills" in self.tools: skills = DATA_DIR / "skills" if skills.exists(): for path in sorted(skills.rglob("*"), key=lambda item: len(item.parts), reverse=True): if self.marker not in path.name.lower(): continue if path.is_dir(): shutil.rmtree(path, ignore_errors=True) else: path.unlink(missing_ok=True) usage_path = skills / "_usage.json" if usage_path.exists(): usage = json.loads(usage_path.read_text(encoding="utf-8")) if isinstance(usage, dict): usage = { key: value for key, value in usage.items() if self.marker not in str(key).lower() } atomic_json(usage_path, usage) if "ui_control" in self.tools: prefs_path = DATA_DIR / "user_prefs.json" prefs = json.loads(prefs_path.read_text(encoding="utf-8")) if prefs_path.exists() else {"_users": {}} users = prefs.setdefault("_users", {}) if self.prefs is None: users.pop(self.owner, None) else: users[self.owner] = self.prefs atomic_json(prefs_path, prefs) if self.email_rows is not None: email_path = DATA_DIR / "fixture_email_messages.json" payload = json.loads(email_path.read_text(encoding="utf-8")) if email_path.exists() else {"messages": []} values = payload.get("messages") if isinstance(payload, dict) else payload other_rows = [ row for row in (values if isinstance(values, list) else []) if not (isinstance(row, dict) and str(row.get("owner") or "") == self.owner) ] if isinstance(payload, dict): payload["messages"] = other_rows + self.email_rows else: payload = other_rows + self.email_rows atomic_json(email_path, payload) blocked_path = DATA_DIR / "email_blocked_senders.json" blocked = json.loads(blocked_path.read_text(encoding="utf-8")) if blocked_path.exists() else {"owners": {}} owners = blocked.setdefault("owners", {}) if self.blocked_senders is None: owners.pop(self.owner, None) else: owners[self.owner] = self.blocked_senders atomic_json(blocked_path, blocked) def marker_fields(value: Any, marker: str) -> Any: if isinstance(value, str): return value.replace("{marker}", marker) if isinstance(value, list): return [marker_fields(item, marker) for item in value] if isinstance(value, dict): return {key: marker_fields(item, marker) for key, item in value.items()} return value def apply_fixture_plan(case: dict[str, Any], owner: str, session_id: str, marker: str) -> None: """Create only owner-scoped local fixtures required before the first turn.""" first_tools = set((case.get("turns") or [{}])[0].get("expected_tools") or []) db = SessionLocal() try: for fixture in case.get("fixture_plan") or []: if not isinstance(fixture, dict): continue fixture_type = str(fixture.get("type") or "") fields = marker_fields(fixture.get("fields") or {}, marker) if fixture_type == "document" and "create_document" not in first_tools: document_id = str(uuid.uuid4()) content = str(fields.get("content") or "") db.add(Document( id=document_id, session_id=session_id, owner=owner, title=str(fields.get("title") or "Untitled"), language=str(fields.get("language") or "text"), current_content=content, version_count=1, is_active=True, archived=False, )) db.add(DocumentVersion( id=str(uuid.uuid4()), document_id=document_id, version_number=1, content=content, summary="Expansion fixture", source="user", )) elif fixture_type == "note": db.add(Note( id=str(uuid.uuid4()), owner=owner, title=str(fields.get("title") or ""), content=str(fields.get("content") or ""), items=json.dumps(fields.get("items"), ensure_ascii=False) if fields.get("items") is not None else None, note_type=str(fields.get("note_type") or "note"), label=fields.get("label"), pinned=bool(fields.get("pinned", False)), source="user", session_id=session_id, )) db.commit() except Exception: db.rollback() raise finally: db.close() def delete_session(client: httpx.Client, base_url: str, session_id: str) -> None: with contextlib.suppress(Exception): client.delete(base_url.rstrip("/") + f"/api/session/{session_id}", timeout=30) def annotate_trace(owner: str, session_id: str, case: dict[str, Any], marker: str) -> int: path = DATA_DIR / "sft_traces" / f"{owner}.jsonl" if not path.exists(): return 0 changed = 0 lines = [] for raw in path.read_text(encoding="utf-8").splitlines(): if not raw.strip(): continue row = json.loads(raw) if str(row.get("session_id") or "") == session_id: metadata = row.get("metadata") or {} if isinstance(metadata, str): with contextlib.suppress(json.JSONDecodeError): metadata = json.loads(metadata) if not isinstance(metadata, dict): metadata = {} metadata.update({ "expansion_case_id": case["case_id"], "seed_family_id": case["seed_family_id"], "source_session_id": case["source_session_id"], "dataset_split": case["split"], "target_owner": owner, "fixture_marker": marker, }) row["metadata"] = metadata changed += 1 lines.append(json.dumps(row, ensure_ascii=False)) path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8") return changed def run_case(args: argparse.Namespace, case: dict[str, Any]) -> dict[str, Any]: owner = case["owner"] marker = f"EXP-{case['case_id']}-{uuid.uuid4().hex[:6]}" session_id = "" turns_out = [] failures: list[str] = [] started = time.time() case_tools = {tool for turn in case["turns"] for tool in turn.get("expected_tools") or []} snapshot = OwnerSnapshot(owner, case_tools, marker) old_handler = signal.getsignal(signal.SIGALRM) signal.signal(signal.SIGALRM, timeout_handler) signal.setitimer(signal.ITIMER_REAL, max(1, args.case_timeout)) client = httpx.Client(follow_redirects=False) try: snapshot.capture() login(client, args.base_url, owner, args.password) session_id = create_session(client, args, case) apply_fixture_plan(case, owner, session_id, marker) for turn in case["turns"]: prompt = str(turn["prompt"]).replace("{marker}", marker) events, answer = stream_turn(client, args, session_id, prompt) turn_failures = score_turn(turn, events, answer) turns_out.append({ "id": turn["id"], "prompt": prompt, "expected_tools": turn["expected_tools"], "observed_tools": tool_names(events), "answer": answer, "failures": turn_failures, }) failures.extend(f"{turn['id']}:{failure}" for failure in turn_failures) if turn_failures: break except Exception as exc: failures.append(f"exception:{exc!r}") finally: with contextlib.suppress(Exception): snapshot.restore() signal.setitimer(signal.ITIMER_REAL, 0) signal.signal(signal.SIGALRM, old_handler) client.close() passed = not failures and len(turns_out) == len(case["turns"]) with httpx.Client(follow_redirects=False) as cleanup_client: with contextlib.suppress(Exception): login(cleanup_client, args.base_url, owner, args.password) if passed: annotated = annotate_trace(owner, session_id, case, marker) if annotated != len(case["turns"]): failures.append(f"trace_turn_count expected={len(case['turns'])} got={annotated}") passed = False if not passed and session_id: delete_session(cleanup_client, args.base_url, session_id) return { "case_id": case["case_id"], "seed_family_id": case["seed_family_id"], "owner": owner, "session_id": session_id, "pass": passed, "failures": failures, "turns": turns_out, "elapsed_seconds": round(time.time() - started, 3), } def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--cases", type=Path, required=True) parser.add_argument("--out", type=Path, required=True) parser.add_argument("--base-url", default="http://127.0.0.1:7011") parser.add_argument("--password", default="SftDemo!2026") parser.add_argument("--endpoint-id", default="f3904562") parser.add_argument("--endpoint", default="https://openrouter.ai/api/v1/chat/completions") parser.add_argument("--model", default="moonshotai/kimi-k3") parser.add_argument("--turn-timeout", type=float, default=180) parser.add_argument("--case-timeout", type=float, default=600) parser.add_argument("--timezone", default="Asia/Tokyo") parser.add_argument("--tz-offset-min", type=int, default=-540) parser.add_argument("--limit", type=int) parser.add_argument("--owner", action="append") parser.add_argument("--case-id", action="append") args = parser.parse_args() cases = json.loads(args.cases.read_text(encoding="utf-8"))["cases"] if args.owner: cases = [case for case in cases if case["owner"] in set(args.owner)] if args.case_id: cases = [case for case in cases if case["case_id"] in set(args.case_id)] if args.limit: cases = cases[: args.limit] existing = {row["case_id"]: row for row in json.loads(args.out.read_text(encoding="utf-8")).get("results", [])} if args.out.exists() else {} for index, case in enumerate(cases, 1): if existing.get(case["case_id"], {}).get("pass") is True: print(f"skip {case['case_id']} already passed", flush=True) continue print(f"[{index}/{len(cases)}] {case['owner']} {case['title']}", flush=True) result = run_case(args, case) existing[case["case_id"]] = result atomic_json(args.out, {"results": list(existing.values())}) print(f" pass={result['pass']} failures={result['failures']} elapsed={result['elapsed_seconds']}s", flush=True) results = list(existing.values()) print(json.dumps({ "cases": len(results), "passed": sum(row.get("pass") is True for row in results), "failed": sum(row.get("pass") is not True for row in results), }, indent=2)) if __name__ == "__main__": main()