Coverage for gco_mcp / mission / audit.py: 100.00%

116 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1"""Mission-specific audit emitters. 

2 

3Three thin wrappers over the existing ``audit_logger`` from ``gco_mcp/audit.py``. 

4Each emitter builds a structured dict matching the Mission audit-fields 

5schema and writes it as a single ``audit_logger.info(json.dumps(entry))`` 

6call — the same pattern that ``_build_audit_entry`` and ``emit_startup_log`` 

7use in ``gco_mcp/audit.py``. 

8 

9Three event types, one helper each: 

10 

11* ``emit_phase_event`` — one per phase (propose / execute / observe / 

12 evaluate / decide), regardless of success or failure. Carries the 

13 per-phase ``started_at`` / ``ended_at`` timestamps that the engine 

14 measures around the phase body, plus an ``error_message`` field on 

15 ``failed`` events. 

16* ``emit_verdict_event`` — one per Decide_Phase outcome. Carries the 

17 ``verdict`` label and ``verdict_reason``, plus a ``revision_rationale`` 

18 field when the verdict is ``adjust``. 

19* ``emit_sampling_event`` — one per sampling call (whether the call was 

20 used, rejected, fell back, was unavailable, or was disabled). The second 

21 positional argument is ``iteration_index_or_purpose`` so callers can 

22 pass either an integer iteration index (during the loop) or a string / 

23 ``None`` for sampling that happens outside any single iteration (e.g. 

24 Final_Report fill-in). 

25 

26Every entry carries a fresh ``timestamp`` set to ``datetime.now(UTC).isoformat()`` 

27at emit time — the supplied phase ``started_at`` / ``ended_at`` are recorded 

28in their own dedicated fields and are not used as the entry timestamp. 

29 

30The Mission package is loaded via ``sys.path``-on-``gco_mcp/`` (the same trick 

31``run_mcp.py`` uses for the rest of the MCP modules), so ``audit_logger`` is 

32imported as ``from audit import audit_logger`` — matching every 

33``gco_mcp/tools/*.py`` module. 

34 

35In-process audit ring buffer 

36============================ 

37Mission also installs a bounded in-process collector 

38(``MissionAuditCollectorHandler``) on the shared ``gco.mcp.audit`` 

39logger so the ``mission://sessions/{session_id}/audit-replay`` 

40resource has a source of phase / verdict entries to feed 

41:func:`replay_audit_entries`. The buffer is capped at 5000 entries 

42(FIFO eviction) so a long-running process cannot OOM through the 

43audit channel; the cap fits comfortably above a 1000-iteration 

44session's ~6000 entries since the session would have terminated on 

45the iteration cap by then. 

46""" 

47 

48from __future__ import annotations 

49 

50import json 

51import logging 

52from collections import deque 

53from datetime import UTC, datetime 

54from typing import Any, Literal 

55 

56from audit import audit_logger 

57 

58# --------------------------------------------------------------------------- 

59# Event-type tags 

60# --------------------------------------------------------------------------- 

61 

62# Stable ``event_type`` strings so audit consumers can filter Mission events 

63# without parsing the rest of the entry. The values are part of the public 

64# audit contract — tests, dashboards, and the reconstruction test in 

65# ``tests/test_mission_audit.py`` match on these literals. 

66EVENT_TYPE_PHASE = "mission_phase_event" 

67EVENT_TYPE_VERDICT = "mission_verdict_event" 

68EVENT_TYPE_SAMPLING = "mission_sampling_event" 

69EVENT_TYPE_SCRIPT_CALL = "mission_script_call_event" 

70EVENT_TYPE_CHILD_LIFECYCLE = "mission_child_lifecycle_event" 

71 

72 

73# --------------------------------------------------------------------------- 

74# Helpers 

75# --------------------------------------------------------------------------- 

76 

77 

78def _now_iso() -> str: 

79 """Return the current UTC time as an ISO 8601 string. 

80 

81 Centralised so every Mission emitter records ``timestamp`` in the same 

82 format. Matches the pattern used by ``_build_audit_entry`` and 

83 ``emit_startup_log`` in ``gco_mcp/audit.py``. 

84 """ 

85 return datetime.now(UTC).isoformat() 

86 

87 

88def _emit(entry: dict[str, Any]) -> None: 

89 """Serialise ``entry`` and route through the shared audit logger. 

90 

91 Centralised so the three public emitters share one log path — and so a 

92 future swap to a structured handler only needs to change one site. 

93 """ 

94 audit_logger.info(json.dumps(entry)) 

95 

96 

97# --------------------------------------------------------------------------- 

98# Public emitters 

99# --------------------------------------------------------------------------- 

100 

101 

102def emit_phase_event( 

103 session_id: str, 

104 iteration_index: int, 

105 phase: Literal["propose", "execute", "observe", "evaluate", "decide"], 

106 status: Literal["succeeded", "failed"], 

107 started_at: str, 

108 ended_at: str, 

109 error_message: str | None = None, 

110) -> None: 

111 """Emit one ``mission_phase_event`` audit entry. 

112 

113 Called by ``MissionEngine`` exactly once per phase from a try/finally 

114 block, so a failed phase still produces an entry with 

115 ``phase_status="failed"`` and the exception's ``error_message``. 

116 

117 The ``started_at`` / ``ended_at`` arguments are recorded in their own 

118 fields — they describe the phase body, not the audit emit. The entry's 

119 own ``timestamp`` field is a fresh ``_now_iso()`` value set at call 

120 time so the audit log retains a faithful emission ordering. 

121 """ 

122 entry: dict[str, Any] = { 

123 "event_type": EVENT_TYPE_PHASE, 

124 "mission_session_id": session_id, 

125 "iteration_index": iteration_index, 

126 "phase": phase, 

127 "phase_status": status, 

128 "phase_started_at": started_at, 

129 "phase_ended_at": ended_at, 

130 "timestamp": _now_iso(), 

131 } 

132 if error_message: 

133 # Match the 200-char truncation that ``_build_audit_entry`` applies 

134 # to its own ``error`` field so phase errors don't blow up the log 

135 # line on a long traceback summary. 

136 entry["error_message"] = error_message[:200] 

137 _emit(entry) 

138 

139 

140def emit_verdict_event( 

141 session_id: str, 

142 iteration_index: int, 

143 verdict: str, 

144 verdict_reason: str, 

145 revision_rationale: str | None = None, 

146) -> None: 

147 """Emit one ``mission_verdict_event`` audit entry. 

148 

149 Called by ``MissionEngine`` once per Decide_Phase outcome. The 

150 ``revision_rationale`` is meaningful only on the ``adjust`` verdict 

151 (the rationale describes why the next iteration is being asked to 

152 revise the strategy), but this helper records it whenever the caller 

153 supplies a non-empty string — the engine decides when to populate 

154 it. This keeps the helper a pure formatter. 

155 """ 

156 entry: dict[str, Any] = { 

157 "event_type": EVENT_TYPE_VERDICT, 

158 "mission_session_id": session_id, 

159 "iteration_index": iteration_index, 

160 "verdict": verdict, 

161 "verdict_reason": verdict_reason, 

162 "timestamp": _now_iso(), 

163 } 

164 if revision_rationale: 

165 entry["revision_rationale"] = revision_rationale 

166 _emit(entry) 

167 

168 

169def emit_sampling_event( 

170 session_id: str, 

171 iteration_index_or_purpose: int | str | None, 

172 sampling_purpose: str, 

173 sampling_status: str, 

174 sampling_backend: str, 

175 sampling_model_id: str | None = None, 

176 model_output_bytes: int | None = None, 

177 validation_error: str | None = None, 

178 input_tokens: int | None = None, 

179 output_tokens: int | None = None, 

180) -> None: 

181 """Emit one ``mission_sampling_event`` audit entry. 

182 

183 The second positional argument carries the iteration index when the 

184 sampling call happens inside the loop body, or a string / ``None`` for 

185 out-of-loop calls (e.g. final-report ``lessons`` fill-in). The helper 

186 routes the value to the right field: 

187 

188 * ``int`` → ``iteration_index`` (matches the design's audit-fields 

189 table, which lists ``iteration_index`` as present on sampling 

190 events that occur during iterations). 

191 * non-empty ``str`` → ``sampling_context`` (an out-of-loop label). 

192 * ``None`` or empty string → neither field is recorded. 

193 

194 ``sampling_model_id`` and ``model_output_bytes`` are recorded only when 

195 the sampler actually produced output (typically ``sampling_status="used"``). 

196 ``validation_error`` is recorded only when present (typically 

197 ``sampling_status="rejected"``). The conditional emission keeps the 

198 audit entry from carrying empty / null fields that downstream consumers 

199 would otherwise have to filter out. 

200 """ 

201 entry: dict[str, Any] = { 

202 "event_type": EVENT_TYPE_SAMPLING, 

203 "mission_session_id": session_id, 

204 "sampling_purpose": sampling_purpose, 

205 "sampling_status": sampling_status, 

206 "sampling_backend": sampling_backend, 

207 "timestamp": _now_iso(), 

208 } 

209 

210 # Route the iteration-or-purpose argument. ``int`` → numeric 

211 # ``iteration_index``; ``str`` (non-empty) → ``sampling_context``; 

212 # ``None`` and empty strings → omitted. ``bool`` is excluded 

213 # explicitly because it is a subclass of ``int`` in Python and would 

214 # otherwise be silently recorded as ``iteration_index=True``. 

215 if isinstance(iteration_index_or_purpose, int) and not isinstance( 

216 iteration_index_or_purpose, bool 

217 ): 

218 entry["iteration_index"] = iteration_index_or_purpose 

219 elif isinstance(iteration_index_or_purpose, str) and iteration_index_or_purpose: 

220 entry["sampling_context"] = iteration_index_or_purpose 

221 

222 if sampling_model_id: 

223 entry["sampling_model_id"] = sampling_model_id 

224 if model_output_bytes is not None: 

225 entry["model_output_bytes"] = model_output_bytes 

226 if validation_error: 

227 entry["validation_error"] = validation_error[:200] 

228 if input_tokens is not None: 

229 entry["input_tokens"] = input_tokens 

230 if output_tokens is not None: 

231 entry["output_tokens"] = output_tokens 

232 

233 _emit(entry) 

234 

235 

236def emit_script_call_event( 

237 session_id: str, 

238 iteration_index: int, 

239 tool_name: str, 

240 status: str, 

241 duration_ms: int, 

242 error_message: str | None = None, 

243) -> None: 

244 """Emit one ``mission_script_call_event`` audit entry. 

245 

246 Called by the in-script tool wrapper after each invocation of an 

247 operator-allowlisted tool from inside a Mission script. The 

248 underlying tool call already produced its own ``@audit_logged`` 

249 entry through the registered tool function; this helper layers a 

250 second, distinct audit row tagged ``via_script=True`` so consumers 

251 can tell at a glance which calls were driven from a script versus 

252 a direct ``tool_calls`` strategy. 

253 

254 The ``status`` argument carries the call's terminal state (``ok`` 

255 / ``failed`` / ``skipped_not_allowed``) and ``duration_ms`` mirrors 

256 the per-call timing the wrapper records on its own 

257 ``script_call_log`` entries. ``error_message`` is recorded only 

258 when supplied and is truncated to 200 characters to match the 

259 existing convention in :func:`emit_phase_event` and 

260 :func:`emit_sampling_event`. 

261 """ 

262 entry: dict[str, Any] = { 

263 "event_type": EVENT_TYPE_SCRIPT_CALL, 

264 "via_script": True, 

265 "mission_session_id": session_id, 

266 "iteration_index": iteration_index, 

267 "tool_name": tool_name, 

268 "tool_status": status, 

269 "duration_ms": duration_ms, 

270 "timestamp": _now_iso(), 

271 } 

272 if error_message: 

273 entry["error_message"] = error_message[:200] 

274 _emit(entry) 

275 

276 

277def emit_child_lifecycle_event( 

278 parent_session_id: str, 

279 child_session_id: str | None, 

280 slot: str, 

281 action: str, 

282 *, 

283 reason: str | None = None, 

284 final_status: str | None = None, 

285) -> None: 

286 """Emit one ``mission_child_lifecycle_event`` audit entry. 

287 

288 Called by the swarm runner on every supervised-slot transition: 

289 ``spawned``, ``respawned``, ``terminal`` (the child session reached a 

290 terminal status on its own), ``aborted`` (the supervisor cascade or 

291 ``child_abort`` ended it), and ``respawn_denied`` (the restart policy 

292 wanted a replacement but spawn admission refused — ``reason`` carries 

293 the admission token). 

294 

295 The entry's ``mission_session_id`` is the **parent** (orchestrator) 

296 session id so ``entries_for(parent_id)`` reconstructs the fleet's 

297 lifecycle history in one filtered read; the child's own engine events 

298 keep carrying the child's session id as they always have. The child 

299 id rides in its own field. 

300 """ 

301 entry: dict[str, Any] = { 

302 "event_type": EVENT_TYPE_CHILD_LIFECYCLE, 

303 "mission_session_id": parent_session_id, 

304 "child_session_id": child_session_id, 

305 "slot": slot, 

306 "action": action, 

307 "timestamp": _now_iso(), 

308 } 

309 if reason: 

310 entry["reason"] = reason 

311 if final_status: 

312 entry["final_status"] = final_status 

313 _emit(entry) 

314 

315 

316__all__ = [ 

317 "EVENT_TYPE_CHILD_LIFECYCLE", 

318 "EVENT_TYPE_PHASE", 

319 "EVENT_TYPE_SAMPLING", 

320 "EVENT_TYPE_SCRIPT_CALL", 

321 "EVENT_TYPE_VERDICT", 

322 "MissionAuditCollectorHandler", 

323 "emit_child_lifecycle_event", 

324 "emit_phase_event", 

325 "emit_sampling_event", 

326 "emit_script_call_event", 

327 "emit_verdict_event", 

328 "get_collector", 

329 "install_collector", 

330 "replay_audit_entries", 

331] 

332 

333 

334# --------------------------------------------------------------------------- 

335# Audit-replay helper 

336# --------------------------------------------------------------------------- 

337 

338 

339# Default cap on the in-process collector ring buffer. 5000 entries is 

340# big enough to cover a session that runs to its iteration budget 

341# (six entries per iteration × ~800 iterations) but small enough that 

342# a long-running process cannot OOM through the audit channel. 

343_DEFAULT_COLLECTOR_CAPACITY = 5000 

344 

345 

346class MissionAuditCollectorHandler(logging.Handler): 

347 """Bounded ring-buffer logging handler that captures Mission audit JSON. 

348 

349 Attached to the shared ``gco.mcp.audit`` logger by 

350 :func:`install_collector` so the 

351 ``mission://sessions/{session_id}/audit-replay`` resource has a 

352 source of phase / verdict entries to feed 

353 :func:`replay_audit_entries`. The handler filters by 

354 ``event_type`` so non-Mission audit emitters (the standard MCP 

355 tool-invocation decorator, the startup-log helper) do not pollute 

356 the buffer. 

357 

358 Bounded via :class:`collections.deque(maxlen=N)` so a long-running 

359 process never grows the buffer without bound. Operators who want a 

360 larger or smaller window can construct the handler explicitly with 

361 ``capacity=`` or call :func:`install_collector(capacity=...)`. 

362 """ 

363 

364 _MISSION_EVENT_TYPES = frozenset( 

365 { 

366 EVENT_TYPE_PHASE, 

367 EVENT_TYPE_VERDICT, 

368 EVENT_TYPE_SAMPLING, 

369 EVENT_TYPE_SCRIPT_CALL, 

370 EVENT_TYPE_CHILD_LIFECYCLE, 

371 } 

372 ) 

373 

374 def __init__(self, capacity: int = _DEFAULT_COLLECTOR_CAPACITY) -> None: 

375 super().__init__(level=logging.INFO) 

376 self._buffer: deque[dict[str, Any]] = deque(maxlen=capacity) 

377 

378 def emit(self, record: logging.LogRecord) -> None: 

379 """Capture Mission audit JSON entries into the ring buffer.""" 

380 try: 

381 payload = json.loads(record.getMessage()) 

382 except TypeError, ValueError: 

383 return 

384 if not isinstance(payload, dict): 

385 return 

386 if payload.get("event_type") not in self._MISSION_EVENT_TYPES: 

387 return 

388 self._buffer.append(payload) 

389 

390 def entries_for(self, session_id: str) -> list[dict[str, Any]]: 

391 """Return a list copy of every captured entry for ``session_id``.""" 

392 return [dict(e) for e in list(self._buffer) if e.get("mission_session_id") == session_id] 

393 

394 def clear(self) -> None: 

395 """Drop every captured entry. Useful for test isolation.""" 

396 self._buffer.clear() 

397 

398 

399# Module-level collector. ``None`` until :func:`install_collector` is 

400# called — the resources/__init__.py wiring installs it once at import 

401# time so every Mission audit entry the engine emits during the 

402# process lifetime is reachable from the audit-replay resource. 

403_COLLECTOR: MissionAuditCollectorHandler | None = None 

404 

405 

406def install_collector( 

407 capacity: int = _DEFAULT_COLLECTOR_CAPACITY, 

408) -> MissionAuditCollectorHandler: 

409 """Attach a :class:`MissionAuditCollectorHandler` to the audit logger. 

410 

411 Idempotent: a second call with the same parameters returns the 

412 existing handler. The function exists so test fixtures can clear 

413 and re-attach the handler between cases without leaking captured 

414 entries across the boundary. 

415 

416 Logger level boost. Python's stdlib ``logging`` defaults the root 

417 threshold to ``WARNING``, which means an unconfigured caller that 

418 never calls ``logging.basicConfig(level=logging.INFO)`` would 

419 silently drop every ``audit_logger.info(...)`` call before it 

420 reaches a handler — including this collector. The 

421 ``mission://sessions/{id}/audit-replay`` resource needs entries 

422 to flow regardless of the host's logging setup, so we floor the 

423 logger's level at ``INFO`` here. Hosts that have already set a 

424 finer threshold (e.g. ``DEBUG``) keep theirs; only the 

425 "unconfigured" case is repaired. 

426 """ 

427 global _COLLECTOR 

428 if _COLLECTOR is None: 

429 _COLLECTOR = MissionAuditCollectorHandler(capacity=capacity) 

430 audit_logger.addHandler(_COLLECTOR) 

431 # Floor at INFO so audit_logger.info() entries reach the handler 

432 # even when the host has not configured logging at all. We never 

433 # *raise* the threshold — a host that explicitly set DEBUG keeps 

434 # DEBUG. 

435 if audit_logger.level == logging.NOTSET or audit_logger.level > logging.INFO: 

436 audit_logger.setLevel(logging.INFO) 

437 return _COLLECTOR 

438 

439 

440def get_collector() -> MissionAuditCollectorHandler | None: 

441 """Return the installed collector or ``None`` when nothing is attached.""" 

442 return _COLLECTOR 

443 

444 

445def replay_audit_entries( 

446 session_id: str, 

447 entries: list[dict[str, Any]], 

448) -> list[dict[str, Any]]: 

449 """Reconstruct iteration history from a stream of Mission audit entries. 

450 

451 Pure function. Each iteration produces five 

452 ``mission_phase_event`` entries (one per phase) plus one 

453 ``mission_verdict_event`` entry that closes it out, in emission 

454 order. This walker filters ``entries`` to the events whose 

455 ``mission_session_id`` matches ``session_id``, accumulates phase 

456 events into the active iteration's ``phases`` list, and stamps 

457 the verdict + reason from the matching verdict event before 

458 appending the completed record. 

459 

460 Returns a list of dicts shaped like 

461 ``{"iteration_index": int, "phases": [{"phase", "status", 

462 "started_at", "ended_at", "error_message"}, ...], "verdict": str 

463 | None, "verdict_reason": str | None, "revision_rationale": str 

464 | None}``. The shape is intentionally narrow — it covers only 

465 the fields the audit stream is expected to fully describe, not 

466 the strategy / observation / criteria-evaluation fields the 

467 engine persists separately to the session backend. 

468 

469 A phase event whose ``iteration_index`` jumps ahead of the 

470 active iteration before its verdict event has landed flushes 

471 the active iteration with ``verdict=None`` / ``verdict_reason 

472 =None`` so a malformed audit stream surfaces as a visible 

473 sentinel rather than a silent merge. An iteration with no 

474 closing verdict event at end-of-stream is appended the same way. 

475 """ 

476 matching = [ 

477 e for e in entries if isinstance(e, dict) and e.get("mission_session_id") == session_id 

478 ] 

479 

480 iterations: list[dict[str, Any]] = [] 

481 current_index: int | None = None 

482 current_phases: list[dict[str, Any]] = [] 

483 

484 def _flush_current(verdict: str | None, reason: str | None, rationale: str | None) -> None: 

485 """Append the active iteration to the result list.""" 

486 if current_index is None: 

487 return 

488 iterations.append( 

489 { 

490 "iteration_index": current_index, 

491 "phases": list(current_phases), 

492 "verdict": verdict, 

493 "verdict_reason": reason, 

494 "revision_rationale": rationale, 

495 } 

496 ) 

497 

498 for entry in matching: 

499 event_type = entry.get("event_type") 

500 iteration_index = entry.get("iteration_index") 

501 

502 if event_type == EVENT_TYPE_PHASE: 

503 if current_index is not None and current_index != iteration_index: 

504 # New iteration arrived before the prior closed — 

505 # flush the prior with sentinel verdict / reason so 

506 # the caller can see the orphaned phases. 

507 _flush_current(None, None, None) 

508 current_phases = [] 

509 current_index = iteration_index 

510 phase_record: dict[str, Any] = { 

511 "phase": entry.get("phase"), 

512 "status": entry.get("phase_status"), 

513 "started_at": entry.get("phase_started_at"), 

514 "ended_at": entry.get("phase_ended_at"), 

515 } 

516 if "error_message" in entry: 

517 phase_record["error_message"] = entry["error_message"] 

518 current_phases.append(phase_record) 

519 elif event_type == EVENT_TYPE_VERDICT: 

520 # ``current_index`` may legitimately be ``None`` when a 

521 # verdict-only iteration arrives (e.g. a synthetic 

522 # ``cadence_skip``); in that case stamp the iteration 

523 # index from the verdict event itself. 

524 if current_index is None: 

525 current_index = iteration_index 

526 _flush_current( 

527 entry.get("verdict"), 

528 entry.get("verdict_reason"), 

529 entry.get("revision_rationale"), 

530 ) 

531 current_index = None 

532 current_phases = [] 

533 

534 # Stream ended mid-iteration — flush the unclosed iteration with 

535 # null verdict so the caller sees the partial record. 

536 if current_index is not None: 

537 _flush_current(None, None, None) 

538 

539 return iterations