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
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
|