Skip to content

Commit e3e854f

Browse files
committed
feat: ADR 0032 M2-T001 — event-stream reader for evidence (additive)
Adds the read side of the spine so evidence builders can fold over typed RunEvents. Inverse mapper audit_event_to_run_event_type returns None for legacy/unmapped audit events (tool_call_started, model_route, git_sandbox_*) rather than raising; read_run_events_from_audit / read_run_events_from_jsonl yield only typed M0 lifecycle events with monotonic seq, passing payloads through unchanged to preserve redaction. Strictly additive: no evidence/receipt builder is switched to this reader yet (M2-T002/T003; plan §13.4 forbids switching the receipt default before a parity assertion exists). Constraint: additive only, zero behavior change; legacy events skipped not errored; redaction preserved Tested: lifecycle 22 (8 new), run_evidence + summary 26 unchanged, smoke 200, full mypy clean 1009 files, ruff check+format clean, docs validator 0 errors Confidence: high Roadmap-Status: unchanged
1 parent 501189a commit e3e854f

4 files changed

Lines changed: 465 additions & 1 deletion

File tree

teaagent/_lazy_exports.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,12 @@
167167
'RunCancelledError': ('teaagent.errors', 'RunCancelledError'),
168168
'RunEvent': ('teaagent.runner', 'RunEvent'),
169169
'RunEventType': ('teaagent.runner', 'RunEventType'),
170+
'audit_event_to_run_event_type': (
171+
'teaagent.runner',
172+
'audit_event_to_run_event_type',
173+
),
174+
'read_run_events_from_audit': ('teaagent.runner', 'read_run_events_from_audit'),
175+
'read_run_events_from_jsonl': ('teaagent.runner', 'read_run_events_from_jsonl'),
170176
'RunRollup': ('teaagent.daily', 'RunRollup'),
171177
'RunStore': ('teaagent.run_store', 'RunStore'),
172178
'RunSummary': ('teaagent.run_store', 'RunSummary'),
@@ -291,6 +297,7 @@
291297
'teaagent.external_backends',
292298
'register_code_parse_backend',
293299
),
300+
'register_audit_consumer': ('teaagent.runner', 'register_audit_consumer'),
294301
'register_git_tools': ('teaagent.workspace_tools', 'register_git_tools'),
295302
'register_hybrid_backend': ('teaagent.hybrid_search', 'register_hybrid_backend'),
296303
'register_knowledge_backend': (

teaagent/runner/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
"""Agent runner package — decision loop, budget, and approval integration."""
22

33
from ._core import AgentRunner, validate_tool_decision
4-
from ._events import EventSpine, RunEvent, RunEventType, register_audit_consumer
4+
from ._events import (
5+
EventSpine,
6+
RunEvent,
7+
RunEventType,
8+
audit_event_to_run_event_type,
9+
read_run_events_from_audit,
10+
read_run_events_from_jsonl,
11+
register_audit_consumer,
12+
)
513
from ._types import (
614
ApprovalHandler,
715
ApprovalRequest,
@@ -22,6 +30,9 @@
2230
'DecisionFn',
2331
'EventSpine',
2432
'FinalAnswer',
33+
'audit_event_to_run_event_type',
34+
'read_run_events_from_audit',
35+
'read_run_events_from_jsonl',
2536
'register_audit_consumer',
2637
'RunEvent',
2738
'RunEventType',

teaagent/runner/_events.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@
1919

2020
from __future__ import annotations
2121

22+
import json
2223
import logging
24+
from collections.abc import Iterable
2325
from dataclasses import dataclass
2426
from enum import Enum
27+
from pathlib import Path
2528
from typing import TYPE_CHECKING, Any, Callable, Mapping
2629

2730
if TYPE_CHECKING:
@@ -102,6 +105,27 @@ def run_event_to_audit_event_type(event_type: RunEventType) -> str:
102105
return audit_type
103106

104107

108+
# Inverse mapping: audit event type string to RunEventType
109+
_AUDIT_EVENT_TO_RUN_EVENT_TYPE: dict[str, RunEventType] = {
110+
v: k for k, v in _RUN_EVENT_TO_AUDIT_EVENT_TYPE.items()
111+
}
112+
113+
114+
def audit_event_to_run_event_type(audit_type: str) -> RunEventType | None:
115+
"""Map an audit event type string to a ``RunEventType``, or None if unmapped.
116+
117+
Legacy and unmapped audit event types (e.g. 'tool_call_started', 'model_route',
118+
'git_sandbox_started') return None and are safely skipped by the reader.
119+
120+
Args:
121+
audit_type: The audit event type string (from JSONL entry).
122+
123+
Returns:
124+
The corresponding RunEventType, or None if not in the M0 set.
125+
"""
126+
return _AUDIT_EVENT_TO_RUN_EVENT_TYPE.get(audit_type)
127+
128+
105129
class EventSpine:
106130
"""Synchronous, in-process event bus for run-lifecycle events.
107131
@@ -190,6 +214,86 @@ def emit(
190214
)
191215

192216

217+
def read_run_events_from_audit(
218+
entries: Iterable[Mapping[str, Any]],
219+
) -> list[RunEvent]:
220+
"""Convert audit JSONL entries to typed RunEvents.
221+
222+
Iterates over audit event dicts (already-parsed from JSONL), converts
223+
those with mapped event types to RunEvents, and skips legacy/unmapped
224+
event types. Sequence numbers are 1-based and monotonic over yielded events.
225+
226+
Redaction is preserved: payloads are passed through as-is from the audit
227+
entries without reconstruction or un-redaction.
228+
229+
Args:
230+
entries: An iterable of audit event dicts (from parsed JSONL lines).
231+
Expected keys: event_type, run_id, payload.
232+
233+
Returns:
234+
A list of RunEvent objects in order, with seq 1..N monotonic.
235+
"""
236+
events: list[RunEvent] = []
237+
seq = 0
238+
239+
for entry in entries:
240+
audit_type = entry.get('event_type')
241+
if audit_type is None:
242+
continue
243+
run_event_type = audit_event_to_run_event_type(audit_type)
244+
245+
# Skip entries whose event_type does not map to RunEventType
246+
if run_event_type is None:
247+
continue
248+
249+
seq += 1
250+
run_id = entry.get('run_id', '')
251+
payload = entry.get('payload', {})
252+
253+
# Construct a RunEvent with the mapped type, run_id, payload, and seq.
254+
event = RunEvent(
255+
type=run_event_type,
256+
run_id=run_id,
257+
payload=payload,
258+
seq=seq,
259+
)
260+
events.append(event)
261+
262+
return events
263+
264+
265+
def read_run_events_from_jsonl(path: str | Path) -> list[RunEvent]:
266+
"""Read a JSONL audit file and convert it to RunEvents.
267+
268+
Opens the file, parses each non-empty line as JSON, delegates to
269+
read_run_events_from_audit, and returns the typed event list.
270+
271+
Tolerates blank lines and preserves redaction.
272+
273+
Args:
274+
path: Path to the JSONL audit file.
275+
276+
Returns:
277+
A list of RunEvent objects extracted from the file.
278+
279+
Raises:
280+
FileNotFoundError: If the file does not exist.
281+
json.JSONDecodeError: If a non-empty line is not valid JSON.
282+
"""
283+
path_obj = Path(path) if isinstance(path, str) else path
284+
entries: list[Mapping[str, Any]] = []
285+
286+
with open(path_obj, encoding='utf-8') as f:
287+
for line in f:
288+
line = line.strip()
289+
if not line:
290+
# Tolerate blank lines
291+
continue
292+
entries.append(json.loads(line))
293+
294+
return read_run_events_from_audit(entries)
295+
296+
193297
def register_audit_consumer(spine: EventSpine, audit: AuditLogger) -> None:
194298
"""Register an ``AuditLogger`` as an ``EventSpine`` consumer (ADR 0032 M1).
195299

0 commit comments

Comments
 (0)