agentic-dev-loop 0.3.0__tar.gz → 1.0.0a1__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.
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/PKG-INFO +37 -12
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/README.md +36 -11
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/pyproject.toml +6 -1
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/actions.py +45 -2
- agentic_dev_loop-1.0.0a1/src/agentic_dev_loop/agents.py +125 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/cli.py +23 -1
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/config.py +56 -2
- agentic_dev_loop-1.0.0a1/src/agentic_dev_loop/dryrun.py +122 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/extensions/aidlc.py +1 -1
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/graph.py +24 -27
- agentic_dev_loop-1.0.0a1/src/agentic_dev_loop/isolation.py +720 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/loaders.py +25 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/outputs.py +1 -1
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/proc.py +46 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/stages.py +45 -19
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/viz.py +10 -3
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop.egg-info/PKG-INFO +37 -12
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop.egg-info/SOURCES.txt +6 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_actions.py +117 -6
- agentic_dev_loop-1.0.0a1/tests/test_agents.py +167 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_aidlc.py +5 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_cli.py +47 -1
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_config.py +90 -2
- agentic_dev_loop-1.0.0a1/tests/test_dry_run.py +137 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_e2e_fixtures.py +14 -4
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_graph.py +33 -2
- agentic_dev_loop-1.0.0a1/tests/test_isolation.py +662 -0
- agentic_dev_loop-1.0.0a1/tests/test_isolation_integration.py +128 -0
- agentic_dev_loop-1.0.0a1/tests/test_isolation_pbt.py +140 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_outputs_pbt.py +3 -1
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_proc.py +72 -1
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_sdk_compat.py +22 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_source_resolution.py +1 -3
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_stages.py +75 -8
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_viz.py +33 -2
- agentic_dev_loop-0.3.0/src/agentic_dev_loop/agents.py +0 -49
- agentic_dev_loop-0.3.0/tests/test_agents.py +0 -88
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/LICENSE +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/setup.cfg +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/extensions/__init__.py +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/llm.py +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/registry.py +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/results.py +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_llm.py +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_publish.py +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_registry.py +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_results.py +0 -0
- {agentic_dev_loop-0.3.0 → agentic_dev_loop-1.0.0a1}/tests/test_verdict.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentic-dev-loop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0a1
|
|
4
4
|
Summary: Opinionated LangGraph engine for OpenHands agent pipelines
|
|
5
5
|
Author: Luke Wilson
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -50,6 +50,17 @@ Publicly depends on [OpenHands SDK](https://github.com/OpenHands/software-agent-
|
|
|
50
50
|
for agent execution and [LangGraph](https://github.com/langchain-ai/langgraph)
|
|
51
51
|
(+ `langgraph-checkpoint-sqlite`) for the state graph.
|
|
52
52
|
|
|
53
|
+
## Sandboxed execution
|
|
54
|
+
|
|
55
|
+
The agent `terminal` tool and command steps run inside an **always-on**,
|
|
56
|
+
Docker-based kernel-level sandbox — `--network none`, read-only rootfs,
|
|
57
|
+
dropped capabilities, writes only on the bind-mounted workspace. The
|
|
58
|
+
`isolation:` section configures the sandbox (image override, resource caps,
|
|
59
|
+
per-step `env:`/`setup:`, git-mutation blocking); it can never disable it. A
|
|
60
|
+
missing or unusable Docker engine fails fast with an `AE-SBX-*` message. See
|
|
61
|
+
[docs/setup.md](docs/setup.md) for the engine setup, first-run image pull, the
|
|
62
|
+
`pipeline.yaml` contract, stamp caching, and troubleshooting.
|
|
63
|
+
|
|
53
64
|
## Install & test
|
|
54
65
|
|
|
55
66
|
```bash
|
|
@@ -67,6 +78,7 @@ export OPENAI_API_KEY=... # whatever env var your pipeline's api_key_env names
|
|
|
67
78
|
agentic-dev-loop agentic-dev-loop/pipeline.yaml --plan plan.md
|
|
68
79
|
agentic-dev-loop agentic-dev-loop/pipeline.yaml --aidlc-unit unit-2
|
|
69
80
|
agentic-dev-loop agentic-dev-loop/pipeline.yaml --resume <thread-id> --max-attempts 5
|
|
81
|
+
agentic-dev-loop agentic-dev-loop/pipeline.yaml --plan plan.md --dry-run
|
|
70
82
|
./trigger-agentic-dev-loop.sh --plan plan.md # same thing, from the repo root
|
|
71
83
|
```
|
|
72
84
|
|
|
@@ -84,6 +96,15 @@ expanded and live-tails its console. To eyeball the viz page without
|
|
|
84
96
|
running a pipeline, `.venv/bin/python viz_demo.py` serves the real topology
|
|
85
97
|
and replays a scripted run (every node state, verdict badges, retry cycle).
|
|
86
98
|
|
|
99
|
+
`--dry-run` performs the full load-and-validate pass — YAML parse, step
|
|
100
|
+
config validation, extension and document-loader loading, agents_dir /
|
|
101
|
+
verdict / routing-target resolution, and pure image-ref resolution — without
|
|
102
|
+
executing anything, then prints a summary plan (pipeline path, subject,
|
|
103
|
+
resolved loader + docs, per-step kind and key params, and the resolved
|
|
104
|
+
isolation profile). It makes no LLM call, touches no Docker daemon, and
|
|
105
|
+
writes no state, so it needs no API key; it exits `0` on a clean load and
|
|
106
|
+
`1` with the usual load error otherwise.
|
|
107
|
+
|
|
87
108
|
`--aidlc-unit` takes the unit directory name under
|
|
88
109
|
`aidlc-docs/construction/` verbatim — `unit-2`,
|
|
89
110
|
`unit-002-custom-configuration`, or any slug AI-DLC's own naming uses
|
|
@@ -107,11 +128,12 @@ dev ──▶ test ──▶ review ──▶ qa ──▶ commit
|
|
|
107
128
|
`additional_files:` + auto-generated expected-output paths). Its writes
|
|
108
129
|
are untracked; the pipeline only cares about declared `produces:` files.
|
|
109
130
|
2. **test** is a mechanical command step: it runs the suite
|
|
110
|
-
(`.venv/bin/python -m pytest tests/ -q`)
|
|
111
|
-
passes and moves on; anything else (including no exit by
|
|
112
|
-
and rewinds to `dev` with the failing output in the retry
|
|
131
|
+
(`.venv-sandbox/bin/python -m pytest tests/ -q -m "not docker"`) inside the
|
|
132
|
+
sandbox. Exit 0 passes and moves on; anything else (including no exit by
|
|
133
|
+
`timeout`) fails and rewinds to `dev` with the failing output in the retry
|
|
134
|
+
note.
|
|
113
135
|
3. **review / qa** are verdict agents: each must write its declared
|
|
114
|
-
artifact (e.g.
|
|
136
|
+
artifact (e.g. `qa-report.md`) whose **last** `VERDICT: <word>` line
|
|
115
137
|
decides routing. `pass` follows `on_pass`, `fail` follows `on_fail` —
|
|
116
138
|
here `{goto: dev, retry: true}`, which consumes an attempt and feeds the
|
|
117
139
|
artifact back to the dev agent as a retry note.
|
|
@@ -141,7 +163,8 @@ llm:
|
|
|
141
163
|
api_key_env: OPENAI_API_KEY # missing env var → run fails fast, naming it
|
|
142
164
|
# timeout: null # default = no client timeout (long agent runs)
|
|
143
165
|
|
|
144
|
-
|
|
166
|
+
# state_dir omitted → default ~/.agentic-dev-loop/runs/<pipeline-hash>/
|
|
167
|
+
# (artifacts + checkpoints live OUTSIDE the sandbox mount)
|
|
145
168
|
max_attempts: 10 # retries across the whole graph
|
|
146
169
|
agents_dir: sdk_agents # consumer-owned <name>.agent.md files
|
|
147
170
|
|
|
@@ -152,8 +175,9 @@ steps:
|
|
|
152
175
|
on_pass: test # explicit; omitted → next step in order
|
|
153
176
|
|
|
154
177
|
- name: test
|
|
155
|
-
command: .venv/bin/python -m pytest tests/ -q #
|
|
156
|
-
|
|
178
|
+
command: .venv-sandbox/bin/python -m pytest tests/ -q -m "not docker" # sandboxed: exit code is the verdict
|
|
179
|
+
setup: python -m venv .venv-sandbox && .venv-sandbox/bin/pip install -e ".[dev]" # provision (network ON, stamp-cached)
|
|
180
|
+
timeout: 600 # seconds
|
|
157
181
|
on_pass: review
|
|
158
182
|
on_fail: {goto: dev, retry: true}
|
|
159
183
|
|
|
@@ -193,8 +217,9 @@ load. Graph-building dispatches on this `kind` (see `StepConfig.kind` in
|
|
|
193
217
|
`artifact:` + `verdicts:` (verdict-parsed routing). `max_iterations` caps
|
|
194
218
|
the agent's own loop.
|
|
195
219
|
- **`command: <shell>`** — a *command step*: mechanical, never an LLM call.
|
|
196
|
-
The command runs
|
|
197
|
-
(including no exit by `timeout`) fails. `timeout:`
|
|
220
|
+
The command runs inside the sandbox (the bind-mounted workspace); exit 0
|
|
221
|
+
passes, anything else (including no exit by `timeout`) fails. `timeout:` and
|
|
222
|
+
`setup:` are its only extra fields.
|
|
198
223
|
- **`action: <name>`** — an *action step*: invokes a registered node-shaped
|
|
199
224
|
function `(state, params) -> state update` with the step's `params:`. The
|
|
200
225
|
built-in is `commit`; extensions register more via `registry.add_action`.
|
|
@@ -205,8 +230,8 @@ load. Graph-building dispatches on this `kind` (see `StepConfig.kind` in
|
|
|
205
230
|
|
|
206
231
|
### Step semantics
|
|
207
232
|
|
|
208
|
-
- A `command` step is **mechanical** — the command runs
|
|
209
|
-
|
|
233
|
+
- A `command` step is **mechanical** — the command runs inside the
|
|
234
|
+
sandbox; exit 0 passes, anything else (including no exit by `timeout`,
|
|
210
235
|
default 600s) fails and follows `on_fail` with the output tail in the
|
|
211
236
|
retry note. The kind is the `command:` key, never the step's name.
|
|
212
237
|
- `artifact` + `verdicts` mark a **verdict step**. `on_pass`/`on_fail` may be
|
|
@@ -12,6 +12,17 @@ Publicly depends on [OpenHands SDK](https://github.com/OpenHands/software-agent-
|
|
|
12
12
|
for agent execution and [LangGraph](https://github.com/langchain-ai/langgraph)
|
|
13
13
|
(+ `langgraph-checkpoint-sqlite`) for the state graph.
|
|
14
14
|
|
|
15
|
+
## Sandboxed execution
|
|
16
|
+
|
|
17
|
+
The agent `terminal` tool and command steps run inside an **always-on**,
|
|
18
|
+
Docker-based kernel-level sandbox — `--network none`, read-only rootfs,
|
|
19
|
+
dropped capabilities, writes only on the bind-mounted workspace. The
|
|
20
|
+
`isolation:` section configures the sandbox (image override, resource caps,
|
|
21
|
+
per-step `env:`/`setup:`, git-mutation blocking); it can never disable it. A
|
|
22
|
+
missing or unusable Docker engine fails fast with an `AE-SBX-*` message. See
|
|
23
|
+
[docs/setup.md](docs/setup.md) for the engine setup, first-run image pull, the
|
|
24
|
+
`pipeline.yaml` contract, stamp caching, and troubleshooting.
|
|
25
|
+
|
|
15
26
|
## Install & test
|
|
16
27
|
|
|
17
28
|
```bash
|
|
@@ -29,6 +40,7 @@ export OPENAI_API_KEY=... # whatever env var your pipeline's api_key_env names
|
|
|
29
40
|
agentic-dev-loop agentic-dev-loop/pipeline.yaml --plan plan.md
|
|
30
41
|
agentic-dev-loop agentic-dev-loop/pipeline.yaml --aidlc-unit unit-2
|
|
31
42
|
agentic-dev-loop agentic-dev-loop/pipeline.yaml --resume <thread-id> --max-attempts 5
|
|
43
|
+
agentic-dev-loop agentic-dev-loop/pipeline.yaml --plan plan.md --dry-run
|
|
32
44
|
./trigger-agentic-dev-loop.sh --plan plan.md # same thing, from the repo root
|
|
33
45
|
```
|
|
34
46
|
|
|
@@ -46,6 +58,15 @@ expanded and live-tails its console. To eyeball the viz page without
|
|
|
46
58
|
running a pipeline, `.venv/bin/python viz_demo.py` serves the real topology
|
|
47
59
|
and replays a scripted run (every node state, verdict badges, retry cycle).
|
|
48
60
|
|
|
61
|
+
`--dry-run` performs the full load-and-validate pass — YAML parse, step
|
|
62
|
+
config validation, extension and document-loader loading, agents_dir /
|
|
63
|
+
verdict / routing-target resolution, and pure image-ref resolution — without
|
|
64
|
+
executing anything, then prints a summary plan (pipeline path, subject,
|
|
65
|
+
resolved loader + docs, per-step kind and key params, and the resolved
|
|
66
|
+
isolation profile). It makes no LLM call, touches no Docker daemon, and
|
|
67
|
+
writes no state, so it needs no API key; it exits `0` on a clean load and
|
|
68
|
+
`1` with the usual load error otherwise.
|
|
69
|
+
|
|
49
70
|
`--aidlc-unit` takes the unit directory name under
|
|
50
71
|
`aidlc-docs/construction/` verbatim — `unit-2`,
|
|
51
72
|
`unit-002-custom-configuration`, or any slug AI-DLC's own naming uses
|
|
@@ -69,11 +90,12 @@ dev ──▶ test ──▶ review ──▶ qa ──▶ commit
|
|
|
69
90
|
`additional_files:` + auto-generated expected-output paths). Its writes
|
|
70
91
|
are untracked; the pipeline only cares about declared `produces:` files.
|
|
71
92
|
2. **test** is a mechanical command step: it runs the suite
|
|
72
|
-
(`.venv/bin/python -m pytest tests/ -q`)
|
|
73
|
-
passes and moves on; anything else (including no exit by
|
|
74
|
-
and rewinds to `dev` with the failing output in the retry
|
|
93
|
+
(`.venv-sandbox/bin/python -m pytest tests/ -q -m "not docker"`) inside the
|
|
94
|
+
sandbox. Exit 0 passes and moves on; anything else (including no exit by
|
|
95
|
+
`timeout`) fails and rewinds to `dev` with the failing output in the retry
|
|
96
|
+
note.
|
|
75
97
|
3. **review / qa** are verdict agents: each must write its declared
|
|
76
|
-
artifact (e.g.
|
|
98
|
+
artifact (e.g. `qa-report.md`) whose **last** `VERDICT: <word>` line
|
|
77
99
|
decides routing. `pass` follows `on_pass`, `fail` follows `on_fail` —
|
|
78
100
|
here `{goto: dev, retry: true}`, which consumes an attempt and feeds the
|
|
79
101
|
artifact back to the dev agent as a retry note.
|
|
@@ -103,7 +125,8 @@ llm:
|
|
|
103
125
|
api_key_env: OPENAI_API_KEY # missing env var → run fails fast, naming it
|
|
104
126
|
# timeout: null # default = no client timeout (long agent runs)
|
|
105
127
|
|
|
106
|
-
|
|
128
|
+
# state_dir omitted → default ~/.agentic-dev-loop/runs/<pipeline-hash>/
|
|
129
|
+
# (artifacts + checkpoints live OUTSIDE the sandbox mount)
|
|
107
130
|
max_attempts: 10 # retries across the whole graph
|
|
108
131
|
agents_dir: sdk_agents # consumer-owned <name>.agent.md files
|
|
109
132
|
|
|
@@ -114,8 +137,9 @@ steps:
|
|
|
114
137
|
on_pass: test # explicit; omitted → next step in order
|
|
115
138
|
|
|
116
139
|
- name: test
|
|
117
|
-
command: .venv/bin/python -m pytest tests/ -q #
|
|
118
|
-
|
|
140
|
+
command: .venv-sandbox/bin/python -m pytest tests/ -q -m "not docker" # sandboxed: exit code is the verdict
|
|
141
|
+
setup: python -m venv .venv-sandbox && .venv-sandbox/bin/pip install -e ".[dev]" # provision (network ON, stamp-cached)
|
|
142
|
+
timeout: 600 # seconds
|
|
119
143
|
on_pass: review
|
|
120
144
|
on_fail: {goto: dev, retry: true}
|
|
121
145
|
|
|
@@ -155,8 +179,9 @@ load. Graph-building dispatches on this `kind` (see `StepConfig.kind` in
|
|
|
155
179
|
`artifact:` + `verdicts:` (verdict-parsed routing). `max_iterations` caps
|
|
156
180
|
the agent's own loop.
|
|
157
181
|
- **`command: <shell>`** — a *command step*: mechanical, never an LLM call.
|
|
158
|
-
The command runs
|
|
159
|
-
(including no exit by `timeout`) fails. `timeout:`
|
|
182
|
+
The command runs inside the sandbox (the bind-mounted workspace); exit 0
|
|
183
|
+
passes, anything else (including no exit by `timeout`) fails. `timeout:` and
|
|
184
|
+
`setup:` are its only extra fields.
|
|
160
185
|
- **`action: <name>`** — an *action step*: invokes a registered node-shaped
|
|
161
186
|
function `(state, params) -> state update` with the step's `params:`. The
|
|
162
187
|
built-in is `commit`; extensions register more via `registry.add_action`.
|
|
@@ -167,8 +192,8 @@ load. Graph-building dispatches on this `kind` (see `StepConfig.kind` in
|
|
|
167
192
|
|
|
168
193
|
### Step semantics
|
|
169
194
|
|
|
170
|
-
- A `command` step is **mechanical** — the command runs
|
|
171
|
-
|
|
195
|
+
- A `command` step is **mechanical** — the command runs inside the
|
|
196
|
+
sandbox; exit 0 passes, anything else (including no exit by `timeout`,
|
|
172
197
|
default 600s) fails and follows `on_fail` with the output tail in the
|
|
173
198
|
retry note. The kind is the `command:` key, never the step's name.
|
|
174
199
|
- `artifact` + `verdicts` mark a **verdict step**. `on_pass`/`on_fail` may be
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentic-dev-loop"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "1.0.0a1"
|
|
8
8
|
description = "Opinionated LangGraph engine for OpenHands agent pipelines"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -57,3 +57,8 @@ where = ["src"]
|
|
|
57
57
|
|
|
58
58
|
[tool.setuptools.package-data]
|
|
59
59
|
agentic_dev_loop = ["py.typed"]
|
|
60
|
+
|
|
61
|
+
[tool.pytest.ini_options]
|
|
62
|
+
markers = [
|
|
63
|
+
"docker: real-Docker integration tests (require a running Linux daemon)",
|
|
64
|
+
]
|
|
@@ -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.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Agent file loading + SDK factory mechanics.
|
|
2
|
+
|
|
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`. 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)."""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from openhands.sdk import LLM
|
|
13
|
+
from openhands.sdk.tool import ToolExecutor
|
|
14
|
+
from openhands.tools.file_editor import FileEditorTool # noqa: F401 — registers "file_editor"
|
|
15
|
+
from openhands.tools.task_tracker import TaskTrackerTool # noqa: F401 — registers "task_tracker"
|
|
16
|
+
from openhands.tools.terminal import TerminalTool # noqa: F401 — registers "terminal"
|
|
17
|
+
|
|
18
|
+
from .isolation import SandboxExecutionRequest, command_argv, resolve_sandbox_env
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from openhands.sdk.agent.agent import Agent
|
|
22
|
+
|
|
23
|
+
from .isolation import Sandbox
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_agent(agents_dir: Path | str, name: str, llm: LLM) -> "Agent":
|
|
27
|
+
"""Load `<agents_dir>/<name>.agent.md` and build the Agent via the SDK
|
|
28
|
+
factory (frontmatter tool names resolved against the registry, unknown →
|
|
29
|
+
ValueError; the markdown body is delivered as the system prompt suffix).
|
|
30
|
+
|
|
31
|
+
Tool surface (BR-1): the frontmatter `tools:` list is the exact
|
|
32
|
+
capability allowlist — every name must resolve to a registered SDK tool.
|
|
33
|
+
`FinishTool` (done signal) and `ThinkTool` (reasoning scaffold) are added
|
|
34
|
+
by the SDK as default infra, never part of the allowlist and never
|
|
35
|
+
removable via `tools:`. There is no regex or default-tools knob.
|
|
36
|
+
|
|
37
|
+
Two pipeline policies on top of the SDK's native AgentDefinition loader:
|
|
38
|
+
the file must exist (fail fast — the SDK's dir loader would silently
|
|
39
|
+
skip it) and its frontmatter name must match its filename. A file with
|
|
40
|
+
no frontmatter or no name gets the SDK's stem fallback ("<name>.agent"),
|
|
41
|
+
which can never match the stem — so both degenerate cases fail the name
|
|
42
|
+
check."""
|
|
43
|
+
from openhands.sdk.subagent.registry import agent_definition_to_factory
|
|
44
|
+
from openhands.sdk.subagent.schema import AgentDefinition
|
|
45
|
+
|
|
46
|
+
path = Path(agents_dir) / f"{name}.agent.md"
|
|
47
|
+
if not path.is_file():
|
|
48
|
+
raise FileNotFoundError(f"Agent file not found: {path}")
|
|
49
|
+
|
|
50
|
+
agent_def = AgentDefinition.load(path)
|
|
51
|
+
|
|
52
|
+
stem = path.name.removesuffix(".agent.md")
|
|
53
|
+
if agent_def.name != stem:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Agent file {path} name {agent_def.name!r} does not match "
|
|
56
|
+
f"filename {stem!r}")
|
|
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)
|
|
@@ -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",
|
|
@@ -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)
|