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.
@@ -0,0 +1,151 @@
1
+ """Engine domain models: runs, tasks, plans, verdicts, the task graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from graphlib import CycleError, TopologicalSorter
6
+ from typing import Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
9
+
10
+ RunState = Literal[
11
+ "created",
12
+ "provisioning",
13
+ "decomposing",
14
+ "running",
15
+ "finalizing",
16
+ "completed",
17
+ "failed",
18
+ "cancelled",
19
+ ]
20
+
21
+ TaskState = Literal[
22
+ "pending",
23
+ "planning",
24
+ "executing",
25
+ "scrutinizing",
26
+ "verifying",
27
+ "validating",
28
+ "done",
29
+ "failed",
30
+ "skipped",
31
+ ]
32
+
33
+ TERMINAL_TASK_STATES: frozenset[str] = frozenset({"done", "failed", "skipped"})
34
+ RESUMABLE_RUN_STATES: frozenset[str] = frozenset(
35
+ {"created", "provisioning", "decomposing", "running", "finalizing", "failed"}
36
+ )
37
+
38
+ Phase = Literal["decompose", "plan", "execute", "scrutinize", "verify", "validate"]
39
+
40
+
41
+ class _Model(BaseModel):
42
+ model_config = ConfigDict(extra="forbid")
43
+
44
+
45
+ class TaskSpec(_Model):
46
+ """One unit of the decomposed outcome (agent-authored, engine-validated)."""
47
+
48
+ id: str
49
+ title: str
50
+ description: str = ""
51
+ depends_on: list[str] = Field(default_factory=list)
52
+ acceptance_criteria: list[str] = Field(default_factory=list)
53
+ verify_commands: list[str] = Field(default_factory=list)
54
+
55
+
56
+ class TaskGraph(_Model):
57
+ tasks: list[TaskSpec]
58
+
59
+ @model_validator(mode="after")
60
+ def _check_graph(self) -> TaskGraph:
61
+ if not self.tasks:
62
+ raise ValueError("task graph must contain at least one task")
63
+ ids = [t.id for t in self.tasks]
64
+ if len(set(ids)) != len(ids):
65
+ raise ValueError(f"duplicate task ids: {ids}")
66
+ known = set(ids)
67
+ for task in self.tasks:
68
+ unknown = [d for d in task.depends_on if d not in known]
69
+ if unknown:
70
+ raise ValueError(f"task {task.id} depends on unknown tasks: {unknown}")
71
+ if task.id in task.depends_on:
72
+ raise ValueError(f"task {task.id} depends on itself")
73
+ try:
74
+ self.topo_order()
75
+ except CycleError as exc:
76
+ raise ValueError(f"task graph contains a cycle: {exc.args}") from exc
77
+ return self
78
+
79
+ def topo_order(self) -> list[TaskSpec]:
80
+ """Dependency order; ties broken by authored order (depth, position)."""
81
+ by_id = {t.id: t for t in self.tasks}
82
+ # TopologicalSorter validates the cycle; the actual ordering is
83
+ # deterministic: dependency depth first, authored position second.
84
+ list(TopologicalSorter({t.id: set(t.depends_on) for t in self.tasks}).static_order())
85
+ position = {t.id: i for i, t in enumerate(self.tasks)}
86
+ return sorted(
87
+ self.tasks,
88
+ key=lambda t: (self._depth(t, by_id), position[t.id]),
89
+ )
90
+
91
+ def _depth(self, task: TaskSpec, by_id: dict[str, TaskSpec]) -> int:
92
+ if not task.depends_on:
93
+ return 0
94
+ return 1 + max(self._depth(by_id[d], by_id) for d in task.depends_on)
95
+
96
+
97
+ class PlanModel(_Model):
98
+ """The agent's plan for one task."""
99
+
100
+ steps: list[str]
101
+ expected_artifacts: list[str] = Field(default_factory=list)
102
+ verify_commands: list[str] = Field(default_factory=list)
103
+
104
+
105
+ class Issue(_Model):
106
+ severity: Literal["low", "medium", "high"] = "medium"
107
+ detail: str
108
+
109
+
110
+ class Verdict(_Model):
111
+ """Critic output: scrutinize uses pass/revise, validate uses accept/reject."""
112
+
113
+ verdict: Literal["pass", "revise", "accept", "reject"]
114
+ issues: list[Issue] = Field(default_factory=list)
115
+ feedback: str = ""
116
+
117
+
118
+ class TaskRecord(_Model):
119
+ """Persisted per-task state."""
120
+
121
+ spec: TaskSpec
122
+ state: TaskState = "pending"
123
+ revisions: int = 0
124
+ replans: int = 0
125
+ last_feedback: str = ""
126
+ session_id: str | None = None
127
+ plan: PlanModel | None = None
128
+
129
+ @property
130
+ def terminal(self) -> bool:
131
+ return self.state in TERMINAL_TASK_STATES
132
+
133
+
134
+ class RunRecord(_Model):
135
+ run_id: str
136
+ outcome: str
137
+ state: RunState
138
+ created_at: float
139
+ updated_at: float
140
+
141
+
142
+ class RunResult(_Model):
143
+ """What LoopEngine.start()/resume() returns to callers."""
144
+
145
+ run_id: str
146
+ state: RunState
147
+ tasks: list[TaskRecord] = Field(default_factory=list)
148
+
149
+ @property
150
+ def succeeded(self) -> bool:
151
+ return self.state == "completed"
@@ -0,0 +1,223 @@
1
+ """Phase handlers: each turns engine state into one or more worker jobs.
2
+
3
+ Session strategy per phase (a deliberate design decision):
4
+
5
+ - DECOMPOSE / PLAN / EXECUTE run as fresh agent sessions with full ("auto")
6
+ permissions — the microVM is the security boundary.
7
+ - SCRUTINIZE / VALIDATE run as **fresh sessions in the same agent sandbox
8
+ with read-only permissions**: a fresh session removes conversational
9
+ anchoring to the executor's claims, read-only permissions stop the critic
10
+ from "fixing" things itself, and reusing the sandbox preserves the
11
+ workspace state the critic must inspect (a fresh sandbox per critic would
12
+ cost minutes and lose it).
13
+ - VERIFY is mechanical — shell commands, no LLM, no opinions.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Literal, TypeVar
19
+
20
+ from pydantic import BaseModel, ValidationError
21
+
22
+ from sdxloop.config import Config
23
+ from sdxloop.engine.model import PlanModel, TaskGraph, TaskRecord, Verdict
24
+ from sdxloop.engine.prompts import bullet_list, render
25
+ from sdxloop.errors import WorkerError
26
+ from sdxloop.ids import new_job_id
27
+ from sdxloop.worker.client import WorkerClient
28
+ from sdxloop_worker.protocol import JobRequest, JobResult
29
+
30
+ EVIDENCE_COMMANDS: tuple[tuple[str, str], ...] = (
31
+ ("git status", "git status --short 2>&1 | head -50"),
32
+ ("git diff stat", "git diff --stat 2>&1 | head -50"),
33
+ ("recent files", "find . -type f -newer /tmp -not -path './.git/*' 2>/dev/null | head -30"),
34
+ )
35
+
36
+ OUTPUT_CLIP = 6_000
37
+
38
+ ModelT = TypeVar("ModelT", bound=BaseModel)
39
+
40
+
41
+ def clip(text: str | None, limit: int = OUTPUT_CLIP) -> str:
42
+ text = text or ""
43
+ if len(text) <= limit:
44
+ return text
45
+ return f"...(clipped)...\n{text[-limit:]}"
46
+
47
+
48
+ class PhaseRunner:
49
+ """Runs the six phases for one run against the agent sandbox's worker."""
50
+
51
+ def __init__(self, agent: WorkerClient, config: Config, run_id: str, outcome: str) -> None:
52
+ self.agent = agent
53
+ self.config = config
54
+ self.run_id = run_id
55
+ self.outcome = outcome
56
+
57
+ # -- job plumbing ------------------------------------------------------
58
+
59
+ def _agent_job(
60
+ self,
61
+ prompt: str,
62
+ *,
63
+ permission_mode: Literal["auto", "read_only"],
64
+ expect: Literal["text", "json"],
65
+ ) -> JobResult:
66
+ job = JobRequest(
67
+ job_id=new_job_id(),
68
+ run_id=self.run_id,
69
+ kind="agent.session",
70
+ prompt=prompt,
71
+ model=self.config.model,
72
+ permission_mode=permission_mode,
73
+ expect=expect,
74
+ timeout_s=self.config.budgets.per_job_timeout_s,
75
+ )
76
+ result = self.agent.submit(job)
77
+ if result.status != "ok":
78
+ assert result.error is not None
79
+ raise WorkerError(f"agent job failed ({result.error.type}): {result.error.message}")
80
+ return result
81
+
82
+ def _agent_json(
83
+ self,
84
+ model_cls: type[ModelT],
85
+ prompt_name: str,
86
+ context: dict[str, str],
87
+ *,
88
+ permission_mode: Literal["auto", "read_only"] = "auto",
89
+ ) -> ModelT:
90
+ """Run a JSON-expecting agent job; one retry with the validation error."""
91
+ retry_context = ""
92
+ last_error: Exception | None = None
93
+ for _ in range(2):
94
+ prompt = render(prompt_name, retry_context=retry_context, **context)
95
+ result = self._agent_job(prompt, permission_mode=permission_mode, expect="json")
96
+ try:
97
+ return model_cls.model_validate(result.output_json)
98
+ except ValidationError as exc:
99
+ last_error = exc
100
+ retry_context = (
101
+ "\n## Previous attempt was invalid\n\n"
102
+ "Your previous response failed validation with:\n\n"
103
+ f"```\n{exc}\n```\n\nFix the structure and respond again."
104
+ )
105
+ raise WorkerError(f"{prompt_name} produced invalid output twice: {last_error}")
106
+
107
+ def shell(self, command: str, *, cwd: str | None = None) -> JobResult:
108
+ job = JobRequest(
109
+ job_id=new_job_id(),
110
+ run_id=self.run_id,
111
+ kind="shell.check",
112
+ argv=["sh", "-c", command],
113
+ cwd=cwd,
114
+ timeout_s=self.config.budgets.per_job_timeout_s,
115
+ )
116
+ result = self.agent.submit(job)
117
+ if result.status != "ok":
118
+ assert result.error is not None
119
+ raise WorkerError(f"shell job failed ({result.error.type}): {result.error.message}")
120
+ return result
121
+
122
+ # -- phases ------------------------------------------------------------
123
+
124
+ def decompose(self) -> TaskGraph:
125
+ return self._agent_json(
126
+ TaskGraph,
127
+ "decompose",
128
+ {
129
+ "outcome": self.outcome,
130
+ "max_tasks": str(self.config.budgets.max_tasks),
131
+ },
132
+ )
133
+
134
+ def plan(self, task: TaskRecord) -> PlanModel:
135
+ return self._agent_json(
136
+ PlanModel,
137
+ "plan",
138
+ {
139
+ "outcome": self.outcome,
140
+ "task_id": task.spec.id,
141
+ "task_title": task.spec.title,
142
+ "task_description": task.spec.description or "(no further description)",
143
+ "acceptance_criteria": bullet_list(task.spec.acceptance_criteria),
144
+ "feedback": task.last_feedback or "(none — first attempt)",
145
+ },
146
+ )
147
+
148
+ def execute(self, task: TaskRecord, plan: PlanModel) -> JobResult:
149
+ prompt = render(
150
+ "execute",
151
+ outcome=self.outcome,
152
+ task_id=task.spec.id,
153
+ task_title=task.spec.title,
154
+ task_description=task.spec.description or "(no further description)",
155
+ plan_steps=bullet_list(plan.steps),
156
+ expected_artifacts=bullet_list(plan.expected_artifacts),
157
+ feedback=task.last_feedback or "(none — first attempt)",
158
+ )
159
+ return self._agent_job(prompt, permission_mode="auto", expect="text")
160
+
161
+ def scrutinize(self, task: TaskRecord, plan: PlanModel, executor_report: str) -> Verdict:
162
+ evidence_parts: list[str] = []
163
+ for label, command in EVIDENCE_COMMANDS:
164
+ try:
165
+ result = self.shell(command)
166
+ except WorkerError:
167
+ continue
168
+ output = clip(result.output_text, 1_500).strip()
169
+ if output:
170
+ evidence_parts.append(f"### {label}\n```\n{output}\n```")
171
+ verdict = self._agent_json(
172
+ Verdict,
173
+ "scrutinize",
174
+ {
175
+ "task_id": task.spec.id,
176
+ "task_title": task.spec.title,
177
+ "task_description": task.spec.description or "(no further description)",
178
+ "acceptance_criteria": bullet_list(task.spec.acceptance_criteria),
179
+ "plan_steps": bullet_list(plan.steps),
180
+ "executor_report": clip(executor_report) or "(executor produced no report)",
181
+ "evidence": "\n\n".join(evidence_parts) or "(no evidence gathered)",
182
+ },
183
+ permission_mode="read_only",
184
+ )
185
+ if verdict.verdict not in ("pass", "revise"):
186
+ raise WorkerError(f"scrutinize returned invalid verdict {verdict.verdict!r}")
187
+ return verdict
188
+
189
+ def verify(self, task: TaskRecord, plan: PlanModel) -> tuple[bool, str]:
190
+ """Run every verify command; returns (all_passed, failure_feedback)."""
191
+ commands = list(dict.fromkeys(task.spec.verify_commands + plan.verify_commands))
192
+ failures: list[str] = []
193
+ results: list[str] = []
194
+ for command in commands:
195
+ result = self.shell(command)
196
+ output = clip(result.output_text, 1_500)
197
+ results.append(f"$ {command}\n(exit {result.exit_code})\n{output}")
198
+ if result.exit_code != 0:
199
+ failures.append(
200
+ f"verify command failed: `{command}` (exit {result.exit_code})\n{output}"
201
+ )
202
+ self._last_verify_results = "\n\n".join(results) or "(no verify commands)"
203
+ return (not failures, "\n\n".join(failures))
204
+
205
+ _last_verify_results: str = "(verification not run)"
206
+
207
+ def validate(self, task: TaskRecord) -> Verdict:
208
+ verdict = self._agent_json(
209
+ Verdict,
210
+ "validate",
211
+ {
212
+ "outcome": self.outcome,
213
+ "task_id": task.spec.id,
214
+ "task_title": task.spec.title,
215
+ "task_description": task.spec.description or "(no further description)",
216
+ "acceptance_criteria": bullet_list(task.spec.acceptance_criteria),
217
+ "verify_results": self._last_verify_results,
218
+ },
219
+ permission_mode="read_only",
220
+ )
221
+ if verdict.verdict not in ("accept", "reject"):
222
+ raise WorkerError(f"validate returned invalid verdict {verdict.verdict!r}")
223
+ return verdict
@@ -0,0 +1,31 @@
1
+ """Prompt template loading and rendering.
2
+
3
+ Templates are Markdown files shipped in this package, rendered with
4
+ ``string.Template`` ($-substitution) so JSON braces in the templates need no
5
+ escaping. Rendering fails loudly on missing variables — a silently empty
6
+ prompt section is a debugging nightmare.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from functools import cache
12
+ from importlib import resources
13
+ from string import Template
14
+
15
+
16
+ @cache
17
+ def _template(name: str) -> Template:
18
+ text = (resources.files(__name__) / f"{name}.md").read_text()
19
+ return Template(text)
20
+
21
+
22
+ def render(name: str, **context: str) -> str:
23
+ """Render prompt template ``name`` with strict substitution."""
24
+ context.setdefault("retry_context", "")
25
+ return _template(name).substitute(context)
26
+
27
+
28
+ def bullet_list(items: list[str], empty: str = "(none)") -> str:
29
+ if not items:
30
+ return empty
31
+ return "\n".join(f"- {item}" for item in items)
@@ -0,0 +1,41 @@
1
+ # Decompose an outcome into a task graph
2
+
3
+ You are the planning stage of an automated engineering loop running inside an
4
+ isolated sandbox. Break the outcome below into a small dependency-ordered set
5
+ of concrete, independently verifiable tasks.
6
+
7
+ ## Outcome
8
+
9
+ $outcome
10
+
11
+ ## Rules
12
+
13
+ - At most $max_tasks tasks. Prefer fewer, larger, coherent tasks over many
14
+ fragments.
15
+ - Every task needs: a stable short `id` (t1, t2, ...), a `title`, a concrete
16
+ `description` of what to do and where, `depends_on` (ids of prerequisite
17
+ tasks, often empty), `acceptance_criteria` (specific, checkable statements),
18
+ and `verify_commands` (shell commands that exit 0 only when the task is
19
+ genuinely done — e.g. test runs, linters, greps; never `echo`).
20
+ - Tasks must form a DAG: no cycles, dependencies only on listed ids.
21
+ - Work happens in the current working directory of this sandbox.
22
+
23
+ ## Response format
24
+
25
+ Respond with exactly one fenced JSON block:
26
+
27
+ ```json
28
+ {
29
+ "tasks": [
30
+ {
31
+ "id": "t1",
32
+ "title": "...",
33
+ "description": "...",
34
+ "depends_on": [],
35
+ "acceptance_criteria": ["..."],
36
+ "verify_commands": ["..."]
37
+ }
38
+ ]
39
+ }
40
+ ```
41
+ $retry_context
@@ -0,0 +1,31 @@
1
+ # Execute one task
2
+
3
+ You are the execution stage of an automated engineering loop running with full
4
+ tool access inside an isolated sandbox. Complete the task below by actually
5
+ doing the work: create and edit files, run commands, and verify as you go.
6
+ Work in the current working directory.
7
+
8
+ ## Overall outcome
9
+
10
+ $outcome
11
+
12
+ ## Task $task_id: $task_title
13
+
14
+ $task_description
15
+
16
+ ## Plan
17
+
18
+ $plan_steps
19
+
20
+ Expected artifacts:
21
+ $expected_artifacts
22
+
23
+ ## Prior feedback to address
24
+
25
+ $feedback
26
+
27
+ ## When you are done
28
+
29
+ Finish with a short summary of what you changed, listing the files you
30
+ created or modified and any commands you ran to check your work. Do not
31
+ claim success for anything you did not actually verify.
@@ -0,0 +1,35 @@
1
+ # Plan one task
2
+
3
+ You are the planning stage of an automated engineering loop. Produce a
4
+ concrete execution plan for the task below. Do not execute anything yet.
5
+
6
+ ## Overall outcome
7
+
8
+ $outcome
9
+
10
+ ## Task $task_id: $task_title
11
+
12
+ $task_description
13
+
14
+ Acceptance criteria:
15
+ $acceptance_criteria
16
+
17
+ ## Prior feedback
18
+
19
+ $feedback
20
+
21
+ ## Response format
22
+
23
+ Respond with exactly one fenced JSON block:
24
+
25
+ ```json
26
+ {
27
+ "steps": ["specific action 1", "specific action 2"],
28
+ "expected_artifacts": ["files or outputs this task should produce"],
29
+ "verify_commands": ["shell commands that exit 0 only when the work is correct"]
30
+ }
31
+ ```
32
+
33
+ Steps must be specific enough that an executor with no other context can
34
+ follow them. Include the task's own verification ideas in `verify_commands`.
35
+ $retry_context
@@ -0,0 +1,43 @@
1
+ # Scrutinize completed work (read-only)
2
+
3
+ You are an independent, skeptical reviewer in an automated engineering loop.
4
+ You have read-only access: inspect files and run read-only commands, but do
5
+ not modify anything. Judge whether the executed work below actually satisfies
6
+ its task. Look for: incomplete implementations, unhandled edge cases, work
7
+ that was claimed but not done, and deviations from the plan.
8
+
9
+ ## Task $task_id: $task_title
10
+
11
+ $task_description
12
+
13
+ Acceptance criteria:
14
+ $acceptance_criteria
15
+
16
+ ## The plan that was supposed to be executed
17
+
18
+ $plan_steps
19
+
20
+ ## The executor's report
21
+
22
+ $executor_report
23
+
24
+ ## Workspace evidence
25
+
26
+ $evidence
27
+
28
+ ## Response format
29
+
30
+ Respond with exactly one fenced JSON block:
31
+
32
+ ```json
33
+ {
34
+ "verdict": "pass",
35
+ "issues": [{"severity": "high", "detail": "..."}],
36
+ "feedback": "actionable instructions for the executor if verdict is revise"
37
+ }
38
+ ```
39
+
40
+ `verdict` must be `"pass"` or `"revise"`. Verify claims yourself where
41
+ possible instead of trusting the report. Only demand revisions for real
42
+ problems that block the acceptance criteria — not stylistic preferences.
43
+ $retry_context
@@ -0,0 +1,38 @@
1
+ # Validate a task against its acceptance criteria (read-only)
2
+
3
+ You are the final acceptance gate in an automated engineering loop. You have
4
+ read-only access. The task's work has already been reviewed and its
5
+ verification commands have passed. Your job is different: judge whether the
6
+ result genuinely satisfies each acceptance criterion and serves the overall
7
+ outcome — not whether the code is pretty.
8
+
9
+ ## Overall outcome
10
+
11
+ $outcome
12
+
13
+ ## Task $task_id: $task_title
14
+
15
+ $task_description
16
+
17
+ Acceptance criteria:
18
+ $acceptance_criteria
19
+
20
+ ## Verification results
21
+
22
+ $verify_results
23
+
24
+ ## Response format
25
+
26
+ Respond with exactly one fenced JSON block:
27
+
28
+ ```json
29
+ {
30
+ "verdict": "accept",
31
+ "issues": [{"severity": "high", "detail": "which criterion fails and why"}],
32
+ "feedback": "if rejecting: what a new plan must do differently"
33
+ }
34
+ ```
35
+
36
+ `verdict` must be `"accept"` or `"reject"`. Reject only when an acceptance
37
+ criterion is genuinely unmet.
38
+ $retry_context