smythe 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
smythe/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ """Smythe: task-based personalized agent swarms with dynamic execution topology."""
2
+
3
+ from smythe.budget import Sentinel, SentinelAlert
4
+ from smythe.checkpoint import CheckpointStore, FileCheckpointStore
5
+ from smythe.constrained_planner import ConstrainedArchitect, SubGraphTemplate
6
+ from smythe.graph import FailurePolicy
7
+ from smythe.loader import load_graph
8
+ from smythe.mcp import MCPConfigError, MCPServerSpec, MCPSkillProvider, MCPToolRuntime
9
+ from smythe.memory import PlannerMemory
10
+ from smythe.planner import ArchitectError, DeterministicArchitect, LLMArchitect, SimpleArchitect
11
+ from smythe.provider import (
12
+ AnthropicProvider,
13
+ CompletionResult,
14
+ GeminiProvider,
15
+ OfflineProvider,
16
+ OpenAIProvider,
17
+ Provider,
18
+ )
19
+ from smythe.router import WhiteRabbit
20
+ from smythe.skills import (
21
+ CapabilityHydrationMode,
22
+ CapabilityMapper,
23
+ DefaultCapabilityMapper,
24
+ SkillProvider,
25
+ SkillRef,
26
+ )
27
+ from smythe.swarm import Swarm, SwarmResult
28
+ from smythe.synthesizer import Synthesizer, SynthesisStrategy
29
+ from smythe.task import Task
30
+ from smythe.tools import (
31
+ ChatMessage,
32
+ ToolCall,
33
+ ToolLoopLimitError,
34
+ ToolResult,
35
+ ToolRuntime,
36
+ ToolSession,
37
+ ToolSpec,
38
+ )
39
+
40
+ __all__ = [
41
+ "AnthropicProvider",
42
+ "ArchitectError",
43
+ "CapabilityHydrationMode",
44
+ "CapabilityMapper",
45
+ "ChatMessage",
46
+ "CheckpointStore",
47
+ "CompletionResult",
48
+ "ConstrainedArchitect",
49
+ "DefaultCapabilityMapper",
50
+ "DeterministicArchitect",
51
+ "FailurePolicy",
52
+ "FileCheckpointStore",
53
+ "GeminiProvider",
54
+ "LLMArchitect",
55
+ "MCPConfigError",
56
+ "MCPServerSpec",
57
+ "MCPSkillProvider",
58
+ "MCPToolRuntime",
59
+ "OfflineProvider",
60
+ "OpenAIProvider",
61
+ "PlannerMemory",
62
+ "Provider",
63
+ "Sentinel",
64
+ "SentinelAlert",
65
+ "SimpleArchitect",
66
+ "SkillProvider",
67
+ "SkillRef",
68
+ "SubGraphTemplate",
69
+ "Swarm",
70
+ "SwarmResult",
71
+ "Synthesizer",
72
+ "SynthesisStrategy",
73
+ "Task",
74
+ "ToolCall",
75
+ "ToolLoopLimitError",
76
+ "ToolResult",
77
+ "ToolRuntime",
78
+ "ToolSession",
79
+ "ToolSpec",
80
+ "WhiteRabbit",
81
+ "load_graph",
82
+ ]
83
+ __version__ = "0.2.0"
smythe/agent.py ADDED
@@ -0,0 +1,47 @@
1
+ """Agent model — persistent identity with capabilities and memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any
7
+ from uuid import uuid4
8
+
9
+ if TYPE_CHECKING:
10
+ from smythe.mcp import MCPServerSpec
11
+
12
+
13
+ @dataclass
14
+ class AgentProfile:
15
+ """Static description of what an agent can do and how it behaves.
16
+
17
+ Attributes:
18
+ name: Human-readable agent name.
19
+ persona: System-prompt-level personality / role description.
20
+ capabilities: Tags describing areas of expertise (e.g. "research", "critique").
21
+ mcp_servers: MCP servers this agent may use as tool sources
22
+ (executed via MCPToolRuntime; see smythe.mcp).
23
+ """
24
+
25
+ name: str
26
+ persona: str = ""
27
+ capabilities: list[str] = field(default_factory=list)
28
+ mcp_servers: list[MCPServerSpec] = field(default_factory=list)
29
+
30
+
31
+ @dataclass
32
+ class Agent:
33
+ """A persistent agent instance tracked across executions.
34
+
35
+ Attributes:
36
+ id: Unique identifier (auto-generated).
37
+ profile: Static capability/persona description.
38
+ history: Append-only log of past execution summaries for learning.
39
+ """
40
+
41
+ profile: AgentProfile
42
+ id: str = field(default_factory=lambda: uuid4().hex[:12])
43
+ history: list[dict[str, Any]] = field(default_factory=list)
44
+
45
+ @property
46
+ def name(self) -> str:
47
+ return self.profile.name
@@ -0,0 +1,148 @@
1
+ """AsyncExecutor — concurrent DAG execution via asyncio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Callable
7
+
8
+ from smythe.budget import Sentinel
9
+ from smythe.executor_base import DEFAULT_MAX_TOOL_ITERATIONS, ExecutorBase
10
+ from smythe.graph import ExecutionGraph, FailurePolicy, Node, NodeStatus
11
+ from smythe.provider import Provider
12
+ from smythe.registry import Registry
13
+ from smythe.tools import ToolRuntime
14
+ from smythe.tracer import Tracer
15
+
16
+
17
+ class AsyncExecutor(ExecutorBase):
18
+ """Executes an assigned graph with maximum concurrency.
19
+
20
+ Independent nodes (those whose dependencies are all completed)
21
+ are launched in parallel via asyncio.gather(). The executor
22
+ runs in waves until every node has completed or failed.
23
+
24
+ Budget safety: before each wave, estimated cost is *reserved* for
25
+ every ready node so concurrent nodes cannot collectively overshoot
26
+ the budget. Reservations are reconciled with actual cost on
27
+ completion, or released on failure.
28
+
29
+ Concurrency safety: ``max_concurrency`` caps in-flight provider
30
+ calls across a wave, so a wide broadcast doesn't fire every call
31
+ at once and trip provider rate limits. None means unlimited.
32
+ """
33
+
34
+ DEFAULT_ESTIMATED_TOKENS = 2000
35
+
36
+ def __init__(
37
+ self,
38
+ provider: Provider,
39
+ registry: Registry,
40
+ tracer: Tracer,
41
+ budget: Sentinel | None = None,
42
+ estimated_tokens_per_node: int = DEFAULT_ESTIMATED_TOKENS,
43
+ max_concurrency: int | None = None,
44
+ on_node_update: Callable[[Node], None] | None = None,
45
+ tool_runtime: ToolRuntime | None = None,
46
+ max_tool_iterations: int = DEFAULT_MAX_TOOL_ITERATIONS,
47
+ ) -> None:
48
+ super().__init__(
49
+ provider=provider, registry=registry, tracer=tracer, budget=budget,
50
+ on_node_update=on_node_update, tool_runtime=tool_runtime,
51
+ max_tool_iterations=max_tool_iterations,
52
+ )
53
+ self._estimated_tokens_per_node = estimated_tokens_per_node
54
+ if max_concurrency is not None and max_concurrency < 1:
55
+ raise ValueError(f"max_concurrency must be >= 1, got {max_concurrency}")
56
+ self._max_concurrency = max_concurrency
57
+
58
+ async def run(self, graph: ExecutionGraph) -> ExecutionGraph:
59
+ """Execute every node, fanning out independent nodes concurrently."""
60
+ semaphore = (
61
+ asyncio.Semaphore(self._max_concurrency)
62
+ if self._max_concurrency is not None
63
+ else None
64
+ )
65
+
66
+ async def bounded(node: Node) -> None:
67
+ if semaphore is None:
68
+ await self._execute_node(node, graph)
69
+ else:
70
+ async with semaphore:
71
+ await self._execute_node(node, graph)
72
+
73
+ first_error: Exception | None = None
74
+ while True:
75
+ pending = [n for n in graph.nodes if n.status == NodeStatus.PENDING]
76
+ if not pending:
77
+ break
78
+ ready = [n for n in pending if graph.is_ready(n)]
79
+ if not ready:
80
+ if first_error is not None:
81
+ raise first_error
82
+ failed = [n for n in graph.nodes if n.status == NodeStatus.FAILED]
83
+ if failed:
84
+ raise RuntimeError(
85
+ f"Execution halted: {len(pending)} node(s) blocked by "
86
+ f"{len(failed)} failed upstream node(s)"
87
+ )
88
+ raise RuntimeError("Deadlock: pending nodes exist but none are ready")
89
+
90
+ if self._budget:
91
+ estimated_cost = (
92
+ self._estimated_tokens_per_node * self._budget.cost_per_token
93
+ )
94
+ reserved_ids: list[str] = []
95
+ try:
96
+ for n in ready:
97
+ self._budget.reserve(n.id, estimated_cost)
98
+ reserved_ids.append(n.id)
99
+ except Exception:
100
+ for rid in reserved_ids:
101
+ self._budget.release(rid)
102
+ raise
103
+
104
+ try:
105
+ await asyncio.gather(*(bounded(n) for n in ready))
106
+ except Exception as exc:
107
+ if first_error is None:
108
+ first_error = exc
109
+
110
+ if first_error is not None:
111
+ raise first_error
112
+ return graph
113
+
114
+ async def _execute_node(self, node: Node, graph: ExecutionGraph) -> None:
115
+ """Run a single node through the provider, respecting its failure policy."""
116
+ last_exc: Exception | None = None
117
+ attempts = 1 + max(node.max_retries, 0) if node.failure_policy == FailurePolicy.RETRY else 1
118
+
119
+ for attempt in range(attempts):
120
+ node.status = NodeStatus.RUNNING
121
+ self._tracer.on_node_start(node)
122
+
123
+ try:
124
+ # Cost recording happens inside acall_node (per provider call).
125
+ result = await self.acall_node(node, graph)
126
+ node.result = result.text
127
+ node.status = NodeStatus.COMPLETED
128
+ self._tracer.on_node_end(node)
129
+ self.notify_update(node)
130
+ return
131
+ except Exception as exc:
132
+ last_exc = exc
133
+ self._tracer.on_node_error(node, exc)
134
+ self._tracer.on_node_end(node)
135
+
136
+ if self._budget:
137
+ self._budget.release(node.id)
138
+
139
+ node.result = str(last_exc)
140
+
141
+ if node.failure_policy == FailurePolicy.SKIP:
142
+ node.status = NodeStatus.SKIPPED
143
+ self.notify_update(node)
144
+ return
145
+
146
+ node.status = NodeStatus.FAILED
147
+ self.notify_update(node)
148
+ raise last_exc # type: ignore[misc]
smythe/budget.py ADDED
@@ -0,0 +1,115 @@
1
+ """Sentinel — deterministic cost guardrails for execution.
2
+
3
+ The Sentinels patrol the boundaries. They enforce the budget.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from smythe.provider import CompletionResult
9
+
10
+
11
+ class SentinelAlert(Exception):
12
+ """Raised when cumulative execution cost would exceed the budget limit."""
13
+
14
+ def __init__(self, spent: float, limit: float, node_id: str) -> None:
15
+ self.spent = spent
16
+ self.limit = limit
17
+ self.node_id = node_id
18
+ super().__init__(
19
+ f"Budget exhausted before node {node_id!r}: "
20
+ f"${spent:.4f} spent of ${limit:.4f} limit"
21
+ )
22
+
23
+
24
+ class Sentinel:
25
+ """Accumulates token costs per node and enforces a USD spending cap.
26
+
27
+ Supports a reservation protocol for parallel execution: ``reserve()``
28
+ pre-commits estimated cost so concurrent nodes cannot collectively
29
+ exceed the budget. ``record()`` reconciles the reservation with the
30
+ actual cost once the node completes. ``release()`` frees a reservation
31
+ if the node fails before ``record()`` is called.
32
+
33
+ Attributes:
34
+ max_budget_usd: Hard cap in USD. None means unlimited.
35
+ cost_per_token: Blended $/token rate (default ~$3/1M tokens).
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ max_budget_usd: float | None = None,
41
+ cost_per_token: float = 0.000003,
42
+ ) -> None:
43
+ self.max_budget_usd = max_budget_usd
44
+ self.cost_per_token = cost_per_token
45
+ self._node_costs: dict[str, float] = {}
46
+ self._reservations: dict[str, float] = {}
47
+ self._spent: float = 0.0
48
+
49
+ @property
50
+ def total_cost_usd(self) -> float:
51
+ return self._spent
52
+
53
+ def check(self, node_id: str) -> None:
54
+ """Raise SentinelAlert if the budget is already exhausted.
55
+
56
+ Used by the serial executor where reservation is unnecessary.
57
+ """
58
+ if self.max_budget_usd is not None and self._spent >= self.max_budget_usd:
59
+ raise SentinelAlert(self._spent, self.max_budget_usd, node_id)
60
+
61
+ def reserve(self, node_id: str, estimated_cost: float) -> None:
62
+ """Pre-commit estimated cost before a node starts executing.
63
+
64
+ Raises SentinelAlert if the reservation would exceed the
65
+ budget. The reservation is held until ``record()`` replaces it
66
+ with actual cost, or ``release()`` cancels it on failure.
67
+ """
68
+ if self.max_budget_usd is not None:
69
+ if self._spent + estimated_cost > self.max_budget_usd:
70
+ raise SentinelAlert(self._spent, self.max_budget_usd, node_id)
71
+ self._reservations[node_id] = estimated_cost
72
+ self._spent += estimated_cost
73
+
74
+ def release(self, node_id: str) -> None:
75
+ """Cancel a reservation (e.g. on node failure before record)."""
76
+ reserved = self._reservations.pop(node_id, 0.0)
77
+ self._spent -= reserved
78
+
79
+ def record(self, node_id: str, result: CompletionResult) -> float:
80
+ """Record the actual cost, replacing any outstanding reservation.
81
+
82
+ Overwrites the node's cost — use add_cost() for multi-call
83
+ nodes (tool loops), where costs must accumulate.
84
+ """
85
+ reserved = self._reservations.pop(node_id, 0.0)
86
+ self._spent -= reserved
87
+ cost = result.total_tokens * self.cost_per_token
88
+ self._node_costs[node_id] = cost
89
+ self._spent += cost
90
+ return cost
91
+
92
+ def add_cost(self, node_id: str, result: CompletionResult) -> float:
93
+ """Accumulate cost for a node across multiple provider calls.
94
+
95
+ The first call for a node also releases any outstanding
96
+ reservation (the estimate is superseded by actuals). Returns
97
+ the node's cumulative cost.
98
+ """
99
+ reserved = self._reservations.pop(node_id, 0.0)
100
+ self._spent -= reserved
101
+ cost = result.total_tokens * self.cost_per_token
102
+ self._node_costs[node_id] = self._node_costs.get(node_id, 0.0) + cost
103
+ self._spent += cost
104
+ return self._node_costs[node_id]
105
+
106
+ def breakdown(self) -> dict[str, float]:
107
+ """Per-node cost map in USD."""
108
+ return dict(self._node_costs)
109
+
110
+ def restore(self, node_costs: dict[str, float]) -> None:
111
+ """Seed per-node costs from a checkpoint so a resumed execution
112
+ keeps counting against the same budget."""
113
+ for node_id, cost in node_costs.items():
114
+ self._node_costs[node_id] = cost
115
+ self._spent = sum(self._node_costs.values()) + sum(self._reservations.values())
smythe/checkpoint.py ADDED
@@ -0,0 +1,267 @@
1
+ """Checkpointing — durable, resumable execution state.
2
+
3
+ After each node reaches a terminal status, the Swarm persists the full
4
+ execution state (graph, node results, agents, budget consumed) through a
5
+ CheckpointStore. A crashed or interrupted execution can then be picked
6
+ up with ``swarm.resume(execution_id)``, re-running only the nodes that
7
+ never completed.
8
+
9
+ The state is a plain JSON document (version 1) so users can inspect or
10
+ repair checkpoints by hand. See docs/checkpoint-format.md for the full
11
+ schema.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import threading
20
+ import time
21
+ from abc import ABC, abstractmethod
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from smythe.agent import Agent, AgentProfile
26
+ from smythe.graph import ExecutionGraph, FailurePolicy, Node, NodeStatus, Topology
27
+ from smythe.registry import Registry
28
+ from smythe.task import Task
29
+
30
+ CHECKPOINT_VERSION = 1
31
+
32
+ _EXECUTION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
33
+
34
+
35
+ def _jsonable(value: Any) -> Any:
36
+ """Return *value* if JSON-serializable, otherwise its str() form."""
37
+ try:
38
+ json.dumps(value)
39
+ return value
40
+ except (TypeError, ValueError):
41
+ return str(value)
42
+
43
+
44
+ def node_to_dict(node: Node) -> dict[str, Any]:
45
+ return {
46
+ "id": node.id,
47
+ "label": node.label,
48
+ "agent_id": node.agent_id,
49
+ "depends_on": list(node.depends_on),
50
+ "result": _jsonable(node.result),
51
+ "status": node.status.value,
52
+ "metadata": {k: _jsonable(v) for k, v in node.metadata.items()},
53
+ "failure_policy": node.failure_policy.value,
54
+ "max_retries": node.max_retries,
55
+ "required_capabilities": list(node.required_capabilities),
56
+ "timeout_s": node.timeout_s,
57
+ "max_tool_iterations": node.max_tool_iterations,
58
+ }
59
+
60
+
61
+ def node_from_dict(data: dict[str, Any]) -> Node:
62
+ return Node(
63
+ id=data["id"],
64
+ label=data["label"],
65
+ agent_id=data.get("agent_id"),
66
+ depends_on=list(data.get("depends_on", [])),
67
+ result=data.get("result"),
68
+ status=NodeStatus(data.get("status", "pending")),
69
+ metadata=dict(data.get("metadata", {})),
70
+ failure_policy=FailurePolicy(data.get("failure_policy", "halt")),
71
+ max_retries=data.get("max_retries", 1),
72
+ required_capabilities=list(data.get("required_capabilities", [])),
73
+ timeout_s=data.get("timeout_s"),
74
+ max_tool_iterations=data.get("max_tool_iterations"),
75
+ )
76
+
77
+
78
+ def graph_to_dict(graph: ExecutionGraph) -> dict[str, Any]:
79
+ return {
80
+ "topology": [t.value for t in graph.topology],
81
+ "estimated_cost_usd": graph.estimated_cost_usd,
82
+ "nodes": [node_to_dict(n) for n in graph.nodes],
83
+ }
84
+
85
+
86
+ def graph_from_dict(data: dict[str, Any]) -> ExecutionGraph:
87
+ """Exact restore of a serialized graph, including statuses and results."""
88
+ return ExecutionGraph(
89
+ topology=[Topology(t) for t in data.get("topology", ["serial"])],
90
+ nodes=[node_from_dict(n) for n in data.get("nodes", [])],
91
+ estimated_cost_usd=data.get("estimated_cost_usd"),
92
+ )
93
+
94
+
95
+ def agents_to_list(registry: Registry) -> list[dict[str, Any]]:
96
+ out = []
97
+ for agent in registry.list_agents():
98
+ entry: dict[str, Any] = {
99
+ "id": agent.id,
100
+ "name": agent.profile.name,
101
+ "persona": agent.profile.persona,
102
+ "capabilities": list(agent.profile.capabilities),
103
+ }
104
+ if agent.profile.mcp_servers:
105
+ # env_passthrough stores variable NAMES only; secret values
106
+ # never touch the checkpoint (see smythe.mcp).
107
+ entry["mcp_servers"] = [s.to_dict() for s in agent.profile.mcp_servers]
108
+ out.append(entry)
109
+ return out
110
+
111
+
112
+ def agents_from_list(data: list[dict[str, Any]]) -> list[Agent]:
113
+ agents = []
114
+ for entry in data:
115
+ mcp_servers = []
116
+ if entry.get("mcp_servers"):
117
+ from smythe.mcp import MCPServerSpec
118
+
119
+ mcp_servers = [MCPServerSpec.from_dict(s) for s in entry["mcp_servers"]]
120
+ agents.append(Agent(
121
+ id=entry["id"],
122
+ profile=AgentProfile(
123
+ name=entry.get("name", entry["id"]),
124
+ persona=entry.get("persona", ""),
125
+ capabilities=list(entry.get("capabilities", [])),
126
+ mcp_servers=mcp_servers,
127
+ ),
128
+ ))
129
+ return agents
130
+
131
+
132
+ def task_to_dict(task: Task | None) -> dict[str, Any] | None:
133
+ if task is None:
134
+ return None
135
+ return {
136
+ "goal": task.goal,
137
+ "constraints": list(task.constraints),
138
+ "context": {k: _jsonable(v) for k, v in task.context.items()},
139
+ }
140
+
141
+
142
+ def task_from_dict(data: dict[str, Any] | None) -> Task | None:
143
+ if data is None:
144
+ return None
145
+ return Task(
146
+ goal=data["goal"],
147
+ constraints=list(data.get("constraints", [])),
148
+ context=dict(data.get("context", {})),
149
+ )
150
+
151
+
152
+ def reset_incomplete_nodes(graph: ExecutionGraph) -> list[str]:
153
+ """Reset RUNNING and FAILED nodes to PENDING so resume re-runs them.
154
+
155
+ COMPLETED and SKIPPED nodes keep their status and results.
156
+ Returns the IDs of the nodes that were reset.
157
+ """
158
+ reset: list[str] = []
159
+ for node in graph.nodes:
160
+ if node.status in (NodeStatus.RUNNING, NodeStatus.FAILED):
161
+ node.status = NodeStatus.PENDING
162
+ node.result = None
163
+ reset.append(node.id)
164
+ return reset
165
+
166
+
167
+ class CheckpointStore(ABC):
168
+ """Persistence interface for execution checkpoints.
169
+
170
+ Implementations must make ``save()`` atomic per execution_id: a
171
+ reader must never observe a partially written state.
172
+ """
173
+
174
+ @abstractmethod
175
+ def save(self, execution_id: str, state: dict[str, Any]) -> None:
176
+ """Persist the full state for an execution, replacing any prior state."""
177
+
178
+ @abstractmethod
179
+ def load(self, execution_id: str) -> dict[str, Any] | None:
180
+ """Return the last saved state, or None if the id is unknown."""
181
+
182
+ @abstractmethod
183
+ def delete(self, execution_id: str) -> None:
184
+ """Remove a checkpoint. Deleting an unknown id is a no-op."""
185
+
186
+ @abstractmethod
187
+ def list_ids(self) -> list[str]:
188
+ """Return all known execution ids, sorted."""
189
+
190
+
191
+ class FileCheckpointStore(CheckpointStore):
192
+ """Filesystem-backed store: one JSON file per execution.
193
+
194
+ Files live in ``~/.smythe/checkpoints/`` by default. Writes go to a
195
+ temp file and are moved into place with os.replace, so a crash
196
+ mid-write never corrupts the previous checkpoint.
197
+ """
198
+
199
+ def __init__(self, directory: str | Path | None = None) -> None:
200
+ self._dir = (
201
+ Path(directory)
202
+ if directory is not None
203
+ else Path.home() / ".smythe" / "checkpoints"
204
+ )
205
+ self._dir.mkdir(parents=True, exist_ok=True)
206
+ self._lock = threading.Lock()
207
+
208
+ def _path(self, execution_id: str) -> Path:
209
+ if not _EXECUTION_ID_RE.match(execution_id):
210
+ raise ValueError(
211
+ f"Invalid execution_id {execution_id!r}: must match "
212
+ f"{_EXECUTION_ID_RE.pattern}"
213
+ )
214
+ return self._dir / f"{execution_id}.json"
215
+
216
+ def save(self, execution_id: str, state: dict[str, Any]) -> None:
217
+ path = self._path(execution_id)
218
+ tmp = path.with_suffix(".json.tmp")
219
+ with self._lock:
220
+ tmp.write_text(json.dumps(state, indent=2), encoding="utf-8")
221
+ os.replace(tmp, path)
222
+
223
+ def load(self, execution_id: str) -> dict[str, Any] | None:
224
+ path = self._path(execution_id)
225
+ try:
226
+ return json.loads(path.read_text(encoding="utf-8"))
227
+ except FileNotFoundError:
228
+ return None
229
+
230
+ def delete(self, execution_id: str) -> None:
231
+ self._path(execution_id).unlink(missing_ok=True)
232
+
233
+ def list_ids(self) -> list[str]:
234
+ return sorted(p.stem for p in self._dir.glob("*.json"))
235
+
236
+
237
+ def build_state(
238
+ *,
239
+ execution_id: str,
240
+ status: str,
241
+ model: str,
242
+ graph: ExecutionGraph,
243
+ registry: Registry,
244
+ task: Task | None,
245
+ max_budget_usd: float | None,
246
+ node_costs: dict[str, float],
247
+ output: str | None = None,
248
+ created_at: float | None = None,
249
+ ) -> dict[str, Any]:
250
+ """Assemble a version-1 checkpoint state document."""
251
+ now = time.time()
252
+ return {
253
+ "version": CHECKPOINT_VERSION,
254
+ "execution_id": execution_id,
255
+ "status": status,
256
+ "created_at": created_at if created_at is not None else now,
257
+ "updated_at": now,
258
+ "model": model,
259
+ "task": task_to_dict(task),
260
+ "graph": graph_to_dict(graph),
261
+ "agents": agents_to_list(registry),
262
+ "budget": {
263
+ "max_budget_usd": max_budget_usd,
264
+ "node_costs": dict(node_costs),
265
+ },
266
+ "output": output,
267
+ }