oc_chat
Loading...
Searching...
No Matches
replay.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/replay.py
4
5"""
6Reconstruct an OC Chat session as a parent/child tree from its event NDJSON — M2.7.
7
8Purpose
9-------
10M2.1–M2.6 produce and (in-session) read a per-session event log. This module is
11the **offline** consumer: given an event directory, it reads every rotation slot,
12reassembles the flat event list into a turn/tool-call/sub-agent tree, and renders
13that tree as human-readable text or canonical ``replay/v0`` JSON. It never calls a
14provider, never mutates the log, never touches the bus or writer — observability
15only (the half-step between M2.6 ``/trace`` and the M7 packet).
16
17Responsibilities
18----------------
19- :func:`read_events_from_dir` — directory-keyed twin of
20 ``trace_view.read_session_events`` (ADR-4): walk ``events.NNNN.jsonl`` slots in
21 sequence, parse to ``TurnEvent``, tolerate missing dirs and torn lines.
22- :func:`build_replay_tree` — pure two-pass reduce: group by ``turn_id``, attach
23 tool calls by ``tool_call_id`` and sub-agents by ``subagent_session_id``.
24- :func:`render_tree_text` / :func:`render_tree_json` — the two output formats;
25 JSON is canonical (byte-stable) for M2.9 golden fixtures.
26
27Diagnostics
28-----------
29Domain: OC_CHAT.REPLAY
30Levels:
31 L2 — read/build lifecycle.
32 L3 — slot/line counts and skipped malformed lines.
33 L4 — module-load coverage marker.
34
35Contracts
36---------
37- Reading never raises on missing dirs or torn lines (returns ``([], 0)`` / skips).
38- ``build_replay_tree`` is pure; it does no I/O.
39- ``render_tree_json`` is canonical and byte-stable; schema is ``"replay/v0"``.
40
41Dependencies
42------------
43- oc_diagnostics (structured debug logging via _dbg)
44- oc_chat.core.events (TurnEvent — parse persisted lines)
45- option_c_agent_contracts (canonical_json — byte-stable JSON output)
46- pydantic (BaseModel — the replay-tree node models)
47"""
48from __future__ import annotations
49
50import re
51from pathlib import Path
52from typing import Any, Optional
53
54from pydantic import BaseModel, Field
55
56from oc_diagnostics import _dbg
57
58from oc_chat.core.events import TurnEvent
59from option_c_agent_contracts import canonical_json
60
61_dbg("OC_CHAT.REPLAY", "replay", "module", 4)
62
63REPLAY_SCHEMA = "replay/v0"
64_SLOT_RE = re.compile(r"^events\.(\d+)\.jsonl$")
65
66
67def _slot_seq(path: Path) -> int:
68 """Return the rotation sequence encoded in an ``events.NNNN.jsonl`` filename."""
69 match = _SLOT_RE.match(path.name)
70 return int(match.group(1)) if match else 0
71
72
73def read_events_from_dir(event_dir: Path) -> tuple[list[TurnEvent], int]:
74 """Read every ``events.NNNN.jsonl`` slot under *event_dir* in sequence order.
75
76 Returns ``(events, skipped)``. A missing dir yields ``([], 0)``; a torn or
77 malformed line is skipped and counted; non-matching filenames are ignored.
78 Deliberately duplicates the small slot-walk from ``trace_view`` (ADR-4) so
79 that frozen module is left untouched; the on-disk layout is itself frozen
80 (``event_writer.py``), so the two readers cannot drift.
81 """
82 func = "read_events_from_dir"
83 base = Path(event_dir)
84 if not base.exists():
85 _dbg("OC_CHAT.REPLAY", func, f"no event dir at {base}", 3)
86 return [], 0
87 slots = sorted(
88 (p for p in base.iterdir() if p.is_file() and _SLOT_RE.match(p.name)),
89 key=_slot_seq,
90 )
91 events: list[TurnEvent] = []
92 skipped = 0
93 for slot in slots:
94 for line in slot.read_text(encoding="utf-8").splitlines():
95 if not line.strip():
96 continue
97 try:
98 events.append(TurnEvent.model_validate_json(line))
99 except Exception:
100 skipped += 1
101 _dbg("OC_CHAT.REPLAY", func, f"read {len(events)} events from {len(slots)} slot(s); skipped={skipped}", 3)
102 return events, skipped
103
104
105class ToolCallNode(BaseModel):
106 """One tool call within a turn, assembled from proposed/started/completed events."""
107
108 tool_call_id: str
109 tool_name: str = ""
110 proposed_args: dict[str, Any] = Field(default_factory=dict)
111 outcome: Optional[str] = None
112 ts_proposed: Optional[str] = None
113 ts_started: Optional[str] = None
114 ts_completed: Optional[str] = None
115
116
117class SubagentNode(BaseModel):
118 """One sub-agent run nested under its parent turn (depth-1; ADR-5)."""
119
120 subagent_session_id: str
121 parent_turn_id: str
122 outcome: Optional[str] = None
123 ts_started: Optional[str] = None
124 ts_completed: Optional[str] = None
125
126
127class TurnNode(BaseModel):
128 """A single turn: user input, tool calls, sub-agents, final answer, errors."""
129
130 turn_id: str
131 ts_first: Optional[str] = None
132 ts_last: Optional[str] = None
133 user_input: Optional[str] = None
134 assistant_message: Optional[str] = None
135 assistant_is_final: bool = False
136 assistant_message_history: list[str] = Field(default_factory=list)
137 tool_calls: list[ToolCallNode] = Field(default_factory=list)
138 subagents: list[SubagentNode] = Field(default_factory=list)
139 errors: list[str] = Field(default_factory=list)
140 other_events: list[dict] = Field(default_factory=list)
141
142
143class ReplayTree(BaseModel):
144 """The reconstructed session: ordered turns + stream-level counts."""
145
146 session_id: str = ""
147 turn_count: int = 0
148 event_count: int = 0
149 skipped_count: int = 0
150 source_dir: Optional[str] = None
151 event_counts: dict[str, int] = Field(default_factory=dict)
152 turns: list[TurnNode] = Field(default_factory=list)
153
154
155_TOOL_KINDS = {"tool_call_proposed", "tool_call_started", "tool_call_completed", "tool_call_progress"}
156_SUBAGENT_KINDS = {"subagent_started", "subagent_completed"}
157_KNOWN_KINDS = (
158 {"user_input_received", "assistant_message", "error"} | _TOOL_KINDS | _SUBAGENT_KINDS
159)
160
161
162def _build_turn_node(turn_id: str, events: list[TurnEvent]) -> TurnNode:
163 """Assemble one :class:`TurnNode` from the events sharing *turn_id* (write order)."""
164 node = TurnNode(turn_id=turn_id, ts_first=events[0].ts, ts_last=events[-1].ts)
165 tool_nodes: dict[str, ToolCallNode] = {}
166 proposed_q: list[Any] = []
167 sub_nodes: dict[str, SubagentNode] = {}
168
169 def _tool(tcid: str) -> ToolCallNode:
170 if tcid not in tool_nodes:
171 tn = ToolCallNode(tool_call_id=tcid)
172 tool_nodes[tcid] = tn
173 node.tool_calls.append(tn)
174 return tool_nodes[tcid]
175
176 for ev in events:
177 p: Any = ev.payload
178 kind = ev.kind
179 if kind == "user_input_received":
180 node.user_input = getattr(p, "text", None)
181 elif kind == "assistant_message":
182 text = getattr(p, "text", "")
183 if getattr(p, "is_final", False):
184 node.assistant_message = text
185 node.assistant_is_final = True
186 else:
187 node.assistant_message_history.append(text)
188 elif kind == "tool_call_proposed":
189 proposed_q.append(p)
190 elif kind == "tool_call_started":
191 tn = _tool(ev.tool_call_id or getattr(p, "tool_call_id", "") or "?")
192 tn.tool_name = getattr(p, "tool_name", "") or tn.tool_name
193 tn.ts_started = ev.ts
194 if proposed_q:
195 prop = proposed_q.pop(0)
196 tn.proposed_args = dict(getattr(prop, "requested_args", {}) or {})
197 tn.ts_proposed = tn.ts_proposed or None
198 elif kind == "tool_call_completed":
199 tn = _tool(ev.tool_call_id or getattr(p, "tool_call_id", "") or "?")
200 tn.outcome = getattr(p, "outcome", None)
201 tn.ts_completed = ev.ts
202 elif kind in _SUBAGENT_KINDS:
203 sid = getattr(p, "subagent_session_id", "") or "?"
204 sn = sub_nodes.get(sid)
205 if sn is None:
206 sn = SubagentNode(subagent_session_id=sid, parent_turn_id=turn_id)
207 sub_nodes[sid] = sn
208 node.subagents.append(sn)
209 if kind == "subagent_started":
210 sn.ts_started = ev.ts
211 else:
212 sn.outcome = getattr(p, "outcome", None)
213 sn.ts_completed = ev.ts
214 elif kind == "error":
215 node.errors.append(getattr(p, "error_summary", "") or "")
216 else:
217 node.other_events.append(ev.model_dump(mode="json"))
218 return node
219
220
221def build_replay_tree(events: list[TurnEvent], *, skipped: int = 0) -> ReplayTree:
222 """Reduce a flat event list into a :class:`ReplayTree` (pure; two-pass, O(N))."""
223 session_id = ""
224 order: list[str] = []
225 buckets: dict[str, list[TurnEvent]] = {}
226 counts: dict[str, int] = {}
227 for ev in events:
228 counts[ev.kind] = counts.get(ev.kind, 0) + 1
229 if ev.session_id and not session_id:
230 session_id = ev.session_id
231 tid = ev.turn_id
232 if not tid:
233 continue # session_started / session_ended — no turn bucket
234 if tid not in buckets:
235 buckets[tid] = []
236 order.append(tid)
237 buckets[tid].append(ev)
238 nodes = [(_build_turn_node(tid, buckets[tid]), idx) for idx, tid in enumerate(order)]
239 nodes.sort(key=lambda pair: (pair[0].ts_first or "", pair[1]))
240 return ReplayTree(
241 session_id=session_id,
242 turn_count=len(nodes),
243 event_count=len(events),
244 skipped_count=skipped,
245 event_counts=counts,
246 turns=[node for node, _ in nodes],
247 )
248
249
250def _filter_turns(tree: ReplayTree, single_turn_id: Optional[str]) -> list[TurnNode]:
251 """Return all turns, or only the one whose id matches *single_turn_id* exactly."""
252 if single_turn_id is None:
253 return list(tree.turns)
254 return [t for t in tree.turns if t.turn_id == single_turn_id]
255
256
257def render_tree_text(tree: ReplayTree, *, single_turn_id: Optional[str] = None) -> str:
258 """Render *tree* as an indented ASCII tree + a per-kind count summary."""
259 if tree.event_count == 0:
260 return "── Replay: empty session (0 events) ──"
261 turns = _filter_turns(tree, single_turn_id)
262 head = (
263 f"── Replay: session {(tree.session_id or '-')[:8]} ──\n"
264 f" events: {tree.event_count} turns: {tree.turn_count} "
265 f"skipped: {tree.skipped_count} source: {tree.source_dir or '-'}"
266 )
267 lines = [head]
268 for ti, turn in enumerate(turns):
269 last_turn = ti == len(turns) - 1
270 tbranch = "└──" if last_turn else "├──"
271 gutter = " " if last_turn else "│ "
272 lines.append(f"{tbranch} turn {turn.turn_id[:4]} [{turn.ts_first} → {turn.ts_last}]")
273 if turn.user_input is not None:
274 lines.append(f"{gutter} user: {turn.user_input!r}")
275 for tc in turn.tool_calls:
276 lines.append(f"{gutter} ├── tool {tc.tool_name} id={tc.tool_call_id[:8]} outcome={tc.outcome or '-'}")
277 for sa in turn.subagents:
278 lines.append(f"{gutter} ├── subagent {sa.subagent_session_id[:4]} outcome={sa.outcome or '-'}")
279 for err in turn.errors:
280 lines.append(f"{gutter} ├── error: {err!r}")
281 if turn.assistant_message is not None:
282 final = "final" if turn.assistant_is_final else "intermediate"
283 lines.append(f"{gutter} └── assistant ({final}): {turn.assistant_message!r}")
284 if turn.other_events:
285 lines.append(f"{gutter} ({len(turn.other_events)} other event(s))")
286 lines.append("── Summary ──")
287 for kind in sorted(tree.event_counts):
288 lines.append(f" {kind:<24} {tree.event_counts[kind]}")
289 return "\n".join(lines)
290
291
292def render_tree_json(tree: ReplayTree, *, single_turn_id: Optional[str] = None) -> str:
293 """Render *tree* (optionally one turn) as canonical, byte-stable ``replay/v0`` JSON."""
294 turns = _filter_turns(tree, single_turn_id)
295 payload = {
296 "schema": REPLAY_SCHEMA,
297 "session_id": tree.session_id,
298 "event_count": tree.event_count,
299 "skipped_count": tree.skipped_count,
300 "turn_count": tree.turn_count,
301 "source_dir": tree.source_dir,
302 "event_counts": tree.event_counts,
303 "turns": [t.model_dump(mode="json") for t in turns],
304 }
305 return canonical_json(payload)
str render_tree_json(ReplayTree tree, *, Optional[str] single_turn_id=None)
Definition replay.py:292
tuple[list[TurnEvent], int] read_events_from_dir(Path event_dir)
Definition replay.py:73
list[TurnNode] _filter_turns(ReplayTree tree, Optional[str] single_turn_id)
Definition replay.py:250
TurnNode _build_turn_node(str turn_id, list[TurnEvent] events)
Definition replay.py:162
ReplayTree build_replay_tree(list[TurnEvent] events, *, int skipped=0)
Definition replay.py:221
int _slot_seq(Path path)
Definition replay.py:67
str render_tree_text(ReplayTree tree, *, Optional[str] single_turn_id=None)
Definition replay.py:257