| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193 |
- """Parse agent run logs (.jsonl or legacy .log) into structured events."""
- from __future__ import annotations
- import json
- import re
- from pathlib import Path
- from typing import Any
- _TITLE_RE = re.compile(
- r"^(RUN START|RUN END|LLM INPUT|LLM OUTPUT|TOOL CALL|SKILL LOADED)"
- r"(?:\s*\|\s*(.+))?$",
- )
- def _title_to_event(title: str) -> tuple[str, dict[str, Any]]:
- """Map a human log title to (event_type, extra_fields)."""
- m = _TITLE_RE.match(title.strip())
- if not m:
- return "unknown", {"title": title}
- kind = m.group(1)
- rest = m.group(2) or ""
- extra: dict[str, Any] = {}
- iter_m = re.search(r"iteration=(\d+)", rest)
- if iter_m:
- extra["iteration"] = int(iter_m.group(1))
- mapping = {
- "RUN START": "run_start",
- "RUN END": "run_end",
- "LLM INPUT": "llm_input",
- "LLM OUTPUT": "llm_output",
- "TOOL CALL": "tool_call",
- "SKILL LOADED": "skill_loaded",
- }
- return mapping.get(kind, "unknown"), extra
- def parse_jsonl(path: Path) -> list[dict[str, Any]]:
- """Parse structured JSONL event stream."""
- events: list[dict[str, Any]] = []
- with path.open(encoding="utf-8") as fh:
- for line_no, line in enumerate(fh, 1):
- line = line.strip()
- if not line:
- continue
- try:
- events.append(json.loads(line))
- except json.JSONDecodeError as exc:
- raise ValueError(f"Invalid JSONL at {path}:{line_no}: {exc}") from exc
- return events
- def parse_legacy_log(path: Path) -> list[dict[str, Any]]:
- """Parse human-readable .log blocks into JSONL-compatible events."""
- text = path.read_text(encoding="utf-8")
- # Split on separator lines that follow a timestamp prefix
- parts = re.split(
- r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] =+\n",
- text,
- )
- events: list[dict[str, Any]] = []
- seq = 0
- for part in parts:
- part = part.strip()
- if not part:
- continue
- # Format: TITLE\n====\n{json}
- lines = part.split("\n", 2)
- if len(lines) < 3:
- continue
- title = lines[0].strip()
- # lines[1] is separator
- body = lines[2]
- # Strip trailing separator residue if any
- body = re.sub(r"\n=+\s*$", "", body).strip()
- try:
- data = json.loads(body)
- except json.JSONDecodeError:
- continue
- event_type, extra = _title_to_event(title)
- seq += 1
- # Normalize legacy llm_output shape: reasoning lived only under parsed/raw
- if event_type == "llm_output" and "content" not in data:
- parsed = data.get("parsed") or {}
- data = {
- **data,
- "content": parsed.get("content"),
- "reasoning": parsed.get("reasoning")
- or ((data.get("raw") or {}).get("choices") or [{}])[0]
- .get("message", {})
- .get("reasoning"),
- "tool_calls": parsed.get("tool_calls") or [],
- "usage": (data.get("raw") or {}).get("usage"),
- }
- if event_type == "tool_call" and "arguments_parsed" not in data:
- args = data.get("arguments", "")
- try:
- data["arguments_parsed"] = json.loads(args) if isinstance(args, str) else args
- except (json.JSONDecodeError, TypeError):
- data["arguments_parsed"] = args
- result = data.get("result", "")
- try:
- data["result_parsed"] = json.loads(result) if isinstance(result, str) else result
- except (json.JSONDecodeError, TypeError):
- data["result_parsed"] = result
- record: dict[str, Any] = {
- "event": event_type,
- "seq": seq,
- "run_id": data.get("run_id"),
- "data": data,
- }
- if "iteration" in extra:
- record["iteration"] = extra["iteration"]
- elif "iteration" in data:
- record["iteration"] = data["iteration"]
- events.append(record)
- return events
- def load_run_events(path: Path | str) -> list[dict[str, Any]]:
- """
- Load events from a run file.
- Accepts ``.jsonl``, ``.log``, or a stem without extension.
- Prefers ``.jsonl`` when both exist.
- """
- path = Path(path)
- if path.is_dir():
- raise ValueError(f"Expected a run file, got directory: {path}")
- candidates: list[Path] = []
- if path.suffix == ".jsonl":
- candidates = [path]
- elif path.suffix == ".log":
- jsonl = path.with_suffix(".jsonl")
- candidates = [jsonl, path] if jsonl.exists() else [path]
- elif path.exists():
- candidates = [path]
- else:
- # Stem lookup: run_xxx → try .jsonl then .log
- candidates = [path.with_suffix(".jsonl"), path.with_suffix(".log"), Path(str(path) + ".jsonl"), Path(str(path) + ".log")]
- for candidate in candidates:
- if not candidate.exists():
- continue
- if candidate.suffix == ".jsonl":
- return parse_jsonl(candidate)
- if candidate.suffix == ".log":
- return parse_legacy_log(candidate)
- # Unknown suffix: try jsonl lines first
- try:
- return parse_jsonl(candidate)
- except ValueError:
- return parse_legacy_log(candidate)
- raise FileNotFoundError(f"No run log found for: {path}")
- def summarize_run(events: list[dict[str, Any]]) -> dict[str, Any]:
- """Extract run-level metadata from event list."""
- start = next((e for e in events if e.get("event") == "run_start"), None)
- end = next((e for e in events if e.get("event") == "run_end"), None)
- start_data = (start or {}).get("data") or {}
- end_data = (end or {}).get("data") or {}
- iterations = {
- e.get("iteration")
- for e in events
- if e.get("iteration") is not None
- }
- tool_events = [e for e in events if e.get("event") == "tool_call"]
- return {
- "run_id": start_data.get("run_id") or end_data.get("run_id") or (start or {}).get("run_id"),
- "model": start_data.get("model"),
- "user_input": start_data.get("user_input"),
- "iterations": end_data.get("iterations") or (max(iterations) if iterations else 0),
- "tool_calls_made": end_data.get("tool_calls_made", len(tool_events)),
- "skills_used": end_data.get("skills_used") or [],
- "final_content": end_data.get("final_content"),
- "event_count": len(events),
- }
|