Coverage for gco_mcp / mission / swarm.py: 100.00%
174 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Pure swarm-supervision primitives: validation, pool accounting, policy.
3A swarm is one **orchestrator** Mission session supervising N **child**
4Mission sessions. This module holds everything about that relationship
5that can be expressed as pure functions — no I/O, no clocks, no
6environment lookups, no FastMCP imports — so the whole admission and
7accounting surface is unit- and property-testable in isolation:
9* :func:`validate_swarm_config` — the swarm-level rails
10 (:class:`~.types.SwarmConfig`).
11* :func:`validate_spawn` — the full spawn-admission pipeline, run for
12 every ``mission_spawn`` dispatch and for every scaffolded plan entry.
13 First failure wins; every rejection is a
14 :class:`~.validation.MissionValidationError` with a stable
15 ``details.reason`` token, so a sampled decomposition gets precise
16 feedback and a retry prompt can quote the exact rule it broke.
17* Pool accounting — :func:`compute_pool_balance`, :func:`settle_entry`,
18 :func:`respawn_entry`, :func:`new_registry_entry`. Reservation model:
19 a spawn reserves the child's ``max_iterations`` from the pool; the
20 settle step on a terminal child folds the actually-recorded iteration
21 count into ``consumed_iterations`` and thereby refunds the unused
22 remainder.
23* :func:`should_respawn` — the deterministic restart-policy table. The
24 respawn *decision* never depends on a sampler; only the optional
25 replacement-directive text does (elsewhere).
27The supervisor tools themselves (``mission_spawn`` / ``children_status``
28/ ``child_abort``) are **in-process dispatcher entries** wired by the
29runner for orchestrator sessions only. They are never registered with
30the MCP server, and — together with the operator-facing ``swarm_*`` MCP
31tools and the ``mission_*`` control tools — they are excluded from every
32resolvable session allowlist (:data:`SWARM_EXCLUDED_TOOLS`), so no loop
33can drive loops except through this validated seam.
34"""
36from __future__ import annotations
38import re
39from collections.abc import Collection, Mapping, Sequence
40from typing import Any, Final, TypedDict, cast
42from .types import (
43 BudgetControls,
44 Cadence,
45 ChildRegistryEntry,
46 Criterion,
47 RestartPolicy,
48 SwarmConfig,
49)
50from .validation import (
51 SUPERVISOR_TOOLS,
52 SWARM_EXCLUDED_TOOLS,
53 SWARM_MCP_TOOLS,
54 MissionValidationError,
55 resolve_effective_allowlist,
56 validate_cadence,
57 validate_criteria,
58 validate_directive,
59)
61__all__ = [
62 "DEFAULT_MAX_CONCURRENT_CHILDREN",
63 "DEFAULT_RESPAWNS_BY_POLICY",
64 "RESTART_POLICIES",
65 "SUPERVISOR_TOOLS",
66 "SUPERVISOR_TOOL_DOCSTRINGS",
67 "SUPERVISOR_TOOL_SCHEMAS",
68 "SWARM_EXCLUDED_TOOLS",
69 "SWARM_MCP_TOOLS",
70 "PoolBalance",
71 "SpawnSpec",
72 "build_orchestrator_session",
73 "compute_pool_balance",
74 "new_registry_entry",
75 "respawn_entry",
76 "settle_entry",
77 "should_respawn",
78 "validate_spawn",
79 "validate_swarm_config",
80]
83# ---------------------------------------------------------------------------
84# Constants
85# ---------------------------------------------------------------------------
87RESTART_POLICIES: Final[frozenset[str]] = frozenset(
88 {"never", "on_failure", "on_failure_with_revision"}
89)
90"""The valid ``restart_policy`` values on a spawn request."""
92DEFAULT_MAX_CONCURRENT_CHILDREN: Final[int] = 3
93"""Default concurrency bound on simultaneously advancing children.
95Deliberately small: it is the swarm's primary throughput control (tool
96fan-out and, when children opt into sampling, concurrent Bedrock calls).
97"""
99DEFAULT_RESPAWNS_BY_POLICY: Final[dict[str, int]] = {
100 "never": 0,
101 "on_failure": 1,
102 "on_failure_with_revision": 1,
103}
104"""Default ``max_respawns`` per restart policy when the request omits it."""
106_SAFE_TAG: Final[str] = "safe"
107"""The risk-tier tag marking a tool read-only for the overlap check."""
109_SLOT_RE: Final[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
110"""Slot names: 1-64 chars, alphanumeric plus ``. _ -``, no whitespace.
112Slots become file-name fragments (task-status heartbeats) and audit keys,
113so the charset is deliberately conservative.
114"""
117# ---------------------------------------------------------------------------
118# Result shapes
119# ---------------------------------------------------------------------------
122class PoolBalance(TypedDict):
123 """A point-in-time view of the child-iteration pool.
125 ``reserved`` counts live (non-settled) entries' reservations;
126 ``consumed`` sums settled consumption; ``remaining`` is what a new
127 spawn may draw from. The admission pipeline keeps ``remaining``
128 non-negative by construction.
129 """
131 pool: int
132 reserved: int
133 consumed: int
134 remaining: int
137class SpawnSpec(TypedDict):
138 """A fully validated, normalized child specification.
140 Everything a runner needs to persist a child session and register the
141 slot. Produced only by :func:`validate_spawn`; consuming code may
142 trust every field.
143 """
145 slot: str
146 directive: str
147 criteria: list[Criterion]
148 budget: BudgetControls
149 tool_allowlist: list[str]
150 checkpoint_cadence: Cadence
151 restart_policy: RestartPolicy
152 max_respawns: int
153 use_sampling: bool
156# ---------------------------------------------------------------------------
157# Local helpers
158# ---------------------------------------------------------------------------
161def _is_positive_int(value: Any) -> bool:
162 """Return True iff ``value`` is an int (not bool) and strictly > 0."""
163 return isinstance(value, int) and not isinstance(value, bool) and value > 0
166def _is_non_negative_int(value: Any) -> bool:
167 """Return True iff ``value`` is an int (not bool) and >= 0."""
168 return isinstance(value, int) and not isinstance(value, bool) and value >= 0
171def _reject(field: str, reason: str, **extra: Any) -> MissionValidationError:
172 """Build the standard structured rejection for this module."""
173 details: dict[str, Any] = {"field": field, "reason": reason}
174 details.update(extra)
175 return MissionValidationError("validation_error", details=details)
178def _is_safe_tool(name: str, registered_tags: Mapping[str, Collection[str]]) -> bool:
179 """Return True iff the tool is known and carries the ``safe`` tag.
181 Unknown names are treated as **not** safe: the overlap rail fails
182 closed when tag information is missing.
183 """
184 tags = registered_tags.get(name)
185 return tags is not None and _SAFE_TAG in tags
188# ---------------------------------------------------------------------------
189# Swarm config
190# ---------------------------------------------------------------------------
193def validate_swarm_config(config: dict[str, Any]) -> SwarmConfig:
194 """Validate the swarm-level rails and normalize defaults.
196 Required: ``max_children`` and ``child_iteration_pool``, each a
197 strictly-positive int — there is deliberately **no** ``-1`` uncapped
198 sentinel at the swarm level; an unbounded fleet or pool is exactly
199 the runaway shape these rails exist to prevent. Optional:
200 ``max_concurrent_children`` (default
201 :data:`DEFAULT_MAX_CONCURRENT_CHILDREN`) and
202 ``allow_overlapping_mutating_tools`` (default ``False``).
204 Returns a normalized :class:`~.types.SwarmConfig` carrying all four
205 keys.
206 """
207 if not isinstance(config, dict):
208 raise _reject("swarm", "not_a_dict")
209 max_children = config.get("max_children")
210 if not _is_positive_int(max_children):
211 raise _reject("swarm", "missing_or_not_positive_int", subfield="max_children")
212 pool = config.get("child_iteration_pool")
213 if not _is_positive_int(pool):
214 raise _reject("swarm", "missing_or_not_positive_int", subfield="child_iteration_pool")
215 concurrency = config.get("max_concurrent_children", DEFAULT_MAX_CONCURRENT_CHILDREN)
216 if not _is_positive_int(concurrency):
217 raise _reject("swarm", "not_positive_int", subfield="max_concurrent_children")
218 allow_overlap = config.get("allow_overlapping_mutating_tools", False)
219 if not isinstance(allow_overlap, bool):
220 raise _reject("swarm", "not_a_bool", subfield="allow_overlapping_mutating_tools")
221 normalized: dict[str, Any] = {
222 "max_children": max_children,
223 "child_iteration_pool": pool,
224 "max_concurrent_children": concurrency,
225 "allow_overlapping_mutating_tools": allow_overlap,
226 }
227 return cast("SwarmConfig", normalized)
230# ---------------------------------------------------------------------------
231# Pool accounting
232# ---------------------------------------------------------------------------
235def compute_pool_balance(
236 child_iteration_pool: int,
237 children: Sequence[ChildRegistryEntry],
238) -> PoolBalance:
239 """Compute the pool view from the registry alone.
241 Live (non-settled) entries hold their full ``reserved_iterations``
242 against the pool; settled entries contribute only their
243 ``consumed_iterations`` (the settle step already folded the refund).
244 """
245 reserved = sum(e["reserved_iterations"] for e in children if not e.get("settled"))
246 consumed = sum(e["consumed_iterations"] for e in children)
247 return {
248 "pool": child_iteration_pool,
249 "reserved": reserved,
250 "consumed": consumed,
251 "remaining": child_iteration_pool - reserved - consumed,
252 }
255def new_registry_entry(spec: SpawnSpec, session_id: str, spawned_at: str) -> ChildRegistryEntry:
256 """Build the registry entry for a freshly spawned slot."""
257 entry: ChildRegistryEntry = {
258 "slot": spec["slot"],
259 "session_id": session_id,
260 "spawned_at": spawned_at,
261 "reserved_iterations": spec["budget"]["max_iterations"],
262 "restart_policy": spec["restart_policy"],
263 "max_respawns": spec["max_respawns"],
264 "respawn_count": 0,
265 "consumed_iterations": 0,
266 }
267 return entry
270def settle_entry(entry: ChildRegistryEntry, iterations_recorded: int) -> ChildRegistryEntry:
271 """Fold a terminal session's consumption into the slot; refund the rest.
273 Consumption is clamped to ``[0, reserved_iterations]`` — the engine's
274 own budget cap guarantees a child never records more iterations than
275 its reservation, and the clamp keeps the pool arithmetic sound even
276 against a corrupted count. Settling an already-settled entry is a
277 no-op (idempotent), so a crash between settle and persist cannot
278 double-count on replay.
280 Returns a new entry; the input is not mutated.
281 """
282 if entry.get("settled"):
283 return entry
284 reserved = entry["reserved_iterations"]
285 folded = min(max(iterations_recorded, 0), reserved)
286 updated = dict(entry)
287 updated["consumed_iterations"] = entry["consumed_iterations"] + folded
288 updated["reserved_iterations"] = 0
289 updated["settled"] = True
290 return cast("ChildRegistryEntry", updated)
293def respawn_entry(
294 entry: ChildRegistryEntry,
295 *,
296 new_session_id: str,
297 reserved_iterations: int,
298 spawned_at: str,
299) -> ChildRegistryEntry:
300 """Point a settled slot at its replacement session.
302 The prior session id moves into the lineage list, the respawn count
303 increments, and the new reservation goes live. Respawning an
304 unsettled entry is a supervision bug, rejected loudly rather than
305 silently corrupting the pool.
307 Returns a new entry; the input is not mutated.
308 """
309 if not entry.get("settled"):
310 raise _reject("spawn", "respawn_before_settle", slot=entry["slot"])
311 updated = dict(entry)
312 lineage = list(entry.get("prior_session_ids", []))
313 lineage.append(entry["session_id"])
314 updated["prior_session_ids"] = lineage
315 updated["session_id"] = new_session_id
316 updated["spawned_at"] = spawned_at
317 updated["reserved_iterations"] = reserved_iterations
318 updated["respawn_count"] = entry["respawn_count"] + 1
319 updated.pop("settled", None)
320 return cast("ChildRegistryEntry", updated)
323# ---------------------------------------------------------------------------
324# Restart policy
325# ---------------------------------------------------------------------------
328def should_respawn(entry: ChildRegistryEntry, final_status: str) -> tuple[bool, str]:
329 """Deterministic restart-policy table for a slot whose session ended.
331 ``final_status`` is the child session's terminal
332 :data:`~.types.StatusLabel`. Returns ``(decision, reason)``; the reason
333 token names which row of the table fired, so tests can assert the policy
334 rather than just the boolean (the runner acts on the decision alone):
336 * non-terminal status → ``(False, "not_terminal")`` (caller bug guard)
337 * ``completed`` → ``(False, "completed_no_respawn")``
338 * policy ``never`` → ``(False, "policy_never")``
339 * respawn budget exhausted → ``(False, "max_respawns_reached")``
340 * otherwise (``failed`` / ``terminated``) → ``(True, "respawn")``
342 The pool and fleet-cap checks still apply at respawn time — a ``True``
343 here is a policy decision, not an admission.
344 """
345 if final_status not in ("completed", "terminated", "failed"):
346 return (False, "not_terminal")
347 if final_status == "completed":
348 return (False, "completed_no_respawn")
349 if entry["restart_policy"] == "never":
350 return (False, "policy_never")
351 if entry["respawn_count"] >= entry["max_respawns"]:
352 return (False, "max_respawns_reached")
353 return (True, "respawn")
356# ---------------------------------------------------------------------------
357# Spawn admission
358# ---------------------------------------------------------------------------
361def _validate_child_budget(budget: Any) -> BudgetControls:
362 """Validate a child budget: both caps required, strictly positive.
364 Children deliberately reject the ``-1`` uncapped sentinel Mission
365 budgets accept: a supervised worker must be self-terminating on both
366 axes even if its supervisor dies, and the iteration cap doubles as
367 the slot's pool reservation, which must be a finite number.
368 """
369 if not isinstance(budget, dict):
370 raise _reject("budget", "not_a_dict")
371 max_iterations = budget.get("max_iterations")
372 if not _is_positive_int(max_iterations):
373 raise _reject("budget", "missing_or_not_positive_int", subfield="max_iterations")
374 max_wall = budget.get("max_wall_clock_seconds")
375 if not _is_positive_int(max_wall):
376 raise _reject("budget", "missing_or_not_positive_int", subfield="max_wall_clock_seconds")
377 normalized: dict[str, Any] = {
378 "max_iterations": max_iterations,
379 "max_wall_clock_seconds": max_wall,
380 }
381 return cast("BudgetControls", normalized)
384def validate_spawn(
385 *,
386 parent_role: str | None,
387 config: SwarmConfig,
388 children: Sequence[ChildRegistryEntry],
389 request: Mapping[str, Any],
390 registered_tools: dict[str, Any],
391 registered_tags: Mapping[str, Collection[str]],
392 sibling_allowlists: Mapping[str, Sequence[str]],
393 flag_lookup: dict[str, str] | None = None,
394 respawn_of_slot: str | None = None,
395) -> SpawnSpec:
396 """Run the full spawn-admission pipeline; first failure wins.
398 Pure: the caller supplies every piece of live state — the parent's
399 role, the persisted registry, the registered tool names and tag map,
400 and the allowlists of **live** sibling children keyed by slot (the
401 overlap rail checks only siblings that can still act).
403 Admission order (each rejection carries its own ``details.reason``):
405 1. depth — the dispatching session must be an orchestrator
406 2. slot shape and uniqueness (``respawn_of_slot`` exempts its own slot)
407 3. child budget shape (strictly positive; ``-1`` rejected)
408 4. restart policy and ``max_respawns``
409 5. ``use_sampling`` shape
410 6. fleet cap over live (non-settled) slots
411 7. iteration-pool balance
412 8. directive, criteria, cadence via the shared Mission validators
413 9. allowlist resolution (control/supervisor/swarm names unreachable)
414 10. mutating-tool overlap against live siblings (unless opted out)
416 Returns the normalized :class:`SpawnSpec`.
417 """
418 # 1. Depth: only orchestrators spawn. Children never receive the
419 # supervisor tools in the first place (structural guard); this role
420 # check is the second, independent layer of the same rule.
421 if parent_role != "orchestrator":
422 raise _reject(
423 "spawn",
424 "spawn_depth_exceeded",
425 parent_role=parent_role,
426 )
428 # 2. Slot.
429 slot = request.get("slot")
430 if not isinstance(slot, str) or not _SLOT_RE.match(slot):
431 raise _reject("spawn", "slot_missing_or_invalid")
432 existing_slots = {entry["slot"] for entry in children}
433 if slot in existing_slots and slot != respawn_of_slot:
434 raise _reject("spawn", "duplicate_slot", slot=slot)
436 # 3. Child budget (also the pool reservation).
437 budget = _validate_child_budget(request.get("budget"))
439 # 4. Restart policy.
440 restart_policy = request.get("restart_policy", "never")
441 if restart_policy not in RESTART_POLICIES:
442 raise _reject("spawn", "restart_policy_invalid", restart_policy=restart_policy)
443 max_respawns = request.get("max_respawns")
444 if max_respawns is None:
445 max_respawns = DEFAULT_RESPAWNS_BY_POLICY[restart_policy]
446 elif not _is_non_negative_int(max_respawns):
447 raise _reject("spawn", "max_respawns_not_a_non_negative_int")
448 if restart_policy == "never":
449 max_respawns = 0
451 # 5. Sampling default: deterministic leaves unless explicitly opted in.
452 use_sampling = request.get("use_sampling", False)
453 if not isinstance(use_sampling, bool):
454 raise _reject("spawn", "use_sampling_not_a_bool")
456 # 6. Fleet cap over live slots. A respawn follows settle, so its old
457 # entry is no longer live and counts itself naturally.
458 live = [entry for entry in children if not entry.get("settled")]
459 if len(live) + 1 > config["max_children"]:
460 raise _reject(
461 "spawn",
462 "fleet_cap_exceeded",
463 max_children=config["max_children"],
464 live_children=len(live),
465 )
467 # 7. Pool balance.
468 balance = compute_pool_balance(config["child_iteration_pool"], children)
469 if budget["max_iterations"] > balance["remaining"]:
470 raise _reject(
471 "spawn",
472 "iteration_pool_exhausted",
473 requested=budget["max_iterations"],
474 remaining=balance["remaining"],
475 )
477 # 8. Directive / criteria / cadence via the shared validators.
478 directive = validate_directive(cast("str", request.get("directive", "")))
479 criteria = validate_criteria(cast("list[dict[str, Any]]", request.get("criteria")))
480 cadence = validate_cadence(
481 cast("dict[str, Any]", request.get("cadence") or {"kind": "every_iteration"})
482 )
484 # 9. Allowlist. An explicit list naming a control-plane tool is a
485 # loud rejection (precise sampler feedback beats silent stripping);
486 # the all-tools expansion excludes them via the control set.
487 explicit = request.get("tool_allowlist")
488 allow_all = bool(request.get("allow_all_tools", False))
489 if isinstance(explicit, list):
490 for name in explicit:
491 if isinstance(name, str) and name in SWARM_EXCLUDED_TOOLS:
492 raise _reject("tool_allowlist", "control_tool_not_allowed", tool_name=name)
493 tool_allowlist = resolve_effective_allowlist(
494 allow_all_tools=allow_all,
495 explicit_allowlist=cast("list[str] | None", explicit),
496 registered_tools=registered_tools,
497 control_tools=SWARM_EXCLUDED_TOOLS,
498 flag_lookup=flag_lookup,
499 )
501 # 10. Mutating-tool overlap against live siblings, slot-ordered so
502 # the first rejection is deterministic.
503 if not config["allow_overlapping_mutating_tools"]:
504 mutating = {name for name in tool_allowlist if not _is_safe_tool(name, registered_tags)}
505 if mutating:
506 for sibling_slot in sorted(sibling_allowlists):
507 if sibling_slot == respawn_of_slot:
508 continue
509 sibling_mutating = {
510 name
511 for name in sibling_allowlists[sibling_slot]
512 if not _is_safe_tool(name, registered_tags)
513 }
514 overlap = sorted(mutating & sibling_mutating)
515 if overlap:
516 raise _reject(
517 "spawn",
518 "mutating_tool_overlap",
519 tools=overlap,
520 sibling_slot=sibling_slot,
521 )
523 spec: SpawnSpec = {
524 "slot": slot,
525 "directive": directive,
526 "criteria": criteria,
527 "budget": budget,
528 "tool_allowlist": tool_allowlist,
529 "checkpoint_cadence": cadence,
530 "restart_policy": cast("RestartPolicy", restart_policy),
531 "max_respawns": max_respawns,
532 "use_sampling": use_sampling,
533 }
534 return spec
537# ---------------------------------------------------------------------------
538# Supervisor tool schemas
539# ---------------------------------------------------------------------------
541SUPERVISOR_TOOL_SCHEMAS: Final[dict[str, dict[str, Any]]] = {
542 "mission_spawn": {
543 "type": "object",
544 "properties": {
545 "slot": {"type": "string", "description": "Unique slot name for the child."},
546 "directive": {"type": "string", "description": "Child goal, natural language."},
547 "criteria": {
548 "type": "array",
549 "items": {"type": "object"},
550 "description": "Mission criteria array for the child.",
551 },
552 "budget": {
553 "type": "object",
554 "properties": {
555 "max_iterations": {"type": "integer", "minimum": 1},
556 "max_wall_clock_seconds": {"type": "integer", "minimum": 1},
557 },
558 "required": ["max_iterations", "max_wall_clock_seconds"],
559 "description": "Finite child budget; -1 is rejected on children.",
560 },
561 "tool_allowlist": {"type": "array", "items": {"type": "string"}},
562 "allow_all_tools": {"type": "boolean"},
563 "restart_policy": {
564 "type": "string",
565 "enum": ["never", "on_failure", "on_failure_with_revision"],
566 },
567 "max_respawns": {"type": "integer", "minimum": 0},
568 "use_sampling": {"type": "boolean"},
569 "cadence": {"type": "object"},
570 },
571 "required": ["slot", "directive", "criteria", "budget"],
572 },
573 "children_status": {"type": "object", "properties": {}},
574 "child_abort": {
575 "type": "object",
576 "properties": {"slot": {"type": "string"}},
577 "required": ["slot"],
578 },
579}
580"""JSON schemas for the in-process supervisor tools.
582The supervisor tools are never registered with FastMCP, so the sampled
583strategy validator (``validate_strategy_against_catalog``) cannot learn
584their shapes from the live registry. Callers that build a sampled
585orchestrator extend the sampler's catalog with these schemas so spawn
586proposals validate at proposal time; the spawn tool itself re-validates
587every dispatch through :func:`validate_spawn` regardless.
588"""
590SUPERVISOR_TOOL_DOCSTRINGS: Final[dict[str, str]] = {
591 "mission_spawn": (
592 "Spawn one supervised child Mission session. Validated against the "
593 "swarm rails (fleet cap, iteration pool, finite child budget, "
594 "allowlist exclusions, mutating-tool overlap)."
595 ),
596 "children_status": (
597 "Return the deterministic fleet snapshot: slot-ordered child rows "
598 "plus aggregate children_* metrics and the remaining iteration pool."
599 ),
600 "child_abort": "Abort one live child slot by name, settling its reservation.",
601}
602"""Prompt-facing docstrings for the supervisor tools (allowlist rendering)."""
605# ---------------------------------------------------------------------------
606# Orchestrator session construction
607# ---------------------------------------------------------------------------
610def build_orchestrator_session(
611 *,
612 session_id: str,
613 directive: str,
614 criteria: list[Criterion],
615 budget: BudgetControls,
616 swarm_config: SwarmConfig,
617 cadence: Cadence,
618 extra_allowlist: Sequence[str] = (),
619 stagnation_threshold: int = 3,
620 use_sampling: bool = False,
621 sampling_backend_resolved: str = "none",
622 created_at: str,
623) -> dict[str, Any]:
624 """Assemble a new orchestrator session dict from validated inputs.
626 Pure: every argument is already validated by its own validator; this
627 function only fixes the shape shared by the MCP tool and the CLI so
628 the two surfaces cannot drift. The effective allowlist brackets the
629 caller's extras with the supervisor tools — ``children_status``
630 first (the deterministic strategy's target), spawn/abort last —
631 which is the only place those names may enter a session allowlist.
632 """
633 effective = ["children_status"]
634 effective.extend(name for name in extra_allowlist if name not in SUPERVISOR_TOOLS)
635 effective.extend(["mission_spawn", "child_abort"])
636 return {
637 "version": _schema_version(),
638 "session_id": session_id,
639 "directive_text": directive,
640 "criteria": [
641 {k: v for k, v in criterion.items() if not str(k).startswith("_")}
642 for criterion in criteria
643 ],
644 "budget": budget,
645 "tool_allowlist": effective,
646 "checkpoint_cadence": cadence,
647 "stagnation_threshold": stagnation_threshold,
648 "use_sampling": use_sampling,
649 "sampling_backend_resolved": sampling_backend_resolved,
650 "allow_scripted_strategies": False,
651 "status": "pending",
652 "created_at": created_at,
653 "iterations": [],
654 "no_progress_counter": 0,
655 "role": "orchestrator",
656 "swarm": swarm_config,
657 "children": [],
658 }
661def _schema_version() -> int:
662 """Late import so this pure module keeps its type-only dependency."""
663 from .types import SCHEMA_VERSION
665 return int(SCHEMA_VERSION)