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

87 statements  

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

1"""Pure deterministic verdict cascade for the Mission Decide_Phase. 

2 

3The cascade is the **control-path** output of the loop: given the current 

4``SessionState`` (before the in-progress iteration is appended), the 

5in-progress :class:`IterationRecord` (with ``strategy``, ``observation``, 

6and ``criteria_evaluation`` already populated but ``verdict`` / 

7``verdict_reason`` not yet set), and the wall-clock value the caller has 

8already measured, :func:`decide_verdict` returns a 

9``(VerdictLabel, VerdictReason)`` tuple. The function is pure: no logger 

10calls, no I/O, no random sources, no clock reads. The wall-clock value is 

11passed in on the call signature so tests can pin it. 

12 

13The cascade order is fixed: 

14 

151. **Budget terminations** — checked in a fixed sub-order so the 

16 verdict_reason is deterministic when more than one cap is breached: 

17 

18 * ``max_iterations`` — the in-progress iteration would be the 

19 ``budget["max_iterations"]``-th or later. Computed as 

20 ``len(session["iterations"]) + 1 >= max_iterations``. 

21 * ``max_wall_clock`` — ``now - session["started_at"] >= max_wall_clock_seconds``. 

22 Returns False when ``started_at`` is missing (the session has 

23 not yet transitioned out of ``pending``). 

24 * ``no_progress`` — ``no_progress_counter >= stagnation_threshold``. 

25 When the session has ``use_sampling=true``, the heuristic (step 

26 4) gets priority so the sampler can revise the strategy before 

27 the loop terminates. Without sampling, ``no_progress`` 

28 terminates immediately. If the heuristic doesn't fire (e.g., 

29 the tool sequence changed after a prior revision), the deferred 

30 stagnation check (step 4b) terminates. 

31 

322. **Completion** — every ``required=True`` Criterion has status 

33 ``met`` in the in-progress iteration's ``criteria_evaluation``, AND 

34 no Criterion (required or not) has status ``inconclusive``. 

35 

363. **Cadence-skip** — when :func:`should_evaluate_now` says "this is 

37 not a checkpoint", emit a synthetic ``("continue", "cadence_skip")`` 

38 without consulting the Strategy_Revision_Heuristic. The heuristic 

39 only fires on real checkpoints so off-cadence iterations cannot 

40 advance the no-progress counter or trigger an ``adjust``. 

41 

424. **Strategy_Revision_Heuristic** — :func:`_strategy_unproductive`: 

43 the same ``tool_calls[*].tool_name`` sequence 

44 for the last 3 iterations AND ``no_progress_counter`` at or above 

45 half the stagnation threshold, OR new errors in the latest 

46 Observation that didn't appear in the prior Observation. Returns 

47 ``("adjust", "heuristic_unproductive")`` when either clause fires. 

48 

494b. **Deferred stagnation** — if step 1c deferred the ``no_progress`` 

50 check (because sampling is enabled) and the heuristic didn't fire, 

51 terminate now. 

52 

535. **Default** — ``("continue", "in_progress")``. 

54 

55The ``iteration`` argument is *not* yet present in 

56``session["iterations"]`` — the engine appends it after the verdict is 

57decided. Anything that needs to look at "the last N iterations 

58including the current one" composes the current ``iteration`` with 

59``session["iterations"][-(N-1):]``. 

60 

61Determinism: same ``(session, iteration, now)`` triples produce the same 

62``(VerdictLabel, VerdictReason)`` tuples. This is enforced by a property 

63test in ``tests/test_mission_decide_determinism.py``. 

64 

65Cost guardrails are intentionally absent from this cascade. Real-time 

66workload cost tracking is structurally inaccurate (Spot vs on-demand 

67drift, EBS / EFA / egress not in the Pricing API, Cost Explorer 24h 

68latency). Operators who need a cost cap should configure AWS Budgets 

69and Cost Anomaly Detection at the account level — Mission caps only 

70the controls the loop has direct visibility into. 

71""" 

72 

73from __future__ import annotations 

74 

75import math 

76from datetime import datetime, timedelta 

77 

78from .checkpoints import should_evaluate_now 

79from .types import IterationRecord, SessionState, VerdictLabel, VerdictReason 

80 

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

82# Generated at (UTC): 2026-09-01T14:42:56Z 

83# Generated from Git commit: 89b000378ed5a912a38c06f4feab2b029936ebcc 

84# Flowchart(s) generated from this file: 

85# * ``decide_verdict`` -> ``diagrams/code_diagrams/gco_mcp/mission/decide.decide_verdict.html`` 

86# (PNG: ``diagrams/code_diagrams/gco_mcp/mission/decide.decide_verdict.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 "build_revision_rationale_template", 

93 "decide_verdict", 

94] 

95 

96 

97def decide_verdict( 

98 session: SessionState, 

99 iteration: IterationRecord, 

100 now: datetime, 

101) -> tuple[VerdictLabel, VerdictReason]: 

102 """Return the deterministic Verdict for the in-progress iteration. 

103 

104 The cascade order is fixed (see module docstring). The first matching 

105 branch wins: a session that has both run out of iterations and has 

106 every Criterion met returns ``("terminate", "max_iterations")``, not 

107 ``("complete", "criteria_met")`` — budget caps are evaluated before 

108 completion so the operator can tell the loop ended because it ran 

109 out of budget rather than because the goal was reached on the 

110 closing iteration. 

111 Note: When the prior Execute_Phase ran a scripted Strategy and the 

112 sandbox cap fired, ``_execute_script`` writes 

113 ``iteration["sandbox_terminated_reason"]`` and the cascade returns 

114 that reason verbatim before anything else is consulted. The 

115 sandbox limit is a true budget cap — the script ran out of wall 

116 clock during execution — so it routes to a ``terminate`` verdict 

117 on the same path as the ``BudgetControls``-driven caps below. 

118 """ 

119 # 0. Sandbox-cap propagation. ``_execute_script`` stashes the 

120 # reason on the in-progress iteration when the sandbox runner 

121 # raised :class:`SandboxTerminated`. Reading the sentinel here 

122 # means the engine's Execute_Phase can complete cleanly (no phase 

123 # failure) while still routing the verdict to the budget-cap path. 

124 sandbox_reason = iteration.get("sandbox_terminated_reason") 

125 if sandbox_reason is not None: 

126 return ("terminate", sandbox_reason) 

127 

128 # 1a. max_iterations — the +1 captures "this in-progress iteration 

129 # would be the Nth one to land", so a session with budget=N and N-1 

130 # already-recorded iterations terminates on the Nth's Decide_Phase. 

131 # ``-1`` is the explicit "uncapped" sentinel; the validator 

132 # already enforced that any other non-positive value is rejected. 

133 max_iter = session["budget"]["max_iterations"] 

134 if max_iter != -1 and len(session["iterations"]) + 1 >= max_iter: 

135 return ("terminate", "max_iterations") 

136 # 1b. max_wall_clock — pure time arithmetic; missing started_at 

137 # means the session has not yet recorded its first iteration's 

138 # start, so no wall-clock can be measured. 

139 if _wall_clock_exceeded(session, now): 

140 return ("terminate", "max_wall_clock") 

141 # 1c. no_progress — the counter is incremented by the engine only 

142 # on evaluated iterations, so a session with all-skipped checkpoints 

143 # cannot terminate for stagnation. When the session has sampling 

144 # enabled, the heuristic gets priority (step 4 below) so the 

145 # sampler can revise the strategy before the loop terminates. 

146 # Without sampling, ``adjust`` is purely informational and 

147 # ``no_progress`` terminates immediately. 

148 if session["no_progress_counter"] >= session["stagnation_threshold"]: 

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

150 return ("terminate", "no_progress") 

151 # With sampling enabled, fall through to the heuristic check 

152 # below. If the heuristic fires, the sampler gets one more 

153 # chance. If it doesn't fire (e.g., the tool sequence changed 

154 # after a prior revision), terminate for stagnation. 

155 _stagnation_pending = True 

156 else: 

157 _stagnation_pending = False 

158 

159 # 2. Completion — every required Criterion met AND nothing inconclusive. 

160 if _completion_satisfied(session, iteration): 

161 return ("complete", "criteria_met") 

162 

163 # 3. Cadence-skip — bail before the heuristic fires so off-cadence 

164 # iterations don't ever produce ``adjust``. The iteration_index 

165 # passed to ``should_evaluate_now`` is the 0-indexed position of 

166 # the in-progress iteration (which equals the count of already- 

167 # persisted iterations). 

168 if not should_evaluate_now(session, len(session["iterations"]), now): 

169 return ("continue", "cadence_skip") 

170 

171 # 4. Strategy_Revision_Heuristic. 

172 unproductive, _heuristic_reason = _strategy_unproductive(session, iteration) 

173 if unproductive: 

174 return ("adjust", "heuristic_unproductive") 

175 

176 # 4b. Deferred stagnation — the counter hit the threshold but the 

177 # heuristic didn't fire (e.g., the tool sequence changed after a 

178 # prior sampled revision). Terminate now. 

179 if _stagnation_pending: 

180 return ("terminate", "no_progress") 

181 

182 # 5. Default. 

183 return ("continue", "in_progress") 

184 

185 

186# --------------------------------------------------------------------------- 

187# Budget helpers — pure 

188# --------------------------------------------------------------------------- 

189 

190 

191def _wall_clock_exceeded(session: SessionState, now: datetime) -> bool: 

192 """True iff ``now - session["started_at"] >= max_wall_clock_seconds``. 

193 

194 Returns False when ``started_at`` is absent — a session that has 

195 never been transitioned out of ``pending`` cannot have exceeded any 

196 wall-clock budget. The engine writes ``started_at`` on the first 

197 iteration entry, so this guard only matters for the synthetic 

198 "decide called before run_iteration" path used in unit tests. 

199 

200 Returns False when ``max_wall_clock_seconds`` is the explicit 

201 ``-1`` "uncapped" sentinel — the operator opted out of the wall- 

202 clock cap and the cascade should fall through to the next branch 

203 rather than terminate spuriously. 

204 """ 

205 started_iso = session.get("started_at") 

206 if not started_iso: 

207 return False 

208 max_seconds = session["budget"]["max_wall_clock_seconds"] 

209 if max_seconds == -1: 

210 return False 

211 started = datetime.fromisoformat(started_iso) 

212 return now - started >= timedelta(seconds=max_seconds) 

213 

214 

215# --------------------------------------------------------------------------- 

216# Completion check 

217# --------------------------------------------------------------------------- 

218 

219 

220def _completion_satisfied( 

221 session: SessionState, 

222 iteration: IterationRecord, 

223) -> bool: 

224 """True iff every required Criterion is met and none are inconclusive. 

225 

226 A session completes when all Criteria with ``required=True`` have 

227 status ``met`` AND no Criterion (required or not) has status 

228 ``inconclusive``. The ``required`` flag lives on the Criterion 

229 declaration in ``session["criteria"]``; the per-iteration status 

230 lives on ``iteration["criteria_evaluation"]``. The two are joined 

231 by ``criterion_id``. 

232 

233 A session with zero declared Criteria can never complete on its own 

234 — there are no required Criteria for the cascade to satisfy. The 

235 operator drives such a session to terminal via ``mission_complete`` 

236 or a budget cap. We mirror that semantic here by returning False 

237 when the criteria list is empty. 

238 """ 

239 if not session["criteria"]: 

240 return False 

241 required_by_id = {c["criterion_id"]: c.get("required", True) for c in session["criteria"]} 

242 for result in iteration["criteria_evaluation"]: 

243 status = result["status"] 

244 if status == "inconclusive": 

245 return False 

246 if required_by_id.get(result["criterion_id"], True) and status != "met": 

247 return False 

248 return True 

249 

250 

251# --------------------------------------------------------------------------- 

252# Strategy_Revision_Heuristic 

253# --------------------------------------------------------------------------- 

254 

255 

256def _strategy_unproductive( 

257 session: SessionState, 

258 iteration: IterationRecord, 

259) -> tuple[bool, str]: 

260 """Pure heuristic for the Strategy_Revision check. 

261 

262 Two clauses, evaluated in declaration order. The first match wins 

263 so the returned reason is deterministic when both clauses fire. 

264 

265 * **Clause (a)** — the same ``tool_calls[*].tool_name`` sequence 

266 has been used for the last 3 iterations (counting the in-progress 

267 one) AND ``no_progress_counter >= ceil(stagnation_threshold / 2)``. 

268 Needs at least 2 prior iterations to evaluate (3 total when the 

269 current iteration is included). A scripted strategy contributes 

270 an empty sequence so two scripts with the same body register as 

271 "same sequence" — that's intentional: the heuristic flags repeats, 

272 and an empty-sequence repeat across three iterations is a repeat. 

273 * **Clause (b)** — the in-progress Observation contains at least 

274 one ``errors`` entry that did not appear in the immediately 

275 prior Iteration's Observation. Needs at least 1 prior iteration 

276 to evaluate. Without a prior to compare to, "new" is undefined 

277 and we return False. 

278 

279 Returns ``(False, "")`` when neither clause fires. When clause (a) 

280 fires, the reason is ``"tool_sequence_repeating"``; when clause (b) 

281 fires, ``"new_observation_errors"``. The reason string is 

282 informational only — the Verdict's ``verdict_reason`` is always 

283 ``"heuristic_unproductive"`` regardless of which clause matched. 

284 """ 

285 # Clause (a): no_progress threshold AND tool-sequence repeat. 

286 threshold = session["stagnation_threshold"] 

287 half = math.ceil(threshold / 2) 

288 if session["no_progress_counter"] >= half: 

289 # Need at least 2 prior + the current = 3 total iterations. 

290 prior = session["iterations"] 

291 if len(prior) >= 2: 

292 recent_three = [prior[-2], prior[-1], iteration] 

293 sequences = [_tool_name_sequence(it) for it in recent_three] 

294 if sequences[0] == sequences[1] == sequences[2]: 

295 return (True, "tool_sequence_repeating") 

296 

297 # Clause (b): new errors in the latest Observation vs the prior one. 

298 if session["iterations"]: 

299 prior_observation = session["iterations"][-1].get("observation") or {} 

300 prior_errors = list(prior_observation.get("errors") or []) 

301 current_errors = list(iteration["observation"].get("errors") or []) 

302 for err in current_errors: 

303 if err not in prior_errors: 

304 return (True, "new_observation_errors") 

305 

306 return (False, "") 

307 

308 

309def _tool_name_sequence(iteration: IterationRecord) -> tuple[str, ...]: 

310 """Extract the ordered tuple of ``tool_name``s from an iteration's strategy. 

311 

312 Returns an empty tuple when the strategy is a script (no 

313 ``tool_calls``) or when ``tool_calls`` is missing. Two scripted 

314 strategies therefore both produce ``()`` and compare equal — clause 

315 (a) treats that as "same sequence", which matches the operator's 

316 intent of flagging mechanical repetition regardless of mode. 

317 """ 

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

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

320 return tuple(str(call.get("tool_name", "")) for call in tool_calls if isinstance(call, dict)) 

321 

322 

323# --------------------------------------------------------------------------- 

324# Revision rationale template 

325# --------------------------------------------------------------------------- 

326 

327 

328def build_revision_rationale_template( 

329 session: SessionState, 

330 iteration: IterationRecord, 

331) -> str: 

332 """Build the deterministic ``revision_rationale`` text for an ``adjust`` verdict. 

333 

334 Used both as the rationale on sessions with ``use_sampling=false`` 

335 and as the fallback rationale when sampling is rejected on a 

336 ``use_sampling=true`` session. 

337 Pure: depends only on persisted Session/Iteration fields, never 

338 calls into the sampler or any other non-deterministic component. 

339 

340 The rendered text names the iteration index (1-indexed for 

341 operator-friendliness), the heuristic reason, the unmet Criterion 

342 ids (so the rationale points at the goal that's still moving), and 

343 a one-line summary of the in-progress strategy (tool-name sequence 

344 or ``"scripted strategy"``). The format is intentionally short and 

345 machine-parseable — operators can grep it; no LLM is involved. 

346 """ 

347 # Resolve the iteration index — the in-progress iteration has not 

348 # been appended to session["iterations"] yet, so its 0-indexed 

349 # position equals len(iterations) and the 1-indexed position is +1. 

350 iteration_index_one_based = len(session["iterations"]) + 1 

351 

352 # Match the heuristic again so the rationale text matches whichever 

353 # clause actually fired. Both calls are pure and cheap. 

354 _, heuristic_reason = _strategy_unproductive(session, iteration) 

355 if not heuristic_reason: 

356 # decide_verdict only emits ``adjust`` when the heuristic fires, 

357 # but the caller may invoke this template independently (e.g. 

358 # the sampling-fallback path on a non-heuristic adjust) — fall 

359 # back to a generic reason so the template stays usable. 

360 heuristic_reason = "strategy_review_requested" 

361 

362 unmet_ids = [ 

363 result["criterion_id"] 

364 for result in iteration["criteria_evaluation"] 

365 if result["status"] == "unmet" 

366 ] 

367 unmet_summary = ", ".join(unmet_ids) if unmet_ids else "none" 

368 

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

370 if "script" in strategy: 

371 strategy_summary = "scripted strategy" 

372 else: 

373 names = _tool_name_sequence(iteration) 

374 strategy_summary = ", ".join(names) if names else "no tool calls" 

375 

376 no_progress = session["no_progress_counter"] 

377 threshold = session["stagnation_threshold"] 

378 

379 return ( 

380 f"Strategy revised on iteration {iteration_index_one_based}: " 

381 f"{heuristic_reason}. Unmet criteria: {unmet_summary}. " 

382 f"Last strategy: {strategy_summary}. " 

383 f"No-progress counter: {no_progress}/{threshold}. " 

384 f"Adjusting approach for next iteration." 

385 )