6Incremental projection of the M2 event stream into readable state — plan 190 T2.
10The UI synthesis (§2/§4/§5) calls the projection/reducer "the keystone
11deliverable": the thing that turns the append-only ``TurnEvent`` stream
12into the state a reader wants, *before* any UI exists. One fold serves
13three consumers that would otherwise each grow their own ad-hoc
14accounting — a live dashboard, verification-backpressure observability,
17The fold is deliberately plain: :meth:`EventProjection.feed` takes one
18event and updates internal counters; :meth:`EventProjection.snapshot`
19returns an immutable value. Because it is pure and incremental, the same
20events fed live and replayed from the durable NDJSON log produce the
21identical snapshot — the property plan 190 T3 witnesses, and the reason
22``/trace``, ``/packet`` and replay can share one implementation.
26- Fold cost, token and cache/ctx gauge inputs from
27 ``provider_budget_consumed``.
28- Track the live tool lifecycle (started → progress → completed) keyed by
29 ``tool_call_id``, plus proposals and permission decisions.
30- Track the sub-agent board, session/provider markers, prompt-render
31 layer hashes, and error / compaction markers.
32- Return all of it as one frozen :class:`ProjectionSnapshot`.
34What this module is NOT
35-----------------------
36No I/O, no presenter or ``oc_ui`` import, and **no bus attachment** — the
37module never subscribes to anything. A caller that wants live state
38attaches ``projection.feed`` to its own bus subscription; a caller
39replaying a log calls ``feed`` in a loop. Keeping attachment outside is
40what lets the same object serve both without a code path difference.
42Plan 188's ``core/presenter_events.py`` is untouched and unrelated: it
43translates events into presenter calls. Unifying the two is a named
44follow-up, not this module's job.
50 L2 — lifecycle events (none emitted here)
51 L3 — semantic details (none emitted here)
52 L4 — module-load coverage marker
56- ``feed`` never raises on an unrecognised ``kind``: the v0.1 enum is
57 closed, but a replayed log may predate or postdate this build, and a
58 reducer that dies on an unknown line is useless for forensics.
59- ``snapshot`` returns a value: taking it does not freeze the reducer, and
60 feeding more events does not mutate a snapshot already returned.
61- Every number reported is one the stream actually carries. Where the
62 current 20 kinds cannot express something a reader wants, this module
63 reports nothing rather than an inferred figure. The three such gaps are
64 documented on :class:`ProjectionSnapshot` and asserted by the tests.
68- oc_diagnostics (structured debug logging via _dbg)
69- option_c_agent_contracts (lazy; TurnEvent type hint for the feed method)
71from __future__
import annotations
73from dataclasses
import dataclass, field
74from typing
import TYPE_CHECKING, Any, Optional
76from oc_diagnostics
import _dbg
79 from option_c_agent_contracts
import TurnEvent
81_dbg(
"OC_CHAT.MAIN",
"projection",
"module", 4)
87_PLAN_TOOL_NAMES: frozenset = frozenset({
88 "create_plan",
"mark_step",
"get_status",
"stop_plan",
92@dataclass(frozen=True, slots=True)
94 """The cost + gauge inputs derivable from ``provider_budget_consumed``."""
96 total_usd: float = 0.0
98 """Sum of the ``cost_usd`` of the samples that carried one.
100 EXACT when :attr:`unpriced_samples` is 0; a LOWER BOUND when it is not —
101 and, since plan 203, the reader can tell which. Plan 190 had to document
102 this as an unconditional lower bound: ``cost_usd`` was a non-optional
103 ``float``, so an unpriceable sample could not be published at all and its
104 absence was indistinguishable from no spend. The contract now types it
105 ``Optional[float]``, the unpriceable sample publishes with ``None``, and
106 the count of those lands in :attr:`unpriced_samples` instead of vanishing.
108 input_tokens: int = 0
109 output_tokens: int = 0
111 unpriced_samples: int = 0
112 """How many of :attr:`samples` published a ``cost_usd`` of ``None``.
114 The unknown-vs-zero distinction: a sample that reported ``0.0`` is
115 priced-and-free and is NOT counted here, while one that reported ``None``
116 could not be priced. Non-zero means :attr:`total_usd` understates real
117 spend by an amount this fold cannot know — which is a fact about the
118 stream, not a defect in it.
120 max_input_tokens: int = 0
121 """The ctx gauge input: the LARGEST single prompt seen, not the sum.
123 ``ctx:%`` asks how close one request came to the context window, so a
124 running total would be meaningless for it.
128@dataclass(frozen=True, slots=True)
130 """One tool call's live lifecycle, keyed by ``tool_call_id``."""
135 """``"started"`` → ``"running"`` (a progress event arrived) → ``"completed"``."""
136 outcome: Optional[str] =
None
138 result_artifact_id: Optional[str] =
None
141@dataclass(frozen=True, slots=True)
143 """A proposal, deliberately WITHOUT a ``tool_call_id``.
145 ``tool_call_proposed`` carries a tool name and the requested args;
146 the call id does not exist until the provider assigns one, so neither
147 its payload nor its envelope can carry it. The reducer records the
148 proposal and declines to guess which started call it became — ADR-5's
149 ordering makes a positional guess *usually* right, which is exactly
150 what makes it dangerous to encode as fact.
154 turn_id: Optional[str] =
None
157@dataclass(frozen=True, slots=True)
159 """A permission decision, deliberately WITHOUT a tool linkage.
161 LIMITATION (plan 190, documented not faked): ``permission_decision``
162 sets ``permission_decision_id`` on payload and envelope, but no
163 ``tool_call_id`` on either — so a decision cannot be attributed to the
164 call it gated. Ordering within a turn is suggestive, not evidence.
167 permission_decision_id: str
170 turn_id: Optional[str] =
None
173@dataclass(frozen=True, slots=True)
175 """One row of the sub-agent board, keyed by ``subagent_session_id``."""
177 subagent_session_id: str
179 outcome: Optional[str] =
None
182@dataclass(frozen=True, slots=True)
184 """Compaction markers.
186 STEP-0 found no compaction publisher in the product, so a live stream
187 will not carry these today. The fold handles them anyway: a replayed
188 log from another build must not break the reducer.
194 last_reason: Optional[str] =
None
197@dataclass(frozen=True, slots=True)
199 """The folded state at one point in the stream — an immutable value.
201 Three things a reader may want are NOT here, because the current 20
202 kinds cannot express them and an inferred number is worse than none:
204 1. **Cache hit ratio.** ``provider_budget_consumed`` carries
205 ``input_tokens`` / ``output_tokens`` but no hit/miss split, so the
206 ``cache:%`` gauge cannot be derived from the stream (the ctx gauge
207 input can — see :attr:`CostView.max_input_tokens`).
208 2. **Plan / queue state.** The v0.1 enum has no plan or queue kinds.
209 :attr:`plan_tool_calls` is a filter over real tool events — it
210 reports plan-tool *activity*, and is not a claim about plan state.
211 3. **Proposal → call and permission → call linkage.** See
212 :class:`ProposedCallView` and :class:`PermissionDecisionView`.
216 session_id: Optional[str] =
None
217 session_status: Optional[str] =
None
218 session_outcome: Optional[str] =
None
220 cost: CostView = field(default_factory=CostView)
221 prompt_versions: tuple = ()
222 layer_hashes: dict = field(default_factory=dict)
223 tool_calls: tuple = ()
224 proposed_calls: tuple = ()
225 permission_decisions: tuple = ()
226 subagents: tuple = ()
227 plan_tool_calls: tuple = ()
229 branch_summaries: tuple = ()
231 compaction: CompactionView = field(default_factory=CompactionView)
232 rate_limited: int = 0
235 assistant_messages: int = 0
239 """Fold a ``TurnEvent`` stream into a :class:`ProjectionSnapshot`.
241 Usage is two lines, live or replayed::
243 proj = EventProjection()
244 bus.subscribe("*", proj.feed) # live — attachment is the CALLER's
245 for event in replayed: proj.feed(event) # replayed
247 Both produce the same snapshot for the same events, which is the
248 property plan 190 T3 witnesses end-to-end.
282 def feed(self, event:
"TurnEvent") ->
None:
283 """Fold one event into the running state.
285 Unrecognised kinds advance the counter and are otherwise ignored
286 (see the module Contracts) — a reducer that dies on an unknown
287 line cannot be used to investigate the log that contains it.
290 kind = getattr(event,
"kind",
"")
291 payload = getattr(event,
"payload",
None)
293 sid = getattr(event,
"session_id",
None)
296 turn_id = getattr(event,
"turn_id",
None)
300 handler = _HANDLERS.get(kind)
301 if handler
is not None:
302 handler(self, event, payload)
319 version = getattr(payload,
"prompt_version",
None)
322 hashes = getattr(payload,
"layer_hashes",
None)
333 cost = getattr(payload,
"cost_usd",
None)
337 self.
_usd += float(cost)
338 inp = int(getattr(payload,
"input_tokens", 0)
or 0)
340 self.
_out_tokens += int(getattr(payload,
"output_tokens", 0)
or 0)
352 tool_name=getattr(payload,
"tool_name",
""),
353 turn_id=getattr(event,
"turn_id",
None),
358 permission_decision_id=getattr(payload,
"permission_decision_id",
""),
359 decision=getattr(payload,
"decision",
""),
360 decided_by=getattr(payload,
"decided_by",
""),
361 turn_id=getattr(event,
"turn_id",
None),
365 call_id = getattr(payload,
"tool_call_id",
"")
366 name = getattr(payload,
"tool_name",
"")
368 tool_call_id=call_id, tool_name=name, status=
"started")
369 if name
in _PLAN_TOOL_NAMES:
373 call_id = getattr(payload,
"tool_call_id",
"")
374 node = self.
_tools.get(call_id)
375 message = getattr(payload,
"message",
"")
380 tool_call_id=call_id, tool_name=
"", status=
"running",
384 tool_call_id=node.tool_call_id, tool_name=node.tool_name,
385 status=
"running" if node.status !=
"completed" else node.status,
386 outcome=node.outcome, progress=node.progress + (message,),
387 result_artifact_id=node.result_artifact_id)
390 call_id = getattr(payload,
"tool_call_id",
"")
391 node = self.
_tools.get(call_id)
392 outcome = getattr(payload,
"outcome",
None)
393 artifact = getattr(payload,
"result_artifact_id",
None)
396 tool_call_id=call_id, tool_name=
"", status=
"completed",
397 outcome=outcome, result_artifact_id=artifact)
400 tool_call_id=node.tool_call_id, tool_name=node.tool_name,
401 status=
"completed", outcome=outcome, progress=node.progress,
402 result_artifact_id=artifact)
405 said = getattr(payload,
"subagent_session_id",
"")
407 subagent_session_id=said, status=
"started")
410 said = getattr(payload,
"subagent_session_id",
"")
412 subagent_session_id=said, status=
"completed",
413 outcome=getattr(payload,
"outcome",
None))
426 self.
_errors.append(getattr(payload,
"error_summary",
""))
428 def _on_hook(self, _event: Any, payload: Any) ->
None:
429 self.
_hooks.append((getattr(payload,
"hook_name",
""),
430 int(getattr(payload,
"exit_code", 0)
or 0)))
434 """Return the folded state as an immutable value.
436 Copies every mutable container, so the returned snapshot is
437 unaffected by later :meth:`feed` calls.
446 total_usd=round(self.
_usd, 10),
455 tool_calls=tuple(self.
_tools.values()),
479 "session_started": EventProjection._on_session_started,
480 "session_ended": EventProjection._on_session_ended,
481 "user_input_received": EventProjection._on_user_input,
482 "assistant_message": EventProjection._on_assistant_message,
483 "prompt_rendered": EventProjection._on_prompt_rendered,
484 "provider_rate_limited": EventProjection._on_rate_limited,
485 "provider_retried": EventProjection._on_retried,
486 "provider_budget_consumed": EventProjection._on_budget,
487 "tool_call_proposed": EventProjection._on_proposed,
488 "permission_decision": EventProjection._on_permission,
489 "tool_call_started": EventProjection._on_tool_started,
490 "tool_call_progress": EventProjection._on_tool_progress,
491 "tool_call_completed": EventProjection._on_tool_completed,
492 "subagent_started": EventProjection._on_subagent_started,
493 "subagent_completed": EventProjection._on_subagent_completed,
494 "compaction_started": EventProjection._on_compaction_started,
495 "compaction_completed": EventProjection._on_compaction_completed,
496 "branch_summary": EventProjection._on_branch_summary,
497 "error": EventProjection._on_error,
498 "hook_executed": EventProjection._on_hook,
505 "PermissionDecisionView",
506 "ProjectionSnapshot",
None _on_hook(self, Any _event, Any payload)
Optional[str] _session_status
ProjectionSnapshot snapshot(self)
None _on_assistant_message(self, Any _event, Any _payload)
None _on_retried(self, Any _event, Any _payload)
Optional[str] _session_outcome
None _on_subagent_started(self, Any _event, Any payload)
None _on_user_input(self, Any _event, Any _payload)
None _on_branch_summary(self, Any _event, Any payload)
None _on_prompt_rendered(self, Any _event, Any payload)
None _on_tool_started(self, Any _event, Any payload)
None _on_tool_completed(self, Any _event, Any payload)
int _compaction_completed
None _on_error(self, Any _event, Any payload)
None _on_proposed(self, Any event, Any payload)
None _on_compaction_completed(self, Any _event, Any _payload)
None _on_session_started(self, Any _event, Any _payload)
None _on_compaction_started(self, Any _event, Any payload)
None _on_rate_limited(self, Any _event, Any _payload)
Optional[str] _session_id
None _on_session_ended(self, Any _event, Any payload)
None _on_permission(self, Any event, Any payload)
None feed(self, "TurnEvent" event)
None _on_tool_progress(self, Any _event, Any payload)
None _on_budget(self, Any _event, Any payload)
Optional[str] _compaction_reason
None _on_subagent_completed(self, Any _event, Any payload)