sdxloop 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.
sdxloop/config.py ADDED
@@ -0,0 +1,180 @@
1
+ """Configuration loading with layered precedence.
2
+
3
+ Precedence (highest wins): ``SDXLOOP_*`` environment variables >
4
+ ``./sdxloop.toml`` > ``pyproject.toml [tool.sdxloop]`` > built-in defaults.
5
+
6
+ Nested keys use ``__`` in environment variables, e.g.
7
+ ``SDXLOOP_BUDGETS__MAX_TASKS=5``. Values are parsed as TOML scalars where
8
+ possible (so ``true``, ``42``, ``1.5`` get real types) and fall back to
9
+ strings.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import tomllib
16
+ from collections.abc import Mapping
17
+ from pathlib import Path
18
+ from typing import Any, Literal
19
+
20
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
21
+
22
+ from sdxloop.errors import ConfigError
23
+
24
+ ENV_PREFIX = "SDXLOOP_"
25
+
26
+ # SDXLOOP_-prefixed variables consumed by the *worker process* rather than
27
+ # host configuration; the env config layer must not treat them as settings.
28
+ RESERVED_ENV_KEYS = frozenset({"worker_backend", "echo_script"})
29
+
30
+ WorkerTransport = Literal["stream", "poll"]
31
+ SecretStrategy = Literal["proxy", "plain-env"]
32
+
33
+
34
+ class _ConfigModel(BaseModel):
35
+ model_config = ConfigDict(extra="forbid")
36
+
37
+
38
+ class SandboxConfig(_ConfigModel):
39
+ template: str | None = None
40
+ workspace: Path | None = None
41
+ extra_allow_domains: list[str] = Field(default_factory=list)
42
+
43
+
44
+ class GithubConfig(_ConfigModel):
45
+ report_repo: str | None = None
46
+
47
+
48
+ class Budgets(_ConfigModel):
49
+ max_revisions_per_task: int = 2
50
+ max_replans_per_task: int = 1
51
+ max_tasks: int = 20
52
+ max_wall_clock_s: float = 7200.0
53
+ per_job_timeout_s: float = 900.0
54
+
55
+
56
+ class Config(_ConfigModel):
57
+ model: str = "auto"
58
+ app_name: str = "sdxloop"
59
+ state_dir: Path = Path(".sdxloop")
60
+ keep_sandboxes: bool = False
61
+ worker_transport: WorkerTransport = "stream"
62
+ secret_strategy: SecretStrategy = "proxy"
63
+ # Advanced: in-sandbox interpreter for the worker, and whether to run the
64
+ # install flow. Overridden in tests/e2e via SDXLOOP_WORKER_PYTHON /
65
+ # SDXLOOP_INSTALL_WORKERS.
66
+ worker_python: str = "/home/agent/.sdxloop/venv/bin/python"
67
+ install_workers: bool = True
68
+ sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
69
+ github: GithubConfig = Field(default_factory=GithubConfig)
70
+ budgets: Budgets = Field(default_factory=Budgets)
71
+
72
+
73
+ def _read_toml(path: Path) -> dict[str, Any]:
74
+ try:
75
+ with path.open("rb") as f:
76
+ return tomllib.load(f)
77
+ except tomllib.TOMLDecodeError as exc:
78
+ raise ConfigError(f"invalid TOML in {path}: {exc}") from exc
79
+
80
+
81
+ def _pyproject_layer(cwd: Path) -> dict[str, Any]:
82
+ path = cwd / "pyproject.toml"
83
+ if not path.is_file():
84
+ return {}
85
+ data = _read_toml(path)
86
+ tool = data.get("tool", {})
87
+ if not isinstance(tool, dict):
88
+ return {}
89
+ section = tool.get("sdxloop", {})
90
+ return section if isinstance(section, dict) else {}
91
+
92
+
93
+ def _sdxloop_toml_layer(cwd: Path) -> dict[str, Any]:
94
+ path = cwd / "sdxloop.toml"
95
+ if not path.is_file():
96
+ return {}
97
+ return _read_toml(path)
98
+
99
+
100
+ def _parse_env_value(raw: str) -> Any:
101
+ """Parse an env var as a TOML scalar; fall back to the raw string."""
102
+ try:
103
+ return tomllib.loads(f"v = {raw}")["v"]
104
+ except tomllib.TOMLDecodeError:
105
+ return raw
106
+
107
+
108
+ def _env_layer(env: Mapping[str, str]) -> dict[str, Any]:
109
+ layer: dict[str, Any] = {}
110
+ for key, raw in env.items():
111
+ if not key.startswith(ENV_PREFIX):
112
+ continue
113
+ path = key[len(ENV_PREFIX) :].lower().split("__")
114
+ if not all(path) or path[0] in RESERVED_ENV_KEYS:
115
+ continue
116
+ node = layer
117
+ for part in path[:-1]:
118
+ node = node.setdefault(part, {})
119
+ if not isinstance(node, dict):
120
+ raise ConfigError(f"conflicting env overrides at {key}")
121
+ node[path[-1]] = _parse_env_value(raw)
122
+ return layer
123
+
124
+
125
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
126
+ merged = dict(base)
127
+ for key, value in override.items():
128
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
129
+ merged[key] = _deep_merge(merged[key], value)
130
+ else:
131
+ merged[key] = value
132
+ return merged
133
+
134
+
135
+ def _flatten(data: dict[str, Any], prefix: str = "") -> dict[str, Any]:
136
+ flat: dict[str, Any] = {}
137
+ for key, value in data.items():
138
+ dotted = f"{prefix}{key}"
139
+ if isinstance(value, dict):
140
+ flat.update(_flatten(value, f"{dotted}."))
141
+ else:
142
+ flat[dotted] = value
143
+ return flat
144
+
145
+
146
+ def load_config_with_sources(
147
+ cwd: Path | None = None,
148
+ env: Mapping[str, str] | None = None,
149
+ ) -> tuple[Config, dict[str, str]]:
150
+ """Load config and report, per dotted key, which layer supplied it."""
151
+ cwd = cwd or Path.cwd()
152
+ env = os.environ if env is None else env
153
+
154
+ layers: list[tuple[str, dict[str, Any]]] = [
155
+ ("pyproject.toml", _pyproject_layer(cwd)),
156
+ ("sdxloop.toml", _sdxloop_toml_layer(cwd)),
157
+ ("env", _env_layer(env)),
158
+ ]
159
+
160
+ merged: dict[str, Any] = {}
161
+ sources: dict[str, str] = {}
162
+ for name, layer in layers:
163
+ merged = _deep_merge(merged, layer)
164
+ for dotted in _flatten(layer):
165
+ sources[dotted] = name
166
+
167
+ try:
168
+ config = Config.model_validate(merged)
169
+ except ValidationError as exc:
170
+ raise ConfigError(f"invalid sdxloop configuration: {exc}") from exc
171
+
172
+ for dotted in _flatten(config.model_dump()):
173
+ sources.setdefault(dotted, "default")
174
+ return config, sources
175
+
176
+
177
+ def load_config(cwd: Path | None = None, env: Mapping[str, str] | None = None) -> Config:
178
+ """Load the effective sdxloop configuration for ``cwd``."""
179
+ config, _ = load_config_with_sources(cwd=cwd, env=env)
180
+ return config
@@ -0,0 +1,26 @@
1
+ """The sdxloop loop engine."""
2
+
3
+ from sdxloop.engine.engine import LoopEngine, run_outcome
4
+ from sdxloop.engine.model import (
5
+ PlanModel,
6
+ RunRecord,
7
+ RunResult,
8
+ TaskGraph,
9
+ TaskRecord,
10
+ TaskSpec,
11
+ Verdict,
12
+ )
13
+ from sdxloop.engine.store import StateStore
14
+
15
+ __all__ = [
16
+ "LoopEngine",
17
+ "PlanModel",
18
+ "RunRecord",
19
+ "RunResult",
20
+ "StateStore",
21
+ "TaskGraph",
22
+ "TaskRecord",
23
+ "TaskSpec",
24
+ "Verdict",
25
+ "run_outcome",
26
+ ]
@@ -0,0 +1,380 @@
1
+ """LoopEngine: drives DECOMPOSE → (PLAN → EXECUTE → SCRUTINIZE → VERIFY →
2
+ VALIDATE)* under budgets, with SQLite checkpointing after every transition.
3
+
4
+ Failure semantics:
5
+
6
+ - Budget exhaustion (revisions/replans) fails the *task*; dependents are
7
+ skipped and the run continues, finishing ``failed`` if any task failed.
8
+ - Infrastructure errors (worker/sbx crashes) propagate after state is
9
+ persisted — equivalent to a kill. ``resume()`` re-provisions a fresh
10
+ sandbox pair (sandboxes are cattle; the workspace and SQLite state
11
+ persist on the host) and continues from the last committed transition:
12
+ a phase whose result was never committed re-runs from its start.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import time
19
+ from collections.abc import Callable, Sequence
20
+
21
+ from sdxloop.config import Config, load_config
22
+ from sdxloop.engine.model import (
23
+ RESUMABLE_RUN_STATES,
24
+ RunResult,
25
+ RunState,
26
+ TaskRecord,
27
+ TaskState,
28
+ )
29
+ from sdxloop.engine.phases import PhaseRunner, clip
30
+ from sdxloop.engine.store import StateStore
31
+ from sdxloop.errors import BudgetExceededError, SdxloopError, StateError
32
+ from sdxloop.events import EventBus, Hook, HostEventTypes
33
+ from sdxloop.gh.ops import GithubOps
34
+ from sdxloop.gh.reporter import GithubReporterHook
35
+ from sdxloop.ids import new_run_id
36
+ from sdxloop.sbx.cli import SbxCLI
37
+ from sdxloop.sbx.provision import Provisioner
38
+ from sdxloop.worker.client import WorkerClient
39
+
40
+
41
+ class LoopEngine:
42
+ def __init__(
43
+ self,
44
+ config: Config | None = None,
45
+ *,
46
+ store: StateStore | None = None,
47
+ bus: EventBus | None = None,
48
+ hooks: Sequence[Hook] = (),
49
+ sbx: SbxCLI | None = None,
50
+ worker_python: str | None = None,
51
+ install_workers: bool | None = None,
52
+ clock: Callable[[], float] = time.monotonic,
53
+ ) -> None:
54
+ self.config = config or load_config()
55
+ self.store = store or StateStore(self.config.state_dir / "state.db")
56
+ self.bus = bus or EventBus()
57
+ self.sbx = sbx or SbxCLI(app_name=self.config.app_name or None)
58
+ self.worker_python = (
59
+ worker_python if worker_python is not None else (self.config.worker_python)
60
+ )
61
+ self.install_workers = (
62
+ install_workers if install_workers is not None else self.config.install_workers
63
+ )
64
+ self.clock = clock
65
+ for hook in hooks:
66
+ self.bus.attach_hook(hook)
67
+ self.bus.subscribe(self._persist_event)
68
+
69
+ def _persist_event(self, event: object) -> None:
70
+ from sdxloop_worker.protocol import Event
71
+
72
+ assert isinstance(event, Event)
73
+ self.store.append_event(event)
74
+
75
+ # -- public API --------------------------------------------------------
76
+
77
+ def start(self, outcome: str, *, run_id: str | None = None) -> RunResult:
78
+ run_id = run_id or new_run_id()
79
+ self.store.create_run(run_id, outcome, self.config.model_dump_json())
80
+ self.bus.emit(HostEventTypes.RUN_START, run_id, outcome=outcome)
81
+ return self._drive(run_id, outcome)
82
+
83
+ def resume(self, run_id: str) -> RunResult:
84
+ run = self.store.get_run(run_id)
85
+ if run.state not in RESUMABLE_RUN_STATES:
86
+ raise StateError(f"run {run_id} is {run.state}; only unfinished runs can resume")
87
+ self.bus.emit(HostEventTypes.RUN_START, run_id, outcome=run.outcome, resumed=True)
88
+ return self._drive(run_id, run.outcome)
89
+
90
+ def cancel(self, run_id: str) -> None:
91
+ self.store.get_run(run_id) # raises for unknown runs
92
+ self.store.set_run_state(run_id, "cancelled")
93
+
94
+ # -- run driver --------------------------------------------------------
95
+
96
+ def _drive(self, run_id: str, outcome: str) -> RunResult:
97
+ deadline = self.clock() + self.config.budgets.max_wall_clock_s
98
+ self._set_run_state(run_id, "provisioning")
99
+ provisioner = Provisioner(self.sbx, self.config, self.bus)
100
+ pair = provisioner.ensure_pair(run_id)
101
+ try:
102
+ with pair:
103
+ agent = WorkerClient(
104
+ pair.agent,
105
+ self.bus,
106
+ transport=self.config.worker_transport,
107
+ python=self.worker_python,
108
+ )
109
+ github = WorkerClient(
110
+ pair.github,
111
+ self.bus,
112
+ transport=self.config.worker_transport,
113
+ python=self.worker_python,
114
+ )
115
+ if self.install_workers:
116
+ agent.install(extras="copilot")
117
+ github.install(extras="")
118
+ detach = self._attach_reporter(github, run_id)
119
+ try:
120
+ phases = PhaseRunner(agent, self.config, run_id, outcome)
121
+ state = self._run_phases(run_id, phases, deadline)
122
+ finally:
123
+ detach()
124
+ except SdxloopError:
125
+ # State is already persisted; the exception is the kill signal.
126
+ raise
127
+ self._set_run_state(run_id, state)
128
+ tasks = self.store.get_tasks(run_id)
129
+ self.bus.emit(HostEventTypes.RUN_END, run_id, state=state)
130
+ return RunResult(run_id=run_id, state=state, tasks=tasks)
131
+
132
+ def _attach_reporter(self, github: WorkerClient, run_id: str) -> Callable[[], None]:
133
+ repo = self.config.github.report_repo
134
+ if not repo:
135
+ return lambda: None
136
+ hook = GithubReporterHook(GithubOps(github, run_id), repo)
137
+ return self.bus.attach_hook(hook)
138
+
139
+ def _run_phases(self, run_id: str, phases: PhaseRunner, deadline: float) -> RunState:
140
+ tasks = self.store.get_tasks(run_id)
141
+ if not tasks:
142
+ self._set_run_state(run_id, "decomposing")
143
+ started = time.time()
144
+ graph = phases.decompose()
145
+ if len(graph.tasks) > self.config.budgets.max_tasks:
146
+ raise BudgetExceededError(
147
+ f"decomposition produced {len(graph.tasks)} tasks "
148
+ f"(max {self.config.budgets.max_tasks})"
149
+ )
150
+ ordered = graph.topo_order()
151
+ self.store.save_tasks(run_id, ordered)
152
+ self.store.record_phase(
153
+ run_id,
154
+ "decompose",
155
+ task_id=None,
156
+ attempt=1,
157
+ status="ok",
158
+ output_json=graph.model_dump_json(),
159
+ started_at=started,
160
+ )
161
+ tasks = self.store.get_tasks(run_id)
162
+
163
+ self._set_run_state(run_id, "running")
164
+ failed_ids: set[str] = {t.spec.id for t in tasks if t.state == "failed"}
165
+ skipped_ids: set[str] = {t.spec.id for t in tasks if t.state == "skipped"}
166
+ for task in tasks:
167
+ if task.terminal:
168
+ failed_ids |= {task.spec.id} if task.state == "failed" else set()
169
+ continue
170
+ blocked = [d for d in task.spec.depends_on if d in failed_ids | skipped_ids]
171
+ if blocked:
172
+ task.state = "skipped"
173
+ skipped_ids.add(task.spec.id)
174
+ self.store.update_task(run_id, task)
175
+ self._emit_task_end(run_id, task)
176
+ continue
177
+ self._run_task(run_id, phases, task, deadline)
178
+ if task.state == "failed":
179
+ failed_ids.add(task.spec.id)
180
+
181
+ self._set_run_state(run_id, "finalizing")
182
+ return "failed" if failed_ids or skipped_ids else "completed"
183
+
184
+ # -- task state machine ------------------------------------------------
185
+
186
+ def _run_task(
187
+ self,
188
+ run_id: str,
189
+ phases: PhaseRunner,
190
+ task: TaskRecord,
191
+ deadline: float,
192
+ ) -> None:
193
+ budgets = self.config.budgets
194
+ if task.state == "pending":
195
+ self._set_task_state(run_id, task, "planning")
196
+ self.bus.emit(
197
+ HostEventTypes.TASK_START, run_id, task_id=task.spec.id, title=task.spec.title
198
+ )
199
+ # Resume mapping: executing/scrutinizing restart at executing (the
200
+ # plan is persisted); a missing plan always restarts at planning.
201
+ if task.state in ("executing", "scrutinizing") and task.plan is None:
202
+ self._set_task_state(run_id, task, "planning")
203
+ if task.state in ("verifying", "validating") and task.plan is None:
204
+ self._set_task_state(run_id, task, "planning")
205
+ if task.state == "scrutinizing":
206
+ self._set_task_state(run_id, task, "executing")
207
+
208
+ while not task.terminal:
209
+ self._check_cancelled_and_clock(run_id, deadline)
210
+ if task.state == "planning":
211
+ self._phase_plan(run_id, phases, task)
212
+ elif task.state == "executing":
213
+ self._phase_execute_and_scrutinize(run_id, phases, task, budgets)
214
+ elif task.state == "verifying":
215
+ self._phase_verify(run_id, phases, task, budgets)
216
+ elif task.state == "validating":
217
+ self._phase_validate(run_id, phases, task, budgets)
218
+ else: # pragma: no cover - defensive
219
+ raise StateError(f"task {task.spec.id} in unexpected state {task.state}")
220
+
221
+ self._emit_task_end(run_id, task)
222
+
223
+ def _phase_plan(self, run_id: str, phases: PhaseRunner, task: TaskRecord) -> None:
224
+ started = time.time()
225
+ plan = phases.plan(task)
226
+ task.plan = plan
227
+ self.store.record_phase(
228
+ run_id,
229
+ "plan",
230
+ task_id=task.spec.id,
231
+ attempt=task.replans + 1,
232
+ status="ok",
233
+ output_json=plan.model_dump_json(),
234
+ started_at=started,
235
+ )
236
+ self._set_task_state(run_id, task, "executing")
237
+
238
+ def _phase_execute_and_scrutinize(
239
+ self,
240
+ run_id: str,
241
+ phases: PhaseRunner,
242
+ task: TaskRecord,
243
+ budgets: object,
244
+ ) -> None:
245
+ assert task.plan is not None
246
+ started = time.time()
247
+ result = phases.execute(task, task.plan)
248
+ task.session_id = result.session_id
249
+ executor_report = clip(result.output_text)
250
+ self.store.record_phase(
251
+ run_id,
252
+ "execute",
253
+ task_id=task.spec.id,
254
+ attempt=task.revisions + 1,
255
+ status="ok",
256
+ output_json=json.dumps({"report": executor_report, "session_id": result.session_id}),
257
+ started_at=started,
258
+ )
259
+ self._set_task_state(run_id, task, "scrutinizing")
260
+
261
+ started = time.time()
262
+ verdict = phases.scrutinize(task, task.plan, executor_report)
263
+ self.store.record_phase(
264
+ run_id,
265
+ "scrutinize",
266
+ task_id=task.spec.id,
267
+ attempt=task.revisions + 1,
268
+ status=verdict.verdict,
269
+ output_json=verdict.model_dump_json(),
270
+ started_at=started,
271
+ )
272
+ if verdict.verdict == "revise":
273
+ self._register_revision(run_id, task, verdict.feedback or "scrutiny found issues")
274
+ else:
275
+ task.last_feedback = ""
276
+ self._set_task_state(run_id, task, "verifying")
277
+
278
+ def _phase_verify(
279
+ self,
280
+ run_id: str,
281
+ phases: PhaseRunner,
282
+ task: TaskRecord,
283
+ budgets: object,
284
+ ) -> None:
285
+ assert task.plan is not None
286
+ started = time.time()
287
+ passed, feedback = phases.verify(task, task.plan)
288
+ self.store.record_phase(
289
+ run_id,
290
+ "verify",
291
+ task_id=task.spec.id,
292
+ attempt=task.revisions + 1,
293
+ status="ok" if passed else "failed",
294
+ output_json=json.dumps({"passed": passed, "feedback": clip(feedback)}),
295
+ started_at=started,
296
+ )
297
+ if passed:
298
+ self._set_task_state(run_id, task, "validating")
299
+ else:
300
+ self._register_revision(run_id, task, feedback)
301
+
302
+ def _phase_validate(
303
+ self,
304
+ run_id: str,
305
+ phases: PhaseRunner,
306
+ task: TaskRecord,
307
+ budgets: object,
308
+ ) -> None:
309
+ started = time.time()
310
+ verdict = phases.validate(task)
311
+ self.store.record_phase(
312
+ run_id,
313
+ "validate",
314
+ task_id=task.spec.id,
315
+ attempt=task.replans + 1,
316
+ status=verdict.verdict,
317
+ output_json=verdict.model_dump_json(),
318
+ started_at=started,
319
+ )
320
+ if verdict.verdict == "accept":
321
+ self._set_task_state(run_id, task, "done")
322
+ return
323
+ task.replans += 1
324
+ if task.replans > self.config.budgets.max_replans_per_task:
325
+ task.last_feedback = verdict.feedback
326
+ self._set_task_state(run_id, task, "failed")
327
+ return
328
+ task.last_feedback = verdict.feedback or "validation rejected the result"
329
+ task.plan = None
330
+ task.revisions = 0
331
+ self._set_task_state(run_id, task, "planning")
332
+
333
+ def _register_revision(self, run_id: str, task: TaskRecord, feedback: str) -> None:
334
+ task.revisions += 1
335
+ task.last_feedback = feedback
336
+ if task.revisions > self.config.budgets.max_revisions_per_task:
337
+ self._set_task_state(run_id, task, "failed")
338
+ else:
339
+ self._set_task_state(run_id, task, "executing")
340
+
341
+ # -- bookkeeping -------------------------------------------------------
342
+
343
+ def _check_cancelled_and_clock(self, run_id: str, deadline: float) -> None:
344
+ if self.store.get_run(run_id).state == "cancelled":
345
+ raise StateError(f"run {run_id} was cancelled")
346
+ if self.clock() > deadline:
347
+ self._set_run_state(run_id, "failed")
348
+ raise BudgetExceededError(
349
+ f"run {run_id} exceeded max_wall_clock_s={self.config.budgets.max_wall_clock_s}"
350
+ )
351
+
352
+ def _set_run_state(self, run_id: str, state: RunState) -> None:
353
+ self.store.set_run_state(run_id, state)
354
+ self.bus.emit(HostEventTypes.RUN_STATE, run_id, state=state)
355
+
356
+ def _set_task_state(self, run_id: str, task: TaskRecord, state: TaskState) -> None:
357
+ task.state = state
358
+ self.store.update_task(run_id, task)
359
+ self.bus.emit(
360
+ HostEventTypes.TASK_STATE,
361
+ run_id,
362
+ task_id=task.spec.id,
363
+ state=state,
364
+ revisions=task.revisions,
365
+ replans=task.replans,
366
+ )
367
+
368
+ def _emit_task_end(self, run_id: str, task: TaskRecord) -> None:
369
+ self.bus.emit(
370
+ HostEventTypes.TASK_END,
371
+ run_id,
372
+ task_id=task.spec.id,
373
+ title=task.spec.title,
374
+ state=task.state,
375
+ )
376
+
377
+
378
+ def run_outcome(outcome: str, config: Config | None = None) -> RunResult:
379
+ """Convenience one-shot API: run an outcome with default wiring."""
380
+ return LoopEngine(config or load_config()).start(outcome)