Coverage for gco_mcp/mission/_engine_factory.py: 90.53%

137 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +0000

1"""Shared :class:`MissionEngine` factory used by the MCP tool and the CLI. 

2 

3Both the ``gco_mcp/tools/mission.py`` MCP tool surface and the 

4``cli/commands/mission_cmd.py`` Click subcommands need to build a 

5:class:`mcp.mission.engine.MissionEngine` with production-wired 

6dependencies — a real tool dispatcher that routes through the live 

7FastMCP registry, a sampling callable that runs the 

8``Strategy_Revision`` prompt against the resolved backend, and an 

9optional sandbox runner for scripted strategies. The wiring used to 

10live only inside the MCP tool module, which left the CLI with a stub 

11dispatcher and ``sampling_callable=None``. That made 

12``gco mission run`` and ``gco mission iterate`` useful for smoke- 

13testing the engine bookkeeping but unable to converge on goals that 

14depend on actual tool-result content. 

15 

16This module hosts the shared factory. The MCP tool surface and the 

17CLI both call :func:`build_engine_dependencies` to obtain the same 

18``(tool_dispatcher, sampling_callable, sandbox_runner)`` triple, then 

19hand them to :class:`mcp.mission.engine.MissionEngine`. The CLI also 

20uses :func:`make_stub_dispatcher` to opt into the canned-response 

21behaviour explicitly through ``--dry-run`` for the smoke-test use 

22case the original stub was designed for. 

23 

24Why a separate module rather than living inside the MCP tool? The 

25MCP tool's body is gated by ``GCO_ENABLE_MISSION``; the CLI must be 

26able to import the factory regardless of the flag, because the 

27flag-gating happens in the Click group, not at import time. Splitting 

28the factory out keeps the MCP tool body lean and lets the CLI reach 

29the same wiring without crossing a feature-flag boundary. 

30""" 

31 

32from __future__ import annotations 

33 

34import json 

35import sys 

36from collections.abc import Awaitable, Callable, Mapping 

37from datetime import UTC, datetime 

38from pathlib import Path 

39from typing import TYPE_CHECKING, Any, cast 

40 

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

42# Generated at (UTC): 2026-07-18T01:03:40Z 

43# Flowchart(s) generated from this file: 

44# * ``build_engine_dependencies`` -> ``diagrams/code_diagrams/gco_mcp/mission/_engine_factory.build_engine_dependencies.html`` 

45# (PNG: ``diagrams/code_diagrams/gco_mcp/mission/_engine_factory.build_engine_dependencies.png``) 

46# Regenerate with ``python diagrams/code_diagrams/generate.py``. 

47# <pyflowchart-code-diagram> END 

48 

49 

50# The mission package and the FastMCP server module both live under 

51# ``gco_mcp/``; the path-injection pattern matches the rest of the MCP 

52# surface so ``import server`` and ``import mission.*`` resolve 

53# without making the ``mcp`` directory a package. 

54sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) 

55 

56from mission import sampling as mission_sampling # noqa: E402 

57from mission import state as mission_state # noqa: E402 

58from mission.engine import MissionEngine, SandboxRunner, ToolDispatcher # noqa: E402 

59 

60if TYPE_CHECKING: # pragma: no cover - import only for type checkers 

61 from mission.types import SessionState, ToolCallRecord 

62 

63 

64__all__ = [ 

65 "EngineDependencies", 

66 "build_engine_dependencies", 

67 "build_mission_engine", 

68 "fetch_registered_tool_metadata", 

69 "make_stub_dispatcher", 

70 "remaining_wall_clock_seconds", 

71] 

72 

73 

74# --------------------------------------------------------------------------- 

75# Public types 

76# --------------------------------------------------------------------------- 

77 

78 

79class EngineDependencies: 

80 """Dependency triple consumed by :class:`MissionEngine`. 

81 

82 Holds the callables :class:`MissionEngine` needs at construction 

83 time so callers can build them once and pass them through. A simple 

84 namespace class rather than a NamedTuple so fields can be optional 

85 without the boilerplate. 

86 """ 

87 

88 __slots__ = ("final_lessons_callable", "sampling_callable", "sandbox_runner", "tool_dispatcher") 

89 

90 def __init__( 

91 self, 

92 *, 

93 tool_dispatcher: ToolDispatcher, 

94 sampling_callable: Callable[..., Awaitable[Any]] | None, 

95 sandbox_runner: SandboxRunner | None, 

96 final_lessons_callable: Callable[..., Awaitable[Any]] | None = None, 

97 ) -> None: 

98 self.tool_dispatcher = tool_dispatcher 

99 self.sampling_callable = sampling_callable 

100 self.sandbox_runner = sandbox_runner 

101 self.final_lessons_callable = final_lessons_callable 

102 

103 

104# --------------------------------------------------------------------------- 

105# Helpers shared between the MCP tool and the CLI 

106# --------------------------------------------------------------------------- 

107 

108 

109def remaining_wall_clock_seconds(session: Mapping[str, Any]) -> float | None: 

110 """Return remaining wall-clock seconds for ``session``, or ``None``. 

111 

112 Mirrors the behaviour the MCP tool surface used to keep private: 

113 sessions that haven't started yet report the full cap; sessions 

114 whose ``max_wall_clock_seconds`` is the ``-1`` "uncapped" sentinel 

115 return ``None`` so the sampling prompt's budget context renders 

116 ``"remaining_wall_clock_seconds": null``; running sessions return 

117 the difference between cap and elapsed time clamped at zero. 

118 """ 

119 cap = session.get("budget", {}).get("max_wall_clock_seconds") 

120 if cap is None or cap == -1: 

121 return None 

122 started_raw = session.get("started_at") 

123 if not started_raw: 

124 return float(cap) 

125 try: 

126 started = datetime.fromisoformat(started_raw) 

127 except TypeError, ValueError: 

128 return float(cap) 

129 elapsed = (datetime.now(UTC) - started).total_seconds() 

130 return max(0.0, float(cap) - elapsed) 

131 

132 

133async def fetch_registered_tool_metadata() -> tuple[dict[str, Any], dict[str, str]]: 

134 """Return (registered_tools, tool_docstrings) from the live FastMCP registry. 

135 

136 Reads through ``server.mcp._list_tools()``, the same low-level path 

137 the MCP tool surface used to walk inline. Returns two parallel 

138 dicts so the sampler closure can be built without the caller 

139 needing to call ``mcp.*`` introspection twice. 

140 

141 A blanket ``Exception`` swallow yields ``({}, {})`` so the engine 

142 factory still produces a usable dispatcher when the registry is 

143 not yet populated (CLI path before ``register_all_tools`` ran, 

144 test harness with a stub mcp instance, etc.). 

145 """ 

146 try: 

147 from server import mcp # noqa: PLC0415 - lazy 

148 except Exception: 

149 return {}, {} 

150 try: 

151 tools = await mcp._list_tools() 

152 except Exception: 

153 return {}, {} 

154 registered = {t.name: t for t in tools} 

155 docstrings = {name: (getattr(t, "description", "") or "") for name, t in registered.items()} 

156 return registered, docstrings 

157 

158 

159# --------------------------------------------------------------------------- 

160# Tool dispatcher 

161# --------------------------------------------------------------------------- 

162 

163 

164async def _live_dispatch_tool( 

165 tool_name: str, 

166 args: dict[str, Any], 

167 ctx_inner: Any | None, 

168) -> Any: 

169 """Dispatch ``tool_name`` against the live FastMCP registry. 

170 

171 Looks the tool up via ``server.mcp.get_tool`` and invokes it with 

172 ``args``. The raw FastMCP ``ToolResult`` Pydantic model is not 

173 JSON-serialisable, so the helper unwraps it: 

174 

175 * Prefer ``structured_content`` when present — every FastMCP tool 

176 with a typed return surfaces a JSON-able dict here. 

177 * Fall back to the first content block's ``text`` field; 

178 best-effort JSON-parse so structured string-returning tools 

179 round-trip as dicts. 

180 * Anything else returns ``None`` so the engine records a benign 

181 placeholder rather than a non-serialisable object. 

182 

183 ``RuntimeError`` propagates for unknown tool names so the engine's 

184 per-call try/except records a ``failed`` outcome rather than 

185 silently invoking nothing. 

186 """ 

187 # Reuse the active request context when one exists so tools that 

188 # introspect ``get_context()`` see the right one. Fall back to 

189 # ``ctx_inner`` when no request is active (CLI path / unit-test 

190 # path). 

191 context: Any | None 

192 try: 

193 from fastmcp.server.dependencies import get_context # noqa: PLC0415 

194 

195 try: 

196 context = get_context() 

197 except Exception: 

198 context = ctx_inner 

199 except Exception: 

200 context = ctx_inner 

201 del context # FastMCP uses contextvars internally 

202 

203 from server import mcp # noqa: PLC0415 

204 

205 tool_obj = await mcp.get_tool(tool_name) 

206 if tool_obj is None: 

207 raise RuntimeError(f"tool {tool_name!r} not registered") 

208 result = await tool_obj.run(args) 

209 

210 structured = getattr(result, "structured_content", None) 

211 if isinstance(structured, dict): 

212 return structured 

213 content_blocks = getattr(result, "content", None) or [] 

214 if content_blocks: 

215 first = content_blocks[0] 

216 text_payload = getattr(first, "text", None) 

217 if isinstance(text_payload, str): 217 ↛ 222line 217 didn't jump to line 222 because the condition on line 217 was always true

218 try: 

219 return json.loads(text_payload) 

220 except TypeError, ValueError: 

221 return text_payload 

222 return None 

223 

224 

225def make_stub_dispatcher() -> ToolDispatcher: 

226 """Return a tool dispatcher that returns canned-ok responses. 

227 

228 Reserved for ``--dry-run`` smoke testing. Returns 

229 ``{"_status": "ok", "_stub": True, ...}`` for every call so the 

230 engine bookkeeping converges without invoking any real tool — 

231 useful for exercising the loop without spending Bedrock or AWS 

232 credits, and for unit tests that don't want a live registry. 

233 

234 Production code paths use :func:`build_engine_dependencies` so 

235 this stub never fires unless the operator opts in explicitly. 

236 """ 

237 

238 async def _dispatch(tool_name: str, args: dict[str, Any], ctx: Any) -> dict[str, Any]: 

239 return { 

240 "_status": "ok", 

241 "_stub": True, 

242 "tool_name": tool_name, 

243 "args": dict(args), 

244 } 

245 

246 return _dispatch 

247 

248 

249# --------------------------------------------------------------------------- 

250# Sandbox runner 

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

252 

253 

254def _build_sandbox_runner(session: Mapping[str, Any]) -> SandboxRunner | None: 

255 """Wire a real sandbox runner when the session permits scripted strategies.""" 

256 if not session.get("allow_scripted_strategies"): 

257 return None 

258 try: 

259 from mission.sandbox import MissionSandbox # noqa: PLC0415 

260 except ImportError: 

261 return None 

262 

263 sandbox = MissionSandbox( 

264 list(session.get("tool_allowlist") or []), 

265 cast("SessionState", session), 

266 ) 

267 

268 async def _sandbox_runner( 

269 script: str, 

270 ctx_arg: Any, 

271 dispatcher: ToolDispatcher, 

272 ) -> tuple[dict[str, Any], list[ToolCallRecord]]: 

273 obs, calls = await sandbox.run(script, ctx_arg, dispatcher) 

274 return obs, cast("list[ToolCallRecord]", calls) 

275 

276 return _sandbox_runner 

277 

278 

279# --------------------------------------------------------------------------- 

280# Sampling callable 

281# --------------------------------------------------------------------------- 

282 

283 

284def _build_sampling_callable( 

285 session: Mapping[str, Any], 

286 ctx: Any | None, 

287 *, 

288 registered_tools: dict[str, Any], 

289 tool_docstrings: dict[str, str], 

290) -> Callable[..., Awaitable[Any]] | None: 

291 """Wire the Strategy_Revision sampler when the session opted in.""" 

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

293 return None 

294 if session.get("sampling_backend_resolved") == "none": 

295 return None 

296 

297 backend_obj = mission_sampling.select_sampling_backend( 

298 ctx, 

299 model_id=session.get("bedrock_model_id"), 

300 prefs=session.get("sampling_model_preferences"), 

301 ) 

302 if backend_obj is None: 

303 return None 

304 

305 # Slow-moving live signals (per-region queue depth, GPU utilisation, 

306 # deployed-region list, reservation counts). Cached on the closure 

307 # so one ``build_engine_dependencies`` call — which spans the 

308 # multi-iteration drive of one CLI invocation or one MCP request — 

309 # only pays the AWS round-trip once. Outer-list trick keeps the 

310 # cache mutable through the inner closure without ``nonlocal``. 

311 from mission._environment import gather_session_environment # noqa: PLC0415 

312 

313 env_cache: list[Mapping[str, Any] | None] = [] 

314 

315 async def _sampler(*, session: dict[str, Any], ctx: Any | None) -> Any: 

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

317 latest = iterations[-1] if iterations else None 

318 if latest is None: 

319 return None 

320 budget = session.get("budget") or {} 

321 # ``max_iterations=-1`` is the "uncapped" sentinel; the prompt 

322 # expects an informational remaining-iterations count, so we 

323 # report zero in that mode (the model is told nothing about 

324 # the iteration axis when there's no cap to count down from). 

325 # Finite caps subtract the count of recorded iterations and 

326 # clamp at zero. 

327 cap = int(budget.get("max_iterations", 0)) 

328 remaining_iters = 0 if cap == -1 else max(0, cap - len(iterations)) 

329 if not env_cache: 

330 try: 

331 env_cache.append(gather_session_environment(session)) 

332 except Exception: # noqa: BLE001 

333 env_cache.append(None) 

334 env_ctx = env_cache[0] 

335 return await mission_sampling.maybe_sample_strategy_revision( 

336 backend=backend_obj, 

337 session=cast("SessionState", session), 

338 iteration=latest, 

339 allowlist=list(session.get("tool_allowlist") or []), 

340 registered_tools=registered_tools, 

341 tool_docstrings=tool_docstrings, 

342 remaining_iterations=remaining_iters, 

343 remaining_wall_clock_secs=remaining_wall_clock_seconds(session), 

344 allow_scripts=bool(session.get("allow_scripted_strategies", False)), 

345 environment_context=env_ctx, 

346 ) 

347 

348 return _sampler 

349 

350 

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

352# Final lessons callable 

353# --------------------------------------------------------------------------- 

354 

355 

356def _build_final_lessons_callable( 

357 session: Mapping[str, Any], 

358 ctx: Any | None, 

359 tool_docstrings: dict[str, str], 

360) -> Callable[..., Awaitable[Any]] | None: 

361 """Wire the Final_Report lessons overlay when sampling is enabled. 

362 

363 When the session opted into sampling and a backend resolves, the 

364 engine calls this after a terminal verdict to produce model-derived 

365 ``lessons`` and ``recommended_followups`` for the Final_Report. 

366 Without it, the report uses deterministic templates. 

367 """ 

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

369 return None 

370 if session.get("sampling_backend_resolved") == "none": 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true

371 return None 

372 

373 backend_obj = mission_sampling.select_sampling_backend( 

374 ctx, 

375 model_id=session.get("bedrock_model_id"), 

376 prefs=session.get("sampling_model_preferences"), 

377 ) 

378 if backend_obj is None: 378 ↛ 379line 378 didn't jump to line 379 because the condition on line 378 was never true

379 return None 

380 

381 async def _final_lessons(*, session: dict[str, Any], ctx: Any | None) -> Any: 

382 return await mission_sampling.maybe_sample_final_lessons( 

383 backend=backend_obj, 

384 session=cast("SessionState", session), 

385 tool_docstrings=tool_docstrings, 

386 ) 

387 

388 return _final_lessons 

389 

390 

391# --------------------------------------------------------------------------- 

392# Public factory 

393# --------------------------------------------------------------------------- 

394 

395 

396async def build_engine_dependencies( 

397 session: Mapping[str, Any], 

398 ctx: Any | None, 

399 *, 

400 use_stub_dispatcher: bool = False, 

401) -> EngineDependencies: 

402 """Build the :class:`MissionEngine` dependency triple for ``session``. 

403 

404 Looks up the live FastMCP registry to populate the registered-tools 

405 map and per-tool docstring cache, then assembles the production 

406 wiring: a live tool dispatcher (or the canned-stub when 

407 ``use_stub_dispatcher`` is True), the Strategy_Revision sampler 

408 when sampling resolved to a real backend, and a sandbox runner 

409 when the session permits scripted strategies. 

410 

411 The CLI passes ``use_stub_dispatcher=True`` only on the 

412 ``--dry-run`` path; the MCP tool surface always uses the live 

413 dispatcher. 

414 """ 

415 if use_stub_dispatcher: 

416 # The stub dispatcher means real tools never run, which means 

417 # there's nothing to inform the sampling prompt; downgrade to 

418 # the deterministic propose path for symmetry. The session's 

419 # ``use_sampling`` flag stays as the operator set it so the 

420 # criteria-scaffold path still runs through Bedrock. 

421 return EngineDependencies( 

422 tool_dispatcher=make_stub_dispatcher(), 

423 sampling_callable=None, 

424 sandbox_runner=_build_sandbox_runner(session), 

425 ) 

426 

427 registered_tools, tool_docstrings = await fetch_registered_tool_metadata() 

428 sampling_callable = _build_sampling_callable( 

429 session, 

430 ctx, 

431 registered_tools=registered_tools, 

432 tool_docstrings=tool_docstrings, 

433 ) 

434 sandbox_runner = _build_sandbox_runner(session) 

435 final_lessons = _build_final_lessons_callable(session, ctx, tool_docstrings) 

436 return EngineDependencies( 

437 tool_dispatcher=_live_dispatch_tool, 

438 sampling_callable=sampling_callable, 

439 sandbox_runner=sandbox_runner, 

440 final_lessons_callable=final_lessons, 

441 ) 

442 

443 

444async def build_mission_engine( 

445 session: Mapping[str, Any], 

446 ctx: Any | None, 

447 *, 

448 use_stub_dispatcher: bool = False, 

449) -> MissionEngine: 

450 """Build a :class:`MissionEngine` instance ready to drive ``session``. 

451 

452 Convenience wrapper over :func:`build_engine_dependencies` that 

453 also resolves the persistence backend through 

454 :func:`mcp.mission.state.get_backend`. Most callers should use 

455 this; the lower-level :func:`build_engine_dependencies` is 

456 available for code that needs to instantiate the engine itself 

457 (e.g. with a custom backend). 

458 """ 

459 deps = await build_engine_dependencies(session, ctx, use_stub_dispatcher=use_stub_dispatcher) 

460 backend = mission_state.get_backend() 

461 return MissionEngine( 

462 backend=backend, 

463 tool_dispatcher=deps.tool_dispatcher, 

464 sampling_callable=deps.sampling_callable, 

465 sandbox_runner=deps.sandbox_runner, 

466 final_lessons_callable=deps.final_lessons_callable, 

467 )