agentic-dev-loop 0.9.0__py3-none-any.whl → 0.9.2__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/core/config.py +40 -79
- agentic_dev_loop/engine/cli.py +2 -14
- agentic_dev_loop/engine/graph.py +3 -4
- agentic_dev_loop/engine/isolation.py +142 -408
- agentic_dev_loop/engine/stages.py +24 -0
- agentic_dev_loop/engine/step_workspace.py +51 -39
- agentic_dev_loop/engine/sync_back.py +42 -16
- agentic_dev_loop/viz/server.py +52 -15
- {agentic_dev_loop-0.9.0.dist-info → agentic_dev_loop-0.9.2.dist-info}/METADATA +3 -6
- {agentic_dev_loop-0.9.0.dist-info → agentic_dev_loop-0.9.2.dist-info}/RECORD +14 -14
- {agentic_dev_loop-0.9.0.dist-info → agentic_dev_loop-0.9.2.dist-info}/WHEEL +0 -0
- {agentic_dev_loop-0.9.0.dist-info → agentic_dev_loop-0.9.2.dist-info}/entry_points.txt +0 -0
- {agentic_dev_loop-0.9.0.dist-info → agentic_dev_loop-0.9.2.dist-info}/licenses/LICENSE +0 -0
- {agentic_dev_loop-0.9.0.dist-info → agentic_dev_loop-0.9.2.dist-info}/top_level.txt +0 -0
agentic_dev_loop/core/config.py
CHANGED
|
@@ -16,9 +16,6 @@ from agentic_dev_loop.core.paths import _has_wildcards, path_excluded, relative_
|
|
|
16
16
|
TERMINALS = frozenset({"success", "failure"})
|
|
17
17
|
KNOWN_SECRET_PROVIDERS = frozenset({"env"})
|
|
18
18
|
MOUNT_MODES = frozenset({"rw", "ro"})
|
|
19
|
-
# Host-only dirs a mount path may never name (022): they are masked in
|
|
20
|
-
# the sandbox with a read-only tmpfs, so declaring them would be a decoy.
|
|
21
|
-
HOST_ONLY_MOUNT_DIRS = frozenset({".venv"})
|
|
22
19
|
# The egress preset names the engine ships (BR-9). Config validates the
|
|
23
20
|
# name here; the target data lives in engine/presets.py (preset data,
|
|
24
21
|
# never mechanism - D-2).
|
|
@@ -52,8 +49,8 @@ def _validate_mount_path(path: str) -> str:
|
|
|
52
49
|
"""Validate one repo-relative mount path (BR-1.3).
|
|
53
50
|
|
|
54
51
|
Rejects (loudly) an empty path, an absolute path, ``..`` traversal,
|
|
55
|
-
any ``.git`` component
|
|
56
|
-
|
|
52
|
+
and any ``.git`` component. The path is normalised to forward-slash
|
|
53
|
+
form and returned.
|
|
57
54
|
"""
|
|
58
55
|
if not isinstance(path, str) or not path.strip():
|
|
59
56
|
raise ValueError("mount path must be a non-empty string")
|
|
@@ -70,9 +67,6 @@ def _validate_mount_path(path: str) -> str:
|
|
|
70
67
|
if seg == ".git":
|
|
71
68
|
raise ValueError(
|
|
72
69
|
f"mount path {path!r} must never name '.git'")
|
|
73
|
-
if seg in HOST_ONLY_MOUNT_DIRS:
|
|
74
|
-
raise ValueError(
|
|
75
|
-
f"mount path {path!r} names the host-only dir {seg!r}")
|
|
76
70
|
return "/".join(parts) if parts else "."
|
|
77
71
|
|
|
78
72
|
|
|
@@ -80,7 +74,7 @@ def _validate_declared_path(path: str, *, allow_wildcards: bool) -> str:
|
|
|
80
74
|
"""Validate one declared ``caches:``/``sync_excludes:`` entry (028).
|
|
81
75
|
|
|
82
76
|
Same repo-relative contract as mount paths: non-empty, no absolute paths,
|
|
83
|
-
no ``..`` traversal, no ``.git`` component
|
|
77
|
+
no ``..`` traversal, no ``.git`` component. Wildcard
|
|
84
78
|
entries (``sync_excludes:`` only) skip wildcard segments in the literal
|
|
85
79
|
checks; the literal segments still obey the same rules. The normalised
|
|
86
80
|
forward-slash form is returned.
|
|
@@ -102,9 +96,6 @@ def _validate_declared_path(path: str, *, allow_wildcards: bool) -> str:
|
|
|
102
96
|
if seg == ".git":
|
|
103
97
|
raise ValueError(
|
|
104
98
|
f"declared path {path!r} must never name '.git'")
|
|
105
|
-
if seg in HOST_ONLY_MOUNT_DIRS:
|
|
106
|
-
raise ValueError(
|
|
107
|
-
f"declared path {path!r} names the host-only dir {seg!r}")
|
|
108
99
|
return "/".join(parts) if parts else "."
|
|
109
100
|
|
|
110
101
|
|
|
@@ -220,24 +211,32 @@ class SecretSpec(BaseModel):
|
|
|
220
211
|
class IsolationConfig(BaseModel):
|
|
221
212
|
"""Optional `isolation:` section - configures the always-on sandbox (BR-2).
|
|
222
213
|
Absence means opinionated defaults, never unsandboxed behaviour. Unknown
|
|
223
|
-
keys fail at load (BR-5). `state_dir` is HOST-ONLY (
|
|
224
|
-
never mounted into the sandbox (Q1)."""
|
|
214
|
+
keys fail at load (BR-5). `state_dir` is HOST-ONLY (host-side caches +
|
|
215
|
+
audit) and is never mounted into the sandbox (Q1)."""
|
|
225
216
|
|
|
226
217
|
model_config = ConfigDict(extra="forbid")
|
|
227
218
|
|
|
219
|
+
@model_validator(mode="before")
|
|
220
|
+
@classmethod
|
|
221
|
+
def _reject_removed_keys(cls, data: Any) -> Any:
|
|
222
|
+
"""052: removed isolation keys fail loud with a migration message."""
|
|
223
|
+
if isinstance(data, dict):
|
|
224
|
+
if "sandbox_venv_dir" in data:
|
|
225
|
+
raise ValueError(
|
|
226
|
+
"isolation.sandbox_venv_dir was removed: venvs are "
|
|
227
|
+
"per-run, built by a setup hook into the export; remove "
|
|
228
|
+
"this key")
|
|
229
|
+
if "refresh" in data:
|
|
230
|
+
raise ValueError(
|
|
231
|
+
"isolation.refresh was removed: setup now runs fresh at "
|
|
232
|
+
"every step entry with no stamping; remove this key")
|
|
233
|
+
return data
|
|
234
|
+
|
|
228
235
|
image: str | None = None # per-pipeline override (KD-2 ref rules)
|
|
229
|
-
refresh: bool = False # force re-provision (NFR-5)
|
|
230
236
|
memory: str | None = None # override exec container memory
|
|
231
237
|
cpus: float | None = None # override exec container cpus
|
|
232
238
|
allow_git_mutations: bool = False # BR-6 knob
|
|
233
|
-
state_dir: str = "~/.agentic-dev-loop/state" #
|
|
234
|
-
# 022 relocation opt-in: an out-of-workspace host dir that is bind-mounted
|
|
235
|
-
# at ``{ws}/.venv-sandbox`` (rw) in both the exec and provision containers,
|
|
236
|
-
# keeping the workspace free of container venv artifacts. Unset keeps the
|
|
237
|
-
# legacy workspace-local ``.venv-sandbox`` unchanged. Must resolve outside
|
|
238
|
-
# the workspace - validated at startup, not load (the workspace is unknown
|
|
239
|
-
# here).
|
|
240
|
-
sandbox_venv_dir: str | None = None
|
|
239
|
+
state_dir: str = "~/.agentic-dev-loop/state" # host-side caches + audit
|
|
241
240
|
# 023/025: optional host path to a Docker seccomp profile JSON, or a
|
|
242
241
|
# ``bundled:<name>`` sentinel resolved at load to a packaged profile
|
|
243
242
|
# (e.g. ``bundled:chromium-userns``). When set, the exec container runs
|
|
@@ -402,8 +401,7 @@ class StepConfig(BaseModel):
|
|
|
402
401
|
reasoning_effort: ReasoningEffort | None = None
|
|
403
402
|
custom_prompt: str | None = None # agent steps only (040); file path resolved at load
|
|
404
403
|
env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
|
|
405
|
-
setup: str | None = None #
|
|
406
|
-
setup_probe: str | None = None # 045: consumer probe replacing the venv probe on a warm stamp; requires `setup`; probe text is NOT part of the stamp key (a probe-only change needs --refresh/--clean-stamps)
|
|
404
|
+
setup: str | None = None # command or agent steps; runs fresh at each step entry (052)
|
|
407
405
|
forbidden_commands: list[str] = [] # banned-command regex deny-list (036 D1)
|
|
408
406
|
network: bool = False # per-stage egress (command/agent + sandbox actions; host-side actions reject at registry time)
|
|
409
407
|
# unit-5: per-step mount granularity + egress policy. Empty mounts =
|
|
@@ -432,6 +430,16 @@ class StepConfig(BaseModel):
|
|
|
432
430
|
return "command"
|
|
433
431
|
return "action"
|
|
434
432
|
|
|
433
|
+
@model_validator(mode="before")
|
|
434
|
+
@classmethod
|
|
435
|
+
def _reject_removed_keys(cls, data: Any) -> Any:
|
|
436
|
+
"""052: removed step keys fail loud with a migration message."""
|
|
437
|
+
if isinstance(data, dict) and "setup_probe" in data:
|
|
438
|
+
raise ValueError(
|
|
439
|
+
"step setup_probe was removed: setup now runs fresh at every "
|
|
440
|
+
"step entry with no stamping; remove this key")
|
|
441
|
+
return data
|
|
442
|
+
|
|
435
443
|
@field_validator("produces", mode="before")
|
|
436
444
|
@classmethod
|
|
437
445
|
def _coerce_produces(cls, v: Any) -> Any:
|
|
@@ -464,13 +472,6 @@ class StepConfig(BaseModel):
|
|
|
464
472
|
raise ValueError("custom_prompt must be a non-empty string")
|
|
465
473
|
return v
|
|
466
474
|
|
|
467
|
-
@field_validator("setup_probe")
|
|
468
|
-
@classmethod
|
|
469
|
-
def _nonempty_setup_probe(cls, v: str | None) -> str | None:
|
|
470
|
-
if v is not None and not v.strip():
|
|
471
|
-
raise ValueError("setup_probe must be a non-empty string")
|
|
472
|
-
return v
|
|
473
|
-
|
|
474
475
|
@model_validator(mode="after")
|
|
475
476
|
def _check_shape(self) -> "StepConfig":
|
|
476
477
|
for key in ("agent", "action", "command"):
|
|
@@ -566,15 +567,10 @@ class StepConfig(BaseModel):
|
|
|
566
567
|
raise ValueError(
|
|
567
568
|
f"step {self.name!r}: 'custom_prompt' requires 'agent'"
|
|
568
569
|
)
|
|
569
|
-
if
|
|
570
|
-
and self.agent is None and self.brief_inputs is None):
|
|
570
|
+
if self.setup is not None and self.command is None and self.agent is None:
|
|
571
571
|
raise ValueError(
|
|
572
|
-
f"step {self.name!r}: 'setup' is only allowed on 'command'
|
|
573
|
-
"'agent'
|
|
574
|
-
)
|
|
575
|
-
if self.setup_probe is not None and self.setup is None:
|
|
576
|
-
raise ValueError(
|
|
577
|
-
f"step {self.name!r}: 'setup_probe' requires 'setup'"
|
|
572
|
+
f"step {self.name!r}: 'setup' is only allowed on 'command' or "
|
|
573
|
+
"'agent' steps (052)"
|
|
578
574
|
)
|
|
579
575
|
if self.secrets and self.action is not None:
|
|
580
576
|
raise ValueError(
|
|
@@ -624,11 +620,10 @@ class PipelineConfig(BaseModel):
|
|
|
624
620
|
# missing-dependency fail-fast.
|
|
625
621
|
deps_policy: str = "locked"
|
|
626
622
|
lockfiles: list[str] = []
|
|
627
|
-
# 028: pipeline-declared toolchain caches (rw bind-mounts from the
|
|
628
|
-
#
|
|
629
|
-
#
|
|
630
|
-
#
|
|
631
|
-
# "no sync-back excludes".
|
|
623
|
+
# 028/052: pipeline-declared toolchain caches (rw bind-mounts from the
|
|
624
|
+
# host-side cache dir ``{isolation.state_dir}/caches/<name>``) and
|
|
625
|
+
# run-wide sync-back excludes (scratch semantics: skipped from both the
|
|
626
|
+
# export copy and sync-back). Both default to [].
|
|
632
627
|
caches: list[str] = []
|
|
633
628
|
sync_excludes: list[str] = []
|
|
634
629
|
# 029: append-style logs that sync-back union-merges on a concurrent
|
|
@@ -715,25 +710,6 @@ class PipelineConfig(BaseModel):
|
|
|
715
710
|
f"the host-only state_dir {self.state_dir!r}")
|
|
716
711
|
return self
|
|
717
712
|
|
|
718
|
-
@model_validator(mode="after")
|
|
719
|
-
def _caches_avoid_state_dir(self) -> "PipelineConfig":
|
|
720
|
-
"""028: a declared cache may never name (or sit under) the host-only
|
|
721
|
-
run-state dir - it would bind-mount the engine's own state into the
|
|
722
|
-
sandbox as a rw cache."""
|
|
723
|
-
sd = self.state_dir.strip().rstrip("/\\")
|
|
724
|
-
if not sd or sd in ("/", "."):
|
|
725
|
-
return self
|
|
726
|
-
sd_parts = relative_parts(sd)
|
|
727
|
-
if not sd_parts:
|
|
728
|
-
return self
|
|
729
|
-
for cache in self.caches:
|
|
730
|
-
cp = relative_parts(cache)
|
|
731
|
-
if cp[:len(sd_parts)] == sd_parts:
|
|
732
|
-
raise ValueError(
|
|
733
|
-
f"cache path {cache!r} names the host-only state_dir "
|
|
734
|
-
f"{self.state_dir!r}")
|
|
735
|
-
return self
|
|
736
|
-
|
|
737
713
|
@model_validator(mode="after")
|
|
738
714
|
def _mounts_avoid_caches(self) -> "PipelineConfig":
|
|
739
715
|
"""028/BR-1.3: a step mount equal to or under a declared cache is
|
|
@@ -799,21 +775,6 @@ class PipelineConfig(BaseModel):
|
|
|
799
775
|
f"{('/'.join(ep))!r}")
|
|
800
776
|
return self
|
|
801
777
|
|
|
802
|
-
@model_validator(mode="after")
|
|
803
|
-
def _sandbox_venv_not_declared_cache(self) -> "PipelineConfig":
|
|
804
|
-
"""028: the sandbox venv is engine infrastructure, never author data.
|
|
805
|
-
Declaring ``.venv-sandbox`` in ``caches:`` while the 022 relocation
|
|
806
|
-
(``isolation.sandbox_venv_dir``) is set is a contradiction."""
|
|
807
|
-
if self.isolation is None or self.isolation.sandbox_venv_dir is None:
|
|
808
|
-
return self
|
|
809
|
-
for cache in self.caches:
|
|
810
|
-
if relative_parts(cache) == (".venv-sandbox",):
|
|
811
|
-
raise ValueError(
|
|
812
|
-
"declaring '.venv-sandbox' in 'caches:' contradicts "
|
|
813
|
-
"'isolation.sandbox_venv_dir' - the sandbox venv is "
|
|
814
|
-
"relocated outside the workspace (022)")
|
|
815
|
-
return self
|
|
816
|
-
|
|
817
778
|
@model_validator(mode="after")
|
|
818
779
|
def _warn_shadowed_artifacts(self) -> "PipelineConfig":
|
|
819
780
|
"""028: an artifact/produces path matching a sync-exclude would never
|
agentic_dev_loop/engine/cli.py
CHANGED
|
@@ -14,8 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
|
|
15
15
|
from agentic_dev_loop.core.config import PipelineConfig, load_config
|
|
16
16
|
from .containers import containers_report
|
|
17
|
-
from .isolation import
|
|
18
|
-
reap_orphaned_containers)
|
|
17
|
+
from .isolation import isolation_state_dir, reap_orphaned_containers
|
|
19
18
|
from .loaders import mark_plan_completed
|
|
20
19
|
from .registry import Registry, default_registry, load_extensions
|
|
21
20
|
|
|
@@ -82,15 +81,10 @@ def _core_parser() -> argparse.ArgumentParser:
|
|
|
82
81
|
help="preferred port for the live graph view "
|
|
83
82
|
"(default: 8321; falls back to a free port when "
|
|
84
83
|
"busy)")
|
|
85
|
-
parser.add_argument("--refresh", action="store_true",
|
|
86
|
-
help="force re-provision of every setup: hook "
|
|
87
|
-
"(bypasses the stamp cache)")
|
|
88
84
|
parser.add_argument("--dry-run", action="store_true",
|
|
89
85
|
help="validate the pipeline and print a summary plan "
|
|
90
86
|
"without executing anything (no LLM, no "
|
|
91
87
|
"sandbox, no state writes)")
|
|
92
|
-
parser.add_argument("--clean-stamps", action="store_true",
|
|
93
|
-
help="delete cached provision stamps and exit")
|
|
94
88
|
_add_version_flag(parser)
|
|
95
89
|
return parser
|
|
96
90
|
|
|
@@ -185,18 +179,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
185
179
|
for loader in registry.document_loaders:
|
|
186
180
|
loader.add_cli_args(parser)
|
|
187
181
|
args = parser.parse_args(argv)
|
|
188
|
-
if args.clean_stamps:
|
|
189
|
-
state = isolation_state_dir(cfg)
|
|
190
|
-
removed = clean_stamps(state)
|
|
191
|
-
print(f"Removed {removed} cached provision stamp(s) from "
|
|
192
|
-
f"{state / 'stamps'}", flush=True)
|
|
193
|
-
return 0
|
|
194
182
|
if args.dry_run:
|
|
195
183
|
from .dryrun import dry_run
|
|
196
184
|
return dry_run(args.pipeline, args, registry)
|
|
197
185
|
# The run path alone needs the heavy graph/SDK imports, and --dry-run
|
|
198
186
|
# needs the dryrun import chain in-branch; defer both so `containers`,
|
|
199
|
-
# --
|
|
187
|
+
# --help, and --version stay side-effect free. 042 D4: a
|
|
200
188
|
# SIGTERM here raises SystemExit(143) so graph.run_pipeline's finally
|
|
201
189
|
# still closes the sandbox; a second SIGTERM hard-exits.
|
|
202
190
|
previous = _install_sigterm_handler()
|
agentic_dev_loop/engine/graph.py
CHANGED
|
@@ -160,15 +160,14 @@ def run_pipeline(cfg_path, args, *, viz_bus=None,
|
|
|
160
160
|
if not state_dir.is_absolute():
|
|
161
161
|
state_dir = base / state_dir
|
|
162
162
|
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
163
|
+
# Startup (052): probe -> image -> create+start exec container. No setup
|
|
164
|
+
# runs here: each step entry provisions its own setup hook, fresh, into
|
|
165
|
+
# the export.
|
|
166
166
|
# 042 D3: startup is close-protected - a post-create provision failure
|
|
167
167
|
# must still reach close() and reap the container it created.
|
|
168
168
|
sandbox = None
|
|
169
169
|
try:
|
|
170
170
|
sandbox = startup_sandbox(cfg, workspace=Path.cwd(),
|
|
171
|
-
refresh=getattr(args, "refresh", False),
|
|
172
171
|
state_dir=state_dir)
|
|
173
172
|
return _run_pipeline(cfg, base, registry, state_dir, max_attempts,
|
|
174
173
|
args, viz_bus, sandbox, resolved_secrets,
|