Coverage for gco_mcp / mission / swarm.py: 100.00%
174 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +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)`` where the
333 reason token lands in the child-lifecycle audit event:
335 * non-terminal status → ``(False, "not_terminal")`` (caller bug guard)
336 * ``completed`` → ``(False, "completed_no_respawn")``
337 * policy ``never`` → ``(False, "policy_never")``
338 * respawn budget exhausted → ``(False, "max_respawns_reached")``
339 * otherwise (``failed`` / ``terminated``) → ``(True, "respawn")``
341 The pool and fleet-cap checks still apply at respawn time — a ``True``
342 here is a policy decision, not an admission.
343 """
344 if final_status not in ("completed", "terminated", "failed"):
345 return (False, "not_terminal")
346 if final_status == "completed":
347 return (False, "completed_no_respawn")
348 if entry["restart_policy"] == "never":
349 return (False, "policy_never")
350 if entry["respawn_count"] >= entry["max_respawns"]:
351 return (False, "max_respawns_reached")
352 return (True, "respawn")
355# ---------------------------------------------------------------------------
356# Spawn admission
357# ---------------------------------------------------------------------------
360def _validate_child_budget(budget: Any) -> BudgetControls:
361 """Validate a child budget: both caps required, strictly positive.
363 Children deliberately reject the ``-1`` uncapped sentinel Mission
364 budgets accept: a supervised worker must be self-terminating on both
365 axes even if its supervisor dies, and the iteration cap doubles as
366 the slot's pool reservation, which must be a finite number.
367 """
368 if not isinstance(budget, dict):
369 raise _reject("budget", "not_a_dict")
370 max_iterations = budget.get("max_iterations")
371 if not _is_positive_int(max_iterations):
372 raise _reject("budget", "missing_or_not_positive_int", subfield="max_iterations")
373 max_wall = budget.get("max_wall_clock_seconds")
374 if not _is_positive_int(max_wall):
375 raise _reject("budget", "missing_or_not_positive_int", subfield="max_wall_clock_seconds")
376 normalized: dict[str, Any] = {
377 "max_iterations": max_iterations,
378 "max_wall_clock_seconds": max_wall,
379 }
380 return cast("BudgetControls", normalized)
383def validate_spawn(
384 *,
385 parent_role: str | None,
386 config: SwarmConfig,
387 children: Sequence[ChildRegistryEntry],
388 request: Mapping[str, Any],
389 registered_tools: dict[str, Any],
390 registered_tags: Mapping[str, Collection[str]],
391 sibling_allowlists: Mapping[str, Sequence[str]],
392 flag_lookup: dict[str, str] | None = None,
393 respawn_of_slot: str | None = None,
394) -> SpawnSpec:
395 """Run the full spawn-admission pipeline; first failure wins.
397 Pure: the caller supplies every piece of live state — the parent's
398 role, the persisted registry, the registered tool names and tag map,
399 and the allowlists of **live** sibling children keyed by slot (the
400 overlap rail checks only siblings that can still act).
402 Admission order (each rejection carries its own ``details.reason``):
404 1. depth — the dispatching session must be an orchestrator
405 2. slot shape and uniqueness (``respawn_of_slot`` exempts its own slot)
406 3. child budget shape (strictly positive; ``-1`` rejected)
407 4. restart policy and ``max_respawns``
408 5. ``use_sampling`` shape
409 6. fleet cap over live (non-settled) slots
410 7. iteration-pool balance
411 8. directive, criteria, cadence via the shared Mission validators
412 9. allowlist resolution (control/supervisor/swarm names unreachable)
413 10. mutating-tool overlap against live siblings (unless opted out)
415 Returns the normalized :class:`SpawnSpec`.
416 """
417 # 1. Depth: only orchestrators spawn. Children never receive the
418 # supervisor tools in the first place (structural guard); this role
419 # check is the second, independent layer of the same rule.
420 if parent_role != "orchestrator":
421 raise _reject(
422 "spawn",
423 "spawn_depth_exceeded",
424 parent_role=parent_role,
425 )
427 # 2. Slot.
428 slot = request.get("slot")
429 if not isinstance(slot, str) or not _SLOT_RE.match(slot):
430 raise _reject("spawn", "slot_missing_or_invalid")
431 existing_slots = {entry["slot"] for entry in children}
432 if slot in existing_slots and slot != respawn_of_slot:
433 raise _reject("spawn", "duplicate_slot", slot=slot)
435 # 3. Child budget (also the pool reservation).
436 budget = _validate_child_budget(request.get("budget"))
438 # 4. Restart policy.
439 restart_policy = request.get("restart_policy", "never")
440 if restart_policy not in RESTART_POLICIES:
441 raise _reject("spawn", "restart_policy_invalid", restart_policy=restart_policy)
442 max_respawns = request.get("max_respawns")
443 if max_respawns is None:
444 max_respawns = DEFAULT_RESPAWNS_BY_POLICY[restart_policy]
445 elif not _is_non_negative_int(max_respawns):
446 raise _reject("spawn", "max_respawns_not_a_non_negative_int")
447 if restart_policy == "never":
448 max_respawns = 0
450 # 5. Sampling default: deterministic leaves unless explicitly opted in.
451 use_sampling = request.get("use_sampling", False)
452 if not isinstance(use_sampling, bool):
453 raise _reject("spawn", "use_sampling_not_a_bool")
455 # 6. Fleet cap over live slots. A respawn follows settle, so its old
456 # entry is no longer live and counts itself naturally.
457 live = [entry for entry in children if not entry.get("settled")]
458 if len(live) + 1 > config["max_children"]:
459 raise _reject(
460 "spawn",
461 "fleet_cap_exceeded",
462 max_children=config["max_children"],
463 live_children=len(live),
464 )
466 # 7. Pool balance.
467 balance = compute_pool_balance(config["child_iteration_pool"], children)
468 if budget["max_iterations"] > balance["remaining"]:
469 raise _reject(
470 "spawn",
471 "iteration_pool_exhausted",
472 requested=budget["max_iterations"],
473 remaining=balance["remaining"],
474 )
476 # 8. Directive / criteria / cadence via the shared validators.
477 directive = validate_directive(cast("str", request.get("directive", "")))
478 criteria = validate_criteria(cast("list[dict[str, Any]]", request.get("criteria")))
479 cadence = validate_cadence(
480 cast("dict[str, Any]", request.get("cadence") or {"kind": "every_iteration"})
481 )
483 # 9. Allowlist. An explicit list naming a control-plane tool is a
484 # loud rejection (precise sampler feedback beats silent stripping);
485 # the all-tools expansion excludes them via the control set.
486 explicit = request.get("tool_allowlist")
487 allow_all = bool(request.get("allow_all_tools", False))
488 if isinstance(explicit, list):
489 for name in explicit:
490 if isinstance(name, str) and name in SWARM_EXCLUDED_TOOLS:
491 raise _reject("tool_allowlist", "control_tool_not_allowed", tool_name=name)
492 tool_allowlist = resolve_effective_allowlist(
493 allow_all_tools=allow_all,
494 explicit_allowlist=cast("list[str] | None", explicit),
495 registered_tools=registered_tools,
496 control_tools=SWARM_EXCLUDED_TOOLS,
497 flag_lookup=flag_lookup,
498 )
500 # 10. Mutating-tool overlap against live siblings, slot-ordered so
501 # the first rejection is deterministic.
502 if not config["allow_overlapping_mutating_tools"]:
503 mutating = {name for name in tool_allowlist if not _is_safe_tool(name, registered_tags)}
504 if mutating:
505 for sibling_slot in sorted(sibling_allowlists):
506 if sibling_slot == respawn_of_slot:
507 continue
508 sibling_mutating = {
509 name
510 for name in sibling_allowlists[sibling_slot]
511 if not _is_safe_tool(name, registered_tags)
512 }
513 overlap = sorted(mutating & sibling_mutating)
514 if overlap:
515 raise _reject(
516 "spawn",
517 "mutating_tool_overlap",
518 tools=overlap,
519 sibling_slot=sibling_slot,
520 )
522 spec: SpawnSpec = {
523 "slot": slot,
524 "directive": directive,
525 "criteria": criteria,
526 "budget": budget,
527 "tool_allowlist": tool_allowlist,
528 "checkpoint_cadence": cadence,
529 "restart_policy": cast("RestartPolicy", restart_policy),
530 "max_respawns": max_respawns,
531 "use_sampling": use_sampling,
532 }
533 return spec
536# ---------------------------------------------------------------------------
537# Supervisor tool schemas
538# ---------------------------------------------------------------------------
540SUPERVISOR_TOOL_SCHEMAS: Final[dict[str, dict[str, Any]]] = {
541 "mission_spawn": {
542 "type": "object",
543 "properties": {
544 "slot": {"type": "string", "description": "Unique slot name for the child."},
545 "directive": {"type": "string", "description": "Child goal, natural language."},
546 "criteria": {
547 "type": "array",
548 "items": {"type": "object"},
549 "description": "Mission criteria array for the child.",
550 },
551 "budget": {
552 "type": "object",
553 "properties": {
554 "max_iterations": {"type": "integer", "minimum": 1},
555 "max_wall_clock_seconds": {"type": "integer", "minimum": 1},
556 },
557 "required": ["max_iterations", "max_wall_clock_seconds"],
558 "description": "Finite child budget; -1 is rejected on children.",
559 },
560 "tool_allowlist": {"type": "array", "items": {"type": "string"}},
561 "allow_all_tools": {"type": "boolean"},
562 "restart_policy": {
563 "type": "string",
564 "enum": ["never", "on_failure", "on_failure_with_revision"],
565 },
566 "max_respawns": {"type": "integer", "minimum": 0},
567 "use_sampling": {"type": "boolean"},
568 "cadence": {"type": "object"},
569 },
570 "required": ["slot", "directive", "criteria", "budget"],
571 },
572 "children_status": {"type": "object", "properties": {}},
573 "child_abort": {
574 "type": "object",
575 "properties": {"slot": {"type": "string"}},
576 "required": ["slot"],
577 },
578}
579"""JSON schemas for the in-process supervisor tools.
581The supervisor tools are never registered with FastMCP, so the sampled
582strategy validator (``validate_strategy_against_catalog``) cannot learn
583their shapes from the live registry. Callers that build a sampled
584orchestrator extend the sampler's catalog with these schemas so spawn
585proposals validate at proposal time; the spawn tool itself re-validates
586every dispatch through :func:`validate_spawn` regardless.
587"""
589SUPERVISOR_TOOL_DOCSTRINGS: Final[dict[str, str]] = {
590 "mission_spawn": (
591 "Spawn one supervised child Mission session. Validated against the "
592 "swarm rails (fleet cap, iteration pool, finite child budget, "
593 "allowlist exclusions, mutating-tool overlap)."
594 ),
595 "children_status": (
596 "Return the deterministic fleet snapshot: slot-ordered child rows "
597 "plus aggregate children_* metrics and the remaining iteration pool."
598 ),
599 "child_abort": "Abort one live child slot by name, settling its reservation.",
600}
601"""Prompt-facing docstrings for the supervisor tools (allowlist rendering)."""
604# ---------------------------------------------------------------------------
605# Orchestrator session construction
606# ---------------------------------------------------------------------------
609def build_orchestrator_session(
610 *,
611 session_id: str,
612 directive: str,
613 criteria: list[Criterion],
614 budget: BudgetControls,
615 swarm_config: SwarmConfig,
616 cadence: Cadence,
617 extra_allowlist: Sequence[str] = (),
618 stagnation_threshold: int = 3,
619 use_sampling: bool = False,
620 sampling_backend_resolved: str = "none",
621 created_at: str,
622) -> dict[str, Any]:
623 """Assemble a new orchestrator session dict from validated inputs.
625 Pure: every argument is already validated by its own validator; this
626 function only fixes the shape shared by the MCP tool and the CLI so
627 the two surfaces cannot drift. The effective allowlist brackets the
628 caller's extras with the supervisor tools — ``children_status``
629 first (the deterministic strategy's target), spawn/abort last —
630 which is the only place those names may enter a session allowlist.
631 """
632 effective = ["children_status"]
633 effective.extend(name for name in extra_allowlist if name not in SUPERVISOR_TOOLS)
634 effective.extend(["mission_spawn", "child_abort"])
635 return {
636 "version": _schema_version(),
637 "session_id": session_id,
638 "directive_text": directive,
639 "criteria": [
640 {k: v for k, v in criterion.items() if not str(k).startswith("_")}
641 for criterion in criteria
642 ],
643 "budget": budget,
644 "tool_allowlist": effective,
645 "checkpoint_cadence": cadence,
646 "stagnation_threshold": stagnation_threshold,
647 "use_sampling": use_sampling,
648 "sampling_backend_resolved": sampling_backend_resolved,
649 "allow_scripted_strategies": False,
650 "status": "pending",
651 "created_at": created_at,
652 "iterations": [],
653 "no_progress_counter": 0,
654 "role": "orchestrator",
655 "swarm": swarm_config,
656 "children": [],
657 }
660def _schema_version() -> int:
661 """Late import so this pure module keeps its type-only dependency."""
662 from .types import SCHEMA_VERSION
664 return int(SCHEMA_VERSION)