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/mock.py ADDED
@@ -0,0 +1,203 @@
1
+ """Mock coder adapter for testing.
2
+
3
+ FILE: snodo/coders/mock.py (Fixes #12, #185, #187)
4
+
5
+ Returns deterministic outputs without making LLM calls.
6
+
7
+ The mock exists to exercise the engine loop deterministically. Its output
8
+ must therefore satisfy every validator that judges it — including the quality
9
+ validator (pytest for Python, npm test for JavaScript).
10
+
11
+ Language selection is driven by ``TaskSpec.project_context["language"]``:
12
+ - ``javascript`` or ``typescript`` → ``src/hello.js`` + ``src/hello.test.js``
13
+ (uses Node's built-in ``node:test`` module; no npm install needed)
14
+ - anything else (``python``, ``unknown``, or unset) → ``src/hello.py`` +
15
+ ``tests/test_hello.py`` (the original Python fixture, fixed in #185)
16
+
17
+ Do not change the Python fixture paths or content: they are relied on by
18
+ hundreds of tests in the suite.
19
+ """
20
+
21
+ import json
22
+ from pathlib import Path
23
+ from typing import Any, Optional
24
+ from unittest.mock import MagicMock
25
+
26
+ from snodo.core.interfaces import TaskSpec, CodeArtifact, FileArtifact
27
+ from snodo.coders.base import CoderAdapter
28
+ from snodo.infrastructure.config import DEFAULT_MODEL
29
+
30
+ _GLOBAL_MOCK_MODE: bool = False
31
+
32
+
33
+ def set_mock_mode(enabled: bool) -> None:
34
+ """Enable or disable global hermetic mock mode for the process."""
35
+ global _GLOBAL_MOCK_MODE
36
+ _GLOBAL_MOCK_MODE = enabled
37
+
38
+
39
+ def is_mock_mode_active() -> bool:
40
+ """Return True if global mock mode is enabled."""
41
+ return _GLOBAL_MOCK_MODE
42
+
43
+
44
+ class MockChoiceMessage:
45
+ def __init__(self, content: str = "", tool_calls: Optional[list] = None):
46
+ self.content = content
47
+ self.tool_calls = tool_calls or []
48
+
49
+
50
+ class MockChoice:
51
+ def __init__(self, message: MockChoiceMessage, finish_reason: str = "stop"):
52
+ self.message = message
53
+ self.finish_reason = finish_reason
54
+
55
+
56
+ class MockModelResponse:
57
+ def __init__(self, content: str = "", tool_calls: Optional[list] = None, finish_reason: str = "stop"):
58
+ self.choices = [MockChoice(MockChoiceMessage(content, tool_calls), finish_reason)]
59
+
60
+
61
+ def mock_completion_fn(*args: Any, **kwargs: Any) -> MockModelResponse:
62
+ """Hermetic mock completion function. Never touches network or credentials."""
63
+ messages = kwargs.get("messages", [])
64
+ tools = kwargs.get("tools", [])
65
+
66
+ # Check if tools contain submit_verdict or submit_files
67
+ tool_names = set()
68
+ if isinstance(tools, list):
69
+ for t in tools:
70
+ if isinstance(t, dict):
71
+ fn = t.get("function", {})
72
+ if isinstance(fn, dict) and "name" in fn:
73
+ tool_names.add(fn["name"])
74
+
75
+ # LLMValidator tool loop
76
+ if "submit_verdict" in tool_names:
77
+ tc = MagicMock()
78
+ tc.id = "mock_val_call_1"
79
+ tc.function.name = "submit_verdict"
80
+ tc.function.arguments = json.dumps({"severity": "pass", "justification": "Mock validation pass"})
81
+ return MockModelResponse(content="", tool_calls=[tc])
82
+
83
+ # LiteLLMAdapter tool loop
84
+ if "submit_files" in tool_names:
85
+ tc = MagicMock()
86
+ tc.id = "mock_coder_call_1"
87
+ tc.function.name = "submit_files"
88
+ tc.function.arguments = json.dumps({"files": []})
89
+ return MockModelResponse(content="", tool_calls=[tc])
90
+
91
+ # Inspect messages to provide appropriate response
92
+ user_prompt = ""
93
+ for msg in reversed(messages):
94
+ if isinstance(msg, dict) and msg.get("role") == "user":
95
+ user_prompt = str(msg.get("content", ""))
96
+ break
97
+
98
+ # Wave classifier prompt
99
+ role_meta = str(kwargs.get("metadata", {}).get("role", ""))
100
+ if "wave" in user_prompt.lower() or "classifier" in role_meta.lower():
101
+ content = json.dumps({
102
+ "flow_type": "feature",
103
+ "wave_id": "W-MOCK-1",
104
+ "task_summary": "Mock task classification",
105
+ "feature_description": "Mock feature description",
106
+ "justification": "Mock wave 1 classification",
107
+ })
108
+ return MockModelResponse(content=content)
109
+
110
+ # Validator prompt
111
+ if "validator" in role_meta.lower() or "severity" in user_prompt.lower():
112
+ content = json.dumps({"severity": "pass", "justification": "Mock validation pass"})
113
+ return MockModelResponse(content=content)
114
+
115
+ # Spec authoring prompt
116
+ if "spec" in user_prompt.lower():
117
+ content = "Refined spec: mock task specification"
118
+ return MockModelResponse(content=content)
119
+
120
+ # Default fallback content
121
+ content = json.dumps([{"path": "src/mock.py", "content": "# Mock code", "action": "write"}])
122
+ return MockModelResponse(content=content)
123
+
124
+
125
+ mock_completion_fn._is_mock = True # type: ignore[attr-defined]
126
+
127
+
128
+ def is_mock_completion_fn(fn: Any) -> bool:
129
+ """Return True if fn is mock_completion_fn or marked with _is_mock."""
130
+ if fn is None:
131
+ return False
132
+ target = getattr(fn, "func", fn)
133
+ if target == mock_completion_fn or getattr(target, "_is_mock", False) is True or getattr(fn, "_is_mock", False) is True:
134
+ return True
135
+ return is_mock_mode_active()
136
+
137
+
138
+ class MockAdapter(CoderAdapter):
139
+ """Mock coder adapter for testing.
140
+
141
+ Returns deterministic outputs without making LLM calls.
142
+ Useful for fast, reliable unit tests.
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ mock_files: Optional[list] = None,
148
+ model: Optional[str] = None,
149
+ **kwargs: Any,
150
+ ):
151
+ self.model = model or DEFAULT_MODEL
152
+ self.mock_files = mock_files or [
153
+ FileArtifact(path="src/hello.py", content="def hello():\n return 'world'\n"),
154
+ FileArtifact(path="tests/test_hello.py", content="from src.hello import hello\n\n\ndef test_hello():\n assert hello() == 'world'\n"),
155
+ ]
156
+ self.call_count = 0
157
+ self.last_spec: Optional[TaskSpec] = None
158
+ self._completion_fn = mock_completion_fn
159
+
160
+ def _bare_model(self) -> str:
161
+ """Return the model the coder used to make LLM calls, or "" if none.
162
+
163
+ The mock makes no LLM call at all, so there is never a model to
164
+ attribute to it: the ``-m`` value is the judging model, which the
165
+ mock never forwards. Returning "" lets the halt payload and the
166
+ audit trail report ``coder_model: null`` for the mock path, the
167
+ same as an external CLI coder whose model was not namespaced.
168
+ """
169
+ return ""
170
+
171
+ def implement(self, spec: TaskSpec) -> CodeArtifact:
172
+ self.call_count += 1
173
+ self.last_spec = spec
174
+
175
+ language = spec.project_context.get("language", "").lower()
176
+ if language in ("javascript", "typescript"):
177
+ files = [
178
+ FileArtifact(
179
+ path="src/hello.js",
180
+ content="function hello() { return 'world'; }\nmodule.exports = { hello };\n",
181
+ ),
182
+ FileArtifact(
183
+ path="src/hello.test.js",
184
+ content=(
185
+ "const { test } = require('node:test');\n"
186
+ "const assert = require('node:assert');\n"
187
+ "const { hello } = require('./hello.js');\n"
188
+ "\n"
189
+ "test('hello returns world', () => {\n"
190
+ " assert.strictEqual(hello(), 'world');\n"
191
+ "});\n"
192
+ ),
193
+ ),
194
+ ]
195
+ else:
196
+ files = self.mock_files
197
+
198
+ valid_files = [
199
+ f for f in files
200
+ if not (Path(f.path).parts and Path(f.path).parts[0] == ".snodo")
201
+ ]
202
+ return CodeArtifact(files=valid_files)
203
+
@@ -0,0 +1,16 @@
1
+ """OpenAI coder adapter.
2
+
3
+ Inherits the base LiteLLMAdapter unchanged — already OpenAI-shaped.
4
+ """
5
+
6
+ from snodo.coders.litellm import LiteLLMAdapter
7
+
8
+
9
+ class OpenAIAdapter(LiteLLMAdapter):
10
+ """Coder adapter for OpenAI models (gpt-*, o1-*, o3-*).
11
+
12
+ Inherits the base _call_llm_with_tools unchanged — the OpenAI
13
+ message shape is the native format.
14
+ """
15
+
16
+ TRUNCATION_REASONS: set[str] = {"length", "max_tokens", "MAX_TOKENS"}
@@ -0,0 +1,327 @@
1
+ """OpenCode coder adapter — delegates to opencode server in Docker.
2
+
3
+ FILE: snodo/coders/opencode_adapter.py
4
+
5
+ Implements CoderAdapter via the opencode HTTP API:
6
+ POST /session → session_id
7
+ POST /session/{id}/message → submit spec (model in message body)
8
+ GET /event → SSE subscription for completion signal
9
+ GET /session/{id}/diff → fetch changed files after completion
10
+
11
+ After the session completes, changes are read from the volume-mounted
12
+ workspace via git diff (the in-place edits are the source of truth).
13
+ The /diff API is used as a best-effort fallback.
14
+
15
+ The opencode server runs inside a Docker container managed by
16
+ OpenCodeContainer. Each implement() call creates a fresh session.
17
+ """
18
+
19
+ import json
20
+ import logging
21
+ import threading
22
+ from pathlib import Path
23
+ from typing import Any, Optional
24
+
25
+ import httpx
26
+
27
+ from snodo.core.interfaces import TaskSpec, CodeArtifact, FileArtifact
28
+ from snodo.coders.base import InPlaceCoderAdapter, LLMCallError
29
+
30
+ _logger = logging.getLogger(__name__)
31
+
32
+ _SESSION_TIMEOUT = 300.0 # 5 minutes
33
+
34
+
35
+ class OpenCodeAdapter(InPlaceCoderAdapter):
36
+ """Coder adapter backed by opencode CLI running in Docker.
37
+
38
+ Writes to the volume-mounted workspace in place (never through
39
+ WorkspaceMCP), so the .snodo/ boundary is enforced by the base class:
40
+ a post-run .snodo/ mutation raises ``SnodoMutationError`` instead of
41
+ being filtered out of the artifact report (Fixes #52, ADR 027).
42
+ """
43
+
44
+ coder_name: str = "opencode"
45
+ skip_engine_commit: bool = True
46
+ skip_workspace_write: bool = True
47
+
48
+ def __init__(
49
+ self,
50
+ model: str = "opencode/",
51
+ temperature: float = 0.7,
52
+ workspace: Optional[Path] = None,
53
+ container: Optional[Any] = None,
54
+ workspace_mcp: Optional[Any] = None,
55
+ **kwargs,
56
+ ):
57
+ self.model = model
58
+ self.temperature = temperature
59
+
60
+ if workspace is not None:
61
+ self._workspace = workspace
62
+ elif workspace_mcp is not None:
63
+ from snodo.tools.workspace import WorkspaceMCP
64
+ if isinstance(workspace_mcp, WorkspaceMCP):
65
+ self._workspace = workspace_mcp.project_root
66
+ else:
67
+ self._workspace = Path.cwd()
68
+ else:
69
+ self._workspace = Path.cwd()
70
+
71
+ if container is None:
72
+ from snodo.coders.opencode_container import OpenCodeContainer
73
+ self._container = OpenCodeContainer()
74
+ else:
75
+ self._container = container
76
+
77
+ @property
78
+ def base_url(self) -> str:
79
+ return self._container.base_url
80
+
81
+ def _implement_in_place(self, spec: TaskSpec) -> CodeArtifact:
82
+ """Generate code via the opencode HTTP API.
83
+
84
+ 1. Ensure container is running (start if needed)
85
+ 2. Create session via POST /session
86
+ 3. Subscribe to SSE /event BEFORE sending message (race-safe)
87
+ 4. Submit the task spec via POST /session/{id}/message (with model)
88
+ 5. Wait for session.idle event (threading.Event, 300s timeout)
89
+ 6. Read changed files from the volume-mounted workspace via git
90
+ 7. Fall back to GET /session/{id}/diff if git readback is empty
91
+ 8. Build CodeArtifact from on-disk file contents
92
+ """
93
+ if not self._container.is_running():
94
+ self._start_container()
95
+
96
+ session_id = self._create_session()
97
+ try:
98
+ self._wait_for_completion(session_id, spec)
99
+ # Primary: read from the volume-mounted workspace (git diff)
100
+ diff_entries = self._read_changes_from_disk()
101
+ # Fallback: use the /diff API
102
+ if not diff_entries:
103
+ _logger.debug("git readback empty — trying /session/{id}/diff")
104
+ diff_entries = self._fetch_diff(session_id)
105
+ return self._diff_to_artifact(diff_entries)
106
+ finally:
107
+ self._cleanup_session(session_id)
108
+
109
+ def _wait_for_completion(self, session_id: str, spec: TaskSpec) -> None:
110
+ """Subscribe to SSE, send message, wait for session.idle event."""
111
+ completed = threading.Event()
112
+
113
+ def _listen_sse():
114
+ try:
115
+ with httpx.stream(
116
+ "GET",
117
+ f"{self.base_url}/event",
118
+ timeout=_SESSION_TIMEOUT,
119
+ ) as r:
120
+ for line in r.iter_lines():
121
+ if completed.is_set():
122
+ break
123
+ if not line.startswith("data: "):
124
+ continue
125
+ try:
126
+ event = json.loads(line[6:])
127
+ except json.JSONDecodeError:
128
+ continue
129
+ props = event.get("properties", {}) or {}
130
+ event_sid = props.get("sessionID", "")
131
+ if event_sid != session_id:
132
+ continue
133
+ event_type = event.get("type", "")
134
+ if event_type == "session.idle":
135
+ completed.set()
136
+ return
137
+ if (
138
+ event_type == "session.status"
139
+ and (props.get("status", {}) or {}).get("type") == "idle"
140
+ ):
141
+ completed.set()
142
+ return
143
+ except Exception as e:
144
+ _logger.debug("opencode SSE listener error: %s", e)
145
+
146
+ thread = threading.Thread(target=_listen_sse, daemon=True)
147
+ thread.start()
148
+
149
+ self._send_message(session_id, spec)
150
+
151
+ if not completed.wait(timeout=_SESSION_TIMEOUT):
152
+ raise LLMCallError(
153
+ f"opencode session {session_id} timed out after {_SESSION_TIMEOUT}s"
154
+ )
155
+
156
+ def _fetch_diff(self, session_id: str) -> list:
157
+ """GET /session/{id}/diff — return changed files as a list (fallback)."""
158
+ diff_resp = httpx.get(
159
+ f"{self.base_url}/session/{session_id}/diff",
160
+ timeout=10.0,
161
+ )
162
+ data = diff_resp.json() if diff_resp.status_code == 200 else []
163
+ _logger.debug(
164
+ "opencode diff received: %d entries", len(data),
165
+ )
166
+ return data
167
+
168
+ def _start_container(self) -> None:
169
+ """Start the opencode container if not running."""
170
+ from snodo.coders.opencode_container import OpenCodeContainerError
171
+
172
+ if not self._container.is_available():
173
+ raise LLMCallError(
174
+ "Docker is not available. The opencode adapter requires Docker."
175
+ )
176
+ if not self._container.image_exists():
177
+ _logger.info("Building opencode Docker image (first run)...")
178
+ try:
179
+ self._container.build_image()
180
+ except OpenCodeContainerError as e:
181
+ raise LLMCallError(f"Failed to build opencode image: {e}") from e
182
+
183
+ try:
184
+ self._container.start(self._workspace)
185
+ except OpenCodeContainerError as e:
186
+ raise LLMCallError(f"Failed to start opencode container: {e}") from e
187
+
188
+ def _create_session(self) -> str:
189
+ """POST /session — create a new session and return the ID."""
190
+ try:
191
+ resp = httpx.post(
192
+ f"{self.base_url}/session",
193
+ json={
194
+ "model": self._resolve_model_payload(),
195
+ },
196
+ timeout=10.0,
197
+ )
198
+ if resp.status_code != 200:
199
+ raise LLMCallError(
200
+ f"opencode session creation failed (HTTP {resp.status_code}): "
201
+ f"{resp.text[:500]}"
202
+ )
203
+ data = resp.json()
204
+ session_id = data.get("id") or data.get("session_id")
205
+ if not session_id:
206
+ raise LLMCallError(
207
+ f"opencode session response missing id: {resp.text[:500]}"
208
+ )
209
+ _logger.debug("opencode session created: %s", session_id)
210
+ return session_id
211
+ except (httpx.RequestError, json.JSONDecodeError) as e:
212
+ raise LLMCallError(f"opencode session creation error: {e}") from e
213
+
214
+ def _send_message(self, session_id: str, spec: TaskSpec) -> None:
215
+ """POST /session/{id}/message — submit the task spec with model."""
216
+ prompt = self._build_prompt(spec)
217
+ try:
218
+ resp = httpx.post(
219
+ f"{self.base_url}/session/{session_id}/message",
220
+ json={
221
+ "model": self._resolve_model_payload(),
222
+ "parts": [{"type": "text", "text": prompt}],
223
+ },
224
+ timeout=60.0,
225
+ )
226
+ if resp.status_code != 200:
227
+ _logger.warning(
228
+ "opencode message rejected (HTTP %d): %s",
229
+ resp.status_code, resp.text[:500],
230
+ )
231
+ raise LLMCallError(
232
+ f"opencode message rejected (HTTP {resp.status_code})"
233
+ )
234
+ except httpx.RequestError as e:
235
+ _logger.warning("opencode message send error: %s", e)
236
+
237
+ def _cleanup_session(self, session_id: str) -> None:
238
+ """Best-effort session cleanup."""
239
+ try:
240
+ httpx.delete(
241
+ f"{self.base_url}/session/{session_id}",
242
+ timeout=5.0,
243
+ )
244
+ except Exception as e:
245
+ _logger.debug("opencode session cleanup error for %s: %s", session_id, e)
246
+
247
+ def _diff_to_artifact(self, diff_entries: list) -> CodeArtifact:
248
+ """Convert opencode diff entries to a CodeArtifact.
249
+
250
+ Each entry: ``{file, patch, additions, deletions, status}``.
251
+ We need the full file content — re-read from disk after the
252
+ opencode session has written changes.
253
+ """
254
+ files = []
255
+ for entry in diff_entries:
256
+ path = entry.get("file", "")
257
+ if not path:
258
+ continue
259
+ status = entry.get("status", "modified")
260
+
261
+ if status == "deleted":
262
+ files.append(FileArtifact(path=path, content="", action="delete"))
263
+ continue
264
+
265
+ # Re-read the file from disk (opencode wrote to workspace)
266
+ file_path = Path(self._workspace) / path
267
+ try:
268
+ content = file_path.read_text()
269
+ except Exception:
270
+ content = ""
271
+
272
+ files.append(FileArtifact(path=path, content=content, action="write"))
273
+
274
+ if not files:
275
+ _logger.warning("opencode diff returned no files — task completed with no changes")
276
+ return CodeArtifact(files=[])
277
+
278
+ return CodeArtifact(files=files)
279
+
280
+ def _resolve_model_payload(self) -> dict:
281
+ """Map the snodo model string to an opencode model payload.
282
+
283
+ opencode's API expects ``{"providerID": <p>, "modelID": <m>}``
284
+ on ``POST /session/{id}/message``.
285
+ """
286
+ model = self.model
287
+ if model.startswith("opencode/"):
288
+ provider_and_id = model[len("opencode/"):]
289
+ if "/" in provider_and_id:
290
+ provider, model_id = provider_and_id.split("/", 1)
291
+ return {"providerID": provider, "modelID": model_id}
292
+ return {"modelID": provider_and_id}
293
+ return {"modelID": model}
294
+
295
+ def _build_prompt(self, spec: TaskSpec) -> str:
296
+ """Build a prompt from the TaskSpec for opencode."""
297
+ language = spec.project_context.get("language", "unknown")
298
+ lang_hint = f" ({language} project)" if language != "unknown" else ""
299
+
300
+ parts = [
301
+ f"You are an expert software engineer{lang_hint}.",
302
+ "Generate code based on the following specification.",
303
+ "",
304
+ ]
305
+
306
+ structure = spec.project_context.get("structure", "")
307
+ if structure:
308
+ parts.append(f"## Directory Structure\n```\n{structure}\n```")
309
+ parts.append("")
310
+
311
+ if spec.memory_summary:
312
+ parts.append(f"## Session History\n{spec.memory_summary}")
313
+ parts.append("")
314
+
315
+ parts.append(f"## Task\n{spec.description}")
316
+
317
+ if spec.constraints:
318
+ parts.append("\n## Constraints")
319
+ for c in spec.constraints:
320
+ parts.append(f"- {c}")
321
+
322
+ parts.append("")
323
+ parts.append(
324
+ "Write the implementation to disk. Create all necessary files."
325
+ )
326
+
327
+ return "\n".join(parts)
@@ -0,0 +1,34 @@
1
+ """OpenCode CLI coder adapter — shells `opencode run` on the host.
2
+
3
+ FILE: snodo/coders/opencode_cli_adapter.py
4
+
5
+ Runs opencode directly on the host machine via SubprocessCoderAdapter.
6
+ """
7
+
8
+ from typing import List
9
+
10
+ from snodo.coders.subprocess_adapter import SubprocessCoderAdapter
11
+
12
+
13
+ class OpenCodeCLIAdapter(SubprocessCoderAdapter):
14
+ """Coder adapter backed by the host ``opencode run`` CLI."""
15
+
16
+ coder_name: str = "opencode-cli"
17
+ binary: str = "opencode"
18
+ model_prefix: str = "opencode-cli/"
19
+ install_hint: str = (
20
+ "Install opencode: curl -fsSL https://opencode.ai/install | bash"
21
+ )
22
+
23
+ def _build_argv(self, prompt: str, project_root: str, model: str) -> List[str]:
24
+ argv = [
25
+ "opencode",
26
+ "run",
27
+ "--dir",
28
+ project_root,
29
+ "--dangerously-skip-permissions",
30
+ prompt,
31
+ ]
32
+ if model:
33
+ argv.extend(["-m", model])
34
+ return argv