snodo-engine 0.7.2__py3-none-any.whl
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.
- snodo/coders/__init__.py +108 -0
- snodo/coders/agy_adapter.py +38 -0
- snodo/coders/anthropic_adapter.py +22 -0
- snodo/coders/base.py +365 -0
- snodo/coders/gemini_adapter.py +28 -0
- snodo/coders/litellm.py +1439 -0
- snodo/coders/mock.py +203 -0
- snodo/coders/openai_adapter.py +16 -0
- snodo/coders/opencode_adapter.py +327 -0
- snodo/coders/opencode_cli_adapter.py +34 -0
- snodo/coders/opencode_container.py +267 -0
- snodo/coders/subprocess_adapter.py +219 -0
- snodo/engine/__init__.py +0 -0
- snodo/engine/closure.py +295 -0
- snodo/engine/constraints.py +120 -0
- snodo/engine/loop.py +1002 -0
- snodo/engine/nodes/__init__.py +1 -0
- snodo/engine/nodes/context.py +137 -0
- snodo/engine/nodes/executor.py +168 -0
- snodo/engine/nodes/governance.py +289 -0
- snodo/engine/nodes/state.py +160 -0
- snodo/engine/nodes/validation.py +563 -0
- snodo/engine/nodes/writeback.py +597 -0
- snodo/engine/policy.py +411 -0
- snodo/engine/progress.py +71 -0
- snodo/engine/state.py +105 -0
- snodo/engine/validators.py +90 -0
- snodo/validators/__init__.py +6 -0
- snodo/validators/acceptance.py +164 -0
- snodo/validators/context.py +65 -0
- snodo/validators/llm_validator.py +1044 -0
- snodo/validators/protocol_adherence.py +331 -0
- snodo/validators/quality.py +376 -0
- snodo/validators/registry.py +52 -0
- snodo/validators/runner.py +429 -0
- snodo_engine-0.7.2.dist-info/METADATA +19 -0
- snodo_engine-0.7.2.dist-info/RECORD +39 -0
- snodo_engine-0.7.2.dist-info/WHEEL +5 -0
- snodo_engine-0.7.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""OpenCode Docker container lifecycle manager.
|
|
2
|
+
|
|
3
|
+
FILE: snodo/coders/opencode_container.py
|
|
4
|
+
|
|
5
|
+
Manages the opencode server container — start, stop, health check.
|
|
6
|
+
Built on docker-py (same dependency as DockerSandbox).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
import urllib.parse
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_IMAGE = "snodo-opencode:latest"
|
|
18
|
+
_PORT = 55440
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OpenCodeContainerError(Exception):
|
|
22
|
+
"""Container operation failed."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OpenCodeContainer:
|
|
26
|
+
"""Manages a long-lived opencode server container.
|
|
27
|
+
|
|
28
|
+
The container runs ``opencode serve --port {port}`` inside the
|
|
29
|
+
workspace directory and exposes the HTTP API on localhost.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, image: str = _IMAGE, port: int = _PORT):
|
|
33
|
+
self._image = image
|
|
34
|
+
self._port = port
|
|
35
|
+
self._client = None
|
|
36
|
+
self._container = None
|
|
37
|
+
|
|
38
|
+
docker_host = os.environ.get("DOCKER_HOST", "")
|
|
39
|
+
if docker_host.startswith(("ssh://", "tcp://")):
|
|
40
|
+
self._host = urllib.parse.urlparse(docker_host).hostname or "localhost"
|
|
41
|
+
else:
|
|
42
|
+
self._host = "localhost"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def client(self):
|
|
46
|
+
if self._client is None:
|
|
47
|
+
import docker
|
|
48
|
+
self._client = docker.from_env()
|
|
49
|
+
return self._client
|
|
50
|
+
|
|
51
|
+
def is_available(self) -> bool:
|
|
52
|
+
"""Check if Docker daemon is reachable."""
|
|
53
|
+
try:
|
|
54
|
+
self.client.ping()
|
|
55
|
+
return True
|
|
56
|
+
except Exception:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
def image_exists(self) -> bool:
|
|
60
|
+
"""Check if the opencode image exists locally."""
|
|
61
|
+
try:
|
|
62
|
+
self.client.images.get(self._image)
|
|
63
|
+
return True
|
|
64
|
+
except Exception:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
def build_image(self) -> str:
|
|
68
|
+
"""Build the opencode Docker image.
|
|
69
|
+
|
|
70
|
+
Returns the image ID.
|
|
71
|
+
|
|
72
|
+
Raises OpenCodeContainerError on failure.
|
|
73
|
+
"""
|
|
74
|
+
dockerfile_dir = Path(__file__).parent.parent.parent / "docker"
|
|
75
|
+
dockerfile_path = dockerfile_dir / "Dockerfile.opencode"
|
|
76
|
+
if not dockerfile_path.exists():
|
|
77
|
+
raise OpenCodeContainerError(
|
|
78
|
+
f"Dockerfile not found at {dockerfile_path}"
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
image, _ = self.client.images.build(
|
|
82
|
+
path=str(dockerfile_dir),
|
|
83
|
+
dockerfile=dockerfile_path.name,
|
|
84
|
+
tag=self._image,
|
|
85
|
+
rm=True,
|
|
86
|
+
)
|
|
87
|
+
return image.id
|
|
88
|
+
except Exception as e:
|
|
89
|
+
raise OpenCodeContainerError(f"Failed to build image: {e}") from e
|
|
90
|
+
|
|
91
|
+
def start(self, workspace: Path) -> None:
|
|
92
|
+
"""Start the opencode server container, reusing an existing one if healthy.
|
|
93
|
+
|
|
94
|
+
The *workspace* directory is mounted at /workspace inside the
|
|
95
|
+
container so opencode can read project files.
|
|
96
|
+
|
|
97
|
+
Raises OpenCodeContainerError if startup fails.
|
|
98
|
+
"""
|
|
99
|
+
# If we already hold a reference and it's healthy, skip
|
|
100
|
+
if self._container is not None and self._is_container_healthy():
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
# Check for an existing container from a previous session
|
|
104
|
+
existing = self._find_existing_container()
|
|
105
|
+
if existing is not None:
|
|
106
|
+
self._container = existing
|
|
107
|
+
if self._is_container_healthy():
|
|
108
|
+
_logger.info("Reusing existing opencode container %s", existing.id[:12])
|
|
109
|
+
return
|
|
110
|
+
_logger.debug("Existing container %s is unhealthy — removing", existing.id[:12])
|
|
111
|
+
self.stop()
|
|
112
|
+
|
|
113
|
+
# Start fresh
|
|
114
|
+
try:
|
|
115
|
+
volumes = {
|
|
116
|
+
str(workspace.resolve()): {"bind": "/workspace", "mode": "rw"},
|
|
117
|
+
}
|
|
118
|
+
env = _build_provider_env()
|
|
119
|
+
env["OPENCODE_PORT"] = str(self._port)
|
|
120
|
+
self._container = self.client.containers.run(
|
|
121
|
+
self._image,
|
|
122
|
+
detach=True,
|
|
123
|
+
volumes=volumes,
|
|
124
|
+
ports={f"{self._port}/tcp": self._port},
|
|
125
|
+
publish_all_ports=False,
|
|
126
|
+
remove=True,
|
|
127
|
+
environment=env,
|
|
128
|
+
)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
self._container = None
|
|
131
|
+
raise OpenCodeContainerError(f"Failed to start container: {e}") from e
|
|
132
|
+
|
|
133
|
+
self._wait_ready()
|
|
134
|
+
|
|
135
|
+
_logger.info("Started new opencode container")
|
|
136
|
+
self._log_readiness()
|
|
137
|
+
|
|
138
|
+
def _find_existing_container(self):
|
|
139
|
+
"""Return an existing running container with this image, or None."""
|
|
140
|
+
try:
|
|
141
|
+
containers = self.client.containers.list(
|
|
142
|
+
filters={"ancestor": self._image, "status": "running"},
|
|
143
|
+
)
|
|
144
|
+
if containers:
|
|
145
|
+
return containers[0]
|
|
146
|
+
except Exception as e:
|
|
147
|
+
_logger.debug("Failed to list running container by image: %s", e)
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def _is_container_healthy(self) -> bool:
|
|
151
|
+
"""Check if the container is running AND /global/health responds."""
|
|
152
|
+
import httpx
|
|
153
|
+
|
|
154
|
+
if not self.is_running():
|
|
155
|
+
return False
|
|
156
|
+
try:
|
|
157
|
+
resp = httpx.get(
|
|
158
|
+
f"{self.base_url}/global/health", timeout=2.0,
|
|
159
|
+
)
|
|
160
|
+
return resp.status_code == 200
|
|
161
|
+
except Exception:
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
def _wait_ready(self, timeout: float = 60.0) -> None:
|
|
165
|
+
"""Poll the opencode HTTP API until it responds or times out."""
|
|
166
|
+
import httpx
|
|
167
|
+
|
|
168
|
+
deadline = time.time() + timeout
|
|
169
|
+
url = self.base_url + "/global/health"
|
|
170
|
+
while time.time() < deadline:
|
|
171
|
+
if self._container is None:
|
|
172
|
+
raise OpenCodeContainerError("Container stopped during startup")
|
|
173
|
+
try:
|
|
174
|
+
resp = httpx.get(url, timeout=2.0)
|
|
175
|
+
if resp.status_code < 500:
|
|
176
|
+
_logger.debug("OpenCode server ready at %s", self.base_url)
|
|
177
|
+
return
|
|
178
|
+
except Exception as e:
|
|
179
|
+
_logger.debug("OpenCode server health check poll error: %s", e)
|
|
180
|
+
time.sleep(0.5)
|
|
181
|
+
raise OpenCodeContainerError(
|
|
182
|
+
f"OpenCode server did not become ready within {timeout}s"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def _log_readiness(self) -> None:
|
|
186
|
+
"""Log container health information at DEBUG level."""
|
|
187
|
+
import httpx
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
resp = httpx.get(
|
|
191
|
+
f"{self.base_url}/global/health", timeout=2.0,
|
|
192
|
+
)
|
|
193
|
+
data = resp.json() if resp.status_code == 200 else {}
|
|
194
|
+
version = data.get("version", "unknown")
|
|
195
|
+
_logger.debug(
|
|
196
|
+
"OpenCode container ready: http://%s:%d (v%s)",
|
|
197
|
+
self._host, self._port, version,
|
|
198
|
+
)
|
|
199
|
+
except Exception:
|
|
200
|
+
_logger.debug(
|
|
201
|
+
"OpenCode container ready: http://%s:%d",
|
|
202
|
+
self._host, self._port,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def base_url(self) -> str:
|
|
207
|
+
return f"http://{self._host}:{self._port}"
|
|
208
|
+
|
|
209
|
+
def is_running(self) -> bool:
|
|
210
|
+
"""Return True if the container is alive."""
|
|
211
|
+
if self._container is None:
|
|
212
|
+
return False
|
|
213
|
+
try:
|
|
214
|
+
self._container.reload()
|
|
215
|
+
return self._container.status == "running"
|
|
216
|
+
except Exception:
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
def stop(self) -> None:
|
|
220
|
+
"""Stop and remove the container."""
|
|
221
|
+
if self._container is not None:
|
|
222
|
+
try:
|
|
223
|
+
self._container.stop(timeout=5)
|
|
224
|
+
except Exception as e:
|
|
225
|
+
_logger.debug("Failed to stop opencode container: %s", e)
|
|
226
|
+
try:
|
|
227
|
+
self._container.remove(force=True)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
_logger.debug("Failed to remove opencode container: %s", e)
|
|
230
|
+
self._container = None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
_PROVIDER_ENV_MAP = {
|
|
234
|
+
"google": ("GOOGLE_API_KEY", "GEMINI_API_KEY"),
|
|
235
|
+
"anthropic": ("ANTHROPIC_API_KEY",),
|
|
236
|
+
"openai": ("OPENAI_API_KEY",),
|
|
237
|
+
"deepseek": ("DEEPSEEK_API_KEY",),
|
|
238
|
+
"openrouter": ("OPENROUTER_API_KEY",),
|
|
239
|
+
"opencode": ("OPENCODE_API_KEY",),
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _build_provider_env() -> dict:
|
|
244
|
+
"""Read provider API keys from snodo config and return env vars for opencode."""
|
|
245
|
+
env: dict[str, str] = {}
|
|
246
|
+
try:
|
|
247
|
+
from snodo.config import ConfigManager
|
|
248
|
+
import os as _os
|
|
249
|
+
config = ConfigManager().load()
|
|
250
|
+
providers = config.get("providers", {})
|
|
251
|
+
if not isinstance(providers, dict):
|
|
252
|
+
return env
|
|
253
|
+
for provider_name, env_var_names in _PROVIDER_ENV_MAP.items():
|
|
254
|
+
provider_config = providers.get(provider_name, {})
|
|
255
|
+
if not isinstance(provider_config, dict):
|
|
256
|
+
continue
|
|
257
|
+
key = provider_config.get("api_key", "")
|
|
258
|
+
if not key:
|
|
259
|
+
api_key_env_name = provider_config.get("api_key_env", "")
|
|
260
|
+
if api_key_env_name:
|
|
261
|
+
key = _os.environ.get(api_key_env_name, "")
|
|
262
|
+
if key:
|
|
263
|
+
for env_name in env_var_names:
|
|
264
|
+
env[env_name] = key
|
|
265
|
+
except Exception as e:
|
|
266
|
+
_logger.warning("Failed to build provider env for opencode: %s", e)
|
|
267
|
+
return env
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Base adapter for host CLI tools that write files in place via subprocess.
|
|
2
|
+
|
|
3
|
+
FILE: snodo/coders/subprocess_adapter.py
|
|
4
|
+
|
|
5
|
+
Abstracts host CLI tools (opencode-cli, agy) that execute in-place edits in the
|
|
6
|
+
working tree via subprocess invocation. Base class handles:
|
|
7
|
+
- Prompt formatting (_build_prompt)
|
|
8
|
+
- Subprocess invocation and error handling (missing binary, timeout, non-zero returncode)
|
|
9
|
+
- Git working tree readback (_read_changes_from_disk)
|
|
10
|
+
- Artifact construction (_diff_to_artifact)
|
|
11
|
+
- .snodo/ mutation protection and git commit (inherited from InPlaceCoderAdapter)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import subprocess
|
|
16
|
+
from abc import abstractmethod
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
from snodo.coders.base import InPlaceCoderAdapter, LLMCallError
|
|
21
|
+
from snodo.core.interfaces import CodeArtifact, FileArtifact, TaskSpec
|
|
22
|
+
|
|
23
|
+
_logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SubprocessCoderAdapter(InPlaceCoderAdapter):
|
|
27
|
+
"""Base coder adapter for host CLI subprocess tools."""
|
|
28
|
+
|
|
29
|
+
skip_engine_commit: bool = True
|
|
30
|
+
skip_workspace_write: bool = True
|
|
31
|
+
|
|
32
|
+
binary: str = ""
|
|
33
|
+
model_prefix: str = ""
|
|
34
|
+
install_hint: str = ""
|
|
35
|
+
timeout_seconds: int = 1800
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
model: str = "",
|
|
40
|
+
temperature: float = 0.7,
|
|
41
|
+
workspace: Optional[Path] = None,
|
|
42
|
+
workspace_mcp: Optional[Any] = None,
|
|
43
|
+
**kwargs: Any,
|
|
44
|
+
):
|
|
45
|
+
self.model = model or self.model_prefix
|
|
46
|
+
self.temperature = temperature
|
|
47
|
+
|
|
48
|
+
if workspace is not None:
|
|
49
|
+
self._workspace = Path(workspace)
|
|
50
|
+
elif workspace_mcp is not None:
|
|
51
|
+
from snodo.tools.workspace import WorkspaceMCP
|
|
52
|
+
if isinstance(workspace_mcp, WorkspaceMCP):
|
|
53
|
+
self._workspace = Path(workspace_mcp.project_root)
|
|
54
|
+
elif hasattr(workspace_mcp, "project_root") and workspace_mcp.project_root is not None:
|
|
55
|
+
self._workspace = Path(workspace_mcp.project_root)
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"{self.__class__.__name__} requires an explicit workspace or a valid "
|
|
59
|
+
f"WorkspaceMCP with project_root; received invalid workspace_mcp: {workspace_mcp!r}. "
|
|
60
|
+
"Inferring a containment boundary from Path.cwd() is prohibited (ADR 024/025)."
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"{self.__class__.__name__} requires an explicit workspace or workspace_mcp; "
|
|
65
|
+
"none was provided. Inferring a containment boundary from Path.cwd() is prohibited (ADR 024/025)."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def _bare_model(self) -> str:
|
|
69
|
+
"""Return the model to pass to the CLI, or "" to let it choose.
|
|
70
|
+
|
|
71
|
+
An external coding agent owns its own model catalog — agy offers
|
|
72
|
+
"Gemini 3.6 Flash (Medium)", opencode offers whatever its providers
|
|
73
|
+
expose. snodo's ``-m`` names the model that JUDGES the work: it is
|
|
74
|
+
resolved through litellm for the validators and the classifier, and it
|
|
75
|
+
is meaningless to the CLI. Forwarding it produced:
|
|
76
|
+
|
|
77
|
+
agy run failed (rc=1): invalid model selection
|
|
78
|
+
(--model "deepseek/deepseek-v4-flash"): not recognized as a known
|
|
79
|
+
model or custom model in settings
|
|
80
|
+
|
|
81
|
+
So a model reaches the CLI only when the operator named one in THIS
|
|
82
|
+
adapter's namespace (``agy/...``, ``opencode-cli/...``). Anything else
|
|
83
|
+
yields "", and ``_build_argv`` omits the flag so the tool falls back to
|
|
84
|
+
its own last-selected default.
|
|
85
|
+
|
|
86
|
+
(Selecting the coder's model explicitly, while the validators keep
|
|
87
|
+
theirs, needs a separate flag — ``-m`` cannot carry both.)
|
|
88
|
+
"""
|
|
89
|
+
model = self.model
|
|
90
|
+
if not self.model_prefix:
|
|
91
|
+
return model
|
|
92
|
+
prefixes = (self.model_prefix,)
|
|
93
|
+
if self.model_prefix == "opencode-cli/":
|
|
94
|
+
prefixes = ("opencode-cli/", "opencode/")
|
|
95
|
+
for prefix in prefixes:
|
|
96
|
+
if model.startswith(prefix):
|
|
97
|
+
return model[len(prefix):]
|
|
98
|
+
return ""
|
|
99
|
+
|
|
100
|
+
@abstractmethod
|
|
101
|
+
def _build_argv(self, prompt: str, project_root: str, model: str) -> list[str]:
|
|
102
|
+
"""Construct the subprocess argument list for the specific CLI tool."""
|
|
103
|
+
|
|
104
|
+
def _implement_in_place(self, spec: TaskSpec) -> CodeArtifact:
|
|
105
|
+
prompt = self._build_prompt(spec)
|
|
106
|
+
project_root = str(self._workspace)
|
|
107
|
+
bare_model = self._bare_model()
|
|
108
|
+
argv = self._build_argv(prompt, project_root, bare_model)
|
|
109
|
+
|
|
110
|
+
_logger.info(
|
|
111
|
+
"%s: executing with containment boundary at %s",
|
|
112
|
+
self.binary, project_root,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
# noqa carried over from opencode_cli_adapter when this call moved
|
|
117
|
+
# here in the subprocess-adapter refactor. The binary name is a
|
|
118
|
+
# class attribute, never operator input; the prompt and model are
|
|
119
|
+
# single argv elements and are never shell-interpreted.
|
|
120
|
+
proc = subprocess.run( # noqa: S603 - argv list, no shell; binary is a class attribute
|
|
121
|
+
argv,
|
|
122
|
+
cwd=project_root,
|
|
123
|
+
capture_output=True,
|
|
124
|
+
text=True,
|
|
125
|
+
timeout=self.timeout_seconds,
|
|
126
|
+
)
|
|
127
|
+
except FileNotFoundError as e:
|
|
128
|
+
raise LLMCallError(
|
|
129
|
+
f"{self.binary} not found on PATH. {self.install_hint}"
|
|
130
|
+
) from e
|
|
131
|
+
except subprocess.TimeoutExpired as e:
|
|
132
|
+
raise LLMCallError(
|
|
133
|
+
f"{self.binary} run timed out after {self.timeout_seconds}s"
|
|
134
|
+
) from e
|
|
135
|
+
|
|
136
|
+
if proc.returncode != 0:
|
|
137
|
+
tail = (proc.stderr or "")[:2000] or (proc.stdout or "")[:2000]
|
|
138
|
+
raise LLMCallError(
|
|
139
|
+
f"{self.binary} run failed (rc={proc.returncode}): {tail}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
diff_entries = self._read_changes_from_disk()
|
|
143
|
+
if not diff_entries:
|
|
144
|
+
combined = ((proc.stdout or "") + "\n" + (proc.stderr or "")).strip()
|
|
145
|
+
tail = combined[-1000:]
|
|
146
|
+
_logger.warning(
|
|
147
|
+
"%s run completed but no changes detected (rc=0). output tail: %s",
|
|
148
|
+
self.binary, tail,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return self._diff_to_artifact(diff_entries)
|
|
152
|
+
|
|
153
|
+
def _diff_to_artifact(self, diff_entries: list) -> CodeArtifact:
|
|
154
|
+
"""Build a CodeArtifact from diff entries, re-reading content from disk."""
|
|
155
|
+
_ignored_dirs = {"__pycache__", ".venv", "venv", "node_modules", ".git", ".pytest_cache", ".mypy_cache"}
|
|
156
|
+
_ignored_exts = {".pyc", ".pyo", ".DS_Store"}
|
|
157
|
+
|
|
158
|
+
files = []
|
|
159
|
+
for entry in diff_entries:
|
|
160
|
+
path = entry.get("file", "")
|
|
161
|
+
if not path:
|
|
162
|
+
continue
|
|
163
|
+
parts = Path(path).parts
|
|
164
|
+
if any(p in _ignored_dirs for p in parts) or Path(path).suffix in _ignored_exts:
|
|
165
|
+
continue
|
|
166
|
+
status = entry.get("status", "modified")
|
|
167
|
+
|
|
168
|
+
if status == "deleted":
|
|
169
|
+
files.append(FileArtifact(path=path, content="", action="delete"))
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
file_path = Path(self._workspace) / path
|
|
173
|
+
try:
|
|
174
|
+
content = file_path.read_text()
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
_logger.warning("%s: failed to read %s: %s", self.binary, file_path, exc)
|
|
177
|
+
content = f"<unreadable: {exc}>"
|
|
178
|
+
|
|
179
|
+
files.append(FileArtifact(path=path, content=content, action="write"))
|
|
180
|
+
|
|
181
|
+
if not files:
|
|
182
|
+
_logger.warning("%s returned no files — task completed with no changes", self.binary)
|
|
183
|
+
return CodeArtifact(files=[])
|
|
184
|
+
|
|
185
|
+
return CodeArtifact(files=files)
|
|
186
|
+
|
|
187
|
+
def _build_prompt(self, spec: TaskSpec) -> str:
|
|
188
|
+
"""Build prompt from TaskSpec."""
|
|
189
|
+
language = spec.project_context.get("language", "unknown")
|
|
190
|
+
lang_hint = f" ({language} project)" if language != "unknown" else ""
|
|
191
|
+
|
|
192
|
+
parts = [
|
|
193
|
+
f"You are an expert software engineer{lang_hint}.",
|
|
194
|
+
"Generate code based on the following specification.",
|
|
195
|
+
"",
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
structure = spec.project_context.get("structure", "")
|
|
199
|
+
if structure:
|
|
200
|
+
parts.append(f"## Directory Structure\n```\n{structure}\n```")
|
|
201
|
+
parts.append("")
|
|
202
|
+
|
|
203
|
+
if spec.memory_summary:
|
|
204
|
+
parts.append(f"## Session History\n{spec.memory_summary}")
|
|
205
|
+
parts.append("")
|
|
206
|
+
|
|
207
|
+
parts.append(f"## Task\n{spec.description}")
|
|
208
|
+
|
|
209
|
+
if spec.constraints:
|
|
210
|
+
parts.append("\n## Constraints")
|
|
211
|
+
for c in spec.constraints:
|
|
212
|
+
parts.append(f"- {c}")
|
|
213
|
+
|
|
214
|
+
parts.append("")
|
|
215
|
+
parts.append(
|
|
216
|
+
"Write the implementation to disk. Create all necessary files."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return "\n".join(parts)
|
snodo/engine/__init__.py
ADDED
|
File without changes
|