parser.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. """Parse agent run logs (.jsonl or legacy .log) into structured events."""
  2. from __future__ import annotations
  3. import json
  4. import re
  5. from pathlib import Path
  6. from typing import Any
  7. _TITLE_RE = re.compile(
  8. r"^(RUN START|RUN END|LLM INPUT|LLM OUTPUT|TOOL CALL|SKILL LOADED)"
  9. r"(?:\s*\|\s*(.+))?$",
  10. )
  11. def _title_to_event(title: str) -> tuple[str, dict[str, Any]]:
  12. """Map a human log title to (event_type, extra_fields)."""
  13. m = _TITLE_RE.match(title.strip())
  14. if not m:
  15. return "unknown", {"title": title}
  16. kind = m.group(1)
  17. rest = m.group(2) or ""
  18. extra: dict[str, Any] = {}
  19. iter_m = re.search(r"iteration=(\d+)", rest)
  20. if iter_m:
  21. extra["iteration"] = int(iter_m.group(1))
  22. mapping = {
  23. "RUN START": "run_start",
  24. "RUN END": "run_end",
  25. "LLM INPUT": "llm_input",
  26. "LLM OUTPUT": "llm_output",
  27. "TOOL CALL": "tool_call",
  28. "SKILL LOADED": "skill_loaded",
  29. }
  30. return mapping.get(kind, "unknown"), extra
  31. def parse_jsonl(path: Path) -> list[dict[str, Any]]:
  32. """Parse structured JSONL event stream."""
  33. events: list[dict[str, Any]] = []
  34. with path.open(encoding="utf-8") as fh:
  35. for line_no, line in enumerate(fh, 1):
  36. line = line.strip()
  37. if not line:
  38. continue
  39. try:
  40. events.append(json.loads(line))
  41. except json.JSONDecodeError as exc:
  42. raise ValueError(f"Invalid JSONL at {path}:{line_no}: {exc}") from exc
  43. return events
  44. def parse_legacy_log(path: Path) -> list[dict[str, Any]]:
  45. """Parse human-readable .log blocks into JSONL-compatible events."""
  46. text = path.read_text(encoding="utf-8")
  47. # Split on separator lines that follow a timestamp prefix
  48. parts = re.split(
  49. r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] =+\n",
  50. text,
  51. )
  52. events: list[dict[str, Any]] = []
  53. seq = 0
  54. for part in parts:
  55. part = part.strip()
  56. if not part:
  57. continue
  58. # Format: TITLE\n====\n{json}
  59. lines = part.split("\n", 2)
  60. if len(lines) < 3:
  61. continue
  62. title = lines[0].strip()
  63. # lines[1] is separator
  64. body = lines[2]
  65. # Strip trailing separator residue if any
  66. body = re.sub(r"\n=+\s*$", "", body).strip()
  67. try:
  68. data = json.loads(body)
  69. except json.JSONDecodeError:
  70. continue
  71. event_type, extra = _title_to_event(title)
  72. seq += 1
  73. # Normalize legacy llm_output shape: reasoning lived only under parsed/raw
  74. if event_type == "llm_output" and "content" not in data:
  75. parsed = data.get("parsed") or {}
  76. data = {
  77. **data,
  78. "content": parsed.get("content"),
  79. "reasoning": parsed.get("reasoning")
  80. or ((data.get("raw") or {}).get("choices") or [{}])[0]
  81. .get("message", {})
  82. .get("reasoning"),
  83. "tool_calls": parsed.get("tool_calls") or [],
  84. "usage": (data.get("raw") or {}).get("usage"),
  85. }
  86. if event_type == "tool_call" and "arguments_parsed" not in data:
  87. args = data.get("arguments", "")
  88. try:
  89. data["arguments_parsed"] = json.loads(args) if isinstance(args, str) else args
  90. except (json.JSONDecodeError, TypeError):
  91. data["arguments_parsed"] = args
  92. result = data.get("result", "")
  93. try:
  94. data["result_parsed"] = json.loads(result) if isinstance(result, str) else result
  95. except (json.JSONDecodeError, TypeError):
  96. data["result_parsed"] = result
  97. record: dict[str, Any] = {
  98. "event": event_type,
  99. "seq": seq,
  100. "run_id": data.get("run_id"),
  101. "data": data,
  102. }
  103. if "iteration" in extra:
  104. record["iteration"] = extra["iteration"]
  105. elif "iteration" in data:
  106. record["iteration"] = data["iteration"]
  107. events.append(record)
  108. return events
  109. def load_run_events(path: Path | str) -> list[dict[str, Any]]:
  110. """
  111. Load events from a run file.
  112. Accepts ``.jsonl``, ``.log``, or a stem without extension.
  113. Prefers ``.jsonl`` when both exist.
  114. """
  115. path = Path(path)
  116. if path.is_dir():
  117. raise ValueError(f"Expected a run file, got directory: {path}")
  118. candidates: list[Path] = []
  119. if path.suffix == ".jsonl":
  120. candidates = [path]
  121. elif path.suffix == ".log":
  122. jsonl = path.with_suffix(".jsonl")
  123. candidates = [jsonl, path] if jsonl.exists() else [path]
  124. elif path.exists():
  125. candidates = [path]
  126. else:
  127. # Stem lookup: run_xxx → try .jsonl then .log
  128. candidates = [path.with_suffix(".jsonl"), path.with_suffix(".log"), Path(str(path) + ".jsonl"), Path(str(path) + ".log")]
  129. for candidate in candidates:
  130. if not candidate.exists():
  131. continue
  132. if candidate.suffix == ".jsonl":
  133. return parse_jsonl(candidate)
  134. if candidate.suffix == ".log":
  135. return parse_legacy_log(candidate)
  136. # Unknown suffix: try jsonl lines first
  137. try:
  138. return parse_jsonl(candidate)
  139. except ValueError:
  140. return parse_legacy_log(candidate)
  141. raise FileNotFoundError(f"No run log found for: {path}")
  142. def summarize_run(events: list[dict[str, Any]]) -> dict[str, Any]:
  143. """Extract run-level metadata from event list."""
  144. start = next((e for e in events if e.get("event") == "run_start"), None)
  145. end = next((e for e in events if e.get("event") == "run_end"), None)
  146. start_data = (start or {}).get("data") or {}
  147. end_data = (end or {}).get("data") or {}
  148. iterations = {
  149. e.get("iteration")
  150. for e in events
  151. if e.get("iteration") is not None
  152. }
  153. tool_events = [e for e in events if e.get("event") == "tool_call"]
  154. return {
  155. "run_id": start_data.get("run_id") or end_data.get("run_id") or (start or {}).get("run_id"),
  156. "model": start_data.get("model"),
  157. "user_input": start_data.get("user_input"),
  158. "iterations": end_data.get("iterations") or (max(iterations) if iterations else 0),
  159. "tool_calls_made": end_data.get("tool_calls_made", len(tool_events)),
  160. "skills_used": end_data.get("skills_used") or [],
  161. "final_content": end_data.get("final_content"),
  162. "event_count": len(events),
  163. }