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.
- {agentic_dev_loop-1.0.0a28/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a29}/PKG-INFO +5 -1
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/README.md +4 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/pyproject.toml +1 -1
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/core/__init__.py +3 -1
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/core/config.py +40 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/agents.py +17 -3
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/isolation.py +11 -2
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/stages.py +120 -2
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29/src/agentic_dev_loop.egg-info}/PKG-INFO +5 -1
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop.egg-info/SOURCES.txt +1 -0
- agentic_dev_loop-1.0.0a29/tests/test_032_stop_when_green.py +363 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_sdk_compat.py +27 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/LICENSE +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/setup.cfg +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/core/paths.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/core/results.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/core/verdict.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/actions.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/cli.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/deps.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/dryrun.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/graph.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/llm.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/loaders.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/mounts.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/outputs.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/presets.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/proc.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/registry.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/secrets.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/sync_back.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/viz/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/viz/__main__.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/viz/server.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_028_declared_caches.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_029_merge_on_conflict.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_actions.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_agents.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_cli.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_config.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_deps.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_dry_run.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_e2e_fixtures.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_egress_proxy.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_graph.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_hostboundary_integration.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_isolation.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_isolation_integration.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_isolation_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_llm.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_module_contract.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_proc.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_publish.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_qa_prompt.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_registry.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_repo_ascii.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_results.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_secrets.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_secrets_integration.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_secrets_pipeline.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_stages.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_sync_back.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_trigger_script.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_unit5_config.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_unit5_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_unit5_pure.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_verdict.py +0 -0
- {agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_viz.py +0 -0
{agentic_dev_loop-1.0.0a28/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a29}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentic-dev-loop
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.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.
|
|
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"
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/core/__init__.py
RENAMED
|
@@ -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"
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/agents.py
RENAMED
|
@@ -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=
|
|
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)
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/isolation.py
RENAMED
|
@@ -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
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/stages.py
RENAMED
|
@@ -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
|
-
|
|
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
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29/src/agentic_dev_loop.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentic-dev-loop
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.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). |
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/core/results.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/core/verdict.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/__init__.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/actions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/dryrun.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/egress_policy.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/egress_proxy.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/graph.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/hostboundary.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/loaders.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/mounts.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/outputs.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/presets.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/registry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/secrets.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/engine/sync_back.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/viz/__init__.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop/viz/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop.egg-info/requires.txt
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/src/agentic_dev_loop.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a28 → agentic_dev_loop-1.0.0a29}/tests/test_hostboundary_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|