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

188 statements  

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

1"""Mission goal-directed iteration loop tools. 

2 

3The whole module body is gated by :data:`feature_flags.FLAG_MISSION` so 

4the ten ``mission_*`` tool decorators only fire when 

5``GCO_ENABLE_MISSION=true``. With the flag unset, this module imports 

6cleanly and FastMCP never sees the tools. 

7 

8[gated by GCO_ENABLE_MISSION] 

9""" 

10 

11from __future__ import annotations 

12 

13import contextlib 

14import json 

15import secrets 

16import sys 

17from collections.abc import Mapping, Sequence 

18from datetime import UTC, datetime 

19from pathlib import Path 

20from typing import Any, cast 

21 

22from audit import audit_logged 

23from feature_flags import FLAG_MISSION, is_enabled 

24from server import mcp 

25 

26# Mission package lives under ``gco_mcp/mission/``; the path-injection 

27# pattern matches the rest of the MCP module surface so ``import 

28# mission.*`` resolves without making the ``mcp`` directory a package. 

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

30 

31 

32def _try_get_context() -> Any | None: 

33 """Return the active FastMCP Context if inside a request, else ``None``. 

34 

35 Mirrors :func:`mcp.tools.jobs._ctx_warning`: wraps the optional 

36 ``fastmcp.server.dependencies.get_context`` import so the helper 

37 works in unit tests that don't go through an MCP request — those 

38 raise ``RuntimeError`` from ``get_context()``, which we swallow. 

39 """ 

40 try: 

41 from fastmcp.server.dependencies import get_context 

42 

43 return get_context() 

44 except Exception: 

45 return None 

46 

47 

48# Module body is entirely gated by the feature flag. When the flag is 

49# unset, none of the tool decorators below fire and FastMCP never sees 

50# the registrations. 

51if is_enabled(FLAG_MISSION): 

52 from mission import ( 

53 sampling as mission_sampling, 

54 ) 

55 from mission import ( 

56 state as mission_state, 

57 ) 

58 from mission import ( 

59 validation as mission_validation, 

60 ) 

61 from mission.decide import decide_verdict 

62 from mission.engine import MissionEngineError 

63 from mission.types import SCHEMA_VERSION, TERMINAL_STATES, SessionState 

64 from mission.validation import MissionValidationError 

65 

66 # ------------------------------------------------------------------ # 

67 # Registry introspection helpers 

68 # ------------------------------------------------------------------ # 

69 

70 async def _registered_tools_dict() -> dict[str, Any]: 

71 """Return a name -> Tool object mapping for every registered tool. 

72 

73 Uses ``mcp._list_tools()`` because that bypasses the catalog- 

74 replacement transforms (BM25 / Code Mode) and gives us the 

75 underlying registry. Tolerates FastMCP API drift by falling 

76 back to an empty mapping on any exception — the validators 

77 downstream interpret an empty dict as "nothing registered", 

78 which is benign for sessions that don't lean on tag-based 

79 cost gating. 

80 """ 

81 try: 

82 tools = await mcp._list_tools() 

83 except Exception: 

84 return {} 

85 return {t.name: t for t in tools} 

86 

87 async def _registered_tool_tags() -> dict[str, set[str]]: 

88 """Return name -> tag-set for every registered tool. 

89 

90 Same defensive shape as :func:`_registered_tools_dict`. Tools 

91 with no declared ``tags`` attribute contribute an empty set so 

92 the budget validator treats them as non-cost-incurring. 

93 """ 

94 registered = await _registered_tools_dict() 

95 out: dict[str, set[str]] = {} 

96 for name, tool in registered.items(): 

97 tags = getattr(tool, "tags", None) 

98 out[name] = set(tags) if tags else set() 

99 return out 

100 

101 async def _tool_docstrings_dict() -> dict[str, str]: 

102 """Return name -> docstring/description mapping for sampling prompts.""" 

103 registered = await _registered_tools_dict() 

104 return {name: (getattr(t, "description", "") or "") for name, t in registered.items()} 

105 

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

107 # Session helpers 

108 # ------------------------------------------------------------------ # 

109 

110 def _strip_private_fields(session: Mapping[str, Any]) -> dict[str, Any]: 

111 """Return a JSON-safe copy of ``session`` with private criterion keys dropped. 

112 

113 Thin alias over :func:`mission.validation.strip_private_fields`. 

114 Kept as a module-private name so call sites in this file 

115 (``mission_start``, ``mission_complete``, etc.) read at a 

116 glance without having to qualify the canonical helper through 

117 the long ``mission.validation`` path. 

118 """ 

119 return mission_validation.strip_private_fields(session) 

120 

121 def _strip_private_fields_iterations( 

122 iterations: Sequence[Mapping[str, Any]], 

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

124 """Strip private keys from each iteration's ``criteria_evaluation`` shape. 

125 

126 Thin alias over 

127 :func:`mission.validation.strip_private_fields_iterations`. 

128 """ 

129 return mission_validation.strip_private_fields_iterations(iterations) 

130 

131 # ------------------------------------------------------------------ # 

132 # Engine wiring 

133 # ------------------------------------------------------------------ # 

134 

135 # The engine factory itself lives in :mod:`mcp.mission._engine_factory` 

136 # so the CLI can reuse the same wiring without crossing the 

137 # ``GCO_ENABLE_MISSION`` gate. We keep a thin alias here so call 

138 # sites in this module stay readable without having to spell out 

139 # the long import path. 

140 from mission._engine_factory import build_mission_engine as _build_engine # noqa: PLC0415 

141 

142 # ------------------------------------------------------------------ # 

143 # mission_start 

144 # ------------------------------------------------------------------ # 

145 

146 @mcp.tool(tags={"low-risk", "mission"}) 

147 @audit_logged 

148 async def mission_start( 

149 directive: str, 

150 criteria: list[dict[str, Any]], 

151 budget: dict[str, Any], 

152 tool_allowlist: list[str] | None = None, 

153 checkpoint_cadence: dict[str, Any] | None = None, 

154 stagnation_threshold: int = 3, 

155 use_sampling: bool | None = None, 

156 allow_scripted_strategies: bool = False, 

157 allow_all_tools: bool = False, 

158 ) -> str: 

159 """[gated by GCO_ENABLE_MISSION] Start a new Mission session. 

160 

161 Args: 

162 directive: Natural-language goal description. 

163 criteria: List of success criterion dicts (``metric_threshold``, 

164 ``event``, or ``predicate`` kinds). 

165 budget: Budget controls dict with ``max_iterations`` and 

166 ``max_wall_clock_seconds``. Cost guardrails live 

167 out-of-band via AWS Budgets and Cost Anomaly Detection. 

168 tool_allowlist: List of tool names the session may invoke. 

169 Optional; omit it when ``allow_all_tools`` is set. 

170 checkpoint_cadence: Optional cadence dict (default 

171 ``{"kind": "every_iteration"}``). 

172 stagnation_threshold: Iterations of no progress before 

173 terminating (default 3). 

174 use_sampling: Three-state opt-in. ``None`` auto-detects, 

175 ``True`` opts in explicitly, ``False`` opts out. 

176 Sampling always runs server-side through Bedrock — MCP 

177 client sampling left the protocol with FastMCP 4. 

178 allow_scripted_strategies: When True, the session permits 

179 scripted strategies (validated via the sandbox AST). 

180 allow_all_tools: When True (default ``False``), resolve the 

181 session's allowlist to every currently-registered tool 

182 minus the ``mission_*`` control tools, instead of an 

183 explicit ``tool_allowlist``. Mutually exclusive with a 

184 non-empty ``tool_allowlist``. 

185 

186 Returns a JSON string with the new ``session_id`` and the 

187 resolved sampling state, or an error envelope. 

188 """ 

189 try: 

190 directive_clean = mission_validation.validate_directive(directive) 

191 criteria_clean = mission_validation.validate_criteria(criteria) 

192 registered_tools = await _registered_tools_dict() 

193 registered_tags = await _registered_tool_tags() 

194 control_tools = {n for n, tags in registered_tags.items() if "mission" in tags} 

195 allowlist_clean = mission_validation.resolve_effective_allowlist( 

196 allow_all_tools=allow_all_tools, 

197 explicit_allowlist=tool_allowlist, 

198 registered_tools=registered_tools, 

199 control_tools=control_tools, 

200 ) 

201 budget_clean = mission_validation.validate_budget( 

202 budget, allowlist_clean, registered_tags 

203 ) 

204 cadence_clean = mission_validation.validate_cadence( 

205 checkpoint_cadence 

206 if checkpoint_cadence is not None 

207 else {"kind": "every_iteration"} 

208 ) 

209 except MissionValidationError as err: 

210 return json.dumps({"code": err.code, "details": err.details}) 

211 

212 use_sampling_resolved, backend_resolved = mission_sampling.resolve_sampling_state( 

213 use_sampling 

214 ) 

215 

216 session_id = f"mission-{secrets.token_hex(8)}" 

217 session: dict[str, Any] = { 

218 "version": SCHEMA_VERSION, 

219 "session_id": session_id, 

220 "directive_text": directive_clean, 

221 "criteria": criteria_clean, 

222 "budget": budget_clean, 

223 "tool_allowlist": allowlist_clean, 

224 "checkpoint_cadence": cadence_clean, 

225 "stagnation_threshold": stagnation_threshold, 

226 "use_sampling": use_sampling_resolved, 

227 "sampling_backend_resolved": backend_resolved, 

228 "allow_scripted_strategies": bool(allow_scripted_strategies), 

229 "status": "pending", 

230 "created_at": datetime.now(UTC).isoformat(), 

231 "iterations": [], 

232 "no_progress_counter": 0, 

233 } 

234 

235 backend = mission_state.get_backend() 

236 # Strip the validator's cached ``_parsed_ast`` AST nodes from 

237 # predicate criteria before persistence — ``ast.Expression`` 

238 # is not JSON-serialisable and the FilesystemBackend writes 

239 # via ``json.dump``. The engine re-parses on demand from the 

240 # ``expression`` string when it next loads the session, so 

241 # stripping here is lossless. Mirrors ``_strip_private_criteria`` 

242 # in ``cli/commands/mission_cmd.py``. 

243 backend.save_session(cast("SessionState", _strip_private_fields(session))) 

244 

245 return json.dumps( 

246 { 

247 "session_id": session_id, 

248 "status": "pending", 

249 "use_sampling": use_sampling_resolved, 

250 "sampling_backend_resolved": backend_resolved, 

251 } 

252 ) 

253 

254 # ------------------------------------------------------------------ # 

255 # mission_status 

256 # ------------------------------------------------------------------ # 

257 

258 @mcp.tool(tags={"safe", "mission"}) 

259 @audit_logged 

260 async def mission_status(session_id: str) -> str: 

261 """[gated by GCO_ENABLE_MISSION] Get the full state of a Mission session. 

262 

263 Args: 

264 session_id: The session identifier returned by 

265 :func:`mission_start`. 

266 

267 Returns the full session JSON or an error envelope when the 

268 session is unknown. 

269 """ 

270 backend = mission_state.get_backend() 

271 session = backend.load_session(session_id) 

272 if session is None: 

273 return json.dumps( 

274 { 

275 "code": "session_not_found", 

276 "details": {"session_id": session_id}, 

277 } 

278 ) 

279 cleaned = _strip_private_fields(session) 

280 return json.dumps(cleaned, default=str) 

281 

282 # ------------------------------------------------------------------ # 

283 # mission_iterate 

284 # ------------------------------------------------------------------ # 

285 

286 @mcp.tool(tags={"low-risk", "mission"}) 

287 @audit_logged 

288 async def mission_iterate( 

289 session_id: str, 

290 max_iterations_this_call: int = 1, 

291 ) -> str: 

292 """[gated by GCO_ENABLE_MISSION] Run iteration(s) on a Mission session. 

293 

294 Args: 

295 session_id: The session to iterate. 

296 max_iterations_this_call: How many iterations to run before 

297 returning (default 1). The loop exits early on a 

298 terminal verdict (``complete`` or ``terminate``). 

299 

300 Returns a JSON object with a ``session_id`` and an 

301 ``iterations`` list of iteration summaries (verdict, reason, 

302 iteration index). On engine errors, returns an error envelope 

303 with whatever summaries had accumulated before the failure. 

304 """ 

305 if max_iterations_this_call < 1: 

306 return json.dumps( 

307 { 

308 "code": "invalid_argument", 

309 "details": {"reason": "max_iterations_this_call must be >= 1"}, 

310 } 

311 ) 

312 

313 ctx = _try_get_context() 

314 backend = mission_state.get_backend() 

315 

316 session = backend.load_session(session_id) 

317 if session is None: 

318 return json.dumps( 

319 { 

320 "code": "session_not_found", 

321 "details": {"session_id": session_id}, 

322 } 

323 ) 

324 

325 engine = await _build_engine(session, ctx) 

326 

327 summaries: list[dict[str, Any]] = [] 

328 for _ in range(max_iterations_this_call): 

329 try: 

330 record = await engine.run_iteration(session_id, ctx=ctx) 

331 except MissionEngineError as err: 

332 return json.dumps( 

333 { 

334 "code": err.code, 

335 "details": {"session_id": session_id}, 

336 "iterations": summaries, 

337 } 

338 ) 

339 summaries.append( 

340 { 

341 "iteration_index": record["iteration_index"], 

342 "verdict": record["verdict"], 

343 "verdict_reason": record["verdict_reason"], 

344 } 

345 ) 

346 if record["verdict"] in ("complete", "terminate"): 

347 break 

348 

349 return json.dumps({"session_id": session_id, "iterations": summaries}) 

350 

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

352 # mission_checkpoint 

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

354 

355 @mcp.tool(tags={"safe", "mission"}) 

356 @audit_logged 

357 async def mission_checkpoint(session_id: str) -> str: 

358 """[gated by GCO_ENABLE_MISSION] Re-run the verdict cascade on the latest iteration. 

359 

360 Args: 

361 session_id: The session whose latest iteration should be 

362 re-evaluated. 

363 

364 Returns a JSON object with the freshly-computed verdict and 

365 reason. Does not run the propose / execute / observe phases — 

366 only the deterministic decide cascade. Returns an error envelope 

367 when the session is missing or has no iterations yet. 

368 """ 

369 backend = mission_state.get_backend() 

370 session = backend.load_session(session_id) 

371 if session is None: 

372 return json.dumps( 

373 { 

374 "code": "session_not_found", 

375 "details": {"session_id": session_id}, 

376 } 

377 ) 

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

379 if not iterations: 

380 return json.dumps( 

381 { 

382 "code": "no_iterations", 

383 "details": {"session_id": session_id}, 

384 } 

385 ) 

386 

387 latest = iterations[-1] 

388 verdict, reason = decide_verdict(session, latest, datetime.now(UTC)) 

389 return json.dumps( 

390 { 

391 "session_id": session_id, 

392 "iteration_index": latest["iteration_index"], 

393 "verdict": verdict, 

394 "verdict_reason": reason, 

395 } 

396 ) 

397 

398 # ------------------------------------------------------------------ # 

399 # mission_complete 

400 # ------------------------------------------------------------------ # 

401 

402 @mcp.tool(tags={"low-risk", "mission"}) 

403 @audit_logged 

404 async def mission_complete(session_id: str, reason: str = "forced_complete") -> str: 

405 """[gated by GCO_ENABLE_MISSION] Force a Mission session into completed status. 

406 

407 Args: 

408 session_id: The session to complete. 

409 reason: Free-form reason recorded alongside the synthetic 

410 final verdict (default ``forced_complete``). 

411 

412 Stamps a synthetic ``complete`` final verdict and an 

413 ``ended_at`` timestamp. Refuses sessions already in a terminal 

414 state. 

415 """ 

416 del reason # currently informational; the synthetic verdict is fixed 

417 backend = mission_state.get_backend() 

418 session = backend.load_session(session_id) 

419 if session is None: 

420 return json.dumps( 

421 { 

422 "code": "session_not_found", 

423 "details": {"session_id": session_id}, 

424 } 

425 ) 

426 if session["status"] in TERMINAL_STATES: 

427 return json.dumps( 

428 { 

429 "code": "session_terminal", 

430 "details": { 

431 "session_id": session_id, 

432 "status": session["status"], 

433 }, 

434 } 

435 ) 

436 session["status"] = "completed" 

437 session["final_verdict"] = "complete" 

438 session["ended_at"] = datetime.now(UTC).isoformat() 

439 # Defensive strip — sessions loaded from the backend were 

440 # already private-field-clean, but a future change that 

441 # re-attaches ``_parsed_ast`` somewhere in the flow shouldn't 

442 # silently break persistence. ``_strip_private_fields`` is 

443 # cheap and idempotent on already-clean inputs. 

444 backend.save_session(cast("SessionState", _strip_private_fields(session))) 

445 return json.dumps({"session_id": session_id, "status": "completed"}) 

446 

447 # ------------------------------------------------------------------ # 

448 # mission_abort 

449 # ------------------------------------------------------------------ # 

450 

451 @mcp.tool(tags={"low-risk", "mission"}) 

452 @audit_logged 

453 async def mission_abort(session_id: str, pause: bool = False) -> str: 

454 """[gated by GCO_ENABLE_MISSION] Pause or terminate a Mission session. 

455 

456 Args: 

457 session_id: The session to transition. 

458 pause: When True, transition to ``paused``. When False 

459 (default), transition to ``terminated`` with a 

460 synthetic ``terminate`` final verdict. 

461 

462 Refuses sessions already in a terminal state. Best-effort 

463 emits a ``ctx.warning`` when the active request context is 

464 available so the operator sees the side-effect. 

465 """ 

466 backend = mission_state.get_backend() 

467 session = backend.load_session(session_id) 

468 if session is None: 

469 return json.dumps( 

470 { 

471 "code": "session_not_found", 

472 "details": {"session_id": session_id}, 

473 } 

474 ) 

475 if session["status"] in TERMINAL_STATES: 

476 return json.dumps( 

477 { 

478 "code": "session_terminal", 

479 "details": { 

480 "session_id": session_id, 

481 "status": session["status"], 

482 }, 

483 } 

484 ) 

485 if pause: 

486 session["status"] = "paused" 

487 else: 

488 session["status"] = "terminated" 

489 session["final_verdict"] = "terminate" 

490 session["ended_at"] = datetime.now(UTC).isoformat() 

491 ctx = _try_get_context() 

492 if ctx is not None: 

493 with contextlib.suppress(Exception): 

494 await ctx.warning(f"Mission session {session_id} terminated by operator.") 

495 # Defensive strip — see mission_complete for the rationale. 

496 backend.save_session(cast("SessionState", _strip_private_fields(session))) 

497 return json.dumps({"session_id": session_id, "status": session["status"]}) 

498 

499 # ------------------------------------------------------------------ # 

500 # mission_resume 

501 # ------------------------------------------------------------------ # 

502 

503 @mcp.tool(tags={"low-risk", "mission"}) 

504 @audit_logged 

505 async def mission_resume(session_id: str) -> str: 

506 """[gated by GCO_ENABLE_MISSION] Resume a paused Mission session. 

507 

508 Args: 

509 session_id: The session to resume. 

510 

511 Transitions ``paused -> running``. Returns an error envelope 

512 when the session is missing or not in ``paused`` state. 

513 """ 

514 backend = mission_state.get_backend() 

515 session = backend.load_session(session_id) 

516 if session is None: 

517 return json.dumps( 

518 { 

519 "code": "session_not_found", 

520 "details": {"session_id": session_id}, 

521 } 

522 ) 

523 if session["status"] != "paused": 

524 return json.dumps( 

525 { 

526 "code": "not_paused", 

527 "details": { 

528 "session_id": session_id, 

529 "status": session["status"], 

530 }, 

531 } 

532 ) 

533 session["status"] = "running" 

534 # Defensive strip — see mission_complete for the rationale. 

535 backend.save_session(cast("SessionState", _strip_private_fields(session))) 

536 return json.dumps({"session_id": session_id, "status": "running"}) 

537 

538 # ------------------------------------------------------------------ # 

539 # mission_history 

540 # ------------------------------------------------------------------ # 

541 

542 @mcp.tool(tags={"safe", "mission"}) 

543 @audit_logged 

544 async def mission_history(session_id: str, format: str = "summary") -> str: 

545 """[gated by GCO_ENABLE_MISSION] Get iteration history for a Mission session. 

546 

547 Args: 

548 session_id: The session whose history to retrieve. 

549 format: ``"summary"`` (default) returns a compact list of 

550 ``{iteration_index, verdict, verdict_reason, 

551 started_at, ended_at}`` dicts; ``"full"`` returns the 

552 complete iteration record dicts. 

553 

554 Returns a JSON object with an ``iterations`` list, or an error 

555 envelope when the session is unknown. 

556 """ 

557 backend = mission_state.get_backend() 

558 session = backend.load_session(session_id) 

559 if session is None: 

560 return json.dumps( 

561 { 

562 "code": "session_not_found", 

563 "details": {"session_id": session_id}, 

564 } 

565 ) 

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

567 if format == "full": 

568 return json.dumps( 

569 {"iterations": _strip_private_fields_iterations(iterations)}, 

570 default=str, 

571 ) 

572 summaries = [ 

573 { 

574 "iteration_index": it.get("iteration_index"), 

575 "verdict": it.get("verdict"), 

576 "verdict_reason": it.get("verdict_reason"), 

577 "started_at": it.get("started_at"), 

578 "ended_at": it.get("ended_at"), 

579 } 

580 for it in iterations 

581 ] 

582 return json.dumps({"iterations": summaries}) 

583 

584 # ------------------------------------------------------------------ # 

585 # mission_list 

586 # ------------------------------------------------------------------ # 

587 

588 @mcp.tool(tags={"safe", "mission"}) 

589 @audit_logged 

590 async def mission_list(status: str | None = None) -> str: 

591 """[gated by GCO_ENABLE_MISSION] List Mission sessions. 

592 

593 Args: 

594 status: Optional filter. Recognised values are ``running``, 

595 ``completed``, ``terminated``, ``failed``, ``paused``, 

596 ``pending``. Omit to list every known session. 

597 

598 Returns a JSON object with a ``sessions`` list of summary dicts 

599 (``session_id``, ``status``, ``created_at``, 

600 ``iteration_count``). 

601 """ 

602 backend = mission_state.get_backend() 

603 filter_dict = {"status": status} if status else None 

604 sessions = backend.list_sessions(filter_dict) 

605 return json.dumps({"sessions": sessions}) 

606 

607 # ------------------------------------------------------------------ # 

608 # mission_memory_search 

609 # ------------------------------------------------------------------ # 

610 

611 @mcp.tool(tags={"safe", "mission"}) 

612 @audit_logged 

613 async def mission_memory_search( 

614 directive: str, 

615 top_k: int = 3, 

616 final_verdict: str | None = None, 

617 ) -> str: 

618 """[gated by GCO_ENABLE_MISSION] Search mission memory for similar past missions. 

619 

620 Embeds ``directive`` and queries the ``{project}-mission-memory`` 

621 DynamoDB vector index for the closest completed missions — the 

622 same institutional memory the engine consults on sampling 

623 sessions. Requires the mission-memory add-on to be deployed 

624 (``mission_memory.enabled`` in cdk.json, on by default). 

625 

626 Args: 

627 directive: Natural-language mission goal to search by. 

628 top_k: Number of similar missions to return (default 3, 

629 mirroring the ``mission_memory.top_k`` config default). 

630 final_verdict: Optional inline filter on the stored 

631 terminal verdict — ``"complete"`` or ``"terminate"``. 

632 

633 Returns a JSON object with a ``results`` list (each entry 

634 carries ``session_id``, ``directive``, ``lessons``, 

635 ``recommended_followups``, ``final_verdict``, ``verdict_reason``, 

636 ``iteration_count``, ``completed_at``, and a similarity 

637 ``score``), or an error envelope: ``mission_memory_unavailable`` 

638 when the table/index is absent or still backfilling, 

639 ``mission_memory_search_failed`` for anything else. 

640 """ 

641 from mission.memory import ( # noqa: PLC0415 

642 MissionMemoryStore, 

643 MissionMemoryUnavailableError, 

644 ) 

645 

646 try: 

647 results = MissionMemoryStore().search_similar( 

648 directive, top_k=top_k, final_verdict=final_verdict 

649 ) 

650 except MissionMemoryUnavailableError as err: 

651 return json.dumps( 

652 { 

653 "code": "mission_memory_unavailable", 

654 "details": {"message": str(err)}, 

655 } 

656 ) 

657 except Exception as err: # noqa: BLE001 — tool surface must envelope, not raise 

658 return json.dumps( 

659 { 

660 "code": "mission_memory_search_failed", 

661 "details": {"message": str(err)}, 

662 } 

663 ) 

664 return json.dumps({"results": results})