vibe-remote 2.1.6__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.
- config/__init__.py +37 -0
- config/paths.py +56 -0
- config/v2_compat.py +74 -0
- config/v2_config.py +206 -0
- config/v2_sessions.py +73 -0
- config/v2_settings.py +115 -0
- core/__init__.py +0 -0
- core/controller.py +736 -0
- core/handlers/__init__.py +13 -0
- core/handlers/command_handlers.py +342 -0
- core/handlers/message_handler.py +365 -0
- core/handlers/session_handler.py +233 -0
- core/handlers/settings_handler.py +362 -0
- modules/__init__.py +0 -0
- modules/agent_router.py +58 -0
- modules/agents/__init__.py +38 -0
- modules/agents/base.py +91 -0
- modules/agents/claude_agent.py +344 -0
- modules/agents/codex_agent.py +368 -0
- modules/agents/opencode_agent.py +2155 -0
- modules/agents/service.py +41 -0
- modules/agents/subagent_router.py +136 -0
- modules/claude_client.py +154 -0
- modules/im/__init__.py +63 -0
- modules/im/base.py +323 -0
- modules/im/factory.py +60 -0
- modules/im/formatters/__init__.py +4 -0
- modules/im/formatters/base_formatter.py +639 -0
- modules/im/formatters/slack_formatter.py +127 -0
- modules/im/slack.py +2091 -0
- modules/session_manager.py +138 -0
- modules/settings_manager.py +587 -0
- vibe/__init__.py +6 -0
- vibe/__main__.py +12 -0
- vibe/_version.py +34 -0
- vibe/api.py +412 -0
- vibe/cli.py +637 -0
- vibe/runtime.py +213 -0
- vibe/service_main.py +101 -0
- vibe/templates/slack_manifest.json +65 -0
- vibe/ui/dist/assets/index-8g3mNwMK.js +35 -0
- vibe/ui/dist/assets/index-M55aMB5R.css +1 -0
- vibe/ui/dist/assets/logo-BzryTZ7u.png +0 -0
- vibe/ui/dist/index.html +17 -0
- vibe/ui/dist/logo.png +0 -0
- vibe/ui/dist/vite.svg +1 -0
- vibe/ui_server.py +346 -0
- vibe_remote-2.1.6.dist-info/METADATA +295 -0
- vibe_remote-2.1.6.dist-info/RECORD +52 -0
- vibe_remote-2.1.6.dist-info/WHEEL +4 -0
- vibe_remote-2.1.6.dist-info/entry_points.txt +2 -0
- vibe_remote-2.1.6.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,2155 @@
|
|
|
1
|
+
"""OpenCode Server API integration as an agent backend."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import socket
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from asyncio.subprocess import Process
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
|
|
17
|
+
from modules.agents.base import AgentRequest, BaseAgent
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
DEFAULT_OPENCODE_PORT = 4096
|
|
22
|
+
DEFAULT_OPENCODE_HOST = "127.0.0.1"
|
|
23
|
+
SERVER_START_TIMEOUT = 15
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_REASONING_FALLBACK_OPTIONS = [
|
|
27
|
+
{"value": "low", "label": "Low"},
|
|
28
|
+
{"value": "medium", "label": "Medium"},
|
|
29
|
+
{"value": "high", "label": "High"},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
_REASONING_VARIANT_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
|
33
|
+
|
|
34
|
+
_REASONING_VARIANT_LABELS = {
|
|
35
|
+
"none": "None",
|
|
36
|
+
"minimal": "Minimal",
|
|
37
|
+
"low": "Low",
|
|
38
|
+
"medium": "Medium",
|
|
39
|
+
"high": "High",
|
|
40
|
+
"xhigh": "Extra High",
|
|
41
|
+
"max": "Max",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_model_key(model_key: Optional[str]) -> tuple[str, str]:
|
|
46
|
+
if not model_key:
|
|
47
|
+
return "", ""
|
|
48
|
+
parts = model_key.split("/", 1)
|
|
49
|
+
if len(parts) != 2:
|
|
50
|
+
return "", ""
|
|
51
|
+
return parts[0], parts[1]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _find_model_variants(opencode_models: dict, target_model: Optional[str]) -> Dict[str, Any]:
|
|
55
|
+
target_provider, target_model_id = _parse_model_key(target_model)
|
|
56
|
+
if not target_provider or not target_model_id or not isinstance(opencode_models, dict):
|
|
57
|
+
return {}
|
|
58
|
+
providers_data = opencode_models.get("providers", [])
|
|
59
|
+
for provider in providers_data:
|
|
60
|
+
provider_id = provider.get("id") or provider.get("provider_id") or provider.get("name")
|
|
61
|
+
if provider_id != target_provider:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
models = provider.get("models", {})
|
|
65
|
+
model_info: Optional[dict] = None
|
|
66
|
+
if isinstance(models, dict):
|
|
67
|
+
candidate = models.get(target_model_id)
|
|
68
|
+
if isinstance(candidate, dict):
|
|
69
|
+
model_info = candidate
|
|
70
|
+
elif isinstance(models, list):
|
|
71
|
+
for entry in models:
|
|
72
|
+
if isinstance(entry, dict) and entry.get("id") == target_model_id:
|
|
73
|
+
model_info = entry
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
if isinstance(model_info, dict):
|
|
77
|
+
variants = model_info.get("variants", {})
|
|
78
|
+
if isinstance(variants, dict):
|
|
79
|
+
return variants
|
|
80
|
+
break
|
|
81
|
+
return {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _build_reasoning_options_from_variants(variants: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
85
|
+
sorted_variants = sorted(
|
|
86
|
+
variants.keys(),
|
|
87
|
+
key=lambda variant: (
|
|
88
|
+
_REASONING_VARIANT_ORDER.index(variant)
|
|
89
|
+
if variant in _REASONING_VARIANT_ORDER
|
|
90
|
+
else len(_REASONING_VARIANT_ORDER),
|
|
91
|
+
variant,
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
"value": variant_key,
|
|
97
|
+
"label": _REASONING_VARIANT_LABELS.get(
|
|
98
|
+
variant_key, variant_key.capitalize()
|
|
99
|
+
),
|
|
100
|
+
}
|
|
101
|
+
for variant_key in sorted_variants
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_reasoning_effort_options(
|
|
106
|
+
opencode_models: dict,
|
|
107
|
+
target_model: Optional[str],
|
|
108
|
+
) -> List[Dict[str, str]]:
|
|
109
|
+
"""Build reasoning effort options from OpenCode model metadata."""
|
|
110
|
+
options = [{"value": "__default__", "label": "(Default)"}]
|
|
111
|
+
variants = _find_model_variants(opencode_models, target_model)
|
|
112
|
+
if variants:
|
|
113
|
+
options.extend(_build_reasoning_options_from_variants(variants))
|
|
114
|
+
return options
|
|
115
|
+
options.extend(_REASONING_FALLBACK_OPTIONS)
|
|
116
|
+
return options
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class OpenCodeServerManager:
|
|
120
|
+
"""Manages a singleton OpenCode server process shared across all working directories."""
|
|
121
|
+
|
|
122
|
+
_instance: Optional["OpenCodeServerManager"] = None
|
|
123
|
+
_class_lock: asyncio.Lock = asyncio.Lock()
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
binary: str = "opencode",
|
|
128
|
+
port: int = DEFAULT_OPENCODE_PORT,
|
|
129
|
+
request_timeout_seconds: int = 60,
|
|
130
|
+
):
|
|
131
|
+
self.binary = binary
|
|
132
|
+
self.port = port
|
|
133
|
+
self.request_timeout_seconds = request_timeout_seconds
|
|
134
|
+
self.host = DEFAULT_OPENCODE_HOST
|
|
135
|
+
self._process: Optional[Process] = None
|
|
136
|
+
self._base_url: Optional[str] = None
|
|
137
|
+
self._http_session: Optional[aiohttp.ClientSession] = None
|
|
138
|
+
self._http_session_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
139
|
+
self._lock = asyncio.Lock()
|
|
140
|
+
self._pid_file = (
|
|
141
|
+
Path(__file__).resolve().parents[2] / "logs" / "opencode_server.json"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
async def get_instance(
|
|
146
|
+
cls,
|
|
147
|
+
binary: str = "opencode",
|
|
148
|
+
port: int = DEFAULT_OPENCODE_PORT,
|
|
149
|
+
request_timeout_seconds: int = 60,
|
|
150
|
+
) -> "OpenCodeServerManager":
|
|
151
|
+
async with cls._class_lock:
|
|
152
|
+
if cls._instance is None:
|
|
153
|
+
cls._instance = cls(
|
|
154
|
+
binary=binary,
|
|
155
|
+
port=port,
|
|
156
|
+
request_timeout_seconds=request_timeout_seconds,
|
|
157
|
+
)
|
|
158
|
+
elif (
|
|
159
|
+
cls._instance.binary != binary
|
|
160
|
+
or cls._instance.port != port
|
|
161
|
+
or cls._instance.request_timeout_seconds != request_timeout_seconds
|
|
162
|
+
):
|
|
163
|
+
logger.warning(
|
|
164
|
+
"OpenCodeServerManager already initialized with "
|
|
165
|
+
f"binary={cls._instance.binary}, port={cls._instance.port}, "
|
|
166
|
+
f"request_timeout_seconds={cls._instance.request_timeout_seconds}; "
|
|
167
|
+
f"ignoring new params binary={binary}, port={port}, "
|
|
168
|
+
f"request_timeout_seconds={request_timeout_seconds}"
|
|
169
|
+
)
|
|
170
|
+
return cls._instance
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def base_url(self) -> str:
|
|
174
|
+
if self._base_url:
|
|
175
|
+
return self._base_url
|
|
176
|
+
return f"http://{self.host}:{self.port}"
|
|
177
|
+
|
|
178
|
+
async def _get_http_session(self) -> aiohttp.ClientSession:
|
|
179
|
+
if self._http_session is None or self._http_session.closed:
|
|
180
|
+
total_timeout: Optional[int] = (
|
|
181
|
+
None
|
|
182
|
+
if self.request_timeout_seconds <= 0
|
|
183
|
+
else self.request_timeout_seconds
|
|
184
|
+
)
|
|
185
|
+
self._http_session = aiohttp.ClientSession(
|
|
186
|
+
timeout=aiohttp.ClientTimeout(total=total_timeout)
|
|
187
|
+
)
|
|
188
|
+
self._http_session_loop = asyncio.get_running_loop()
|
|
189
|
+
return self._http_session
|
|
190
|
+
|
|
191
|
+
def _read_pid_file(self) -> Optional[Dict[str, Any]]:
|
|
192
|
+
try:
|
|
193
|
+
raw = self._pid_file.read_text()
|
|
194
|
+
except FileNotFoundError:
|
|
195
|
+
return None
|
|
196
|
+
except Exception as e:
|
|
197
|
+
logger.debug(f"Failed to read OpenCode pid file: {e}")
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
data = json.loads(raw)
|
|
202
|
+
except Exception as e:
|
|
203
|
+
logger.debug(f"Failed to parse OpenCode pid file: {e}")
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
return data if isinstance(data, dict) else None
|
|
207
|
+
|
|
208
|
+
def _write_pid_file(self, pid: int) -> None:
|
|
209
|
+
try:
|
|
210
|
+
self._pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
payload = {
|
|
212
|
+
"pid": pid,
|
|
213
|
+
"port": self.port,
|
|
214
|
+
"host": self.host,
|
|
215
|
+
"started_at": time.time(),
|
|
216
|
+
}
|
|
217
|
+
self._pid_file.write_text(json.dumps(payload))
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.debug(f"Failed to write OpenCode pid file: {e}")
|
|
220
|
+
|
|
221
|
+
def _clear_pid_file(self) -> None:
|
|
222
|
+
try:
|
|
223
|
+
if self._pid_file.exists():
|
|
224
|
+
self._pid_file.unlink()
|
|
225
|
+
except Exception as e:
|
|
226
|
+
logger.debug(f"Failed to clear OpenCode pid file: {e}")
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _pid_exists(pid: int) -> bool:
|
|
230
|
+
if not isinstance(pid, int) or pid <= 0:
|
|
231
|
+
return False
|
|
232
|
+
try:
|
|
233
|
+
os.kill(pid, 0)
|
|
234
|
+
return True
|
|
235
|
+
except ProcessLookupError:
|
|
236
|
+
return False
|
|
237
|
+
except PermissionError:
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _get_pid_command(pid: int) -> Optional[str]:
|
|
242
|
+
try:
|
|
243
|
+
result = subprocess.run(
|
|
244
|
+
["ps", "-p", str(pid), "-o", "command="],
|
|
245
|
+
capture_output=True,
|
|
246
|
+
text=True,
|
|
247
|
+
check=False,
|
|
248
|
+
)
|
|
249
|
+
except Exception:
|
|
250
|
+
return None
|
|
251
|
+
cmd = (result.stdout or "").strip()
|
|
252
|
+
return cmd or None
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _is_opencode_serve_cmd(command: str, port: int) -> bool:
|
|
256
|
+
if not command:
|
|
257
|
+
return False
|
|
258
|
+
return "opencode" in command and " serve" in command and f"--port={port}" in command
|
|
259
|
+
|
|
260
|
+
def _is_port_available(self) -> bool:
|
|
261
|
+
try:
|
|
262
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
263
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
264
|
+
sock.bind((self.host, self.port))
|
|
265
|
+
return True
|
|
266
|
+
except OSError:
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def _find_opencode_serve_pids(port: int) -> List[int]:
|
|
271
|
+
try:
|
|
272
|
+
result = subprocess.run(
|
|
273
|
+
["ps", "-ax", "-o", "pid=,command="],
|
|
274
|
+
capture_output=True,
|
|
275
|
+
text=True,
|
|
276
|
+
check=False,
|
|
277
|
+
)
|
|
278
|
+
except Exception:
|
|
279
|
+
return []
|
|
280
|
+
|
|
281
|
+
needle = f"--port={port}"
|
|
282
|
+
pids: List[int] = []
|
|
283
|
+
for line in (result.stdout or "").splitlines():
|
|
284
|
+
line = line.strip()
|
|
285
|
+
if not line:
|
|
286
|
+
continue
|
|
287
|
+
parts = line.split(None, 1)
|
|
288
|
+
if len(parts) != 2:
|
|
289
|
+
continue
|
|
290
|
+
pid_str, cmd = parts
|
|
291
|
+
if "opencode" in cmd and " serve" in cmd and needle in cmd:
|
|
292
|
+
try:
|
|
293
|
+
pids.append(int(pid_str))
|
|
294
|
+
except ValueError:
|
|
295
|
+
continue
|
|
296
|
+
return pids
|
|
297
|
+
|
|
298
|
+
async def _terminate_pid(self, pid: int, reason: str) -> None:
|
|
299
|
+
logger.info(f"Stopping OpenCode server pid={pid} ({reason})")
|
|
300
|
+
try:
|
|
301
|
+
os.kill(pid, signal.SIGTERM)
|
|
302
|
+
except ProcessLookupError:
|
|
303
|
+
return
|
|
304
|
+
except Exception as e:
|
|
305
|
+
logger.debug(f"Failed to terminate OpenCode server pid={pid}: {e}")
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
start_time = time.monotonic()
|
|
309
|
+
while time.monotonic() - start_time < 5:
|
|
310
|
+
if not self._pid_exists(pid):
|
|
311
|
+
return
|
|
312
|
+
await asyncio.sleep(0.25)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
os.kill(pid, signal.SIGKILL)
|
|
316
|
+
except Exception:
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
async def _cleanup_orphaned_managed_server(self) -> None:
|
|
320
|
+
info = self._read_pid_file()
|
|
321
|
+
if not info:
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
pid = info.get("pid")
|
|
325
|
+
port = info.get("port")
|
|
326
|
+
if not isinstance(pid, int) or port != self.port:
|
|
327
|
+
self._clear_pid_file()
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
if self._process and self._process.returncode is None and self._process.pid == pid:
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
# Check if the server is healthy before deciding to kill it.
|
|
334
|
+
# If it's healthy, we should adopt it rather than kill it.
|
|
335
|
+
if await self._is_healthy():
|
|
336
|
+
# Update PID file to reflect the actual running process.
|
|
337
|
+
# The PID in the file may be stale if OpenCode was restarted externally.
|
|
338
|
+
actual_pids = self._find_opencode_serve_pids(self.port)
|
|
339
|
+
if actual_pids:
|
|
340
|
+
actual_pid = actual_pids[0]
|
|
341
|
+
if actual_pid != pid:
|
|
342
|
+
logger.info(
|
|
343
|
+
f"Adopting healthy OpenCode server (updating stale PID {pid} -> {actual_pid})"
|
|
344
|
+
)
|
|
345
|
+
self._write_pid_file(actual_pid)
|
|
346
|
+
else:
|
|
347
|
+
logger.info(
|
|
348
|
+
f"Adopting healthy OpenCode server pid={pid} from previous run"
|
|
349
|
+
)
|
|
350
|
+
else:
|
|
351
|
+
# Server is healthy but we can't find its PID - clear stale file
|
|
352
|
+
logger.info(
|
|
353
|
+
f"Adopting healthy OpenCode server (clearing stale PID file, pid={pid} not found)"
|
|
354
|
+
)
|
|
355
|
+
self._clear_pid_file()
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
cmd = self._get_pid_command(pid)
|
|
359
|
+
if cmd and self._is_opencode_serve_cmd(cmd, self.port) and self._pid_exists(pid):
|
|
360
|
+
await self._terminate_pid(pid, reason="orphaned and unhealthy")
|
|
361
|
+
self._clear_pid_file()
|
|
362
|
+
|
|
363
|
+
async def ensure_running(self) -> str:
|
|
364
|
+
async with self._lock:
|
|
365
|
+
await self._cleanup_orphaned_managed_server()
|
|
366
|
+
|
|
367
|
+
if await self._is_healthy():
|
|
368
|
+
# If the server is already running (e.g., started by a previous run),
|
|
369
|
+
# record its PID so shutdown can clean it up.
|
|
370
|
+
if not self._read_pid_file():
|
|
371
|
+
pids = self._find_opencode_serve_pids(self.port)
|
|
372
|
+
if pids:
|
|
373
|
+
pid = pids[0]
|
|
374
|
+
cmd = self._get_pid_command(pid)
|
|
375
|
+
if cmd and self._is_opencode_serve_cmd(cmd, self.port):
|
|
376
|
+
self._write_pid_file(pid)
|
|
377
|
+
|
|
378
|
+
self._base_url = f"http://{self.host}:{self.port}"
|
|
379
|
+
return self.base_url
|
|
380
|
+
|
|
381
|
+
if not self._is_port_available():
|
|
382
|
+
for pid in self._find_opencode_serve_pids(self.port):
|
|
383
|
+
await self._terminate_pid(pid, reason="port occupied but unhealthy")
|
|
384
|
+
await asyncio.sleep(0.5)
|
|
385
|
+
|
|
386
|
+
if not self._is_port_available():
|
|
387
|
+
raise RuntimeError(
|
|
388
|
+
f"OpenCode port {self.port} is already in use but the server is not responding. "
|
|
389
|
+
"Stop the process using this port or set OPENCODE_PORT to a free port."
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
await self._start_server()
|
|
393
|
+
return self.base_url
|
|
394
|
+
|
|
395
|
+
async def _is_healthy(self) -> bool:
|
|
396
|
+
try:
|
|
397
|
+
session = await self._get_http_session()
|
|
398
|
+
async with session.get(
|
|
399
|
+
f"{self.base_url}/global/health", timeout=aiohttp.ClientTimeout(total=5)
|
|
400
|
+
) as resp:
|
|
401
|
+
if resp.status == 200:
|
|
402
|
+
data = await resp.json()
|
|
403
|
+
return data.get("healthy", False)
|
|
404
|
+
except Exception as e:
|
|
405
|
+
logger.debug(f"Health check failed: {e}")
|
|
406
|
+
return False
|
|
407
|
+
|
|
408
|
+
async def _start_server(self) -> None:
|
|
409
|
+
if self._process and self._process.returncode is None:
|
|
410
|
+
try:
|
|
411
|
+
self._process.terminate()
|
|
412
|
+
await asyncio.wait_for(self._process.wait(), timeout=5)
|
|
413
|
+
except Exception:
|
|
414
|
+
self._process.kill()
|
|
415
|
+
|
|
416
|
+
# Ensure any stale pid file is cleared before starting.
|
|
417
|
+
self._clear_pid_file()
|
|
418
|
+
|
|
419
|
+
cmd = [
|
|
420
|
+
self.binary,
|
|
421
|
+
"serve",
|
|
422
|
+
f"--hostname={self.host}",
|
|
423
|
+
f"--port={self.port}",
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
logger.info(f"Starting OpenCode server: {' '.join(cmd)}")
|
|
427
|
+
|
|
428
|
+
env = os.environ.copy()
|
|
429
|
+
env["OPENCODE_ENABLE_EXA"] = "1"
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
433
|
+
*cmd,
|
|
434
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
435
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
436
|
+
env=env,
|
|
437
|
+
)
|
|
438
|
+
if self._process and self._process.pid:
|
|
439
|
+
self._write_pid_file(self._process.pid)
|
|
440
|
+
except FileNotFoundError:
|
|
441
|
+
raise RuntimeError(
|
|
442
|
+
f"OpenCode CLI not found at '{self.binary}'. "
|
|
443
|
+
"Please install OpenCode or set OPENCODE_CLI_PATH."
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
start_time = time.monotonic()
|
|
447
|
+
while time.monotonic() - start_time < SERVER_START_TIMEOUT:
|
|
448
|
+
if await self._is_healthy():
|
|
449
|
+
self._base_url = f"http://{self.host}:{self.port}"
|
|
450
|
+
logger.info(f"OpenCode server started at {self._base_url}")
|
|
451
|
+
return
|
|
452
|
+
await asyncio.sleep(0.5)
|
|
453
|
+
|
|
454
|
+
exit_code = self._process.returncode
|
|
455
|
+
self._clear_pid_file()
|
|
456
|
+
self._process = None
|
|
457
|
+
raise RuntimeError(
|
|
458
|
+
f"OpenCode server failed to start within {SERVER_START_TIMEOUT}s. "
|
|
459
|
+
f"Process exit code: {exit_code}"
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
async def stop(self) -> None:
|
|
463
|
+
async with self._lock:
|
|
464
|
+
if self._http_session:
|
|
465
|
+
await self._http_session.close()
|
|
466
|
+
self._http_session = None
|
|
467
|
+
self._http_session_loop = None
|
|
468
|
+
|
|
469
|
+
# Don't terminate OpenCode server on vibe-remote shutdown.
|
|
470
|
+
# Let it continue running so the next vibe-remote instance can adopt it.
|
|
471
|
+
# This prevents interrupting tasks that are still in progress.
|
|
472
|
+
logger.info("OpenCode server left running for next vibe-remote instance to adopt")
|
|
473
|
+
|
|
474
|
+
# Keep pid_file so next instance knows about the running server.
|
|
475
|
+
self._process = None
|
|
476
|
+
|
|
477
|
+
def stop_sync(self) -> None:
|
|
478
|
+
if self._http_session and self._http_session_loop:
|
|
479
|
+
try:
|
|
480
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
481
|
+
self._http_session.close(), self._http_session_loop
|
|
482
|
+
)
|
|
483
|
+
future.result(timeout=5)
|
|
484
|
+
except Exception as e:
|
|
485
|
+
logger.debug(f"Failed to close OpenCode HTTP session: {e}")
|
|
486
|
+
finally:
|
|
487
|
+
self._http_session = None
|
|
488
|
+
self._http_session_loop = None
|
|
489
|
+
|
|
490
|
+
# Don't terminate OpenCode server on vibe-remote shutdown.
|
|
491
|
+
# Let it continue running so the next vibe-remote instance can adopt it.
|
|
492
|
+
# This prevents interrupting tasks that are still in progress.
|
|
493
|
+
logger.info("OpenCode server left running for next vibe-remote instance to adopt")
|
|
494
|
+
|
|
495
|
+
# Keep pid_file so next instance knows about the running server.
|
|
496
|
+
# Don't clear _process reference - just let it be garbage collected.
|
|
497
|
+
self._process = None
|
|
498
|
+
|
|
499
|
+
@classmethod
|
|
500
|
+
def stop_instance_sync(cls) -> None:
|
|
501
|
+
if cls._instance:
|
|
502
|
+
cls._instance.stop_sync()
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
# Don't terminate OpenCode server on vibe-remote shutdown.
|
|
506
|
+
# Let it continue running so the next vibe-remote instance can adopt it.
|
|
507
|
+
logger.info("OpenCode server left running for next vibe-remote instance to adopt")
|
|
508
|
+
|
|
509
|
+
async def create_session(
|
|
510
|
+
self, directory: str, title: Optional[str] = None
|
|
511
|
+
) -> Dict[str, Any]:
|
|
512
|
+
session = await self._get_http_session()
|
|
513
|
+
body: Dict[str, Any] = {}
|
|
514
|
+
if title:
|
|
515
|
+
body["title"] = title
|
|
516
|
+
|
|
517
|
+
async with session.post(
|
|
518
|
+
f"{self.base_url}/session",
|
|
519
|
+
json=body,
|
|
520
|
+
headers={"x-opencode-directory": directory},
|
|
521
|
+
) as resp:
|
|
522
|
+
if resp.status != 200:
|
|
523
|
+
text = await resp.text()
|
|
524
|
+
raise RuntimeError(f"Failed to create session: {resp.status} {text}")
|
|
525
|
+
return await resp.json()
|
|
526
|
+
|
|
527
|
+
async def send_message(
|
|
528
|
+
self,
|
|
529
|
+
session_id: str,
|
|
530
|
+
directory: str,
|
|
531
|
+
text: str,
|
|
532
|
+
agent: Optional[str] = None,
|
|
533
|
+
model: Optional[Dict[str, str]] = None,
|
|
534
|
+
reasoning_effort: Optional[str] = None,
|
|
535
|
+
) -> Dict[str, Any]:
|
|
536
|
+
session = await self._get_http_session()
|
|
537
|
+
|
|
538
|
+
body: Dict[str, Any] = {
|
|
539
|
+
"parts": [{"type": "text", "text": text}],
|
|
540
|
+
}
|
|
541
|
+
if agent:
|
|
542
|
+
body["agent"] = agent
|
|
543
|
+
if model:
|
|
544
|
+
body["model"] = model
|
|
545
|
+
if reasoning_effort:
|
|
546
|
+
body["reasoningEffort"] = reasoning_effort
|
|
547
|
+
|
|
548
|
+
async with session.post(
|
|
549
|
+
f"{self.base_url}/session/{session_id}/message",
|
|
550
|
+
json=body,
|
|
551
|
+
headers={"x-opencode-directory": directory},
|
|
552
|
+
) as resp:
|
|
553
|
+
if resp.status != 200:
|
|
554
|
+
error_text = await resp.text()
|
|
555
|
+
raise RuntimeError(
|
|
556
|
+
f"Failed to send message: {resp.status} {error_text}"
|
|
557
|
+
)
|
|
558
|
+
return await resp.json()
|
|
559
|
+
|
|
560
|
+
async def prompt_async(
|
|
561
|
+
self,
|
|
562
|
+
session_id: str,
|
|
563
|
+
directory: str,
|
|
564
|
+
text: str,
|
|
565
|
+
agent: Optional[str] = None,
|
|
566
|
+
model: Optional[Dict[str, str]] = None,
|
|
567
|
+
reasoning_effort: Optional[str] = None,
|
|
568
|
+
) -> None:
|
|
569
|
+
"""Start a prompt asynchronously without holding the HTTP request open."""
|
|
570
|
+
session = await self._get_http_session()
|
|
571
|
+
|
|
572
|
+
body: Dict[str, Any] = {
|
|
573
|
+
"parts": [{"type": "text", "text": text}],
|
|
574
|
+
}
|
|
575
|
+
if agent:
|
|
576
|
+
body["agent"] = agent
|
|
577
|
+
if model:
|
|
578
|
+
body["model"] = model
|
|
579
|
+
if reasoning_effort:
|
|
580
|
+
body["reasoningEffort"] = reasoning_effort
|
|
581
|
+
|
|
582
|
+
async with session.post(
|
|
583
|
+
f"{self.base_url}/session/{session_id}/prompt_async",
|
|
584
|
+
json=body,
|
|
585
|
+
headers={"x-opencode-directory": directory},
|
|
586
|
+
) as resp:
|
|
587
|
+
# OpenCode returns 204 when accepted.
|
|
588
|
+
if resp.status not in (200, 204):
|
|
589
|
+
error_text = await resp.text()
|
|
590
|
+
raise RuntimeError(
|
|
591
|
+
f"Failed to start async prompt: {resp.status} {error_text}"
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
async def list_messages(
|
|
595
|
+
self, session_id: str, directory: str
|
|
596
|
+
) -> List[Dict[str, Any]]:
|
|
597
|
+
session = await self._get_http_session()
|
|
598
|
+
async with session.get(
|
|
599
|
+
f"{self.base_url}/session/{session_id}/message",
|
|
600
|
+
headers={"x-opencode-directory": directory},
|
|
601
|
+
) as resp:
|
|
602
|
+
if resp.status != 200:
|
|
603
|
+
error_text = await resp.text()
|
|
604
|
+
raise RuntimeError(
|
|
605
|
+
f"Failed to list messages: {resp.status} {error_text}"
|
|
606
|
+
)
|
|
607
|
+
return await resp.json()
|
|
608
|
+
|
|
609
|
+
async def get_message(
|
|
610
|
+
self, session_id: str, message_id: str, directory: str
|
|
611
|
+
) -> Dict[str, Any]:
|
|
612
|
+
session = await self._get_http_session()
|
|
613
|
+
async with session.get(
|
|
614
|
+
f"{self.base_url}/session/{session_id}/message/{message_id}",
|
|
615
|
+
headers={"x-opencode-directory": directory},
|
|
616
|
+
) as resp:
|
|
617
|
+
if resp.status != 200:
|
|
618
|
+
error_text = await resp.text()
|
|
619
|
+
raise RuntimeError(
|
|
620
|
+
f"Failed to get message: {resp.status} {error_text}"
|
|
621
|
+
)
|
|
622
|
+
return await resp.json()
|
|
623
|
+
|
|
624
|
+
async def list_questions(
|
|
625
|
+
self, directory: Optional[str] = None
|
|
626
|
+
) -> List[Dict[str, Any]]:
|
|
627
|
+
session = await self._get_http_session()
|
|
628
|
+
params = {"directory": directory} if directory else None
|
|
629
|
+
async with session.get(
|
|
630
|
+
f"{self.base_url}/question",
|
|
631
|
+
params=params,
|
|
632
|
+
) as resp:
|
|
633
|
+
if resp.status != 200:
|
|
634
|
+
error_text = await resp.text()
|
|
635
|
+
raise RuntimeError(
|
|
636
|
+
f"Failed to list questions: {resp.status} {error_text}"
|
|
637
|
+
)
|
|
638
|
+
data = await resp.json()
|
|
639
|
+
return data if isinstance(data, list) else []
|
|
640
|
+
|
|
641
|
+
async def reply_question(
|
|
642
|
+
self, question_id: str, directory: str, answers: List[List[str]]
|
|
643
|
+
) -> bool:
|
|
644
|
+
session = await self._get_http_session()
|
|
645
|
+
async with session.post(
|
|
646
|
+
f"{self.base_url}/question/{question_id}/reply",
|
|
647
|
+
params={"directory": directory},
|
|
648
|
+
json={"answers": answers},
|
|
649
|
+
) as resp:
|
|
650
|
+
if resp.status != 200:
|
|
651
|
+
error_text = await resp.text()
|
|
652
|
+
raise RuntimeError(
|
|
653
|
+
f"Failed to reply question: {resp.status} {error_text}"
|
|
654
|
+
)
|
|
655
|
+
data = await resp.json()
|
|
656
|
+
return bool(data)
|
|
657
|
+
|
|
658
|
+
async def abort_session(self, session_id: str, directory: str) -> bool:
|
|
659
|
+
|
|
660
|
+
session = await self._get_http_session()
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
async with session.post(
|
|
664
|
+
f"{self.base_url}/session/{session_id}/abort",
|
|
665
|
+
headers={"x-opencode-directory": directory},
|
|
666
|
+
) as resp:
|
|
667
|
+
return resp.status == 200
|
|
668
|
+
except Exception as e:
|
|
669
|
+
logger.warning(f"Failed to abort session {session_id}: {e}")
|
|
670
|
+
return False
|
|
671
|
+
|
|
672
|
+
async def get_session(
|
|
673
|
+
self, session_id: str, directory: str
|
|
674
|
+
) -> Optional[Dict[str, Any]]:
|
|
675
|
+
session = await self._get_http_session()
|
|
676
|
+
try:
|
|
677
|
+
async with session.get(
|
|
678
|
+
f"{self.base_url}/session/{session_id}",
|
|
679
|
+
headers={"x-opencode-directory": directory},
|
|
680
|
+
) as resp:
|
|
681
|
+
if resp.status == 200:
|
|
682
|
+
return await resp.json()
|
|
683
|
+
return None
|
|
684
|
+
except Exception as e:
|
|
685
|
+
logger.debug(f"Failed to get session {session_id}: {e}")
|
|
686
|
+
return None
|
|
687
|
+
|
|
688
|
+
async def get_available_agents(self, directory: str) -> List[Dict[str, Any]]:
|
|
689
|
+
"""Fetch available agents from OpenCode server.
|
|
690
|
+
|
|
691
|
+
Returns:
|
|
692
|
+
List of agent dicts with 'name', 'mode', 'native', etc.
|
|
693
|
+
"""
|
|
694
|
+
session = await self._get_http_session()
|
|
695
|
+
try:
|
|
696
|
+
async with session.get(
|
|
697
|
+
f"{self.base_url}/agent",
|
|
698
|
+
headers={"x-opencode-directory": directory},
|
|
699
|
+
) as resp:
|
|
700
|
+
if resp.status == 200:
|
|
701
|
+
agents = await resp.json()
|
|
702
|
+
# Filter to primary agents (build, plan), exclude hidden/subagent
|
|
703
|
+
return [
|
|
704
|
+
a for a in agents
|
|
705
|
+
if a.get("mode") == "primary" and not a.get("hidden", False)
|
|
706
|
+
]
|
|
707
|
+
return []
|
|
708
|
+
except Exception as e:
|
|
709
|
+
logger.warning(f"Failed to get available agents: {e}")
|
|
710
|
+
return []
|
|
711
|
+
|
|
712
|
+
async def get_available_models(self, directory: str) -> Dict[str, Any]:
|
|
713
|
+
"""Fetch available models from OpenCode server.
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
Dict with 'providers' list and 'default' dict mapping provider to default model.
|
|
717
|
+
"""
|
|
718
|
+
session = await self._get_http_session()
|
|
719
|
+
try:
|
|
720
|
+
async with session.get(
|
|
721
|
+
f"{self.base_url}/config/providers",
|
|
722
|
+
headers={"x-opencode-directory": directory},
|
|
723
|
+
) as resp:
|
|
724
|
+
if resp.status == 200:
|
|
725
|
+
return await resp.json()
|
|
726
|
+
return {"providers": [], "default": {}}
|
|
727
|
+
except Exception as e:
|
|
728
|
+
logger.warning(f"Failed to get available models: {e}")
|
|
729
|
+
return {"providers": [], "default": {}}
|
|
730
|
+
|
|
731
|
+
async def get_default_config(self, directory: str) -> Dict[str, Any]:
|
|
732
|
+
"""Fetch current default config from OpenCode server.
|
|
733
|
+
|
|
734
|
+
Returns:
|
|
735
|
+
Config dict including 'model' (current default), 'agent' configs, etc.
|
|
736
|
+
"""
|
|
737
|
+
session = await self._get_http_session()
|
|
738
|
+
try:
|
|
739
|
+
async with session.get(
|
|
740
|
+
f"{self.base_url}/config",
|
|
741
|
+
headers={"x-opencode-directory": directory},
|
|
742
|
+
) as resp:
|
|
743
|
+
if resp.status == 200:
|
|
744
|
+
return await resp.json()
|
|
745
|
+
return {}
|
|
746
|
+
except Exception as e:
|
|
747
|
+
logger.warning(f"Failed to get default config: {e}")
|
|
748
|
+
return {}
|
|
749
|
+
|
|
750
|
+
def _load_opencode_user_config(self) -> Optional[Dict[str, Any]]:
|
|
751
|
+
"""Load and cache opencode.json config file.
|
|
752
|
+
|
|
753
|
+
Returns:
|
|
754
|
+
Parsed config dict, or None if file doesn't exist or is invalid.
|
|
755
|
+
"""
|
|
756
|
+
import json
|
|
757
|
+
from pathlib import Path
|
|
758
|
+
|
|
759
|
+
config_path = Path.home() / ".config" / "opencode" / "opencode.json"
|
|
760
|
+
if not config_path.exists():
|
|
761
|
+
return None
|
|
762
|
+
|
|
763
|
+
try:
|
|
764
|
+
with open(config_path, "r") as f:
|
|
765
|
+
config = json.load(f)
|
|
766
|
+
if not isinstance(config, dict):
|
|
767
|
+
logger.warning("opencode.json root is not a dict")
|
|
768
|
+
return None
|
|
769
|
+
return config
|
|
770
|
+
except Exception as e:
|
|
771
|
+
logger.warning(f"Failed to load opencode.json: {e}")
|
|
772
|
+
return None
|
|
773
|
+
|
|
774
|
+
def _get_agent_config(
|
|
775
|
+
self, config: Dict[str, Any], agent_name: Optional[str]
|
|
776
|
+
) -> Dict[str, Any]:
|
|
777
|
+
"""Get agent-specific config from opencode.json with type safety.
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
config: Parsed opencode.json config
|
|
781
|
+
agent_name: Name of the agent, or None
|
|
782
|
+
|
|
783
|
+
Returns:
|
|
784
|
+
Agent config dict, or empty dict if not found/invalid.
|
|
785
|
+
"""
|
|
786
|
+
if not agent_name:
|
|
787
|
+
return {}
|
|
788
|
+
agents = config.get("agent", {})
|
|
789
|
+
if not isinstance(agents, dict):
|
|
790
|
+
return {}
|
|
791
|
+
agent_config = agents.get(agent_name, {})
|
|
792
|
+
if not isinstance(agent_config, dict):
|
|
793
|
+
return {}
|
|
794
|
+
return agent_config
|
|
795
|
+
|
|
796
|
+
def get_agent_model_from_config(self, agent_name: Optional[str]) -> Optional[str]:
|
|
797
|
+
"""Read agent's default model from user's opencode.json config file.
|
|
798
|
+
|
|
799
|
+
This is a workaround for OpenCode server not using agent-specific models
|
|
800
|
+
when only the agent parameter is passed to the message API.
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
agent_name: Name of the agent (e.g., "build", "plan"), or None for global default
|
|
804
|
+
|
|
805
|
+
Returns:
|
|
806
|
+
Model string in "provider/model" format, or None if not configured.
|
|
807
|
+
"""
|
|
808
|
+
config = self._load_opencode_user_config()
|
|
809
|
+
if not config:
|
|
810
|
+
return None
|
|
811
|
+
|
|
812
|
+
# Try agent-specific model first
|
|
813
|
+
agent_config = self._get_agent_config(config, agent_name)
|
|
814
|
+
model = agent_config.get("model")
|
|
815
|
+
if isinstance(model, str) and model:
|
|
816
|
+
logger.debug(f"Found model '{model}' for agent '{agent_name}' in opencode.json")
|
|
817
|
+
return model
|
|
818
|
+
|
|
819
|
+
# Fall back to global default model
|
|
820
|
+
model = config.get("model")
|
|
821
|
+
if isinstance(model, str) and model:
|
|
822
|
+
logger.debug(f"Using global default model '{model}' from opencode.json")
|
|
823
|
+
return model
|
|
824
|
+
return None
|
|
825
|
+
|
|
826
|
+
def get_agent_reasoning_effort_from_config(
|
|
827
|
+
self, agent_name: Optional[str]
|
|
828
|
+
) -> Optional[str]:
|
|
829
|
+
"""Read agent's reasoningEffort from user's opencode.json config file.
|
|
830
|
+
|
|
831
|
+
Args:
|
|
832
|
+
agent_name: Name of the agent (e.g., "build", "plan"), or None for global default
|
|
833
|
+
|
|
834
|
+
Returns:
|
|
835
|
+
reasoningEffort string (e.g., "low", "medium", "high", "xhigh"), or None if not configured.
|
|
836
|
+
"""
|
|
837
|
+
config = self._load_opencode_user_config()
|
|
838
|
+
if not config:
|
|
839
|
+
return None
|
|
840
|
+
|
|
841
|
+
# Valid reasoning effort values
|
|
842
|
+
valid_efforts = {"none", "minimal", "low", "medium", "high", "xhigh", "max"}
|
|
843
|
+
|
|
844
|
+
# Try agent-specific reasoningEffort first
|
|
845
|
+
agent_config = self._get_agent_config(config, agent_name)
|
|
846
|
+
reasoning_effort = agent_config.get("reasoningEffort")
|
|
847
|
+
if isinstance(reasoning_effort, str) and reasoning_effort:
|
|
848
|
+
if reasoning_effort in valid_efforts:
|
|
849
|
+
logger.debug(
|
|
850
|
+
f"Found reasoningEffort '{reasoning_effort}' for agent '{agent_name}' in opencode.json"
|
|
851
|
+
)
|
|
852
|
+
return reasoning_effort
|
|
853
|
+
else:
|
|
854
|
+
logger.debug(f"Ignoring unknown reasoningEffort '{reasoning_effort}' for agent '{agent_name}'")
|
|
855
|
+
|
|
856
|
+
# Fall back to global default reasoningEffort
|
|
857
|
+
reasoning_effort = config.get("reasoningEffort")
|
|
858
|
+
if isinstance(reasoning_effort, str) and reasoning_effort:
|
|
859
|
+
if reasoning_effort in valid_efforts:
|
|
860
|
+
logger.debug(
|
|
861
|
+
f"Using global default reasoningEffort '{reasoning_effort}' from opencode.json"
|
|
862
|
+
)
|
|
863
|
+
return reasoning_effort
|
|
864
|
+
else:
|
|
865
|
+
logger.debug(f"Ignoring unknown global reasoningEffort '{reasoning_effort}'")
|
|
866
|
+
return None
|
|
867
|
+
|
|
868
|
+
def get_default_agent_from_config(self) -> Optional[str]:
|
|
869
|
+
"""Read the default agent from user's opencode.json config file.
|
|
870
|
+
|
|
871
|
+
OpenCode server doesn't automatically use its configured default agent
|
|
872
|
+
when called via API, so we need to read and pass it explicitly.
|
|
873
|
+
|
|
874
|
+
Returns:
|
|
875
|
+
Default agent name (e.g., "build", "plan"), or "build" as fallback.
|
|
876
|
+
"""
|
|
877
|
+
# OpenCode doesn't have an explicit "default agent" config field.
|
|
878
|
+
# Users can override via channel settings.
|
|
879
|
+
# Default to "build" agent which uses the agent's configured model,
|
|
880
|
+
# avoiding fallback to global model which may use restricted credentials.
|
|
881
|
+
return "build"
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
class OpenCodeAgent(BaseAgent):
|
|
885
|
+
"""OpenCode Server API integration via HTTP."""
|
|
886
|
+
|
|
887
|
+
name = "opencode"
|
|
888
|
+
|
|
889
|
+
def __init__(self, controller, opencode_config):
|
|
890
|
+
super().__init__(controller)
|
|
891
|
+
self.opencode_config = opencode_config
|
|
892
|
+
self._server_manager: Optional[OpenCodeServerManager] = None
|
|
893
|
+
self._active_requests: Dict[str, asyncio.Task] = {}
|
|
894
|
+
self._request_sessions: Dict[str, Tuple[str, str, str]] = {}
|
|
895
|
+
self._session_locks: Dict[str, asyncio.Lock] = {}
|
|
896
|
+
self._initialized_sessions: set[str] = set()
|
|
897
|
+
self._pending_questions: Dict[str, Dict[str, Any]] = {}
|
|
898
|
+
|
|
899
|
+
async def _get_server(self) -> OpenCodeServerManager:
|
|
900
|
+
if self._server_manager is None:
|
|
901
|
+
self._server_manager = await OpenCodeServerManager.get_instance(
|
|
902
|
+
binary=self.opencode_config.binary,
|
|
903
|
+
port=self.opencode_config.port,
|
|
904
|
+
request_timeout_seconds=self.opencode_config.request_timeout_seconds,
|
|
905
|
+
)
|
|
906
|
+
return self._server_manager
|
|
907
|
+
|
|
908
|
+
def _get_session_lock(self, base_session_id: str) -> asyncio.Lock:
|
|
909
|
+
if base_session_id not in self._session_locks:
|
|
910
|
+
self._session_locks[base_session_id] = asyncio.Lock()
|
|
911
|
+
return self._session_locks[base_session_id]
|
|
912
|
+
|
|
913
|
+
async def _wait_for_session_idle(
|
|
914
|
+
self,
|
|
915
|
+
server: OpenCodeServerManager,
|
|
916
|
+
session_id: str,
|
|
917
|
+
directory: str,
|
|
918
|
+
timeout_seconds: float = 15.0,
|
|
919
|
+
) -> None:
|
|
920
|
+
deadline = time.monotonic() + timeout_seconds
|
|
921
|
+
while time.monotonic() < deadline:
|
|
922
|
+
try:
|
|
923
|
+
messages = await server.list_messages(session_id, directory)
|
|
924
|
+
except Exception as err:
|
|
925
|
+
logger.debug(f"Failed to poll OpenCode session {session_id} for idle: {err}")
|
|
926
|
+
await asyncio.sleep(1.0)
|
|
927
|
+
continue
|
|
928
|
+
|
|
929
|
+
in_progress = False
|
|
930
|
+
for message in messages:
|
|
931
|
+
info = message.get("info", {})
|
|
932
|
+
if info.get("role") != "assistant":
|
|
933
|
+
continue
|
|
934
|
+
time_info = info.get("time") or {}
|
|
935
|
+
if not time_info.get("completed"):
|
|
936
|
+
in_progress = True
|
|
937
|
+
break
|
|
938
|
+
|
|
939
|
+
if not in_progress:
|
|
940
|
+
return
|
|
941
|
+
|
|
942
|
+
await asyncio.sleep(1.0)
|
|
943
|
+
|
|
944
|
+
logger.warning(
|
|
945
|
+
"OpenCode session %s did not reach idle state within %.1fs",
|
|
946
|
+
session_id,
|
|
947
|
+
timeout_seconds,
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
async def handle_message(self, request: AgentRequest) -> None:
|
|
951
|
+
lock = self._get_session_lock(request.base_session_id)
|
|
952
|
+
open_modal_task: Optional[asyncio.Task] = None
|
|
953
|
+
task: Optional[asyncio.Task] = None
|
|
954
|
+
async with lock:
|
|
955
|
+
pending = self._pending_questions.get(request.base_session_id)
|
|
956
|
+
is_modal_open = pending and request.message == "opencode_question:open_modal"
|
|
957
|
+
is_question_action = pending and request.message.startswith("opencode_question:")
|
|
958
|
+
|
|
959
|
+
existing_task = self._active_requests.get(request.base_session_id)
|
|
960
|
+
if existing_task and not existing_task.done():
|
|
961
|
+
if is_modal_open:
|
|
962
|
+
logger.info(
|
|
963
|
+
"OpenCode session %s running; opening modal without cancel",
|
|
964
|
+
request.base_session_id,
|
|
965
|
+
)
|
|
966
|
+
elif is_question_action:
|
|
967
|
+
logger.info(
|
|
968
|
+
"OpenCode session %s running; cancelling poll task for question reply",
|
|
969
|
+
request.base_session_id,
|
|
970
|
+
)
|
|
971
|
+
existing_task.cancel()
|
|
972
|
+
try:
|
|
973
|
+
await existing_task
|
|
974
|
+
except asyncio.CancelledError:
|
|
975
|
+
pass
|
|
976
|
+
else:
|
|
977
|
+
logger.info(
|
|
978
|
+
"OpenCode session %s already running; cancelling before new request",
|
|
979
|
+
request.base_session_id,
|
|
980
|
+
)
|
|
981
|
+
req_info = self._request_sessions.get(request.base_session_id)
|
|
982
|
+
if req_info:
|
|
983
|
+
server = await self._get_server()
|
|
984
|
+
await server.abort_session(req_info[0], req_info[1])
|
|
985
|
+
await self._wait_for_session_idle(
|
|
986
|
+
server, req_info[0], req_info[1]
|
|
987
|
+
)
|
|
988
|
+
existing_task.cancel()
|
|
989
|
+
try:
|
|
990
|
+
await existing_task
|
|
991
|
+
except asyncio.CancelledError:
|
|
992
|
+
pass
|
|
993
|
+
logger.info(
|
|
994
|
+
"OpenCode session %s cancelled; continuing with new request",
|
|
995
|
+
request.base_session_id,
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
if is_modal_open:
|
|
999
|
+
if hasattr(self.im_client, "open_opencode_question_modal"):
|
|
1000
|
+
open_modal_task = asyncio.create_task(
|
|
1001
|
+
self._open_question_modal(request, pending or {})
|
|
1002
|
+
)
|
|
1003
|
+
else:
|
|
1004
|
+
task = asyncio.create_task(self._process_message(request))
|
|
1005
|
+
self._active_requests[request.base_session_id] = task
|
|
1006
|
+
elif pending:
|
|
1007
|
+
pending_payload = self._pending_questions.pop(request.base_session_id, None)
|
|
1008
|
+
task = asyncio.create_task(
|
|
1009
|
+
self._process_question_answer(request, pending_payload or {})
|
|
1010
|
+
)
|
|
1011
|
+
self._active_requests[request.base_session_id] = task
|
|
1012
|
+
else:
|
|
1013
|
+
task = asyncio.create_task(self._process_message(request))
|
|
1014
|
+
self._active_requests[request.base_session_id] = task
|
|
1015
|
+
|
|
1016
|
+
if open_modal_task:
|
|
1017
|
+
await open_modal_task
|
|
1018
|
+
return
|
|
1019
|
+
|
|
1020
|
+
if not task:
|
|
1021
|
+
return
|
|
1022
|
+
|
|
1023
|
+
try:
|
|
1024
|
+
await task
|
|
1025
|
+
except asyncio.CancelledError:
|
|
1026
|
+
# Task was cancelled (e.g. by /stop), exit gracefully without bubbling
|
|
1027
|
+
logger.debug(f"OpenCode task cancelled for {request.base_session_id}")
|
|
1028
|
+
finally:
|
|
1029
|
+
if self._active_requests.get(request.base_session_id) is task:
|
|
1030
|
+
self._active_requests.pop(request.base_session_id, None)
|
|
1031
|
+
self._request_sessions.pop(request.base_session_id, None)
|
|
1032
|
+
|
|
1033
|
+
def _build_question_selection_note(
|
|
1034
|
+
self, answers_payload: List[List[str]]
|
|
1035
|
+
) -> str:
|
|
1036
|
+
if not answers_payload:
|
|
1037
|
+
return ""
|
|
1038
|
+
|
|
1039
|
+
if len(answers_payload) == 1:
|
|
1040
|
+
joined = ", ".join([value for value in answers_payload[0] if value])
|
|
1041
|
+
return f"已选择:{joined}" if joined else ""
|
|
1042
|
+
|
|
1043
|
+
lines = []
|
|
1044
|
+
for idx, answers in enumerate(answers_payload, start=1):
|
|
1045
|
+
joined = ", ".join([value for value in answers if value])
|
|
1046
|
+
if joined:
|
|
1047
|
+
lines.append(f"Q{idx}: {joined}")
|
|
1048
|
+
if not lines:
|
|
1049
|
+
return ""
|
|
1050
|
+
return "已选择:\n" + "\n".join(lines)
|
|
1051
|
+
|
|
1052
|
+
async def _open_question_modal(
|
|
1053
|
+
self, request: AgentRequest, pending: Dict[str, Any]
|
|
1054
|
+
) -> None:
|
|
1055
|
+
trigger_id = None
|
|
1056
|
+
if request.context.platform_specific:
|
|
1057
|
+
trigger_id = request.context.platform_specific.get("trigger_id")
|
|
1058
|
+
if not trigger_id:
|
|
1059
|
+
await self.im_client.send_message(
|
|
1060
|
+
request.context,
|
|
1061
|
+
"Slack did not provide a trigger_id for the modal. Please reply with a custom message.",
|
|
1062
|
+
)
|
|
1063
|
+
return
|
|
1064
|
+
|
|
1065
|
+
if not hasattr(self.im_client, "open_opencode_question_modal"):
|
|
1066
|
+
await self.im_client.send_message(
|
|
1067
|
+
request.context,
|
|
1068
|
+
"Modal UI is not available. Please reply with a custom message.",
|
|
1069
|
+
)
|
|
1070
|
+
return
|
|
1071
|
+
|
|
1072
|
+
try:
|
|
1073
|
+
await self.im_client.open_opencode_question_modal(
|
|
1074
|
+
trigger_id=trigger_id,
|
|
1075
|
+
context=request.context,
|
|
1076
|
+
pending=pending,
|
|
1077
|
+
)
|
|
1078
|
+
except Exception as err:
|
|
1079
|
+
logger.error(f"Failed to open OpenCode question modal: {err}", exc_info=True)
|
|
1080
|
+
await self.im_client.send_message(
|
|
1081
|
+
request.context,
|
|
1082
|
+
f"Failed to open modal: {err}. Please reply with a custom message.",
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
async def _process_question_answer(
|
|
1086
|
+
self, request: AgentRequest, pending: Dict[str, Any]
|
|
1087
|
+
) -> None:
|
|
1088
|
+
# pending contains: session_id, directory, question_id, questions
|
|
1089
|
+
session_id = pending.get("session_id")
|
|
1090
|
+
directory = pending.get("directory")
|
|
1091
|
+
question_id = pending.get("question_id")
|
|
1092
|
+
option_labels = pending.get("option_labels")
|
|
1093
|
+
option_labels = option_labels if isinstance(option_labels, list) else []
|
|
1094
|
+
question_count = pending.get("question_count")
|
|
1095
|
+
pending_thread_id = pending.get("thread_id")
|
|
1096
|
+
question_message_id = pending.get("prompt_message_id")
|
|
1097
|
+
if pending_thread_id and not request.context.thread_id:
|
|
1098
|
+
request.context.thread_id = pending_thread_id
|
|
1099
|
+
try:
|
|
1100
|
+
question_count_int = int(question_count) if question_count is not None else 1
|
|
1101
|
+
except Exception:
|
|
1102
|
+
question_count_int = 1
|
|
1103
|
+
question_count_int = max(1, question_count_int)
|
|
1104
|
+
|
|
1105
|
+
if not session_id or not directory:
|
|
1106
|
+
await self.controller.emit_agent_message(
|
|
1107
|
+
request.context,
|
|
1108
|
+
"notify",
|
|
1109
|
+
"OpenCode question context is missing; please reply with a custom message.",
|
|
1110
|
+
)
|
|
1111
|
+
return
|
|
1112
|
+
|
|
1113
|
+
server = await self._get_server()
|
|
1114
|
+
|
|
1115
|
+
answer_text = None
|
|
1116
|
+
if request.message.startswith("opencode_question:choose:"):
|
|
1117
|
+
try:
|
|
1118
|
+
choice_idx = int(request.message.rsplit(":", 1)[-1]) - 1
|
|
1119
|
+
if 0 <= choice_idx < len(option_labels):
|
|
1120
|
+
answer_text = str(option_labels[choice_idx]).strip()
|
|
1121
|
+
except Exception:
|
|
1122
|
+
pass
|
|
1123
|
+
|
|
1124
|
+
is_modal_payload = False
|
|
1125
|
+
answers_payload: Optional[List[List[str]]] = None
|
|
1126
|
+
if request.message.startswith("opencode_question:modal:"):
|
|
1127
|
+
is_modal_payload = True
|
|
1128
|
+
try:
|
|
1129
|
+
payload = json.loads(request.message.split(":", 2)[-1])
|
|
1130
|
+
answers = payload.get("answers") if isinstance(payload, dict) else None
|
|
1131
|
+
if isinstance(answers, list) and answers:
|
|
1132
|
+
normalized: List[List[str]] = []
|
|
1133
|
+
for answer in answers:
|
|
1134
|
+
if isinstance(answer, list):
|
|
1135
|
+
normalized.append([str(x) for x in answer if x])
|
|
1136
|
+
elif answer:
|
|
1137
|
+
normalized.append([str(answer)])
|
|
1138
|
+
else:
|
|
1139
|
+
normalized.append([])
|
|
1140
|
+
answers_payload = normalized
|
|
1141
|
+
if normalized:
|
|
1142
|
+
answer_text = " ".join(normalized[0])
|
|
1143
|
+
except Exception:
|
|
1144
|
+
logger.debug("Failed to parse modal answers payload")
|
|
1145
|
+
|
|
1146
|
+
if answer_text is None and request.message.startswith("opencode_question:"):
|
|
1147
|
+
raw_payload = request.message.split(":", 2)[-1]
|
|
1148
|
+
answer_text = raw_payload.strip() if raw_payload else ""
|
|
1149
|
+
# Otherwise user replied with free text.
|
|
1150
|
+
if not answer_text:
|
|
1151
|
+
answer_text = (request.message or "").strip()
|
|
1152
|
+
|
|
1153
|
+
if not answer_text:
|
|
1154
|
+
await self.controller.emit_agent_message(
|
|
1155
|
+
request.context,
|
|
1156
|
+
"notify",
|
|
1157
|
+
"Please reply with an answer.",
|
|
1158
|
+
)
|
|
1159
|
+
return
|
|
1160
|
+
|
|
1161
|
+
if pending:
|
|
1162
|
+
self._pending_questions.pop(request.base_session_id, None)
|
|
1163
|
+
|
|
1164
|
+
if not question_id:
|
|
1165
|
+
# Fallback resolution if the /question listing wasn't available when we first saw the toolcall.
|
|
1166
|
+
call_id = pending.get("call_id")
|
|
1167
|
+
message_id = pending.get("message_id")
|
|
1168
|
+
try:
|
|
1169
|
+
questions = await server.list_questions(directory)
|
|
1170
|
+
if not questions:
|
|
1171
|
+
questions = await server.list_questions()
|
|
1172
|
+
for item in questions:
|
|
1173
|
+
tool = item.get("tool") or {}
|
|
1174
|
+
item_session_id = (
|
|
1175
|
+
item.get("sessionID")
|
|
1176
|
+
or item.get("sessionId")
|
|
1177
|
+
or item.get("session_id")
|
|
1178
|
+
)
|
|
1179
|
+
if item_session_id != session_id:
|
|
1180
|
+
continue
|
|
1181
|
+
if call_id and tool.get("callID") != call_id:
|
|
1182
|
+
continue
|
|
1183
|
+
if message_id and tool.get("messageID") != message_id:
|
|
1184
|
+
continue
|
|
1185
|
+
question_id = item.get("id")
|
|
1186
|
+
questions_obj = item.get("questions")
|
|
1187
|
+
if isinstance(questions_obj, list):
|
|
1188
|
+
question_count_int = max(1, len(questions_obj))
|
|
1189
|
+
break
|
|
1190
|
+
except Exception as err:
|
|
1191
|
+
logger.warning(f"Failed to resolve OpenCode question id: {err}")
|
|
1192
|
+
|
|
1193
|
+
if not question_id:
|
|
1194
|
+
self._pending_questions[request.base_session_id] = pending
|
|
1195
|
+
await self.controller.emit_agent_message(
|
|
1196
|
+
request.context,
|
|
1197
|
+
"notify",
|
|
1198
|
+
"OpenCode is waiting for input, but the question id could not be resolved. Please retry.",
|
|
1199
|
+
)
|
|
1200
|
+
return
|
|
1201
|
+
|
|
1202
|
+
if is_modal_payload and answers_payload is not None:
|
|
1203
|
+
padded = answers_payload[:question_count_int]
|
|
1204
|
+
if len(padded) < question_count_int:
|
|
1205
|
+
padded.extend([[] for _ in range(question_count_int - len(padded))])
|
|
1206
|
+
answers_payload = padded
|
|
1207
|
+
else:
|
|
1208
|
+
answers_payload = [[answer_text] for _ in range(question_count_int)]
|
|
1209
|
+
|
|
1210
|
+
if question_message_id:
|
|
1211
|
+
note = self._build_question_selection_note(answers_payload)
|
|
1212
|
+
fallback_text = pending.get("prompt_text") if isinstance(pending, dict) else None
|
|
1213
|
+
if note:
|
|
1214
|
+
try:
|
|
1215
|
+
updated_text = f"{fallback_text}\n\n{note}" if fallback_text else note
|
|
1216
|
+
await self.im_client.remove_inline_keyboard(
|
|
1217
|
+
request.context,
|
|
1218
|
+
question_message_id,
|
|
1219
|
+
text=updated_text,
|
|
1220
|
+
parse_mode="markdown",
|
|
1221
|
+
)
|
|
1222
|
+
except Exception as err:
|
|
1223
|
+
logger.debug(f"Failed to update question message: {err}")
|
|
1224
|
+
else:
|
|
1225
|
+
try:
|
|
1226
|
+
await self.im_client.remove_inline_keyboard(
|
|
1227
|
+
request.context,
|
|
1228
|
+
question_message_id,
|
|
1229
|
+
text=fallback_text,
|
|
1230
|
+
parse_mode="markdown",
|
|
1231
|
+
)
|
|
1232
|
+
except Exception as err:
|
|
1233
|
+
logger.debug(f"Failed to remove question buttons: {err}")
|
|
1234
|
+
|
|
1235
|
+
try:
|
|
1236
|
+
ok = await server.reply_question(question_id, directory, answers_payload)
|
|
1237
|
+
except Exception as err:
|
|
1238
|
+
logger.warning(f"Failed to reply OpenCode question: {err}")
|
|
1239
|
+
self._pending_questions[request.base_session_id] = pending
|
|
1240
|
+
await self.controller.emit_agent_message(
|
|
1241
|
+
request.context,
|
|
1242
|
+
"notify",
|
|
1243
|
+
f"Failed to submit answer to OpenCode: {err}",
|
|
1244
|
+
)
|
|
1245
|
+
return
|
|
1246
|
+
|
|
1247
|
+
if not ok:
|
|
1248
|
+
self._pending_questions[request.base_session_id] = pending
|
|
1249
|
+
await self.controller.emit_agent_message(
|
|
1250
|
+
request.context,
|
|
1251
|
+
"notify",
|
|
1252
|
+
"OpenCode did not accept the answer. Please retry.",
|
|
1253
|
+
)
|
|
1254
|
+
return
|
|
1255
|
+
|
|
1256
|
+
# After replying, continue polling for final assistant output.
|
|
1257
|
+
baseline_message_ids: set[str] = set()
|
|
1258
|
+
try:
|
|
1259
|
+
baseline_messages = await server.list_messages(session_id, directory)
|
|
1260
|
+
for m in baseline_messages:
|
|
1261
|
+
mid = m.get("info", {}).get("id")
|
|
1262
|
+
if mid:
|
|
1263
|
+
baseline_message_ids.add(mid)
|
|
1264
|
+
except Exception:
|
|
1265
|
+
pass
|
|
1266
|
+
|
|
1267
|
+
poll_interval_seconds = 2.0
|
|
1268
|
+
while True:
|
|
1269
|
+
messages = await server.list_messages(session_id, directory)
|
|
1270
|
+
if messages:
|
|
1271
|
+
last = messages[-1]
|
|
1272
|
+
last_info = last.get("info", {})
|
|
1273
|
+
if (
|
|
1274
|
+
last_info.get("role") == "assistant"
|
|
1275
|
+
and last_info.get("time", {}).get("completed")
|
|
1276
|
+
and last_info.get("finish") != "tool-calls"
|
|
1277
|
+
and last_info.get("id") not in baseline_message_ids
|
|
1278
|
+
):
|
|
1279
|
+
text = self._extract_response_text(last)
|
|
1280
|
+
if text:
|
|
1281
|
+
await self.emit_result_message(
|
|
1282
|
+
request.context,
|
|
1283
|
+
text,
|
|
1284
|
+
subtype="success",
|
|
1285
|
+
started_at=request.started_at,
|
|
1286
|
+
parse_mode="markdown",
|
|
1287
|
+
)
|
|
1288
|
+
else:
|
|
1289
|
+
await self.emit_result_message(
|
|
1290
|
+
request.context,
|
|
1291
|
+
"(No response from OpenCode)",
|
|
1292
|
+
subtype="warning",
|
|
1293
|
+
started_at=request.started_at,
|
|
1294
|
+
)
|
|
1295
|
+
return
|
|
1296
|
+
|
|
1297
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
1298
|
+
|
|
1299
|
+
async def _process_message(self, request: AgentRequest) -> None:
|
|
1300
|
+
try:
|
|
1301
|
+
server = await self._get_server()
|
|
1302
|
+
await server.ensure_running()
|
|
1303
|
+
except Exception as e:
|
|
1304
|
+
logger.error(f"Failed to start OpenCode server: {e}", exc_info=True)
|
|
1305
|
+
await self.controller.emit_agent_message(
|
|
1306
|
+
request.context,
|
|
1307
|
+
"notify",
|
|
1308
|
+
f"Failed to start OpenCode server: {e}",
|
|
1309
|
+
)
|
|
1310
|
+
return
|
|
1311
|
+
|
|
1312
|
+
await self._delete_ack(request)
|
|
1313
|
+
|
|
1314
|
+
if not os.path.exists(request.working_path):
|
|
1315
|
+
os.makedirs(request.working_path, exist_ok=True)
|
|
1316
|
+
|
|
1317
|
+
session_id = self.settings_manager.get_agent_session_id(
|
|
1318
|
+
request.settings_key,
|
|
1319
|
+
request.base_session_id,
|
|
1320
|
+
agent_name=self.name,
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
if not session_id:
|
|
1324
|
+
try:
|
|
1325
|
+
session_data = await server.create_session(
|
|
1326
|
+
directory=request.working_path,
|
|
1327
|
+
title=f"vibe-remote:{request.base_session_id}",
|
|
1328
|
+
)
|
|
1329
|
+
session_id = session_data.get("id")
|
|
1330
|
+
if session_id:
|
|
1331
|
+
self.settings_manager.set_agent_session_mapping(
|
|
1332
|
+
request.settings_key,
|
|
1333
|
+
self.name,
|
|
1334
|
+
request.base_session_id,
|
|
1335
|
+
session_id,
|
|
1336
|
+
)
|
|
1337
|
+
logger.info(
|
|
1338
|
+
f"Created OpenCode session {session_id} for {request.base_session_id}"
|
|
1339
|
+
)
|
|
1340
|
+
except Exception as e:
|
|
1341
|
+
logger.error(f"Failed to create OpenCode session: {e}", exc_info=True)
|
|
1342
|
+
await self.controller.emit_agent_message(
|
|
1343
|
+
request.context,
|
|
1344
|
+
"notify",
|
|
1345
|
+
f"Failed to create OpenCode session: {e}",
|
|
1346
|
+
)
|
|
1347
|
+
return
|
|
1348
|
+
else:
|
|
1349
|
+
existing = await server.get_session(session_id, request.working_path)
|
|
1350
|
+
if not existing:
|
|
1351
|
+
try:
|
|
1352
|
+
session_data = await server.create_session(
|
|
1353
|
+
directory=request.working_path,
|
|
1354
|
+
title=f"vibe-remote:{request.base_session_id}",
|
|
1355
|
+
)
|
|
1356
|
+
session_id = session_data.get("id")
|
|
1357
|
+
if session_id:
|
|
1358
|
+
self.settings_manager.set_agent_session_mapping(
|
|
1359
|
+
request.settings_key,
|
|
1360
|
+
self.name,
|
|
1361
|
+
request.base_session_id,
|
|
1362
|
+
session_id,
|
|
1363
|
+
)
|
|
1364
|
+
logger.info(
|
|
1365
|
+
f"Recreated OpenCode session {session_id} for {request.base_session_id}"
|
|
1366
|
+
)
|
|
1367
|
+
except Exception as e:
|
|
1368
|
+
logger.error(f"Failed to recreate session: {e}", exc_info=True)
|
|
1369
|
+
await self.controller.emit_agent_message(
|
|
1370
|
+
request.context,
|
|
1371
|
+
"notify",
|
|
1372
|
+
f"Failed to create OpenCode session: {e}",
|
|
1373
|
+
)
|
|
1374
|
+
return
|
|
1375
|
+
|
|
1376
|
+
if not session_id:
|
|
1377
|
+
await self.controller.emit_agent_message(
|
|
1378
|
+
request.context,
|
|
1379
|
+
"notify",
|
|
1380
|
+
"Failed to obtain OpenCode session ID",
|
|
1381
|
+
)
|
|
1382
|
+
return
|
|
1383
|
+
|
|
1384
|
+
self._request_sessions[request.base_session_id] = (
|
|
1385
|
+
session_id,
|
|
1386
|
+
request.working_path,
|
|
1387
|
+
request.settings_key,
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
if session_id not in self._initialized_sessions:
|
|
1391
|
+
self._initialized_sessions.add(session_id)
|
|
1392
|
+
system_text = self.im_client.formatter.format_system_message(
|
|
1393
|
+
request.working_path, "init", session_id
|
|
1394
|
+
)
|
|
1395
|
+
await self.controller.emit_agent_message(
|
|
1396
|
+
request.context,
|
|
1397
|
+
"system",
|
|
1398
|
+
system_text,
|
|
1399
|
+
parse_mode="markdown",
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
try:
|
|
1403
|
+
# Get per-channel overrides from settings.json
|
|
1404
|
+
override_agent, override_model, override_reasoning = (
|
|
1405
|
+
self.controller.get_opencode_overrides(request.context)
|
|
1406
|
+
)
|
|
1407
|
+
|
|
1408
|
+
override_agent = request.subagent_name or override_agent
|
|
1409
|
+
if request.subagent_name:
|
|
1410
|
+
override_model = request.subagent_model
|
|
1411
|
+
override_reasoning = request.subagent_reasoning_effort
|
|
1412
|
+
|
|
1413
|
+
if request.subagent_name and not override_model:
|
|
1414
|
+
override_model = server.get_agent_model_from_config(request.subagent_name)
|
|
1415
|
+
if request.subagent_name and not override_reasoning:
|
|
1416
|
+
override_reasoning = server.get_agent_reasoning_effort_from_config(
|
|
1417
|
+
request.subagent_name
|
|
1418
|
+
)
|
|
1419
|
+
|
|
1420
|
+
# Determine agent to use
|
|
1421
|
+
# Priority: 1) channel override or prefix subagent, 2) opencode.json default, 3) None (let OpenCode decide)
|
|
1422
|
+
agent_to_use = override_agent
|
|
1423
|
+
if not agent_to_use:
|
|
1424
|
+
agent_to_use = server.get_default_agent_from_config()
|
|
1425
|
+
|
|
1426
|
+
# Determine model to use
|
|
1427
|
+
# Priority: 1) channel override or prefix subagent, 2) agent's config model, 3) global opencode.json model
|
|
1428
|
+
model_dict = None
|
|
1429
|
+
model_str = override_model
|
|
1430
|
+
if not model_str:
|
|
1431
|
+
# OpenCode server doesn't use agent's configured model when called via API,
|
|
1432
|
+
# so we read it from opencode.json explicitly
|
|
1433
|
+
model_str = server.get_agent_model_from_config(agent_to_use)
|
|
1434
|
+
if model_str:
|
|
1435
|
+
parts = model_str.split("/", 1)
|
|
1436
|
+
if len(parts) == 2:
|
|
1437
|
+
model_dict = {"providerID": parts[0], "modelID": parts[1]}
|
|
1438
|
+
|
|
1439
|
+
# Determine reasoningEffort to use
|
|
1440
|
+
# Priority: 1) channel override or prefix subagent, 2) agent's config, 3) global opencode.json config
|
|
1441
|
+
reasoning_effort = override_reasoning
|
|
1442
|
+
if not reasoning_effort:
|
|
1443
|
+
reasoning_effort = server.get_agent_reasoning_effort_from_config(agent_to_use)
|
|
1444
|
+
|
|
1445
|
+
# Use OpenCode's async prompt API so long-running turns don't hold a single HTTP request.
|
|
1446
|
+
baseline_message_ids: set[str] = set()
|
|
1447
|
+
try:
|
|
1448
|
+
baseline_messages = await server.list_messages(
|
|
1449
|
+
session_id=session_id,
|
|
1450
|
+
directory=request.working_path,
|
|
1451
|
+
)
|
|
1452
|
+
for message in baseline_messages:
|
|
1453
|
+
message_id = message.get("info", {}).get("id")
|
|
1454
|
+
if message_id:
|
|
1455
|
+
baseline_message_ids.add(message_id)
|
|
1456
|
+
except Exception as err:
|
|
1457
|
+
logger.debug(
|
|
1458
|
+
f"Failed to snapshot OpenCode messages before prompt: {err}"
|
|
1459
|
+
)
|
|
1460
|
+
|
|
1461
|
+
await server.prompt_async(
|
|
1462
|
+
session_id=session_id,
|
|
1463
|
+
directory=request.working_path,
|
|
1464
|
+
text=request.message,
|
|
1465
|
+
agent=agent_to_use,
|
|
1466
|
+
model=model_dict,
|
|
1467
|
+
reasoning_effort=reasoning_effort,
|
|
1468
|
+
)
|
|
1469
|
+
|
|
1470
|
+
logger.info(
|
|
1471
|
+
"Starting OpenCode poll loop for %s (thread=%s, cwd=%s)",
|
|
1472
|
+
session_id,
|
|
1473
|
+
request.base_session_id,
|
|
1474
|
+
request.working_path,
|
|
1475
|
+
)
|
|
1476
|
+
|
|
1477
|
+
seen_tool_calls: set[str] = set()
|
|
1478
|
+
emitted_assistant_messages: set[str] = set()
|
|
1479
|
+
poll_interval_seconds = 2.0
|
|
1480
|
+
final_text: Optional[str] = None
|
|
1481
|
+
|
|
1482
|
+
# Error retry tracking
|
|
1483
|
+
error_retry_count = 0
|
|
1484
|
+
error_retry_limit = getattr(self.opencode_config, "error_retry_limit", 1)
|
|
1485
|
+
last_error_message_id: Optional[str] = None
|
|
1486
|
+
|
|
1487
|
+
def _relative_path(path: str) -> str:
|
|
1488
|
+
return self._to_relative_path(path, request.working_path)
|
|
1489
|
+
|
|
1490
|
+
poll_iter = 0
|
|
1491
|
+
while True:
|
|
1492
|
+
poll_iter += 1
|
|
1493
|
+
try:
|
|
1494
|
+
messages = await server.list_messages(
|
|
1495
|
+
session_id=session_id,
|
|
1496
|
+
directory=request.working_path,
|
|
1497
|
+
)
|
|
1498
|
+
if poll_iter % 5 == 0:
|
|
1499
|
+
last_info = messages[-1].get("info", {}) if messages else {}
|
|
1500
|
+
logger.info(
|
|
1501
|
+
"OpenCode poll heartbeat %s iter=%s last=%s role=%s completed=%s finish=%s error=%s",
|
|
1502
|
+
session_id,
|
|
1503
|
+
poll_iter,
|
|
1504
|
+
last_info.get("id"),
|
|
1505
|
+
last_info.get("role"),
|
|
1506
|
+
bool(last_info.get("time", {}).get("completed")),
|
|
1507
|
+
last_info.get("finish"),
|
|
1508
|
+
bool(last_info.get("error")),
|
|
1509
|
+
)
|
|
1510
|
+
except Exception as poll_err:
|
|
1511
|
+
logger.warning(f"Failed to poll OpenCode messages: {poll_err}")
|
|
1512
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
1513
|
+
continue
|
|
1514
|
+
|
|
1515
|
+
for message in messages:
|
|
1516
|
+
info = message.get("info", {})
|
|
1517
|
+
message_id = info.get("id")
|
|
1518
|
+
if not message_id or message_id in baseline_message_ids:
|
|
1519
|
+
continue
|
|
1520
|
+
if info.get("role") != "assistant":
|
|
1521
|
+
continue
|
|
1522
|
+
|
|
1523
|
+
for part in message.get("parts", []) or []:
|
|
1524
|
+
if part.get("type") != "tool":
|
|
1525
|
+
continue
|
|
1526
|
+
call_key = part.get("callID") or part.get("id")
|
|
1527
|
+
if not call_key or call_key in seen_tool_calls:
|
|
1528
|
+
continue
|
|
1529
|
+
tool_name = part.get("tool") or "tool"
|
|
1530
|
+
tool_state = part.get("state") or {}
|
|
1531
|
+
tool_input = tool_state.get("input") or {}
|
|
1532
|
+
|
|
1533
|
+
if tool_name == "question" and tool_state.get("status") != "completed":
|
|
1534
|
+
logger.info(
|
|
1535
|
+
"Detected question toolcall for %s message=%s callID=%s",
|
|
1536
|
+
session_id,
|
|
1537
|
+
message_id,
|
|
1538
|
+
part.get("callID"),
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
# Always render the question text from the tool input so the
|
|
1542
|
+
# user can answer even if /question listing is temporarily empty.
|
|
1543
|
+
qlist = tool_input.get("questions") if isinstance(tool_input, dict) else None
|
|
1544
|
+
qlist = qlist if isinstance(qlist, list) else []
|
|
1545
|
+
|
|
1546
|
+
question_fetch_deadline = time.monotonic() + 60.0
|
|
1547
|
+
question_fetch_delays = [
|
|
1548
|
+
1.0,
|
|
1549
|
+
2.0,
|
|
1550
|
+
3.0,
|
|
1551
|
+
4.0,
|
|
1552
|
+
6.0,
|
|
1553
|
+
8.0,
|
|
1554
|
+
10.0,
|
|
1555
|
+
12.0,
|
|
1556
|
+
14.0,
|
|
1557
|
+
]
|
|
1558
|
+
max_question_attempts = 10
|
|
1559
|
+
|
|
1560
|
+
def _question_delay(attempt_index: int) -> float:
|
|
1561
|
+
if attempt_index >= len(question_fetch_delays):
|
|
1562
|
+
return 0.0
|
|
1563
|
+
remaining = question_fetch_deadline - time.monotonic()
|
|
1564
|
+
if remaining <= 0:
|
|
1565
|
+
return 0.0
|
|
1566
|
+
return min(question_fetch_delays[attempt_index], remaining)
|
|
1567
|
+
|
|
1568
|
+
question_id = None
|
|
1569
|
+
questions_listing: List[Dict[str, Any]] = []
|
|
1570
|
+
list_attempts = 0
|
|
1571
|
+
last_list_err: Optional[Exception] = None
|
|
1572
|
+
for attempt in range(max_question_attempts):
|
|
1573
|
+
list_attempts = attempt + 1
|
|
1574
|
+
try:
|
|
1575
|
+
questions_listing = await server.list_questions(
|
|
1576
|
+
request.working_path
|
|
1577
|
+
)
|
|
1578
|
+
if not questions_listing:
|
|
1579
|
+
questions_listing = await server.list_questions()
|
|
1580
|
+
last_list_err = None
|
|
1581
|
+
except Exception as err:
|
|
1582
|
+
last_list_err = err
|
|
1583
|
+
questions_listing = []
|
|
1584
|
+
if questions_listing:
|
|
1585
|
+
break
|
|
1586
|
+
if attempt < max_question_attempts - 1:
|
|
1587
|
+
delay = _question_delay(attempt)
|
|
1588
|
+
if delay <= 0:
|
|
1589
|
+
break
|
|
1590
|
+
await asyncio.sleep(delay)
|
|
1591
|
+
|
|
1592
|
+
if last_list_err and not questions_listing:
|
|
1593
|
+
logger.warning(
|
|
1594
|
+
f"Failed to fetch questions listing for prompt fallback: {last_list_err}"
|
|
1595
|
+
)
|
|
1596
|
+
|
|
1597
|
+
logger.info(
|
|
1598
|
+
"Question list fetch for %s: dir=%s attempts=%s items=%s",
|
|
1599
|
+
session_id,
|
|
1600
|
+
request.working_path,
|
|
1601
|
+
list_attempts,
|
|
1602
|
+
len(questions_listing),
|
|
1603
|
+
)
|
|
1604
|
+
|
|
1605
|
+
if questions_listing:
|
|
1606
|
+
try:
|
|
1607
|
+
item_sessions = [
|
|
1608
|
+
(
|
|
1609
|
+
item.get("sessionID")
|
|
1610
|
+
or item.get("sessionId")
|
|
1611
|
+
or item.get("session_id")
|
|
1612
|
+
)
|
|
1613
|
+
for item in questions_listing
|
|
1614
|
+
]
|
|
1615
|
+
logger.info(
|
|
1616
|
+
"Question list sessions for %s: %s",
|
|
1617
|
+
session_id,
|
|
1618
|
+
item_sessions,
|
|
1619
|
+
)
|
|
1620
|
+
except Exception:
|
|
1621
|
+
pass
|
|
1622
|
+
|
|
1623
|
+
if questions_listing and not qlist:
|
|
1624
|
+
try:
|
|
1625
|
+
first_questions = questions_listing[0].get("questions")
|
|
1626
|
+
if not isinstance(first_questions, list):
|
|
1627
|
+
first_questions = []
|
|
1628
|
+
listing_preview = {
|
|
1629
|
+
"id": questions_listing[0].get("id"),
|
|
1630
|
+
"sessionID": questions_listing[0].get("sessionID"),
|
|
1631
|
+
"tool": questions_listing[0].get("tool"),
|
|
1632
|
+
"questions_len": len(first_questions),
|
|
1633
|
+
}
|
|
1634
|
+
logger.info(
|
|
1635
|
+
"Question list preview for %s: %s",
|
|
1636
|
+
session_id,
|
|
1637
|
+
listing_preview,
|
|
1638
|
+
)
|
|
1639
|
+
except Exception:
|
|
1640
|
+
pass
|
|
1641
|
+
|
|
1642
|
+
matched_item = None
|
|
1643
|
+
if questions_listing:
|
|
1644
|
+
session_items: List[Dict[str, Any]] = []
|
|
1645
|
+
for item in questions_listing:
|
|
1646
|
+
item_session_id = (
|
|
1647
|
+
item.get("sessionID")
|
|
1648
|
+
or item.get("sessionId")
|
|
1649
|
+
or item.get("session_id")
|
|
1650
|
+
)
|
|
1651
|
+
if item_session_id == session_id:
|
|
1652
|
+
session_items.append(item)
|
|
1653
|
+
|
|
1654
|
+
for item in session_items:
|
|
1655
|
+
tool_meta = item.get("tool") or {}
|
|
1656
|
+
if part.get("callID") and tool_meta.get("callID") == part.get("callID"):
|
|
1657
|
+
matched_item = item
|
|
1658
|
+
break
|
|
1659
|
+
if message_id and tool_meta.get("messageID") == message_id:
|
|
1660
|
+
matched_item = item
|
|
1661
|
+
break
|
|
1662
|
+
|
|
1663
|
+
if matched_item is None and session_items:
|
|
1664
|
+
matched_item = session_items[0]
|
|
1665
|
+
|
|
1666
|
+
if matched_item is None and part.get("callID"):
|
|
1667
|
+
for item in questions_listing:
|
|
1668
|
+
tool_meta = item.get("tool") or {}
|
|
1669
|
+
if tool_meta.get("callID") == part.get("callID"):
|
|
1670
|
+
matched_item = item
|
|
1671
|
+
break
|
|
1672
|
+
|
|
1673
|
+
if matched_item is None and message_id:
|
|
1674
|
+
for item in questions_listing:
|
|
1675
|
+
tool_meta = item.get("tool") or {}
|
|
1676
|
+
if tool_meta.get("messageID") == message_id:
|
|
1677
|
+
matched_item = item
|
|
1678
|
+
break
|
|
1679
|
+
|
|
1680
|
+
if matched_item is None and len(questions_listing) == 1:
|
|
1681
|
+
matched_item = questions_listing[0]
|
|
1682
|
+
|
|
1683
|
+
if matched_item:
|
|
1684
|
+
question_id = matched_item.get("id")
|
|
1685
|
+
if not qlist:
|
|
1686
|
+
q_obj = matched_item.get("questions")
|
|
1687
|
+
if isinstance(q_obj, list):
|
|
1688
|
+
qlist = q_obj
|
|
1689
|
+
|
|
1690
|
+
if not qlist and message_id:
|
|
1691
|
+
msg_attempts = 0
|
|
1692
|
+
last_msg_err: Optional[Exception] = None
|
|
1693
|
+
full_message: Optional[Dict[str, Any]] = None
|
|
1694
|
+
for attempt in range(max_question_attempts):
|
|
1695
|
+
msg_attempts = attempt + 1
|
|
1696
|
+
try:
|
|
1697
|
+
full_message = await server.get_message(
|
|
1698
|
+
session_id=session_id,
|
|
1699
|
+
message_id=message_id,
|
|
1700
|
+
directory=request.working_path,
|
|
1701
|
+
)
|
|
1702
|
+
last_msg_err = None
|
|
1703
|
+
except Exception as err:
|
|
1704
|
+
last_msg_err = err
|
|
1705
|
+
full_message = None
|
|
1706
|
+
|
|
1707
|
+
if full_message:
|
|
1708
|
+
for msg_part in full_message.get("parts", []) or []:
|
|
1709
|
+
if msg_part.get("type") != "tool":
|
|
1710
|
+
continue
|
|
1711
|
+
if msg_part.get("tool") != "question":
|
|
1712
|
+
continue
|
|
1713
|
+
msg_call_id = msg_part.get("callID") or msg_part.get("id")
|
|
1714
|
+
if call_key and msg_call_id and msg_call_id != call_key:
|
|
1715
|
+
continue
|
|
1716
|
+
msg_state = msg_part.get("state") or {}
|
|
1717
|
+
msg_input = msg_state.get("input") or {}
|
|
1718
|
+
msg_questions = (
|
|
1719
|
+
msg_input.get("questions")
|
|
1720
|
+
if isinstance(msg_input, dict)
|
|
1721
|
+
else None
|
|
1722
|
+
)
|
|
1723
|
+
if isinstance(msg_questions, list):
|
|
1724
|
+
qlist = msg_questions
|
|
1725
|
+
break
|
|
1726
|
+
if qlist:
|
|
1727
|
+
break
|
|
1728
|
+
if attempt < max_question_attempts - 1:
|
|
1729
|
+
delay = _question_delay(attempt)
|
|
1730
|
+
if delay <= 0:
|
|
1731
|
+
break
|
|
1732
|
+
await asyncio.sleep(delay)
|
|
1733
|
+
|
|
1734
|
+
if last_msg_err and not qlist:
|
|
1735
|
+
logger.warning(
|
|
1736
|
+
f"Failed to fetch full question input from message {message_id}: {last_msg_err}"
|
|
1737
|
+
)
|
|
1738
|
+
if full_message is not None:
|
|
1739
|
+
parts = full_message.get("parts", []) or []
|
|
1740
|
+
tool_parts = [
|
|
1741
|
+
p for p in parts if p.get("type") == "tool"
|
|
1742
|
+
]
|
|
1743
|
+
logger.info(
|
|
1744
|
+
"Question message fetch for %s: attempts=%s parts=%s tool_parts=%s",
|
|
1745
|
+
session_id,
|
|
1746
|
+
msg_attempts,
|
|
1747
|
+
len(parts),
|
|
1748
|
+
len(tool_parts),
|
|
1749
|
+
)
|
|
1750
|
+
|
|
1751
|
+
option_labels: list[str] = []
|
|
1752
|
+
lines: list[str] = []
|
|
1753
|
+
for q_idx, q in enumerate(qlist or []):
|
|
1754
|
+
if not isinstance(q, dict):
|
|
1755
|
+
continue
|
|
1756
|
+
title = (q.get("header") or f"Question {q_idx + 1}").strip()
|
|
1757
|
+
prompt = (q.get("question") or "").strip()
|
|
1758
|
+
options_raw = q.get("options")
|
|
1759
|
+
options: List[Dict[str, Any]] = (
|
|
1760
|
+
options_raw if isinstance(options_raw, list) else []
|
|
1761
|
+
)
|
|
1762
|
+
|
|
1763
|
+
lines.append(f"**{title}**")
|
|
1764
|
+
if prompt:
|
|
1765
|
+
lines.append(prompt)
|
|
1766
|
+
|
|
1767
|
+
for idx, opt in enumerate(options, start=1):
|
|
1768
|
+
if not isinstance(opt, dict):
|
|
1769
|
+
continue
|
|
1770
|
+
label = (opt.get("label") or f"Option {idx}").strip()
|
|
1771
|
+
desc = (opt.get("description") or "").strip()
|
|
1772
|
+
if q_idx == 0:
|
|
1773
|
+
option_labels.append(label)
|
|
1774
|
+
if desc:
|
|
1775
|
+
lines.append(f"{idx}. *{label}* - {desc}")
|
|
1776
|
+
else:
|
|
1777
|
+
lines.append(f"{idx}. *{label}*")
|
|
1778
|
+
|
|
1779
|
+
if q_idx < len(qlist) - 1:
|
|
1780
|
+
lines.append("")
|
|
1781
|
+
|
|
1782
|
+
first_q = qlist[0] if qlist and isinstance(qlist[0], dict) else {}
|
|
1783
|
+
multiple = bool(first_q.get("multiple"))
|
|
1784
|
+
text = "\n".join(lines)
|
|
1785
|
+
logger.info(
|
|
1786
|
+
"Question prompt built for %s: len=%s preview=%r",
|
|
1787
|
+
session_id,
|
|
1788
|
+
len(text),
|
|
1789
|
+
text[:200],
|
|
1790
|
+
)
|
|
1791
|
+
|
|
1792
|
+
logger.info(
|
|
1793
|
+
"Question prompt data for %s: qlist=%s options=%s question_id=%s call_id=%s",
|
|
1794
|
+
session_id,
|
|
1795
|
+
len(qlist),
|
|
1796
|
+
len(option_labels),
|
|
1797
|
+
question_id,
|
|
1798
|
+
part.get("callID"),
|
|
1799
|
+
)
|
|
1800
|
+
|
|
1801
|
+
# question_id was resolved via /question listing above when possible.
|
|
1802
|
+
if not option_labels:
|
|
1803
|
+
logger.warning(
|
|
1804
|
+
"Question toolcall had no options in tool_input; session=%s question_id=%s",
|
|
1805
|
+
session_id,
|
|
1806
|
+
question_id,
|
|
1807
|
+
)
|
|
1808
|
+
|
|
1809
|
+
question_count = len(qlist) if qlist else 1
|
|
1810
|
+
multiple = bool(first_q.get("multiple"))
|
|
1811
|
+
|
|
1812
|
+
pending_payload = {
|
|
1813
|
+
"session_id": session_id,
|
|
1814
|
+
"directory": request.working_path,
|
|
1815
|
+
"question_id": question_id,
|
|
1816
|
+
"call_id": part.get("callID"),
|
|
1817
|
+
"message_id": message_id,
|
|
1818
|
+
"prompt_message_id": None,
|
|
1819
|
+
"prompt_text": text,
|
|
1820
|
+
"option_labels": option_labels,
|
|
1821
|
+
"question_count": question_count,
|
|
1822
|
+
"multiple": multiple,
|
|
1823
|
+
"questions": qlist,
|
|
1824
|
+
"thread_id": request.context.thread_id,
|
|
1825
|
+
}
|
|
1826
|
+
self._pending_questions[request.base_session_id] = pending_payload
|
|
1827
|
+
|
|
1828
|
+
if multiple or question_count != 1 or len(option_labels) > 10:
|
|
1829
|
+
# Multi-select or multi-question: show full text + modal button
|
|
1830
|
+
modal_keyboard = None
|
|
1831
|
+
if hasattr(self.im_client, "send_message_with_buttons"):
|
|
1832
|
+
from modules.im import InlineButton, InlineKeyboard
|
|
1833
|
+
|
|
1834
|
+
modal_keyboard = InlineKeyboard(
|
|
1835
|
+
buttons=[[InlineButton(text="Choose…", callback_data="opencode_question:open_modal")]]
|
|
1836
|
+
)
|
|
1837
|
+
|
|
1838
|
+
if modal_keyboard:
|
|
1839
|
+
try:
|
|
1840
|
+
logger.info(
|
|
1841
|
+
"Sending modal open button for %s (multiple=%s questions=%s)",
|
|
1842
|
+
session_id,
|
|
1843
|
+
multiple,
|
|
1844
|
+
question_count,
|
|
1845
|
+
)
|
|
1846
|
+
question_message_id = await self.im_client.send_message_with_buttons(
|
|
1847
|
+
request.context,
|
|
1848
|
+
text,
|
|
1849
|
+
modal_keyboard,
|
|
1850
|
+
parse_mode="markdown",
|
|
1851
|
+
)
|
|
1852
|
+
if question_message_id:
|
|
1853
|
+
pending_payload["prompt_message_id"] = question_message_id
|
|
1854
|
+
return
|
|
1855
|
+
except Exception as err:
|
|
1856
|
+
logger.warning(
|
|
1857
|
+
f"Failed to send modal button, falling back to text: {err}",
|
|
1858
|
+
exc_info=True,
|
|
1859
|
+
)
|
|
1860
|
+
|
|
1861
|
+
await self.im_client.send_message(
|
|
1862
|
+
request.context,
|
|
1863
|
+
text,
|
|
1864
|
+
parse_mode="markdown",
|
|
1865
|
+
)
|
|
1866
|
+
return
|
|
1867
|
+
|
|
1868
|
+
# single question + single select + <=10 options -> buttons
|
|
1869
|
+
if (
|
|
1870
|
+
question_count == 1
|
|
1871
|
+
and isinstance(first_q, dict)
|
|
1872
|
+
and not multiple
|
|
1873
|
+
and len(option_labels) <= 10
|
|
1874
|
+
and hasattr(self.im_client, "send_message_with_buttons")
|
|
1875
|
+
):
|
|
1876
|
+
from modules.im import InlineButton, InlineKeyboard
|
|
1877
|
+
|
|
1878
|
+
buttons: list[list[InlineButton]] = []
|
|
1879
|
+
row: list[InlineButton] = []
|
|
1880
|
+
for idx, label in enumerate(option_labels, start=1):
|
|
1881
|
+
callback = f"opencode_question:choose:{idx}"
|
|
1882
|
+
row.append(InlineButton(text=label, callback_data=callback))
|
|
1883
|
+
if len(row) == 5:
|
|
1884
|
+
buttons.append(row)
|
|
1885
|
+
row = []
|
|
1886
|
+
if row:
|
|
1887
|
+
buttons.append(row)
|
|
1888
|
+
|
|
1889
|
+
keyboard = InlineKeyboard(buttons=buttons)
|
|
1890
|
+
try:
|
|
1891
|
+
logger.info(
|
|
1892
|
+
"Sending single-select buttons for %s (options=%s)",
|
|
1893
|
+
session_id,
|
|
1894
|
+
len(option_labels),
|
|
1895
|
+
)
|
|
1896
|
+
question_message_id = await self.im_client.send_message_with_buttons(
|
|
1897
|
+
request.context,
|
|
1898
|
+
text,
|
|
1899
|
+
keyboard,
|
|
1900
|
+
parse_mode="markdown",
|
|
1901
|
+
)
|
|
1902
|
+
if question_message_id:
|
|
1903
|
+
pending_payload["prompt_message_id"] = question_message_id
|
|
1904
|
+
return
|
|
1905
|
+
except Exception as err:
|
|
1906
|
+
logger.warning(
|
|
1907
|
+
f"Failed to send Slack buttons, falling back to text: {err}",
|
|
1908
|
+
exc_info=True,
|
|
1909
|
+
)
|
|
1910
|
+
|
|
1911
|
+
# fallback: text-only
|
|
1912
|
+
try:
|
|
1913
|
+
await self.im_client.send_message(
|
|
1914
|
+
request.context,
|
|
1915
|
+
text,
|
|
1916
|
+
parse_mode="markdown",
|
|
1917
|
+
)
|
|
1918
|
+
except Exception as err:
|
|
1919
|
+
logger.error(
|
|
1920
|
+
f"Failed to send question prompt to Slack: {err}",
|
|
1921
|
+
exc_info=True,
|
|
1922
|
+
)
|
|
1923
|
+
return
|
|
1924
|
+
|
|
1925
|
+
toolcall = self.im_client.formatter.format_toolcall(
|
|
1926
|
+
tool_name,
|
|
1927
|
+
tool_input,
|
|
1928
|
+
get_relative_path=_relative_path,
|
|
1929
|
+
)
|
|
1930
|
+
await self.controller.emit_agent_message(
|
|
1931
|
+
request.context,
|
|
1932
|
+
"toolcall",
|
|
1933
|
+
toolcall,
|
|
1934
|
+
parse_mode="markdown",
|
|
1935
|
+
)
|
|
1936
|
+
seen_tool_calls.add(call_key)
|
|
1937
|
+
|
|
1938
|
+
if (
|
|
1939
|
+
info.get("time", {}).get("completed")
|
|
1940
|
+
and message_id not in emitted_assistant_messages
|
|
1941
|
+
and info.get("finish") == "tool-calls"
|
|
1942
|
+
):
|
|
1943
|
+
text = self._extract_response_text(message)
|
|
1944
|
+
if text:
|
|
1945
|
+
await self.controller.emit_agent_message(
|
|
1946
|
+
request.context,
|
|
1947
|
+
"assistant",
|
|
1948
|
+
text,
|
|
1949
|
+
parse_mode="markdown",
|
|
1950
|
+
)
|
|
1951
|
+
emitted_assistant_messages.add(message_id)
|
|
1952
|
+
|
|
1953
|
+
if messages:
|
|
1954
|
+
last_message = messages[-1]
|
|
1955
|
+
last_info = last_message.get("info", {})
|
|
1956
|
+
last_id = last_info.get("id")
|
|
1957
|
+
|
|
1958
|
+
# Check for error in completed message
|
|
1959
|
+
if (
|
|
1960
|
+
last_id
|
|
1961
|
+
and last_id not in baseline_message_ids
|
|
1962
|
+
and last_info.get("role") == "assistant"
|
|
1963
|
+
and last_info.get("time", {}).get("completed")
|
|
1964
|
+
):
|
|
1965
|
+
msg_error = last_info.get("error")
|
|
1966
|
+
if msg_error and last_id != last_error_message_id:
|
|
1967
|
+
# New error detected
|
|
1968
|
+
last_error_message_id = last_id
|
|
1969
|
+
error_name = msg_error.get("name", "UnknownError")
|
|
1970
|
+
error_data = msg_error.get("data", {})
|
|
1971
|
+
error_msg = error_data.get("message", "") if isinstance(error_data, dict) else str(error_data)
|
|
1972
|
+
|
|
1973
|
+
logger.warning(
|
|
1974
|
+
"OpenCode message error detected for %s: %s - %s (retry %d/%d)",
|
|
1975
|
+
session_id,
|
|
1976
|
+
error_name,
|
|
1977
|
+
error_msg[:200],
|
|
1978
|
+
error_retry_count,
|
|
1979
|
+
error_retry_limit,
|
|
1980
|
+
)
|
|
1981
|
+
|
|
1982
|
+
if error_retry_count < error_retry_limit:
|
|
1983
|
+
error_retry_count += 1
|
|
1984
|
+
logger.info(
|
|
1985
|
+
"Auto-retrying OpenCode session %s with 'continue' (attempt %d/%d)",
|
|
1986
|
+
session_id,
|
|
1987
|
+
error_retry_count,
|
|
1988
|
+
error_retry_limit,
|
|
1989
|
+
)
|
|
1990
|
+
|
|
1991
|
+
# Send "continue" to retry
|
|
1992
|
+
try:
|
|
1993
|
+
await server.prompt_async(
|
|
1994
|
+
session_id=session_id,
|
|
1995
|
+
directory=request.working_path,
|
|
1996
|
+
text="continue",
|
|
1997
|
+
agent=agent_to_use,
|
|
1998
|
+
model=model_dict,
|
|
1999
|
+
reasoning_effort=reasoning_effort,
|
|
2000
|
+
)
|
|
2001
|
+
# Continue polling for new messages
|
|
2002
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
2003
|
+
continue
|
|
2004
|
+
except Exception as retry_err:
|
|
2005
|
+
logger.error(
|
|
2006
|
+
"Failed to send retry 'continue' for %s: %s",
|
|
2007
|
+
session_id,
|
|
2008
|
+
retry_err,
|
|
2009
|
+
)
|
|
2010
|
+
# Fall through to report error
|
|
2011
|
+
|
|
2012
|
+
# Retry limit reached or retry failed, report error to user
|
|
2013
|
+
await self.controller.emit_agent_message(
|
|
2014
|
+
request.context,
|
|
2015
|
+
"notify",
|
|
2016
|
+
f"OpenCode error: {error_name} - {error_msg[:500]}",
|
|
2017
|
+
)
|
|
2018
|
+
final_text = None
|
|
2019
|
+
break
|
|
2020
|
+
|
|
2021
|
+
# No error, check for normal completion
|
|
2022
|
+
if last_info.get("finish") != "tool-calls":
|
|
2023
|
+
# Reset retry count on successful non-error message
|
|
2024
|
+
if not msg_error:
|
|
2025
|
+
error_retry_count = 0
|
|
2026
|
+
final_text = self._extract_response_text(last_message)
|
|
2027
|
+
break
|
|
2028
|
+
|
|
2029
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
2030
|
+
|
|
2031
|
+
if final_text:
|
|
2032
|
+
await self.emit_result_message(
|
|
2033
|
+
request.context,
|
|
2034
|
+
final_text,
|
|
2035
|
+
subtype="success",
|
|
2036
|
+
started_at=request.started_at,
|
|
2037
|
+
parse_mode="markdown",
|
|
2038
|
+
)
|
|
2039
|
+
else:
|
|
2040
|
+
await self.emit_result_message(
|
|
2041
|
+
request.context,
|
|
2042
|
+
"(No response from OpenCode)",
|
|
2043
|
+
subtype="warning",
|
|
2044
|
+
started_at=request.started_at,
|
|
2045
|
+
)
|
|
2046
|
+
|
|
2047
|
+
except asyncio.CancelledError:
|
|
2048
|
+
logger.info(f"OpenCode request cancelled for {request.base_session_id}")
|
|
2049
|
+
raise
|
|
2050
|
+
except Exception as e:
|
|
2051
|
+
error_name = type(e).__name__
|
|
2052
|
+
error_details = str(e).strip()
|
|
2053
|
+
error_text = f"{error_name}: {error_details}" if error_details else error_name
|
|
2054
|
+
|
|
2055
|
+
logger.error(f"OpenCode request failed: {error_text}", exc_info=True)
|
|
2056
|
+
try:
|
|
2057
|
+
await server.abort_session(session_id, request.working_path)
|
|
2058
|
+
except Exception as abort_err:
|
|
2059
|
+
logger.warning(
|
|
2060
|
+
f"Failed to abort OpenCode session after error: {abort_err}"
|
|
2061
|
+
)
|
|
2062
|
+
|
|
2063
|
+
await self.controller.emit_agent_message(
|
|
2064
|
+
request.context,
|
|
2065
|
+
"notify",
|
|
2066
|
+
f"OpenCode request failed: {error_text}",
|
|
2067
|
+
)
|
|
2068
|
+
|
|
2069
|
+
def _extract_response_text(self, response: Dict[str, Any]) -> str:
|
|
2070
|
+
parts = response.get("parts", [])
|
|
2071
|
+
text_parts = []
|
|
2072
|
+
|
|
2073
|
+
for part in parts:
|
|
2074
|
+
part_type = part.get("type")
|
|
2075
|
+
if part_type == "text":
|
|
2076
|
+
text = part.get("text", "")
|
|
2077
|
+
if text:
|
|
2078
|
+
text_parts.append(text)
|
|
2079
|
+
|
|
2080
|
+
if not text_parts and parts:
|
|
2081
|
+
part_types = [p.get("type") for p in parts]
|
|
2082
|
+
logger.debug(f"OpenCode response has no text parts; part types: {part_types}")
|
|
2083
|
+
|
|
2084
|
+
return "\n\n".join(text_parts).strip()
|
|
2085
|
+
|
|
2086
|
+
def _to_relative_path(self, abs_path: str, cwd: str) -> str:
|
|
2087
|
+
"""Convert absolute file paths to relative paths under cwd."""
|
|
2088
|
+
try:
|
|
2089
|
+
abs_path = os.path.abspath(os.path.expanduser(abs_path))
|
|
2090
|
+
cwd = os.path.abspath(os.path.expanduser(cwd))
|
|
2091
|
+
rel_path = os.path.relpath(abs_path, cwd)
|
|
2092
|
+
if rel_path.startswith("../.."):
|
|
2093
|
+
return abs_path
|
|
2094
|
+
if not rel_path.startswith(".") and rel_path != ".":
|
|
2095
|
+
rel_path = "./" + rel_path
|
|
2096
|
+
return rel_path
|
|
2097
|
+
except Exception:
|
|
2098
|
+
return abs_path
|
|
2099
|
+
|
|
2100
|
+
async def handle_stop(self, request: AgentRequest) -> bool:
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
task = self._active_requests.get(request.base_session_id)
|
|
2104
|
+
if not task or task.done():
|
|
2105
|
+
return False
|
|
2106
|
+
|
|
2107
|
+
req_info = self._request_sessions.get(request.base_session_id)
|
|
2108
|
+
if req_info:
|
|
2109
|
+
try:
|
|
2110
|
+
server = await self._get_server()
|
|
2111
|
+
await server.abort_session(req_info[0], req_info[1])
|
|
2112
|
+
except Exception as e:
|
|
2113
|
+
logger.warning(f"Failed to abort OpenCode session: {e}")
|
|
2114
|
+
|
|
2115
|
+
task.cancel()
|
|
2116
|
+
try:
|
|
2117
|
+
await task
|
|
2118
|
+
except asyncio.CancelledError:
|
|
2119
|
+
pass
|
|
2120
|
+
|
|
2121
|
+
await self.controller.emit_agent_message(
|
|
2122
|
+
request.context, "notify", "Terminated OpenCode execution."
|
|
2123
|
+
)
|
|
2124
|
+
logger.info(f"OpenCode session {request.base_session_id} terminated via /stop")
|
|
2125
|
+
return True
|
|
2126
|
+
|
|
2127
|
+
async def clear_sessions(self, settings_key: str) -> int:
|
|
2128
|
+
self.settings_manager.clear_agent_sessions(settings_key, self.name)
|
|
2129
|
+
terminated = 0
|
|
2130
|
+
for base_id, task in list(self._active_requests.items()):
|
|
2131
|
+
req_info = self._request_sessions.get(base_id)
|
|
2132
|
+
if req_info and len(req_info) >= 3 and req_info[2] == settings_key:
|
|
2133
|
+
if not task.done():
|
|
2134
|
+
try:
|
|
2135
|
+
server = await self._get_server()
|
|
2136
|
+
await server.abort_session(req_info[0], req_info[1])
|
|
2137
|
+
except Exception:
|
|
2138
|
+
pass
|
|
2139
|
+
task.cancel()
|
|
2140
|
+
try:
|
|
2141
|
+
await task
|
|
2142
|
+
except asyncio.CancelledError:
|
|
2143
|
+
pass
|
|
2144
|
+
terminated += 1
|
|
2145
|
+
return terminated
|
|
2146
|
+
|
|
2147
|
+
async def _delete_ack(self, request: AgentRequest):
|
|
2148
|
+
ack_id = request.ack_message_id
|
|
2149
|
+
if ack_id and hasattr(self.im_client, "delete_message"):
|
|
2150
|
+
try:
|
|
2151
|
+
await self.im_client.delete_message(request.context.channel_id, ack_id)
|
|
2152
|
+
except Exception as err:
|
|
2153
|
+
logger.debug(f"Could not delete ack message: {err}")
|
|
2154
|
+
finally:
|
|
2155
|
+
request.ack_message_id = None
|