agentic-dev-loop 1.0.0a28__tar.gz → 1.0.0a29__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 (90) hide show
  1. {agentic_dev_loop-1.0.0a28/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a29}/PKG-INFO +5 -1
  2. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/README.md +4 -0
  3. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/core/__init__.py +3 -1
  5. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/core/config.py +40 -0
  6. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/agents.py +17 -3
  7. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/isolation.py +11 -2
  8. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/stages.py +120 -2
  9. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29/src/agentic_dev_loop.egg-info}/PKG-INFO +5 -1
  10. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop.egg-info/SOURCES.txt +1 -0
  11. agentic_dev_loop-1.0.0a29/tests/test_032_stop_when_green.py +363 -0
  12. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_sdk_compat.py +27 -0
  13. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/LICENSE +0 -0
  14. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/setup.cfg +0 -0
  15. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/__init__.py +0 -0
  16. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/core/paths.py +0 -0
  17. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/core/results.py +0 -0
  18. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/core/verdict.py +0 -0
  19. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/__init__.py +0 -0
  20. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/actions.py +0 -0
  21. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/cli.py +0 -0
  22. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/deps.py +0 -0
  23. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  24. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  25. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  26. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  27. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  28. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/graph.py +0 -0
  29. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  30. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/llm.py +0 -0
  31. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/loaders.py +0 -0
  32. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/mounts.py +0 -0
  33. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/outputs.py +0 -0
  34. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/presets.py +0 -0
  35. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/proc.py +0 -0
  36. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/registry.py +0 -0
  37. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  38. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  39. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/secrets.py +0 -0
  40. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  41. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  42. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/py.typed +0 -0
  43. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/viz/__init__.py +0 -0
  44. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/viz/__main__.py +0 -0
  45. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/viz/server.py +0 -0
  46. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  47. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  48. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  49. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  50. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_028_declared_caches.py +0 -0
  51. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_029_merge_on_conflict.py +0 -0
  52. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_actions.py +0 -0
  53. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_agents.py +0 -0
  54. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_aidlc.py +0 -0
  55. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_cli.py +0 -0
  56. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_config.py +0 -0
  57. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_deps.py +0 -0
  58. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_dry_run.py +0 -0
  59. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_e2e_fixtures.py +0 -0
  60. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_egress_proxy.py +0 -0
  61. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_graph.py +0 -0
  62. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_hostboundary.py +0 -0
  63. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_hostboundary_integration.py +0 -0
  64. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_isolation.py +0 -0
  65. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_isolation_integration.py +0 -0
  66. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_isolation_pbt.py +0 -0
  67. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_llm.py +0 -0
  68. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_loaders.py +0 -0
  69. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_module_contract.py +0 -0
  70. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_outputs.py +0 -0
  71. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_outputs_pbt.py +0 -0
  72. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_proc.py +0 -0
  73. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_publish.py +0 -0
  74. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_qa_prompt.py +0 -0
  75. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_registry.py +0 -0
  76. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_repo_ascii.py +0 -0
  77. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_results.py +0 -0
  78. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_secrets.py +0 -0
  79. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_secrets_integration.py +0 -0
  80. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_secrets_pipeline.py +0 -0
  81. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_source_resolution.py +0 -0
  82. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_stages.py +0 -0
  83. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_step_workspace.py +0 -0
  84. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_sync_back.py +0 -0
  85. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_trigger_script.py +0 -0
  86. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_unit5_config.py +0 -0
  87. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_unit5_pbt.py +0 -0
  88. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_unit5_pure.py +0 -0
  89. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_verdict.py +0 -0
  90. {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/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.0a28
3
+ Version: 1.0.0a29
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
@@ -195,6 +195,8 @@ steps:
195
195
  - name: dev
196
196
  agent: dev
197
197
  produces: implementation-summary.md
198
+ terminal_timeout: 900
199
+ stop_when: { green_command: ".venv-sandbox/bin/python -m pytest tests/ -q", post_green_turns: 2 }
198
200
  on_pass: test
199
201
 
200
202
  - name: test
@@ -227,6 +229,8 @@ steps:
227
229
  | --- | --- |
228
230
  | `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)). |
229
231
  | `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
232
+ | `stop_when:` | Optional agent-step termination: `{ green_command: <argv regex>, post_green_turns: <int, default 2> }`. When the agent's most recent terminal observation matching `green_command` exits 0 and no workspace writes have happened since, the engine injects one nudge turn, allows `post_green_turns` further turns, then closes the conversation. Absent = run to finish. |
233
+ | `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. |
230
234
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
231
235
  | `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). |
232
236
  | `mounts:` / `egress:` | Per-step workspace isolation. `mounts:` lists `{path, mode}` entries (`rw`/`ro`, default `ro`) and is **strict-by-default** - empty/missing mounts nothing; only declared-rw paths sync back to the repo. `egress:` is `{class, presets, targets}` (`class: browser` = broad-but-logged; otherwise a strict allowlist). See [Per-stage networking](#per-stage-networking) and [docs/setup.md](docs/setup.md). |
@@ -153,6 +153,8 @@ steps:
153
153
  - name: dev
154
154
  agent: dev
155
155
  produces: implementation-summary.md
156
+ terminal_timeout: 900
157
+ stop_when: { green_command: ".venv-sandbox/bin/python -m pytest tests/ -q", post_green_turns: 2 }
156
158
  on_pass: test
157
159
 
158
160
  - name: test
@@ -185,6 +187,8 @@ steps:
185
187
  | --- | --- |
186
188
  | `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)). |
187
189
  | `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
190
+ | `stop_when:` | Optional agent-step termination: `{ green_command: <argv regex>, post_green_turns: <int, default 2> }`. When the agent's most recent terminal observation matching `green_command` exits 0 and no workspace writes have happened since, the engine injects one nudge turn, allows `post_green_turns` further turns, then closes the conversation. Absent = run to finish. |
191
+ | `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. |
188
192
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
189
193
  | `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). |
190
194
  | `mounts:` / `egress:` | Per-step workspace isolation. `mounts:` lists `{path, mode}` entries (`rw`/`ro`, default `ro`) and is **strict-by-default** - empty/missing mounts nothing; only declared-rw paths sync back to the repo. `egress:` is `{class, presets, targets}` (`class: browser` = broad-but-logged; otherwise a strict allowlist). See [Per-stage networking](#per-stage-networking) and [docs/setup.md](docs/setup.md). |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a28"
7
+ version = "1.0.0a29"
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,8 @@ test fails if this package imports engine/viz or heavy deps.
6
6
 
7
7
  from .config import (PipelineConfig, StepConfig, LlmConfig, IsolationConfig,
8
8
  VerdictSpec, Route, route_target, load_config,
9
- default_state_dir, TERMINALS, MountSpec, EgressSpec)
9
+ default_state_dir, TERMINALS, MountSpec, EgressSpec,
10
+ StopWhenSpec)
10
11
  from .results import Result
11
12
  from .verdict import Verdict, VerdictError, parse_verdict, RETRY
12
13
 
@@ -14,5 +15,6 @@ __all__ = [
14
15
  "PipelineConfig", "StepConfig", "LlmConfig", "IsolationConfig",
15
16
  "VerdictSpec", "Route", "route_target", "load_config",
16
17
  "default_state_dir", "TERMINALS", "MountSpec", "EgressSpec",
18
+ "StopWhenSpec",
17
19
  "Result", "Verdict", "VerdictError", "parse_verdict", "RETRY",
18
20
  ]
@@ -305,6 +305,36 @@ class EgressSpec(BaseModel):
305
305
  return [_validate_egress_target(t) for t in v]
306
306
 
307
307
 
308
+ class StopWhenSpec(BaseModel):
309
+ """Optional ``stop_when:`` termination signal on an agent step (032).
310
+
311
+ ``green_command`` is a regex matched against the agent's terminal
312
+ observation command; a matching observation that exits 0 - and no
313
+ workspace writes since - triggers one nudge turn, then
314
+ ``post_green_turns`` further turns before the engine closes the
315
+ conversation. Absent ``stop_when`` keeps the current run-to-finish
316
+ behaviour unchanged.
317
+ """
318
+
319
+ model_config = ConfigDict(extra="forbid")
320
+
321
+ green_command: str
322
+ post_green_turns: int = Field(default=2, ge=0)
323
+
324
+ @field_validator("green_command")
325
+ @classmethod
326
+ def _valid_regex(cls, v: str) -> str:
327
+ if not isinstance(v, str) or not v.strip():
328
+ raise ValueError(
329
+ "stop_when.green_command must be a non-empty string")
330
+ try:
331
+ re.compile(v)
332
+ except re.error as e:
333
+ raise ValueError(
334
+ f"stop_when.green_command is not a valid regex: {e}") from e
335
+ return v
336
+
337
+
308
338
  class StepConfig(BaseModel):
309
339
  """One pipeline step. Its type IS the key it carries: exactly one of
310
340
  `agent`, `action`, or `command` - empty values do not count, anything
@@ -322,6 +352,8 @@ class StepConfig(BaseModel):
322
352
  verdicts: VerdictSpec | None = None
323
353
  max_iterations: int | None = None
324
354
  timeout: float | None = Field(default=None, gt=0) # seconds, command steps only
355
+ terminal_timeout: float | None = Field(default=None, gt=0) # seconds, agent steps only (032)
356
+ stop_when: StopWhenSpec | None = None # agent steps only (032)
325
357
  env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
326
358
  setup: str | None = None # BR-13: command steps only
327
359
  network: bool = False # per-stage egress (command/agent + sandbox actions; host-side actions reject at registry time)
@@ -386,6 +418,14 @@ class StepConfig(BaseModel):
386
418
  raise ValueError(
387
419
  f"step {self.name!r}: 'timeout' requires 'command'"
388
420
  )
421
+ if self.terminal_timeout is not None and self.agent is None:
422
+ raise ValueError(
423
+ f"step {self.name!r}: 'terminal_timeout' requires 'agent'"
424
+ )
425
+ if self.stop_when is not None and self.agent is None:
426
+ raise ValueError(
427
+ f"step {self.name!r}: 'stop_when' requires 'agent'"
428
+ )
389
429
  if self.setup is not None and self.command is None and self.agent is None:
390
430
  raise ValueError(
391
431
  f"step {self.name!r}: 'setup' is only allowed on 'command' or 'agent' steps"
@@ -66,7 +66,9 @@ class SandboxTerminalExecutor(ToolExecutor):
66
66
  def __init__(self, sandbox: "Sandbox", *, workspace: str = "",
67
67
  env: dict[str, str] | None = None,
68
68
  pipeline: str | None = None, step: str | None = None,
69
- network: bool = False, mount_set=None, egress_policy=None):
69
+ network: bool = False, mount_set=None, egress_policy=None,
70
+ terminal_timeout: float | None = None,
71
+ on_terminal_result=None):
70
72
  self._sandbox = sandbox
71
73
  self._workspace = workspace
72
74
  self._env = env or {}
@@ -75,6 +77,8 @@ class SandboxTerminalExecutor(ToolExecutor):
75
77
  self._network = network
76
78
  self._mount_set = mount_set
77
79
  self._egress_policy = egress_policy
80
+ self._terminal_timeout = terminal_timeout
81
+ self._on_terminal_result = on_terminal_result
78
82
 
79
83
  def __call__(self, action, conversation=None):
80
84
  from openhands.tools.terminal import TerminalObservation
@@ -92,9 +96,12 @@ class SandboxTerminalExecutor(ToolExecutor):
92
96
  command=action.command, exit_code=None, timeout=False,
93
97
  metadata=CmdOutputMetadata(exit_code=-1))
94
98
 
99
+ timeout = action.timeout
100
+ if timeout is None and self._terminal_timeout is not None:
101
+ timeout = self._terminal_timeout
95
102
  request = SandboxExecutionRequest(
96
103
  argv=command_argv(action.command), cwd=self._workspace,
97
- env=self._env, timeout=action.timeout,
104
+ env=self._env, timeout=timeout,
98
105
  pipeline=self._pipeline, step=self._step, network=self._network,
99
106
  mount_set=self._mount_set, egress_policy=self._egress_policy)
100
107
  try:
@@ -112,6 +119,9 @@ class SandboxTerminalExecutor(ToolExecutor):
112
119
  ConversationExecutionStatus.STUCK
113
120
  )
114
121
  raise
122
+ if self._on_terminal_result is not None:
123
+ self._on_terminal_result(
124
+ action.command, result.exit_code, result.wrote_workspace)
115
125
  output = result.stdout + result.stderr
116
126
  return TerminalObservation.from_text(
117
127
  output, is_error=not result.succeeded and not result.is_timeout,
@@ -130,6 +140,8 @@ def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None]
130
140
  step: str | None = None,
131
141
  network: bool = False,
132
142
  mount_set=None, egress_policy=None,
143
+ terminal_timeout: float | None = None,
144
+ on_terminal_result=None,
133
145
  secrets: dict[str, str] | None = None) -> None:
134
146
  """Per-run re-registration of the `terminal` name (KD-4): a callable
135
147
  factory that builds the SDK terminal with a `SandboxTerminalExecutor`.
@@ -147,6 +159,8 @@ def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None]
147
159
  executor=SandboxTerminalExecutor(
148
160
  sandbox, workspace=workspace, env=resolved_env,
149
161
  pipeline=pipeline, step=step, network=network,
150
- mount_set=mount_set, egress_policy=egress_policy))
162
+ mount_set=mount_set, egress_policy=egress_policy,
163
+ terminal_timeout=terminal_timeout,
164
+ on_terminal_result=on_terminal_result))
151
165
 
152
166
  register_tool("terminal", factory)
@@ -144,6 +144,7 @@ class SandboxExecutionResult:
144
144
  duration_seconds: float
145
145
  backend: str # "docker"
146
146
  timed_out: bool = False
147
+ wrote_workspace: bool = False # declared-rw paths changed (032)
147
148
 
148
149
  @property
149
150
  def succeeded(self) -> bool:
@@ -1068,8 +1069,15 @@ class DockerBackend(Sandbox):
1068
1069
  "failing closed (BR-11.3): no networked execution runs "
1069
1070
  "without egress enforcement")
1070
1071
  result = self._exec(request, prof.name)
1072
+ report: SyncBackReport | None = None
1071
1073
  if mount_set.rw_paths() and self._export_root is not None:
1072
- self._sync_back(mount_set)
1074
+ report = self._sync_back(mount_set)
1075
+ if report is not None and (report.synced_paths or report.merged_paths):
1076
+ result = SandboxExecutionResult(
1077
+ exit_code=result.exit_code, stdout=result.stdout,
1078
+ stderr=result.stderr, duration_seconds=result.duration_seconds,
1079
+ backend=result.backend, timed_out=result.timed_out,
1080
+ wrote_workspace=True)
1073
1081
  return result
1074
1082
 
1075
1083
  def _exec(self, request: SandboxExecutionRequest,
@@ -1161,7 +1169,7 @@ class DockerBackend(Sandbox):
1161
1169
  export_path.parent.mkdir(parents=True, exist_ok=True)
1162
1170
  shutil.copy2(host_path, export_path, follow_symlinks=False)
1163
1171
 
1164
- def _sync_back(self, mount_set: MountSet) -> None:
1172
+ def _sync_back(self, mount_set: MountSet) -> SyncBackReport | None:
1165
1173
  """Sync declared-rw export changes back to the real repo (BR-4/F1),
1166
1174
  git-aware, host-side, with the sandbox step already complete.
1167
1175
 
@@ -1227,6 +1235,7 @@ class DockerBackend(Sandbox):
1227
1235
  "synced_paths": list(report.synced_paths),
1228
1236
  "merged_paths": list(report.merged_paths),
1229
1237
  })
1238
+ return report
1230
1239
 
1231
1240
  def close(self) -> None:
1232
1241
  """Best-effort stop + rm; idempotent (BR-8.3). With ``--rm`` the
@@ -20,6 +20,7 @@ pipeline config; the fixed policies stay here, not in config:
20
20
  from __future__ import annotations
21
21
 
22
22
  import os
23
+ import re
23
24
  import subprocess
24
25
  import sys
25
26
  import traceback
@@ -54,6 +55,74 @@ LAST_ERROR = "last-error.md"
54
55
  ARTIFACT_CONTEXT_CAP = 24_000 # chars per artifact injected into a step's context
55
56
 
56
57
 
58
+ class _StopWhenController:
59
+ """Turn-level stop-when-green observation (032).
60
+
61
+ Pure per-event state machine - no knowledge of how the conversation is
62
+ driven. ``on_event`` is a SDK conversation callback: it records the most
63
+ recent matching terminal observation and any subsequent file-editor
64
+ write, and pauses the conversation after every agent-sourced event so the
65
+ caller can drive one turn at a time.
66
+ """
67
+
68
+ NUDGE = "Step complete: produce your final response now"
69
+ _WRITE_COMMANDS = frozenset({"create", "str_replace", "insert", "undo_edit"})
70
+
71
+ def __init__(self, spec) -> None:
72
+ self._pattern = re.compile(spec.green_command)
73
+ self.post_green_turns = spec.post_green_turns
74
+ self.green_hit = False
75
+ self.writes_since_green = False
76
+ self.nudged = False
77
+ self.post_nudge_turns = 0
78
+ self.conversation = None
79
+
80
+ def attach(self, conversation) -> None:
81
+ self.conversation = conversation
82
+
83
+ @property
84
+ def green_signal(self) -> bool:
85
+ return self.green_hit and not self.writes_since_green
86
+
87
+ def on_event(self, event) -> None:
88
+ if self.conversation is None:
89
+ return
90
+ from openhands.sdk.event import ObservationEvent
91
+
92
+ if isinstance(event, ObservationEvent):
93
+ self._record_observation(event)
94
+ if getattr(event, "source", None) == "agent":
95
+ from openhands.sdk.conversation.state import (
96
+ ConversationExecutionStatus,
97
+ )
98
+ self.conversation.state.execution_status = (
99
+ ConversationExecutionStatus.PAUSED
100
+ )
101
+
102
+ def on_terminal_result(self, command: str | None, exit_code: int | None,
103
+ wrote_workspace: bool) -> None:
104
+ """Feed the sandbox terminal result channel (032): a matching green
105
+ command is the signal; any other terminal write resets it."""
106
+ if command is not None and self._pattern.search(command):
107
+ self.green_hit = exit_code == 0
108
+ self.writes_since_green = False
109
+ elif wrote_workspace:
110
+ self.writes_since_green = True
111
+
112
+ def _record_observation(self, event) -> None:
113
+ observation = event.observation
114
+ tool_name = event.tool_name
115
+ if tool_name == "terminal":
116
+ command = getattr(observation, "command", None)
117
+ if command is not None and self._pattern.search(command):
118
+ self.green_hit = getattr(observation, "exit_code", None) == 0
119
+ self.writes_since_green = False
120
+ elif tool_name == "file_editor":
121
+ command = getattr(observation, "command", None)
122
+ if command in self._WRITE_COMMANDS and not observation.is_error:
123
+ self.writes_since_green = True
124
+
125
+
57
126
  @dataclass
58
127
  class Runtime:
59
128
  """Everything stage nodes need that isn't in LoopState: pipeline config,
@@ -85,6 +154,10 @@ class Runtime:
85
154
  try:
86
155
  from openhands.sdk.conversation.state import ConversationExecutionStatus
87
156
 
157
+ controller = None
158
+ if step is not None and step.stop_when is not None:
159
+ controller = _StopWhenController(step.stop_when)
160
+
88
161
  if self.sandbox is not None:
89
162
  if step is not None:
90
163
  cwd, mount_set, egress_policy = _step_sandbox(self, step)
@@ -99,13 +172,24 @@ class Runtime:
99
172
  network=step.network if step is not None else False,
100
173
  mount_set=mount_set,
101
174
  egress_policy=egress_policy,
175
+ terminal_timeout=(step.terminal_timeout
176
+ if step is not None else None),
177
+ on_terminal_result=(controller.on_terminal_result
178
+ if controller is not None else None),
102
179
  secrets=(step_secret_env(self.cfg, step, self.secrets)
103
180
  if step is not None else None))
104
181
  agent = load_agent(self.agents_dir, agent_name, self.llm)
182
+ callbacks = [controller.on_event] if controller is not None else None
105
183
  conv = Conversation(agent=agent, workspace=str(self.workspace),
106
- max_iteration_per_run=max_iterations)
184
+ max_iteration_per_run=max_iterations,
185
+ callbacks=callbacks)
186
+ if controller is not None:
187
+ controller.attach(conv)
107
188
  conv.send_message(message)
108
- conv.run()
189
+ if controller is not None:
190
+ self._run_stop_when_loop(conv, controller, max_iterations)
191
+ else:
192
+ conv.run()
109
193
 
110
194
  # 030, D1/D2: a sticky infra failure must fail the run with no
111
195
  # retry - check before any capture/verdict path would schedule an
@@ -144,6 +228,40 @@ class Runtime:
144
228
  if conv is not None:
145
229
  conv.close()
146
230
 
231
+ def _run_stop_when_loop(self, conv, controller: _StopWhenController,
232
+ max_iterations: int) -> None:
233
+ """Drive one turn at a time so a green signal can inject the nudge
234
+ and enforce the post-nudge turn budget. The controller's callback
235
+ pauses the SDK run loop after every agent-sourced event, so each
236
+ ``conv.run()`` here advances exactly one agent turn."""
237
+ from openhands.sdk.conversation.state import ConversationExecutionStatus
238
+
239
+ turns = 0
240
+ while True:
241
+ conv.run()
242
+ status = conv.state.execution_status
243
+ if status in (ConversationExecutionStatus.FINISHED,
244
+ ConversationExecutionStatus.STUCK,
245
+ ConversationExecutionStatus.ERROR):
246
+ break
247
+
248
+ turns += 1
249
+ if turns >= max_iterations:
250
+ conv.state.execution_status = ConversationExecutionStatus.ERROR
251
+ break
252
+
253
+ if not controller.nudged and controller.green_signal:
254
+ conv.send_message(controller.NUDGE)
255
+ controller.nudged = True
256
+ if controller.post_green_turns == 0:
257
+ break
258
+ continue
259
+
260
+ if controller.nudged:
261
+ controller.post_nudge_turns += 1
262
+ if controller.post_nudge_turns >= controller.post_green_turns:
263
+ break
264
+
147
265
 
148
266
  def prompt_dir(runtime: Runtime) -> str:
149
267
  """state_dir as agents must address it. Agents resolve prompt paths
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a28
3
+ Version: 1.0.0a29
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
@@ -195,6 +195,8 @@ steps:
195
195
  - name: dev
196
196
  agent: dev
197
197
  produces: implementation-summary.md
198
+ terminal_timeout: 900
199
+ stop_when: { green_command: ".venv-sandbox/bin/python -m pytest tests/ -q", post_green_turns: 2 }
198
200
  on_pass: test
199
201
 
200
202
  - name: test
@@ -227,6 +229,8 @@ steps:
227
229
  | --- | --- |
228
230
  | `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)). |
229
231
  | `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
232
+ | `stop_when:` | Optional agent-step termination: `{ green_command: <argv regex>, post_green_turns: <int, default 2> }`. When the agent's most recent terminal observation matching `green_command` exits 0 and no workspace writes have happened since, the engine injects one nudge turn, allows `post_green_turns` further turns, then closes the conversation. Absent = run to finish. |
233
+ | `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. |
230
234
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
231
235
  | `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). |
232
236
  | `mounts:` / `egress:` | Per-step workspace isolation. `mounts:` lists `{path, mode}` entries (`rw`/`ro`, default `ro`) and is **strict-by-default** - empty/missing mounts nothing; only declared-rw paths sync back to the repo. `egress:` is `{class, presets, targets}` (`class: browser` = broad-but-logged; otherwise a strict allowlist). See [Per-stage networking](#per-stage-networking) and [docs/setup.md](docs/setup.md). |
@@ -45,6 +45,7 @@ src/agentic_dev_loop/viz/__main__.py
45
45
  src/agentic_dev_loop/viz/server.py
46
46
  tests/test_028_declared_caches.py
47
47
  tests/test_029_merge_on_conflict.py
48
+ tests/test_032_stop_when_green.py
48
49
  tests/test_actions.py
49
50
  tests/test_agents.py
50
51
  tests/test_aidlc.py
@@ -0,0 +1,363 @@
1
+ """Stop-when-green (032): config surface, the per-event signal state machine,
2
+ the one-turn run-loop budget, and the terminal timeout override."""
3
+
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+ import yaml
9
+ from pydantic import ValidationError
10
+
11
+ from openhands.sdk import LLM
12
+ from openhands.sdk.conversation.state import ConversationExecutionStatus
13
+ from openhands.sdk.event import ActionEvent, MessageEvent, ObservationEvent
14
+ from openhands.sdk.llm.message import Message, MessageToolCall, TextContent
15
+ from openhands.tools.file_editor import FileEditorObservation
16
+ from openhands.tools.terminal import TerminalAction, TerminalObservation
17
+ from openhands.tools.terminal.metadata import CmdOutputMetadata
18
+
19
+ from agentic_dev_loop.core.config import PipelineConfig, StopWhenSpec, load_config
20
+ from agentic_dev_loop.engine.agents import SandboxTerminalExecutor
21
+ from agentic_dev_loop.engine.registry import default_registry
22
+ from agentic_dev_loop.engine.stages import Runtime, _StopWhenController
23
+
24
+
25
+ def _base() -> dict:
26
+ return {
27
+ "name": "t",
28
+ "llm": {"model": "m", "api_key_env": "K"},
29
+ "agents_dir": "agents",
30
+ "lockfiles": ["uv.lock"],
31
+ "steps": [{"name": "dev", "agent": "dev", "on_pass": "success"}],
32
+ }
33
+
34
+
35
+ def _load(tmp_path, data):
36
+ p = tmp_path / "pipeline.yaml"
37
+ p.write_text(yaml.safe_dump(data))
38
+ return load_config(p)
39
+
40
+
41
+ def _terminal_obs(command, exit_code):
42
+ return TerminalObservation.from_text(
43
+ "output", command=command, exit_code=exit_code,
44
+ metadata=CmdOutputMetadata(exit_code=exit_code))
45
+
46
+
47
+ def _terminal_event(command, exit_code, idx=0):
48
+ return ObservationEvent(
49
+ tool_name="terminal",
50
+ tool_call_id=f"tc-{idx}",
51
+ action_id=f"a-{idx}",
52
+ observation=_terminal_obs(command, exit_code))
53
+
54
+
55
+ def _file_write_event(command="create", idx=0, is_error=False):
56
+ obs = FileEditorObservation(
57
+ command=command, path="src/x.py", prev_exist=False,
58
+ old_content=None, new_content="x")
59
+ obs = obs.model_copy(update={"is_error": is_error})
60
+ return ObservationEvent(
61
+ tool_name="file_editor",
62
+ tool_call_id=f"fc-{idx}",
63
+ action_id=f"fa-{idx}",
64
+ observation=obs)
65
+
66
+
67
+ def _agent_event(idx=0):
68
+ return ActionEvent(
69
+ thought=[TextContent(type="text", text="working")],
70
+ tool_name="terminal",
71
+ tool_call_id=f"ag-{idx}",
72
+ tool_call=MessageToolCall(
73
+ id=f"ag-{idx}", name="terminal", arguments="{}",
74
+ origin="completion"),
75
+ llm_response_id=f"r-{idx}",
76
+ action=TerminalAction(command="echo hi"))
77
+
78
+
79
+ def _controller(green_command="pytest"):
80
+ return _StopWhenController(StopWhenSpec(green_command=green_command))
81
+
82
+
83
+ class TestStopWhenConfig:
84
+ def test_valid_agent_step_round_trip(self, tmp_path):
85
+ data = _base()
86
+ data["steps"] = [{"name": "dev", "agent": "dev", "on_pass": "success",
87
+ "stop_when": {"green_command": "pytest tests/"},
88
+ "terminal_timeout": 900}]
89
+ step = _load(tmp_path, data).steps[0]
90
+ assert step.stop_when == StopWhenSpec(green_command="pytest tests/")
91
+ assert step.stop_when.post_green_turns == 2
92
+ assert step.terminal_timeout == 900
93
+
94
+ def test_post_green_turns_custom(self, tmp_path):
95
+ data = _base()
96
+ data["steps"] = [{"name": "dev", "agent": "dev", "on_pass": "success",
97
+ "stop_when": {"green_command": "pytest",
98
+ "post_green_turns": 0}}]
99
+ assert _load(tmp_path, data).steps[0].stop_when.post_green_turns == 0
100
+
101
+ @pytest.mark.parametrize("bad", ["[", ""])
102
+ def test_invalid_regex_rejected(self, tmp_path, bad):
103
+ data = _base()
104
+ data["steps"] = [{"name": "dev", "agent": "dev",
105
+ "stop_when": {"green_command": bad}}]
106
+ with pytest.raises(ValidationError, match="green_command"):
107
+ _load(tmp_path, data)
108
+
109
+ def test_negative_post_green_turns_rejected(self, tmp_path):
110
+ data = _base()
111
+ data["steps"] = [{"name": "dev", "agent": "dev",
112
+ "stop_when": {"green_command": "pytest",
113
+ "post_green_turns": -1}}]
114
+ with pytest.raises(ValidationError):
115
+ _load(tmp_path, data)
116
+
117
+ @pytest.mark.parametrize("step", [
118
+ {"name": "test", "command": "true", "stop_when": {"green_command": "pytest"}},
119
+ {"name": "commit", "action": "commit", "stop_when": {"green_command": "pytest"}},
120
+ {"name": "test", "command": "true", "terminal_timeout": 900},
121
+ {"name": "commit", "action": "commit", "terminal_timeout": 900},
122
+ ])
123
+ def test_agent_only_fields_rejected_on_non_agent(self, tmp_path, step):
124
+ data = _base()
125
+ data["steps"] = [step]
126
+ with pytest.raises(ValidationError, match="requires 'agent'"):
127
+ _load(tmp_path, data)
128
+
129
+ def test_negative_terminal_timeout_rejected(self, tmp_path):
130
+ data = _base()
131
+ data["steps"] = [{"name": "dev", "agent": "dev",
132
+ "terminal_timeout": -1}]
133
+ with pytest.raises(ValidationError):
134
+ _load(tmp_path, data)
135
+
136
+
137
+ class TestStopWhenController:
138
+ def test_matching_green_terminal_sets_signal(self):
139
+ c = _controller()
140
+ c.attach(SimpleNamespace(state=SimpleNamespace(execution_status=None)))
141
+ c.on_event(_agent_event(0))
142
+ c.on_event(_terminal_event("pytest tests/ -q", 0))
143
+ assert c.green_signal is True
144
+
145
+ def test_matching_failure_clears_signal(self):
146
+ c = _controller()
147
+ c.attach(SimpleNamespace(state=SimpleNamespace(execution_status=None)))
148
+ c.on_event(_agent_event(0))
149
+ c.on_event(_terminal_event("pytest tests/ -q", 1))
150
+ assert c.green_signal is False
151
+
152
+ def test_file_write_after_green_resets_signal(self):
153
+ c = _controller()
154
+ c.attach(SimpleNamespace(state=SimpleNamespace(execution_status=None)))
155
+ c.on_event(_agent_event(0))
156
+ c.on_event(_terminal_event("pytest tests/ -q", 0))
157
+ c.on_event(_file_write_event("create"))
158
+ assert c.green_signal is False
159
+
160
+ def test_file_view_does_not_reset_signal(self):
161
+ c = _controller()
162
+ c.attach(SimpleNamespace(state=SimpleNamespace(execution_status=None)))
163
+ c.on_event(_agent_event(0))
164
+ c.on_event(_terminal_event("pytest tests/ -q", 0))
165
+ c.on_event(_file_write_event("view"))
166
+ assert c.green_signal is True
167
+
168
+ def test_later_nonmatching_terminal_keeps_signal(self):
169
+ c = _controller()
170
+ c.attach(SimpleNamespace(state=SimpleNamespace(execution_status=None)))
171
+ c.on_event(_agent_event(0))
172
+ c.on_event(_terminal_event("pytest tests/ -q", 0))
173
+ c.on_event(_terminal_event("git status", 0))
174
+ assert c.green_signal is True
175
+
176
+ def test_later_matching_failure_replaces_signal(self):
177
+ c = _controller()
178
+ c.attach(SimpleNamespace(state=SimpleNamespace(execution_status=None)))
179
+ c.on_event(_agent_event(0))
180
+ c.on_event(_terminal_event("pytest tests/ -q", 0))
181
+ c.on_event(_terminal_event("pytest tests/ -q", 2))
182
+ assert c.green_signal is False
183
+
184
+ def test_agent_event_pauses_conversation(self):
185
+ c = _controller()
186
+ conv = SimpleNamespace(state=SimpleNamespace(execution_status=None))
187
+ c.attach(conv)
188
+ c.on_event(_agent_event(0))
189
+ assert conv.state.execution_status is not None
190
+ assert conv.state.execution_status.name == "PAUSED"
191
+
192
+
193
+ class TestTerminalWriteChannel:
194
+ def test_terminal_result_green_then_write_resets(self):
195
+ c = _controller()
196
+ c.on_terminal_result("pytest tests/ -q", 0, False)
197
+ assert c.green_signal is True
198
+ c.on_terminal_result("git diff", 0, True)
199
+ assert c.green_signal is False
200
+
201
+ def test_terminal_result_matching_failure_clears(self):
202
+ c = _controller()
203
+ c.on_terminal_result("pytest tests/ -q", 0, False)
204
+ c.on_terminal_result("pytest tests/ -q", 1, False)
205
+ assert c.green_signal is False
206
+
207
+ def test_executor_reports_writes(self, fake_sandbox, tmp_path):
208
+ seen = []
209
+ ex = SandboxTerminalExecutor(
210
+ fake_sandbox, workspace=str(tmp_path), env={"PATH": "/bin"},
211
+ on_terminal_result=lambda command, exit_code, wrote: seen.append(
212
+ (command, exit_code, wrote)))
213
+ fake_sandbox.script(lambda req: (0, "ok", False, True))
214
+ ex(TerminalAction(command="echo hi"), None)
215
+ assert seen == [("echo hi", 0, True)]
216
+
217
+
218
+ class TestStopWhenRunLoop:
219
+ def _conv(self, controller):
220
+ class Fake:
221
+ def __init__(self):
222
+ self.state = SimpleNamespace(execution_status=None)
223
+ self.messages = []
224
+ self.run_calls = 0
225
+
226
+ def run(self):
227
+ self.run_calls += 1
228
+ # The controller callback normally pauses after the turn.
229
+ self.state.execution_status = SimpleNamespace(name="PAUSED")
230
+
231
+ def send_message(self, message):
232
+ self.messages.append(message)
233
+
234
+ return Fake()
235
+
236
+ def test_nudge_then_budget_close(self):
237
+ controller = _controller()
238
+ controller.green_hit = True
239
+ controller.writes_since_green = False
240
+ conv = self._conv(controller)
241
+ Runtime._run_stop_when_loop(None, conv, controller, max_iterations=10)
242
+ assert controller.nudged is True
243
+ assert conv.messages == [controller.NUDGE]
244
+ assert conv.run_calls == 1 + controller.post_green_turns
245
+ assert controller.post_nudge_turns == controller.post_green_turns
246
+
247
+ def test_post_green_turns_zero_closes_after_nudge(self):
248
+ controller = _StopWhenController(
249
+ StopWhenSpec(green_command="pytest", post_green_turns=0))
250
+ controller.green_hit = True
251
+ conv = self._conv(controller)
252
+ Runtime._run_stop_when_loop(None, conv, controller, max_iterations=10)
253
+ assert controller.nudged is True
254
+ assert conv.messages == [controller.NUDGE]
255
+ assert conv.run_calls == 1
256
+
257
+
258
+ class TestRunAgentStopWhen:
259
+ def _runtime(self, tmp_path):
260
+ cfg = PipelineConfig.model_validate({
261
+ "name": "t",
262
+ "llm": {"model": "m", "api_key_env": "K"},
263
+ "agents_dir": "agents",
264
+ "lockfiles": ["uv.lock"],
265
+ "steps": [{
266
+ "name": "dev",
267
+ "agent": "dev",
268
+ "produces": ["implementation-summary.md"],
269
+ "stop_when": {"green_command": "pytest", "post_green_turns": 2},
270
+ "on_pass": "success",
271
+ }],
272
+ })
273
+ state_dir = tmp_path / ".pr"
274
+ state_dir.mkdir()
275
+ agents_dir = tmp_path / "agents"
276
+ agents_dir.mkdir()
277
+ (agents_dir / "dev.agent.md").write_text(
278
+ "---\nname: dev\ntools: [terminal]\n---\nDo the work.")
279
+ return Runtime(
280
+ cfg=cfg, registry=default_registry(),
281
+ llm=LLM(model="openai/test-model", api_key="sk-test"),
282
+ agents_dir=agents_dir, state_dir=state_dir,
283
+ workspace=tmp_path, sandbox=None)
284
+
285
+ def _fake_conv(self, script):
286
+ class Fake:
287
+ def __init__(self, script):
288
+ self.state = SimpleNamespace(events=[], execution_status=None)
289
+ self.callbacks = []
290
+ self.messages = []
291
+ self.closed = False
292
+ self.script = list(script)
293
+ self.run_calls = 0
294
+
295
+ def send_message(self, message):
296
+ self.messages.append(message)
297
+
298
+ def run(self):
299
+ self.run_calls += 1
300
+ events, status = self.script.pop(0)
301
+ for event in events:
302
+ self.state.events.append(event)
303
+ for cb in self.callbacks:
304
+ cb(event)
305
+ self.state.execution_status = status
306
+
307
+ def close(self):
308
+ self.closed = True
309
+
310
+ return Fake(script)
311
+
312
+ def test_green_signal_nudges_closes_and_captures(self, tmp_path, monkeypatch):
313
+ runtime = self._runtime(tmp_path)
314
+ final = MessageEvent(
315
+ source="agent",
316
+ llm_message=Message(
317
+ role="assistant",
318
+ content=[TextContent(type="text", text="final summary")]))
319
+ script = [
320
+ ([_agent_event(0), _terminal_event("pytest tests/ -q", 0)],
321
+ ConversationExecutionStatus.PAUSED),
322
+ ([final], ConversationExecutionStatus.FINISHED),
323
+ ]
324
+ conv = self._fake_conv(script)
325
+
326
+ def factory(**kw):
327
+ conv.callbacks = kw.get("callbacks") or []
328
+ return conv
329
+
330
+ monkeypatch.setattr(
331
+ "agentic_dev_loop.engine.stages.Conversation", factory)
332
+
333
+ result = runtime.run_agent("dev", "the task", max_iterations=10,
334
+ step=runtime.cfg.steps[0])
335
+
336
+ assert result.ok is True
337
+ assert result.response == "final summary"
338
+ assert result.truncated is False
339
+ assert conv.messages == ["the task", _StopWhenController.NUDGE]
340
+ assert conv.run_calls == 2
341
+ assert conv.closed is True
342
+
343
+
344
+ class TestTerminalTimeoutOverride:
345
+ def test_defaults_to_terminal_timeout(self, fake_sandbox, tmp_path):
346
+ ex = SandboxTerminalExecutor(
347
+ fake_sandbox, workspace=str(tmp_path), env={"PATH": "/bin"},
348
+ terminal_timeout=900)
349
+ ex(TerminalAction(command="echo hi"), None)
350
+ assert fake_sandbox.requests[0].timeout == 900
351
+
352
+ def test_action_timeout_wins(self, fake_sandbox, tmp_path):
353
+ ex = SandboxTerminalExecutor(
354
+ fake_sandbox, workspace=str(tmp_path), env={"PATH": "/bin"},
355
+ terminal_timeout=900)
356
+ ex(TerminalAction(command="echo hi", timeout=30), None)
357
+ assert fake_sandbox.requests[0].timeout == 30
358
+
359
+ def test_unset_keeps_none(self, fake_sandbox, tmp_path):
360
+ ex = SandboxTerminalExecutor(
361
+ fake_sandbox, workspace=str(tmp_path), env={"PATH": "/bin"})
362
+ ex(TerminalAction(command="echo hi"), None)
363
+ assert fake_sandbox.requests[0].timeout is None
@@ -90,6 +90,33 @@ def test_canary_sdk_terminal_seam_still_present():
90
90
  "update COMPAT-SHIMS.md.")
91
91
 
92
92
 
93
+ def test_canary_sdk_turn_level_pause_seam_still_present():
94
+ """Canary for the 032 turn-level stop-when-green seam (COMPAT-SHIMS.md):
95
+ passes while the SDK still accepts ``callbacks`` on LocalConversation,
96
+ passes agent-sourced events to them via ``agent.step(on_event=...)``, and
97
+ breaks its run loop on PAUSED; fails once any of those disappear - the
98
+ _StopWhenController pause loop is then dead and must be replaced with the
99
+ SDK's supported turn-level pause mechanism."""
100
+ import inspect
101
+
102
+ from openhands.sdk.conversation.impl.local_conversation import LocalConversation
103
+
104
+ assert "callbacks" in inspect.signature(LocalConversation.__init__).parameters, (
105
+ "The SDK's LocalConversation no longer accepts `callbacks=` - the 032 "
106
+ "turn-level stop-when-green seam in src/agentic_dev_loop/engine/stages.py "
107
+ "is dead. Adopt the SDK's supported pause mechanism and update "
108
+ "COMPAT-SHIMS.md.")
109
+ run_src = inspect.getsource(LocalConversation.run)
110
+ assert "on_event=self._on_event" in run_src, (
111
+ "The SDK's LocalConversation.run no longer passes its composed callback "
112
+ "to agent.step - the 032 callback seam no longer receives agent-sourced "
113
+ "events. Update COMPAT-SHIMS.md.")
114
+ assert "ConversationExecutionStatus.PAUSED" in run_src, (
115
+ "The SDK's LocalConversation.run no longer breaks on PAUSED - the 032 "
116
+ "one-turn run loop in src/agentic_dev_loop/engine/stages.py is dead. "
117
+ "Adopt the SDK's supported pause mechanism and update COMPAT-SHIMS.md.")
118
+
119
+
93
120
  def test_canary_sdk_run_loop_still_breaks_on_stuck():
94
121
  """Canary for the D3 fast-stop reliance (COMPAT-SHIMS.md): passes while
95
122
  the SDK's LocalConversation.run breaks on STUCK; fails once the run loop