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

127 statements  

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

1"""Swarm_Plan generation: sampled decomposition, deterministic fallback. 

2 

3A Swarm_Plan is a list of **spawn requests** — plain JSON-safe dicts in 

4exactly the shape :func:`mission.swarm.validate_spawn` accepts — so a 

5plan can be printed for review, saved to disk, and fed straight to the 

6runner's spawn seam. Every plan this module returns has already been 

7admission-validated end to end (fleet cap, iteration pool, finite child 

8budgets, allowlist exclusions, and mutating-tool overlap are enforced 

9*across* the plan by simulating the registry the spawns would build), so 

10a returned plan cannot be rejected at spawn time against the same config 

11and registered-tool set. 

12 

13Two producers, mirroring the criteria scaffolder one level up: 

14 

15* :func:`generate_sampled_plan` — asks a sampling backend for a JSON 

16 array of child specs, validates every entry, and feeds rejection 

17 reasons back into a bounded retry loop 

18 (:func:`mission.criteria_scaffold.generate_sampled_criteria` is the 

19 precedent). Exhaustion raises :class:`SwarmScaffoldError`; callers 

20 fall back to the deterministic path, warning once, exactly like 

21 ``gco mission run`` does. 

22* :func:`generate_deterministic_plan` — always available, no sampling, 

23 no AWS: a single child mirroring the swarm directive with criteria 

24 from the deterministic criteria scaffold. Degenerate on purpose — a 

25 swarm of one is safe and correct when nothing smarter is available, 

26 and it is the CI path. 

27 

28The advisory-only boundary holds: sampling proposes plans, the pure 

29validators admit them, and nothing in this module touches a verdict. 

30:func:`sample_revised_directive` supplies the optional 

31``on_failure_with_revision`` directive text for respawns — again advisory 

32text only; the respawn decision lives in the deterministic restart table. 

33""" 

34 

35from __future__ import annotations 

36 

37import json 

38from collections.abc import Collection, Mapping 

39from typing import Any, Final 

40 

41from gco.bedrock import BedrockFTUFormNotAcceptedError 

42 

43from . import criteria_scaffold 

44from . import swarm as swarm_rules 

45from .types import SessionState, SwarmConfig 

46from .validation import MissionValidationError, validate_directive 

47 

48__all__ = [ 

49 "DEFAULT_CHILD_MAX_ITERATIONS", 

50 "DEFAULT_CHILD_MAX_WALL_CLOCK_SECONDS", 

51 "DEFAULT_PLAN_RETRIES", 

52 "SwarmScaffoldError", 

53 "build_plan_prompt", 

54 "generate_deterministic_plan", 

55 "generate_sampled_plan", 

56 "sample_revised_directive", 

57 "validate_plan", 

58] 

59 

60DEFAULT_CHILD_MAX_ITERATIONS: Final[int] = 5 

61"""Default per-child iteration budget when the caller supplies none.""" 

62 

63DEFAULT_CHILD_MAX_WALL_CLOCK_SECONDS: Final[int] = 300 

64"""Default per-child wall-clock budget when the caller supplies none.""" 

65 

66DEFAULT_PLAN_RETRIES: Final[int] = 3 

67"""Sampled-path retry budget before deterministic fallback.""" 

68 

69_PROMPT_TOOL_LIMIT: Final[int] = 40 

70"""Cap on catalog entries rendered into the plan prompt.""" 

71 

72_PROMPT_DOCSTRING_CHARS: Final[int] = 200 

73"""Per-tool docstring budget in the plan prompt.""" 

74 

75 

76class SwarmScaffoldError(Exception): 

77 """Every sampled plan attempt was rejected. 

78 

79 ``last_reason`` carries the final rejection token (a validator 

80 ``details.reason``, ``json_parse``, or ``transport_error``) so the 

81 caller's fallback warning names the cause. 

82 """ 

83 

84 def __init__(self, last_reason: str, message: str | None = None) -> None: 

85 self.last_reason = last_reason 

86 super().__init__(message if message is not None else last_reason) 

87 

88 

89def _spec_to_request(spec: swarm_rules.SpawnSpec) -> dict[str, Any]: 

90 """Serialize a validated SpawnSpec back into a JSON-safe spawn request. 

91 

92 Criteria drop the validator's cached ``_parsed_ast`` so the request 

93 round-trips through ``json.dumps``; re-validation re-parses on 

94 demand. The request re-admits by construction against the same 

95 config and registered-tool inputs it was validated with. 

96 """ 

97 criteria = [ 

98 {k: v for k, v in criterion.items() if not str(k).startswith("_")} 

99 for criterion in spec["criteria"] 

100 ] 

101 return { 

102 "slot": spec["slot"], 

103 "directive": spec["directive"], 

104 "criteria": criteria, 

105 "budget": dict(spec["budget"]), 

106 "tool_allowlist": list(spec["tool_allowlist"]), 

107 "cadence": dict(spec["checkpoint_cadence"]), 

108 "restart_policy": spec["restart_policy"], 

109 "max_respawns": spec["max_respawns"], 

110 "use_sampling": spec["use_sampling"], 

111 } 

112 

113 

114def validate_plan( 

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

116 *, 

117 config: SwarmConfig, 

118 registered_tools: dict[str, Any], 

119 registered_tags: Mapping[str, Collection[str]], 

120 flag_lookup: dict[str, str] | None = None, 

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

122 """Admission-validate a whole plan, simulating the registry it builds. 

123 

124 Entries are validated in order against a simulated child registry so 

125 the fleet cap, the iteration pool, and the mutating-tool overlap rule 

126 apply across the plan exactly as they will at spawn time. Raises 

127 :class:`~mission.validation.MissionValidationError` on the first 

128 failing entry (its ``details`` gain a ``plan_index``); returns the 

129 normalized, JSON-safe request list on success. 

130 """ 

131 if not isinstance(entries, list) or not entries: 

132 raise MissionValidationError( 

133 "validation_error", 

134 details={"field": "plan", "reason": "empty_or_not_a_list"}, 

135 ) 

136 simulated: list[Any] = [] 

137 sibling_allowlists: dict[str, list[str]] = {} 

138 requests: list[dict[str, Any]] = [] 

139 for index, entry in enumerate(entries): 

140 if not isinstance(entry, dict): 

141 raise MissionValidationError( 

142 "validation_error", 

143 details={"field": "plan", "reason": "entry_not_a_dict", "plan_index": index}, 

144 ) 

145 try: 

146 spec = swarm_rules.validate_spawn( 

147 parent_role="orchestrator", 

148 config=config, 

149 children=simulated, 

150 request=entry, 

151 registered_tools=registered_tools, 

152 registered_tags=registered_tags, 

153 sibling_allowlists=sibling_allowlists, 

154 flag_lookup=flag_lookup, 

155 ) 

156 except MissionValidationError as err: 

157 details = dict(err.details or {}) 

158 details["plan_index"] = index 

159 raise MissionValidationError(err.code, details=details) from err 

160 simulated.append( 

161 swarm_rules.new_registry_entry(spec, f"plan-{index}", "1970-01-01T00:00:00+00:00") 

162 ) 

163 sibling_allowlists[spec["slot"]] = list(spec["tool_allowlist"]) 

164 requests.append(_spec_to_request(spec)) 

165 return requests 

166 

167 

168def generate_deterministic_plan( 

169 directive: str, 

170 *, 

171 config: SwarmConfig, 

172 registered_tools: dict[str, Any], 

173 registered_tags: Mapping[str, Collection[str]], 

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

175 allow_all_tools: bool = False, 

176 flag_lookup: dict[str, str] | None = None, 

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

178 """The always-available fallback: one child mirroring the directive. 

179 

180 Criteria come from the deterministic criteria scaffold (so a 

181 search-flavoured directive with an allowlist gets concrete 

182 ``tool_call_succeeded`` entries), the budget is the module default 

183 bounded by the pool, and the restart policy is ``never``. The single 

184 entry runs through :func:`validate_plan` before returning, so the 

185 fallback can never emit a plan the spawn seam would reject. 

186 """ 

187 iterations = min(DEFAULT_CHILD_MAX_ITERATIONS, config["child_iteration_pool"]) 

188 entry: dict[str, Any] = { 

189 "slot": "worker-1", 

190 "directive": directive, 

191 "criteria": criteria_scaffold.generate_deterministic_criteria( 

192 directive, allowlist=tool_allowlist 

193 ), 

194 "budget": { 

195 "max_iterations": iterations, 

196 "max_wall_clock_seconds": DEFAULT_CHILD_MAX_WALL_CLOCK_SECONDS, 

197 }, 

198 "restart_policy": "never", 

199 "use_sampling": False, 

200 } 

201 if allow_all_tools: 

202 entry["allow_all_tools"] = True 

203 else: 

204 entry["tool_allowlist"] = list(tool_allowlist or []) 

205 return validate_plan( 

206 [entry], 

207 config=config, 

208 registered_tools=registered_tools, 

209 registered_tags=registered_tags, 

210 flag_lookup=flag_lookup, 

211 ) 

212 

213 

214def build_plan_prompt( 

215 directive: str, 

216 *, 

217 config: SwarmConfig, 

218 registered_tools: dict[str, Any], 

219 tool_docstrings: Mapping[str, str] | None = None, 

220 max_children: int | None = None, 

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

222 feedback: str | None = None, 

223) -> str: 

224 """Assemble the deterministic decomposition prompt. 

225 

226 Deterministic given its inputs — tool names sorted, docstrings 

227 truncated to a fixed budget, the catalog capped — matching the 

228 byte-identity discipline of the wider sampling prompt builders. 

229 

230 ``tool_allowlist`` narrows the advertised catalog to the operator's 

231 permitted set. The narrowing is advisory here and enforced by 

232 :func:`generate_sampled_plan` narrowing the registry it validates 

233 against; showing the model only what it may use keeps the two in 

234 agreement instead of inviting rejections. 

235 """ 

236 cap = max_children if max_children is not None else config["max_children"] 

237 cap = max(1, min(cap, config["max_children"])) 

238 permitted = set(registered_tools) 

239 if tool_allowlist: 

240 permitted &= set(tool_allowlist) 

241 names = sorted(permitted)[:_PROMPT_TOOL_LIMIT] 

242 docs = tool_docstrings or {} 

243 catalog_lines = [] 

244 for name in names: 

245 doc = str(docs.get(name, "")).strip().splitlines() 

246 first = doc[0][:_PROMPT_DOCSTRING_CHARS] if doc else "" 

247 catalog_lines.append(f"- {name}: {first}" if first else f"- {name}") 

248 sections = [ 

249 "You are decomposing an operator goal into a fleet of supervised,", 

250 "budgeted worker sessions (a swarm plan). Respond with a single JSON", 

251 "array — no prose, no markdown fences. Each element is one child spec:", 

252 "", 

253 '{"slot": "<unique-name>", "directive": "<child goal>",', 

254 ' "criteria": [<mission criteria objects>],', 

255 ' "budget": {"max_iterations": <int >= 1>,', 

256 ' "max_wall_clock_seconds": <int >= 1>},', 

257 ' "tool_allowlist": ["<registered tool name>", ...],', 

258 ' "restart_policy": "never" | "on_failure" | "on_failure_with_revision"}', 

259 "", 

260 "Rules:", 

261 f"- At most {cap} children.", 

262 f"- The sum of max_iterations across children must not exceed " 

263 f"{config['child_iteration_pool']} (the shared iteration pool).", 

264 "- Budgets are finite: -1 is rejected on children.", 

265 "- Slot names: 1-64 chars, alphanumeric plus . _ - only.", 

266 "- Only tools from the catalog below may appear in an allowlist.", 

267 "- Two children must not share a non-read-only tool.", 

268 "", 

269 "Every criterion object requires all three of these keys:", 

270 ' - "criterion_id": unique non-empty string (unique within the child)', 

271 ' - "kind": one of "metric_threshold" / "metric_trend" / "event" /', 

272 ' "tool_call_succeeded" / "predicate"', 

273 ' - "required": JSON boolean', 

274 "Plus the kind-specific keys:", 

275 ' metric_threshold -> "metric" (dot-path into the Observation,', 

276 ' e.g. metrics.results_count), "op" (one of', 

277 ' <, <=, >, >=, ==, !=), "target" (number)', 

278 ' metric_trend -> "metric" (dot-path), "direction" (one of', 

279 " increasing, decreasing, non_increasing,", 

280 " non_decreasing)", 

281 ' event -> "event_name" (non-empty string)', 

282 ' tool_call_succeeded -> "tool_name" (non-empty string, from the', 

283 " child's own allowlist). PREFER this kind", 

284 ' when the goal is "this tool ran and', 

285 ' succeeded" — it is server-evaluated.', 

286 ' predicate -> "expression" (Python expression over `obs`)', 

287 "", 

288 "CRITICAL — every criterion must be decidable from the Observation.", 

289 "A criterion whose metric path is absent evaluates *inconclusive*, and", 

290 "ANY inconclusive criterion — required or not — blocks completion for", 

291 'the child\'s entire budget. "required": false does NOT make a', 

292 "criterion safe to guess at. Most tools emit no metrics at all, so do", 

293 "not invent metric paths: use metric_threshold / metric_trend only for", 

294 "metrics you are confident the child's tools actually emit, and prefer", 

295 "tool_call_succeeded otherwise. A single well-chosen criterion beats a", 

296 "speculative second one that can never be decided.", 

297 "", 

298 "Worked example of one complete child spec:", 

299 '{"slot": "docs-worker", "directive": "Find the inference docs.",', 

300 ' "criteria": [{"criterion_id": "docs_found",', 

301 ' "kind": "tool_call_succeeded",', 

302 ' "required": true, "tool_name": "find_docs"}],', 

303 ' "budget": {"max_iterations": 5, "max_wall_clock_seconds": 300},', 

304 ' "tool_allowlist": ["find_docs"], "restart_policy": "never"}', 

305 "", 

306 "=== Operator directive ===", 

307 directive, 

308 "", 

309 "=== Registered tool catalog ===", 

310 *catalog_lines, 

311 ] 

312 if feedback: 

313 sections.extend(["", "=== Validator feedback on your previous attempt ===", feedback]) 

314 return "\n".join(sections) 

315 

316 

317class _PromptAdapter: 

318 """Thin ``SamplingPrompt`` look-alike over a pre-assembled string. 

319 

320 Both shipped backends call ``prompt.assemble()`` and nothing else — 

321 the same duck-typing seam the criteria scaffolder and the 

322 semantic-progress judge exploit. 

323 """ 

324 

325 def __init__(self, text: str) -> None: 

326 self._text = text 

327 

328 def assemble(self) -> str: 

329 return self._text 

330 

331 

332async def generate_sampled_plan( 

333 backend: Any, 

334 directive: str, 

335 *, 

336 config: SwarmConfig, 

337 registered_tools: dict[str, Any], 

338 registered_tags: Mapping[str, Collection[str]], 

339 tool_docstrings: Mapping[str, str] | None = None, 

340 max_children: int | None = None, 

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

342 retries: int = DEFAULT_PLAN_RETRIES, 

343 flag_lookup: dict[str, str] | None = None, 

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

345 """Drive a sampling backend to produce an admission-validated plan. 

346 

347 Same loop discipline as ``generate_sampled_criteria``: attempt, 

348 parse, validate through :func:`validate_plan`, feed the precise 

349 rejection back, retry up to ``retries`` extra times, and raise 

350 :class:`SwarmScaffoldError` on exhaustion. Transport failures are 

351 not retriable here — the backend owns its own recovery — and 

352 surface as ``transport_error`` immediately. 

353 

354 ``tool_allowlist`` is the operator's permitted tool set. When given, 

355 the registry this function prompts with *and validates against* is 

356 narrowed to it, so a sampled child can never carry a tool the 

357 operator did not permit: the allowlist is the primary blast-radius 

358 control over a fleet, and honouring it only on the deterministic 

359 path would mean a successful sample silently widened it. 

360 """ 

361 if tool_allowlist: 

362 permitted = set(tool_allowlist) 

363 registered_tools = { 

364 name: tool for name, tool in registered_tools.items() if name in permitted 

365 } 

366 registered_tags = { 

367 name: tags for name, tags in registered_tags.items() if name in permitted 

368 } 

369 feedback: str | None = None 

370 last_reason = "no_attempts" 

371 for _attempt in range(retries + 1): 

372 prompt = build_plan_prompt( 

373 directive, 

374 config=config, 

375 registered_tools=registered_tools, 

376 tool_docstrings=tool_docstrings, 

377 max_children=max_children, 

378 tool_allowlist=tool_allowlist, 

379 feedback=feedback, 

380 ) 

381 try: 

382 raw = str(await backend.sample(_PromptAdapter(prompt))) 

383 except BedrockFTUFormNotAcceptedError: 

384 # A missing Anthropic first-time-use form is a permanent 

385 # account misconfiguration; report it rather than silently 

386 # downgrading to the deterministic plan (criteria-scaffold 

387 # precedent). 

388 raise 

389 except Exception as exc: # noqa: BLE001 — transport-agnostic catch 

390 raise SwarmScaffoldError( 

391 "transport_error", 

392 message=f"sampling backend raised {type(exc).__name__}: {exc}", 

393 ) from exc 

394 try: 

395 parsed = _parse_json_array(raw) 

396 except ValueError as exc: 

397 last_reason = "json_parse" 

398 feedback = ( 

399 "Your previous response could not be parsed as a JSON array. " 

400 f"Return a single JSON array, no prose, no markdown fences. ({exc})" 

401 ) 

402 continue 

403 try: 

404 return validate_plan( 

405 parsed, 

406 config=config, 

407 registered_tools=registered_tools, 

408 registered_tags=registered_tags, 

409 flag_lookup=flag_lookup, 

410 ) 

411 except MissionValidationError as exc: 

412 details = exc.details or {} 

413 last_reason = str(details.get("reason") or exc.code) 

414 feedback = ( 

415 "Your previous response was rejected by the spawn validator. " 

416 f"Rejection reason: {last_reason}. Details: {details!r}. " 

417 "Re-emit a corrected JSON array." 

418 ) 

419 continue 

420 raise SwarmScaffoldError(last_reason) 

421 

422 

423def _parse_json_array(raw: str) -> list[dict[str, Any]]: 

424 """Parse the model response as a JSON array, tolerating code fences.""" 

425 text = raw.strip() 

426 if text.startswith("```"): 

427 lines = [line for line in text.splitlines() if not line.strip().startswith("```")] 

428 text = "\n".join(lines).strip() 

429 parsed = json.loads(text) 

430 if not isinstance(parsed, list): 

431 raise ValueError("expected a JSON array") 

432 return parsed 

433 

434 

435async def sample_revised_directive( 

436 backend: Any, 

437 failed_session: SessionState, 

438 *, 

439 lessons: list[str] | None = None, 

440) -> str | None: 

441 """Ask the backend for a revised directive after a child failure. 

442 

443 Advisory text supply for ``on_failure_with_revision`` respawns — 

444 the deterministic restart table already made the respawn decision. 

445 Returns validated directive text, or ``None`` on any failure or 

446 unusable response so the caller falls back to the original 

447 directive. Never raises. 

448 """ 

449 original = str(failed_session.get("directive_text", "")).strip() 

450 if not original: 

451 return None 

452 lesson_lines = [f"- {lesson}" for lesson in (lessons or []) if str(lesson).strip()] 

453 sections = [ 

454 "A supervised worker session failed to meet its criteria and is being", 

455 "respawned. Revise its directive so the next attempt is more likely to", 

456 "succeed. Respond with exactly one line of plain text — the revised", 

457 "directive. No JSON, no prose around it.", 

458 "", 

459 "=== Original directive ===", 

460 original, 

461 ] 

462 if lesson_lines: 

463 sections.extend(["", "=== Lessons from the failed attempt ===", *lesson_lines]) 

464 try: 

465 raw = str(await backend.sample(_PromptAdapter("\n".join(sections)))) 

466 candidate = raw.strip().splitlines()[0].strip() if raw.strip() else "" 

467 return validate_directive(candidate) 

468 except Exception: # noqa: BLE001 — advisory path degrades, never raises 

469 return None