agentic-dev-loop 1.0.0a29__tar.gz → 1.0.0a30__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.0a29/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a30}/PKG-INFO +14 -1
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/README.md +13 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/pyproject.toml +1 -1
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/core/config.py +48 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/dryrun.py +4 -1
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/extensions/aidlc.py +23 -1
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/graph.py +13 -5
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/loaders.py +18 -3
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/stages.py +227 -5
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30/src/agentic_dev_loop.egg-info}/PKG-INFO +14 -1
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop.egg-info/SOURCES.txt +1 -0
- agentic_dev_loop-1.0.0a30/tests/test_033_brief_step.py +573 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_e2e_fixtures.py +10 -4
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_viz.py +1 -1
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/LICENSE +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/setup.cfg +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/core/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/core/paths.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/core/results.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/core/verdict.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/actions.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/agents.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/cli.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/deps.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/isolation.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/llm.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/mounts.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/outputs.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/presets.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/proc.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/registry.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/secrets.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/sync_back.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/viz/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/viz/__main__.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/viz/server.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_028_declared_caches.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_029_merge_on_conflict.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_032_stop_when_green.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_actions.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_agents.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_cli.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_config.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_deps.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_dry_run.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_egress_proxy.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_graph.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_hostboundary_integration.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_isolation.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_isolation_integration.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_isolation_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_llm.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_module_contract.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_proc.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_publish.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_qa_prompt.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_registry.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_repo_ascii.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_results.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_sdk_compat.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_secrets.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_secrets_integration.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_secrets_pipeline.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_stages.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_sync_back.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_trigger_script.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_unit5_config.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_unit5_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_unit5_pure.py +0 -0
- {agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/tests/test_verdict.py +0 -0
{agentic_dev_loop-1.0.0a29/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a30}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentic-dev-loop
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.0a30
|
|
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
|
|
@@ -192,6 +192,13 @@ max_attempts: 10 # CLI --max-attempts ove
|
|
|
192
192
|
agents_dir: sdk_agents # your <name>.agent.md files
|
|
193
193
|
|
|
194
194
|
steps:
|
|
195
|
+
- name: brief
|
|
196
|
+
agent: brief
|
|
197
|
+
produces: attempt-brief.md
|
|
198
|
+
max_iterations: 12
|
|
199
|
+
brief_inputs: { include: [AGENTS.md, coding-standards-ddd.yaml], env_facts: ["the workspace is an export with no .git; verify with find/ls, never git status"] }
|
|
200
|
+
on_pass: dev
|
|
201
|
+
|
|
195
202
|
- name: dev
|
|
196
203
|
agent: dev
|
|
197
204
|
produces: implementation-summary.md
|
|
@@ -229,6 +236,7 @@ steps:
|
|
|
229
236
|
| --- | --- |
|
|
230
237
|
| `agent:` / `command:` / `action:` | Exactly one per step. Agents live in `agents_dir` as `<name>.agent.md`; commands run sandboxed (mechanical, never an LLM call). `commit` is the sole host-side built-in action; consumer actions are sandbox-executed, command-shaped scripts (see [Host boundary](#host-boundary-security-model)). |
|
|
231
238
|
| `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
|
|
239
|
+
| `brief_inputs:` | Optional agent-step input spec: `{ include: [<paths/globs>], env_facts: [<strings>] }`. The engine resolves the include list against the workspace, passes the resolved docs + include contents + env facts as that step's task message, and regenerates the step only when those inputs change. A produces-only agent step such as `brief` is the documented pattern for per-run context digestion. |
|
|
232
240
|
| `stop_when:` | Optional agent-step termination: `{ green_command: <argv regex>, post_green_turns: <int, default 2> }`. When the agent's most recent terminal observation matching `green_command` exits 0 and no workspace writes have happened since, the engine injects one nudge turn, allows `post_green_turns` further turns, then closes the conversation. Absent = run to finish. |
|
|
233
241
|
| `terminal_timeout:` | Optional agent-step per-action timeout in seconds (gt 0). `SandboxTerminalExecutor` uses it when the agent leaves the terminal action timeout unset, overriding the SDK tool's default. `None` = current behavior. |
|
|
234
242
|
| `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
|
|
@@ -243,6 +251,11 @@ steps:
|
|
|
243
251
|
| `caches:` / `sync_excludes:` | Top-level run-wide lists. `caches:` names repo-relative toolchain cache dirs bind-mounted rw from the real workspace (auto-created if missing); a `setup:` hook can warm them (the provision container carries the same cache mounts); `sync_excludes:` names scratch paths skipped from both the export copy and sync-back (wildcard `*` spans separators, so `**/bin/**` matches a `/bin/` segment at any depth). Both default to `[]`. See [docs/setup.md](docs/setup.md). |
|
|
244
252
|
| `merge_on_conflict:` | Top-level run-wide list of append-style logs (same path vocabulary as `sync_excludes:`, default `[]`). A concurrent host edit to a listed path is union-merged instead of raising: the host file wins and the sandbox's export-only lines are appended in order; both sides converge and the run continues. See [docs/setup.md](docs/setup.md). |
|
|
245
253
|
|
|
254
|
+
A `brief_inputs:` step is the per-run context-digestion pattern: it runs once at
|
|
255
|
+
run start (or when its inputs change), its `produces:` artifact survives attempt
|
|
256
|
+
starts, and the dev/review agents read that digest instead of re-deriving repo
|
|
257
|
+
rules and environment facts every attempt.
|
|
258
|
+
|
|
246
259
|
### Secrets (`secrets:`) - refs only, never values
|
|
247
260
|
|
|
248
261
|
Secret values never belong in the pipeline yaml, the workspace, or `state_dir`.
|
|
@@ -150,6 +150,13 @@ max_attempts: 10 # CLI --max-attempts ove
|
|
|
150
150
|
agents_dir: sdk_agents # your <name>.agent.md files
|
|
151
151
|
|
|
152
152
|
steps:
|
|
153
|
+
- name: brief
|
|
154
|
+
agent: brief
|
|
155
|
+
produces: attempt-brief.md
|
|
156
|
+
max_iterations: 12
|
|
157
|
+
brief_inputs: { include: [AGENTS.md, coding-standards-ddd.yaml], env_facts: ["the workspace is an export with no .git; verify with find/ls, never git status"] }
|
|
158
|
+
on_pass: dev
|
|
159
|
+
|
|
153
160
|
- name: dev
|
|
154
161
|
agent: dev
|
|
155
162
|
produces: implementation-summary.md
|
|
@@ -187,6 +194,7 @@ steps:
|
|
|
187
194
|
| --- | --- |
|
|
188
195
|
| `agent:` / `command:` / `action:` | Exactly one per step. Agents live in `agents_dir` as `<name>.agent.md`; commands run sandboxed (mechanical, never an LLM call). `commit` is the sole host-side built-in action; consumer actions are sandbox-executed, command-shaped scripts (see [Host boundary](#host-boundary-security-model)). |
|
|
189
196
|
| `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
|
|
197
|
+
| `brief_inputs:` | Optional agent-step input spec: `{ include: [<paths/globs>], env_facts: [<strings>] }`. The engine resolves the include list against the workspace, passes the resolved docs + include contents + env facts as that step's task message, and regenerates the step only when those inputs change. A produces-only agent step such as `brief` is the documented pattern for per-run context digestion. |
|
|
190
198
|
| `stop_when:` | Optional agent-step termination: `{ green_command: <argv regex>, post_green_turns: <int, default 2> }`. When the agent's most recent terminal observation matching `green_command` exits 0 and no workspace writes have happened since, the engine injects one nudge turn, allows `post_green_turns` further turns, then closes the conversation. Absent = run to finish. |
|
|
191
199
|
| `terminal_timeout:` | Optional agent-step per-action timeout in seconds (gt 0). `SandboxTerminalExecutor` uses it when the agent leaves the terminal action timeout unset, overriding the SDK tool's default. `None` = current behavior. |
|
|
192
200
|
| `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
|
|
@@ -201,6 +209,11 @@ steps:
|
|
|
201
209
|
| `caches:` / `sync_excludes:` | Top-level run-wide lists. `caches:` names repo-relative toolchain cache dirs bind-mounted rw from the real workspace (auto-created if missing); a `setup:` hook can warm them (the provision container carries the same cache mounts); `sync_excludes:` names scratch paths skipped from both the export copy and sync-back (wildcard `*` spans separators, so `**/bin/**` matches a `/bin/` segment at any depth). Both default to `[]`. See [docs/setup.md](docs/setup.md). |
|
|
202
210
|
| `merge_on_conflict:` | Top-level run-wide list of append-style logs (same path vocabulary as `sync_excludes:`, default `[]`). A concurrent host edit to a listed path is union-merged instead of raising: the host file wins and the sandbox's export-only lines are appended in order; both sides converge and the run continues. See [docs/setup.md](docs/setup.md). |
|
|
203
211
|
|
|
212
|
+
A `brief_inputs:` step is the per-run context-digestion pattern: it runs once at
|
|
213
|
+
run start (or when its inputs change), its `produces:` artifact survives attempt
|
|
214
|
+
starts, and the dev/review agents read that digest instead of re-deriving repo
|
|
215
|
+
rules and environment facts every attempt.
|
|
216
|
+
|
|
204
217
|
### Secrets (`secrets:`) - refs only, never values
|
|
205
218
|
|
|
206
219
|
Secret values never belong in the pipeline yaml, the workspace, or `state_dir`.
|
|
@@ -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.0a30"
|
|
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"
|
|
@@ -335,6 +335,39 @@ class StopWhenSpec(BaseModel):
|
|
|
335
335
|
return v
|
|
336
336
|
|
|
337
337
|
|
|
338
|
+
class BriefInputsSpec(BaseModel):
|
|
339
|
+
"""Optional ``brief_inputs:`` on an agent step (033): the closed input
|
|
340
|
+
list a brief agent digests once per run. ``include`` is repo-relative
|
|
341
|
+
paths/globs (wildcards allowed); ``env_facts`` are the stable environment
|
|
342
|
+
facts handed to the agent verbatim so it never discovers them by running
|
|
343
|
+
commands. Both accept a single string or a list; unknown keys fail at
|
|
344
|
+
load."""
|
|
345
|
+
|
|
346
|
+
model_config = ConfigDict(extra="forbid")
|
|
347
|
+
|
|
348
|
+
include: list[str] = []
|
|
349
|
+
env_facts: list[str] = []
|
|
350
|
+
|
|
351
|
+
@field_validator("include", "env_facts", mode="before")
|
|
352
|
+
@classmethod
|
|
353
|
+
def _coerce_list(cls, v: Any) -> Any:
|
|
354
|
+
return [v] if isinstance(v, str) else v
|
|
355
|
+
|
|
356
|
+
@field_validator("include")
|
|
357
|
+
@classmethod
|
|
358
|
+
def _valid_include(cls, v: list[str]) -> list[str]:
|
|
359
|
+
return [_validate_declared_path(p, allow_wildcards=True) for p in v]
|
|
360
|
+
|
|
361
|
+
@field_validator("env_facts")
|
|
362
|
+
@classmethod
|
|
363
|
+
def _valid_env_facts(cls, v: list[str]) -> list[str]:
|
|
364
|
+
for fact in v:
|
|
365
|
+
if not isinstance(fact, str) or not fact.strip():
|
|
366
|
+
raise ValueError(
|
|
367
|
+
"brief_inputs.env_facts entries must be non-empty strings")
|
|
368
|
+
return v
|
|
369
|
+
|
|
370
|
+
|
|
338
371
|
class StepConfig(BaseModel):
|
|
339
372
|
"""One pipeline step. Its type IS the key it carries: exactly one of
|
|
340
373
|
`agent`, `action`, or `command` - empty values do not count, anything
|
|
@@ -354,6 +387,7 @@ class StepConfig(BaseModel):
|
|
|
354
387
|
timeout: float | None = Field(default=None, gt=0) # seconds, command steps only
|
|
355
388
|
terminal_timeout: float | None = Field(default=None, gt=0) # seconds, agent steps only (032)
|
|
356
389
|
stop_when: StopWhenSpec | None = None # agent steps only (032)
|
|
390
|
+
brief_inputs: BriefInputsSpec | None = None # agent steps only (033)
|
|
357
391
|
env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
|
|
358
392
|
setup: str | None = None # BR-13: command steps only
|
|
359
393
|
network: bool = False # per-stage egress (command/agent + sandbox actions; host-side actions reject at registry time)
|
|
@@ -426,6 +460,20 @@ class StepConfig(BaseModel):
|
|
|
426
460
|
raise ValueError(
|
|
427
461
|
f"step {self.name!r}: 'stop_when' requires 'agent'"
|
|
428
462
|
)
|
|
463
|
+
if self.brief_inputs is not None and self.agent is None:
|
|
464
|
+
raise ValueError(
|
|
465
|
+
f"step {self.name!r}: 'brief_inputs' requires 'agent'"
|
|
466
|
+
)
|
|
467
|
+
if (self.brief_inputs is not None
|
|
468
|
+
and (self.artifact is not None or self.verdicts is not None)):
|
|
469
|
+
raise ValueError(
|
|
470
|
+
f"step {self.name!r}: 'brief_inputs' is only valid on a "
|
|
471
|
+
"produces-only agent step"
|
|
472
|
+
)
|
|
473
|
+
if self.brief_inputs is not None and not self.produces:
|
|
474
|
+
raise ValueError(
|
|
475
|
+
f"step {self.name!r}: 'brief_inputs' requires 'produces'"
|
|
476
|
+
)
|
|
429
477
|
if self.setup is not None and self.command is None and self.agent is None:
|
|
430
478
|
raise ValueError(
|
|
431
479
|
f"step {self.name!r}: 'setup' is only allowed on 'command' or 'agent' steps"
|
{agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/dryrun.py
RENAMED
|
@@ -17,6 +17,7 @@ from .hostboundary import check_orchestrator_install, check_tooling_integrity
|
|
|
17
17
|
from .loaders import resolve_docs
|
|
18
18
|
from .isolation import (isolation_state_dir, resolve_effective_profile,
|
|
19
19
|
resolve_image_ref, resolved_seccomp_profile)
|
|
20
|
+
from .stages import validate_brief_inputs
|
|
20
21
|
from .registry import (Registry, default_registry, load_extensions,
|
|
21
22
|
validate_action_mounts_egress, validate_action_network)
|
|
22
23
|
from .secrets import scan_env_files
|
|
@@ -137,8 +138,10 @@ def dry_run(cfg_path, args, registry: Registry | None = None) -> int:
|
|
|
137
138
|
_validate_agents(cfg, base / cfg.agents_dir)
|
|
138
139
|
_validate_actions(cfg, registry)
|
|
139
140
|
resolved_seccomp_profile(cfg) # fails loud on an unknown bundled:<name>
|
|
141
|
+
for step in cfg.steps:
|
|
142
|
+
validate_brief_inputs(Path.cwd(), step)
|
|
140
143
|
|
|
141
|
-
loader, docs, subject = resolve_docs(cfg, base, registry, args)
|
|
144
|
+
loader, docs, subject, _doc_sources = resolve_docs(cfg, base, registry, args)
|
|
142
145
|
image, memory, cpus = _resolved_image(cfg)
|
|
143
146
|
state_dir = isolation_state_dir(cfg)
|
|
144
147
|
|
|
@@ -21,8 +21,15 @@ from pathlib import Path
|
|
|
21
21
|
_DOC_SUFFIXES = (".md", ".yaml", ".yml", ".txt")
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def
|
|
24
|
+
def resolve_unit_docs_with_sources(
|
|
25
|
+
docs_dir: Path | str, unit_slug: str
|
|
26
|
+
) -> tuple[dict[str, str], dict[str, Path]]:
|
|
27
|
+
"""Resolve one AI-DLC unit into (docs, sources). ``docs`` maps each
|
|
28
|
+
doc's workspace-relative key to its content; ``sources`` maps the same
|
|
29
|
+
key to its on-disk file so the brief regeneration gate can re-read it
|
|
30
|
+
(033 D4)."""
|
|
25
31
|
docs: dict[str, str] = {}
|
|
32
|
+
sources: dict[str, Path] = {}
|
|
26
33
|
docs_dir = Path(docs_dir)
|
|
27
34
|
unit_slug = unit_slug.strip()
|
|
28
35
|
|
|
@@ -47,6 +54,7 @@ def resolve_unit_docs(docs_dir: Path | str, unit_slug: str) -> dict[str, str]:
|
|
|
47
54
|
if file.is_file() and file.suffix in _DOC_SUFFIXES:
|
|
48
55
|
key = file.relative_to(unit_dir).as_posix()
|
|
49
56
|
docs[key] = file.read_text(encoding="utf-8")
|
|
57
|
+
sources[key] = file
|
|
50
58
|
|
|
51
59
|
# At this point docs holds only unit-dir files - an empty load means the
|
|
52
60
|
# pipeline would build context from inception docs alone (the 2026-07-19
|
|
@@ -63,6 +71,7 @@ def resolve_unit_docs(docs_dir: Path | str, unit_slug: str) -> dict[str, str]:
|
|
|
63
71
|
for file in sorted(inception.iterdir()):
|
|
64
72
|
if file.is_file() and file.suffix in (".md", ".txt"):
|
|
65
73
|
docs[f"inception/{file.name}"] = file.read_text(encoding="utf-8")
|
|
74
|
+
sources[f"inception/{file.name}"] = file
|
|
66
75
|
|
|
67
76
|
# The unit's code-generation plans are the build spec - absent is fine,
|
|
68
77
|
# present is how the spec reaches agents.
|
|
@@ -70,7 +79,13 @@ def resolve_unit_docs(docs_dir: Path | str, unit_slug: str) -> dict[str, str]:
|
|
|
70
79
|
if plans_dir.is_dir():
|
|
71
80
|
for file in sorted(plans_dir.glob(f"{unit_slug}-*-code-generation-plan.md")):
|
|
72
81
|
docs[f"plans/{file.name}"] = file.read_text(encoding="utf-8")
|
|
82
|
+
sources[f"plans/{file.name}"] = file
|
|
83
|
+
|
|
84
|
+
return docs, sources
|
|
85
|
+
|
|
73
86
|
|
|
87
|
+
def resolve_unit_docs(docs_dir: Path | str, unit_slug: str) -> dict[str, str]:
|
|
88
|
+
docs, _ = resolve_unit_docs_with_sources(docs_dir, unit_slug)
|
|
74
89
|
return docs
|
|
75
90
|
|
|
76
91
|
|
|
@@ -116,6 +131,13 @@ class AidlcDocumentLoader:
|
|
|
116
131
|
unit = args.aidlc_unit or get_current_unit(self.docs_dir)
|
|
117
132
|
return resolve_unit_docs(self.docs_dir, unit), f"unit {unit}"
|
|
118
133
|
|
|
134
|
+
def sources(self, args: argparse.Namespace) -> dict[str, Path]:
|
|
135
|
+
"""Source path for every resolved doc key (033): the regeneration
|
|
136
|
+
gate re-reads these from disk so a mid-run unit-plan edit is detected."""
|
|
137
|
+
unit = args.aidlc_unit or get_current_unit(self.docs_dir)
|
|
138
|
+
_, sources = resolve_unit_docs_with_sources(self.docs_dir, unit)
|
|
139
|
+
return sources
|
|
140
|
+
|
|
119
141
|
|
|
120
142
|
class AidlcExtension:
|
|
121
143
|
name = "ai-dlc"
|
{agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/graph.py
RENAMED
|
@@ -28,8 +28,9 @@ from .secrets import resolve_secrets, scan_env_files
|
|
|
28
28
|
from .registry import (Registry, default_registry, load_extensions,
|
|
29
29
|
validate_action_mounts_egress, validate_action_network)
|
|
30
30
|
from .stages import (Runtime, clear_attempt_artifacts,
|
|
31
|
-
make_action_node, make_agent_node,
|
|
32
|
-
make_command_node, make_router,
|
|
31
|
+
make_action_node, make_agent_node, make_brief_node,
|
|
32
|
+
make_bump_node, make_command_node, make_router,
|
|
33
|
+
success_node)
|
|
33
34
|
|
|
34
35
|
CHECKPOINT_DB = "loop-checkpoints.sqlite" # under state_dir, one DB per workspace
|
|
35
36
|
|
|
@@ -47,6 +48,7 @@ class LoopState(TypedDict):
|
|
|
47
48
|
every other key is last-write-wins."""
|
|
48
49
|
subject: str
|
|
49
50
|
docs: dict[str, str]
|
|
51
|
+
doc_sources: dict[str, str] # doc key -> source path (033 regeneration gate)
|
|
50
52
|
artifacts: Annotated[dict[str, str], _merge_artifacts] # step name -> artifact text
|
|
51
53
|
attempt: int
|
|
52
54
|
max_attempts: int
|
|
@@ -61,6 +63,7 @@ class LoopState(TypedDict):
|
|
|
61
63
|
step_verdicts: dict[str, str | None] # per-step pass/fail/None(unclear)
|
|
62
64
|
retry_target: str | None # where the bump node loops back to
|
|
63
65
|
thread_id: str
|
|
66
|
+
brief_hashes: Annotated[dict[str, str], _merge_artifacts] # step name -> input hash (033)
|
|
64
67
|
|
|
65
68
|
|
|
66
69
|
def build_graph(runtime: Runtime, checkpointer=None):
|
|
@@ -83,7 +86,10 @@ def build_graph(runtime: Runtime, checkpointer=None):
|
|
|
83
86
|
graph = StateGraph(LoopState)
|
|
84
87
|
for step in runtime.cfg.steps:
|
|
85
88
|
if step.kind == "agent":
|
|
86
|
-
|
|
89
|
+
if step.brief_inputs is not None:
|
|
90
|
+
node = make_brief_node(runtime, step)
|
|
91
|
+
else:
|
|
92
|
+
node = make_agent_node(runtime, step)
|
|
87
93
|
elif step.kind == "command":
|
|
88
94
|
node = make_command_node(runtime, step)
|
|
89
95
|
else:
|
|
@@ -178,14 +184,15 @@ def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
|
|
|
178
184
|
if resume:
|
|
179
185
|
initial = None # state (docs/subject/artifacts) comes from the checkpoint
|
|
180
186
|
else:
|
|
181
|
-
_loader, docs, subject = resolve_docs(cfg, base, registry, args)
|
|
187
|
+
_loader, docs, subject, doc_sources = resolve_docs(cfg, base, registry, args)
|
|
182
188
|
# A fresh run owns state_dir: drop cross-run leftovers (a previous
|
|
183
189
|
# failure's last-error.md, stale artifacts) so nothing mistakes
|
|
184
190
|
# them for this run's output. --resume keeps them.
|
|
185
|
-
clear_attempt_artifacts(runtime)
|
|
191
|
+
clear_attempt_artifacts(runtime, exempt_run_scoped=False)
|
|
186
192
|
initial: LoopState = {
|
|
187
193
|
"subject": subject,
|
|
188
194
|
"docs": docs,
|
|
195
|
+
"doc_sources": doc_sources,
|
|
189
196
|
"artifacts": {},
|
|
190
197
|
"attempt": 1,
|
|
191
198
|
"max_attempts": max_attempts,
|
|
@@ -204,6 +211,7 @@ def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
|
|
|
204
211
|
"step_verdicts": {},
|
|
205
212
|
"retry_target": None,
|
|
206
213
|
"thread_id": thread_id,
|
|
214
|
+
"brief_hashes": {},
|
|
207
215
|
}
|
|
208
216
|
bus = viz_bus
|
|
209
217
|
with SqliteSaver.from_conn_string(db) as saver:
|
{agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/loaders.py
RENAMED
|
@@ -71,6 +71,12 @@ class PlanDocumentLoader:
|
|
|
71
71
|
docs = resolve_plan_docs(args.plan)
|
|
72
72
|
return docs, f"plan {args.plan.name}"
|
|
73
73
|
|
|
74
|
+
def sources(self, args: argparse.Namespace) -> dict[str, Path]:
|
|
75
|
+
"""Source path for every resolved doc key (033): the regeneration
|
|
76
|
+
gate re-reads these from disk so a mid-run plan edit is detected."""
|
|
77
|
+
path = Path(args.plan)
|
|
78
|
+
return {path.name: path}
|
|
79
|
+
|
|
74
80
|
|
|
75
81
|
def select_loader(registry: Registry, args: argparse.Namespace) -> DocumentLoader | None:
|
|
76
82
|
"""The document-loader coexistence model - load many, invoke one:
|
|
@@ -103,11 +109,15 @@ def select_loader(registry: Registry, args: argparse.Namespace) -> DocumentLoade
|
|
|
103
109
|
|
|
104
110
|
|
|
105
111
|
def resolve_docs(cfg: PipelineConfig, base: Path, registry: Registry,
|
|
106
|
-
args) -> tuple[DocumentLoader | None, dict[str, str], str
|
|
112
|
+
args) -> tuple[DocumentLoader | None, dict[str, str], str,
|
|
113
|
+
dict[str, str]]:
|
|
107
114
|
"""Select and invoke the document loader, then append the pipeline's
|
|
108
115
|
additional_files (resolved relative to the pipeline file's dir;
|
|
109
|
-
listed-but-missing fails fast). Returns (loader, docs, subject
|
|
116
|
+
listed-but-missing fails fast). Returns (loader, docs, subject,
|
|
117
|
+
doc_sources) - the source path for every loader/additional_files doc key
|
|
118
|
+
so the brief regeneration gate can re-read them from disk (033 D4)."""
|
|
110
119
|
loader = select_loader(registry, args)
|
|
120
|
+
sources: dict[str, str] = {}
|
|
111
121
|
if loader is not None:
|
|
112
122
|
docs, subject = loader.resolve(args)
|
|
113
123
|
if not docs:
|
|
@@ -116,6 +126,10 @@ def resolve_docs(cfg: PipelineConfig, base: Path, registry: Registry,
|
|
|
116
126
|
raise RuntimeError(
|
|
117
127
|
f"Document loader {loader.name!r} returned no docs - "
|
|
118
128
|
"refusing to build context from nothing")
|
|
129
|
+
loader_sources = getattr(loader, "sources", None)
|
|
130
|
+
if callable(loader_sources):
|
|
131
|
+
for name, path in loader_sources(args).items():
|
|
132
|
+
sources[name] = str(Path(path).resolve())
|
|
119
133
|
else:
|
|
120
134
|
docs, subject = {}, cfg.name
|
|
121
135
|
for rel in cfg.additional_files:
|
|
@@ -123,4 +137,5 @@ def resolve_docs(cfg: PipelineConfig, base: Path, registry: Registry,
|
|
|
123
137
|
if not path.is_file():
|
|
124
138
|
raise FileNotFoundError(f"additional_files entry not found: {path}")
|
|
125
139
|
docs[path.name] = path.read_text(encoding="utf-8")
|
|
126
|
-
|
|
140
|
+
sources[path.name] = str(path.resolve())
|
|
141
|
+
return loader, docs, subject, sources
|
{agentic_dev_loop-1.0.0a29 → agentic_dev_loop-1.0.0a30}/src/agentic_dev_loop/engine/stages.py
RENAMED
|
@@ -19,6 +19,7 @@ pipeline config; the fixed policies stay here, not in config:
|
|
|
19
19
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
|
+
import hashlib
|
|
22
23
|
import os
|
|
23
24
|
import re
|
|
24
25
|
import subprocess
|
|
@@ -33,6 +34,7 @@ from openhands.sdk import Conversation
|
|
|
33
34
|
|
|
34
35
|
from .agents import load_agent, register_sandbox_terminal
|
|
35
36
|
from agentic_dev_loop.core.config import TERMINALS, PipelineConfig, Route, StepConfig, route_target
|
|
37
|
+
from agentic_dev_loop.core.paths import _has_wildcards
|
|
36
38
|
from .deps import extension_message, parse_missing_dependencies, policy_injection
|
|
37
39
|
from .egress_policy import assemble_egress_policy
|
|
38
40
|
from .isolation import SandboxExecutionRequest, command_argv, resolve_sandbox_env
|
|
@@ -406,14 +408,152 @@ def declared_artifacts(cfg: PipelineConfig) -> list[str]:
|
|
|
406
408
|
return files
|
|
407
409
|
|
|
408
410
|
|
|
409
|
-
def
|
|
411
|
+
def run_scoped_artifacts(cfg: PipelineConfig) -> list[str]:
|
|
412
|
+
"""Artifacts that live for the whole run, not one attempt (033).
|
|
413
|
+
|
|
414
|
+
A ``brief_inputs:`` step is the documented pattern: its digest is
|
|
415
|
+
created once at run start and reused by every attempt, so its declared
|
|
416
|
+
outputs are exempt from per-attempt hygiene."""
|
|
417
|
+
files = []
|
|
418
|
+
for step in cfg.steps:
|
|
419
|
+
if step.brief_inputs is None:
|
|
420
|
+
continue
|
|
421
|
+
files.extend(step.produces)
|
|
422
|
+
if step.artifact is not None:
|
|
423
|
+
files.append(step.artifact)
|
|
424
|
+
return files
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def clear_attempt_artifacts(runtime: Runtime, *,
|
|
428
|
+
exempt_run_scoped: bool = True) -> None:
|
|
410
429
|
"""Per-attempt hygiene (fixed policy): delete every declared
|
|
411
430
|
artifact/produces file + last-error.md so stale files from earlier
|
|
412
|
-
attempts or runs can't be mistaken for this attempt's output.
|
|
431
|
+
attempts or runs can't be mistaken for this attempt's output.
|
|
432
|
+
|
|
433
|
+
Run-scoped artifacts (033) survive attempt starts and are deleted at run
|
|
434
|
+
start instead - pass ``exempt_run_scoped=False`` for that run-start sweep.
|
|
435
|
+
"""
|
|
436
|
+
keep = set(run_scoped_artifacts(runtime.cfg)) if exempt_run_scoped else set()
|
|
413
437
|
for name in [*declared_artifacts(runtime.cfg), LAST_ERROR]:
|
|
438
|
+
if name in keep:
|
|
439
|
+
continue
|
|
414
440
|
(runtime.state_dir / name).unlink(missing_ok=True)
|
|
415
441
|
|
|
416
442
|
|
|
443
|
+
def _resolve_include_spec(workspace: Path, spec: str) -> list[Path]:
|
|
444
|
+
"""Resolve one ``brief_inputs.include`` entry to files, sorted by their
|
|
445
|
+
workspace-relative posix path. Literal entries must name an existing file;
|
|
446
|
+
wildcard entries must match at least one file (no silent fallback)."""
|
|
447
|
+
if _has_wildcards(spec):
|
|
448
|
+
matches = sorted(
|
|
449
|
+
(p for p in workspace.glob(spec) if p.is_file()),
|
|
450
|
+
key=lambda p: p.relative_to(workspace).as_posix())
|
|
451
|
+
else:
|
|
452
|
+
path = workspace / spec
|
|
453
|
+
if not path.is_file():
|
|
454
|
+
raise FileNotFoundError(
|
|
455
|
+
f"brief_inputs include entry not found: {spec}")
|
|
456
|
+
matches = [path]
|
|
457
|
+
if not matches:
|
|
458
|
+
raise FileNotFoundError(
|
|
459
|
+
f"brief_inputs include entry matched no files: {spec}")
|
|
460
|
+
return matches
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def resolve_brief_inputs(runtime: Runtime, step: StepConfig
|
|
464
|
+
) -> tuple[dict[str, str], list[str]]:
|
|
465
|
+
"""Resolve ``brief_inputs.include`` against the workspace into
|
|
466
|
+
``{workspace-relative-path: content}`` and return the env-facts list."""
|
|
467
|
+
if step.brief_inputs is None:
|
|
468
|
+
return {}, []
|
|
469
|
+
included: dict[str, str] = {}
|
|
470
|
+
for spec in step.brief_inputs.include:
|
|
471
|
+
for path in _resolve_include_spec(runtime.workspace, spec):
|
|
472
|
+
key = path.relative_to(runtime.workspace).as_posix()
|
|
473
|
+
included.setdefault(key, path.read_text(encoding="utf-8"))
|
|
474
|
+
return included, list(step.brief_inputs.env_facts)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def validate_brief_inputs(workspace: Path, step: StepConfig) -> None:
|
|
478
|
+
"""Fail fast on ``brief_inputs.include`` entries that name no files (033).
|
|
479
|
+
|
|
480
|
+
The no-silent-fallback rule demands these surface at load/--dry-run time,
|
|
481
|
+
not on the brief step's first run."""
|
|
482
|
+
if step.brief_inputs is None:
|
|
483
|
+
return
|
|
484
|
+
for spec in step.brief_inputs.include:
|
|
485
|
+
_resolve_include_spec(workspace, spec)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _resolved_loader_docs(docs: dict[str, str] | None,
|
|
489
|
+
doc_sources: dict[str, str] | None) -> dict[str, str]:
|
|
490
|
+
"""The loader/`additional_files` docs for the brief hash and message.
|
|
491
|
+
|
|
492
|
+
When source paths are known (033 D4), re-read them from disk so a
|
|
493
|
+
mid-run edit to the unit plan or an additional file changes the hash -
|
|
494
|
+
the run-start snapshot alone would miss it. Doc keys without a tracked
|
|
495
|
+
source keep their snapshot content (third-party loaders)."""
|
|
496
|
+
if not doc_sources:
|
|
497
|
+
return dict(docs or {})
|
|
498
|
+
resolved = dict(docs or {})
|
|
499
|
+
for name, source in doc_sources.items():
|
|
500
|
+
resolved[name] = Path(source).read_text(encoding="utf-8")
|
|
501
|
+
return resolved
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _brief_sections(runtime: Runtime, step: StepConfig,
|
|
505
|
+
docs: dict[str, str] | None,
|
|
506
|
+
doc_sources: dict[str, str] | None = None
|
|
507
|
+
) -> tuple[dict[str, str], list[str]]:
|
|
508
|
+
"""The brief's full resolved input set: the loader docs (unit plan,
|
|
509
|
+
additional_files) re-read from disk where their sources are known, merged
|
|
510
|
+
with the closed include-file set, plus the env-facts list. Duplicate
|
|
511
|
+
names are de-duplicated; the loader doc already carries that input, so
|
|
512
|
+
the include copy is not repeated."""
|
|
513
|
+
sections: dict[str, str] = {}
|
|
514
|
+
for name, content in _resolved_loader_docs(docs, doc_sources).items():
|
|
515
|
+
sections.setdefault(name, content)
|
|
516
|
+
included, env_facts = resolve_brief_inputs(runtime, step)
|
|
517
|
+
for name, content in included.items():
|
|
518
|
+
sections.setdefault(name, content)
|
|
519
|
+
return sections, env_facts
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def brief_inputs_hash(runtime: Runtime, step: StepConfig,
|
|
523
|
+
docs: dict[str, str] | None = None,
|
|
524
|
+
doc_sources: dict[str, str] | None = None) -> str:
|
|
525
|
+
"""Hash the resolved brief inputs (033): loader docs + include-file
|
|
526
|
+
contents + env facts. Used by the regeneration gate - a change re-runs the
|
|
527
|
+
brief step, otherwise the existing digest is reused without an LLM call."""
|
|
528
|
+
sections, env_facts = _brief_sections(runtime, step, docs, doc_sources)
|
|
529
|
+
digest = hashlib.sha256()
|
|
530
|
+
for key in sorted(sections):
|
|
531
|
+
digest.update(key.encode("utf-8"))
|
|
532
|
+
digest.update(b"\0")
|
|
533
|
+
digest.update(sections[key].encode("utf-8"))
|
|
534
|
+
digest.update(b"\0")
|
|
535
|
+
for fact in env_facts:
|
|
536
|
+
digest.update(fact.encode("utf-8"))
|
|
537
|
+
digest.update(b"\0")
|
|
538
|
+
return digest.hexdigest()
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def build_brief_context(runtime: Runtime, step: StepConfig,
|
|
542
|
+
docs: dict[str, str],
|
|
543
|
+
doc_sources: dict[str, str] | None = None) -> str:
|
|
544
|
+
"""Assemble the brief agent's task message (033): the resolved unit/plan
|
|
545
|
+
docs (re-read from disk where sources are known), the closed
|
|
546
|
+
``brief_inputs.include`` file set, and the env-facts block. Prior verdicts
|
|
547
|
+
and feedback are deliberately absent - the notes channel delivers them per
|
|
548
|
+
attempt."""
|
|
549
|
+
sections, env_facts = _brief_sections(runtime, step, docs, doc_sources)
|
|
550
|
+
parts = [f"## {name}\n\n{content}" for name, content in sections.items()]
|
|
551
|
+
if env_facts:
|
|
552
|
+
parts.append("## Environment facts\n\n"
|
|
553
|
+
+ "\n".join(f"- {fact}" for fact in env_facts))
|
|
554
|
+
return "\n\n".join(parts)
|
|
555
|
+
|
|
556
|
+
|
|
417
557
|
def _unclear_target(runtime: Runtime, step: StepConfig) -> str:
|
|
418
558
|
"""Where the unclear-verdict retry loops to: on_fail's goto when that is
|
|
419
559
|
a step, else the entry step (the consumer always looped back to dev)."""
|
|
@@ -555,6 +695,70 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
|
|
|
555
695
|
return node
|
|
556
696
|
|
|
557
697
|
|
|
698
|
+
def make_brief_node(runtime: Runtime, step: StepConfig):
|
|
699
|
+
"""Node closure for a ``brief_inputs:`` agent step (033).
|
|
700
|
+
|
|
701
|
+
The brief is an ordinary capture-applying agent step with one extra rule:
|
|
702
|
+
it runs once per input set. The resolved inputs are hashed when the brief
|
|
703
|
+
is written; on re-entry with an unchanged hash and an existing digest the
|
|
704
|
+
LLM call is skipped entirely. A changed include file or env fact re-runs
|
|
705
|
+
the step and refreshes the hash. Prior verdicts and feedback never enter
|
|
706
|
+
the brief message - they already reach dev/review through the notes
|
|
707
|
+
channel."""
|
|
708
|
+
captures = step.artifact is not None or bool(step.produces)
|
|
709
|
+
|
|
710
|
+
def node(state: dict) -> dict:
|
|
711
|
+
attempt = state["attempt"]
|
|
712
|
+
subject = state["subject"]
|
|
713
|
+
max_iterations = step.max_iterations or DEFAULT_MAX_ITERATIONS
|
|
714
|
+
|
|
715
|
+
try:
|
|
716
|
+
current_hash = brief_inputs_hash(runtime, step, state["docs"],
|
|
717
|
+
state.get("doc_sources"))
|
|
718
|
+
if (state.get("artifacts", {}).get(step.name) is not None
|
|
719
|
+
and state.get("brief_hashes", {}).get(step.name) == current_hash):
|
|
720
|
+
print(f"[{subject}] {step.name} brief inputs unchanged - reusing.",
|
|
721
|
+
flush=True)
|
|
722
|
+
merged = {**state["step_verdicts"], step.name: "pass"}
|
|
723
|
+
return {"step_verdicts": merged, "failed": False}
|
|
724
|
+
message = build_brief_context(runtime, step, state["docs"],
|
|
725
|
+
state.get("doc_sources"))
|
|
726
|
+
except Exception:
|
|
727
|
+
err = redact_values(traceback.format_exc(), runtime.secrets.values())
|
|
728
|
+
print(err[-4000:], file=sys.stderr, flush=True)
|
|
729
|
+
runtime.state_dir.mkdir(parents=True, exist_ok=True)
|
|
730
|
+
(runtime.state_dir / LAST_ERROR).write_text(
|
|
731
|
+
f"# Brief step failed\n\n```\n{err[-4000:]}\n```\n",
|
|
732
|
+
encoding="utf-8")
|
|
733
|
+
return {"failed": True, "failed_step": step.name}
|
|
734
|
+
|
|
735
|
+
print(f"[{subject}] Running {step.agent} agent...", flush=True)
|
|
736
|
+
result = runtime.run_agent(step.agent, message,
|
|
737
|
+
max_iterations=max_iterations, step=step)
|
|
738
|
+
if not result.ok:
|
|
739
|
+
print(f"[{subject}] {step.name} agent failed on attempt {attempt}.",
|
|
740
|
+
flush=True)
|
|
741
|
+
return {"failed": True, "failed_step": step.name}
|
|
742
|
+
|
|
743
|
+
if result.capture_error is not None:
|
|
744
|
+
print(f"[{subject}] {step.name} agent produced no final response on "
|
|
745
|
+
f"attempt {attempt}, retrying.", flush=True)
|
|
746
|
+
merged = {**state["step_verdicts"], step.name: None}
|
|
747
|
+
return {"step_verdicts": merged,
|
|
748
|
+
"notes": [_unclear_note(runtime, step, attempt, None,
|
|
749
|
+
result.truncated, max_iterations)],
|
|
750
|
+
"retry_target": _unclear_target(runtime, step)}
|
|
751
|
+
|
|
752
|
+
merged = {**state["step_verdicts"], step.name: "pass"}
|
|
753
|
+
update = {"step_verdicts": merged, "failed": False}
|
|
754
|
+
if captures and result.response is not None:
|
|
755
|
+
update["artifacts"] = {step.name: result.response}
|
|
756
|
+
update["brief_hashes"] = {step.name: current_hash}
|
|
757
|
+
return update
|
|
758
|
+
|
|
759
|
+
return node
|
|
760
|
+
|
|
761
|
+
|
|
558
762
|
def make_command_node(runtime: Runtime, step: StepConfig):
|
|
559
763
|
"""Node closure for a mechanical command step. The exit code is the sole
|
|
560
764
|
verdict: 0 ⇒ pass, anything else (nonzero, signal-killed, not-found, or
|
|
@@ -748,9 +952,27 @@ def make_bump_node(runtime: Runtime):
|
|
|
748
952
|
|
|
749
953
|
def node(state: dict) -> dict:
|
|
750
954
|
clear_attempt_artifacts(runtime)
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
955
|
+
update = {"attempt": state["attempt"] + 1,
|
|
956
|
+
"step_verdicts": {},
|
|
957
|
+
"failed": False}
|
|
958
|
+
# 033 regeneration gate: if a brief step's resolved inputs changed
|
|
959
|
+
# since it was written (a mid-run edit to AGENTS.md or the unit plan),
|
|
960
|
+
# re-enter that brief step so it regenerates once and refreshes its
|
|
961
|
+
# hash. Unchanged inputs keep the failing step's retry_target.
|
|
962
|
+
for step in runtime.cfg.steps:
|
|
963
|
+
if step.brief_inputs is None:
|
|
964
|
+
continue
|
|
965
|
+
try:
|
|
966
|
+
current = brief_inputs_hash(runtime, step,
|
|
967
|
+
state.get("docs", {}),
|
|
968
|
+
state.get("doc_sources"))
|
|
969
|
+
except Exception:
|
|
970
|
+
update["retry_target"] = step.name
|
|
971
|
+
break
|
|
972
|
+
if state.get("brief_hashes", {}).get(step.name) != current:
|
|
973
|
+
update["retry_target"] = step.name
|
|
974
|
+
break
|
|
975
|
+
return update
|
|
754
976
|
|
|
755
977
|
return node
|
|
756
978
|
|