agentic-dev-loop 1.0.0a45__tar.gz → 1.0.0a48__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.0a45/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a48}/PKG-INFO +33 -5
  2. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/README.md +32 -4
  3. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/core/config.py +13 -1
  5. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/isolation.py +66 -21
  6. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/llm.py +14 -7
  7. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/stages.py +10 -2
  8. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48/src/agentic_dev_loop.egg-info}/PKG-INFO +33 -5
  9. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_config.py +34 -0
  10. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_isolation.py +80 -2
  11. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_isolation_integration.py +79 -1
  12. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_llm.py +40 -0
  13. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_publish.py +39 -0
  14. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_trigger_script.py +49 -4
  15. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/LICENSE +0 -0
  16. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/setup.cfg +0 -0
  17. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/__init__.py +0 -0
  18. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/core/__init__.py +0 -0
  19. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/core/paths.py +0 -0
  20. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/core/results.py +0 -0
  21. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/core/verdict.py +0 -0
  22. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/__init__.py +0 -0
  23. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/actions.py +0 -0
  24. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  25. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  26. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/agents.py +0 -0
  27. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  28. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/brief_coverage.py +0 -0
  29. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/brief_prompt.md +0 -0
  30. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/brief_prompt.py +0 -0
  31. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/cli.py +0 -0
  32. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/containers.py +0 -0
  33. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/deps.py +0 -0
  34. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  35. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  36. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  37. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  38. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  39. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  40. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/graph.py +0 -0
  41. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  42. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/loaders.py +0 -0
  43. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/mounts.py +0 -0
  44. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/outputs.py +0 -0
  45. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/presets.py +0 -0
  46. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/proc.py +0 -0
  47. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/registry.py +0 -0
  48. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  49. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  50. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/secrets.py +0 -0
  51. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  52. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  53. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/py.typed +0 -0
  54. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/viz/__init__.py +0 -0
  55. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/viz/__main__.py +0 -0
  56. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/viz/server.py +0 -0
  57. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  58. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  59. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  60. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  61. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  62. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_028_declared_caches.py +0 -0
  63. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_029_merge_on_conflict.py +0 -0
  64. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_033_brief_step.py +0 -0
  65. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_035_finalize_nudge.py +0 -0
  66. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  67. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_041_brief_coverage.py +0 -0
  68. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_046_brief_prompt.py +0 -0
  69. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_actions.py +0 -0
  70. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_agent_error_guide.py +0 -0
  71. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_agents.py +0 -0
  72. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_aidlc.py +0 -0
  73. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_cli.py +0 -0
  74. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_containers.py +0 -0
  75. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_deps.py +0 -0
  76. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_dry_run.py +0 -0
  77. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_e2e_fixtures.py +0 -0
  78. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_egress_proxy.py +0 -0
  79. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_graph.py +0 -0
  80. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_hostboundary.py +0 -0
  81. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_hostboundary_integration.py +0 -0
  82. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_isolation_pbt.py +0 -0
  83. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_loaders.py +0 -0
  84. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_module_contract.py +0 -0
  85. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_outputs.py +0 -0
  86. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_outputs_pbt.py +0 -0
  87. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_proc.py +0 -0
  88. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_qa_prompt.py +0 -0
  89. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_registry.py +0 -0
  90. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_repo_ascii.py +0 -0
  91. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_results.py +0 -0
  92. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_sdk_compat.py +0 -0
  93. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_secrets.py +0 -0
  94. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_secrets_integration.py +0 -0
  95. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_secrets_pipeline.py +0 -0
  96. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_source_resolution.py +0 -0
  97. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_stages.py +0 -0
  98. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_step_workspace.py +0 -0
  99. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_sync_back.py +0 -0
  100. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_unit5_config.py +0 -0
  101. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_unit5_pbt.py +0 -0
  102. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_unit5_pure.py +0 -0
  103. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/tests/test_verdict.py +0 -0
  104. {agentic_dev_loop-1.0.0a45 → agentic_dev_loop-1.0.0a48}/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.0a45
3
+ Version: 1.0.0a48
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
@@ -199,6 +199,7 @@ llm:
199
199
  model: openai/deepseek-v4-pro
200
200
  base_url: https://api.deepseek.com
201
201
  api_key_env: OPENAI_API_KEY
202
+ # reasoning_effort: medium # optional: low | medium | high | xhigh | none
202
203
 
203
204
  max_attempts: 10 # CLI --max-attempts overrides
204
205
  agents_dir: sdk_agents # your <name>.agent.md files
@@ -228,6 +229,7 @@ steps:
228
229
  agent: review
229
230
  artifact: review-findings.md
230
231
  verdicts: { pass: APPROVED, fail: NEEDS CHANGES }
232
+ # reasoning_effort: xhigh # per-step override; falls back to the llm block, then SDK default
231
233
  on_pass: qa
232
234
  on_fail: { goto: dev, retry: true }
233
235
 
@@ -253,6 +255,7 @@ steps:
253
255
  | `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
256
  | `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
257
  | `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. |
258
+ | `reasoning_effort:` | Optional `low` / `medium` / `high` / `xhigh` / `none`. Set on the `llm` block (pipeline-global) or per-step (overrides the block). Unset anywhere = the field is not passed and the SDK default (`high`) applies, so today's behavior is unchanged. Forwarded only for models the SDK/LiteLLM mark as supporting it; an unsupported value fails at the first LLM call. See [Reasoning effort](#reasoning-effort-opt-in). |
256
259
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
257
260
  | `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). |
258
261
  | `setup_probe:` | Optional consumer-authored in-container probe run on a warm `setup:` stamp instead of the venv probe. Exit 0 trusts the stamp (skips); any nonzero re-provisions with network on (fail toward freshness). The probe text is NOT part of the stamp key, so a probe-only change needs `--refresh` or `--clean-stamps` to take effect. Example (asserting a warmed esbuild binary is Linux-shaped): `file frontend-editor/node_modules/esbuild/bin/esbuild | grep -q ELF`. |
@@ -266,6 +269,30 @@ steps:
266
269
  | `caches:` / `sync_excludes:` | Top-level run-wide lists. `caches:` names repo-relative toolchain cache dirs bind-mounted rw from the real workspace (auto-created if missing); a `setup:` hook can warm them (the provision container carries the same cache mounts); `sync_excludes:` names scratch paths skipped from both the export copy and sync-back (wildcard `*` spans separators, so `**/bin/**` matches a `/bin/` segment at any depth). Both default to `[]`. See [docs/setup.md](docs/setup.md). |
267
270
  | `merge_on_conflict:` | Top-level run-wide list of append-style logs (same path vocabulary as `sync_excludes:`, default `[]`). A concurrent host edit to a listed path is union-merged instead of raising: the host file wins and the sandbox's export-only lines are appended in order; both sides converge and the run continues. See [docs/setup.md](docs/setup.md). |
268
271
 
272
+ ### Reasoning effort (opt-in)
273
+
274
+ `reasoning_effort` is an opt-in latency dial on the pipeline `llm` block and
275
+ on individual agent steps. It takes one of `low`, `medium`, `high`, `xhigh`,
276
+ or `none`; unset means the field is never passed and the SDK default
277
+ (`high`) applies, so a pipeline that omits it behaves exactly as before. A
278
+ step's value overrides the `llm` block's value, so the review lane can hold
279
+ a higher effort than the rest.
280
+
281
+ The value is forwarded only for models the SDK/LiteLLM mark as supporting
282
+ it; an unsupported value fails at the first LLM call (the schema rejects
283
+ unknown names at load time, but cannot know provider capability). No
284
+ pipeline ships a value: adopt one only after an observed run proves lower
285
+ per-turn latency with a green suite and no provider 400s.
286
+
287
+ Recommended lane defaults (guidance only, not enforced):
288
+
289
+ | Lane | reasoning_effort | Why |
290
+ | --- | --- | --- |
291
+ | brief | medium | digest and classify, no design work |
292
+ | dev | high | hold the line: dev caught a real plan bug and a real sandbox triage this unit |
293
+ | qa | medium | classification discipline must stay reliable |
294
+ | review | xhigh | shortest lane, highest stakes: a missed detection sails past the last gate into commit |
295
+
269
296
  A `brief_inputs:` step is the per-run context-digestion pattern: it runs once at
270
297
  run start (or when its inputs change), its `produces:` artifact survives attempt
271
298
  starts, and the dev/review agents read that digest instead of re-deriving repo
@@ -357,10 +384,11 @@ steps:
357
384
  structurally enforced.** A networked step runs on a per-run internal
358
385
  docker network with no route to the host. Its traffic is forced
359
386
  through a containerized egress proxy (injected via
360
- `HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy container's DNS name
361
- on the internal network); a request to an undeclared host is denied
362
- and logged as an enforcement event. The engine never adds an
363
- undeclared target. `egress: { class: browser }` is the explicit
387
+ `HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy's internal IP
388
+ on the internal network; each distinct egress policy gets its own
389
+ proxy with a distinct internal IP); a request to an undeclared host
390
+ is denied and logged as an enforcement event. The engine never adds
391
+ an undeclared target. `egress: { class: browser }` is the explicit
364
392
  broad-but-logged class (every connection logged, nothing filtered); command
365
393
  and agent steps default to a strict allowlist.
366
394
  - **Command, agent, and sandbox-action steps** may declare it. The host-side
@@ -155,6 +155,7 @@ llm:
155
155
  model: openai/deepseek-v4-pro
156
156
  base_url: https://api.deepseek.com
157
157
  api_key_env: OPENAI_API_KEY
158
+ # reasoning_effort: medium # optional: low | medium | high | xhigh | none
158
159
 
159
160
  max_attempts: 10 # CLI --max-attempts overrides
160
161
  agents_dir: sdk_agents # your <name>.agent.md files
@@ -184,6 +185,7 @@ steps:
184
185
  agent: review
185
186
  artifact: review-findings.md
186
187
  verdicts: { pass: APPROVED, fail: NEEDS CHANGES }
188
+ # reasoning_effort: xhigh # per-step override; falls back to the llm block, then SDK default
187
189
  on_pass: qa
188
190
  on_fail: { goto: dev, retry: true }
189
191
 
@@ -209,6 +211,7 @@ steps:
209
211
  | `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
212
  | `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
213
  | `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. |
214
+ | `reasoning_effort:` | Optional `low` / `medium` / `high` / `xhigh` / `none`. Set on the `llm` block (pipeline-global) or per-step (overrides the block). Unset anywhere = the field is not passed and the SDK default (`high`) applies, so today's behavior is unchanged. Forwarded only for models the SDK/LiteLLM mark as supporting it; an unsupported value fails at the first LLM call. See [Reasoning effort](#reasoning-effort-opt-in). |
212
215
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
213
216
  | `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). |
214
217
  | `setup_probe:` | Optional consumer-authored in-container probe run on a warm `setup:` stamp instead of the venv probe. Exit 0 trusts the stamp (skips); any nonzero re-provisions with network on (fail toward freshness). The probe text is NOT part of the stamp key, so a probe-only change needs `--refresh` or `--clean-stamps` to take effect. Example (asserting a warmed esbuild binary is Linux-shaped): `file frontend-editor/node_modules/esbuild/bin/esbuild | grep -q ELF`. |
@@ -222,6 +225,30 @@ steps:
222
225
  | `caches:` / `sync_excludes:` | Top-level run-wide lists. `caches:` names repo-relative toolchain cache dirs bind-mounted rw from the real workspace (auto-created if missing); a `setup:` hook can warm them (the provision container carries the same cache mounts); `sync_excludes:` names scratch paths skipped from both the export copy and sync-back (wildcard `*` spans separators, so `**/bin/**` matches a `/bin/` segment at any depth). Both default to `[]`. See [docs/setup.md](docs/setup.md). |
223
226
  | `merge_on_conflict:` | Top-level run-wide list of append-style logs (same path vocabulary as `sync_excludes:`, default `[]`). A concurrent host edit to a listed path is union-merged instead of raising: the host file wins and the sandbox's export-only lines are appended in order; both sides converge and the run continues. See [docs/setup.md](docs/setup.md). |
224
227
 
228
+ ### Reasoning effort (opt-in)
229
+
230
+ `reasoning_effort` is an opt-in latency dial on the pipeline `llm` block and
231
+ on individual agent steps. It takes one of `low`, `medium`, `high`, `xhigh`,
232
+ or `none`; unset means the field is never passed and the SDK default
233
+ (`high`) applies, so a pipeline that omits it behaves exactly as before. A
234
+ step's value overrides the `llm` block's value, so the review lane can hold
235
+ a higher effort than the rest.
236
+
237
+ The value is forwarded only for models the SDK/LiteLLM mark as supporting
238
+ it; an unsupported value fails at the first LLM call (the schema rejects
239
+ unknown names at load time, but cannot know provider capability). No
240
+ pipeline ships a value: adopt one only after an observed run proves lower
241
+ per-turn latency with a green suite and no provider 400s.
242
+
243
+ Recommended lane defaults (guidance only, not enforced):
244
+
245
+ | Lane | reasoning_effort | Why |
246
+ | --- | --- | --- |
247
+ | brief | medium | digest and classify, no design work |
248
+ | dev | high | hold the line: dev caught a real plan bug and a real sandbox triage this unit |
249
+ | qa | medium | classification discipline must stay reliable |
250
+ | review | xhigh | shortest lane, highest stakes: a missed detection sails past the last gate into commit |
251
+
225
252
  A `brief_inputs:` step is the per-run context-digestion pattern: it runs once at
226
253
  run start (or when its inputs change), its `produces:` artifact survives attempt
227
254
  starts, and the dev/review agents read that digest instead of re-deriving repo
@@ -313,10 +340,11 @@ steps:
313
340
  structurally enforced.** A networked step runs on a per-run internal
314
341
  docker network with no route to the host. Its traffic is forced
315
342
  through a containerized egress proxy (injected via
316
- `HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy container's DNS name
317
- on the internal network); a request to an undeclared host is denied
318
- and logged as an enforcement event. The engine never adds an
319
- undeclared target. `egress: { class: browser }` is the explicit
343
+ `HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy's internal IP
344
+ on the internal network; each distinct egress policy gets its own
345
+ proxy with a distinct internal IP); a request to an undeclared host
346
+ is denied and logged as an enforcement event. The engine never adds
347
+ an undeclared target. `egress: { class: browser }` is the explicit
320
348
  broad-but-logged class (every connection logged, nothing filtered); command
321
349
  and agent steps default to a strict allowlist.
322
350
  - **Command, agent, and sandbox-action steps** may declare it. The host-side
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a45"
7
+ version = "1.0.0a48"
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"
@@ -6,7 +6,7 @@ import hashlib
6
6
  import re
7
7
  import warnings
8
8
  from pathlib import Path
9
- from typing import Any
9
+ from typing import Any, Literal
10
10
 
11
11
  import yaml
12
12
  from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
@@ -35,6 +35,11 @@ DEFAULT_BRIEF_SECTIONS = [
35
35
  "Open risks",
36
36
  ]
37
37
 
38
+ # 047: the full SDK reasoning_effort literal, passed through verbatim. The
39
+ # engine is provider-generic, so it validates shape only - whether a given
40
+ # provider accepts every value (notably "none") is operator risk (D3).
41
+ ReasoningEffort = Literal["low", "medium", "high", "xhigh", "none"]
42
+
38
43
 
39
44
  def _validate_mount_path(path: str) -> str:
40
45
  """Validate one repo-relative mount path (BR-1.3).
@@ -385,6 +390,9 @@ class StepConfig(BaseModel):
385
390
  terminal_timeout: float | None = Field(default=None, gt=0) # seconds, agent steps only (032)
386
391
  finalize_nudge_turns: int | None = Field(default=None, gt=0) # agent steps only (035)
387
392
  brief_inputs: BriefInputsSpec | None = None # agent steps only (033)
393
+ # 047 (D7): per-step opt-in override; unset falls back to the llm block,
394
+ # then the SDK default. Full SDK literal, passthrough semantics.
395
+ reasoning_effort: ReasoningEffort | None = None
388
396
  custom_prompt: str | None = None # agent steps only (040); file path resolved at load
389
397
  env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
390
398
  setup: str | None = None # BR-13: command steps only
@@ -552,6 +560,10 @@ class LlmConfig(BaseModel):
552
560
  base_url: str | None = None
553
561
  api_key_env: str
554
562
  timeout: float | None = None # omitted → opinionated no-timeout default
563
+ # 047 (D1): opt-in passthrough; unset means the field is not passed at
564
+ # all, so the SDK default ("high") applies and today's behavior is
565
+ # byte-identical. No default may move without an observed run (D6).
566
+ reasoning_effort: ReasoningEffort | None = None
555
567
 
556
568
 
557
569
  class PipelineConfig(BaseModel):
@@ -31,7 +31,7 @@ import sys
31
31
  import time
32
32
  import uuid
33
33
  from abc import ABC, abstractmethod
34
- from collections.abc import Mapping
34
+ from collections.abc import Iterable, Mapping
35
35
  from dataclasses import dataclass
36
36
  from datetime import datetime, timezone
37
37
  from pathlib import Path, PurePosixPath
@@ -299,10 +299,10 @@ def proxy_container_name(run_id: str, profile_key: str) -> str:
299
299
  return f"{run_id}-proxy-{profile_key}"
300
300
 
301
301
 
302
- def proxy_url(proxy_name: str, port: int = EGRESS_PROXY_PORT) -> str:
303
- """The sandbox-reachable proxy URL: the proxy container's internal DNS
304
- name on the internal network (038, D3). Never 127.0.0.1 or the host-gateway."""
305
- return f"http://{proxy_name}:{port}"
302
+ def proxy_url(proxy_host: str, port: int = EGRESS_PROXY_PORT) -> str:
303
+ """The sandbox-reachable proxy URL: the proxy's internal address on the
304
+ internal network (038, D3). Never 127.0.0.1 or the host-gateway."""
305
+ return f"http://{proxy_host}:{port}"
306
306
 
307
307
 
308
308
  def egress_events_path(iso_state_dir: str | Path, run_id: str) -> Path:
@@ -310,8 +310,10 @@ def egress_events_path(iso_state_dir: str | Path, run_id: str) -> Path:
310
310
  return Path(iso_state_dir) / f"{EGRESS_EVENTS_PREFIX}-{run_id}.jsonl"
311
311
 
312
312
 
313
- def first_usable_ip(subnets: str) -> str | None:
314
- """First usable non-gateway IPv4 address from one or more CIDR subnets."""
313
+ def proxy_usable_ips(subnets: str) -> list[str]:
314
+ """Usable non-gateway IPv4 host addresses from the first viable subnet
315
+ (048 D1): ``hosts[1:]``, skipping the gateway at ``hosts[0]``. Empty when
316
+ no subnet offers a non-gateway host."""
315
317
  for token in subnets.split():
316
318
  try:
317
319
  network = ipaddress.ip_network(token, strict=False)
@@ -322,8 +324,8 @@ def first_usable_ip(subnets: str) -> str | None:
322
324
  hosts = list(network.hosts())
323
325
  if len(hosts) < 2:
324
326
  continue
325
- return str(hosts[1])
326
- return None
327
+ return [str(host) for host in hosts[1:]]
328
+ return []
327
329
 
328
330
 
329
331
 
@@ -964,6 +966,7 @@ class DockerBackend(Sandbox):
964
966
  self._run_net = run_network_name(self._run_id)
965
967
  self._network_created = False
966
968
  self._proxy_containers: list[str] = []
969
+ self._proxy_ips: dict[str, str] = {}
967
970
  self._egress_events_path = egress_events_path(self._iso_state_dir,
968
971
  self._run_id)
969
972
  self._sandbox_venv_dir = sandbox_venv_dir # 022 relocated venv (opt-in)
@@ -1309,9 +1312,9 @@ class DockerBackend(Sandbox):
1309
1312
  proxy_url_value: str | None = None
1310
1313
  if network and self._export_root is not None:
1311
1314
  proxy_name = proxy_container_name(self._run_id, key[0][:16])
1312
- self._start_proxy_container(proxy_name, policy,
1313
- self._dial_rewrites)
1314
- proxy_url_value = proxy_url(proxy_name)
1315
+ proxy_ip = self._start_proxy_container(proxy_name, policy,
1316
+ self._dial_rewrites)
1317
+ proxy_url_value = proxy_url(proxy_ip)
1315
1318
  payload = docker_create_payload(
1316
1319
  run_id=self._run_id, image=self._image, profile=self._profile,
1317
1320
  workspace=self._workspace, network=network,
@@ -1334,7 +1337,7 @@ class DockerBackend(Sandbox):
1334
1337
  started=True)
1335
1338
 
1336
1339
  def _start_proxy_container(self, name: str, policy: EgressPolicy,
1337
- dial_rewrites: dict[str, str] | None = None) -> None:
1340
+ dial_rewrites: dict[str, str] | None = None) -> str:
1338
1341
  """Create one containerized proxy (038 D2/D5, 039 D1-D4): host-access
1339
1342
  network at create time with a pinned internal IP, then attach the internal
1340
1343
  network; the proxy binds that address only and is readiness probed from
@@ -1350,7 +1353,7 @@ class DockerBackend(Sandbox):
1350
1353
  policy_json = egress_proxy_module.policy_to_json(policy)
1351
1354
  dial_rewrites = dict(dial_rewrites or {})
1352
1355
  dial_rewrites_json = json.dumps(dial_rewrites, sort_keys=True)
1353
- bind_host = self._proxy_internal_ip()
1356
+ bind_host = self._proxy_internal_ip(name)
1354
1357
  payload = docker_proxy_payload(
1355
1358
  run_id=self._run_id, image=self._image, name=name,
1356
1359
  policy_json=policy_json, dial_rewrites_json=dial_rewrites_json,
@@ -1379,22 +1382,63 @@ class DockerBackend(Sandbox):
1379
1382
  f"{bind_host}:{EGRESS_PROXY_PORT} after "
1380
1383
  f"{PROXY_READY_ATTEMPTS} readiness probes")
1381
1384
  self._proxy_containers.append(name)
1382
-
1383
- def _proxy_internal_ip(self) -> str:
1384
- """Return the pinned first-usable IPv4 address on the internal network."""
1385
+ return bind_host
1386
+
1387
+ def _proxy_internal_ip(self, proxy_name: str) -> str:
1388
+ """Return the per-proxy pinned IPv4 address on the internal network
1389
+ (048 D1): inspect the subnet and the addresses containers already hold
1390
+ on it, then allocate the next free host (profile containers join the
1391
+ run network with a daemon auto-assigned IP, so those must be skipped
1392
+ or the later ``network connect --ip`` collides)."""
1385
1393
  inspect = self._runner.run(
1386
1394
  ["docker", "network", "inspect", "-f",
1387
- "{{range .IPAM.Config}}{{.Subnet}} {{end}}", self._run_net],
1395
+ "{{range .IPAM.Config}}{{.Subnet}} {{end}}|"
1396
+ "{{range .Containers}}{{.IPv4Address}} {{end}}",
1397
+ self._run_net],
1388
1398
  timeout=30)
1389
1399
  if inspect.exit_code != 0:
1390
1400
  raise SandboxError(
1391
1401
  f"AE-SBX-021: internal network inspect failed: "
1392
1402
  f"{inspect.stderr.strip() or inspect.stdout.strip()}")
1393
- ip = first_usable_ip(inspect.stdout)
1394
- if ip is None:
1403
+ subnets, sep, in_use_part = inspect.stdout.partition("|")
1404
+ in_use = in_use_part.split() if sep else ()
1405
+ return self._allocate_proxy_ip(subnets, proxy_name, in_use)
1406
+
1407
+ def _allocate_proxy_ip(self, subnets: str, proxy_name: str,
1408
+ in_use: Iterable[str] = ()) -> str:
1409
+ """Allocate one internal IPv4 address per proxy (048 D1/D2). An
1410
+ existing allocation for ``proxy_name`` is reused; otherwise the first
1411
+ free non-gateway host wins (the first proxy keeps ``hosts[1]``).
1412
+ Addresses in ``in_use`` (held by other containers on the network) are
1413
+ skipped alongside the already-allocated proxy IPs. Raises when the
1414
+ subnet has no free host instead of letting the daemon emit a
1415
+ confusing collision error."""
1416
+ existing = self._proxy_ips.get(proxy_name)
1417
+ if existing is not None:
1418
+ return existing
1419
+ hosts = proxy_usable_ips(subnets)
1420
+ if not hosts:
1395
1421
  raise SandboxError(
1396
1422
  f"AE-SBX-021: internal network has no usable IPv4 subnet: "
1397
- f"{inspect.stdout.strip()!r}")
1423
+ f"{subnets.strip()!r}")
1424
+ taken = set(self._proxy_ips.values())
1425
+ for token in in_use:
1426
+ addr = token.split("/", 1)[0].strip()
1427
+ try:
1428
+ ip = ipaddress.ip_address(addr)
1429
+ except ValueError:
1430
+ continue
1431
+ if isinstance(ip, ipaddress.IPv4Address):
1432
+ taken.add(str(ip))
1433
+ free = [host for host in hosts if host not in taken]
1434
+ if not free:
1435
+ raise SandboxError(
1436
+ f"AE-SBX-021: internal network {subnets.strip()!r} has "
1437
+ f"{len(hosts)} usable proxy address(es) but "
1438
+ f"{len(self._proxy_ips) + 1} egress proxies were requested - "
1439
+ f"no free address to assign")
1440
+ ip = free[0]
1441
+ self._proxy_ips[proxy_name] = ip
1398
1442
  return ip
1399
1443
 
1400
1444
  def _wait_proxy_ready(self, name: str, host: str) -> bool:
@@ -1541,6 +1585,7 @@ class DockerBackend(Sandbox):
1541
1585
  for name in self._proxy_containers:
1542
1586
  self._rm_container(name)
1543
1587
  self._proxy_containers.clear()
1588
+ self._proxy_ips.clear()
1544
1589
  if self._network_created:
1545
1590
  self._runner.run(["docker", "network", "rm", self._run_net], timeout=60)
1546
1591
  self._network_created = False
@@ -48,13 +48,20 @@ if not getattr(_ORIGINAL_FORMAT_MESSAGES, "_agentic_dev_loop_reasoning_repair",
48
48
  LLM.format_messages_for_llm = _repair_reasoning_passback
49
49
 
50
50
 
51
- def build_llm(cfg: LlmConfig) -> LLM:
51
+ def build_llm(cfg: LlmConfig, reasoning_effort: str | None = None) -> LLM:
52
52
  api_key = os.environ.get(cfg.api_key_env)
53
53
  if not api_key:
54
54
  raise RuntimeError(f"{cfg.api_key_env} environment variable is required")
55
- return LLM(
56
- model=cfg.model,
57
- api_key=api_key,
58
- base_url=cfg.base_url,
59
- timeout=cfg.timeout, # policy: no LLM timeout unless explicitly set
60
- )
55
+ # 047 (D2/D7): an explicit per-step override wins, else the llm block's
56
+ # value applies, else the field is omitted entirely and the SDK default
57
+ # ("high") is in force. The reasoning_content shims above stay untouched.
58
+ effort = reasoning_effort if reasoning_effort is not None else cfg.reasoning_effort
59
+ kwargs = {
60
+ "model": cfg.model,
61
+ "api_key": api_key,
62
+ "base_url": cfg.base_url,
63
+ "timeout": cfg.timeout, # policy: no LLM timeout unless explicitly set
64
+ }
65
+ if effort is not None:
66
+ kwargs["reasoning_effort"] = effort
67
+ return LLM(**kwargs)
@@ -42,6 +42,7 @@ from .egress_policy import assemble_egress_policy
42
42
  from .forbidden import resolve_forbidden_rules
43
43
  from .isolation import (SandboxError, SandboxExecutionRequest, command_argv,
44
44
  resolve_sandbox_env)
45
+ from .llm import build_llm
45
46
  from .mounts import resolve_mount_set
46
47
  from .outputs import CapturedOutput
47
48
  from .secrets import redact_values, step_secret_env
@@ -174,11 +175,18 @@ class Runtime:
174
175
  if step is not None else None),
175
176
  forbidden_commands=forbidden_commands,
176
177
  allowlist=allowlist)
178
+ # 047 (D7): a step's reasoning_effort overrides the llm block's;
179
+ # self.llm already carries the block value (or the SDK default),
180
+ # so only build a fresh LLM when the step overrides it.
181
+ llm = self.llm
182
+ if step is not None and step.reasoning_effort is not None:
183
+ llm = build_llm(self.cfg.llm,
184
+ reasoning_effort=step.reasoning_effort)
177
185
  if step is not None and step.brief_inputs is not None:
178
186
  agent = load_brief_agent(
179
- agent_name, step.brief_inputs.sections, self.llm)
187
+ agent_name, step.brief_inputs.sections, llm)
180
188
  else:
181
- agent = load_agent(self.agents_dir, agent_name, self.llm)
189
+ agent = load_agent(self.agents_dir, agent_name, llm)
182
190
  callbacks = []
183
191
  if finalize_controller is not None:
184
192
  callbacks.append(finalize_controller.on_event)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a45
3
+ Version: 1.0.0a48
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
@@ -199,6 +199,7 @@ llm:
199
199
  model: openai/deepseek-v4-pro
200
200
  base_url: https://api.deepseek.com
201
201
  api_key_env: OPENAI_API_KEY
202
+ # reasoning_effort: medium # optional: low | medium | high | xhigh | none
202
203
 
203
204
  max_attempts: 10 # CLI --max-attempts overrides
204
205
  agents_dir: sdk_agents # your <name>.agent.md files
@@ -228,6 +229,7 @@ steps:
228
229
  agent: review
229
230
  artifact: review-findings.md
230
231
  verdicts: { pass: APPROVED, fail: NEEDS CHANGES }
232
+ # reasoning_effort: xhigh # per-step override; falls back to the llm block, then SDK default
231
233
  on_pass: qa
232
234
  on_fail: { goto: dev, retry: true }
233
235
 
@@ -253,6 +255,7 @@ steps:
253
255
  | `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
256
  | `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
257
  | `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. |
258
+ | `reasoning_effort:` | Optional `low` / `medium` / `high` / `xhigh` / `none`. Set on the `llm` block (pipeline-global) or per-step (overrides the block). Unset anywhere = the field is not passed and the SDK default (`high`) applies, so today's behavior is unchanged. Forwarded only for models the SDK/LiteLLM mark as supporting it; an unsupported value fails at the first LLM call. See [Reasoning effort](#reasoning-effort-opt-in). |
256
259
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
257
260
  | `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). |
258
261
  | `setup_probe:` | Optional consumer-authored in-container probe run on a warm `setup:` stamp instead of the venv probe. Exit 0 trusts the stamp (skips); any nonzero re-provisions with network on (fail toward freshness). The probe text is NOT part of the stamp key, so a probe-only change needs `--refresh` or `--clean-stamps` to take effect. Example (asserting a warmed esbuild binary is Linux-shaped): `file frontend-editor/node_modules/esbuild/bin/esbuild | grep -q ELF`. |
@@ -266,6 +269,30 @@ steps:
266
269
  | `caches:` / `sync_excludes:` | Top-level run-wide lists. `caches:` names repo-relative toolchain cache dirs bind-mounted rw from the real workspace (auto-created if missing); a `setup:` hook can warm them (the provision container carries the same cache mounts); `sync_excludes:` names scratch paths skipped from both the export copy and sync-back (wildcard `*` spans separators, so `**/bin/**` matches a `/bin/` segment at any depth). Both default to `[]`. See [docs/setup.md](docs/setup.md). |
267
270
  | `merge_on_conflict:` | Top-level run-wide list of append-style logs (same path vocabulary as `sync_excludes:`, default `[]`). A concurrent host edit to a listed path is union-merged instead of raising: the host file wins and the sandbox's export-only lines are appended in order; both sides converge and the run continues. See [docs/setup.md](docs/setup.md). |
268
271
 
272
+ ### Reasoning effort (opt-in)
273
+
274
+ `reasoning_effort` is an opt-in latency dial on the pipeline `llm` block and
275
+ on individual agent steps. It takes one of `low`, `medium`, `high`, `xhigh`,
276
+ or `none`; unset means the field is never passed and the SDK default
277
+ (`high`) applies, so a pipeline that omits it behaves exactly as before. A
278
+ step's value overrides the `llm` block's value, so the review lane can hold
279
+ a higher effort than the rest.
280
+
281
+ The value is forwarded only for models the SDK/LiteLLM mark as supporting
282
+ it; an unsupported value fails at the first LLM call (the schema rejects
283
+ unknown names at load time, but cannot know provider capability). No
284
+ pipeline ships a value: adopt one only after an observed run proves lower
285
+ per-turn latency with a green suite and no provider 400s.
286
+
287
+ Recommended lane defaults (guidance only, not enforced):
288
+
289
+ | Lane | reasoning_effort | Why |
290
+ | --- | --- | --- |
291
+ | brief | medium | digest and classify, no design work |
292
+ | dev | high | hold the line: dev caught a real plan bug and a real sandbox triage this unit |
293
+ | qa | medium | classification discipline must stay reliable |
294
+ | review | xhigh | shortest lane, highest stakes: a missed detection sails past the last gate into commit |
295
+
269
296
  A `brief_inputs:` step is the per-run context-digestion pattern: it runs once at
270
297
  run start (or when its inputs change), its `produces:` artifact survives attempt
271
298
  starts, and the dev/review agents read that digest instead of re-deriving repo
@@ -357,10 +384,11 @@ steps:
357
384
  structurally enforced.** A networked step runs on a per-run internal
358
385
  docker network with no route to the host. Its traffic is forced
359
386
  through a containerized egress proxy (injected via
360
- `HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy container's DNS name
361
- on the internal network); a request to an undeclared host is denied
362
- and logged as an enforcement event. The engine never adds an
363
- undeclared target. `egress: { class: browser }` is the explicit
387
+ `HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy's internal IP
388
+ on the internal network; each distinct egress policy gets its own
389
+ proxy with a distinct internal IP); a request to an undeclared host
390
+ is denied and logged as an enforcement event. The engine never adds
391
+ an undeclared target. `egress: { class: browser }` is the explicit
364
392
  broad-but-logged class (every connection logged, nothing filtered); command
365
393
  and agent steps default to a strict allowlist.
366
394
  - **Command, agent, and sandbox-action steps** may declare it. The host-side
@@ -180,6 +180,40 @@ def test_extra_body_llm_key_rejected(tmp_path):
180
180
  _load(tmp_path, data)
181
181
 
182
182
 
183
+ def test_llm_reasoning_effort_round_trips(tmp_path):
184
+ data = _base()
185
+ data["llm"]["reasoning_effort"] = "medium"
186
+ assert _load(tmp_path, data).llm.reasoning_effort == "medium"
187
+
188
+
189
+ def test_llm_reasoning_effort_defaults_none(tmp_path):
190
+ assert _load(tmp_path, _base()).llm.reasoning_effort is None
191
+
192
+
193
+ def test_llm_reasoning_effort_unknown_value_rejected(tmp_path):
194
+ data = _base()
195
+ data["llm"]["reasoning_effort"] = "maximum"
196
+ with pytest.raises(ValidationError):
197
+ _load(tmp_path, data)
198
+
199
+
200
+ def test_step_reasoning_effort_round_trips(tmp_path):
201
+ data = _base()
202
+ data["steps"][0]["reasoning_effort"] = "xhigh"
203
+ assert _load(tmp_path, data).steps[0].reasoning_effort == "xhigh"
204
+
205
+
206
+ def test_step_reasoning_effort_defaults_none(tmp_path):
207
+ assert _load(tmp_path, _base()).steps[0].reasoning_effort is None
208
+
209
+
210
+ def test_step_reasoning_effort_unknown_value_rejected(tmp_path):
211
+ data = _base()
212
+ data["steps"][0]["reasoning_effort"] = "banana"
213
+ with pytest.raises(ValidationError):
214
+ _load(tmp_path, data)
215
+
216
+
183
217
  def test_unknown_route_key_rejected(tmp_path):
184
218
  data = _base()
185
219
  data["steps"][0]["on_pass"] = {"goto": "success", "bogus": 1}