agentic-dev-loop 1.0.0a10__tar.gz → 1.0.0a12__tar.gz
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.
Potentially problematic release.
This version of agentic-dev-loop might be problematic. Click here for more details.
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/PKG-INFO +74 -1
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/README.md +72 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/pyproject.toml +2 -1
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/config.py +37 -0
- agentic_dev_loop-1.0.0a12/src/agentic_dev_loop/engine/deps.py +77 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/isolation.py +122 -26
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/stages.py +21 -2
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/PKG-INFO +74 -1
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/SOURCES.txt +3 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/requires.txt +1 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_cli.py +4 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_config.py +46 -0
- agentic_dev_loop-1.0.0a12/tests/test_deps.py +76 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_dry_run.py +1 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_e2e_fixtures.py +5 -4
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_graph.py +38 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_hostboundary.py +1 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_hostboundary_integration.py +1 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_isolation.py +159 -3
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_isolation_integration.py +72 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_secrets.py +1 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_secrets_pipeline.py +2 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_stages.py +52 -1
- agentic_dev_loop-1.0.0a12/tests/test_trigger_script.py +111 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_viz.py +1 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/LICENSE +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/setup.cfg +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/results.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/verdict.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/actions.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/agents.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/cli.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/dryrun.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/graph.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/llm.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/loaders.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/outputs.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/proc.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/registry.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/secrets.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/viz/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/viz/__main__.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/viz/server.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_actions.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_agents.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_isolation_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_llm.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_module_contract.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_proc.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_publish.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_registry.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_results.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_sdk_compat.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_secrets_integration.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_verdict.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentic-dev-loop
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.0a12
|
|
4
4
|
Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
|
|
5
5
|
Author: Luke Wilson
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -35,6 +35,7 @@ Requires-Dist: langgraph-checkpoint-sqlite>=3.0
|
|
|
35
35
|
Provides-Extra: dev
|
|
36
36
|
Requires-Dist: hypothesis>=6.0; extra == "dev"
|
|
37
37
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
38
|
+
Requires-Dist: pip-audit>=2.0; extra == "dev"
|
|
38
39
|
Dynamic: license-file
|
|
39
40
|
|
|
40
41
|
# agentic-dev-loop
|
|
@@ -213,6 +214,8 @@ steps:
|
|
|
213
214
|
| `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
|
|
214
215
|
| `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
|
|
215
216
|
| `extensions_dir` | Where consumer `extensions:` modules resolve — defaults to `~/.agentic-dev-loop/extensions`, **outside** the workspace. Workspace-resident extension/tool modules are refused at load. |
|
|
217
|
+
| `deps_policy` | `locked` (default) or `open`. `locked` = the human-owned lockfile is the source of truth and the agent may not add dependencies; `open` = the bootstrap protocol (the agent may add exact-pinned manifest entries). See [Dependency policy](#dependency-policy-locked-by-default). |
|
|
218
|
+
| `lockfiles` | The pipeline's declared lockfile names (workspace-relative), injected into agent context and named by the missing-dependency fail-fast. Required (non-empty) when `deps_policy: locked` — a `locked` pipeline with no lockfiles fails fast at load. |
|
|
216
219
|
|
|
217
220
|
### Secrets (`secrets:`) — refs only, never values
|
|
218
221
|
|
|
@@ -278,6 +281,76 @@ steps:
|
|
|
278
281
|
for stages that need it (QA, integration). The residual risk is documented
|
|
279
282
|
in [docs/setup.md](docs/setup.md).
|
|
280
283
|
|
|
284
|
+
## Dependency policy (locked by default)
|
|
285
|
+
|
|
286
|
+
The supply-chain dial is a pipeline-level `deps_policy:` field, **locked by
|
|
287
|
+
default**. The engine injects the policy into every agent step's context —
|
|
288
|
+
the rule lives in the engine, not in hand-authored `dev.agent.md` prose (the
|
|
289
|
+
class of rule that deadlocked the NuGet cache).
|
|
290
|
+
|
|
291
|
+
```yaml
|
|
292
|
+
deps_policy: locked # or: open (default: locked)
|
|
293
|
+
lockfiles: [uv.lock] # or: [packages.lock.json, package-lock.json]
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
- **`locked`** — the human-owned lockfile is the source of truth; the agent
|
|
297
|
+
may not add dependencies. The injected rule says *"You may only use
|
|
298
|
+
dependencies already present in `<lockfile>`. If the design requires a
|
|
299
|
+
dependency that is not present, declare it in your final response under the
|
|
300
|
+
fixed header `## Missing dependencies` (one `name==version` per line) and
|
|
301
|
+
finish what you can. Do not add it to the manifest."* A non-empty
|
|
302
|
+
`## Missing dependencies` declaration **fails the run fast** with
|
|
303
|
+
`Extend <lockfile> with these pins and re-run: …` — one human lockfile edit,
|
|
304
|
+
not a retry loop. The declaration is advisory to the human; the engine never
|
|
305
|
+
auto-fetches the declared packages.
|
|
306
|
+
- **`open`** — the bootstrap protocol (018): the agent may add exact-pinned
|
|
307
|
+
manifest entries directly (never a package-manager `add` command); the
|
|
308
|
+
network-on restore step fetches them. The engine injects the same rule
|
|
309
|
+
canonically. Nothing about `open` is default.
|
|
310
|
+
|
|
311
|
+
The **restore step** runs the toolchain's locked mode and fails when the
|
|
312
|
+
manifest and lockfile disagree:
|
|
313
|
+
|
|
314
|
+
| Toolchain | Locked restore | Lockfile |
|
|
315
|
+
| --- | --- | --- |
|
|
316
|
+
| .NET | `dotnet restore --locked-mode` | `packages.lock.json` (set `RestorePackagesWithLockFile=true`) |
|
|
317
|
+
| node | `npm ci` | `package-lock.json` |
|
|
318
|
+
| python | `uv sync --locked` (or pip-tools `pip-sync requirements.lock`) | `uv.lock` — pip has no native lockfile; `uv` is the canonical locked-install tool |
|
|
319
|
+
|
|
320
|
+
### Audit escalation in the restore step
|
|
321
|
+
|
|
322
|
+
The restore step also fails on known-CVE findings, at a per-toolchain
|
|
323
|
+
threshold:
|
|
324
|
+
|
|
325
|
+
| Toolchain | Audit (fails the step) |
|
|
326
|
+
| --- | --- |
|
|
327
|
+
| .NET | `NuGetAudit` at `error` — any audit finding fails the restore |
|
|
328
|
+
| node | `npm audit --audit-level=high` |
|
|
329
|
+
| python | `pip-audit` — any finding fails |
|
|
330
|
+
|
|
331
|
+
Under `locked`, a failing audit means the lockfile pins a vulnerable version:
|
|
332
|
+
the run fails and the human regenerates the lockfile at a patched version.
|
|
333
|
+
Under `open`, the failure routes to `dev` like any restore failure — the dev
|
|
334
|
+
agent can bump the pin, but that pin is audited again on the next pass, so
|
|
335
|
+
the loop self-corrects.
|
|
336
|
+
|
|
337
|
+
### Generating a lockfile
|
|
338
|
+
|
|
339
|
+
A human generates the lockfile once, on a network machine, and commits it —
|
|
340
|
+
the engine never fetches on the agent's behalf:
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
uv lock # python — writes uv.lock from pyproject.toml
|
|
344
|
+
uv sync --locked # installs exactly uv.lock; fails on any drift
|
|
345
|
+
npm install --package-lock-only # node — writes package-lock.json
|
|
346
|
+
npm ci # installs exactly package-lock.json
|
|
347
|
+
dotnet restore --use-lock-file # .NET — writes packages.lock.json
|
|
348
|
+
dotnet restore --locked-mode # installs exactly packages.lock.json
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Keep the lockfile tracked (do not gitignore it) and re-run the locked restore
|
|
352
|
+
to prove it holds from a clean cache before setting `deps_policy: locked`.
|
|
353
|
+
|
|
281
354
|
## Host boundary (security model)
|
|
282
355
|
|
|
283
356
|
The orchestrator, tool layer, and the `commit` action run in the **host
|
|
@@ -174,6 +174,8 @@ steps:
|
|
|
174
174
|
| `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
|
|
175
175
|
| `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
|
|
176
176
|
| `extensions_dir` | Where consumer `extensions:` modules resolve — defaults to `~/.agentic-dev-loop/extensions`, **outside** the workspace. Workspace-resident extension/tool modules are refused at load. |
|
|
177
|
+
| `deps_policy` | `locked` (default) or `open`. `locked` = the human-owned lockfile is the source of truth and the agent may not add dependencies; `open` = the bootstrap protocol (the agent may add exact-pinned manifest entries). See [Dependency policy](#dependency-policy-locked-by-default). |
|
|
178
|
+
| `lockfiles` | The pipeline's declared lockfile names (workspace-relative), injected into agent context and named by the missing-dependency fail-fast. Required (non-empty) when `deps_policy: locked` — a `locked` pipeline with no lockfiles fails fast at load. |
|
|
177
179
|
|
|
178
180
|
### Secrets (`secrets:`) — refs only, never values
|
|
179
181
|
|
|
@@ -239,6 +241,76 @@ steps:
|
|
|
239
241
|
for stages that need it (QA, integration). The residual risk is documented
|
|
240
242
|
in [docs/setup.md](docs/setup.md).
|
|
241
243
|
|
|
244
|
+
## Dependency policy (locked by default)
|
|
245
|
+
|
|
246
|
+
The supply-chain dial is a pipeline-level `deps_policy:` field, **locked by
|
|
247
|
+
default**. The engine injects the policy into every agent step's context —
|
|
248
|
+
the rule lives in the engine, not in hand-authored `dev.agent.md` prose (the
|
|
249
|
+
class of rule that deadlocked the NuGet cache).
|
|
250
|
+
|
|
251
|
+
```yaml
|
|
252
|
+
deps_policy: locked # or: open (default: locked)
|
|
253
|
+
lockfiles: [uv.lock] # or: [packages.lock.json, package-lock.json]
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
- **`locked`** — the human-owned lockfile is the source of truth; the agent
|
|
257
|
+
may not add dependencies. The injected rule says *"You may only use
|
|
258
|
+
dependencies already present in `<lockfile>`. If the design requires a
|
|
259
|
+
dependency that is not present, declare it in your final response under the
|
|
260
|
+
fixed header `## Missing dependencies` (one `name==version` per line) and
|
|
261
|
+
finish what you can. Do not add it to the manifest."* A non-empty
|
|
262
|
+
`## Missing dependencies` declaration **fails the run fast** with
|
|
263
|
+
`Extend <lockfile> with these pins and re-run: …` — one human lockfile edit,
|
|
264
|
+
not a retry loop. The declaration is advisory to the human; the engine never
|
|
265
|
+
auto-fetches the declared packages.
|
|
266
|
+
- **`open`** — the bootstrap protocol (018): the agent may add exact-pinned
|
|
267
|
+
manifest entries directly (never a package-manager `add` command); the
|
|
268
|
+
network-on restore step fetches them. The engine injects the same rule
|
|
269
|
+
canonically. Nothing about `open` is default.
|
|
270
|
+
|
|
271
|
+
The **restore step** runs the toolchain's locked mode and fails when the
|
|
272
|
+
manifest and lockfile disagree:
|
|
273
|
+
|
|
274
|
+
| Toolchain | Locked restore | Lockfile |
|
|
275
|
+
| --- | --- | --- |
|
|
276
|
+
| .NET | `dotnet restore --locked-mode` | `packages.lock.json` (set `RestorePackagesWithLockFile=true`) |
|
|
277
|
+
| node | `npm ci` | `package-lock.json` |
|
|
278
|
+
| python | `uv sync --locked` (or pip-tools `pip-sync requirements.lock`) | `uv.lock` — pip has no native lockfile; `uv` is the canonical locked-install tool |
|
|
279
|
+
|
|
280
|
+
### Audit escalation in the restore step
|
|
281
|
+
|
|
282
|
+
The restore step also fails on known-CVE findings, at a per-toolchain
|
|
283
|
+
threshold:
|
|
284
|
+
|
|
285
|
+
| Toolchain | Audit (fails the step) |
|
|
286
|
+
| --- | --- |
|
|
287
|
+
| .NET | `NuGetAudit` at `error` — any audit finding fails the restore |
|
|
288
|
+
| node | `npm audit --audit-level=high` |
|
|
289
|
+
| python | `pip-audit` — any finding fails |
|
|
290
|
+
|
|
291
|
+
Under `locked`, a failing audit means the lockfile pins a vulnerable version:
|
|
292
|
+
the run fails and the human regenerates the lockfile at a patched version.
|
|
293
|
+
Under `open`, the failure routes to `dev` like any restore failure — the dev
|
|
294
|
+
agent can bump the pin, but that pin is audited again on the next pass, so
|
|
295
|
+
the loop self-corrects.
|
|
296
|
+
|
|
297
|
+
### Generating a lockfile
|
|
298
|
+
|
|
299
|
+
A human generates the lockfile once, on a network machine, and commits it —
|
|
300
|
+
the engine never fetches on the agent's behalf:
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
uv lock # python — writes uv.lock from pyproject.toml
|
|
304
|
+
uv sync --locked # installs exactly uv.lock; fails on any drift
|
|
305
|
+
npm install --package-lock-only # node — writes package-lock.json
|
|
306
|
+
npm ci # installs exactly package-lock.json
|
|
307
|
+
dotnet restore --use-lock-file # .NET — writes packages.lock.json
|
|
308
|
+
dotnet restore --locked-mode # installs exactly packages.lock.json
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Keep the lockfile tracked (do not gitignore it) and re-run the locked restore
|
|
312
|
+
to prove it holds from a clean cache before setting `deps_policy: locked`.
|
|
313
|
+
|
|
242
314
|
## Host boundary (security model)
|
|
243
315
|
|
|
244
316
|
The orchestrator, tool layer, and the `commit` action run in the **host
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentic-dev-loop"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.0a12"
|
|
8
8
|
description = "Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -55,6 +55,7 @@ agentic-dev-loop = "agentic_dev_loop.engine.cli:main"
|
|
|
55
55
|
dev = [
|
|
56
56
|
"hypothesis>=6.0",
|
|
57
57
|
"pytest>=8.0",
|
|
58
|
+
"pip-audit>=2.0",
|
|
58
59
|
]
|
|
59
60
|
|
|
60
61
|
[tool.setuptools.packages.find]
|
|
@@ -79,6 +79,13 @@ class IsolationConfig(BaseModel):
|
|
|
79
79
|
cpus: float | None = None # override exec container cpus
|
|
80
80
|
allow_git_mutations: bool = False # BR-6 knob
|
|
81
81
|
state_dir: str = "~/.agentic-dev-loop/state" # stamps + audit (host-only)
|
|
82
|
+
# 022 relocation opt-in: an out-of-workspace host dir that is bind-mounted
|
|
83
|
+
# at ``{ws}/.venv-sandbox`` (rw) in both the exec and provision containers,
|
|
84
|
+
# keeping the workspace free of container venv artifacts. Unset keeps the
|
|
85
|
+
# legacy workspace-local ``.venv-sandbox`` unchanged. Must resolve outside
|
|
86
|
+
# the workspace — validated at startup, not load (the workspace is unknown
|
|
87
|
+
# here).
|
|
88
|
+
sandbox_venv_dir: str | None = None
|
|
82
89
|
|
|
83
90
|
@field_validator("memory")
|
|
84
91
|
@classmethod
|
|
@@ -214,6 +221,27 @@ class PipelineConfig(BaseModel):
|
|
|
214
221
|
max_attempts: int = 3
|
|
215
222
|
agents_dir: str
|
|
216
223
|
steps: list[StepConfig] = Field(min_length=1)
|
|
224
|
+
# 021 supply-chain dependency dial: `locked` (default) = the human-owned
|
|
225
|
+
# lockfile is the source of truth and the agent may not add dependencies;
|
|
226
|
+
# `open` = the bootstrap protocol (the agent may add exact-pinned manifest
|
|
227
|
+
# entries for the restore step to fetch). `lockfiles` names the pipeline's
|
|
228
|
+
# declared lockfile(s) — injected into agent context and named by the
|
|
229
|
+
# missing-dependency fail-fast.
|
|
230
|
+
deps_policy: str = "locked"
|
|
231
|
+
lockfiles: list[str] = []
|
|
232
|
+
|
|
233
|
+
@field_validator("deps_policy")
|
|
234
|
+
@classmethod
|
|
235
|
+
def _known_deps_policy(cls, v: str) -> str:
|
|
236
|
+
if v not in ("locked", "open"):
|
|
237
|
+
raise ValueError(
|
|
238
|
+
f"deps_policy must be 'locked' or 'open', got {v!r}")
|
|
239
|
+
return v
|
|
240
|
+
|
|
241
|
+
@field_validator("lockfiles", mode="before")
|
|
242
|
+
@classmethod
|
|
243
|
+
def _coerce_lockfiles(cls, v: Any) -> Any:
|
|
244
|
+
return [v] if isinstance(v, str) else v
|
|
217
245
|
|
|
218
246
|
@field_validator("secrets", mode="before")
|
|
219
247
|
@classmethod
|
|
@@ -232,6 +260,15 @@ class PipelineConfig(BaseModel):
|
|
|
232
260
|
"values never belong in the pipeline yaml")
|
|
233
261
|
return v
|
|
234
262
|
|
|
263
|
+
@model_validator(mode="after")
|
|
264
|
+
def _locked_requires_lockfiles(self) -> "PipelineConfig":
|
|
265
|
+
if self.deps_policy == "locked" and not self.lockfiles:
|
|
266
|
+
raise ValueError(
|
|
267
|
+
"deps_policy 'locked' requires at least one entry in "
|
|
268
|
+
"'lockfiles:' — name the lockfile(s) the locked boundary "
|
|
269
|
+
"enforces, or set 'deps_policy: open'")
|
|
270
|
+
return self
|
|
271
|
+
|
|
235
272
|
@model_validator(mode="after")
|
|
236
273
|
def _check_graph(self) -> "PipelineConfig":
|
|
237
274
|
names = [s.name for s in self.steps]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Supply-chain dependency dial (021): canonical policy injection and
|
|
2
|
+
missing-dependency declaration parsing.
|
|
3
|
+
|
|
4
|
+
The rule text lives HERE, in the engine, not in consumer `dev.agent.md` prose
|
|
5
|
+
— the hand-authored rule is exactly the class that deadlocked the NuGet cache
|
|
6
|
+
in 018. `deps_policy: locked` (the default) bounds the agent to the
|
|
7
|
+
human-owned lockfile and fails a run fast when an agent declares a missing
|
|
8
|
+
dependency; `deps_policy: open` injects the bootstrap protocol (the agent may
|
|
9
|
+
add exact-pinned manifest entries for the restore step to fetch).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from agentic_dev_loop.core.config import PipelineConfig
|
|
18
|
+
|
|
19
|
+
MISSING_DEPS_HEADER = "## Missing dependencies"
|
|
20
|
+
|
|
21
|
+
_LOCKED_TEMPLATE = (
|
|
22
|
+
"You may only use dependencies already present in {lockfiles}. If the "
|
|
23
|
+
"design requires a dependency that is not present, declare it in your "
|
|
24
|
+
"final response under the fixed header `{header}` (one `name==version` "
|
|
25
|
+
"per line) and finish what you can. Do not add it to the manifest."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_OPEN_TEXT = (
|
|
29
|
+
"If the design needs a dependency that is not already present, add the "
|
|
30
|
+
"exact-pinned entry to the dependency manifest directly (do not run a "
|
|
31
|
+
"package-manager `add` command — it may need the network). Your offline "
|
|
32
|
+
"builds may fail until the restore step fetches it — that is expected; "
|
|
33
|
+
"write the code to match and leave the manifest entry in place."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _lockfile_label(cfg: "PipelineConfig") -> str:
|
|
38
|
+
if cfg.lockfiles:
|
|
39
|
+
return ", ".join(f"`{name}`" for name in cfg.lockfiles)
|
|
40
|
+
return "the lockfile"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def policy_injection(cfg: "PipelineConfig") -> str:
|
|
44
|
+
"""The canonical dependency-policy paragraph injected into every agent
|
|
45
|
+
step's context, keyed on ``deps_policy`` and the declared lockfiles."""
|
|
46
|
+
if cfg.deps_policy == "open":
|
|
47
|
+
return _OPEN_TEXT
|
|
48
|
+
return _LOCKED_TEMPLATE.format(
|
|
49
|
+
lockfiles=_lockfile_label(cfg), header=MISSING_DEPS_HEADER)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_missing_dependencies(text: str | None) -> list[str]:
|
|
53
|
+
"""Parse a final agent response for the fixed ``## Missing dependencies``
|
|
54
|
+
section. Returns the declared pins (one non-empty line each) beneath the
|
|
55
|
+
header, until the next ``## `` section or end of text. An absent header or
|
|
56
|
+
an empty/malformed section yields an empty list — only a non-empty
|
|
57
|
+
declaration triggers the fail-fast."""
|
|
58
|
+
if not text:
|
|
59
|
+
return []
|
|
60
|
+
lines = text.splitlines()
|
|
61
|
+
for i, line in enumerate(lines):
|
|
62
|
+
if line.strip() == MISSING_DEPS_HEADER:
|
|
63
|
+
pins: list[str] = []
|
|
64
|
+
for rest in lines[i + 1:]:
|
|
65
|
+
if rest.startswith("## "):
|
|
66
|
+
break
|
|
67
|
+
stripped = rest.strip()
|
|
68
|
+
if stripped:
|
|
69
|
+
pins.append(stripped)
|
|
70
|
+
return pins
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def extension_message(cfg: "PipelineConfig", pins: list[str]) -> str:
|
|
75
|
+
"""The fail-fast message naming the lockfile(s) and the declared pins."""
|
|
76
|
+
return (f"Extend {_lockfile_label(cfg)} with these pins and re-run:\n\n"
|
|
77
|
+
+ "\n".join(pins))
|
{agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/isolation.py
RENAMED
|
@@ -7,10 +7,13 @@ concrete implementation (user directive 2026-10-19). Everything else here is
|
|
|
7
7
|
either pure translation logic (the PBT-03 targets) or host-side audit/stamp
|
|
8
8
|
helpers.
|
|
9
9
|
|
|
10
|
-
Host-only vs sandboxed: the sandbox mount set is
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
Host-only vs sandboxed: the sandbox mount set is {workspace} plus the
|
|
11
|
+
host-only mask mounts (022): a read-only empty tmpfs at ``{ws}/.venv`` so the
|
|
12
|
+
host's throwaway venv can never be written from inside the sandbox. When
|
|
13
|
+
``isolation.sandbox_venv_dir`` relocates the sandbox venv, one extra rw bind
|
|
14
|
+
mount at ``{ws}/.venv-sandbox`` is added (opt-in). The isolation state dir
|
|
15
|
+
(stamps + audit JSONL) and the pipeline run-state dir (checkpoint DB +
|
|
16
|
+
last-error.md) are never mounted — sandboxed code cannot reach them.
|
|
14
17
|
"""
|
|
15
18
|
|
|
16
19
|
from __future__ import annotations
|
|
@@ -74,6 +77,10 @@ PROVISION_TIMEOUT_SECONDS = 3600
|
|
|
74
77
|
# container, so it must be detected and re-provisioned there.
|
|
75
78
|
SANDBOX_VENV_DIR = ".venv-sandbox"
|
|
76
79
|
SANDBOX_VENV_PYTHON = ".venv-sandbox/bin/python"
|
|
80
|
+
# Host-only dirs masked inside the sandbox (022): each gets a read-only empty
|
|
81
|
+
# tmpfs submount at ``{ws}/<dir>`` so agents fail loudly on any write instead
|
|
82
|
+
# of silently mutating a host-owned throwaway venv.
|
|
83
|
+
HOST_ONLY_DIRS = (".venv",)
|
|
77
84
|
|
|
78
85
|
|
|
79
86
|
class SandboxError(RuntimeError):
|
|
@@ -277,6 +284,27 @@ def translate_mount_path(path: str) -> str:
|
|
|
277
284
|
return str(p)
|
|
278
285
|
|
|
279
286
|
|
|
287
|
+
def resolve_sandbox_venv_dir(sandbox_venv_dir: str | None,
|
|
288
|
+
workspace: str | Path) -> str | None:
|
|
289
|
+
"""Resolve the opt-in relocated sandbox venv (022).
|
|
290
|
+
|
|
291
|
+
``None`` keeps the legacy workspace-local ``.venv-sandbox`` (bit-compatible
|
|
292
|
+
default — payloads are unchanged). A set value is expanduser + absolute and
|
|
293
|
+
must resolve OUTSIDE the workspace: an in-workspace value defeats the whole
|
|
294
|
+
relocation and fails loud (AE-SBX-015).
|
|
295
|
+
"""
|
|
296
|
+
if sandbox_venv_dir is None:
|
|
297
|
+
return None
|
|
298
|
+
resolved = Path(translate_mount_path(sandbox_venv_dir))
|
|
299
|
+
ws = Path(translate_mount_path(os.fspath(workspace)))
|
|
300
|
+
if resolved == ws or ws in resolved.parents:
|
|
301
|
+
raise SandboxError(
|
|
302
|
+
f"AE-SBX-015: isolation.sandbox_venv_dir {resolved!r} must resolve "
|
|
303
|
+
"outside the workspace — an in-workspace value defeats the 022 "
|
|
304
|
+
"relocation (docs/setup.md §3)")
|
|
305
|
+
return str(resolved)
|
|
306
|
+
|
|
307
|
+
|
|
280
308
|
def setup_stamp_key(setup_cmd: str, lockfile_hashes: dict[str, str]) -> str:
|
|
281
309
|
"""sha256 hex of canonical JSON {setup_cmd, lockfiles: sorted hashes}."""
|
|
282
310
|
canonical = {
|
|
@@ -323,10 +351,44 @@ def venv_probe_state(stdout: str, exit_code: int | None) -> str:
|
|
|
323
351
|
return line if line in ("valid", "missing", "broken") else "unknown"
|
|
324
352
|
|
|
325
353
|
|
|
326
|
-
def
|
|
327
|
-
"""
|
|
328
|
-
|
|
329
|
-
|
|
354
|
+
def _clear_dir_contents(path: Path) -> None:
|
|
355
|
+
"""Delete every entry under ``path``, keeping ``path`` itself. The 022
|
|
356
|
+
relocated venv is the bind-mount source for the still-running execution
|
|
357
|
+
container, so the source dir must survive removal (deleting it would leave
|
|
358
|
+
the container pointed at a stale inode)."""
|
|
359
|
+
if not path.exists():
|
|
360
|
+
return
|
|
361
|
+
for child in path.iterdir():
|
|
362
|
+
if child.is_symlink() or child.is_file():
|
|
363
|
+
child.unlink()
|
|
364
|
+
elif child.is_dir():
|
|
365
|
+
shutil.rmtree(child)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def remove_sandbox_venv(workspace: str | Path, *,
|
|
369
|
+
sandbox_venv_dir: str | None = None) -> None:
|
|
370
|
+
"""Remove the sandbox venv before re-provision (016 self-heal). Fails loud
|
|
371
|
+
when the venv exists but cannot be removed — a silent skip would re-create
|
|
372
|
+
the ENOENT failure the next run.
|
|
373
|
+
|
|
374
|
+
With the 022 relocation opt-in (``sandbox_venv_dir``), the venv lives at
|
|
375
|
+
that host path and is the source of a live bind mount in the execution
|
|
376
|
+
container — so only its CONTENTS are removed (the mount source dir stays),
|
|
377
|
+
leaving the container's ``{ws}/.venv-sandbox`` mount valid for re-provision.
|
|
378
|
+
The default (unset) path removes the whole workspace-local dir as before.
|
|
379
|
+
"""
|
|
380
|
+
if sandbox_venv_dir is not None:
|
|
381
|
+
venv = Path(sandbox_venv_dir)
|
|
382
|
+
try:
|
|
383
|
+
if venv.is_symlink() or venv.is_file():
|
|
384
|
+
venv.unlink()
|
|
385
|
+
elif venv.is_dir():
|
|
386
|
+
_clear_dir_contents(venv)
|
|
387
|
+
except OSError as e:
|
|
388
|
+
raise SandboxError(
|
|
389
|
+
f"AE-SBX-014: cannot remove invalid sandbox venv at {venv}: {e} "
|
|
390
|
+
f"— remove it manually and re-run (docs/setup.md §5)") from e
|
|
391
|
+
return
|
|
330
392
|
venv = Path(workspace) / SANDBOX_VENV_DIR
|
|
331
393
|
try:
|
|
332
394
|
if venv.is_symlink() or venv.is_file():
|
|
@@ -360,15 +422,34 @@ def exec_container_name(run_id: str, network: bool = False) -> str:
|
|
|
360
422
|
return f"{run_id}-exec-net" if network else f"{run_id}-exec"
|
|
361
423
|
|
|
362
424
|
|
|
425
|
+
def _sandbox_mounts(workspace: str, sandbox_venv_dir: str | None) -> list[str]:
|
|
426
|
+
"""The mount segment shared by the exec and provision payloads (022).
|
|
427
|
+
|
|
428
|
+
``{workspace}`` rw is always first; each host-only dir is then shadowed by
|
|
429
|
+
a read-only empty tmpfs submount (Docker cannot exclude a subdirectory of a
|
|
430
|
+
bind — a submount is the only way to hide it, and ``ro`` makes any write
|
|
431
|
+
attempt fail loudly instead of silently succeeding against a decoy). When
|
|
432
|
+
``sandbox_venv_dir`` relocates the sandbox venv, one rw bind mount maps it
|
|
433
|
+
to the unchanged container path ``{ws}/.venv-sandbox``.
|
|
434
|
+
"""
|
|
435
|
+
mounts = ["-v", f"{workspace}:{workspace}"]
|
|
436
|
+
for host_only in HOST_ONLY_DIRS:
|
|
437
|
+
mounts += ["--tmpfs", f"{workspace}/{host_only}:ro"]
|
|
438
|
+
if sandbox_venv_dir is not None:
|
|
439
|
+
mounts += ["-v", f"{sandbox_venv_dir}:{workspace}/{SANDBOX_VENV_DIR}"]
|
|
440
|
+
return mounts
|
|
441
|
+
|
|
442
|
+
|
|
363
443
|
def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
|
|
364
|
-
workspace: str, network: bool = False
|
|
444
|
+
workspace: str, network: bool = False,
|
|
445
|
+
sandbox_venv_dir: str | None = None) -> list[str]:
|
|
365
446
|
"""Exact ``docker run -d --rm`` argv for the execution container (§3.3, KD-8).
|
|
366
|
-
|
|
367
|
-
is true — 014 per-stage networking), --read-only,
|
|
368
|
-
host uid:gid (host-owned bind mounts, AE-SBX-009
|
|
369
|
-
HOME=/tmp (the rootfs is read-only; /tmp is a 1777 tmpfs).
|
|
370
|
-
daemon auto-remove the container the instant it exits,
|
|
371
|
-
(D1) — a SIGKILLed CLI never reaches close()."""
|
|
447
|
+
Mount set {workspace} + host-only masks (022); ``--network none`` (bridge
|
|
448
|
+
when ``network`` is true — 014 per-stage networking), --read-only,
|
|
449
|
+
--cap-drop ALL; --user host uid:gid (host-owned bind mounts, AE-SBX-009
|
|
450
|
+
root cause) with HOME=/tmp (the rootfs is read-only; /tmp is a 1777 tmpfs).
|
|
451
|
+
``--rm`` lets the daemon auto-remove the container the instant it exits,
|
|
452
|
+
however it exits (D1) — a SIGKILLed CLI never reaches close()."""
|
|
372
453
|
payload = [
|
|
373
454
|
"docker", "run", "--detach", "--rm", "--name",
|
|
374
455
|
exec_container_name(run_id, network),
|
|
@@ -380,8 +461,8 @@ def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
|
|
|
380
461
|
user = host_user_flag()
|
|
381
462
|
if user is not None:
|
|
382
463
|
payload.extend(["--user", user, "--env", "HOME=/tmp"])
|
|
464
|
+
payload.extend(_sandbox_mounts(workspace, sandbox_venv_dir))
|
|
383
465
|
payload.extend([
|
|
384
|
-
"-v", f"{workspace}:{workspace}",
|
|
385
466
|
"--workdir", workspace,
|
|
386
467
|
image, "sleep", "infinity",
|
|
387
468
|
])
|
|
@@ -389,10 +470,12 @@ def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
|
|
|
389
470
|
|
|
390
471
|
|
|
391
472
|
def docker_provision_payload(*, run_id: str, image: str, workspace: str,
|
|
392
|
-
setup_cmd: str
|
|
473
|
+
setup_cmd: str,
|
|
474
|
+
sandbox_venv_dir: str | None = None) -> list[str]:
|
|
393
475
|
"""Exact ``docker run --rm`` argv for the provision container (§3.4).
|
|
394
|
-
Network ON (default bridge), rw rootfs, --cap-drop ALL,
|
|
395
|
-
{workspace} (
|
|
476
|
+
Network ON (default bridge), rw rootfs, --cap-drop ALL, mount set
|
|
477
|
+
{workspace} + host-only masks (022); --user host uid:gid (AE-SBX-009) with
|
|
478
|
+
HOME=/tmp."""
|
|
396
479
|
payload = [
|
|
397
480
|
"docker", "run", "--rm", "--name", f"{run_id}-provision",
|
|
398
481
|
"--network", "bridge", "--cap-drop", "ALL",
|
|
@@ -401,8 +484,8 @@ def docker_provision_payload(*, run_id: str, image: str, workspace: str,
|
|
|
401
484
|
user = host_user_flag()
|
|
402
485
|
if user is not None:
|
|
403
486
|
payload.extend(["--user", user, "--env", "HOME=/tmp"])
|
|
487
|
+
payload.extend(_sandbox_mounts(workspace, sandbox_venv_dir))
|
|
404
488
|
payload.extend([
|
|
405
|
-
"-v", f"{workspace}:{workspace}",
|
|
406
489
|
"--workdir", workspace,
|
|
407
490
|
image, "sh", "-c", setup_cmd,
|
|
408
491
|
])
|
|
@@ -502,7 +585,7 @@ class DockerBackend(Sandbox):
|
|
|
502
585
|
|
|
503
586
|
def __init__(self, *, image: str, workspace: str, iso_state_dir: str,
|
|
504
587
|
profile: EffectiveProfile, runner: CommandRunner | None = None,
|
|
505
|
-
network: bool = False):
|
|
588
|
+
network: bool = False, sandbox_venv_dir: str | None = None):
|
|
506
589
|
self._image = image
|
|
507
590
|
self._workspace = workspace
|
|
508
591
|
self._iso_state_dir = Path(iso_state_dir) # host-only (audit)
|
|
@@ -512,6 +595,13 @@ class DockerBackend(Sandbox):
|
|
|
512
595
|
self._container = exec_container_name(self._run_id, network=False)
|
|
513
596
|
self._network_container = exec_container_name(self._run_id, network=True)
|
|
514
597
|
self._network_enabled = network
|
|
598
|
+
self._sandbox_venv_dir = sandbox_venv_dir # 022 relocated venv (opt-in)
|
|
599
|
+
if sandbox_venv_dir is not None:
|
|
600
|
+
# 022: the relocated venv dir is a bind-mount source. On native
|
|
601
|
+
# Linux, Docker auto-creates a missing source as root, which the
|
|
602
|
+
# --user uid:gid containers then cannot write (the AE-SBX-009
|
|
603
|
+
# ownership class). Pre-create it host-side so it is uid-owned.
|
|
604
|
+
Path(sandbox_venv_dir).mkdir(parents=True, exist_ok=True)
|
|
515
605
|
self._started = False
|
|
516
606
|
self._network_started = False
|
|
517
607
|
|
|
@@ -551,7 +641,8 @@ class DockerBackend(Sandbox):
|
|
|
551
641
|
infrastructure work happens mid-run (014 per-stage networking)."""
|
|
552
642
|
payload = docker_create_payload(
|
|
553
643
|
run_id=self._run_id, image=self._image, profile=self._profile,
|
|
554
|
-
workspace=self._workspace, network=False
|
|
644
|
+
workspace=self._workspace, network=False,
|
|
645
|
+
sandbox_venv_dir=self._sandbox_venv_dir)
|
|
555
646
|
res = self._runner.run(payload, timeout=60)
|
|
556
647
|
if res.exit_code != 0:
|
|
557
648
|
raise SandboxError(
|
|
@@ -562,7 +653,8 @@ class DockerBackend(Sandbox):
|
|
|
562
653
|
return
|
|
563
654
|
net_payload = docker_create_payload(
|
|
564
655
|
run_id=self._run_id, image=self._image, profile=self._profile,
|
|
565
|
-
workspace=self._workspace, network=True
|
|
656
|
+
workspace=self._workspace, network=True,
|
|
657
|
+
sandbox_venv_dir=self._sandbox_venv_dir)
|
|
566
658
|
net_res = self._runner.run(net_payload, timeout=60)
|
|
567
659
|
if net_res.exit_code != 0:
|
|
568
660
|
# reclaim the already-started network-none container — never leak it.
|
|
@@ -587,12 +679,13 @@ class DockerBackend(Sandbox):
|
|
|
587
679
|
"""
|
|
588
680
|
if not force and read_stamp(Path(stamp_dir), stamp_key):
|
|
589
681
|
if self._probe_venv() == "broken":
|
|
590
|
-
remove_sandbox_venv(self._workspace
|
|
682
|
+
remove_sandbox_venv(self._workspace,
|
|
683
|
+
sandbox_venv_dir=self._sandbox_venv_dir)
|
|
591
684
|
else:
|
|
592
685
|
return
|
|
593
686
|
payload = docker_provision_payload(
|
|
594
687
|
run_id=self._run_id, image=self._image, workspace=self._workspace,
|
|
595
|
-
setup_cmd=setup_cmd)
|
|
688
|
+
setup_cmd=setup_cmd, sandbox_venv_dir=self._sandbox_venv_dir)
|
|
596
689
|
res = self._runner.run(payload, timeout=PROVISION_TIMEOUT_SECONDS)
|
|
597
690
|
if res.exit_code != 0:
|
|
598
691
|
tail = (res.stdout or res.stderr or "")[-4000:]
|
|
@@ -846,9 +939,12 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
|
|
|
846
939
|
image = resolve_image_ref(profile.image)
|
|
847
940
|
iso_state_dir = isolation_state_dir(cfg)
|
|
848
941
|
needs_network = any(step.network for step in cfg.steps)
|
|
942
|
+
sandbox_venv_dir = resolve_sandbox_venv_dir(
|
|
943
|
+
cfg.isolation.sandbox_venv_dir if cfg.isolation else None, workspace)
|
|
849
944
|
backend = DockerBackend(
|
|
850
945
|
image=image, workspace=str(workspace), iso_state_dir=str(iso_state_dir),
|
|
851
|
-
profile=profile, runner=runner, network=needs_network
|
|
946
|
+
profile=profile, runner=runner, network=needs_network,
|
|
947
|
+
sandbox_venv_dir=sandbox_venv_dir)
|
|
852
948
|
backend.ensure_image()
|
|
853
949
|
backend.create_execution_container()
|
|
854
950
|
if refresh is None:
|
{agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/stages.py
RENAMED
|
@@ -32,6 +32,7 @@ from openhands.sdk import Conversation
|
|
|
32
32
|
|
|
33
33
|
from .agents import load_agent, register_sandbox_terminal
|
|
34
34
|
from agentic_dev_loop.core.config import TERMINALS, PipelineConfig, Route, StepConfig, route_target
|
|
35
|
+
from .deps import extension_message, parse_missing_dependencies, policy_injection
|
|
35
36
|
from .isolation import SandboxExecutionRequest, command_argv, resolve_sandbox_env
|
|
36
37
|
from .outputs import CapturedOutput
|
|
37
38
|
from .secrets import redact_values, step_secret_env
|
|
@@ -198,11 +199,13 @@ def _run_command(command: str, workspace: Path, timeout: float,
|
|
|
198
199
|
def build_context(runtime: Runtime, docs: dict[str, str], subject: str,
|
|
199
200
|
notes: list[str] | None = None) -> str:
|
|
200
201
|
"""Assemble the attempt's shared message: subject, doc bundle, the
|
|
201
|
-
|
|
202
|
-
|
|
202
|
+
canonical dependency-policy paragraph (021), the expected-output-paths
|
|
203
|
+
section generated from step defs (a prompt merely naming files lets agents
|
|
204
|
+
skip them), and previous-attempt notes."""
|
|
203
205
|
parts = [f"You are working on {subject}.\n"]
|
|
204
206
|
for name, content in docs.items():
|
|
205
207
|
parts.append(f"## {name}\n\n{content}")
|
|
208
|
+
parts.append("## Dependency policy\n\n" + policy_injection(runtime.cfg))
|
|
206
209
|
lines = []
|
|
207
210
|
for step in runtime.cfg.steps:
|
|
208
211
|
if step.agent is None:
|
|
@@ -301,6 +304,22 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
|
|
|
301
304
|
result.truncated, max_iterations)],
|
|
302
305
|
"retry_target": _unclear_target(runtime, step)}
|
|
303
306
|
|
|
307
|
+
# 021: under `locked`, a declared missing dependency is a fail-fast —
|
|
308
|
+
# one human lockfile edit, never a retry loop. The extension message
|
|
309
|
+
# is written to last-error.md (the run-failure channel) and printed.
|
|
310
|
+
if (runtime.cfg.deps_policy == "locked"
|
|
311
|
+
and result.response is not None):
|
|
312
|
+
pins = parse_missing_dependencies(result.response)
|
|
313
|
+
if pins:
|
|
314
|
+
msg = extension_message(runtime.cfg, pins)
|
|
315
|
+
runtime.state_dir.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
(runtime.state_dir / LAST_ERROR).write_text(
|
|
317
|
+
f"# Missing dependencies\n\n```\n{msg}\n```\n",
|
|
318
|
+
encoding="utf-8")
|
|
319
|
+
print(redact_values(msg, runtime.secrets.values()),
|
|
320
|
+
file=sys.stderr, flush=True)
|
|
321
|
+
return {"failed": True, "failed_step": step.name}
|
|
322
|
+
|
|
304
323
|
if verdicts_map is None:
|
|
305
324
|
merged = {**state["step_verdicts"], step.name: "pass"} if captures else \
|
|
306
325
|
state["step_verdicts"]
|