loopengt 0.1.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.
- loopengt/__init__.py +31 -0
- loopengt/adapters/__init__.py +1 -0
- loopengt/adapters/antigravity/__init__.py +1 -0
- loopengt/adapters/antigravity/adapter.py +55 -0
- loopengt/adapters/antigravity/commands.py +21 -0
- loopengt/adapters/base.py +51 -0
- loopengt/adapters/claude_code/__init__.py +1 -0
- loopengt/adapters/claude_code/adapter.py +55 -0
- loopengt/adapters/claude_code/commands.py +16 -0
- loopengt/adapters/codex/__init__.py +1 -0
- loopengt/adapters/codex/adapter.py +52 -0
- loopengt/adapters/codex/commands.py +16 -0
- loopengt/adapters/cursor/__init__.py +1 -0
- loopengt/adapters/cursor/adapter.py +56 -0
- loopengt/adapters/cursor/commands.py +29 -0
- loopengt/adapters/generic/__init__.py +1 -0
- loopengt/adapters/generic/terminal.py +82 -0
- loopengt/cli/__init__.py +1 -0
- loopengt/cli/commands/__init__.py +1 -0
- loopengt/cli/commands/design.py +171 -0
- loopengt/cli/commands/doctor.py +110 -0
- loopengt/cli/commands/eval.py +105 -0
- loopengt/cli/commands/init.py +131 -0
- loopengt/cli/commands/mcp_serve.py +57 -0
- loopengt/cli/commands/run.py +99 -0
- loopengt/cli/commands/template.py +145 -0
- loopengt/cli/commands/trace.py +114 -0
- loopengt/cli/formatters.py +125 -0
- loopengt/cli/main.py +66 -0
- loopengt/core/__init__.py +1 -0
- loopengt/core/evals/__init__.py +1 -0
- loopengt/core/evals/judges.py +216 -0
- loopengt/core/evals/metrics.py +119 -0
- loopengt/core/evals/regression.py +157 -0
- loopengt/core/memory/__init__.py +1 -0
- loopengt/core/memory/retrieval.py +124 -0
- loopengt/core/memory/store.py +184 -0
- loopengt/core/memory/summarizer.py +97 -0
- loopengt/core/models/__init__.py +43 -0
- loopengt/core/models/agent.py +126 -0
- loopengt/core/models/loop_spec.py +251 -0
- loopengt/core/models/policy.py +131 -0
- loopengt/core/models/state.py +271 -0
- loopengt/core/models/tool.py +105 -0
- loopengt/core/runtime/__init__.py +1 -0
- loopengt/core/runtime/checkpoint.py +152 -0
- loopengt/core/runtime/executor.py +463 -0
- loopengt/core/runtime/handoff.py +139 -0
- loopengt/core/runtime/scheduler.py +168 -0
- loopengt/core/tracing/__init__.py +1 -0
- loopengt/core/tracing/events.py +95 -0
- loopengt/core/tracing/exporters.py +158 -0
- loopengt/core/tracing/store.py +202 -0
- loopengt/mcp/__init__.py +1 -0
- loopengt/mcp/client/__init__.py +1 -0
- loopengt/mcp/client/manager.py +118 -0
- loopengt/mcp/client/tools.py +107 -0
- loopengt/mcp/server/__init__.py +1 -0
- loopengt/mcp/server/prompts.py +82 -0
- loopengt/mcp/server/resources.py +75 -0
- loopengt/mcp/server/server.py +50 -0
- loopengt/mcp/server/tools.py +214 -0
- loopengt/mcp/shared/__init__.py +1 -0
- loopengt/mcp/shared/schemas.py +91 -0
- loopengt/plugins/__init__.py +1 -0
- loopengt/plugins/base.py +90 -0
- loopengt/plugins/loader.py +130 -0
- loopengt/plugins/manifest.py +70 -0
- loopengt/plugins/registry.py +146 -0
- loopengt/prompts/LOOPENGT.md +60 -0
- loopengt/prompts/__init__.py +1 -0
- loopengt/storage/__init__.py +1 -0
- loopengt/storage/jsonl.py +84 -0
- loopengt/storage/sqlite.py +102 -0
- loopengt/templates/__init__.py +1 -0
- loopengt/templates/builtins/handoff_loop/LOOPENGS.md +10 -0
- loopengt/templates/builtins/planner_executor/LOOPENGS.md +29 -0
- loopengt/templates/builtins/research_architect/LOOPENGS.md +17 -0
- loopengt/templates/builtins/reviewer_retry/LOOPENGS.md +29 -0
- loopengt/templates/builtins/supervisor_workers/LOOPENGS.md +29 -0
- loopengt/templates/loader.py +38 -0
- loopengt/templates/registry.py +85 -0
- loopengt-0.1.0.dist-info/METADATA +275 -0
- loopengt-0.1.0.dist-info/RECORD +87 -0
- loopengt-0.1.0.dist-info/WHEEL +4 -0
- loopengt-0.1.0.dist-info/entry_points.txt +8 -0
- loopengt-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Runtime state models for loop execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RunStatus(StrEnum):
|
|
14
|
+
"""Overall status of a loop run."""
|
|
15
|
+
|
|
16
|
+
PENDING = "pending"
|
|
17
|
+
RUNNING = "running"
|
|
18
|
+
PAUSED = "paused"
|
|
19
|
+
COMPLETED = "completed"
|
|
20
|
+
FAILED = "failed"
|
|
21
|
+
CANCELLED = "cancelled"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StepStatus(StrEnum):
|
|
25
|
+
"""Status of a single step execution."""
|
|
26
|
+
|
|
27
|
+
PENDING = "pending"
|
|
28
|
+
RUNNING = "running"
|
|
29
|
+
COMPLETED = "completed"
|
|
30
|
+
FAILED = "failed"
|
|
31
|
+
SKIPPED = "skipped"
|
|
32
|
+
RETRYING = "retrying"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _utc_now() -> datetime:
|
|
36
|
+
"""Return the current UTC timestamp."""
|
|
37
|
+
return datetime.now(timezone.utc)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _new_id() -> str:
|
|
41
|
+
"""Generate a new unique run/step ID."""
|
|
42
|
+
return uuid.uuid4().hex[:12]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class StepResult(BaseModel):
|
|
46
|
+
"""The output produced by a single step execution."""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(frozen=True)
|
|
49
|
+
|
|
50
|
+
output: Any = Field(default=None, description="Step output data")
|
|
51
|
+
error: str | None = Field(default=None, description="Error message if failed")
|
|
52
|
+
duration_seconds: float = Field(
|
|
53
|
+
default=0.0, ge=0, description="Wall-clock execution time"
|
|
54
|
+
)
|
|
55
|
+
token_usage: dict[str, int] = Field(
|
|
56
|
+
default_factory=dict,
|
|
57
|
+
description="Token usage breakdown (prompt_tokens, completion_tokens, …)",
|
|
58
|
+
)
|
|
59
|
+
metadata: dict[str, Any] = Field(
|
|
60
|
+
default_factory=dict, description="Arbitrary result metadata"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class StepState(BaseModel):
|
|
65
|
+
"""Mutable runtime state for a single step."""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict()
|
|
68
|
+
|
|
69
|
+
step_name: str = Field(..., description="Name of the step")
|
|
70
|
+
status: StepStatus = Field(
|
|
71
|
+
default=StepStatus.PENDING, description="Current status"
|
|
72
|
+
)
|
|
73
|
+
attempts: int = Field(default=0, ge=0, description="Number of attempts so far")
|
|
74
|
+
result: StepResult | None = Field(
|
|
75
|
+
default=None, description="Most recent execution result"
|
|
76
|
+
)
|
|
77
|
+
started_at: datetime | None = Field(
|
|
78
|
+
default=None, description="When execution began"
|
|
79
|
+
)
|
|
80
|
+
completed_at: datetime | None = Field(
|
|
81
|
+
default=None, description="When execution finished"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def mark_running(self) -> None:
|
|
85
|
+
"""Transition to running status."""
|
|
86
|
+
self.status = StepStatus.RUNNING
|
|
87
|
+
self.attempts += 1
|
|
88
|
+
self.started_at = _utc_now()
|
|
89
|
+
self.completed_at = None
|
|
90
|
+
|
|
91
|
+
def mark_completed(self, result: StepResult) -> None:
|
|
92
|
+
"""Transition to completed status with the given result."""
|
|
93
|
+
self.status = StepStatus.COMPLETED
|
|
94
|
+
self.result = result
|
|
95
|
+
self.completed_at = _utc_now()
|
|
96
|
+
|
|
97
|
+
def mark_failed(self, error: str) -> None:
|
|
98
|
+
"""Transition to failed status."""
|
|
99
|
+
self.status = StepStatus.FAILED
|
|
100
|
+
self.result = StepResult(error=error)
|
|
101
|
+
self.completed_at = _utc_now()
|
|
102
|
+
|
|
103
|
+
def mark_retrying(self) -> None:
|
|
104
|
+
"""Transition to retrying status."""
|
|
105
|
+
self.status = StepStatus.RETRYING
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class AgentState(BaseModel):
|
|
109
|
+
"""Mutable runtime state for a single agent across the loop."""
|
|
110
|
+
|
|
111
|
+
model_config = ConfigDict()
|
|
112
|
+
|
|
113
|
+
agent_name: str = Field(..., description="Name of the agent")
|
|
114
|
+
invocations: int = Field(
|
|
115
|
+
default=0, ge=0, description="Total invocations in this run"
|
|
116
|
+
)
|
|
117
|
+
total_tokens: int = Field(
|
|
118
|
+
default=0, ge=0, description="Cumulative token usage"
|
|
119
|
+
)
|
|
120
|
+
errors: int = Field(
|
|
121
|
+
default=0, ge=0, description="Total errors encountered"
|
|
122
|
+
)
|
|
123
|
+
last_output: Any = Field(
|
|
124
|
+
default=None, description="Most recent output from this agent"
|
|
125
|
+
)
|
|
126
|
+
context: dict[str, Any] = Field(
|
|
127
|
+
default_factory=dict,
|
|
128
|
+
description="Agent-local context / scratchpad",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def record_invocation(
|
|
132
|
+
self, tokens: int = 0, *, error: bool = False
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Record one invocation, updating counters."""
|
|
135
|
+
self.invocations += 1
|
|
136
|
+
self.total_tokens += tokens
|
|
137
|
+
if error:
|
|
138
|
+
self.errors += 1
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class CheckpointData(BaseModel):
|
|
142
|
+
"""Serializable snapshot of loop state for persistence and resumption."""
|
|
143
|
+
|
|
144
|
+
model_config = ConfigDict(frozen=True)
|
|
145
|
+
|
|
146
|
+
checkpoint_id: str = Field(
|
|
147
|
+
default_factory=_new_id, description="Unique checkpoint ID"
|
|
148
|
+
)
|
|
149
|
+
run_id: str = Field(..., description="Parent run ID")
|
|
150
|
+
created_at: datetime = Field(
|
|
151
|
+
default_factory=_utc_now, description="Creation timestamp"
|
|
152
|
+
)
|
|
153
|
+
current_step_index: int = Field(
|
|
154
|
+
..., ge=0, description="Index of the step being executed"
|
|
155
|
+
)
|
|
156
|
+
step_states: list[StepState] = Field(
|
|
157
|
+
default_factory=list, description="Snapshot of all step states"
|
|
158
|
+
)
|
|
159
|
+
agent_states: list[AgentState] = Field(
|
|
160
|
+
default_factory=list, description="Snapshot of all agent states"
|
|
161
|
+
)
|
|
162
|
+
context: dict[str, Any] = Field(
|
|
163
|
+
default_factory=dict, description="Full loop context at checkpoint time"
|
|
164
|
+
)
|
|
165
|
+
history: list[dict[str, Any]] = Field(
|
|
166
|
+
default_factory=list,
|
|
167
|
+
description="Ordered history of completed step outputs",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class LoopState(BaseModel):
|
|
172
|
+
"""Mutable runtime state for an entire loop run.
|
|
173
|
+
|
|
174
|
+
Created by the executor when a run starts. Updated in-place as steps
|
|
175
|
+
execute. Can be checkpointed and restored for pause/resume.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
model_config = ConfigDict()
|
|
179
|
+
|
|
180
|
+
run_id: str = Field(
|
|
181
|
+
default_factory=_new_id, description="Unique run identifier"
|
|
182
|
+
)
|
|
183
|
+
loop_name: str = Field(..., description="Name of the loop spec being executed")
|
|
184
|
+
status: RunStatus = Field(
|
|
185
|
+
default=RunStatus.PENDING, description="Overall run status"
|
|
186
|
+
)
|
|
187
|
+
current_step_index: int = Field(
|
|
188
|
+
default=0, ge=0, description="Index of the step currently being executed"
|
|
189
|
+
)
|
|
190
|
+
turn: int = Field(
|
|
191
|
+
default=0, ge=0, description="Current turn number (across all agents)"
|
|
192
|
+
)
|
|
193
|
+
step_states: dict[str, StepState] = Field(
|
|
194
|
+
default_factory=dict,
|
|
195
|
+
description="Map of step name → StepState",
|
|
196
|
+
)
|
|
197
|
+
agent_states: dict[str, AgentState] = Field(
|
|
198
|
+
default_factory=dict,
|
|
199
|
+
description="Map of agent name → AgentState",
|
|
200
|
+
)
|
|
201
|
+
context: dict[str, Any] = Field(
|
|
202
|
+
default_factory=dict,
|
|
203
|
+
description="Shared mutable context accessible to all agents",
|
|
204
|
+
)
|
|
205
|
+
history: list[dict[str, Any]] = Field(
|
|
206
|
+
default_factory=list,
|
|
207
|
+
description="Ordered history of step results",
|
|
208
|
+
)
|
|
209
|
+
started_at: datetime = Field(
|
|
210
|
+
default_factory=_utc_now, description="Run start timestamp"
|
|
211
|
+
)
|
|
212
|
+
completed_at: datetime | None = Field(
|
|
213
|
+
default=None, description="Run completion timestamp"
|
|
214
|
+
)
|
|
215
|
+
error: str | None = Field(
|
|
216
|
+
default=None, description="Top-level error message if run failed"
|
|
217
|
+
)
|
|
218
|
+
checkpoints: list[str] = Field(
|
|
219
|
+
default_factory=list,
|
|
220
|
+
description="List of checkpoint IDs created during this run",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
# Lifecycle helpers
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def mark_running(self) -> None:
|
|
228
|
+
"""Transition to running status."""
|
|
229
|
+
self.status = RunStatus.RUNNING
|
|
230
|
+
|
|
231
|
+
def mark_completed(self) -> None:
|
|
232
|
+
"""Transition to completed status."""
|
|
233
|
+
self.status = RunStatus.COMPLETED
|
|
234
|
+
self.completed_at = _utc_now()
|
|
235
|
+
|
|
236
|
+
def mark_failed(self, error: str) -> None:
|
|
237
|
+
"""Transition to failed status with an error message."""
|
|
238
|
+
self.status = RunStatus.FAILED
|
|
239
|
+
self.error = error
|
|
240
|
+
self.completed_at = _utc_now()
|
|
241
|
+
|
|
242
|
+
def mark_cancelled(self) -> None:
|
|
243
|
+
"""Transition to cancelled status."""
|
|
244
|
+
self.status = RunStatus.CANCELLED
|
|
245
|
+
self.completed_at = _utc_now()
|
|
246
|
+
|
|
247
|
+
def increment_turn(self) -> None:
|
|
248
|
+
"""Advance the turn counter."""
|
|
249
|
+
self.turn += 1
|
|
250
|
+
|
|
251
|
+
def add_to_history(self, step_name: str, result: Any) -> None:
|
|
252
|
+
"""Append a step result to the history."""
|
|
253
|
+
self.history.append(
|
|
254
|
+
{
|
|
255
|
+
"step": step_name,
|
|
256
|
+
"turn": self.turn,
|
|
257
|
+
"result": result,
|
|
258
|
+
"timestamp": _utc_now().isoformat(),
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def to_checkpoint(self) -> CheckpointData:
|
|
263
|
+
"""Create a checkpoint snapshot of the current state."""
|
|
264
|
+
return CheckpointData(
|
|
265
|
+
run_id=self.run_id,
|
|
266
|
+
current_step_index=self.current_step_index,
|
|
267
|
+
step_states=list(self.step_states.values()),
|
|
268
|
+
agent_states=list(self.agent_states.values()),
|
|
269
|
+
context=self.context.copy(),
|
|
270
|
+
history=list(self.history),
|
|
271
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Tool and MCP capability definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ToolType(StrEnum):
|
|
12
|
+
"""Classification of tool integration type."""
|
|
13
|
+
|
|
14
|
+
MCP = "mcp"
|
|
15
|
+
FUNCTION = "function"
|
|
16
|
+
BUILTIN = "builtin"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ToolAuth(BaseModel):
|
|
20
|
+
"""Authentication configuration for a tool.
|
|
21
|
+
|
|
22
|
+
Supports API key, OAuth, and bearer token patterns. Secrets are
|
|
23
|
+
referenced by environment-variable name — never stored in plain text.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(frozen=True)
|
|
27
|
+
|
|
28
|
+
method: str = Field(
|
|
29
|
+
default="none",
|
|
30
|
+
description="Auth method: none, api_key, bearer, oauth",
|
|
31
|
+
)
|
|
32
|
+
env_var: str | None = Field(
|
|
33
|
+
default=None,
|
|
34
|
+
description="Environment variable holding the secret",
|
|
35
|
+
)
|
|
36
|
+
header_name: str = Field(
|
|
37
|
+
default="Authorization",
|
|
38
|
+
description="HTTP header used to pass the credential",
|
|
39
|
+
)
|
|
40
|
+
extra: dict[str, Any] = Field(
|
|
41
|
+
default_factory=dict,
|
|
42
|
+
description="Additional auth parameters",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ToolSpec(BaseModel):
|
|
47
|
+
"""Specification for a tool available to agents in a loop.
|
|
48
|
+
|
|
49
|
+
Tools can be MCP server tools, local Python functions, or built-in
|
|
50
|
+
framework utilities.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(frozen=True)
|
|
54
|
+
|
|
55
|
+
name: str = Field(
|
|
56
|
+
..., min_length=1, max_length=128, description="Unique tool identifier"
|
|
57
|
+
)
|
|
58
|
+
description: str = Field(
|
|
59
|
+
default="",
|
|
60
|
+
max_length=2048,
|
|
61
|
+
description="Human-readable description of what the tool does",
|
|
62
|
+
)
|
|
63
|
+
tool_type: ToolType = Field(
|
|
64
|
+
default=ToolType.FUNCTION,
|
|
65
|
+
description="Integration type: mcp, function, or builtin",
|
|
66
|
+
)
|
|
67
|
+
schema_def: dict[str, Any] = Field(
|
|
68
|
+
default_factory=dict,
|
|
69
|
+
description="JSON Schema for the tool's input parameters",
|
|
70
|
+
)
|
|
71
|
+
return_schema: dict[str, Any] | None = Field(
|
|
72
|
+
default=None,
|
|
73
|
+
description="JSON Schema for the tool's return value",
|
|
74
|
+
)
|
|
75
|
+
auth: ToolAuth = Field(
|
|
76
|
+
default_factory=ToolAuth,
|
|
77
|
+
description="Authentication configuration",
|
|
78
|
+
)
|
|
79
|
+
timeout_seconds: float = Field(
|
|
80
|
+
default=60.0, gt=0, description="Maximum execution time"
|
|
81
|
+
)
|
|
82
|
+
mcp_server: str | None = Field(
|
|
83
|
+
default=None,
|
|
84
|
+
description="MCP server name (required when tool_type is 'mcp')",
|
|
85
|
+
)
|
|
86
|
+
function_ref: str | None = Field(
|
|
87
|
+
default=None,
|
|
88
|
+
description="Dotted import path for function tools (e.g. 'mymod.utils.do_thing')",
|
|
89
|
+
)
|
|
90
|
+
metadata: dict[str, Any] = Field(
|
|
91
|
+
default_factory=dict,
|
|
92
|
+
description="Arbitrary key-value metadata",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def is_mcp(self) -> bool:
|
|
96
|
+
"""Return True if this is an MCP-backed tool."""
|
|
97
|
+
return self.tool_type == ToolType.MCP
|
|
98
|
+
|
|
99
|
+
def is_function(self) -> bool:
|
|
100
|
+
"""Return True if this is a local function tool."""
|
|
101
|
+
return self.tool_type == ToolType.FUNCTION
|
|
102
|
+
|
|
103
|
+
def is_builtin(self) -> bool:
|
|
104
|
+
"""Return True if this is a framework built-in tool."""
|
|
105
|
+
return self.tool_type == ToolType.BUILTIN
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Runtime engine for loop execution."""
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""State persistence via checkpoints for pause/resume support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
from loopengt.core.models.state import CheckpointData, LoopState
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CheckpointError(Exception):
|
|
17
|
+
"""Raised when a checkpoint operation fails."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CheckpointManager:
|
|
21
|
+
"""SQLite-backed (or file-backed) state persistence for loop resumption.
|
|
22
|
+
|
|
23
|
+
In the current implementation, checkpoints are stored as JSON files
|
|
24
|
+
under a runs directory. A future version will use SQLite for better
|
|
25
|
+
query and concurrency support.
|
|
26
|
+
|
|
27
|
+
Usage::
|
|
28
|
+
|
|
29
|
+
mgr = CheckpointManager(runs_dir=Path(".loopengt/runs"))
|
|
30
|
+
cp_id = await mgr.save(state)
|
|
31
|
+
restored = await mgr.load(run_id, cp_id)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, runs_dir: Path | None = None) -> None:
|
|
35
|
+
self._runs_dir = runs_dir or Path(".loopengt/runs")
|
|
36
|
+
self._runs_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
self._log = logger.bind(component="checkpoint")
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------------
|
|
40
|
+
# Public API
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
async def save(self, state: LoopState) -> str:
|
|
44
|
+
"""Create a checkpoint from the current loop state.
|
|
45
|
+
|
|
46
|
+
Returns the checkpoint ID.
|
|
47
|
+
"""
|
|
48
|
+
checkpoint = state.to_checkpoint()
|
|
49
|
+
state.checkpoints.append(checkpoint.checkpoint_id)
|
|
50
|
+
|
|
51
|
+
cp_path = self._checkpoint_path(state.run_id, checkpoint.checkpoint_id)
|
|
52
|
+
cp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
data = checkpoint.model_dump(mode="json")
|
|
55
|
+
cp_path.write_text(
|
|
56
|
+
json.dumps(data, indent=2, default=str), encoding="utf-8"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
self._log.info(
|
|
60
|
+
"checkpoint.saved",
|
|
61
|
+
run_id=state.run_id,
|
|
62
|
+
checkpoint_id=checkpoint.checkpoint_id,
|
|
63
|
+
)
|
|
64
|
+
return checkpoint.checkpoint_id
|
|
65
|
+
|
|
66
|
+
async def load(
|
|
67
|
+
self, run_id: str, checkpoint_id: str | None = None
|
|
68
|
+
) -> CheckpointData:
|
|
69
|
+
"""Load a checkpoint.
|
|
70
|
+
|
|
71
|
+
If *checkpoint_id* is ``None``, loads the latest checkpoint for
|
|
72
|
+
the given run.
|
|
73
|
+
"""
|
|
74
|
+
if checkpoint_id:
|
|
75
|
+
cp_path = self._checkpoint_path(run_id, checkpoint_id)
|
|
76
|
+
else:
|
|
77
|
+
cp_path = self._latest_checkpoint_path(run_id)
|
|
78
|
+
|
|
79
|
+
if not cp_path.exists():
|
|
80
|
+
raise CheckpointError(
|
|
81
|
+
f"Checkpoint not found: {cp_path}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
data = json.loads(cp_path.read_text(encoding="utf-8"))
|
|
85
|
+
checkpoint = CheckpointData.model_validate(data)
|
|
86
|
+
|
|
87
|
+
self._log.info(
|
|
88
|
+
"checkpoint.loaded",
|
|
89
|
+
run_id=run_id,
|
|
90
|
+
checkpoint_id=checkpoint.checkpoint_id,
|
|
91
|
+
)
|
|
92
|
+
return checkpoint
|
|
93
|
+
|
|
94
|
+
async def list_checkpoints(self, run_id: str) -> list[str]:
|
|
95
|
+
"""List checkpoint IDs for a given run."""
|
|
96
|
+
cp_dir = self._runs_dir / run_id / "checkpoints"
|
|
97
|
+
if not cp_dir.exists():
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
return sorted(
|
|
101
|
+
p.stem for p in cp_dir.glob("*.json")
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def delete(self, run_id: str, checkpoint_id: str) -> None:
|
|
105
|
+
"""Delete a specific checkpoint."""
|
|
106
|
+
cp_path = self._checkpoint_path(run_id, checkpoint_id)
|
|
107
|
+
if cp_path.exists():
|
|
108
|
+
cp_path.unlink()
|
|
109
|
+
self._log.info(
|
|
110
|
+
"checkpoint.deleted",
|
|
111
|
+
run_id=run_id,
|
|
112
|
+
checkpoint_id=checkpoint_id,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def restore_state(
|
|
116
|
+
self, checkpoint: CheckpointData, base_state: LoopState
|
|
117
|
+
) -> LoopState:
|
|
118
|
+
"""Apply checkpoint data to a LoopState, restoring it.
|
|
119
|
+
|
|
120
|
+
Returns the restored state (mutates *base_state* in-place).
|
|
121
|
+
"""
|
|
122
|
+
base_state.current_step_index = checkpoint.current_step_index
|
|
123
|
+
base_state.context = dict(checkpoint.context)
|
|
124
|
+
base_state.history = list(checkpoint.history)
|
|
125
|
+
|
|
126
|
+
for ss in checkpoint.step_states:
|
|
127
|
+
base_state.step_states[ss.step_name] = ss
|
|
128
|
+
|
|
129
|
+
for ags in checkpoint.agent_states:
|
|
130
|
+
base_state.agent_states[ags.agent_name] = ags
|
|
131
|
+
|
|
132
|
+
return base_state
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Internals
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _checkpoint_path(self, run_id: str, checkpoint_id: str) -> Path:
|
|
139
|
+
"""Return the file path for a specific checkpoint."""
|
|
140
|
+
return self._runs_dir / run_id / "checkpoints" / f"{checkpoint_id}.json"
|
|
141
|
+
|
|
142
|
+
def _latest_checkpoint_path(self, run_id: str) -> Path:
|
|
143
|
+
"""Return the path of the most recent checkpoint for a run."""
|
|
144
|
+
cp_dir = self._runs_dir / run_id / "checkpoints"
|
|
145
|
+
if not cp_dir.exists():
|
|
146
|
+
raise CheckpointError(f"No checkpoints for run {run_id}")
|
|
147
|
+
|
|
148
|
+
candidates = sorted(cp_dir.glob("*.json"), key=lambda p: p.stat().st_mtime)
|
|
149
|
+
if not candidates:
|
|
150
|
+
raise CheckpointError(f"No checkpoints for run {run_id}")
|
|
151
|
+
|
|
152
|
+
return candidates[-1]
|