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 +83 -0
- smythe/agent.py +47 -0
- smythe/async_executor.py +148 -0
- smythe/budget.py +115 -0
- smythe/checkpoint.py +267 -0
- smythe/constrained_planner.py +189 -0
- smythe/constrained_prompts.py +63 -0
- smythe/executor.py +98 -0
- smythe/executor_base.py +163 -0
- smythe/graph.py +329 -0
- smythe/loader.py +171 -0
- smythe/mcp.py +338 -0
- smythe/memory.py +144 -0
- smythe/openclaw_adapter.py +61 -0
- smythe/planner.py +171 -0
- smythe/prompts.py +167 -0
- smythe/provider.py +496 -0
- smythe/registry.py +164 -0
- smythe/router.py +101 -0
- smythe/skills.py +70 -0
- smythe/swarm.py +490 -0
- smythe/synthesizer.py +187 -0
- smythe/task.py +25 -0
- smythe/tools.py +166 -0
- smythe/tracer.py +105 -0
- smythe-0.2.0.dist-info/METADATA +529 -0
- smythe-0.2.0.dist-info/RECORD +29 -0
- smythe-0.2.0.dist-info/WHEEL +4 -0
- smythe-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
smythe/async_executor.py
ADDED
|
@@ -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
|
+
}
|