agentic-dev-loop 1.0.0a44__tar.gz → 1.0.0a45__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 (104) hide show
  1. {agentic_dev_loop-1.0.0a44/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a45}/PKG-INFO +10 -8
  2. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/README.md +9 -7
  3. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/core/config.py +27 -1
  5. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/agents.py +19 -0
  6. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/brief_coverage.py +7 -18
  7. agentic_dev_loop-1.0.0a45/src/agentic_dev_loop/engine/brief_prompt.md +46 -0
  8. agentic_dev_loop-1.0.0a45/src/agentic_dev_loop/engine/brief_prompt.py +47 -0
  9. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/dryrun.py +9 -5
  10. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/stages.py +35 -5
  11. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45/src/agentic_dev_loop.egg-info}/PKG-INFO +10 -8
  12. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop.egg-info/SOURCES.txt +3 -0
  13. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_033_brief_step.py +44 -6
  14. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_041_brief_coverage.py +27 -16
  15. agentic_dev_loop-1.0.0a45/tests/test_046_brief_prompt.py +45 -0
  16. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_agents.py +20 -1
  17. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_e2e_fixtures.py +1 -1
  18. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/LICENSE +0 -0
  19. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/setup.cfg +0 -0
  20. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/__init__.py +0 -0
  21. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/core/__init__.py +0 -0
  22. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/core/paths.py +0 -0
  23. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/core/results.py +0 -0
  24. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/core/verdict.py +0 -0
  25. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/__init__.py +0 -0
  26. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/actions.py +0 -0
  27. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  28. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  29. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  30. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/cli.py +0 -0
  31. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/containers.py +0 -0
  32. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/deps.py +0 -0
  33. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  34. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  35. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  36. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  37. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  38. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/graph.py +0 -0
  39. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  40. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/isolation.py +0 -0
  41. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/llm.py +0 -0
  42. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/loaders.py +0 -0
  43. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/mounts.py +0 -0
  44. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/outputs.py +0 -0
  45. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/presets.py +0 -0
  46. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/proc.py +0 -0
  47. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/registry.py +0 -0
  48. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  49. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  50. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/secrets.py +0 -0
  51. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  52. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  53. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/py.typed +0 -0
  54. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/viz/__init__.py +0 -0
  55. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/viz/__main__.py +0 -0
  56. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop/viz/server.py +0 -0
  57. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  58. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  59. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  60. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  61. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_028_declared_caches.py +0 -0
  62. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_029_merge_on_conflict.py +0 -0
  63. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_035_finalize_nudge.py +0 -0
  64. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  65. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_actions.py +0 -0
  66. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_agent_error_guide.py +0 -0
  67. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_aidlc.py +0 -0
  68. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_cli.py +0 -0
  69. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_config.py +0 -0
  70. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_containers.py +0 -0
  71. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_deps.py +0 -0
  72. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_dry_run.py +0 -0
  73. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_egress_proxy.py +0 -0
  74. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_graph.py +0 -0
  75. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_hostboundary.py +0 -0
  76. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_hostboundary_integration.py +0 -0
  77. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_isolation.py +0 -0
  78. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_isolation_integration.py +0 -0
  79. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_isolation_pbt.py +0 -0
  80. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_llm.py +0 -0
  81. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_loaders.py +0 -0
  82. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_module_contract.py +0 -0
  83. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_outputs.py +0 -0
  84. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_outputs_pbt.py +0 -0
  85. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_proc.py +0 -0
  86. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_publish.py +0 -0
  87. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_qa_prompt.py +0 -0
  88. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_registry.py +0 -0
  89. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_repo_ascii.py +0 -0
  90. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_results.py +0 -0
  91. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_sdk_compat.py +0 -0
  92. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_secrets.py +0 -0
  93. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_secrets_integration.py +0 -0
  94. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_secrets_pipeline.py +0 -0
  95. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_source_resolution.py +0 -0
  96. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_stages.py +0 -0
  97. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_step_workspace.py +0 -0
  98. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_sync_back.py +0 -0
  99. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_trigger_script.py +0 -0
  100. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_unit5_config.py +0 -0
  101. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_unit5_pbt.py +0 -0
  102. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_unit5_pure.py +0 -0
  103. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/tests/test_verdict.py +0 -0
  104. {agentic_dev_loop-1.0.0a44 → agentic_dev_loop-1.0.0a45}/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.0a44
3
+ Version: 1.0.0a45
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
@@ -249,7 +249,7 @@ steps:
249
249
  | --- | --- |
250
250
  | `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)). |
251
251
  | `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
252
- | `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. |
252
+ | `brief_inputs:` | Optional agent-step input spec: `{ include: [<paths/globs>], env_facts: [<strings>], sections: [<headings>] }`. 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. `sections` is the ordered list of digest headings the brief must emit (default `[Repo rules digest, Unit scope digest, Environment facts, Open risks]`; non-empty and unique). A produces-only agent step such as `brief` is the documented pattern for per-run context digestion. |
253
253
  | `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. |
254
254
  | `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). |
255
255
  | `custom_prompt:` | Planned (planning-docs/040). Optional agent-step value: literal text, or a path whose file contents are read at load time, appended verbatim to the agent prompt at context assembly (after the prompt, before the task/artifacts). A value that looks like a path but names no file is a validation error. Agent steps only. |
@@ -271,12 +271,14 @@ run start (or when its inputs change), its `produces:` artifact survives attempt
271
271
  starts, and the dev/review agents read that digest instead of re-deriving repo
272
272
  rules and environment facts every attempt.
273
273
 
274
- The brief digest is mechanically coverage-checked: the four section headings
275
- (`Repo rules digest`, `Unit scope and lane digest`, `Environment facts`, and
276
- `Open risks`) must appear, every anchor extracted from the `include` files
277
- (markdown `##` headings, and YAML category names plus `enforcement_levels`)
278
- must appear on its own line (case-insensitive, whitespace-collapsed, `**`
279
- decoration allowed), and every `env_facts` entry must appear verbatim. A miss
274
+ The brief digest is mechanically coverage-checked: the configured
275
+ `brief_inputs.sections` headings must each appear on their own line, every
276
+ anchor extracted from the `include` files (markdown `##` headings, and YAML
277
+ category names plus `enforcement_levels`) must appear on its own line
278
+ (case-insensitive, whitespace-collapsed, `**` decoration allowed), and every
279
+ `env_facts` entry must appear verbatim. The section list feeds both the
280
+ engine-embedded brief prompt and the coverage gate, so the two can never
281
+ disagree; a miss
280
282
  re-runs the brief agent once per missing set with a `## Coverage retry`
281
283
  correction note, bounded to three retries per input set; if the same items are
282
284
  still dropped after the cap, the run fails and `last-error.md` names the
@@ -205,7 +205,7 @@ steps:
205
205
  | --- | --- |
206
206
  | `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)). |
207
207
  | `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
208
- | `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. |
208
+ | `brief_inputs:` | Optional agent-step input spec: `{ include: [<paths/globs>], env_facts: [<strings>], sections: [<headings>] }`. 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. `sections` is the ordered list of digest headings the brief must emit (default `[Repo rules digest, Unit scope digest, Environment facts, Open risks]`; non-empty and unique). A produces-only agent step such as `brief` is the documented pattern for per-run context digestion. |
209
209
  | `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. |
210
210
  | `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). |
211
211
  | `custom_prompt:` | Planned (planning-docs/040). Optional agent-step value: literal text, or a path whose file contents are read at load time, appended verbatim to the agent prompt at context assembly (after the prompt, before the task/artifacts). A value that looks like a path but names no file is a validation error. Agent steps only. |
@@ -227,12 +227,14 @@ run start (or when its inputs change), its `produces:` artifact survives attempt
227
227
  starts, and the dev/review agents read that digest instead of re-deriving repo
228
228
  rules and environment facts every attempt.
229
229
 
230
- The brief digest is mechanically coverage-checked: the four section headings
231
- (`Repo rules digest`, `Unit scope and lane digest`, `Environment facts`, and
232
- `Open risks`) must appear, every anchor extracted from the `include` files
233
- (markdown `##` headings, and YAML category names plus `enforcement_levels`)
234
- must appear on its own line (case-insensitive, whitespace-collapsed, `**`
235
- decoration allowed), and every `env_facts` entry must appear verbatim. A miss
230
+ The brief digest is mechanically coverage-checked: the configured
231
+ `brief_inputs.sections` headings must each appear on their own line, every
232
+ anchor extracted from the `include` files (markdown `##` headings, and YAML
233
+ category names plus `enforcement_levels`) must appear on its own line
234
+ (case-insensitive, whitespace-collapsed, `**` decoration allowed), and every
235
+ `env_facts` entry must appear verbatim. The section list feeds both the
236
+ engine-embedded brief prompt and the coverage gate, so the two can never
237
+ disagree; a miss
236
238
  re-runs the brief agent once per missing set with a `## Coverage retry`
237
239
  correction note, bounded to three retries per input set; if the same items are
238
240
  still dropped after the cap, the run fails and `last-error.md` names the
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a44"
7
+ version = "1.0.0a45"
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"
@@ -25,6 +25,16 @@ HOST_ONLY_MOUNT_DIRS = frozenset({".venv"})
25
25
  KNOWN_EGRESS_PRESETS = frozenset({"package-registries"})
26
26
  EGRESS_CLASSES = frozenset({"allowlist", "browser"})
27
27
 
28
+ # The neutral default section headings a brief step must emit (046). A
29
+ # consumer overrides them via `brief_inputs.sections`; the coverage gate
30
+ # and the embedded brief prompt both render from this list.
31
+ DEFAULT_BRIEF_SECTIONS = [
32
+ "Repo rules digest",
33
+ "Unit scope digest",
34
+ "Environment facts",
35
+ "Open risks",
36
+ ]
37
+
28
38
 
29
39
  def _validate_mount_path(path: str) -> str:
30
40
  """Validate one repo-relative mount path (BR-1.3).
@@ -318,8 +328,9 @@ class BriefInputsSpec(BaseModel):
318
328
 
319
329
  include: list[str] = []
320
330
  env_facts: list[str] = []
331
+ sections: list[str] = DEFAULT_BRIEF_SECTIONS
321
332
 
322
- @field_validator("include", "env_facts", mode="before")
333
+ @field_validator("include", "env_facts", "sections", mode="before")
323
334
  @classmethod
324
335
  def _coerce_list(cls, v: Any) -> Any:
325
336
  return [v] if isinstance(v, str) else v
@@ -338,6 +349,21 @@ class BriefInputsSpec(BaseModel):
338
349
  "brief_inputs.env_facts entries must be non-empty strings")
339
350
  return v
340
351
 
352
+ @field_validator("sections")
353
+ @classmethod
354
+ def _valid_sections(cls, v: list[str]) -> list[str]:
355
+ for section in v:
356
+ if not isinstance(section, str) or not section.strip():
357
+ raise ValueError(
358
+ "brief_inputs.sections entries must be non-empty strings")
359
+ if not v:
360
+ raise ValueError(
361
+ "brief_inputs.sections must be a non-empty list of headings")
362
+ if len(set(v)) != len(v):
363
+ raise ValueError(
364
+ "brief_inputs.sections entries must be unique")
365
+ return v
366
+
341
367
 
342
368
  class StepConfig(BaseModel):
343
369
  """One pipeline step. Its type IS the key it carries: exactly one of
@@ -16,6 +16,7 @@ from openhands.tools.task_tracker import TaskTrackerTool # noqa: F401 - registe
16
16
  from openhands.tools.terminal import TerminalTool # noqa: F401 - registers "terminal"
17
17
 
18
18
  from .forbidden import ForbiddenRule, match_forbidden_command
19
+ from .brief_prompt import render_brief_prompt
19
20
  from .isolation import (SandboxError, SandboxExecutionRequest, command_argv,
20
21
  resolve_sandbox_env)
21
22
 
@@ -59,6 +60,24 @@ def load_agent(agents_dir: Path | str, name: str, llm: LLM) -> "Agent":
59
60
  return agent_definition_to_factory(agent_def)(llm)
60
61
 
61
62
 
63
+ def load_brief_agent(name: str, sections: list[str], llm: LLM) -> "Agent":
64
+ """Build the brief agent from the engine-embedded prompt (046).
65
+
66
+ The brief prompt is engine-owned and versioned; it is never loaded from
67
+ ``agents_dir``. The OUTPUT contract's headings are rendered from the
68
+ configured ``brief_inputs.sections``, so the prompt and the coverage gate
69
+ share one list and cannot diverge (043)."""
70
+ from openhands.sdk.subagent.registry import agent_definition_to_factory
71
+ from openhands.sdk.subagent.schema import AgentDefinition
72
+
73
+ agent_def = AgentDefinition(
74
+ name=name,
75
+ tools=["terminal"],
76
+ system_prompt=render_brief_prompt(sections),
77
+ )
78
+ return agent_definition_to_factory(agent_def)(llm)
79
+
80
+
62
81
  class SandboxTerminalExecutor(ToolExecutor):
63
82
  """A stateless terminal executor: each action is a fresh ``Sandbox.run``
64
83
  (no persistent shell session). ``is_input`` degrades to an unsupported
@@ -12,13 +12,6 @@ from __future__ import annotations
12
12
 
13
13
  import re
14
14
 
15
- _REQUIRED_SECTIONS = (
16
- "Repo rules digest",
17
- "Unit scope and lane digest",
18
- "Environment facts",
19
- "Open risks",
20
- )
21
-
22
15
 
23
16
  def _normalize(text: str) -> str:
24
17
  """Case-fold and collapse whitespace - the matching normal form."""
@@ -38,11 +31,6 @@ def _normalize_line(line: str) -> str:
38
31
  return _normalize(stripped)
39
32
 
40
33
 
41
- def required_sections() -> list[str]:
42
- """The four template headings the brief must emit."""
43
- return list(_REQUIRED_SECTIONS)
44
-
45
-
46
34
  def extract_anchors(content: str) -> list[str]:
47
35
  """Derive the mechanical anchor set from one include file.
48
36
 
@@ -75,16 +63,17 @@ def extract_anchors(content: str) -> list[str]:
75
63
 
76
64
 
77
65
  def missing_anchors(brief_text: str, anchors: list[str],
78
- env_facts: list[str]) -> list[str]:
66
+ env_facts: list[str], sections: list[str]) -> list[str]:
79
67
  """Return every required item the captured brief fails to cover.
80
68
 
81
- Required section headings and anchors are matched line-anchored
82
- (case-insensitive, whitespace-collapsed, ``**`` decoration stripped). Env
83
- facts are matched verbatim, exactly as handed to the brief agent. An empty
84
- list means the digest passes coverage."""
69
+ Required section headings (from the configured ``brief_inputs.sections``)
70
+ and anchors are matched line-anchored (case-insensitive,
71
+ whitespace-collapsed, ``**`` decoration stripped). Env facts are matched
72
+ verbatim, exactly as handed to the brief agent. An empty list means the
73
+ digest passes coverage."""
85
74
  missing: list[str] = []
86
75
  lines = {_normalize_line(line) for line in brief_text.splitlines()}
87
- for section in _REQUIRED_SECTIONS:
76
+ for section in sections:
88
77
  if _normalize(section) not in lines:
89
78
  missing.append(section)
90
79
  for anchor in anchors:
@@ -0,0 +1,46 @@
1
+ # Brief Agent
2
+
3
+ You are the per-run context digester for a gated construction loop. You run
4
+ once per run (or once when the inputs change) and turn a closed input set into
5
+ a compact brief that the dev and review agents reuse on every attempt.
6
+
7
+ ## WORKFLOW
8
+
9
+ 1. Read every input section in the task message below. The inputs are the
10
+ resolved unit/plan docs, the listed repo-rule files, and the
11
+ `Environment facts` block. That is the closed list - nothing else.
12
+ 2. Digest the inputs into the configured sections named in OUTPUT below.
13
+ Digest, do not transcribe: the brief is compact working memory, not a copy
14
+ of the inputs.
15
+ 3. State the brief in your final response. The pipeline captures it as
16
+ `attempt-brief.md`.
17
+
18
+ ## RULES
19
+
20
+ - One pass, no exploration. Read the listed inputs, emit the brief, finish.
21
+ Do not explore, do not verify, do not run commands, and do not read anything
22
+ not listed in the task message. Your inputs are already embedded in the task
23
+ message; the terminal is available only if a listed input must be re-read.
24
+ - Prior verdicts and feedback are deliberately absent from your inputs. They
25
+ reach the dev and review agents through a separate notes channel every
26
+ attempt. Do not invent them and do not re-derive them.
27
+ - The repo-rules digest is structural: one line per top-level section of each
28
+ repo-rule input. Markdown anchors are the `##` headings: keep each heading
29
+ verbatim, then compress that section's rules into the one line that follows
30
+ it. YAML anchors are each `- name:` entry under `categories:` plus the
31
+ `enforcement_levels` preamble: keep each name verbatim. No top-level section
32
+ or category may be dropped; merging is allowed only between a section and
33
+ its own subsections. The anchor line holds the heading and nothing else; the
34
+ compressed rules go on the following line:
35
+
36
+ - general_principles
37
+ Simplest thing that works; defer decisions to the last responsible moment.
38
+
39
+ A line like `general_principles: Simplest thing that works; ...` does not
40
+ satisfy coverage; the heading must be the only text on its line.
41
+ - Keep the environment-facts entries verbatim: each fact and the behavior it
42
+ implies travel together.
43
+ - ASCII only. Do not use em dash characters.
44
+ - If the task message carries a `## Coverage retry` section, regenerate the
45
+ brief so every listed item appears, verbatim where the contract requires
46
+ verbatim. No exploration: the listed items are the only correction signal.
@@ -0,0 +1,47 @@
1
+ """Bundled brief-agent prompt (046).
2
+
3
+ The brief prompt is an engine-owned resource, versioned with the engine so the
4
+ prompt contract and the coverage gate cannot drift (the 043 divergence class).
5
+ The static prose is packaged and read at runtime like the 034 error guide; the
6
+ section-dependent OUTPUT block is rendered here from the configured
7
+ ``brief_inputs.sections`` list, so one list feeds the prompt and the gate.
8
+
9
+ ``BRIEF_PROMPT_VERSION`` joins the brief digest hash: an engine upgrade (or a
10
+ sections edit) busts a cached digest and forces a fresh capture.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from importlib.resources import files
16
+
17
+ BRIEF_PROMPT_RESOURCE = "brief_prompt.md"
18
+ BRIEF_PROMPT_VERSION = "1"
19
+
20
+
21
+ def load_template() -> str:
22
+ """The static brief-prompt prose (header, workflow, rules)."""
23
+ return (files("agentic_dev_loop.engine")
24
+ .joinpath(BRIEF_PROMPT_RESOURCE)
25
+ .read_text(encoding="utf-8"))
26
+
27
+
28
+ def _output_block(sections: list[str]) -> str:
29
+ """Render the OUTPUT contract from the configured section headings."""
30
+ headings = "\n\n".join(f"## {section}" for section in sections)
31
+ names = ", ".join(f"`## {section}`" for section in sections)
32
+ return (
33
+ "## OUTPUT\n\n"
34
+ "Start your final response with exactly this structure. Each configured "
35
+ "section heading appears as its own `## ` line, followed by the "
36
+ "compressed content for that section (write `None` when a section has "
37
+ "nothing):\n\n"
38
+ f"{headings}\n\n"
39
+ f"The section headings must appear exactly as written above: {names}. "
40
+ "Do not shorten or rename them, and do not merge a section with "
41
+ "another section."
42
+ )
43
+
44
+
45
+ def render_brief_prompt(sections: list[str]) -> str:
46
+ """The full brief prompt: static prose plus the section-rendered OUTPUT."""
47
+ return load_template() + "\n\n" + _output_block(sections)
@@ -25,12 +25,16 @@ from .secrets import scan_env_files
25
25
 
26
26
  def _validate_agents(cfg: PipelineConfig, agents_dir: Path) -> None:
27
27
  """Every `agent:` step must resolve to an existing file under agents_dir
28
- (the same fail-fast `load_agent` enforces at run time)."""
28
+ (the same fail-fast `load_agent` enforces at run time).
29
+
30
+ The brief prompt is engine-embedded (046), so a ``brief_inputs:`` step is
31
+ exempt from the agents_dir lookup - it never reads a vendored file."""
29
32
  for step in cfg.steps:
30
- if step.agent is not None:
31
- path = agents_dir / f"{step.agent}.agent.md"
32
- if not path.is_file():
33
- raise FileNotFoundError(f"Agent file not found: {path}")
33
+ if step.agent is None or step.brief_inputs is not None:
34
+ continue
35
+ path = agents_dir / f"{step.agent}.agent.md"
36
+ if not path.is_file():
37
+ raise FileNotFoundError(f"Agent file not found: {path}")
34
38
 
35
39
 
36
40
  def _validate_actions(cfg: PipelineConfig, registry: Registry) -> None:
@@ -32,8 +32,9 @@ from langgraph.graph import END
32
32
  from openhands.sdk import Conversation
33
33
 
34
34
  from .agent_error_guide import guide_section
35
- from .agents import load_agent, register_sandbox_terminal
35
+ from .agents import load_agent, load_brief_agent, register_sandbox_terminal
36
36
  from .brief_coverage import extract_anchors, missing_anchors
37
+ from .brief_prompt import BRIEF_PROMPT_VERSION
37
38
  from agentic_dev_loop.core.config import TERMINALS, PipelineConfig, Route, StepConfig, route_target
38
39
  from agentic_dev_loop.core.paths import _has_wildcards
39
40
  from .deps import extension_message, parse_missing_dependencies, policy_injection
@@ -173,7 +174,11 @@ class Runtime:
173
174
  if step is not None else None),
174
175
  forbidden_commands=forbidden_commands,
175
176
  allowlist=allowlist)
176
- agent = load_agent(self.agents_dir, agent_name, self.llm)
177
+ if step is not None and step.brief_inputs is not None:
178
+ agent = load_brief_agent(
179
+ agent_name, step.brief_inputs.sections, self.llm)
180
+ else:
181
+ agent = load_agent(self.agents_dir, agent_name, self.llm)
177
182
  callbacks = []
178
183
  if finalize_controller is not None:
179
184
  callbacks.append(finalize_controller.on_event)
@@ -543,6 +548,14 @@ def brief_inputs_hash(runtime: Runtime, step: StepConfig,
543
548
  brief step, otherwise the existing digest is reused without an LLM call."""
544
549
  sections, env_facts = _brief_sections(runtime, step, docs, doc_sources)
545
550
  digest = hashlib.sha256()
551
+ # 046, D6: the embedded prompt version and the configured section headings
552
+ # join the hash, so an engine upgrade or a sections edit busts the cached
553
+ # digest and forces a fresh capture.
554
+ digest.update(BRIEF_PROMPT_VERSION.encode("utf-8"))
555
+ digest.update(b"\0")
556
+ for section in step.brief_inputs.sections:
557
+ digest.update(section.encode("utf-8"))
558
+ digest.update(b"\0")
546
559
  for key in sorted(sections):
547
560
  digest.update(key.encode("utf-8"))
548
561
  digest.update(b"\0")
@@ -751,6 +764,20 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
751
764
  return node
752
765
 
753
766
 
767
+ def _warn_vendored_brief(runtime: Runtime, step: StepConfig) -> None:
768
+ """Warn once per load when a vendored brief prompt exists (046, D5).
769
+
770
+ The embedded prompt is authoritative, so a leftover
771
+ ``<agents_dir>/<agent>.agent.md`` for a ``brief_inputs:`` step is ignored;
772
+ the warning names it without failing the run."""
773
+ vendored = runtime.agents_dir / f"{step.agent}.agent.md"
774
+ if vendored.is_file():
775
+ print(
776
+ f"[{runtime.cfg.name}] ignoring vendored brief prompt {vendored} - "
777
+ "the engine-embedded prompt is authoritative",
778
+ file=sys.stderr, flush=True)
779
+
780
+
754
781
  def make_brief_node(runtime: Runtime, step: StepConfig):
755
782
  """Node closure for a ``brief_inputs:`` agent step (033).
756
783
 
@@ -768,6 +795,7 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
768
795
  re-runs the agent with a ``## Coverage retry`` correction note. Exhausting
769
796
  the budget writes last-error.md and fails the run."""
770
797
  captures = step.artifact is not None or bool(step.produces)
798
+ _warn_vendored_brief(runtime, step)
771
799
 
772
800
  def node(state: dict) -> dict:
773
801
  attempt = state["attempt"]
@@ -780,6 +808,8 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
780
808
  anchors = _brief_anchors(runtime, step)
781
809
  env_facts = (list(step.brief_inputs.env_facts)
782
810
  if step.brief_inputs is not None else [])
811
+ sections = (list(step.brief_inputs.sections)
812
+ if step.brief_inputs is not None else [])
783
813
  except Exception:
784
814
  err = redact_values(traceback.format_exc(), runtime.secrets.values())
785
815
  print(err[-4000:], file=sys.stderr, flush=True)
@@ -793,7 +823,7 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
793
823
  reused = (existing is not None
794
824
  and state.get("brief_hashes", {}).get(step.name) == current_hash)
795
825
 
796
- if reused and not missing_anchors(existing, anchors, env_facts):
826
+ if reused and not missing_anchors(existing, anchors, env_facts, sections):
797
827
  print(f"[{subject}] {step.name} brief inputs unchanged - reusing.",
798
828
  flush=True)
799
829
  merged = {**state["step_verdicts"], step.name: "pass"}
@@ -802,7 +832,7 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
802
832
  coverage_note = None
803
833
  if reused:
804
834
  coverage_note = _coverage_note_text(
805
- missing_anchors(existing, anchors, env_facts))
835
+ missing_anchors(existing, anchors, env_facts, sections))
806
836
  message = build_brief_context(runtime, step, state["docs"],
807
837
  state.get("doc_sources"),
808
838
  coverage_note=coverage_note)
@@ -824,7 +854,7 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
824
854
  result.truncated, max_iterations)],
825
855
  "retry_target": _unclear_target(runtime, step)}
826
856
 
827
- missing = missing_anchors(result.response or "", anchors, env_facts)
857
+ missing = missing_anchors(result.response or "", anchors, env_facts, sections)
828
858
  if missing:
829
859
  prev_hash = state.get("brief_hashes", {}).get(step.name)
830
860
  retries = (0 if prev_hash != current_hash
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a44
3
+ Version: 1.0.0a45
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
@@ -249,7 +249,7 @@ steps:
249
249
  | --- | --- |
250
250
  | `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)). |
251
251
  | `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
252
- | `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. |
252
+ | `brief_inputs:` | Optional agent-step input spec: `{ include: [<paths/globs>], env_facts: [<strings>], sections: [<headings>] }`. 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. `sections` is the ordered list of digest headings the brief must emit (default `[Repo rules digest, Unit scope digest, Environment facts, Open risks]`; non-empty and unique). A produces-only agent step such as `brief` is the documented pattern for per-run context digestion. |
253
253
  | `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. |
254
254
  | `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). |
255
255
  | `custom_prompt:` | Planned (planning-docs/040). Optional agent-step value: literal text, or a path whose file contents are read at load time, appended verbatim to the agent prompt at context assembly (after the prompt, before the task/artifacts). A value that looks like a path but names no file is a validation error. Agent steps only. |
@@ -271,12 +271,14 @@ run start (or when its inputs change), its `produces:` artifact survives attempt
271
271
  starts, and the dev/review agents read that digest instead of re-deriving repo
272
272
  rules and environment facts every attempt.
273
273
 
274
- The brief digest is mechanically coverage-checked: the four section headings
275
- (`Repo rules digest`, `Unit scope and lane digest`, `Environment facts`, and
276
- `Open risks`) must appear, every anchor extracted from the `include` files
277
- (markdown `##` headings, and YAML category names plus `enforcement_levels`)
278
- must appear on its own line (case-insensitive, whitespace-collapsed, `**`
279
- decoration allowed), and every `env_facts` entry must appear verbatim. A miss
274
+ The brief digest is mechanically coverage-checked: the configured
275
+ `brief_inputs.sections` headings must each appear on their own line, every
276
+ anchor extracted from the `include` files (markdown `##` headings, and YAML
277
+ category names plus `enforcement_levels`) must appear on its own line
278
+ (case-insensitive, whitespace-collapsed, `**` decoration allowed), and every
279
+ `env_facts` entry must appear verbatim. The section list feeds both the
280
+ engine-embedded brief prompt and the coverage gate, so the two can never
281
+ disagree; a miss
280
282
  re-runs the brief agent once per missing set with a `## Coverage retry`
281
283
  correction note, bounded to three retries per input set; if the same items are
282
284
  still dropped after the cap, the run fails and `last-error.md` names the
@@ -21,6 +21,8 @@ src/agentic_dev_loop/engine/agent_error_guide.py
21
21
  src/agentic_dev_loop/engine/agents.py
22
22
  src/agentic_dev_loop/engine/allowlist.py
23
23
  src/agentic_dev_loop/engine/brief_coverage.py
24
+ src/agentic_dev_loop/engine/brief_prompt.md
25
+ src/agentic_dev_loop/engine/brief_prompt.py
24
26
  src/agentic_dev_loop/engine/cli.py
25
27
  src/agentic_dev_loop/engine/containers.py
26
28
  src/agentic_dev_loop/engine/deps.py
@@ -55,6 +57,7 @@ tests/test_033_brief_step.py
55
57
  tests/test_035_finalize_nudge.py
56
58
  tests/test_036_banned_commands_and_lane_boundary.py
57
59
  tests/test_041_brief_coverage.py
60
+ tests/test_046_brief_prompt.py
58
61
  tests/test_actions.py
59
62
  tests/test_agent_error_guide.py
60
63
  tests/test_agents.py
@@ -44,7 +44,7 @@ def _load(tmp_path, data):
44
44
 
45
45
  BRIEF_OK = (
46
46
  "## Repo rules digest\n\nREPO RULES\n\n"
47
- "## Unit scope and lane digest\n\nSCOPE\n\n"
47
+ "## Unit scope digest\n\nSCOPE\n\n"
48
48
  "## Environment facts\n\n- offline; never fetch\n\n"
49
49
  "## Open risks\n\nNone\n"
50
50
  )
@@ -167,6 +167,48 @@ class TestBriefInputsConfig:
167
167
  _load(tmp_path, data)
168
168
 
169
169
 
170
+ def test_sections_default_neutral(self, tmp_path):
171
+ data = _base()
172
+ data["steps"] = [BRIEF_STEP, {"name": "dev", "agent": "dev",
173
+ "on_pass": "success"}]
174
+ brief = _load(tmp_path, data).steps[0]
175
+ assert brief.brief_inputs.sections == [
176
+ "Repo rules digest", "Unit scope digest",
177
+ "Environment facts", "Open risks",
178
+ ]
179
+
180
+ def test_sections_override(self, tmp_path):
181
+ data = _base()
182
+ step = dict(BRIEF_STEP)
183
+ step["brief_inputs"] = {**step["brief_inputs"],
184
+ "sections": ["Rules", "Scope", "Risks"]}
185
+ data["steps"] = [step, {"name": "dev", "agent": "dev",
186
+ "on_pass": "success"}]
187
+ brief = _load(tmp_path, data).steps[0]
188
+ assert brief.brief_inputs.sections == ["Rules", "Scope", "Risks"]
189
+
190
+ def test_sections_empty_rejected(self, tmp_path):
191
+ data = _base()
192
+ data["steps"] = [{
193
+ "name": "brief", "agent": "brief", "produces": "attempt-brief.md",
194
+ "brief_inputs": {"include": ["AGENTS.md"], "sections": []},
195
+ "on_pass": "success",
196
+ }]
197
+ with pytest.raises(ValidationError, match="non-empty list"):
198
+ _load(tmp_path, data)
199
+
200
+ def test_sections_duplicate_rejected(self, tmp_path):
201
+ data = _base()
202
+ data["steps"] = [{
203
+ "name": "brief", "agent": "brief", "produces": "attempt-brief.md",
204
+ "brief_inputs": {"include": ["AGENTS.md"],
205
+ "sections": ["Rules", "Rules"]},
206
+ "on_pass": "success",
207
+ }]
208
+ with pytest.raises(ValidationError, match="unique"):
209
+ _load(tmp_path, data)
210
+
211
+
170
212
  def make_cfg(steps, **over):
171
213
  base = {"name": "t", "llm": {"model": "m", "api_key_env": "K"},
172
214
  "agents_dir": "agents", "lockfiles": ["uv.lock"], "steps": steps}
@@ -486,8 +528,6 @@ class TestDryRunBriefInputs:
486
528
  " on_pass: success\n")
487
529
  agents = repo / "agents"
488
530
  agents.mkdir()
489
- (agents / "brief.agent.md").write_text(
490
- "---\nname: brief\ntools: [terminal]\n---\nYou are brief.\n")
491
531
  monkeypatch.chdir(repo)
492
532
  from agentic_dev_loop.engine.dryrun import dry_run
493
533
  with pytest.raises(FileNotFoundError, match="NO-SUCH.md"):
@@ -513,8 +553,6 @@ class TestDryRunBriefInputs:
513
553
  " on_pass: success\n")
514
554
  agents = repo / "agents"
515
555
  agents.mkdir()
516
- (agents / "brief.agent.md").write_text(
517
- "---\nname: brief\ntools: [terminal]\n---\nYou are brief.\n")
518
556
  monkeypatch.chdir(repo)
519
557
  from agentic_dev_loop.engine.dryrun import dry_run
520
558
  assert dry_run("pipeline.yaml",
@@ -580,7 +618,7 @@ class TestBriefPipeline:
580
618
 
581
619
  brief_digest = (
582
620
  "## Repo rules digest\n\nREPO RULES\n\n"
583
- "## Unit scope and lane digest\n\nBRIEF DIGEST\n\n"
621
+ "## Unit scope digest\n\nBRIEF DIGEST\n\n"
584
622
  "## Environment facts\n\n"
585
623
  "- the workspace is an export with no .git; verify with find/ls, never git status\n\n"
586
624
  "## Open risks\n\nNone\n"
@@ -11,7 +11,6 @@ from agentic_dev_loop.core.config import PipelineConfig
11
11
  from agentic_dev_loop.engine.brief_coverage import (
12
12
  extract_anchors,
13
13
  missing_anchors,
14
- required_sections,
15
14
  )
16
15
  from agentic_dev_loop.engine.registry import default_registry
17
16
  from agentic_dev_loop.engine.stages import (
@@ -24,6 +23,8 @@ from agentic_dev_loop.engine.stages import (
24
23
  ROOT = Path(__file__).resolve().parents[1]
25
24
  AGENTS_MD = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
26
25
  STANDARDS_YAML = (ROOT / "coding-standards-ddd.yaml").read_text(encoding="utf-8")
26
+ SECTIONS = ["Repo rules digest", "Unit scope digest",
27
+ "Environment facts", "Open risks"]
27
28
 
28
29
 
29
30
  def _brief_ok() -> str:
@@ -31,7 +32,7 @@ def _brief_ok() -> str:
31
32
  "## Repo rules digest\n\n"
32
33
  "**Error handling**\nNever fall back silently.\n\n"
33
34
  "**Mock policy**\nNo mocks unless unavoidable.\n\n"
34
- "## Unit scope and lane digest\n\nunit scope\n\n"
35
+ "## Unit scope digest\n\nunit scope\n\n"
35
36
  "## Environment facts\n\n- offline; never fetch\n\n"
36
37
  "## Open risks\n\nNone\n"
37
38
  )
@@ -93,13 +94,12 @@ def runtime(tmp_path, fake_sandbox):
93
94
 
94
95
 
95
96
  class TestExtractAnchors:
96
- def test_required_sections_are_the_four_template_headings(self):
97
- assert required_sections() == [
98
- "Repo rules digest",
99
- "Unit scope and lane digest",
100
- "Environment facts",
101
- "Open risks",
102
- ]
97
+ def test_required_sections_are_config_driven(self):
98
+ # 046: the required set is handed in, not read from an engine constant.
99
+ text = _brief_ok()
100
+ assert missing_anchors(text, [], [], SECTIONS) == []
101
+ assert "Renamed section" in missing_anchors(
102
+ text, [], [], ["Renamed section"])
103
103
 
104
104
  def test_markdown_headings_from_real_agents_md(self):
105
105
  anchors = extract_anchors(AGENTS_MD)
@@ -142,39 +142,42 @@ class TestMissingAnchors:
142
142
  def test_all_present_passes(self):
143
143
  assert missing_anchors(_brief_ok(),
144
144
  ["error handling", "mock policy"],
145
- ["offline; never fetch"]) == []
145
+ ["offline; never fetch"], SECTIONS) == []
146
146
 
147
147
  def test_missing_anchor_is_named(self):
148
148
  assert "mock policy" in missing_anchors(
149
149
  _brief_missing_mock(), ["error handling", "mock policy"],
150
- ["offline; never fetch"])
150
+ ["offline; never fetch"], SECTIONS)
151
151
 
152
152
  def test_missing_section_heading_is_named(self):
153
153
  text = _brief_ok().replace("## Open risks\n\nNone\n", "")
154
154
  assert "Open risks" in missing_anchors(
155
- text, ["error handling", "mock policy"], ["offline; never fetch"])
155
+ text, ["error handling", "mock policy"], ["offline; never fetch"],
156
+ SECTIONS)
156
157
 
157
158
  def test_altered_env_fact_is_named(self):
158
159
  text = _brief_ok().replace("offline; never fetch", "offline maybe")
159
160
  assert "offline; never fetch" in missing_anchors(
160
- text, ["error handling", "mock policy"], ["offline; never fetch"])
161
+ text, ["error handling", "mock policy"], ["offline; never fetch"],
162
+ SECTIONS)
161
163
 
162
164
  def test_mid_line_mention_does_not_satisfy(self):
163
165
  text = _brief_ok().replace(
164
166
  "**Error handling**\nNever fall back silently.\n\n",
165
167
  "See error handling for the rules.\n\n")
166
168
  assert "error handling" in missing_anchors(
167
- text, ["error handling", "mock policy"], ["offline; never fetch"])
169
+ text, ["error handling", "mock policy"], ["offline; never fetch"],
170
+ SECTIONS)
168
171
 
169
172
  def test_case_and_whitespace_variants_pass(self):
170
173
  text = _brief_ok().replace(
171
174
  "**Error handling**", "** ERROR HANDLING **")
172
175
  assert missing_anchors(text, ["error handling", "mock policy"],
173
- ["offline; never fetch"]) == []
176
+ ["offline; never fetch"], SECTIONS) == []
174
177
 
175
178
  def test_bold_wrapped_heading_passes(self):
176
179
  assert missing_anchors(_brief_ok(), ["error handling", "mock policy"],
177
- ["offline; never fetch"]) == []
180
+ ["offline; never fetch"], SECTIONS) == []
178
181
 
179
182
 
180
183
  class TestCoverageNoteWording:
@@ -205,6 +208,14 @@ class TestCoverageNoteWording:
205
208
 
206
209
 
207
210
  class TestBriefNodeCoverage:
211
+ def test_vendored_brief_prompt_warns(self, runtime, capsys):
212
+ runtime.agents_dir.mkdir(exist_ok=True)
213
+ (runtime.agents_dir / "brief.agent.md").write_text("vendored\n")
214
+ make_brief_node(runtime, runtime.cfg.steps[0])
215
+ err = capsys.readouterr().err
216
+ assert "ignoring vendored brief prompt" in err
217
+ assert "embedded prompt is authoritative" in err
218
+
208
219
  def test_miss_returns_retry_note_and_reruns(self, runtime, fake_agents):
209
220
  good = _brief_ok()
210
221
  bad = _brief_missing_mock()
@@ -0,0 +1,45 @@
1
+ """Brief prompt embedding (046): the OUTPUT contract renders from the
2
+ configured section headings, so the prompt and the coverage gate share one
3
+ list and cannot drift apart (the 043 divergence class)."""
4
+
5
+ from agentic_dev_loop.engine.brief_prompt import (
6
+ BRIEF_PROMPT_VERSION,
7
+ load_template,
8
+ render_brief_prompt,
9
+ )
10
+
11
+ SECTIONS = ["Repo rules digest", "Unit scope digest",
12
+ "Environment facts", "Open risks"]
13
+
14
+
15
+ class TestRenderBriefPrompt:
16
+ def test_each_configured_section_is_its_own_heading(self):
17
+ prompt = render_brief_prompt(SECTIONS)
18
+ lines = prompt.splitlines()
19
+ for section in SECTIONS:
20
+ assert f"## {section}" in lines
21
+
22
+ def test_custom_sections_rendered(self):
23
+ prompt = render_brief_prompt(["Rules", "Scope", "Risks"])
24
+ lines = prompt.splitlines()
25
+ assert "## Rules" in lines
26
+ assert "## Scope" in lines
27
+ assert "## Risks" in lines
28
+ assert "## Repo rules digest" not in lines
29
+
30
+ def test_warning_lists_configured_names(self):
31
+ prompt = render_brief_prompt(["Rules", "Scope", "Risks"])
32
+ assert "`## Rules`" in prompt
33
+ assert "`## Scope`" in prompt
34
+ assert "`## Risks`" in prompt
35
+ assert "Do not shorten or rename them" in prompt
36
+
37
+ def test_static_template_loads(self):
38
+ template = load_template()
39
+ assert "## WORKFLOW" in template
40
+ assert "## RULES" in template
41
+ assert "Do not use em dash characters" in template
42
+
43
+ def test_version_is_stable_string(self):
44
+ assert isinstance(BRIEF_PROMPT_VERSION, str)
45
+ assert BRIEF_PROMPT_VERSION
@@ -9,7 +9,7 @@ import pytest
9
9
  from openhands.sdk import LLM
10
10
  from openhands.sdk.agent.agent import Agent
11
11
 
12
- from agentic_dev_loop.engine.agents import load_agent
12
+ from agentic_dev_loop.engine.agents import load_agent, load_brief_agent
13
13
 
14
14
 
15
15
  @pytest.fixture
@@ -88,6 +88,25 @@ class TestLoadAgent:
88
88
  assert agent.filter_tools_regex is None
89
89
 
90
90
 
91
+ class TestLoadBriefAgent:
92
+ """The brief agent is engine-embedded (046): no vendored file is read,
93
+ and the rendered OUTPUT contract is driven by the configured sections."""
94
+
95
+ def test_builds_embedded_agent_without_file(self, tmp_path, llm):
96
+ sections = ["Repo rules digest", "Unit scope digest",
97
+ "Environment facts", "Open risks"]
98
+ agent = load_brief_agent("brief", sections, llm)
99
+ assert isinstance(agent, Agent)
100
+ assert [t.name for t in agent.tools] == ["terminal"]
101
+
102
+ def test_does_not_read_agents_dir(self, tmp_path, llm):
103
+ agents_dir = tmp_path / "agents"
104
+ agents_dir.mkdir()
105
+ # No brief.agent.md exists; the embedded loader must not look for one.
106
+ agent = load_brief_agent("brief", ["Repo rules digest"], llm)
107
+ assert isinstance(agent, Agent)
108
+
109
+
91
110
  from agentic_dev_loop.engine.isolation import SandboxError
92
111
 
93
112
 
@@ -64,7 +64,7 @@ def _script_test(*behaviors):
64
64
 
65
65
  _BRIEF_DIGEST = (
66
66
  "## Repo rules digest\n\n# AGENTS.md\n# coding-standards-ddd.yaml\n\n"
67
- "## Unit scope and lane digest\n\nplan scope\n\n"
67
+ "## Unit scope digest\n\nplan scope\n\n"
68
68
  "## Environment facts\n\n"
69
69
  "- the workspace is an export with no .git; verify with find/ls, never git status\n"
70
70
  "- the sandbox is network-off by default; never run package-manager fetches\n"