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

619 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1"""Five-phase iteration loop driver for the Mission goal-directed loop. 

2 

3The :class:`MissionEngine` owns one ``run_iteration`` lifecycle per call: it 

4loads the persisted session, walks the iteration through propose → execute 

5→ observe → evaluate → decide, persists the resulting record, and writes a 

6Final_Report when the verdict is terminal. Every external dependency is 

7injected at construction time so unit tests can supply mocks for the tool 

8dispatcher, the sampling callable, and the script sandbox runner. 

9 

10Why a class rather than a free function? Two reasons. 

11 

12* Each phase needs the same handful of dependencies (the backend, the 

13 tool dispatcher, the cost-estimator map, the clock). Threading them 

14 through every method as positional arguments would be tedious and 

15 error-prone; the dataclass shape gives every phase one place to look 

16 for them. 

17* A test that exercises a single phase in isolation needs to construct 

18 a ``MissionEngine`` with stubbed dependencies and call the private 

19 method directly. Having the dependencies on the instance — rather 

20 than as module-level singletons — keeps the engine pure and free of 

21 process-global state. 

22 

23Phase contract: 

24 

25* Each ``_*_phase`` method is wrapped in a try/finally that emits 

26 exactly one ``audit.emit_phase_event`` regardless of whether the body 

27 succeeded or raised. The matching :class:`PhaseRecord` is appended to 

28 ``record["phases"]`` in the same finally block, so a failed phase 

29 still produces a structured record on the iteration. 

30* Any phase that raises propagates the exception out of 

31 ``run_iteration`` after the engine marks the session as ``failed``, 

32 appends the partial iteration, and persists. Subsequent calls to 

33 ``run_iteration`` on a ``failed`` session refuse with 

34 ``session_failed``. 

35 

36Determinism: only the Decide_Phase consults a clock, and it does so by 

37calling ``self.now()`` exactly once per call so the value is observable 

38and pinnable from tests. The Propose_Phase's deterministic fallback uses 

39no clock and no random source. The Execute_Phase reads the clock for 

40the per-phase ``started_at`` / ``ended_at`` timestamps but its outputs 

41(the tool-call records) do not depend on those values. 

42 

43The ``Context`` type is from FastMCP and brings a heavy dependency tree 

44(MCP transport, ``contextvars``, etc.) that unit tests do not need. We 

45type ``ctx`` as ``Any | None`` so the engine module imports cleanly in 

46isolation; the production wiring threads a real :class:`fastmcp.Context` 

47through the dispatcher and sampler callables, where its concrete type 

48matters. This is the same trade-off the existing ``gco_mcp/tools/*.py`` 

49modules make for tools that take an injected context. 

50""" 

51 

52from __future__ import annotations 

53 

54import contextlib 

55from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence 

56from dataclasses import dataclass, field 

57from datetime import UTC, datetime 

58from typing import Any, cast 

59 

60from gco.bedrock import BedrockFTUFormNotAcceptedError 

61 

62from . import audit, decide, final_report 

63from .checkpoints import mark_checkpoint 

64from .predicate import PredicateRejected, evaluate_predicate, parse_predicate 

65from .sampling import SamplingFallback, SamplingUsed 

66from .types import ( 

67 TERMINAL_STATES, 

68 TERMINAL_VERDICTS, 

69 Criterion, 

70 CriterionResult, 

71 IterationRecord, 

72 Observation, 

73 PhaseRecord, 

74 SessionState, 

75 Strategy, 

76 ToolCallRecord, 

77 VerdictLabel, 

78 VerdictReason, 

79) 

80 

81# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

82# Generated at (UTC): 2026-09-18T02:11:36Z 

83# Generated from Git commit: b8faa9689385cea16155a285a7f70cf6d488e512 

84# Flowchart(s) generated from this file: 

85# * ``MissionEngine.run_iteration`` -> ``diagrams/code_diagrams/gco_mcp/mission/engine.MissionEngine_run_iteration.html`` 

86# (PNG: ``diagrams/code_diagrams/gco_mcp/mission/engine.MissionEngine_run_iteration.png``) 

87# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

88# <pyflowchart-code-diagram> END 

89 

90 

91__all__ = [ 

92 "MissionEngine", 

93 "MissionEngineError", 

94] 

95 

96 

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

98# Error 

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

100 

101 

102class MissionEngineError(Exception): 

103 """Raised by the engine for stable, code-keyed lifecycle errors. 

104 

105 The :attr:`code` attribute carries a short stable string (e.g. 

106 ``"session_not_found"``, ``"session_terminal"``, ``"session_paused"``, 

107 ``"session_failed"``) that the MCP tool wrappers and the CLI render 

108 as a structured tool error. The exception's string form falls back 

109 to ``code`` so logs always show something meaningful even when the 

110 caller does not pull the attribute out explicitly. 

111 """ 

112 

113 def __init__(self, code: str, *, message: str | None = None) -> None: 

114 self.code: str = code 

115 super().__init__(message if message is not None else code) 

116 

117 

118# --------------------------------------------------------------------------- 

119# Phase-name constants (typed) 

120# --------------------------------------------------------------------------- 

121 

122# Centralised so the audit emitter and PhaseRecord constructor share one 

123# spelling for each phase. Matches the ``Literal`` shape declared on 

124# :class:`PhaseRecord` and on :func:`audit.emit_phase_event`. 

125_PROPOSE = "propose" 

126_EXECUTE = "execute" 

127_OBSERVE = "observe" 

128_EVALUATE = "evaluate" 

129_DECIDE = "decide" 

130 

131 

132# --------------------------------------------------------------------------- 

133# Defaults 

134# --------------------------------------------------------------------------- 

135 

136 

137def _default_now() -> Callable[[], datetime]: 

138 """Return a clock callable that yields the current UTC datetime. 

139 

140 Used as the ``default_factory`` for :attr:`MissionEngine.now`. Wrapping 

141 the lambda in a function keeps ``mypy --strict`` happy with the 

142 ``Callable[[], datetime]`` annotation while preserving the 

143 "constructed once per engine, called many times" semantics. 

144 """ 

145 return lambda: datetime.now(UTC) 

146 

147 

148# --------------------------------------------------------------------------- 

149# Engine 

150# --------------------------------------------------------------------------- 

151 

152 

153# Type aliases for the injected callables. Loose on purpose: the precise 

154# shapes settle in later slices (sampling in slice 6, sandbox in slice 5). 

155ToolDispatcher = Callable[[str, dict[str, Any], Any], Awaitable[Any]] 

156SamplingCallable = Callable[..., Awaitable[Any]] 

157#: Synchronous contribution merged into each iteration's Observation at the 

158#: end of the Observe_Phase. Takes the live session, returns a dict whose 

159#: optional ``children`` key lands on the Observation verbatim and whose 

160#: optional ``metrics`` dict merges via ``metrics.update(...)`` — the same 

161#: contract tool results use. Synchronous and pure-by-convention so the 

162#: determinism suite can pin its output byte-for-byte. The return is typed 

163#: ``object`` because augmenters are caller-supplied plugins: 

164#: ``_apply_observation_augmenters`` checks the shape at runtime and ignores 

165#: a non-dict contribution, which the type must leave room for. 

166ObservationAugmenter = Callable[[SessionState], object] 

167SandboxRunner = Callable[ 

168 [str, Any, ToolDispatcher], 

169 Awaitable[tuple[dict[str, Any], list[ToolCallRecord]]], 

170] 

171 

172 

173@dataclass 

174class MissionEngine: 

175 """Driver for the Mission five-phase iteration loop. 

176 

177 Construction takes every external dependency the engine needs: 

178 

179 * ``backend`` — the persistence layer (filesystem, DynamoDB, …). 

180 Engine never reaches outside this protocol for state I/O. 

181 * ``tool_dispatcher`` — async callable that invokes one MCP tool. 

182 Signature ``(tool_name, args, ctx) -> result``. The engine routes 

183 every direct ``tool_calls`` invocation through this callable so 

184 tests can swap in a stub that returns canned results. 

185 * ``sampling_callable`` — optional async callable that produces an 

186 LLM-derived next Strategy when the prior Verdict was ``adjust`` 

187 and the session has ``use_sampling=true``. Loose signature; slice 

188 6 finalises it. ``None`` (or any failure inside it) routes the 

189 engine to the deterministic fallback strategy. 

190 * ``sandbox_runner`` — optional async callable that runs a scripted 

191 Strategy in the Mission sandbox. Signature ``(script, ctx, 

192 tool_dispatcher) -> (observation_dict, script_call_log)``. ``None`` 

193 means the engine refuses any scripted strategy with a clear 

194 error instead of silently executing operator-supplied code. 

195 * ``now`` — injectable clock. Defaults to a UTC clock; tests pin it 

196 so deterministic verdicts (the Decide_Phase consults the clock 

197 for budget caps and for the cadence resolver) are reproducible. 

198 """ 

199 

200 backend: Any 

201 tool_dispatcher: ToolDispatcher 

202 sampling_callable: SamplingCallable | None 

203 sandbox_runner: SandboxRunner | None 

204 now: Callable[[], datetime] = field(default_factory=_default_now) 

205 # Optional async callable that drives the Final_Report's 

206 # ``lessons`` and ``recommended_followups`` overlay. Loose typing 

207 # mirrors :attr:`sampling_callable` so legacy tests that wire a 

208 # plain async stub keep working. Production wiring binds it to a 

209 # closure over :func:`mcp.mission.sampling.maybe_sample_final_lessons` 

210 # — see :meth:`_maybe_sample_final_lessons`. ``None`` (the default) 

211 # disables the overlay; the deterministic templates from 

212 # :func:`mcp.mission.final_report.build_deterministic_report` stand 

213 # on their own in that case. 

214 final_lessons_callable: SamplingCallable | None = None 

215 # Optional mission-memory store (duck-typed against 

216 # :class:`mcp.mission.memory.MissionMemoryStore`) for the 

217 # best-effort memory write on terminal verdicts — see 

218 # :meth:`_maybe_write_memory`. ``None`` (the default) disables the 

219 # write entirely, which is what every directly-constructed test 

220 # engine gets; production wiring injects a real store and relies on 

221 # the write being swallowed on any failure, because the 

222 # Final_Report — not the memory item — is the durable exit 

223 # artifact. 

224 memory_store: Any | None = None 

225 # Optional sequence of :data:`ObservationAugmenter` callables applied at 

226 # the end of every Observe_Phase, in order. ``None`` (the default) is 

227 # byte-identical to pre-seam behavior — standalone and child sessions 

228 # never carry augmenters. The swarm runner injects one on orchestrator 

229 # engines to merge the supervised-children snapshot; each augmenter is 

230 # best-effort (a raising augmenter records an Observation error instead 

231 # of failing the phase, so criteria read inconclusive rather than the 

232 # session dying on a snapshot bug). 

233 observation_augmenters: Sequence[ObservationAugmenter] | None = None 

234 

235 # ------------------------------------------------------------------ # 

236 # Public surface 

237 # ------------------------------------------------------------------ # 

238 

239 async def run_iteration( 

240 self, 

241 session_id: str, 

242 ctx: Any | None = None, 

243 ) -> IterationRecord: 

244 """Run one full iteration for ``session_id`` and return its record. 

245 

246 Lifecycle: 

247 

248 1. Load the session; raise ``session_not_found`` when missing. 

249 2. Refuse a session in any terminal state (``failed`` → 

250 ``session_failed``; ``completed`` / ``terminated`` → 

251 ``session_terminal``) or in ``paused`` (``session_paused``). 

252 3. Transition ``pending → running`` on the very first iteration 

253 and stamp ``session["started_at"]``. 

254 4. Allocate a fresh :class:`IterationRecord` and run the five 

255 phases in order. Each phase emits exactly one 

256 ``audit.emit_phase_event`` regardless of outcome. 

257 5. On any phase exception: append the partial iteration to 

258 ``session["iterations"]``, mark the session ``failed``, save, 

259 and re-raise. The session JSON stays inspectable. 

260 6. On success: stamp the verdict on the iteration, append it, 

261 update the no-progress counter, save the session. 

262 7. On terminal verdict (``complete`` / ``terminate``): transition 

263 the session status, write the Final_Report, save again. 

264 8. Emit one ``audit.emit_verdict_event`` regardless of outcome. 

265 9. Return the iteration record. 

266 """ 

267 session = self.backend.load_session(session_id) 

268 if session is None: 

269 raise MissionEngineError("session_not_found") 

270 

271 # The terminal-state check distinguishes ``failed`` from the 

272 # other terminal states because callers (and the tool-error 

273 # table) treat them differently — a failed session needs manual 

274 # inspection via ``mission_history``, while a completed / 

275 # terminated session is simply done. 

276 status = session["status"] 

277 if status == "failed": 

278 raise MissionEngineError("session_failed") 

279 if status in TERMINAL_STATES: 

280 raise MissionEngineError("session_terminal") 

281 if status == "paused": 

282 raise MissionEngineError("session_paused") 

283 

284 iteration_start = self.now() 

285 

286 # First-iteration transition. ``started_at`` is the wall-clock 

287 # anchor for the wall-clock-budget computation in Decide_Phase 

288 # so we set it exactly once, on the pending → running edge. 

289 if session["status"] == "pending": 

290 session["status"] = "running" 

291 session["started_at"] = iteration_start.isoformat() 

292 

293 iteration_index = len(session["iterations"]) 

294 record = self._make_iteration_record(iteration_index, iteration_start) 

295 

296 try: 

297 strategy = await self._propose_phase(session, ctx, record) 

298 executed_calls = await self._execute_phase(session, strategy, ctx, record) 

299 await self._observe_phase(session, strategy, executed_calls, record) 

300 await self._evaluate_phase(session, record) 

301 verdict, reason = await self._decide_phase(session, record) 

302 except Exception: 

303 # Persist a failure record so the session JSON remains a 

304 # complete history of everything the loop attempted. The 

305 # verdict stays at its placeholder value because no 

306 # Decide_Phase actually fired; consumers detect the failure 

307 # through ``session["status"] == "failed"`` and the failed 

308 # phase entry in ``record["phases"]``. 

309 record["ended_at"] = self.now().isoformat() 

310 session["iterations"].append(record) 

311 session["status"] = "failed" 

312 session["ended_at"] = record["ended_at"] 

313 with contextlib.suppress(Exception): 

314 # A save failure during a failure path must not shadow 

315 # the original phase exception — the operator's first 

316 # need is to see what actually went wrong, not what 

317 # went wrong while reporting what went wrong. 

318 self.backend.save_session(session) 

319 raise 

320 

321 # Stamp the verdict on the iteration record before append; the 

322 # decide cascade inspects ``len(session["iterations"])`` (i.e. 

323 # iterations *before* the current one) so we deliberately 

324 # append after Decide_Phase rather than before. 

325 record["verdict"] = verdict 

326 record["verdict_reason"] = reason 

327 record["ended_at"] = self.now().isoformat() 

328 session["iterations"].append(record) 

329 

330 self._update_session_post_iteration(session, record) 

331 self.backend.save_session(session) 

332 

333 if verdict in TERMINAL_VERDICTS: 

334 await self._finalise_terminal_session(session, record, verdict, reason) 

335 self.backend.save_session(session) 

336 

337 # One verdict event per iteration regardless of terminal vs 

338 # in-progress, so audit consumers see a uniform stream. 

339 audit.emit_verdict_event( 

340 session_id=session_id, 

341 iteration_index=iteration_index, 

342 verdict=verdict, 

343 verdict_reason=reason, 

344 revision_rationale=record.get("revision_rationale"), 

345 ) 

346 

347 return record 

348 

349 # ------------------------------------------------------------------ # 

350 # Iteration record bootstrap 

351 # ------------------------------------------------------------------ # 

352 

353 @staticmethod 

354 def _make_iteration_record(iteration_index: int, started_at: datetime) -> IterationRecord: 

355 """Build the empty :class:`IterationRecord` for a new iteration. 

356 

357 Verdict and reason are placeholder values; the Decide_Phase 

358 overwrites them before the record is appended. ``ended_at`` 

359 is set to the empty string and rewritten just before append 

360 so the persisted shape always carries an ISO-8601 timestamp. 

361 """ 

362 record: IterationRecord = { 

363 "iteration_index": iteration_index, 

364 "started_at": started_at.isoformat(), 

365 "ended_at": "", 

366 "phases": [], 

367 "strategy": cast(Strategy, {}), 

368 "observation": cast(Observation, {}), 

369 "criteria_evaluation": [], 

370 "verdict": "continue", 

371 "verdict_reason": "in_progress", 

372 "checkpoint_evaluated": False, 

373 } 

374 return record 

375 

376 # ------------------------------------------------------------------ # 

377 # Phase wrapper 

378 # ------------------------------------------------------------------ # 

379 

380 async def _run_phase( 

381 self, 

382 session: SessionState, 

383 record: IterationRecord, 

384 phase_name: str, 

385 body: Callable[[], Awaitable[Any]], 

386 ) -> Any: 

387 """Execute ``body`` with phase audit + record bookkeeping. 

388 

389 Centralises the try/finally that every phase needs: 

390 

391 * stamps ``started_at`` from the engine clock, 

392 * runs the body, 

393 * stamps ``ended_at`` from the engine clock again, 

394 * appends a :class:`PhaseRecord` to ``record["phases"]``, 

395 * emits exactly one ``audit.emit_phase_event``. 

396 

397 On exception, the finally block records ``status="failed"`` 

398 with the exception's name + message (truncated to 200 chars to 

399 match the audit module's existing convention) and re-raises so 

400 ``run_iteration`` can drive the failure path. 

401 """ 

402 started_at = self.now().isoformat() 

403 status: str = "succeeded" 

404 error_message: str | None = None 

405 try: 

406 return await body() 

407 except Exception as exc: 

408 status = "failed" 

409 error_message = f"{type(exc).__name__}: {exc}"[:200] 

410 raise 

411 finally: 

412 ended_at = self.now().isoformat() 

413 phase_record: PhaseRecord = { 

414 "phase": cast(Any, phase_name), 

415 "status": cast(Any, status), 

416 "started_at": started_at, 

417 "ended_at": ended_at, 

418 } 

419 if error_message: 

420 phase_record["error_message"] = error_message 

421 record["phases"].append(phase_record) 

422 audit.emit_phase_event( 

423 session_id=session["session_id"], 

424 iteration_index=record["iteration_index"], 

425 phase=cast(Any, phase_name), 

426 status=cast(Any, status), 

427 started_at=started_at, 

428 ended_at=ended_at, 

429 error_message=error_message, 

430 ) 

431 

432 # ------------------------------------------------------------------ # 

433 # Phase 1 — propose 

434 # ------------------------------------------------------------------ # 

435 

436 async def _propose_phase( 

437 self, 

438 session: SessionState, 

439 ctx: Any | None, 

440 record: IterationRecord, 

441 ) -> Strategy: 

442 """Build the Strategy for this iteration. 

443 

444 Two paths: 

445 

446 * **Sampling path** — when the prior verdict was ``adjust`` AND 

447 the session has ``use_sampling=true`` AND a sampling callable 

448 is wired, await the callable and adopt its return value as 

449 the Strategy. Any exception from the callable, or any return 

450 shape that does not look like a Strategy, falls through to 

451 the deterministic path. Slice 6 will replace this with a 

452 richer prompt-builder + validator. 

453 * **Deterministic path** — re-run the most recent successful 

454 tool call (using the same args) when one exists, otherwise 

455 invoke the first tool in the session's allowlist with empty 

456 args. Pure: no clock, no randomness, no external I/O. The 

457 resulting Strategy is always a single ``tool_calls`` entry. 

458 

459 The chosen Strategy is stored on ``record["strategy"]`` and 

460 returned for the Execute_Phase to consume. 

461 """ 

462 

463 async def body() -> Strategy: 

464 strategy = await self._build_strategy(session, ctx, record) 

465 record["strategy"] = strategy 

466 return strategy 

467 

468 return cast(Strategy, await self._run_phase(session, record, _PROPOSE, body)) 

469 

470 async def _build_strategy( 

471 self, session: SessionState, ctx: Any | None, record: IterationRecord 

472 ) -> Strategy: 

473 """Pick the Propose_Phase Strategy via the sampling-or-fallback rule.""" 

474 if self._should_attempt_sampling(session): 

475 sampled = await self._try_sample_strategy(session, ctx, record) 

476 if sampled is not None: 

477 return sampled 

478 return self._deterministic_strategy(session) 

479 

480 def _should_attempt_sampling(self, session: SessionState) -> bool: 

481 """True iff the prior verdict was ``adjust`` and sampling is wired.""" 

482 if self.sampling_callable is None: 

483 return False 

484 if not session.get("use_sampling"): 

485 return False 

486 iterations = session.get("iterations") or [] 

487 if not iterations: 

488 return False 

489 return iterations[-1].get("verdict") == "adjust" 

490 

491 async def _try_sample_strategy( 

492 self, session: SessionState, ctx: Any | None, record: IterationRecord 

493 ) -> Strategy | None: 

494 """Call the sampling callable and adopt its return as a Strategy. 

495 

496 Returns ``None`` on any failure (exception, non-dict return, dict 

497 missing both ``tool_calls`` and ``script``) so the caller can 

498 fall back to the deterministic strategy. Shape validation here 

499 is intentionally tight: the engine cannot run a ``script`` 

500 strategy without a sandbox, and we never want a malformed sampler 

501 result to cascade into Execute_Phase as an opaque error. 

502 

503 Three return shapes are recognised: 

504 

505 * :class:`mcp.mission.sampling.SamplingUsed` — the production 

506 orchestration helper's accepted-output type. The Strategy is 

507 read from ``parsed["next_strategy"]``; the sampler's 

508 ``revision_rationale`` is stamped on the iteration ``record`` 

509 so :func:`mcp.mission.audit.emit_verdict_event` surfaces it 

510 in the next iteration's audit trail. 

511 * :class:`mcp.mission.sampling.SamplingFallback` — the 

512 orchestration helper's rejection / fallback type. The engine 

513 treats this exactly like a missing return: ``None`` so the 

514 deterministic-fallback path runs. The fallback's own 

515 rationale stays on the audit event the helper already emitted; 

516 we deliberately do *not* override the engine's deterministic 

517 rationale-template here so the verdict path stays fully 

518 deterministic when sampling rejects. 

519 * Raw ``dict`` (the legacy / test pattern) — kept verbatim so 

520 existing engine tests that pass simple async lambdas returning 

521 ``{"tool_calls": [...]}`` continue to work without churn. 

522 """ 

523 assert self.sampling_callable is not None # narrowed by caller 

524 try: 

525 result = await self.sampling_callable(session=session, ctx=ctx) 

526 except BedrockFTUFormNotAcceptedError: 

527 # Deliberate exception to the swallow-and-fall-back policy above. 

528 # Every other sampler failure is potentially transient, so the 

529 # deterministic strategy is the better answer. A missing Anthropic 

530 # first-time-use form is permanent and account-scoped: it would fail 

531 # identically on every remaining iteration, so degrading the whole 

532 # run in silence hides a one-line fix. Fail the run instead. 

533 raise 

534 except Exception: 

535 return None 

536 

537 # Phase 6.7 result types — the orchestration helper returns 

538 # either ``SamplingUsed`` (accept) or ``SamplingFallback`` 

539 # (reject). The engine maps them onto its existing 

540 # "Strategy or fall back" surface. 

541 if isinstance(result, SamplingUsed): 

542 next_strategy = result.parsed.get("next_strategy") 

543 if not isinstance(next_strategy, dict): 

544 return None 

545 self._capture_sampled_rationale(record, result.parsed) 

546 return self._coerce_strategy_dict(next_strategy) 

547 

548 if isinstance(result, SamplingFallback): 

549 # The fallback's own deterministic rationale is already on 

550 # the emitted audit event. The engine routes through its 

551 # own deterministic-fallback path so the verdict path stays 

552 # fully deterministic — returning ``None`` is the signal. 

553 return None 

554 

555 # Legacy raw-dict return — preserved verbatim so older tests 

556 # and callers that wire a simple ``async def: return {...}`` 

557 # stub continue to work unchanged. 

558 if not isinstance(result, dict): 

559 return None 

560 return self._coerce_strategy_dict(result) 

561 

562 def _coerce_strategy_dict(self, candidate: dict[str, Any]) -> Strategy | None: 

563 """Adopt ``candidate`` as a :class:`Strategy` if it is well-shaped. 

564 

565 Centralises the structural check (exactly one of ``tool_calls`` 

566 or ``script`` populated and well-typed) so both the 

567 :class:`SamplingUsed` path and the legacy raw-dict path land 

568 through one validator. Returns ``None`` for any malformed 

569 shape; callers fall back to the deterministic strategy. 

570 """ 

571 if "tool_calls" in candidate: 

572 tool_calls = candidate["tool_calls"] 

573 if isinstance(tool_calls, list) and tool_calls: 

574 return cast(Strategy, dict(candidate)) 

575 return None 

576 if "script" in candidate: 

577 script = candidate["script"] 

578 if isinstance(script, str) and script: 

579 # The sandbox runner is the only thing that can 

580 # safely execute a script. If it isn't wired, the 

581 # sampled script is unusable — fall back. 

582 if self.sandbox_runner is None: 

583 return None 

584 return cast(Strategy, dict(candidate)) 

585 return None 

586 return None 

587 

588 @staticmethod 

589 def _capture_sampled_rationale(record: IterationRecord, parsed_payload: dict[str, Any]) -> None: 

590 """Stamp the sampler's ``revision_rationale`` on ``record`` if present. 

591 

592 The advisory model's rationale lives at 

593 ``parsed_payload["revision_rationale"]`` per the 

594 Strategy_Revision schema. Recording it on the iteration record 

595 means :func:`mcp.mission.audit.emit_verdict_event` (which the 

596 engine calls at the end of ``run_iteration`` with 

597 ``record.get("revision_rationale")``) emits the model-derived 

598 text instead of the deterministic template that the 

599 Decide_Phase synthesises for ``adjust`` verdicts. The engine 

600 only ever calls this from the sampling-success path, so a 

601 rejection / fallback never overrides the deterministic 

602 template the Decide_Phase set. 

603 """ 

604 rationale = parsed_payload.get("revision_rationale") 

605 if isinstance(rationale, str) and rationale: 

606 record["revision_rationale"] = rationale 

607 

608 def _deterministic_strategy(self, session: SessionState) -> Strategy: 

609 """Build the fallback Strategy when sampling is off or unusable. 

610 

611 Re-runs the most recent successful tool call. When the prior 

612 call used empty args and there are unmet criteria, the 

613 widening rule injects a ``query`` parameter derived from the 

614 unmet criterion IDs — this gives catalog-search tools 

615 (``find_examples``, ``find_docs``) a chance to return 

616 relevant content without needing the sampler. When no 

617 successful call exists yet, the first tool in the allowlist 

618 runs with widened args if possible, empty args otherwise. 

619 """ 

620 prior_call = self._find_most_recent_successful_call(session) 

621 if prior_call is not None: 

622 tool_name, args = prior_call 

623 widened = self._widen_args(args, session) 

624 rationale_suffix = " with widened args" if widened != args else " with prior args" 

625 return cast( 

626 Strategy, 

627 { 

628 "tool_calls": [{"tool_name": tool_name, "args": dict(widened)}], 

629 "rationale": f"deterministic fallback: re-run {tool_name}{rationale_suffix}", 

630 }, 

631 ) 

632 allowlist = session.get("tool_allowlist") or [] 

633 if not allowlist: 

634 raise MissionEngineError("propose_no_tool_available") 

635 widened = self._widen_args({}, session) 

636 return cast( 

637 Strategy, 

638 { 

639 "tool_calls": [{"tool_name": allowlist[0], "args": widened}], 

640 "rationale": ( 

641 "deterministic fallback: invoking first allowlisted " 

642 "tool with widened args from unmet criteria" 

643 if widened 

644 else "deterministic fallback: invoking first allowlisted " 

645 "tool with empty args (no prior successful call)" 

646 ), 

647 }, 

648 ) 

649 

650 @staticmethod 

651 def _widen_args(args: dict[str, Any], session: SessionState) -> dict[str, Any]: 

652 """Inject a ``query`` parameter from unmet criteria when args are empty. 

653 

654 The widening rule is intentionally simple: if the args dict 

655 has no ``query`` key (or it's empty), extract keywords from 

656 the IDs of unmet criteria (splitting on ``_``) and join them 

657 as a space-separated query string. This gives catalog-search 

658 tools a chance to return relevant content on the deterministic 

659 path without needing the sampler. 

660 

661 Returns the original args unchanged when: 

662 - ``query`` is already populated (don't override operator intent) 

663 - No unmet criteria exist (nothing to widen toward) 

664 - The session has no iteration history (first call, no eval yet) 

665 """ 

666 # Don't override an existing query. 

667 if args.get("query"): 

668 return args 

669 

670 # Find unmet criteria from the latest iteration's evaluation. 

671 iterations = session.get("iterations") or [] 

672 if not iterations: 

673 return args 

674 latest = iterations[-1] 

675 criteria_eval = latest.get("criteria_evaluation") or [] 

676 unmet_ids: list[str] = [] 

677 for result in criteria_eval: 

678 if isinstance(result, dict) and result.get("status") == "unmet": 

679 cid = result.get("criterion_id", "") 

680 if cid: 

681 unmet_ids.append(cid) 

682 if not unmet_ids: 

683 return args 

684 

685 # Build a query from the unmet criterion IDs by splitting on 

686 # underscores and deduplicating. Skip generic words. 

687 skip_words = {"called", "succeeded", "found", "present", "no", "errors", "occurred"} 

688 keywords: list[str] = [] 

689 seen: set[str] = set() 

690 for cid in unmet_ids: 

691 for word in cid.split("_"): 

692 word_lower = word.lower() 

693 if word_lower not in skip_words and word_lower not in seen and len(word) > 2: 

694 keywords.append(word_lower) 

695 seen.add(word_lower) 

696 if not keywords: 

697 return args 

698 

699 widened = dict(args) 

700 widened["query"] = " ".join(keywords[:5]) # Cap at 5 keywords 

701 return widened 

702 

703 @staticmethod 

704 def _find_most_recent_successful_call( 

705 session: SessionState, 

706 ) -> tuple[str, dict[str, Any]] | None: 

707 """Walk the iteration history backwards for a successful tool call. 

708 

709 Returns ``(tool_name, args)`` for the most recent call whose 

710 :class:`ToolCallRecord` has ``status="ok"``. Looks at both the 

711 recorded executed-call list (for tool_calls strategies — kept 

712 on the iteration's Strategy under ``tool_calls`` after execute) 

713 and the script call log (for scripted strategies). Skips 

714 iterations whose strategy was a script with no successful 

715 embedded tool call. 

716 

717 Returns ``None`` when no prior successful call exists across 

718 the entire history. 

719 """ 

720 for iteration in reversed(session.get("iterations") or []): 

721 # Scripted strategies record their inner calls on 

722 # ``script_call_log``; direct tool_calls strategies don't 

723 # have that key. 

724 for source_key in ("script_call_log",): 

725 log = iteration.get(source_key) 

726 if not log: 

727 continue 

728 for call in reversed(log): 

729 if ( 

730 isinstance(call, dict) 

731 and call.get("status") == "ok" 

732 and isinstance(call.get("tool_name"), str) 

733 and isinstance(call.get("args"), dict) 

734 ): 

735 return call["tool_name"], dict(call["args"]) 

736 # Direct tool_calls strategies write the executed records 

737 # back onto the Strategy under ``tool_calls`` (each entry 

738 # carrying the same status / args fields the script log 

739 # would carry). This keeps a single lookup path here. 

740 strategy = iteration.get("strategy") or {} 

741 tool_calls = strategy.get("tool_calls") or [] 

742 for tc in reversed(tool_calls): 

743 if ( 

744 isinstance(tc, dict) 

745 and tc.get("status") == "ok" 

746 and isinstance(tc.get("tool_name"), str) 

747 and isinstance(tc.get("args"), dict) 

748 ): 

749 return tc["tool_name"], dict(tc["args"]) 

750 return None 

751 

752 # ------------------------------------------------------------------ # 

753 # Phase 2 — execute 

754 # ------------------------------------------------------------------ # 

755 

756 async def _execute_phase( 

757 self, 

758 session: SessionState, 

759 strategy: Strategy, 

760 ctx: Any | None, 

761 record: IterationRecord, 

762 ) -> list[ToolCallRecord]: 

763 """Run the Strategy and return the list of executed tool calls. 

764 

765 Two modes: 

766 

767 * **tool_calls** — iterate the strategy's ``tool_calls`` in 

768 order. For each: gate by the session's allowlist, dispatch 

769 via :attr:`tool_dispatcher`, and record the outcome 

770 (``ok`` / ``failed`` / ``skipped_not_allowed``). One failed 

771 call does not abort the iteration — the next call still 

772 runs, the failure lands as one entry, and Observe_Phase 

773 surfaces it under ``errors``. 

774 * **script** — hand the script to :attr:`sandbox_runner` along 

775 with ``ctx`` and the engine's own ``tool_dispatcher`` so the 

776 sandbox can safely invoke allowlisted tools as native 

777 callables. The runner returns ``(observation_dict, 

778 script_call_log)``. The observation is stashed on the record 

779 for Observe_Phase to use directly; the script_call_log is 

780 stored on ``record["script_call_log"]``. 

781 

782 For both modes, every successful call (or each successful 

783 embedded call in script mode) is recorded into the iteration 

784 record for audit and replay. 

785 """ 

786 

787 async def body() -> list[ToolCallRecord]: 

788 if "script" in strategy: 

789 return await self._execute_script(session, strategy, ctx, record) 

790 return await self._execute_tool_calls(session, strategy, ctx, record) 

791 

792 return cast( 

793 list[ToolCallRecord], 

794 await self._run_phase(session, record, _EXECUTE, body), 

795 ) 

796 

797 async def _execute_tool_calls( 

798 self, 

799 session: SessionState, 

800 strategy: Strategy, 

801 ctx: Any | None, 

802 record: IterationRecord, 

803 ) -> list[ToolCallRecord]: 

804 """Run the Strategy's ``tool_calls`` list with allowlist gating.""" 

805 allowlist = set(session.get("tool_allowlist") or []) 

806 executed: list[ToolCallRecord] = [] 

807 for entry in strategy.get("tool_calls", []) or []: 

808 tool_name = entry.get("tool_name") if isinstance(entry, dict) else None 

809 args = entry.get("args") if isinstance(entry, dict) else {} 

810 if not isinstance(tool_name, str) or not tool_name: 

811 # A malformed tool_calls entry is the operator's bug, 

812 # but failing the entire iteration over one bad entry 

813 # is harsher than the loop semantics demand. Record 

814 # it as a failed call and move on. 

815 executed.append( 

816 { 

817 "tool_name": str(tool_name) if tool_name else "<unknown>", 

818 "args": args if isinstance(args, dict) else {}, 

819 "status": "failed", 

820 "result_summary": None, 

821 "duration_ms": 0, 

822 "error_message": "tool_name_missing_or_invalid", 

823 } 

824 ) 

825 continue 

826 if not isinstance(args, dict): 

827 args = {} 

828 if tool_name not in allowlist: 

829 executed.append( 

830 { 

831 "tool_name": tool_name, 

832 "args": args, 

833 "status": "skipped_not_allowed", 

834 "result_summary": None, 

835 "duration_ms": 0, 

836 } 

837 ) 

838 continue 

839 executed.append(await self._dispatch_one_call(session, tool_name, args, ctx)) 

840 

841 # Persist the executed records back onto the Strategy so the 

842 # propose-fallback's "most recent successful call" lookup has 

843 # a single source of truth on every persisted iteration. 

844 record["strategy"]["tool_calls"] = [dict(call) for call in executed] 

845 return executed 

846 

847 async def _dispatch_one_call( 

848 self, 

849 session: SessionState, 

850 tool_name: str, 

851 args: dict[str, Any], 

852 ctx: Any | None, 

853 ) -> ToolCallRecord: 

854 """Invoke one allowlisted tool through the dispatcher.""" 

855 del session # accepted for symmetry with the execute path; unused 

856 started = self.now() 

857 try: 

858 result = await self.tool_dispatcher(tool_name, args, ctx) 

859 except Exception as exc: 

860 duration_ms = self._elapsed_ms(started) 

861 return { 

862 "tool_name": tool_name, 

863 "args": args, 

864 "status": "failed", 

865 "result_summary": None, 

866 "duration_ms": duration_ms, 

867 "error_message": f"{type(exc).__name__}: {exc}"[:200], 

868 } 

869 duration_ms = self._elapsed_ms(started) 

870 record: ToolCallRecord = { 

871 "tool_name": tool_name, 

872 "args": args, 

873 "status": "ok", 

874 "result_summary": result, 

875 "duration_ms": duration_ms, 

876 } 

877 return record 

878 

879 async def _execute_script( 

880 self, 

881 session: SessionState, 

882 strategy: Strategy, 

883 ctx: Any | None, 

884 record: IterationRecord, 

885 ) -> list[ToolCallRecord]: 

886 """Run a scripted strategy through the wired sandbox runner. 

887 

888 Three failure modes get translated into stable engine error 

889 codes so the MCP tool wrappers and the CLI render them as 

890 structured rejections rather than opaque tracebacks: 

891 

892 * ``sandbox_runner is None`` — the engine was constructed 

893 without a sandbox. The session-start validator should have 

894 rejected any script-bearing Strategy already (scripts go 

895 through ``validate_script_ast`` before they reach here), 

896 but a sampled-then-injected script could still arrive at 

897 this method. Treat it as a validation failure with the 

898 equivalent of ``script_rejected``. 

899 * :class:`ScriptRejected` from inside the runner — the runner 

900 re-validated the script just before execution and the AST 

901 gate fired. Re-raise as ``script_rejected``. 

902 * :class:`SandboxTerminated` from inside the runner — Monty 

903 killed the script for exceeding a duration / memory cap. 

904 The cap is a true budget cap, not a code-quality failure, 

905 so the engine *swallows* the exception, builds a partial 

906 Observation from whatever the script collected before being 

907 killed, stashes the partial ``script_call_log`` and a 

908 ``sandbox_terminated_reason`` sentinel on the iteration 

909 record, and returns a list of partial calls. The Decide_Phase 

910 reads the sentinel and emits ``("terminate", 

911 "max_wall_clock")`` so the verdict surfaces on the 

912 budget-cap path rather than via a phase failure. 

913 

914 The sandbox module is imported lazily inside this method so 

915 the engine module stays importable on hosts where the 

916 underlying ``pydantic_monty`` dependency is absent (CLI-only 

917 environments, dry-run validators, etc.). When the lazy 

918 import fails, the structured-exception translation is 

919 skipped and the original exception bubbles up to the 

920 ``run_iteration`` failure path — the engine still records a 

921 failed Execute_Phase, which is the right behaviour even 

922 without per-class translation. 

923 """ 

924 del session # accepted for symmetry with the execute path; unused 

925 if self.sandbox_runner is None: 

926 raise MissionEngineError("script_rejected") 

927 script = strategy["script"] 

928 try: 

929 observation_dict, script_call_log = await self.sandbox_runner( 

930 script, ctx, self.tool_dispatcher 

931 ) 

932 except Exception as exc: 

933 # Late-resolved class lookup: importing the sandbox 

934 # module at top of file would pull in 

935 # ``pydantic_monty`` on import, which the engine 

936 # explicitly does not require (an operator can run 

937 # ``mission_validate`` against a stored session JSON 

938 # without a working sandbox). Importing here means the 

939 # translation is best-effort but the engine module 

940 # itself stays loadable everywhere. 

941 try: 

942 from .sandbox import ( 

943 SandboxTerminated, 

944 ScriptRejected, 

945 ) 

946 except Exception: 

947 raise exc from None 

948 if isinstance(exc, ScriptRejected): 

949 raise MissionEngineError("script_rejected") from exc 

950 if isinstance(exc, SandboxTerminated): 

951 # The sandbox cap is a budget cap, not a phase 

952 # failure: the script ran out of wall clock (or 

953 # memory, or hit a runtime / typing / syntax error 

954 # mid-run) under operator-supplied limits. Capture 

955 # whatever it collected before being killed and 

956 # route the verdict through the budget-cap path. 

957 # 

958 # The sentinel on the iteration record is what the 

959 # cascade in ``decide_verdict`` reads to short- 

960 # circuit to ``("terminate", "max_wall_clock")`` 

961 # before any other branch is consulted; without it 

962 # the cascade would fall through to the default 

963 # ``("continue", "in_progress")`` because no other 

964 # cap was breached. 

965 record["script_call_log"] = cast( 

966 "list[ToolCallRecord]", list(exc.partial_script_call_log) 

967 ) 

968 # Build a minimal Observation from the partial 

969 # logs so Evaluate_Phase has the same shape it 

970 # would have on a successful sandbox run. Missing 

971 # keys (``metrics``, ``events``, etc.) get default 

972 # empties; Observe_Phase fills in any timestamps 

973 # the partial doesn't carry. 

974 partial_observation: dict[str, Any] = { 

975 "tool_results": [ 

976 self._annotate_tool_result(call) for call in exc.partial_script_call_log 

977 ], 

978 "metrics": {}, 

979 "events": list(exc.partial_events), 

980 } 

981 if exc.partial_observations: 

982 partial_observation["metrics"]["observations"] = { 

983 entry["key"]: entry["value"] 

984 for entry in exc.partial_observations 

985 if isinstance(entry, dict) and "key" in entry 

986 } 

987 record["observation"] = cast(Observation, partial_observation) 

988 record["sandbox_terminated_reason"] = "max_wall_clock" 

989 return cast("list[ToolCallRecord]", list(exc.partial_script_call_log)) 

990 raise 

991 # The sandbox already produced a normalized Observation dict, 

992 # so we cache it on the record for Observe_Phase to pick up 

993 # directly. This is the only path where Observe_Phase sees a 

994 # pre-built Observation. 

995 record["script_call_log"] = cast("list[ToolCallRecord]", list(script_call_log)) 

996 record["observation"] = cast(Observation, dict(observation_dict)) 

997 return list(script_call_log) 

998 

999 def _elapsed_ms(self, started: datetime) -> int: 

1000 """Return integer milliseconds elapsed since ``started``.""" 

1001 delta = self.now() - started 

1002 return max(int(delta.total_seconds() * 1000), 0) 

1003 

1004 # ------------------------------------------------------------------ # 

1005 # Phase 3 — observe 

1006 # ------------------------------------------------------------------ # 

1007 

1008 async def _observe_phase( 

1009 self, 

1010 session: SessionState, 

1011 strategy: Strategy, 

1012 executed_calls: list[ToolCallRecord], 

1013 record: IterationRecord, 

1014 ) -> None: 

1015 """Normalise tool-call outputs into an :class:`Observation`. 

1016 

1017 Two paths: 

1018 

1019 * **Script strategy** — Execute_Phase already stashed the 

1020 sandbox's Observation on ``record["observation"]``. Observe 

1021 fills in any missing required keys (``tool_results``, 

1022 ``metrics``, ``events``, ``phase_started_at`` / 

1023 ``phase_ended_at``) so downstream Evaluate_Phase consumers 

1024 can rely on the shape. 

1025 * **Tool-calls strategy** — build the Observation from the 

1026 executed-call records: ``tool_results`` is the list of 

1027 ``result_summary`` values (one per call, including failed 

1028 ones for stable indexing); ``metrics`` and ``events`` are 

1029 merged from any call result that carries those keys at the 

1030 top level; ``errors`` is appended for failed or skipped 

1031 calls. This is intentionally permissive — a Strategy that 

1032 doesn't produce metrics or events leaves those slots empty 

1033 rather than raising. 

1034 """ 

1035 

1036 async def body() -> None: 

1037 phase_started = self.now() 

1038 if "script" in strategy: 

1039 # The sandbox already produced the Observation. Fill 

1040 # in any timestamp slots it didn't populate so the 

1041 # shape is uniform for evaluators. 

1042 obs = cast(dict[str, Any], record.get("observation") or {}) 

1043 obs.setdefault("tool_results", []) 

1044 obs.setdefault("metrics", {}) 

1045 obs.setdefault("events", []) 

1046 obs.setdefault("phase_started_at", phase_started.isoformat()) 

1047 obs.setdefault("phase_ended_at", self.now().isoformat()) 

1048 record["observation"] = cast(Observation, obs) 

1049 self._apply_observation_augmenters(session, record) 

1050 return 

1051 record["observation"] = self._build_observation(executed_calls, phase_started) 

1052 self._apply_observation_augmenters(session, record) 

1053 

1054 await self._run_phase(session, record, _OBSERVE, body) 

1055 

1056 def _apply_observation_augmenters(self, session: SessionState, record: IterationRecord) -> None: 

1057 """Merge each :data:`ObservationAugmenter` contribution into the Observation. 

1058 

1059 Applied at the end of the Observe_Phase for both strategy shapes. 

1060 A contribution's ``children`` list lands on the Observation 

1061 verbatim (later augmenters win, matching the ``metrics.update`` 

1062 last-writer-wins semantics); its ``metrics`` dict merges into 

1063 ``observation["metrics"]``. Non-dict contributions are ignored. 

1064 

1065 Best-effort by design: an augmenter that raises records a 

1066 structured entry under ``observation["errors"]`` instead of 

1067 failing the phase — downstream criteria read ``unmet`` or 

1068 ``inconclusive`` and the deterministic cascade (stagnation, 

1069 budget caps) still terminates the session, which is strictly 

1070 better than dying inside Observe on a snapshot bug. 

1071 """ 

1072 if not self.observation_augmenters: 

1073 return 

1074 obs = cast(dict[str, Any], record.get("observation") or {}) 

1075 for augmenter in self.observation_augmenters: 

1076 try: 

1077 contribution = augmenter(session) 

1078 except Exception as exc: # degrade, never fail the phase 

1079 obs.setdefault("errors", []).append( 

1080 { 

1081 "tool_name": "_observation_augmenter", 

1082 "status": "failed", 

1083 "error_message": str(exc), 

1084 } 

1085 ) 

1086 continue 

1087 if not isinstance(contribution, dict): 

1088 continue 

1089 children = contribution.get("children") 

1090 if isinstance(children, list): 

1091 obs["children"] = children 

1092 extra_metrics = contribution.get("metrics") 

1093 if isinstance(extra_metrics, dict): 

1094 metrics = obs.setdefault("metrics", {}) 

1095 if isinstance(metrics, dict): 

1096 metrics.update(extra_metrics) 

1097 record["observation"] = cast(Observation, obs) 

1098 

1099 @staticmethod 

1100 def _annotate_tool_result(call: ToolCallRecord | dict[str, Any]) -> Any: 

1101 """Wrap a call's ``result_summary`` with the per-call call markers. 

1102 

1103 The Observation's ``tool_results`` list is the canonical input 

1104 to predicate criteria and to the dedicated ``tool_call_succeeded`` 

1105 evaluator. Both consult ``r.get("_status")`` and 

1106 ``r.get("tool_name")`` to know which tool produced the entry 

1107 and whether the call succeeded — markers the engine adds here 

1108 rather than relying on individual tools to inject. The stub 

1109 dispatcher used to synthesise these markers in its return, 

1110 but the live FastMCP dispatcher returns whatever shape the 

1111 underlying tool produces (often a structured ``{"result": [ 

1112 ... ]}`` dict that doesn't carry call-level metadata). 

1113 

1114 Strategy: 

1115 

1116 * **Dict result_summary** — augment in place with ``_status`` 

1117 and ``tool_name`` only when those keys are absent. This 

1118 keeps any caller-supplied marker visible (some tools do 

1119 synthesise them) while ensuring evaluators always find 

1120 them. 

1121 * **Non-dict result_summary** (None, list, primitive) — wrap 

1122 in a fresh dict carrying the call's ``_status`` / 

1123 ``tool_name`` plus a ``result`` field that holds the 

1124 original payload so predicates can still walk into it. 

1125 """ 

1126 result = call.get("result_summary") 

1127 status = call.get("status") or "unknown" 

1128 tool_name = call.get("tool_name") 

1129 if isinstance(result, dict): 

1130 annotated = dict(result) 

1131 annotated.setdefault("_status", status) 

1132 annotated.setdefault("tool_name", tool_name) 

1133 return annotated 

1134 return { 

1135 "_status": status, 

1136 "tool_name": tool_name, 

1137 "result": result, 

1138 } 

1139 

1140 def _build_observation( 

1141 self, 

1142 executed_calls: list[ToolCallRecord], 

1143 phase_started: datetime, 

1144 ) -> Observation: 

1145 """Merge a list of :class:`ToolCallRecord` into an :class:`Observation`. 

1146 

1147 Each ``executed_calls`` entry contributes one annotated dict 

1148 to ``observation["tool_results"]`` via 

1149 :meth:`_annotate_tool_result` so the entry always carries 

1150 the ``_status`` and ``tool_name`` markers the predicate 

1151 evaluator and the ``tool_call_succeeded`` evaluator both rely 

1152 on, regardless of what shape the underlying tool returned. 

1153 """ 

1154 tool_results: list[Any] = [] 

1155 metrics: dict[str, Any] = {} 

1156 events: list[dict[str, Any]] = [] 

1157 errors: list[dict[str, Any]] = [] 

1158 

1159 for call in executed_calls: 

1160 tool_results.append(self._annotate_tool_result(call)) 

1161 if call.get("status") == "ok": 

1162 result = call.get("result_summary") 

1163 # Permissive merge: when a tool's result happens to 

1164 # include a top-level ``metrics`` dict or ``events`` 

1165 # list, lift them into the Observation. Anything else 

1166 # stays only in ``tool_results``. 

1167 if isinstance(result, dict): 

1168 result_metrics = result.get("metrics") 

1169 if isinstance(result_metrics, dict): 

1170 metrics.update(result_metrics) 

1171 result_events = result.get("events") 

1172 if isinstance(result_events, list): 

1173 for event in result_events: 

1174 if isinstance(event, dict): 

1175 events.append(event) 

1176 else: 

1177 # ``failed`` and ``skipped_not_allowed`` both surface as 

1178 # errors; the heuristic in decide.py uses "errors that 

1179 # didn't appear in the prior Observation" to drive the 

1180 # adjust verdict, so a stable shape per error matters. 

1181 errors.append( 

1182 { 

1183 "tool_name": call.get("tool_name"), 

1184 "status": call.get("status"), 

1185 "error_message": call.get("error_message"), 

1186 } 

1187 ) 

1188 

1189 observation: Observation = { 

1190 "tool_results": tool_results, 

1191 "metrics": metrics, 

1192 "events": events, 

1193 "phase_started_at": phase_started.isoformat(), 

1194 "phase_ended_at": self.now().isoformat(), 

1195 } 

1196 if errors: 

1197 observation["errors"] = errors 

1198 return observation 

1199 

1200 # ------------------------------------------------------------------ # 

1201 # Phase 4 — evaluate 

1202 # ------------------------------------------------------------------ # 

1203 

1204 async def _evaluate_phase(self, session: SessionState, record: IterationRecord) -> None: 

1205 """Walk the session's Criteria and produce :class:`CriterionResult` rows. 

1206 

1207 The kinds dispatch to per-kind helpers: 

1208 

1209 * ``metric_threshold`` — dot-path lookup on the Observation, 

1210 numeric comparison via the declared operator. 

1211 * ``event`` — scan the Observation's ``events`` list for an 

1212 entry whose ``event_name`` matches the criterion's target. 

1213 * ``predicate`` — evaluate the cached parsed AST against the 

1214 Observation. A raised exception lands as ``inconclusive`` so 

1215 a malformed predicate cannot crash the loop. 

1216 

1217 Order in the output list matches the declared order of 

1218 ``session["criteria"]`` so iteration audit consumers can pair 

1219 results with criteria positionally. 

1220 """ 

1221 

1222 async def body() -> None: 

1223 observation = cast(dict[str, Any], record.get("observation") or {}) 

1224 # Build a cumulative observation for predicates: merge all 

1225 # prior iterations' tool_results into the current one so 

1226 # predicates like ``any('gpu' in str(r) for r in 

1227 # obs['tool_results'])`` can see results from prior 

1228 # iterations. Metrics and events stay per-iteration (they 

1229 # represent the current state, not history). 

1230 cumulative_obs = self._build_cumulative_observation(observation, session) 

1231 results: list[CriterionResult] = [] 

1232 for criterion in session.get("criteria") or []: 

1233 results.append( 

1234 self._evaluate_one_criterion(criterion, observation, cumulative_obs, session) 

1235 ) 

1236 record["criteria_evaluation"] = results 

1237 

1238 await self._run_phase(session, record, _EVALUATE, body) 

1239 

1240 @staticmethod 

1241 def _build_cumulative_observation( 

1242 current_obs: dict[str, Any], session: SessionState 

1243 ) -> dict[str, Any]: 

1244 """Merge prior iterations' tool_results and metric history into a view. 

1245 

1246 Two things are cumulative on the returned view: 

1247 

1248 * ``tool_results`` — every prior iteration's results concatenated with 

1249 the current iteration's, so predicate criteria can see results from 

1250 all iterations. This enables multi-tool goals where each tool runs in 

1251 a different iteration to converge. 

1252 * ``metric_history`` — a history-aware map from metric name to the 

1253 ordered list of its numeric values across the session 

1254 (oldest→newest, current iteration last). This is what lets the 

1255 ``metric_trend`` criterion ask "is loss falling across iterations?" 

1256 even though the engine keeps the per-iteration ``metrics`` dict 

1257 strictly point-in-time. Non-numeric and boolean metric values are 

1258 skipped so a stray string reading cannot poison a trend. 

1259 

1260 ``metrics``, ``events``, and ``errors`` stay per-iteration on the view 

1261 because they represent current state: ``metrics`` is the latest 

1262 point-in-time reading (a ``metric_threshold`` criterion still compares 

1263 the single current value), events are per-iteration signals, and errors 

1264 are per-call. The history lives *alongside* them under 

1265 ``metric_history`` rather than replacing them, so existing criteria are 

1266 unaffected. 

1267 """ 

1268 all_tool_results: list[Any] = [] 

1269 metric_history: dict[str, list[float]] = {} 

1270 

1271 def _accumulate_metrics(obs: Mapping[str, Any]) -> None: 

1272 metrics = obs.get("metrics") 

1273 if not isinstance(metrics, dict): 

1274 return 

1275 for key, value in metrics.items(): 

1276 # Mirror the Numeric_Value guard the readers use: int or float, 

1277 # never bool. A non-numeric reading contributes no history 

1278 # point rather than breaking the series. 

1279 if isinstance(value, bool) or not isinstance(value, (int, float)): 

1280 continue 

1281 metric_history.setdefault(key, []).append(float(value)) 

1282 

1283 for prior in session.get("iterations") or []: 

1284 prior_obs = prior.get("observation") or {} 

1285 prior_results = prior_obs.get("tool_results") 

1286 if isinstance(prior_results, list): 

1287 all_tool_results.extend(prior_results) 

1288 _accumulate_metrics(prior_obs) 

1289 # Append current iteration's results and metrics last so the history is 

1290 # ordered oldest→newest with the current reading at the end. 

1291 current_results = current_obs.get("tool_results") 

1292 if isinstance(current_results, list): 

1293 all_tool_results.extend(current_results) 

1294 _accumulate_metrics(current_obs) 

1295 # Build the cumulative view: tool_results + metric_history are 

1296 # cumulative, everything else comes from the current observation. 

1297 cumulative: dict[str, Any] = dict(current_obs) 

1298 cumulative["tool_results"] = all_tool_results 

1299 cumulative["metric_history"] = metric_history 

1300 return cumulative 

1301 

1302 def _evaluate_one_criterion( 

1303 self, 

1304 criterion: Criterion, 

1305 observation: dict[str, Any], 

1306 cumulative_obs: dict[str, Any], 

1307 session: SessionState, 

1308 ) -> CriterionResult: 

1309 """Dispatch to the right evaluator and produce a result row.""" 

1310 criterion_id = criterion["criterion_id"] 

1311 # Read ``kind`` as a plain ``str`` rather than the ``CriterionKind`` 

1312 # literal: the criteria come back from the persisted session, and a 

1313 # malformed file can carry a kind the validator never saw. Keeping 

1314 # the wider type is what makes the fallback branch below reachable. 

1315 kind: str = criterion["kind"] 

1316 evaluated_at = self.now().isoformat() 

1317 

1318 if kind == "metric_threshold": 

1319 status, evidence = self._evaluate_metric_threshold(criterion, observation) 

1320 elif kind == "metric_trend": 

1321 # Trend evaluates against the cumulative observation, where the 

1322 # engine accumulates ``metric_history`` across iterations. 

1323 status, evidence = self._evaluate_metric_trend(criterion, cumulative_obs) 

1324 elif kind == "event": 

1325 status, evidence = self._evaluate_event(criterion, observation) 

1326 elif kind == "predicate": 

1327 # Predicates evaluate against the cumulative observation so 

1328 # they can see tool_results from all prior iterations. 

1329 status, evidence = self._evaluate_predicate(criterion, cumulative_obs) 

1330 elif kind == "tool_call_succeeded": 

1331 status, evidence = self._evaluate_tool_call_succeeded(criterion, observation, session) 

1332 else: 

1333 # Never taken once the validator has run — but if a malformed 

1334 # session somehow lands here, surface the bad kind as 

1335 # inconclusive rather than raising and tearing down the 

1336 # entire iteration. 

1337 status = "inconclusive" 

1338 evidence = f"unknown_criterion_kind:{kind!r}" 

1339 

1340 return { 

1341 "criterion_id": criterion_id, 

1342 "status": cast(Any, status), 

1343 "evidence": evidence, 

1344 "evaluated_at": evaluated_at, 

1345 } 

1346 

1347 @staticmethod 

1348 def _evaluate_metric_threshold( 

1349 criterion: Criterion, observation: dict[str, Any] 

1350 ) -> tuple[str, Any]: 

1351 """Look up the metric by dot-path and compare to ``target``.""" 

1352 path = criterion.get("metric") or "" 

1353 op = criterion.get("op") 

1354 target = criterion.get("target") 

1355 value: Any = observation 

1356 for segment in path.split("."): 

1357 if isinstance(value, dict) and segment in value: 

1358 value = value[segment] 

1359 else: 

1360 return "inconclusive", f"metric_path_missing:{path!r}" 

1361 if isinstance(value, bool) or not isinstance(value, (int, float)): 

1362 return "inconclusive", value 

1363 try: 

1364 met = _compare_numbers(value, cast(str, op), cast(float, target)) 

1365 except ValueError: 

1366 return "inconclusive", value 

1367 return ("met" if met else "unmet"), value 

1368 

1369 @staticmethod 

1370 def _evaluate_metric_trend( 

1371 criterion: Criterion, cumulative_obs: dict[str, Any] 

1372 ) -> tuple[str, Any]: 

1373 """Evaluate a metric's direction across the accumulated history. 

1374 

1375 Reads the metric's value series from 

1376 ``cumulative_obs["metric_history"]`` — the oldest→newest list of 

1377 numeric readings the engine accumulates in 

1378 :meth:`_build_cumulative_observation`. The ``metric`` dot-path is 

1379 resolved against that map: a leading ``metrics.`` segment is stripped 

1380 so a criterion can reuse the same ``"metrics.loss"`` path it would use 

1381 for ``metric_threshold`` and still address the ``loss`` history series. 

1382 

1383 The series is trimmed to the most-recent ``window`` points (default: 

1384 all available). With fewer than ``min_points`` numeric points (default 

1385 2) the criterion is ``inconclusive`` — a trend is undefined on a single 

1386 reading, and the loop must never be failed for lack of history. The 

1387 verdict compares the last point to the first point of the windowed 

1388 series per ``direction``: 

1389 

1390 * ``decreasing`` → last < first 

1391 * ``increasing`` → last > first 

1392 * ``non_increasing`` → last <= first 

1393 * ``non_decreasing`` → last >= first 

1394 

1395 Evidence is a structured dict (direction, the windowed points, first / 

1396 last, and net delta) so the audit log shows exactly what the verdict 

1397 was computed from. 

1398 """ 

1399 path = criterion.get("metric") or "" 

1400 direction = criterion.get("direction") 

1401 # Resolve the metric key against the history map. Accept both the bare 

1402 # key (``"loss"``) and the dot-path form (``"metrics.loss"``) so a 

1403 # trend criterion lines up with the metric_threshold convention. 

1404 history = cumulative_obs.get("metric_history") 

1405 if not isinstance(history, dict): 

1406 return "inconclusive", "metric_history_missing" 

1407 key = path.split(".", 1)[1] if path.startswith("metrics.") else path 

1408 series = history.get(key) 

1409 if not isinstance(series, list) or not series: 

1410 return "inconclusive", f"metric_history_empty:{key!r}" 

1411 

1412 # Keep only the numeric points (the accumulator already filters, but be 

1413 # defensive against a hand-built cumulative_obs in tests). 

1414 points: list[float] = [ 

1415 float(v) for v in series if not isinstance(v, bool) and isinstance(v, (int, float)) 

1416 ] 

1417 

1418 window = criterion.get("window") 

1419 if isinstance(window, int) and not isinstance(window, bool) and window > 0: 

1420 points = points[-window:] 

1421 

1422 min_points = criterion.get("min_points") 

1423 required_points = ( 

1424 min_points if isinstance(min_points, int) and not isinstance(min_points, bool) else 2 

1425 ) 

1426 required_points = max(2, required_points) 

1427 if len(points) < required_points: 

1428 return "inconclusive", { 

1429 "reason": "insufficient_history", 

1430 "points": points, 

1431 "required_points": required_points, 

1432 } 

1433 

1434 first = points[0] 

1435 last = points[-1] 

1436 delta = last - first 

1437 if direction == "decreasing": 

1438 met = last < first 

1439 elif direction == "increasing": 

1440 met = last > first 

1441 elif direction == "non_increasing": 

1442 met = last <= first 

1443 elif direction == "non_decreasing": 

1444 met = last >= first 

1445 else: 

1446 # Unreachable when the validator has run; surface defensively. 

1447 return "inconclusive", f"unknown_direction:{direction!r}" 

1448 

1449 evidence = { 

1450 "direction": direction, 

1451 "points": points, 

1452 "first": first, 

1453 "last": last, 

1454 "delta": delta, 

1455 } 

1456 return ("met" if met else "unmet"), evidence 

1457 

1458 @staticmethod 

1459 def _evaluate_event(criterion: Criterion, observation: dict[str, Any]) -> tuple[str, Any]: 

1460 """Scan ``observation['events']`` for the named event.""" 

1461 if "events" not in observation: 

1462 return "inconclusive", "events_field_missing" 

1463 events = observation.get("events") 

1464 if not isinstance(events, list): 

1465 return "inconclusive", "events_field_not_a_list" 

1466 target = criterion.get("event_name") 

1467 for event in events: 

1468 if isinstance(event, dict) and event.get("event_name") == target: 

1469 return "met", event 

1470 return "unmet", None 

1471 

1472 @staticmethod 

1473 def _evaluate_predicate(criterion: Criterion, observation: dict[str, Any]) -> tuple[str, Any]: 

1474 """Run the cached parsed AST against the Observation. 

1475 

1476 The validator caches an :class:`ast.Expression` under 

1477 ``_parsed_ast`` when ``validate_criteria`` runs in-process. 

1478 Persistence layers strip that key before serialisation (the 

1479 AST node is not JSON-safe), so a session reloaded from disk 

1480 carries criteria *without* ``_parsed_ast``. We detect the 

1481 missing cache and re-parse on demand from ``expression``; 

1482 the parser was already accepted at validation time so a 

1483 re-parse is a pure no-op short of re-reading the source. A 

1484 post-load tampering with ``expression`` would cause 

1485 :class:`PredicateRejected`, which we surface as a structured 

1486 ``inconclusive`` evidence string rather than letting it 

1487 propagate. 

1488 """ 

1489 parsed = criterion.get("_parsed_ast") 

1490 if parsed is None: 

1491 expression = criterion.get("expression") 

1492 if not isinstance(expression, str) or not expression: 

1493 return "inconclusive", "predicate_ast_not_cached" 

1494 try: 

1495 parsed = parse_predicate(expression) 

1496 except PredicateRejected as exc: 

1497 return "inconclusive", f"predicate_rejected_post_load: {exc.reason}" 

1498 try: 

1499 value = evaluate_predicate(parsed, observation) 

1500 except Exception as exc: 

1501 return "inconclusive", f"{type(exc).__name__}: {exc}" 

1502 return ("met" if value else "unmet"), value 

1503 

1504 @staticmethod 

1505 def _evaluate_tool_call_succeeded( 

1506 criterion: Criterion, 

1507 observation: dict[str, Any], 

1508 session: SessionState | dict[str, Any] | None = None, 

1509 ) -> tuple[str, Any]: 

1510 """Count successful tool_results matching the named tool across all iterations. 

1511 

1512 The criterion is met when at least ``min_count`` (default 1) 

1513 entries across the **entire session history** (all prior 

1514 iterations' observations plus the current one) have 

1515 ``tool_name`` equal to the criterion's ``tool_name`` and 

1516 ``_status`` equal to ``"ok"``. This cumulative evaluation 

1517 means a multi-tool goal where each tool runs in a different 

1518 iteration can still converge — the criterion remembers that 

1519 the tool succeeded in a prior iteration even if the current 

1520 iteration called a different tool. 

1521 

1522 Returns a ``(status, evidence)`` tuple where ``evidence`` is 

1523 a structured dict so the audit log shows the match shape. 

1524 """ 

1525 target_tool = criterion.get("tool_name") 

1526 min_count = criterion.get("min_count", 1) 

1527 

1528 # Collect tool_results from all prior iterations + the current observation. 

1529 all_results: list[dict[str, Any]] = [] 

1530 

1531 # Prior iterations' observations (when session is provided). 

1532 if session is not None: 

1533 for prior_iteration in session.get("iterations") or []: 

1534 prior_obs = prior_iteration.get("observation") or {} 

1535 prior_results = prior_obs.get("tool_results") 

1536 if isinstance(prior_results, list): 

1537 for r in prior_results: 

1538 if isinstance(r, dict): 

1539 all_results.append(r) 

1540 

1541 # Current iteration's observation. 

1542 current_results = observation.get("tool_results") 

1543 if isinstance(current_results, list): 

1544 for r in current_results: 

1545 if isinstance(r, dict): 

1546 all_results.append(r) 

1547 

1548 if not all_results: 

1549 return "inconclusive", "tool_results_field_missing" 

1550 

1551 successful = [ 

1552 r for r in all_results if r.get("tool_name") == target_tool and r.get("_status") == "ok" 

1553 ] 

1554 evidence = { 

1555 "tool_name": target_tool, 

1556 "min_count": min_count, 

1557 "successful_call_count": len(successful), 

1558 } 

1559 if len(successful) >= min_count: 

1560 return "met", evidence 

1561 return "unmet", evidence 

1562 

1563 # ------------------------------------------------------------------ # 

1564 # Phase 5 — decide 

1565 # ------------------------------------------------------------------ # 

1566 

1567 async def _decide_phase( 

1568 self, session: SessionState, record: IterationRecord 

1569 ) -> tuple[VerdictLabel, VerdictReason]: 

1570 """Run the deterministic verdict cascade and stamp the record.""" 

1571 

1572 async def body() -> tuple[VerdictLabel, VerdictReason]: 

1573 now_value = self.now() 

1574 verdict, reason = decide.decide_verdict(session, record, now_value) 

1575 checkpoint_evaluated = reason != "cadence_skip" 

1576 record["checkpoint_evaluated"] = checkpoint_evaluated 

1577 if verdict == "adjust": 

1578 record["revision_rationale"] = decide.build_revision_rationale_template( 

1579 session, record 

1580 ) 

1581 if checkpoint_evaluated: 

1582 # ``last_checkpoint_at`` anchors the every_t_seconds 

1583 # cadence; only real (non-skip) verdicts advance it. 

1584 mark_checkpoint(session, now_value) 

1585 return verdict, reason 

1586 

1587 return cast( 

1588 tuple[VerdictLabel, VerdictReason], 

1589 await self._run_phase(session, record, _DECIDE, body), 

1590 ) 

1591 

1592 # ------------------------------------------------------------------ # 

1593 # Post-iteration housekeeping 

1594 # ------------------------------------------------------------------ # 

1595 

1596 def _update_session_post_iteration( 

1597 self, session: SessionState, record: IterationRecord 

1598 ) -> None: 

1599 """Advance or reset the no-progress counter. 

1600 

1601 Counter semantics (matching the Decide_Phase's stagnation cap): 

1602 

1603 * Synthetic ``cadence_skip`` iterations leave the counter 

1604 alone — a session whose cadence is ``every_n_iterations`` 

1605 must not be able to reach ``stagnation_threshold`` purely 

1606 because most iterations skip the criteria check. 

1607 * On evaluated iterations, compute the per-criterion 

1608 improvement against the immediately prior evaluated 

1609 iteration. A criterion improved iff its prior status was 

1610 ``unmet`` or ``inconclusive`` AND its current status is 

1611 ``met``. Any improvement resets the counter to 0; otherwise 

1612 the counter increments by 1. 

1613 

1614 ``record`` has already been appended to 

1615 ``session["iterations"]`` by the caller, so the prior 

1616 iteration is at index ``-2``. 

1617 """ 

1618 if not record.get("checkpoint_evaluated"): 

1619 return 

1620 

1621 prior_eval = self._previous_evaluated_iteration(session) 

1622 if prior_eval is None: 

1623 # No prior evaluated iteration: the loop has nothing to 

1624 # measure improvement against. Treat as no-improvement 

1625 # rather than a forced reset, so the stagnation counter 

1626 # tracks "how long since we made measurable progress" 

1627 # uniformly across the run. 

1628 session["no_progress_counter"] = (session.get("no_progress_counter", 0) or 0) + 1 

1629 return 

1630 

1631 if self._criteria_improved(prior_eval, record["criteria_evaluation"]): 

1632 session["no_progress_counter"] = 0 

1633 else: 

1634 session["no_progress_counter"] = (session.get("no_progress_counter", 0) or 0) + 1 

1635 

1636 @staticmethod 

1637 def _previous_evaluated_iteration( 

1638 session: SessionState, 

1639 ) -> list[CriterionResult] | None: 

1640 """Return the criteria evaluation of the most recent evaluated iteration. 

1641 

1642 "Evaluated" here means ``checkpoint_evaluated=True``. Skipping 

1643 cadence-skip iterations is what makes the no-progress counter 

1644 immune to the cadence configuration: it only ever measures 

1645 movement between *real* checkpoints. The current iteration is 

1646 already appended to ``session["iterations"]`` by the caller, 

1647 so we look strictly before it. 

1648 """ 

1649 iterations = session.get("iterations") or [] 

1650 # Skip the just-appended current iteration (last index). 

1651 for prior in reversed(iterations[:-1]): 

1652 if prior.get("checkpoint_evaluated"): 

1653 return list(prior.get("criteria_evaluation") or []) 

1654 return None 

1655 

1656 @staticmethod 

1657 def _criteria_improved(prior: Iterable[object], current: Iterable[object]) -> bool: 

1658 """Return True iff any criterion went from not-met to met. 

1659 

1660 Both lists are ``criteria_evaluation`` rows read back from the 

1661 persisted session, so they are typed as ``object`` entries and 

1662 every row is shape-checked: a non-dict entry (a corrupt file) is 

1663 skipped rather than crashing the stagnation cascade. 

1664 """ 

1665 prior_status = { 

1666 result["criterion_id"]: result["status"] 

1667 for result in prior 

1668 if isinstance(result, dict) and "criterion_id" in result 

1669 } 

1670 for result in current: 

1671 if not isinstance(result, dict): 

1672 continue 

1673 current_status = result.get("status") 

1674 if current_status != "met": 

1675 continue 

1676 prior_value = prior_status.get(result.get("criterion_id")) 

1677 if prior_value in ("unmet", "inconclusive", None): 

1678 # ``None`` covers a criterion that did not appear in 

1679 # the prior evaluation — treating it as "not met 

1680 # before" is consistent with first-time-met being an 

1681 # improvement. 

1682 return True 

1683 return False 

1684 

1685 # ------------------------------------------------------------------ # 

1686 # Terminal-verdict finalisation 

1687 # ------------------------------------------------------------------ # 

1688 

1689 async def _finalise_terminal_session( 

1690 self, 

1691 session: SessionState, 

1692 record: IterationRecord, 

1693 verdict: VerdictLabel, 

1694 reason: VerdictReason, 

1695 ) -> None: 

1696 """Transition the session to its terminal status and write the report. 

1697 

1698 Called by ``run_iteration`` only when the verdict is in 

1699 :data:`TERMINAL_VERDICTS`. The status mapping is fixed: 

1700 ``complete`` → ``completed``, ``terminate`` → ``terminated``. 

1701 ``ended_at`` is anchored on the iteration's own ``ended_at`` 

1702 so the session's lifecycle window matches the last persisted 

1703 iteration's window without an extra clock read. 

1704 

1705 When :attr:`final_lessons_callable` is wired, the engine awaits 

1706 it once to fetch a ``{"lessons": ..., "recommended_followups": 

1707 ...}`` overlay and synthesises a tiny synchronous sampler 

1708 closure that returns the pre-fetched overlay; the closure is 

1709 then handed to :func:`mcp.mission.final_report.write_final_report` 

1710 which keeps its existing sync-callable contract. This pre-fetch 

1711 bridge is needed because the production helper 

1712 (:func:`mcp.mission.sampling.maybe_sample_final_lessons`) is 

1713 async while ``write_final_report`` is sync. 

1714 """ 

1715 if verdict == "complete": 

1716 session["status"] = "completed" 

1717 else: # verdict == "terminate" 

1718 session["status"] = "terminated" 

1719 session["ended_at"] = record["ended_at"] 

1720 session["final_verdict"] = verdict 

1721 # Optional sampling overlay for the lessons / followups fields. 

1722 # Pre-fetch so the (sync) report writer never has to await. 

1723 overlay = await self._maybe_sample_final_lessons(session, verdict, reason) 

1724 sampler: Callable[..., dict[str, Any] | None] | None 

1725 if overlay is not None: 

1726 

1727 def _pre_fetched_sampler( 

1728 _session: SessionState, 

1729 _verdict: VerdictLabel, 

1730 _reason: VerdictReason, 

1731 ) -> dict[str, Any] | None: 

1732 return overlay 

1733 

1734 sampler = _pre_fetched_sampler 

1735 else: 

1736 sampler = None 

1737 # The Final_Report is the durable exit artifact. We write it 

1738 # via the report helper so the persistence path (filesystem 

1739 # sibling vs. embedded-on-session for non-filesystem backends) 

1740 # is owned by one module. 

1741 final_report.write_final_report(self.backend, session, verdict, reason, sampler=sampler) 

1742 

1743 # Best-effort institutional memory, after the report has landed. 

1744 # Reuses the overlay fetched above — never re-samples. 

1745 self._maybe_write_memory(session, verdict, reason, overlay) 

1746 

1747 async def _maybe_sample_final_lessons( 

1748 self, 

1749 session: SessionState, 

1750 verdict: VerdictLabel, 

1751 reason: VerdictReason, 

1752 ) -> dict[str, Any] | None: 

1753 """Fetch the optional Final_Report ``lessons`` / ``followups`` overlay. 

1754 

1755 Calls :attr:`final_lessons_callable` once (when wired and when 

1756 the session opted into sampling) and adapts the return value 

1757 into the ``{"lessons": str, "recommended_followups": list[str]}`` 

1758 shape that 

1759 :func:`mcp.mission.final_report.write_final_report` expects. 

1760 

1761 Three return shapes are recognised: 

1762 

1763 * :class:`mcp.mission.sampling.SamplingUsed` — production path. 

1764 ``parsed["lessons"]`` is a list of strings; the engine joins 

1765 them with double newlines so the report's ``lessons`` field 

1766 stays a single string. ``parsed["recommended_followups"]`` is 

1767 forwarded as a list verbatim. 

1768 * Raw ``dict`` (legacy / test pattern) — passed straight 

1769 through. The downstream sampler-overlay code in 

1770 ``write_final_report`` already validates and silently drops 

1771 malformed fields. 

1772 * Anything else (including :class:`SamplingFallback`, 

1773 ``None``, exceptions) — returns ``None`` so the 

1774 deterministic templates from 

1775 :func:`mcp.mission.final_report.build_deterministic_report` 

1776 stand on their own. 

1777 

1778 The method swallows any exception raised by the callable 

1779 because the Final_Report is the durable exit artifact and a 

1780 flaky sampler must not block it from landing. 

1781 """ 

1782 del verdict, reason # forwarded only for symmetry with the legacy Sampler shape 

1783 if self.final_lessons_callable is None: 

1784 return None 

1785 if not session.get("use_sampling"): 

1786 return None 

1787 try: 

1788 result = await self.final_lessons_callable(session=session) 

1789 except Exception: 

1790 return None 

1791 if isinstance(result, SamplingUsed): 

1792 lessons = result.parsed.get("lessons") 

1793 followups = result.parsed.get("recommended_followups") 

1794 overlay: dict[str, Any] = {} 

1795 if isinstance(lessons, list) and all(isinstance(item, str) for item in lessons): 

1796 # write_final_report expects ``lessons`` as a single 

1797 # string; join with blank lines so multi-bullet output 

1798 # from the model stays readable on render. 

1799 overlay["lessons"] = "\n\n".join(lessons) 

1800 if isinstance(followups, list) and all(isinstance(item, str) for item in followups): 

1801 overlay["recommended_followups"] = list(followups) 

1802 return overlay or None 

1803 if isinstance(result, SamplingFallback): 

1804 return None 

1805 if isinstance(result, dict): 

1806 # Legacy raw-dict path — let the downstream overlay 

1807 # validator do the structural check. 

1808 return result 

1809 return None 

1810 

1811 def _maybe_write_memory( 

1812 self, 

1813 session: SessionState, 

1814 verdict: VerdictLabel, 

1815 reason: VerdictReason, 

1816 overlay: dict[str, Any] | None, 

1817 ) -> None: 

1818 """Best-effort mission-memory write on a terminal verdict. 

1819 

1820 Persists one memory item — the directive, its embedding, and 

1821 the report's narrative — through :attr:`memory_store` so future 

1822 missions with similar directives can recall this one's lessons. 

1823 

1824 The narrative fields reuse what the Final_Report just recorded: 

1825 the sampled ``overlay`` when one was produced (never re-sampled 

1826 — it is the exact dict handed to ``write_final_report`` above), 

1827 else the deterministic templates from 

1828 :func:`mcp.mission.final_report.build_deterministic_report`, 

1829 which is pure and cheap to rebuild. 

1830 

1831 Every exception is swallowed, mirroring 

1832 :meth:`_maybe_sample_final_lessons`: the Final_Report is the 

1833 durable exit artifact and memory is strictly additive. An 

1834 absent table, a backfilling index, a missing SSM parameter, an 

1835 unreachable Bedrock endpoint, or a store bug must all degrade 

1836 to "no memory written" — never to a failed mission. 

1837 """ 

1838 if self.memory_store is None: 

1839 return 

1840 try: 

1841 report = final_report.build_deterministic_report(session, verdict, reason) 

1842 lessons = report.get("lessons", "") 

1843 followups = report.get("recommended_followups", []) 

1844 if overlay: 

1845 overlay_lessons = overlay.get("lessons") 

1846 if isinstance(overlay_lessons, str) and overlay_lessons.strip(): 

1847 lessons = overlay_lessons 

1848 overlay_followups = overlay.get("recommended_followups") 

1849 if isinstance(overlay_followups, list) and all( 

1850 isinstance(item, str) for item in overlay_followups 

1851 ): 

1852 followups = overlay_followups 

1853 self.memory_store.write_memory( 

1854 session, 

1855 verdict, 

1856 reason, 

1857 str(lessons), 

1858 [str(item) for item in followups], 

1859 ) 

1860 except Exception: 

1861 return 

1862 

1863 

1864# --------------------------------------------------------------------------- 

1865# Comparison helper 

1866# --------------------------------------------------------------------------- 

1867 

1868 

1869def _compare_numbers(value: float, op: str, target: float) -> bool: 

1870 """Apply one of the six allowed numeric comparison operators.""" 

1871 if op == "<": 

1872 return value < target 

1873 if op == "<=": 

1874 return value <= target 

1875 if op == ">": 

1876 return value > target 

1877 if op == ">=": 

1878 return value >= target 

1879 if op == "==": 

1880 return value == target 

1881 if op == "!=": 

1882 return value != target 

1883 raise ValueError(f"unknown comparison operator: {op!r}")