agentic-dev-loop 0.3.0__py3-none-any.whl → 1.0.0a2__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.
- agentic_dev_loop/actions.py +45 -2
- agentic_dev_loop/agents.py +77 -1
- agentic_dev_loop/cli.py +23 -1
- agentic_dev_loop/config.py +56 -2
- agentic_dev_loop/dryrun.py +122 -0
- agentic_dev_loop/extensions/aidlc.py +1 -1
- agentic_dev_loop/graph.py +24 -27
- agentic_dev_loop/isolation.py +723 -0
- agentic_dev_loop/loaders.py +25 -0
- agentic_dev_loop/outputs.py +1 -1
- agentic_dev_loop/proc.py +46 -0
- agentic_dev_loop/stages.py +45 -19
- agentic_dev_loop/viz.py +10 -3
- agentic_dev_loop-1.0.0a2.dist-info/METADATA +239 -0
- agentic_dev_loop-1.0.0a2.dist-info/RECORD +25 -0
- {agentic_dev_loop-0.3.0.dist-info → agentic_dev_loop-1.0.0a2.dist-info}/WHEEL +1 -1
- agentic_dev_loop-0.3.0.dist-info/METADATA +0 -318
- agentic_dev_loop-0.3.0.dist-info/RECORD +0 -23
- {agentic_dev_loop-0.3.0.dist-info → agentic_dev_loop-1.0.0a2.dist-info}/entry_points.txt +0 -0
- {agentic_dev_loop-0.3.0.dist-info → agentic_dev_loop-1.0.0a2.dist-info}/licenses/LICENSE +0 -0
- {agentic_dev_loop-0.3.0.dist-info → agentic_dev_loop-1.0.0a2.dist-info}/top_level.txt +0 -0
agentic_dev_loop/actions.py
CHANGED
|
@@ -30,6 +30,15 @@ def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
|
|
|
30
30
|
env=child_env())
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def _trusted_hooks_dir() -> str:
|
|
34
|
+
"""The agentic-dev-loop-managed hooks dir (Q3): repo hooks in ``.git/hooks``
|
|
35
|
+
never execute during the trusted commit action. Created empty; git runs
|
|
36
|
+
with ``-c core.hooksPath=<this dir>`` so there is no hook to run."""
|
|
37
|
+
d = Path("~/.agentic-dev-loop/state/hooks").expanduser()
|
|
38
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
return str(d)
|
|
40
|
+
|
|
41
|
+
|
|
33
42
|
def git_worktree_clean(path: Path) -> bool:
|
|
34
43
|
"""True only when `path` is a git repo with no uncommitted changes.
|
|
35
44
|
Outside a repo there is no clean baseline — False (fail-safe, keeps the
|
|
@@ -155,6 +164,28 @@ def kill_listeners_on_port(port: int, match: str | None = None,
|
|
|
155
164
|
shell=False, env=child_env())
|
|
156
165
|
|
|
157
166
|
|
|
167
|
+
def checkpoint_exclude_pathspec(state_dir: str | None, cwd: Path) -> str | None:
|
|
168
|
+
"""git pathspec excluding the live checkpoint DB from ``git add -A``.
|
|
169
|
+
|
|
170
|
+
The checkpointer keeps ``state_dir/loop-checkpoints.sqlite`` open for the
|
|
171
|
+
whole run; on Windows an open SQLite file blocks other readers, so
|
|
172
|
+
``git add`` fails to hash it (CI-red gate failure). When the state dir
|
|
173
|
+
sits inside the repo, exclude its sqlite files (db + wal/shm) from the
|
|
174
|
+
auto-commit — checkpoint state is runtime machinery, not constructed
|
|
175
|
+
work. Returns None when there is nothing in-repo to exclude (no state
|
|
176
|
+
dir known, or it lives outside the repo).
|
|
177
|
+
"""
|
|
178
|
+
if not state_dir:
|
|
179
|
+
return None
|
|
180
|
+
rel = os.path.relpath(state_dir, cwd)
|
|
181
|
+
if rel == ".." or rel.startswith(".." + os.sep):
|
|
182
|
+
return None
|
|
183
|
+
rel = rel.replace(os.sep, "/")
|
|
184
|
+
if rel == ".":
|
|
185
|
+
return ":(exclude)*.sqlite*"
|
|
186
|
+
return f":(exclude){rel}/*.sqlite*"
|
|
187
|
+
|
|
188
|
+
|
|
158
189
|
def commit(state: dict, params: dict) -> dict:
|
|
159
190
|
"""The pipeline's only commit point. Skipped loudly when the worktree was
|
|
160
191
|
dirty at run start, so the auto-commit can never swallow pre-existing
|
|
@@ -172,12 +203,24 @@ def commit(state: dict, params: dict) -> dict:
|
|
|
172
203
|
raise ValueError("commit action requires params.message "
|
|
173
204
|
"(a template rendered with {subject})")
|
|
174
205
|
cwd = Path.cwd()
|
|
175
|
-
|
|
206
|
+
hooks = _trusted_hooks_dir()
|
|
207
|
+
add_argv = ["-c", f"core.hooksPath={hooks}", "add", "-A"]
|
|
208
|
+
exclude = checkpoint_exclude_pathspec(state.get("state_dir"), cwd)
|
|
209
|
+
if exclude:
|
|
210
|
+
add_argv += ["--", exclude]
|
|
211
|
+
added = _git(add_argv, cwd=cwd)
|
|
212
|
+
if added.returncode != 0:
|
|
213
|
+
# The add is the step that touches the most files (a locked DB, a
|
|
214
|
+
# permission problem) — its error is the diagnostic that matters.
|
|
215
|
+
print(f"WARNING: [{subject}] auto-commit failed — git add: "
|
|
216
|
+
f"{added.stderr.strip()}", file=sys.stderr, flush=True)
|
|
217
|
+
return {}
|
|
176
218
|
if not _git(["status", "--porcelain"], cwd=cwd).stdout.strip():
|
|
177
219
|
print(f"[{subject}] Nothing to commit.", flush=True)
|
|
178
220
|
return {}
|
|
179
221
|
message = params["message"].format(subject=subject)
|
|
180
|
-
result = _git(["commit", "-m", message],
|
|
222
|
+
result = _git(["-c", f"core.hooksPath={hooks}", "commit", "-m", message],
|
|
223
|
+
cwd=cwd)
|
|
181
224
|
if result.returncode != 0:
|
|
182
225
|
# The work is built and verified — a git error is a loud warning,
|
|
183
226
|
# not a pipeline failure.
|
agentic_dev_loop/agents.py
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
"""Agent file loading + SDK factory mechanics.
|
|
2
2
|
|
|
3
3
|
The SDK's built-in tools are registered at import (its registry is global);
|
|
4
|
-
consumer tools are registered by extensions via `Registry.add_tool`.
|
|
4
|
+
consumer tools are registered by extensions via `Registry.add_tool`. Unit-3
|
|
5
|
+
adds the sandbox terminal seam: `SandboxTerminalExecutor` delegates each
|
|
6
|
+
terminal action to a `Sandbox.run`, and `register_sandbox_terminal` re-registers
|
|
7
|
+
the `terminal` tool name per run (KD-4 / §6)."""
|
|
5
8
|
|
|
6
9
|
from pathlib import Path
|
|
7
10
|
from typing import TYPE_CHECKING
|
|
8
11
|
|
|
9
12
|
from openhands.sdk import LLM
|
|
13
|
+
from openhands.sdk.tool import ToolExecutor
|
|
10
14
|
from openhands.tools.file_editor import FileEditorTool # noqa: F401 — registers "file_editor"
|
|
11
15
|
from openhands.tools.task_tracker import TaskTrackerTool # noqa: F401 — registers "task_tracker"
|
|
12
16
|
from openhands.tools.terminal import TerminalTool # noqa: F401 — registers "terminal"
|
|
13
17
|
|
|
18
|
+
from .isolation import SandboxExecutionRequest, command_argv, resolve_sandbox_env
|
|
19
|
+
|
|
14
20
|
if TYPE_CHECKING:
|
|
15
21
|
from openhands.sdk.agent.agent import Agent
|
|
16
22
|
|
|
23
|
+
from .isolation import Sandbox
|
|
24
|
+
|
|
17
25
|
|
|
18
26
|
def load_agent(agents_dir: Path | str, name: str, llm: LLM) -> "Agent":
|
|
19
27
|
"""Load `<agents_dir>/<name>.agent.md` and build the Agent via the SDK
|
|
@@ -47,3 +55,71 @@ def load_agent(agents_dir: Path | str, name: str, llm: LLM) -> "Agent":
|
|
|
47
55
|
f"Agent file {path} name {agent_def.name!r} does not match "
|
|
48
56
|
f"filename {stem!r}")
|
|
49
57
|
return agent_definition_to_factory(agent_def)(llm)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SandboxTerminalExecutor(ToolExecutor):
|
|
61
|
+
"""A stateless terminal executor: each action is a fresh ``Sandbox.run``
|
|
62
|
+
(no persistent shell session). ``is_input`` degrades to an unsupported
|
|
63
|
+
observation; ``reset`` is a no-op (there is no session to reset)."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, sandbox: "Sandbox", *, workspace: str = "",
|
|
66
|
+
env: dict[str, str] | None = None,
|
|
67
|
+
pipeline: str | None = None, step: str | None = None):
|
|
68
|
+
self._sandbox = sandbox
|
|
69
|
+
self._workspace = workspace
|
|
70
|
+
self._env = env or {}
|
|
71
|
+
self._pipeline = pipeline
|
|
72
|
+
self._step = step
|
|
73
|
+
|
|
74
|
+
def __call__(self, action, conversation=None):
|
|
75
|
+
from openhands.tools.terminal import TerminalObservation
|
|
76
|
+
from openhands.tools.terminal.metadata import CmdOutputMetadata
|
|
77
|
+
|
|
78
|
+
if action.reset:
|
|
79
|
+
return TerminalObservation.from_text(
|
|
80
|
+
"[reset unsupported — stateless sandbox]",
|
|
81
|
+
command=action.command, exit_code=None, timeout=False,
|
|
82
|
+
metadata=CmdOutputMetadata(exit_code=-1))
|
|
83
|
+
|
|
84
|
+
if action.is_input:
|
|
85
|
+
return TerminalObservation.from_text(
|
|
86
|
+
"[input unsupported — stateless sandbox]",
|
|
87
|
+
command=action.command, exit_code=None, timeout=False,
|
|
88
|
+
metadata=CmdOutputMetadata(exit_code=-1))
|
|
89
|
+
|
|
90
|
+
request = SandboxExecutionRequest(
|
|
91
|
+
argv=command_argv(action.command), cwd=self._workspace,
|
|
92
|
+
env=self._env, timeout=action.timeout,
|
|
93
|
+
pipeline=self._pipeline, step=self._step)
|
|
94
|
+
result = self._sandbox.run(request)
|
|
95
|
+
output = result.stdout + result.stderr
|
|
96
|
+
return TerminalObservation.from_text(
|
|
97
|
+
output, is_error=not result.succeeded and not result.is_timeout,
|
|
98
|
+
command=action.command, exit_code=result.exit_code,
|
|
99
|
+
timeout=result.timed_out,
|
|
100
|
+
metadata=CmdOutputMetadata(
|
|
101
|
+
exit_code=result.exit_code if result.exit_code is not None else -1,
|
|
102
|
+
working_dir=self._workspace))
|
|
103
|
+
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
pass # the sandbox is closed by Runtime; idempotent
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None] | None = None,
|
|
109
|
+
workspace: str = "", pipeline: str | None = None,
|
|
110
|
+
step: str | None = None) -> None:
|
|
111
|
+
"""Per-run re-registration of the `terminal` name (KD-4): a callable
|
|
112
|
+
factory that builds the SDK terminal with a `SandboxTerminalExecutor`.
|
|
113
|
+
Pipelines are sequential, so re-registering per agent step is safe."""
|
|
114
|
+
from openhands.sdk.tool import register_tool
|
|
115
|
+
|
|
116
|
+
resolved_env = resolve_sandbox_env(env)
|
|
117
|
+
|
|
118
|
+
def factory(conv_state, **params):
|
|
119
|
+
return TerminalTool.create(
|
|
120
|
+
conv_state,
|
|
121
|
+
executor=SandboxTerminalExecutor(
|
|
122
|
+
sandbox, workspace=workspace, env=resolved_env,
|
|
123
|
+
pipeline=pipeline, step=step))
|
|
124
|
+
|
|
125
|
+
register_tool("terminal", factory)
|
agentic_dev_loop/cli.py
CHANGED
|
@@ -10,7 +10,8 @@ import sys
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
from .config import load_config
|
|
13
|
-
from .
|
|
13
|
+
from .dryrun import dry_run
|
|
14
|
+
from .isolation import clean_stamps, isolation_state_dir
|
|
14
15
|
from .registry import default_registry, load_extensions
|
|
15
16
|
|
|
16
17
|
|
|
@@ -49,6 +50,15 @@ def _core_parser() -> argparse.ArgumentParser:
|
|
|
49
50
|
help="preferred port for the live graph view "
|
|
50
51
|
"(default: 8321; falls back to a free port when "
|
|
51
52
|
"busy)")
|
|
53
|
+
parser.add_argument("--refresh", action="store_true",
|
|
54
|
+
help="force re-provision of every setup: hook "
|
|
55
|
+
"(bypasses the stamp cache)")
|
|
56
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
57
|
+
help="validate the pipeline and print a summary plan "
|
|
58
|
+
"without executing anything (no LLM, no "
|
|
59
|
+
"sandbox, no state writes)")
|
|
60
|
+
parser.add_argument("--clean-stamps", action="store_true",
|
|
61
|
+
help="delete cached provision stamps and exit")
|
|
52
62
|
_add_version_flag(parser)
|
|
53
63
|
return parser
|
|
54
64
|
|
|
@@ -75,6 +85,18 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
75
85
|
_core_parser().parse_args(argv)
|
|
76
86
|
return 2 # unreachable — parse_args exits first
|
|
77
87
|
args = build_parser(known.pipeline).parse_args(argv)
|
|
88
|
+
if args.clean_stamps:
|
|
89
|
+
cfg = load_config(args.pipeline)
|
|
90
|
+
state = isolation_state_dir(cfg)
|
|
91
|
+
removed = clean_stamps(state)
|
|
92
|
+
print(f"Removed {removed} cached provision stamp(s) from "
|
|
93
|
+
f"{state / 'stamps'}", flush=True)
|
|
94
|
+
return 0
|
|
95
|
+
if args.dry_run:
|
|
96
|
+
return dry_run(args.pipeline, args)
|
|
97
|
+
# The run path alone needs the heavy graph/SDK imports — defer so a
|
|
98
|
+
# dry run, --clean-stamps, and --help stay side-effect free.
|
|
99
|
+
from .graph import run_pipeline
|
|
78
100
|
success, _attempts = run_pipeline(args.pipeline, args)
|
|
79
101
|
if not success:
|
|
80
102
|
print("agentic-dev-loop: pipeline failed — exiting with code 1",
|
agentic_dev_loop/config.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
"""Pipeline YAML schema and loader. Strict: unknown keys fail at every level."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
3
7
|
from pathlib import Path
|
|
4
8
|
from typing import Any
|
|
5
9
|
|
|
@@ -32,6 +36,36 @@ def route_target(route: RouteTarget | None) -> str | None:
|
|
|
32
36
|
return route.goto if isinstance(route, Route) else route
|
|
33
37
|
|
|
34
38
|
|
|
39
|
+
class IsolationConfig(BaseModel):
|
|
40
|
+
"""Optional `isolation:` section — configures the always-on sandbox (BR-2).
|
|
41
|
+
Absence means opinionated defaults, never unsandboxed behaviour. Unknown
|
|
42
|
+
keys fail at load (BR-5). `state_dir` is HOST-ONLY (stamps + audit) and is
|
|
43
|
+
never mounted into the sandbox (Q1)."""
|
|
44
|
+
|
|
45
|
+
model_config = ConfigDict(extra="forbid")
|
|
46
|
+
|
|
47
|
+
image: str | None = None # per-pipeline override (KD-2 ref rules)
|
|
48
|
+
refresh: bool = False # force re-provision (NFR-5)
|
|
49
|
+
memory: str | None = None # override exec container memory
|
|
50
|
+
cpus: float | None = None # override exec container cpus
|
|
51
|
+
allow_git_mutations: bool = False # BR-6 knob
|
|
52
|
+
state_dir: str = "~/.agentic-dev-loop/state" # stamps + audit (host-only)
|
|
53
|
+
|
|
54
|
+
@field_validator("memory")
|
|
55
|
+
@classmethod
|
|
56
|
+
def _check_memory(cls, v: str | None) -> str | None:
|
|
57
|
+
if v is not None and re.fullmatch(r"\d+[kmg]", v) is None:
|
|
58
|
+
raise ValueError(f"memory {v!r} must match ^\\d+[kmg]$")
|
|
59
|
+
return v
|
|
60
|
+
|
|
61
|
+
@field_validator("cpus")
|
|
62
|
+
@classmethod
|
|
63
|
+
def _check_cpus(cls, v: float | None) -> float | None:
|
|
64
|
+
if v is not None and v <= 0:
|
|
65
|
+
raise ValueError("cpus must be > 0")
|
|
66
|
+
return v
|
|
67
|
+
|
|
68
|
+
|
|
35
69
|
class StepConfig(BaseModel):
|
|
36
70
|
"""One pipeline step. Its type IS the key it carries: exactly one of
|
|
37
71
|
`agent`, `action`, or `command` — empty values do not count, anything
|
|
@@ -49,6 +83,8 @@ class StepConfig(BaseModel):
|
|
|
49
83
|
verdicts: VerdictSpec | None = None
|
|
50
84
|
max_iterations: int | None = None
|
|
51
85
|
timeout: float | None = Field(default=None, gt=0) # seconds, command steps only
|
|
86
|
+
env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
|
|
87
|
+
setup: str | None = None # BR-13: command steps only
|
|
52
88
|
on_pass: RouteTarget | None = None
|
|
53
89
|
on_fail: RouteTarget | None = None
|
|
54
90
|
|
|
@@ -102,6 +138,10 @@ class StepConfig(BaseModel):
|
|
|
102
138
|
raise ValueError(
|
|
103
139
|
f"step {self.name!r}: 'timeout' requires 'command'"
|
|
104
140
|
)
|
|
141
|
+
if self.setup is not None and self.command is None:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"step {self.name!r}: 'setup' is only allowed on 'command' steps"
|
|
144
|
+
)
|
|
105
145
|
return self
|
|
106
146
|
|
|
107
147
|
|
|
@@ -122,7 +162,8 @@ class PipelineConfig(BaseModel):
|
|
|
122
162
|
additional_files: list[str] = []
|
|
123
163
|
env_allowlist: list[str] = []
|
|
124
164
|
llm: LlmConfig
|
|
125
|
-
|
|
165
|
+
isolation: IsolationConfig | None = None # None = always-on defaults
|
|
166
|
+
state_dir: str = ".pr" # default relocated to ~/.agentic-dev-loop/runs/<hash>/ at load
|
|
126
167
|
max_attempts: int = 3
|
|
127
168
|
agents_dir: str
|
|
128
169
|
steps: list[StepConfig] = Field(min_length=1)
|
|
@@ -147,10 +188,23 @@ class PipelineConfig(BaseModel):
|
|
|
147
188
|
return self
|
|
148
189
|
|
|
149
190
|
|
|
191
|
+
def default_state_dir(pipeline_path: Path) -> str:
|
|
192
|
+
"""The new run-state default (Q1): checkpoint DB + last-error.md live OUTSIDE
|
|
193
|
+
the workspace mount, under ~/.agentic-dev-loop/runs/<pipeline-hash>/ where
|
|
194
|
+
pipeline-hash is the first 12 chars of the sha256 of the absolute
|
|
195
|
+
pipeline-file path. Explicit `state_dir:` configs keep working unchanged."""
|
|
196
|
+
digest = hashlib.sha256(
|
|
197
|
+
str(Path(pipeline_path).resolve()).encode("utf-8")).hexdigest()[:12]
|
|
198
|
+
return str(Path("~/.agentic-dev-loop/runs").expanduser() / digest)
|
|
199
|
+
|
|
200
|
+
|
|
150
201
|
def load_config(path: Path) -> PipelineConfig:
|
|
151
|
-
|
|
202
|
+
path = Path(path)
|
|
203
|
+
data = yaml.safe_load(path.read_text())
|
|
152
204
|
if not isinstance(data, dict):
|
|
153
205
|
raise ValueError(
|
|
154
206
|
f"pipeline config must be a mapping, got {type(data).__name__}: {path}"
|
|
155
207
|
)
|
|
208
|
+
if "state_dir" not in data:
|
|
209
|
+
data["state_dir"] = default_state_dir(path)
|
|
156
210
|
return PipelineConfig.model_validate(data)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""`--dry-run`: the full load-and-validate pass with zero execution.
|
|
2
|
+
|
|
3
|
+
Everything short of running a step happens here — YAML parse, step schema
|
|
4
|
+
validation, extension loading (document loaders included), agents_dir /
|
|
5
|
+
verdict / routing-target resolution, isolation config validation, and pure
|
|
6
|
+
image-ref resolution (no Docker calls). No LLM is built, no sandbox is
|
|
7
|
+
started, and no state/checkpoint/viz writes happen, so a dry run needs no
|
|
8
|
+
API key.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .config import PipelineConfig, StepConfig, load_config
|
|
16
|
+
from .loaders import resolve_docs
|
|
17
|
+
from .isolation import (isolation_state_dir, resolve_effective_profile,
|
|
18
|
+
resolve_image_ref)
|
|
19
|
+
from .registry import Registry, default_registry, load_extensions
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _validate_agents(cfg: PipelineConfig, agents_dir: Path) -> None:
|
|
23
|
+
"""Every `agent:` step must resolve to an existing file under agents_dir
|
|
24
|
+
(the same fail-fast `load_agent` enforces at run time)."""
|
|
25
|
+
for step in cfg.steps:
|
|
26
|
+
if step.agent is not None:
|
|
27
|
+
path = agents_dir / f"{step.agent}.agent.md"
|
|
28
|
+
if not path.is_file():
|
|
29
|
+
raise FileNotFoundError(f"Agent file not found: {path}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _validate_actions(cfg: PipelineConfig, registry: Registry) -> None:
|
|
33
|
+
"""Unknown action names fail at graph build time; catch them here so a
|
|
34
|
+
dry run is a true load pass."""
|
|
35
|
+
for step in cfg.steps:
|
|
36
|
+
if step.action is not None and step.action not in registry.actions:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"step {step.name!r}: unknown action {step.action!r} "
|
|
39
|
+
f"(registered: {sorted(registry.actions)})")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolved_image(cfg: PipelineConfig) -> tuple[str, str, float]:
|
|
43
|
+
"""The exec image ref + resource caps. An explicit `image: ""` is a hard
|
|
44
|
+
fail (AE-SBX-005) rather than a silent fallback to the digest-pinned
|
|
45
|
+
default; the env override still wins, exactly as at startup."""
|
|
46
|
+
profile = resolve_effective_profile(cfg.isolation)
|
|
47
|
+
if cfg.isolation is not None and cfg.isolation.image == "":
|
|
48
|
+
image = resolve_image_ref("")
|
|
49
|
+
else:
|
|
50
|
+
image = resolve_image_ref(profile.image)
|
|
51
|
+
return image, profile.memory, profile.cpus
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _step_summary(step: StepConfig) -> str:
|
|
55
|
+
"""The kind + key params for one step, on a single line."""
|
|
56
|
+
if step.kind == "agent":
|
|
57
|
+
parts = [f"agent={step.agent}"]
|
|
58
|
+
if step.artifact is not None:
|
|
59
|
+
parts.append(f"artifact={step.artifact}")
|
|
60
|
+
if step.verdicts is not None:
|
|
61
|
+
parts.append(f"verdicts={step.verdicts.pass_}/{step.verdicts.fail}")
|
|
62
|
+
if step.produces:
|
|
63
|
+
parts.append(f"produces={','.join(step.produces)}")
|
|
64
|
+
if step.max_iterations is not None:
|
|
65
|
+
parts.append(f"max_iterations={step.max_iterations}")
|
|
66
|
+
elif step.kind == "command":
|
|
67
|
+
parts = [f"command={step.command}"]
|
|
68
|
+
if step.timeout is not None:
|
|
69
|
+
parts.append(f"timeout={step.timeout:g}")
|
|
70
|
+
if step.setup is not None:
|
|
71
|
+
parts.append(f"setup={step.setup}")
|
|
72
|
+
else:
|
|
73
|
+
parts = [f"action={step.action}"]
|
|
74
|
+
if step.params:
|
|
75
|
+
parts.append(f"params={step.params}")
|
|
76
|
+
return ", ".join(parts)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _print_summary(cfg_path: Path, cfg: PipelineConfig, subject: str,
|
|
80
|
+
loader, docs: dict[str, str], image: str, memory: str,
|
|
81
|
+
cpus: float, state_dir: Path) -> None:
|
|
82
|
+
print(f"Pipeline: {cfg_path.resolve()}")
|
|
83
|
+
print(f"Subject: {subject}")
|
|
84
|
+
if loader is not None:
|
|
85
|
+
print(f"Document loader: {loader.name} ({len(docs)} docs)")
|
|
86
|
+
for key in sorted(docs):
|
|
87
|
+
print(f" - {key}")
|
|
88
|
+
else:
|
|
89
|
+
print("Document loader: none")
|
|
90
|
+
print("Steps:")
|
|
91
|
+
for step in cfg.steps:
|
|
92
|
+
print(f" - {step.name} [{step.kind}]: {_step_summary(step)}")
|
|
93
|
+
print("Isolation:")
|
|
94
|
+
print(" backend: docker")
|
|
95
|
+
print(f" image: {image}")
|
|
96
|
+
print(f" memory: {memory}")
|
|
97
|
+
print(f" cpus: {cpus}")
|
|
98
|
+
print(f" state_dir: {state_dir}")
|
|
99
|
+
print("dry run complete — nothing executed")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def dry_run(cfg_path, args) -> int:
|
|
103
|
+
"""Validate a pipeline end-to-end without executing it, and print a
|
|
104
|
+
summary plan. Returns 0 on a clean load; any load failure propagates as
|
|
105
|
+
the existing loud error (the CLI exits 1)."""
|
|
106
|
+
cfg_path = Path(cfg_path)
|
|
107
|
+
cfg = load_config(cfg_path)
|
|
108
|
+
base = cfg_path.resolve().parent
|
|
109
|
+
|
|
110
|
+
registry = default_registry()
|
|
111
|
+
load_extensions(cfg.extensions, registry)
|
|
112
|
+
|
|
113
|
+
_validate_agents(cfg, base / cfg.agents_dir)
|
|
114
|
+
_validate_actions(cfg, registry)
|
|
115
|
+
|
|
116
|
+
loader, docs, subject = resolve_docs(cfg, base, registry, args)
|
|
117
|
+
image, memory, cpus = _resolved_image(cfg)
|
|
118
|
+
state_dir = isolation_state_dir(cfg)
|
|
119
|
+
|
|
120
|
+
_print_summary(cfg_path, cfg, subject, loader, docs, image, memory, cpus,
|
|
121
|
+
state_dir)
|
|
122
|
+
return 0
|
|
@@ -45,7 +45,7 @@ def resolve_unit_docs(docs_dir: Path | str, unit_slug: str) -> dict[str, str]:
|
|
|
45
45
|
|
|
46
46
|
for file in sorted(unit_dir.rglob("*")):
|
|
47
47
|
if file.is_file() and file.suffix in _DOC_SUFFIXES:
|
|
48
|
-
key =
|
|
48
|
+
key = file.relative_to(unit_dir).as_posix()
|
|
49
49
|
docs[key] = file.read_text()
|
|
50
50
|
|
|
51
51
|
# At this point docs holds only unit-dir files — an empty load means the
|
agentic_dev_loop/graph.py
CHANGED
|
@@ -19,8 +19,9 @@ from langgraph.graph import END, START, StateGraph
|
|
|
19
19
|
from . import viz
|
|
20
20
|
from .actions import git_worktree_clean, kill_listeners_on_port
|
|
21
21
|
from .config import load_config
|
|
22
|
+
from .isolation import startup_sandbox
|
|
22
23
|
from .llm import build_llm
|
|
23
|
-
from .loaders import
|
|
24
|
+
from .loaders import resolve_docs
|
|
24
25
|
from .registry import Registry, default_registry, load_extensions
|
|
25
26
|
from .stages import (Runtime, build_context, clear_attempt_artifacts,
|
|
26
27
|
make_action_node, make_agent_node, make_bump_node,
|
|
@@ -43,6 +44,7 @@ class LoopState(TypedDict):
|
|
|
43
44
|
resume_target: str | None # where a resumed run re-enters (START edge)
|
|
44
45
|
outcome: str | None # "success" once routed to the success terminal
|
|
45
46
|
commit_allowed: bool # worktree was clean at run start
|
|
47
|
+
state_dir: str # resolved pipeline state dir (for the commit action)
|
|
46
48
|
step_verdicts: dict[str, str | None] # per-step pass/fail/None(unclear)
|
|
47
49
|
retry_target: str | None # where the bump node loops back to
|
|
48
50
|
thread_id: str
|
|
@@ -77,29 +79,6 @@ def build_graph(runtime: Runtime, checkpointer=None):
|
|
|
77
79
|
return graph.compile(checkpointer=checkpointer)
|
|
78
80
|
|
|
79
81
|
|
|
80
|
-
def _resolve_docs(cfg, base: Path, registry: Registry,
|
|
81
|
-
args) -> tuple[dict[str, str], str]:
|
|
82
|
-
"""Select and invoke the document loader, then append the pipeline's
|
|
83
|
-
additional_files (resolved relative to the pipeline file's dir;
|
|
84
|
-
listed-but-missing fails fast)."""
|
|
85
|
-
loader = select_loader(registry, args)
|
|
86
|
-
if loader is not None:
|
|
87
|
-
docs, subject = loader.resolve(args)
|
|
88
|
-
if not docs:
|
|
89
|
-
# U3 policy: an empty load silently degraded to a context the
|
|
90
|
-
# agents couldn't act on. Fail fast at the call site instead.
|
|
91
|
-
raise RuntimeError(
|
|
92
|
-
f"Document loader {loader.name!r} returned no docs — "
|
|
93
|
-
"refusing to build context from nothing")
|
|
94
|
-
else:
|
|
95
|
-
docs, subject = {}, cfg.name
|
|
96
|
-
for rel in cfg.additional_files:
|
|
97
|
-
path = base / rel
|
|
98
|
-
if not path.is_file():
|
|
99
|
-
raise FileNotFoundError(f"additional_files entry not found: {path}")
|
|
100
|
-
docs[path.name] = path.read_text()
|
|
101
|
-
return docs, subject
|
|
102
|
-
|
|
103
82
|
|
|
104
83
|
def run_pipeline(cfg_path, args, *, viz_bus=None) -> tuple[bool, int]:
|
|
105
84
|
"""Load a pipeline YAML, resolve docs via the selected document loader,
|
|
@@ -125,10 +104,27 @@ def run_pipeline(cfg_path, args, *, viz_bus=None) -> tuple[bool, int]:
|
|
|
125
104
|
registry = default_registry()
|
|
126
105
|
load_extensions(cfg.extensions, registry)
|
|
127
106
|
|
|
128
|
-
state_dir =
|
|
107
|
+
state_dir = Path(cfg.state_dir).expanduser()
|
|
108
|
+
if not state_dir.is_absolute():
|
|
109
|
+
state_dir = base / state_dir
|
|
110
|
+
|
|
111
|
+
# Eager startup (Q2): probe -> image -> create+start exec container ->
|
|
112
|
+
# pre-provision all declared setups, BEFORE the graph builds. Zero infra
|
|
113
|
+
# operations mid-run; the only sandbox call during steps is run(request).
|
|
114
|
+
sandbox = startup_sandbox(cfg, workspace=Path.cwd(),
|
|
115
|
+
refresh=getattr(args, "refresh", False))
|
|
116
|
+
try:
|
|
117
|
+
return _run_pipeline(cfg, base, registry, state_dir, max_attempts,
|
|
118
|
+
args, viz_bus, sandbox)
|
|
119
|
+
finally:
|
|
120
|
+
sandbox.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
|
|
124
|
+
sandbox) -> tuple[bool, int]:
|
|
129
125
|
runtime = Runtime(cfg=cfg, registry=registry, llm=build_llm(cfg.llm),
|
|
130
126
|
agents_dir=base / cfg.agents_dir, state_dir=state_dir,
|
|
131
|
-
workspace=Path.cwd())
|
|
127
|
+
workspace=Path.cwd(), sandbox=sandbox)
|
|
132
128
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
133
129
|
|
|
134
130
|
resume = getattr(args, "resume", None)
|
|
@@ -138,7 +134,7 @@ def run_pipeline(cfg_path, args, *, viz_bus=None) -> tuple[bool, int]:
|
|
|
138
134
|
if resume:
|
|
139
135
|
initial = None # state (docs/subject/context) comes from the checkpoint
|
|
140
136
|
else:
|
|
141
|
-
docs, subject =
|
|
137
|
+
_loader, docs, subject = resolve_docs(cfg, base, registry, args)
|
|
142
138
|
# A fresh run owns state_dir: drop cross-run leftovers (a previous
|
|
143
139
|
# failure's last-error.md, stale artifacts) so nothing mistakes
|
|
144
140
|
# them for this run's output. --resume keeps them.
|
|
@@ -159,6 +155,7 @@ def run_pipeline(cfg_path, args, *, viz_bus=None) -> tuple[bool, int]:
|
|
|
159
155
|
# the checkpointer creates its db under state_dir, which would
|
|
160
156
|
# itself dirty the worktree.
|
|
161
157
|
"commit_allowed": git_worktree_clean(Path.cwd()),
|
|
158
|
+
"state_dir": str(state_dir),
|
|
162
159
|
"step_verdicts": {},
|
|
163
160
|
"retry_target": None,
|
|
164
161
|
"thread_id": thread_id,
|