agentic-dev-loop 1.0.0a31__tar.gz → 1.0.0a32__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.

Files changed (95) hide show
  1. {agentic_dev_loop-1.0.0a31/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a32}/PKG-INFO +9 -5
  2. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/README.md +8 -4
  3. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/core/config.py +16 -0
  5. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/stages.py +85 -33
  6. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32/src/agentic_dev_loop.egg-info}/PKG-INFO +9 -5
  7. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop.egg-info/SOURCES.txt +1 -0
  8. agentic_dev_loop-1.0.0a32/tests/test_035_finalize_nudge.py +211 -0
  9. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/LICENSE +0 -0
  10. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/setup.cfg +0 -0
  11. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/__init__.py +0 -0
  12. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/core/__init__.py +0 -0
  13. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/core/paths.py +0 -0
  14. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/core/results.py +0 -0
  15. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/core/verdict.py +0 -0
  16. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/__init__.py +0 -0
  17. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/actions.py +0 -0
  18. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  19. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  20. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/agents.py +0 -0
  21. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/cli.py +0 -0
  22. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/deps.py +0 -0
  23. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  24. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  25. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  26. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  27. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  28. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/graph.py +0 -0
  29. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  30. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/isolation.py +0 -0
  31. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/llm.py +0 -0
  32. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/loaders.py +0 -0
  33. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/mounts.py +0 -0
  34. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/outputs.py +0 -0
  35. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/presets.py +0 -0
  36. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/proc.py +0 -0
  37. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/registry.py +0 -0
  38. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  39. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  40. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/secrets.py +0 -0
  41. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  42. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  43. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/py.typed +0 -0
  44. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/viz/__init__.py +0 -0
  45. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/viz/__main__.py +0 -0
  46. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop/viz/server.py +0 -0
  47. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  48. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  49. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  50. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  51. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_028_declared_caches.py +0 -0
  52. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_029_merge_on_conflict.py +0 -0
  53. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_032_stop_when_green.py +0 -0
  54. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_033_brief_step.py +0 -0
  55. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_actions.py +0 -0
  56. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_agent_error_guide.py +0 -0
  57. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_agents.py +0 -0
  58. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_aidlc.py +0 -0
  59. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_cli.py +0 -0
  60. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_config.py +0 -0
  61. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_deps.py +0 -0
  62. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_dry_run.py +0 -0
  63. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_e2e_fixtures.py +0 -0
  64. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_egress_proxy.py +0 -0
  65. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_graph.py +0 -0
  66. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_hostboundary.py +0 -0
  67. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_hostboundary_integration.py +0 -0
  68. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_isolation.py +0 -0
  69. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_isolation_integration.py +0 -0
  70. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_isolation_pbt.py +0 -0
  71. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_llm.py +0 -0
  72. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_loaders.py +0 -0
  73. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_module_contract.py +0 -0
  74. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_outputs.py +0 -0
  75. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_outputs_pbt.py +0 -0
  76. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_proc.py +0 -0
  77. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_publish.py +0 -0
  78. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_qa_prompt.py +0 -0
  79. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_registry.py +0 -0
  80. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_repo_ascii.py +0 -0
  81. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_results.py +0 -0
  82. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_sdk_compat.py +0 -0
  83. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_secrets.py +0 -0
  84. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_secrets_integration.py +0 -0
  85. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_secrets_pipeline.py +0 -0
  86. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_source_resolution.py +0 -0
  87. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_stages.py +0 -0
  88. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_step_workspace.py +0 -0
  89. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_sync_back.py +0 -0
  90. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_trigger_script.py +0 -0
  91. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_unit5_config.py +0 -0
  92. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_unit5_pbt.py +0 -0
  93. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_unit5_pure.py +0 -0
  94. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_verdict.py +0 -0
  95. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a32}/tests/test_viz.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a31
3
+ Version: 1.0.0a32
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
@@ -224,6 +224,8 @@ steps:
224
224
  agent: qa
225
225
  artifact: qa-report.md
226
226
  verdicts: { pass: PASS, fail: FAIL }
227
+ max_iterations: 100
228
+ finalize_nudge_turns: 10
227
229
  on_pass: commit
228
230
  on_fail: { goto: dev, retry: true }
229
231
 
@@ -239,6 +241,7 @@ steps:
239
241
  | `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. |
240
242
  | `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. |
241
243
  | `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. |
244
+ | `finalize_nudge_turns:` | Optional agent-step final-response nudge (gt 0). Requires `max_iterations:` and must be less than it. At `max_iterations - finalize_nudge_turns` completed turns the engine injects one advisory turn ("`N turns remain: produce your final response now`"), exactly once; the agent may still exhaust its turns and existing stuck/unclear handling applies unchanged. Absent = current behavior. Provisional pending one consumer run (revert if noisy or the raised cap inflates cost). |
242
245
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
243
246
  | `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox before the step runs (command or agent steps; network ON; stamp-cached, re-runs when the pipeline's lockfiles change); `network: true` lets a command/agent step reach the hosts its `egress:` allowlist names (default off) - see [Per-stage networking](#per-stage-networking). |
244
247
  | `mounts:` / `egress:` | Per-step workspace isolation. `mounts:` lists `{path, mode}` entries (`rw`/`ro`, default `ro`) and is **strict-by-default** - empty/missing mounts nothing; only declared-rw paths sync back to the repo. `egress:` is `{class, presets, targets}` (`class: browser` = broad-but-logged; otherwise a strict allowlist). See [Per-stage networking](#per-stage-networking) and [docs/setup.md](docs/setup.md). |
@@ -421,9 +424,10 @@ an agent burned budget learning it mid-run:
421
424
  the dev agent's next context; the dev prompt makes every listed issue a
422
425
  required fix.
423
426
  - **No git mutations, ever.** The `commit` step is the single commit point.
424
- - **`max_iterations` per role.** qa is bounded probing (60 in both
425
- reference repos); dev is open-ended construction and stays uncapped
426
- (the engine default).
427
+ - **`max_iterations` per role.** qa is bounded probing (100 in this
428
+ repo's dogfood loop, with `finalize_nudge_turns: 10` so it never ends
429
+ silently at the cap - both provisional); dev is open-ended construction
430
+ and stays uncapped (the engine default).
427
431
 
428
432
  ### The pipeline file
429
433
 
@@ -438,7 +442,7 @@ adapt per repo.
438
442
  | editor-tests | command | network + `presets: [package-registries]` | frontend suites that cannot run offline (`npm ci && npm test`). A separate step, never merged into `test`: the step name is the failure diagnosis, its timeout does not contend the backend suite's, and a retry re-runs only its own chain |
439
443
  | conformance | command | network + `presets: [package-registries]` | repo-specific fitness checks (the consumer repo: jscpd + DDD rules) |
440
444
  | review | agent | ro, offline | static conformance verdict |
441
- | qa | agent | per repo (see table above) | live-behaviour probes; `max_iterations: 60`; inject secrets only if the probes need them |
445
+ | qa | agent | per repo (see table above) | live-behaviour probes; `max_iterations: 100` + `finalize_nudge_turns: 10` (provisional); inject secrets only if the probes need them |
442
446
  | commit | action | host-side | the single commit point |
443
447
 
444
448
  Adaptation checklist for a new repo:
@@ -182,6 +182,8 @@ steps:
182
182
  agent: qa
183
183
  artifact: qa-report.md
184
184
  verdicts: { pass: PASS, fail: FAIL }
185
+ max_iterations: 100
186
+ finalize_nudge_turns: 10
185
187
  on_pass: commit
186
188
  on_fail: { goto: dev, retry: true }
187
189
 
@@ -197,6 +199,7 @@ steps:
197
199
  | `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. |
198
200
  | `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. |
199
201
  | `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. |
202
+ | `finalize_nudge_turns:` | Optional agent-step final-response nudge (gt 0). Requires `max_iterations:` and must be less than it. At `max_iterations - finalize_nudge_turns` completed turns the engine injects one advisory turn ("`N turns remain: produce your final response now`"), exactly once; the agent may still exhaust its turns and existing stuck/unclear handling applies unchanged. Absent = current behavior. Provisional pending one consumer run (revert if noisy or the raised cap inflates cost). |
200
203
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
201
204
  | `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox before the step runs (command or agent steps; network ON; stamp-cached, re-runs when the pipeline's lockfiles change); `network: true` lets a command/agent step reach the hosts its `egress:` allowlist names (default off) - see [Per-stage networking](#per-stage-networking). |
202
205
  | `mounts:` / `egress:` | Per-step workspace isolation. `mounts:` lists `{path, mode}` entries (`rw`/`ro`, default `ro`) and is **strict-by-default** - empty/missing mounts nothing; only declared-rw paths sync back to the repo. `egress:` is `{class, presets, targets}` (`class: browser` = broad-but-logged; otherwise a strict allowlist). See [Per-stage networking](#per-stage-networking) and [docs/setup.md](docs/setup.md). |
@@ -379,9 +382,10 @@ an agent burned budget learning it mid-run:
379
382
  the dev agent's next context; the dev prompt makes every listed issue a
380
383
  required fix.
381
384
  - **No git mutations, ever.** The `commit` step is the single commit point.
382
- - **`max_iterations` per role.** qa is bounded probing (60 in both
383
- reference repos); dev is open-ended construction and stays uncapped
384
- (the engine default).
385
+ - **`max_iterations` per role.** qa is bounded probing (100 in this
386
+ repo's dogfood loop, with `finalize_nudge_turns: 10` so it never ends
387
+ silently at the cap - both provisional); dev is open-ended construction
388
+ and stays uncapped (the engine default).
385
389
 
386
390
  ### The pipeline file
387
391
 
@@ -396,7 +400,7 @@ adapt per repo.
396
400
  | editor-tests | command | network + `presets: [package-registries]` | frontend suites that cannot run offline (`npm ci && npm test`). A separate step, never merged into `test`: the step name is the failure diagnosis, its timeout does not contend the backend suite's, and a retry re-runs only its own chain |
397
401
  | conformance | command | network + `presets: [package-registries]` | repo-specific fitness checks (the consumer repo: jscpd + DDD rules) |
398
402
  | review | agent | ro, offline | static conformance verdict |
399
- | qa | agent | per repo (see table above) | live-behaviour probes; `max_iterations: 60`; inject secrets only if the probes need them |
403
+ | qa | agent | per repo (see table above) | live-behaviour probes; `max_iterations: 100` + `finalize_nudge_turns: 10` (provisional); inject secrets only if the probes need them |
400
404
  | commit | action | host-side | the single commit point |
401
405
 
402
406
  Adaptation checklist for a new repo:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a31"
7
+ version = "1.0.0a32"
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"
@@ -387,6 +387,7 @@ class StepConfig(BaseModel):
387
387
  timeout: float | None = Field(default=None, gt=0) # seconds, command steps only
388
388
  terminal_timeout: float | None = Field(default=None, gt=0) # seconds, agent steps only (032)
389
389
  stop_when: StopWhenSpec | None = None # agent steps only (032)
390
+ finalize_nudge_turns: int | None = Field(default=None, gt=0) # agent steps only (035)
390
391
  brief_inputs: BriefInputsSpec | None = None # agent steps only (033)
391
392
  env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
392
393
  setup: str | None = None # BR-13: command steps only
@@ -460,6 +461,21 @@ class StepConfig(BaseModel):
460
461
  raise ValueError(
461
462
  f"step {self.name!r}: 'stop_when' requires 'agent'"
462
463
  )
464
+ if self.finalize_nudge_turns is not None and self.agent is None:
465
+ raise ValueError(
466
+ f"step {self.name!r}: 'finalize_nudge_turns' requires 'agent'"
467
+ )
468
+ if self.finalize_nudge_turns is not None and self.max_iterations is None:
469
+ raise ValueError(
470
+ f"step {self.name!r}: 'finalize_nudge_turns' requires "
471
+ "'max_iterations'"
472
+ )
473
+ if (self.finalize_nudge_turns is not None
474
+ and self.finalize_nudge_turns >= self.max_iterations):
475
+ raise ValueError(
476
+ f"step {self.name!r}: 'finalize_nudge_turns' must be less "
477
+ "than 'max_iterations'"
478
+ )
463
479
  if self.brief_inputs is not None and self.agent is None:
464
480
  raise ValueError(
465
481
  f"step {self.name!r}: 'brief_inputs' requires 'agent'"
@@ -58,49 +58,79 @@ LAST_ERROR = "last-error.md"
58
58
  ARTIFACT_CONTEXT_CAP = 24_000 # chars per artifact injected into a step's context
59
59
 
60
60
 
61
- class _StopWhenController:
61
+ class _TurnPauseController:
62
+ """Turn-level pause callback (032): pause the SDK run loop after every
63
+ agent-sourced event so the caller can drive one turn at a time. The
64
+ finalize nudge (035) reuses this hook without any stop-when state."""
65
+
66
+ def __init__(self) -> None:
67
+ self.conversation = None
68
+
69
+ def attach(self, conversation) -> None:
70
+ self.conversation = conversation
71
+
72
+ def on_event(self, event) -> None:
73
+ if self.conversation is None:
74
+ return
75
+ if getattr(event, "source", None) == "agent":
76
+ from openhands.sdk.conversation.state import (
77
+ ConversationExecutionStatus,
78
+ )
79
+ self.conversation.state.execution_status = (
80
+ ConversationExecutionStatus.PAUSED
81
+ )
82
+
83
+
84
+ class _FinalizeNudgeController(_TurnPauseController):
85
+ """Finalize nudge (035): template text, never step-named. The turn loop
86
+ asks for the message when the conversation reaches the configured index;
87
+ ``nudged`` guards against re-injection on a same-turn retry."""
88
+
89
+ _TEMPLATE = "{n} turns remain: produce your final response now"
90
+
91
+ def __init__(self, turns: int) -> None:
92
+ super().__init__()
93
+ self.turns = turns
94
+ self.nudged = False
95
+
96
+ def message(self) -> str:
97
+ return self._TEMPLATE.format(n=self.turns)
98
+
99
+
100
+ class _StopWhenController(_TurnPauseController):
62
101
  """Turn-level stop-when-green observation (032).
63
102
 
64
103
  Pure per-event state machine - no knowledge of how the conversation is
65
104
  driven. ``on_event`` is a SDK conversation callback: it records the most
66
105
  recent matching terminal observation and any subsequent file-editor
67
- write, and pauses the conversation after every agent-sourced event so the
68
- caller can drive one turn at a time.
106
+ write, and (via the base pause) pauses the conversation after every
107
+ agent-sourced event so the caller can drive one turn at a time.
69
108
  """
70
109
 
71
110
  NUDGE = "Step complete: produce your final response now"
72
111
  _WRITE_COMMANDS = frozenset({"create", "str_replace", "insert", "undo_edit"})
73
112
 
74
113
  def __init__(self, spec) -> None:
114
+ super().__init__()
75
115
  self._pattern = re.compile(spec.green_command)
76
116
  self.post_green_turns = spec.post_green_turns
77
117
  self.green_hit = False
78
118
  self.writes_since_green = False
79
119
  self.nudged = False
80
120
  self.post_nudge_turns = 0
81
- self.conversation = None
82
-
83
- def attach(self, conversation) -> None:
84
- self.conversation = conversation
85
121
 
86
122
  @property
87
123
  def green_signal(self) -> bool:
88
124
  return self.green_hit and not self.writes_since_green
89
125
 
90
126
  def on_event(self, event) -> None:
127
+ super().on_event(event)
91
128
  if self.conversation is None:
92
129
  return
93
130
  from openhands.sdk.event import ObservationEvent
94
131
 
95
132
  if isinstance(event, ObservationEvent):
96
133
  self._record_observation(event)
97
- if getattr(event, "source", None) == "agent":
98
- from openhands.sdk.conversation.state import (
99
- ConversationExecutionStatus,
100
- )
101
- self.conversation.state.execution_status = (
102
- ConversationExecutionStatus.PAUSED
103
- )
104
134
 
105
135
  def on_terminal_result(self, command: str | None, exit_code: int | None,
106
136
  wrote_workspace: bool) -> None:
@@ -160,6 +190,10 @@ class Runtime:
160
190
  controller = None
161
191
  if step is not None and step.stop_when is not None:
162
192
  controller = _StopWhenController(step.stop_when)
193
+ finalize_controller = None
194
+ if step is not None and step.finalize_nudge_turns is not None:
195
+ finalize_controller = _FinalizeNudgeController(
196
+ step.finalize_nudge_turns)
163
197
 
164
198
  if self.sandbox is not None:
165
199
  if step is not None:
@@ -182,15 +216,22 @@ class Runtime:
182
216
  secrets=(step_secret_env(self.cfg, step, self.secrets)
183
217
  if step is not None else None))
184
218
  agent = load_agent(self.agents_dir, agent_name, self.llm)
185
- callbacks = [controller.on_event] if controller is not None else None
219
+ callbacks = []
220
+ if controller is not None:
221
+ callbacks.append(controller.on_event)
222
+ if finalize_controller is not None:
223
+ callbacks.append(finalize_controller.on_event)
186
224
  conv = Conversation(agent=agent, workspace=str(self.workspace),
187
225
  max_iteration_per_run=max_iterations,
188
- callbacks=callbacks)
226
+ callbacks=callbacks or None)
189
227
  if controller is not None:
190
228
  controller.attach(conv)
229
+ if finalize_controller is not None:
230
+ finalize_controller.attach(conv)
191
231
  conv.send_message(message)
192
- if controller is not None:
193
- self._run_stop_when_loop(conv, controller, max_iterations)
232
+ if controller is not None or finalize_controller is not None:
233
+ self._run_stop_when_loop(conv, controller, max_iterations,
234
+ finalize_controller)
194
235
  else:
195
236
  conv.run()
196
237
 
@@ -231,15 +272,19 @@ class Runtime:
231
272
  if conv is not None:
232
273
  conv.close()
233
274
 
234
- def _run_stop_when_loop(self, conv, controller: _StopWhenController,
235
- max_iterations: int) -> None:
236
- """Drive one turn at a time so a green signal can inject the nudge
237
- and enforce the post-nudge turn budget. The controller's callback
238
- pauses the SDK run loop after every agent-sourced event, so each
239
- ``conv.run()`` here advances exactly one agent turn."""
275
+ def _run_stop_when_loop(self, conv,
276
+ controller: _StopWhenController | None,
277
+ max_iterations: int,
278
+ finalize_controller: _FinalizeNudgeController | None = None) -> None:
279
+ """Drive one turn at a time so turn-level hooks can inject their
280
+ nudges and enforce budgets. A controller's callback pauses the SDK
281
+ run loop after every agent-sourced event, so each ``conv.run()`` here
282
+ advances exactly one agent turn."""
240
283
  from openhands.sdk.conversation.state import ConversationExecutionStatus
241
284
 
242
285
  turns = 0
286
+ nudge_at = (max_iterations - finalize_controller.turns
287
+ if finalize_controller is not None else None)
243
288
  while True:
244
289
  conv.run()
245
290
  status = conv.state.execution_status
@@ -253,17 +298,24 @@ class Runtime:
253
298
  conv.state.execution_status = ConversationExecutionStatus.ERROR
254
299
  break
255
300
 
256
- if not controller.nudged and controller.green_signal:
257
- conv.send_message(controller.NUDGE)
258
- controller.nudged = True
259
- if controller.post_green_turns == 0:
260
- break
301
+ if (finalize_controller is not None and turns == nudge_at
302
+ and not finalize_controller.nudged):
303
+ conv.send_message(finalize_controller.message())
304
+ finalize_controller.nudged = True
261
305
  continue
262
306
 
263
- if controller.nudged:
264
- controller.post_nudge_turns += 1
265
- if controller.post_nudge_turns >= controller.post_green_turns:
266
- break
307
+ if controller is not None:
308
+ if not controller.nudged and controller.green_signal:
309
+ conv.send_message(controller.NUDGE)
310
+ controller.nudged = True
311
+ if controller.post_green_turns == 0:
312
+ break
313
+ continue
314
+
315
+ if controller.nudged:
316
+ controller.post_nudge_turns += 1
317
+ if controller.post_nudge_turns >= controller.post_green_turns:
318
+ break
267
319
 
268
320
 
269
321
  def prompt_dir(runtime: Runtime) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a31
3
+ Version: 1.0.0a32
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
@@ -224,6 +224,8 @@ steps:
224
224
  agent: qa
225
225
  artifact: qa-report.md
226
226
  verdicts: { pass: PASS, fail: FAIL }
227
+ max_iterations: 100
228
+ finalize_nudge_turns: 10
227
229
  on_pass: commit
228
230
  on_fail: { goto: dev, retry: true }
229
231
 
@@ -239,6 +241,7 @@ steps:
239
241
  | `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. |
240
242
  | `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. |
241
243
  | `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. |
244
+ | `finalize_nudge_turns:` | Optional agent-step final-response nudge (gt 0). Requires `max_iterations:` and must be less than it. At `max_iterations - finalize_nudge_turns` completed turns the engine injects one advisory turn ("`N turns remain: produce your final response now`"), exactly once; the agent may still exhaust its turns and existing stuck/unclear handling applies unchanged. Absent = current behavior. Provisional pending one consumer run (revert if noisy or the raised cap inflates cost). |
242
245
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
243
246
  | `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox before the step runs (command or agent steps; network ON; stamp-cached, re-runs when the pipeline's lockfiles change); `network: true` lets a command/agent step reach the hosts its `egress:` allowlist names (default off) - see [Per-stage networking](#per-stage-networking). |
244
247
  | `mounts:` / `egress:` | Per-step workspace isolation. `mounts:` lists `{path, mode}` entries (`rw`/`ro`, default `ro`) and is **strict-by-default** - empty/missing mounts nothing; only declared-rw paths sync back to the repo. `egress:` is `{class, presets, targets}` (`class: browser` = broad-but-logged; otherwise a strict allowlist). See [Per-stage networking](#per-stage-networking) and [docs/setup.md](docs/setup.md). |
@@ -421,9 +424,10 @@ an agent burned budget learning it mid-run:
421
424
  the dev agent's next context; the dev prompt makes every listed issue a
422
425
  required fix.
423
426
  - **No git mutations, ever.** The `commit` step is the single commit point.
424
- - **`max_iterations` per role.** qa is bounded probing (60 in both
425
- reference repos); dev is open-ended construction and stays uncapped
426
- (the engine default).
427
+ - **`max_iterations` per role.** qa is bounded probing (100 in this
428
+ repo's dogfood loop, with `finalize_nudge_turns: 10` so it never ends
429
+ silently at the cap - both provisional); dev is open-ended construction
430
+ and stays uncapped (the engine default).
427
431
 
428
432
  ### The pipeline file
429
433
 
@@ -438,7 +442,7 @@ adapt per repo.
438
442
  | editor-tests | command | network + `presets: [package-registries]` | frontend suites that cannot run offline (`npm ci && npm test`). A separate step, never merged into `test`: the step name is the failure diagnosis, its timeout does not contend the backend suite's, and a retry re-runs only its own chain |
439
443
  | conformance | command | network + `presets: [package-registries]` | repo-specific fitness checks (the consumer repo: jscpd + DDD rules) |
440
444
  | review | agent | ro, offline | static conformance verdict |
441
- | qa | agent | per repo (see table above) | live-behaviour probes; `max_iterations: 60`; inject secrets only if the probes need them |
445
+ | qa | agent | per repo (see table above) | live-behaviour probes; `max_iterations: 100` + `finalize_nudge_turns: 10` (provisional); inject secrets only if the probes need them |
442
446
  | commit | action | host-side | the single commit point |
443
447
 
444
448
  Adaptation checklist for a new repo:
@@ -49,6 +49,7 @@ tests/test_028_declared_caches.py
49
49
  tests/test_029_merge_on_conflict.py
50
50
  tests/test_032_stop_when_green.py
51
51
  tests/test_033_brief_step.py
52
+ tests/test_035_finalize_nudge.py
52
53
  tests/test_actions.py
53
54
  tests/test_agent_error_guide.py
54
55
  tests/test_agents.py
@@ -0,0 +1,211 @@
1
+ """Finalize nudge (035): config surface, the turn-index injection, and the
2
+ exactly-once guard on the shared 032 turn loop."""
3
+
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+ import yaml
9
+ from pydantic import ValidationError
10
+
11
+ from openhands.sdk import LLM
12
+ from openhands.sdk.conversation.state import ConversationExecutionStatus
13
+
14
+ from agentic_dev_loop.core.config import PipelineConfig, load_config
15
+ from agentic_dev_loop.engine.registry import default_registry
16
+ from agentic_dev_loop.engine.stages import Runtime, _FinalizeNudgeController
17
+
18
+ NUDGE = "3 turns remain: produce your final response now"
19
+
20
+
21
+ def _base() -> dict:
22
+ return {
23
+ "name": "t",
24
+ "llm": {"model": "m", "api_key_env": "K"},
25
+ "agents_dir": "agents",
26
+ "lockfiles": ["uv.lock"],
27
+ "steps": [{"name": "dev", "agent": "dev", "on_pass": "success"}],
28
+ }
29
+
30
+
31
+ def _load(tmp_path, data):
32
+ p = tmp_path / "pipeline.yaml"
33
+ p.write_text(yaml.safe_dump(data))
34
+ return load_config(p)
35
+
36
+
37
+ def _verdict_step(**extra) -> dict:
38
+ step = {
39
+ "name": "qa",
40
+ "agent": "qa",
41
+ "artifact": "qa-report.md",
42
+ "verdicts": {"pass": "PASS", "fail": "FAIL"},
43
+ "max_iterations": 100,
44
+ "finalize_nudge_turns": 10,
45
+ "on_pass": "success",
46
+ }
47
+ step.update(extra)
48
+ return step
49
+
50
+
51
+ class TestFinalizeNudgeConfig:
52
+ def test_valid_agent_step_round_trip(self, tmp_path):
53
+ data = _base()
54
+ data["steps"] = [_verdict_step()]
55
+ step = _load(tmp_path, data).steps[0]
56
+ assert step.finalize_nudge_turns == 10
57
+ assert step.max_iterations == 100
58
+
59
+ def test_absent_keeps_none(self, tmp_path):
60
+ data = _base()
61
+ assert _load(tmp_path, data).steps[0].finalize_nudge_turns is None
62
+
63
+ def test_requires_max_iterations(self, tmp_path):
64
+ data = _base()
65
+ data["steps"] = [_verdict_step(max_iterations=None)]
66
+ with pytest.raises(ValidationError, match="finalize_nudge_turns"):
67
+ _load(tmp_path, data)
68
+
69
+ def test_must_be_less_than_max_iterations(self, tmp_path):
70
+ data = _base()
71
+ data["steps"] = [_verdict_step(max_iterations=10,
72
+ finalize_nudge_turns=10)]
73
+ with pytest.raises(ValidationError, match="finalize_nudge_turns"):
74
+ _load(tmp_path, data)
75
+
76
+ @pytest.mark.parametrize("bad", [0, -1])
77
+ def test_nonpositive_rejected(self, tmp_path, bad):
78
+ data = _base()
79
+ data["steps"] = [_verdict_step(finalize_nudge_turns=bad)]
80
+ with pytest.raises(ValidationError, match="finalize_nudge_turns"):
81
+ _load(tmp_path, data)
82
+
83
+ @pytest.mark.parametrize("step", [
84
+ {"name": "test", "command": "true", "finalize_nudge_turns": 2},
85
+ {"name": "commit", "action": "commit", "finalize_nudge_turns": 2},
86
+ ])
87
+ def test_agent_only_rejected_on_non_agent(self, tmp_path, step):
88
+ data = _base()
89
+ data["steps"] = [step]
90
+ with pytest.raises(ValidationError, match="requires 'agent'"):
91
+ _load(tmp_path, data)
92
+
93
+
94
+ class TestFinalizeNudgeLoop:
95
+ def _conv(self, finish_after):
96
+ class Fake:
97
+ def __init__(self):
98
+ self.state = SimpleNamespace(execution_status=None)
99
+ self.run_calls = 0
100
+ self.sent = []
101
+
102
+ def run(self):
103
+ self.run_calls += 1
104
+ self.state.execution_status = (
105
+ ConversationExecutionStatus.FINISHED
106
+ if self.run_calls >= finish_after
107
+ else ConversationExecutionStatus.PAUSED)
108
+
109
+ def send_message(self, message):
110
+ self.sent.append((self.run_calls, message))
111
+
112
+ return Fake()
113
+
114
+ def test_nudge_at_configured_turn_and_only_once(self):
115
+ conv = self._conv(finish_after=9)
116
+ controller = _FinalizeNudgeController(3)
117
+ Runtime._run_stop_when_loop(None, conv, None, max_iterations=10,
118
+ finalize_controller=controller)
119
+ assert controller.nudged is True
120
+ assert conv.sent == [(7, NUDGE)]
121
+
122
+ def test_no_nudge_when_finished_before_threshold(self):
123
+ conv = self._conv(finish_after=3)
124
+ controller = _FinalizeNudgeController(3)
125
+ Runtime._run_stop_when_loop(None, conv, None, max_iterations=10,
126
+ finalize_controller=controller)
127
+ assert controller.nudged is False
128
+ assert conv.sent == []
129
+
130
+ def test_turn_cap_still_applies_when_nudge_ignored(self):
131
+ conv = self._conv(finish_after=999)
132
+ controller = _FinalizeNudgeController(3)
133
+ Runtime._run_stop_when_loop(None, conv, None, max_iterations=5,
134
+ finalize_controller=controller)
135
+ assert controller.nudged is True
136
+ assert conv.state.execution_status == ConversationExecutionStatus.ERROR
137
+ assert conv.sent == [(2, NUDGE)]
138
+
139
+
140
+ class TestRunAgentFinalizeNudge:
141
+ def _runtime(self, tmp_path):
142
+ cfg = PipelineConfig.model_validate({
143
+ "name": "t",
144
+ "llm": {"model": "m", "api_key_env": "K"},
145
+ "agents_dir": "agents",
146
+ "lockfiles": ["uv.lock"],
147
+ "steps": [{
148
+ "name": "qa",
149
+ "agent": "qa",
150
+ "max_iterations": 10,
151
+ "finalize_nudge_turns": 3,
152
+ "on_pass": "success",
153
+ }],
154
+ })
155
+ state_dir = tmp_path / ".pr"
156
+ state_dir.mkdir()
157
+ agents_dir = tmp_path / "agents"
158
+ agents_dir.mkdir()
159
+ (agents_dir / "qa.agent.md").write_text(
160
+ "---\nname: qa\ntools: [terminal]\n---\nProbe.")
161
+ return Runtime(
162
+ cfg=cfg, registry=default_registry(),
163
+ llm=LLM(model="openai/test-model", api_key="sk-test"),
164
+ agents_dir=agents_dir, state_dir=state_dir,
165
+ workspace=tmp_path, sandbox=None)
166
+
167
+ def _fake_conv(self, finish_after):
168
+ class Fake:
169
+ def __init__(self):
170
+ self.state = SimpleNamespace(events=[], execution_status=None)
171
+ self.callbacks = []
172
+ self.messages = []
173
+ self.closed = False
174
+ self.run_calls = 0
175
+ self.finish_after = finish_after
176
+
177
+ def send_message(self, message):
178
+ self.messages.append(message)
179
+
180
+ def run(self):
181
+ self.run_calls += 1
182
+ self.state.execution_status = (
183
+ ConversationExecutionStatus.FINISHED
184
+ if self.run_calls >= self.finish_after
185
+ else ConversationExecutionStatus.PAUSED)
186
+
187
+ def close(self):
188
+ self.closed = True
189
+
190
+ return Fake()
191
+
192
+ def test_run_agent_attaches_pause_and_sends_nudge(self, tmp_path, monkeypatch):
193
+ runtime = self._runtime(tmp_path)
194
+ conv = self._fake_conv(finish_after=9)
195
+
196
+ def factory(**kw):
197
+ conv.callbacks = kw.get("callbacks") or []
198
+ return conv
199
+
200
+ monkeypatch.setattr(
201
+ "agentic_dev_loop.engine.stages.Conversation", factory)
202
+
203
+ result = runtime.run_agent("qa", "the task", max_iterations=10,
204
+ step=runtime.cfg.steps[0])
205
+
206
+ assert result.ok is True
207
+ assert result.truncated is False
208
+ assert conv.callbacks
209
+ assert conv.messages == ["the task", NUDGE]
210
+ assert conv.run_calls == 9
211
+ assert conv.closed is True