aicortexpy 0.1.0__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.
- aicortexpy/__init__.py +3 -0
- aicortexpy/__main__.py +5 -0
- aicortexpy/acp_client.py +559 -0
- aicortexpy/acp_conn.py +853 -0
- aicortexpy/acp_errors.py +107 -0
- aicortexpy/agent_env.py +88 -0
- aicortexpy/artifact_ws.py +233 -0
- aicortexpy/artifacts.py +67 -0
- aicortexpy/attachments.py +85 -0
- aicortexpy/auto_commit.py +106 -0
- aicortexpy/capabilities.py +92 -0
- aicortexpy/chat_acp_pool.py +321 -0
- aicortexpy/cli.py +133 -0
- aicortexpy/client.py +438 -0
- aicortexpy/codex_config.py +98 -0
- aicortexpy/config.py +298 -0
- aicortexpy/daemon.py +677 -0
- aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
- aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
- aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
- aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
- aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
- aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
- aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
- aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
- aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
- aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
- aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
- aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
- aicortexpy/design_seed.py +59 -0
- aicortexpy/diskusage.py +209 -0
- aicortexpy/execenv.py +264 -0
- aicortexpy/gc.py +286 -0
- aicortexpy/handoff_bundle.py +65 -0
- aicortexpy/health.py +178 -0
- aicortexpy/local_skills.py +157 -0
- aicortexpy/models.py +385 -0
- aicortexpy/openclaw_config.py +153 -0
- aicortexpy/pinned_workdir.py +60 -0
- aicortexpy/platform_info.py +61 -0
- aicortexpy/poisoned.py +44 -0
- aicortexpy/process.py +168 -0
- aicortexpy/procutil.py +61 -0
- aicortexpy/prompt.py +235 -0
- aicortexpy/repo.py +462 -0
- aicortexpy/runner.py +969 -0
- aicortexpy/runtime_config.py +540 -0
- aicortexpy/sandbox.py +264 -0
- aicortexpy/stream.py +422 -0
- aicortexpy/terminal.py +147 -0
- aicortexpy/tunnel.py +82 -0
- aicortexpy/wakeup.py +222 -0
- aicortexpy-0.1.0.dist-info/METADATA +106 -0
- aicortexpy-0.1.0.dist-info/RECORD +58 -0
- aicortexpy-0.1.0.dist-info/WHEEL +5 -0
- aicortexpy-0.1.0.dist-info/top_level.txt +1 -0
aicortexpy/acp_conn.py
ADDED
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
"""Long-lived / one-shot ACP connections for Cursor, Kiro, Hermes, Kimi, Dsh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, Protocol
|
|
15
|
+
|
|
16
|
+
from .acp_client import (
|
|
17
|
+
ACPClient,
|
|
18
|
+
PromptResult,
|
|
19
|
+
dsh_tool_name,
|
|
20
|
+
extract_session_id,
|
|
21
|
+
hermes_tool_name_from_title,
|
|
22
|
+
kimi_tool_name,
|
|
23
|
+
kiro_tool_name,
|
|
24
|
+
resolve_resumed_session_id,
|
|
25
|
+
)
|
|
26
|
+
from .acp_errors import ACPProviderErrorSniffer, promote_acp_result_on_provider_error
|
|
27
|
+
from .procutil import kill_process_tree, popen_kwargs_for_killable_group
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger("aicortexpy.acp")
|
|
30
|
+
|
|
31
|
+
HANDSHAKE_TIMEOUT = 120.0
|
|
32
|
+
PROMPT_TIMEOUT = 20 * 60.0
|
|
33
|
+
STDOUT_MAX_LINE = 10 * 1024 * 1024 # match Go scanner max token
|
|
34
|
+
|
|
35
|
+
MessageCB = Callable[[dict[str, Any]], None]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ACPPromptResult:
|
|
40
|
+
status: str
|
|
41
|
+
output: str
|
|
42
|
+
error: str = ""
|
|
43
|
+
session_id: str = ""
|
|
44
|
+
usage: dict[str, int] | None = None
|
|
45
|
+
duration_ms: int = 0
|
|
46
|
+
messages: list[dict[str, Any]] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ACPChatConn(Protocol):
|
|
50
|
+
def session_id(self) -> str: ...
|
|
51
|
+
def run_prompt(
|
|
52
|
+
self,
|
|
53
|
+
prompt: str,
|
|
54
|
+
*,
|
|
55
|
+
timeout: float = PROMPT_TIMEOUT,
|
|
56
|
+
system_prompt: str = "",
|
|
57
|
+
on_message: MessageCB | None = None,
|
|
58
|
+
) -> ACPPromptResult: ...
|
|
59
|
+
def close(self) -> None: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
ACP_ONESHOT_PROVIDERS = frozenset({"kiro", "hermes", "kimi", "dsh"})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cursor_acp_supported(executable: str) -> bool:
|
|
66
|
+
# Go probes `acp --help` without yolo flags.
|
|
67
|
+
return _acp_help_ok(executable or "cursor-agent", extra_before=["acp", "--help"])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def kiro_acp_supported(executable: str) -> bool:
|
|
71
|
+
return _acp_help_ok(executable or "kiro-cli", extra_before=["acp", "--help"])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _acp_help_ok(executable: str, *, extra_before: list[str]) -> bool:
|
|
75
|
+
path = shutil.which(executable) or executable
|
|
76
|
+
args = [path, *extra_before]
|
|
77
|
+
try:
|
|
78
|
+
proc = subprocess.run(
|
|
79
|
+
args,
|
|
80
|
+
capture_output=True,
|
|
81
|
+
timeout=5,
|
|
82
|
+
check=False,
|
|
83
|
+
**_hide_window(),
|
|
84
|
+
)
|
|
85
|
+
return proc.returncode == 0
|
|
86
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _hide_window() -> dict:
|
|
91
|
+
if sys.platform != "win32":
|
|
92
|
+
return {}
|
|
93
|
+
startup = subprocess.STARTUPINFO()
|
|
94
|
+
startup.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
95
|
+
return {"startupinfo": startup}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def choose_cursor_invocation(executable: str, args: list[str]) -> tuple[str, list[str]]:
|
|
99
|
+
"""On Windows, route cursor-agent.cmd/.bat through powershell -File .ps1.
|
|
100
|
+
|
|
101
|
+
Matches Go chooseCursorInvocation — avoids cmd.exe %* mangling long argv.
|
|
102
|
+
"""
|
|
103
|
+
path = shutil.which(executable) or executable
|
|
104
|
+
if sys.platform != "win32":
|
|
105
|
+
return path, args
|
|
106
|
+
ext = Path(path).suffix.lower()
|
|
107
|
+
if ext not in (".cmd", ".bat"):
|
|
108
|
+
return path, args
|
|
109
|
+
ps1 = Path(path).with_name("cursor-agent.ps1")
|
|
110
|
+
if not ps1.is_file():
|
|
111
|
+
return path, args
|
|
112
|
+
ps_exe = ""
|
|
113
|
+
for name in ("pwsh.exe", "powershell.exe"):
|
|
114
|
+
found = shutil.which(name)
|
|
115
|
+
if found:
|
|
116
|
+
ps_exe = found
|
|
117
|
+
break
|
|
118
|
+
if not ps_exe:
|
|
119
|
+
root = os.environ.get("SystemRoot") or r"C:\Windows"
|
|
120
|
+
candidate = Path(root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
|
121
|
+
if candidate.is_file():
|
|
122
|
+
ps_exe = str(candidate)
|
|
123
|
+
if not ps_exe:
|
|
124
|
+
return path, args
|
|
125
|
+
log.info(
|
|
126
|
+
"cursor-agent: routing through powershell -File to preserve argv tokens ps1=%s",
|
|
127
|
+
ps1,
|
|
128
|
+
)
|
|
129
|
+
return ps_exe, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(ps1), *args]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _filter_blocked(custom_args: list[str], blocked: set[str], value_flags: set[str] | None = None) -> list[str]:
|
|
133
|
+
value_flags = value_flags or set()
|
|
134
|
+
out: list[str] = []
|
|
135
|
+
skip_next = False
|
|
136
|
+
for a in custom_args:
|
|
137
|
+
if skip_next:
|
|
138
|
+
skip_next = False
|
|
139
|
+
continue
|
|
140
|
+
if a in blocked:
|
|
141
|
+
if a in value_flags or a in ("--sandbox", "--trust-tools", "--profile", "-p"):
|
|
142
|
+
skip_next = True
|
|
143
|
+
continue
|
|
144
|
+
out.append(a)
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class _BaseACPConn:
|
|
149
|
+
provider: str = ""
|
|
150
|
+
dual_prompt_fields: bool = False # kiro: send content + prompt
|
|
151
|
+
model_via_config_option: bool = False # dsh
|
|
152
|
+
require_set_model: bool = False # cursor / kiro pooled Open
|
|
153
|
+
soft_set_model: bool = False
|
|
154
|
+
resume_method: str = "session/load" # kimi uses session/resume
|
|
155
|
+
resume_include_mcp: bool = True
|
|
156
|
+
|
|
157
|
+
def __init__(self) -> None:
|
|
158
|
+
self._proc: subprocess.Popen[bytes] | None = None
|
|
159
|
+
self._client: ACPClient | None = None
|
|
160
|
+
self._session_id = ""
|
|
161
|
+
self._model = ""
|
|
162
|
+
self._closed = False
|
|
163
|
+
self._reader_done = threading.Event()
|
|
164
|
+
self._stderr_done = threading.Event()
|
|
165
|
+
self._lock = threading.Lock()
|
|
166
|
+
self._sniffer: ACPProviderErrorSniffer | None = None
|
|
167
|
+
self._stdin_text = None
|
|
168
|
+
|
|
169
|
+
def session_id(self) -> str:
|
|
170
|
+
return self._session_id
|
|
171
|
+
|
|
172
|
+
def close(self) -> None:
|
|
173
|
+
with self._lock:
|
|
174
|
+
if self._closed:
|
|
175
|
+
return
|
|
176
|
+
self._closed = True
|
|
177
|
+
client = self._client
|
|
178
|
+
proc = self._proc
|
|
179
|
+
self._client = None
|
|
180
|
+
self._proc = None
|
|
181
|
+
if self._stdin_text is not None:
|
|
182
|
+
try:
|
|
183
|
+
self._stdin_text.close()
|
|
184
|
+
except OSError:
|
|
185
|
+
pass
|
|
186
|
+
if proc and proc.pid:
|
|
187
|
+
kill_process_tree(proc.pid)
|
|
188
|
+
try:
|
|
189
|
+
proc.wait(timeout=5)
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
self._reader_done.wait(timeout=5)
|
|
193
|
+
self._stderr_done.wait(timeout=5)
|
|
194
|
+
if client:
|
|
195
|
+
client.close_all_pending(RuntimeError(f"{self.provider} acp connection closed"))
|
|
196
|
+
|
|
197
|
+
def run_prompt(
|
|
198
|
+
self,
|
|
199
|
+
prompt: str,
|
|
200
|
+
*,
|
|
201
|
+
timeout: float = PROMPT_TIMEOUT,
|
|
202
|
+
system_prompt: str = "",
|
|
203
|
+
on_message: MessageCB | None = None,
|
|
204
|
+
idle_watchdog: float = 0,
|
|
205
|
+
cancel_event: threading.Event | None = None,
|
|
206
|
+
) -> ACPPromptResult:
|
|
207
|
+
if self._closed or not self._client or not self._session_id:
|
|
208
|
+
return ACPPromptResult(
|
|
209
|
+
status="failed",
|
|
210
|
+
output="",
|
|
211
|
+
error=f"{self.provider} acp connection closed",
|
|
212
|
+
)
|
|
213
|
+
client = self._client
|
|
214
|
+
output_parts: list[str] = []
|
|
215
|
+
messages: list[dict[str, Any]] = []
|
|
216
|
+
prompt_done = threading.Event()
|
|
217
|
+
done_result: list[PromptResult] = []
|
|
218
|
+
streaming = {"on": False}
|
|
219
|
+
last_activity = {"t": time.time()}
|
|
220
|
+
in_flight_tools = {"n": 0}
|
|
221
|
+
|
|
222
|
+
def emit(msg: dict[str, Any]) -> None:
|
|
223
|
+
if not streaming["on"]:
|
|
224
|
+
return
|
|
225
|
+
last_activity["t"] = time.time()
|
|
226
|
+
typ = msg.get("type")
|
|
227
|
+
if typ == "text":
|
|
228
|
+
output_parts.append(str(msg.get("content") or ""))
|
|
229
|
+
elif typ == "tool-use":
|
|
230
|
+
in_flight_tools["n"] += 1
|
|
231
|
+
elif typ == "tool-result":
|
|
232
|
+
in_flight_tools["n"] = max(0, in_flight_tools["n"] - 1)
|
|
233
|
+
messages.append(msg)
|
|
234
|
+
if on_message:
|
|
235
|
+
try:
|
|
236
|
+
on_message(msg)
|
|
237
|
+
except Exception as e:
|
|
238
|
+
log.debug("on_message failed: %s", e)
|
|
239
|
+
|
|
240
|
+
def on_prompt_done(pr: PromptResult) -> None:
|
|
241
|
+
if not streaming["on"]:
|
|
242
|
+
return
|
|
243
|
+
last_activity["t"] = time.time()
|
|
244
|
+
done_result.append(pr)
|
|
245
|
+
prompt_done.set()
|
|
246
|
+
|
|
247
|
+
prev_msg, prev_done, prev_accept = (
|
|
248
|
+
client.on_message,
|
|
249
|
+
client.on_prompt_done,
|
|
250
|
+
client.accept_notification,
|
|
251
|
+
)
|
|
252
|
+
client.on_message = emit
|
|
253
|
+
client.on_prompt_done = on_prompt_done
|
|
254
|
+
client.accept_notification = lambda _t: streaming["on"]
|
|
255
|
+
|
|
256
|
+
user_text = prompt
|
|
257
|
+
if system_prompt:
|
|
258
|
+
user_text = system_prompt + "\n\n---\n\n" + prompt
|
|
259
|
+
blocks = [{"type": "text", "text": user_text}]
|
|
260
|
+
params: dict[str, Any] = {
|
|
261
|
+
"sessionId": self._session_id,
|
|
262
|
+
"prompt": blocks,
|
|
263
|
+
}
|
|
264
|
+
if self.dual_prompt_fields:
|
|
265
|
+
params["content"] = blocks
|
|
266
|
+
|
|
267
|
+
# Go pooled RunPrompt caps at 20 minutes regardless of AgentTimeout.
|
|
268
|
+
if timeout <= 0 or timeout > PROMPT_TIMEOUT:
|
|
269
|
+
timeout = PROMPT_TIMEOUT
|
|
270
|
+
|
|
271
|
+
start = time.time()
|
|
272
|
+
status = "completed"
|
|
273
|
+
error = ""
|
|
274
|
+
stop_watch = threading.Event()
|
|
275
|
+
|
|
276
|
+
def _watch() -> None:
|
|
277
|
+
while not stop_watch.wait(0.25):
|
|
278
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
279
|
+
try:
|
|
280
|
+
self.close()
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
return
|
|
284
|
+
if idle_watchdog > 0 and streaming["on"]:
|
|
285
|
+
silent = time.time() - last_activity["t"]
|
|
286
|
+
if silent >= idle_watchdog and in_flight_tools["n"] == 0:
|
|
287
|
+
log.warning(
|
|
288
|
+
"%s acp idle watchdog fired after %.0fs",
|
|
289
|
+
self.provider,
|
|
290
|
+
idle_watchdog,
|
|
291
|
+
)
|
|
292
|
+
try:
|
|
293
|
+
self.close()
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
watcher = threading.Thread(target=_watch, name=f"{self.provider}-acp-watch", daemon=True)
|
|
299
|
+
watcher.start()
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
with client._usage_lock:
|
|
303
|
+
client.usage.input_tokens = 0
|
|
304
|
+
client.usage.output_tokens = 0
|
|
305
|
+
client.usage.cache_read_tokens = 0
|
|
306
|
+
streaming["on"] = True
|
|
307
|
+
last_activity["t"] = time.time()
|
|
308
|
+
client.request("session/prompt", params, timeout=timeout)
|
|
309
|
+
prompt_done.wait(timeout=0.05)
|
|
310
|
+
if done_result and done_result[0].stop_reason == "cancelled":
|
|
311
|
+
status = "aborted"
|
|
312
|
+
error = f"{self.provider} acp cancelled the prompt"
|
|
313
|
+
except TimeoutError:
|
|
314
|
+
status = "timeout"
|
|
315
|
+
error = f"{self.provider} acp timed out after {timeout:.0f}s"
|
|
316
|
+
except Exception as e:
|
|
317
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
318
|
+
status = "aborted"
|
|
319
|
+
error = "cancelled"
|
|
320
|
+
elif idle_watchdog > 0 and (time.time() - last_activity["t"]) >= idle_watchdog:
|
|
321
|
+
status = "idle_watchdog"
|
|
322
|
+
error = f"{self.provider} acp idle for {idle_watchdog:.0f}s"
|
|
323
|
+
else:
|
|
324
|
+
status = "failed"
|
|
325
|
+
error = f"{self.provider} acp session/prompt failed: {e}"
|
|
326
|
+
finally:
|
|
327
|
+
streaming["on"] = False
|
|
328
|
+
stop_watch.set()
|
|
329
|
+
watcher.join(timeout=1)
|
|
330
|
+
client.on_message = prev_msg
|
|
331
|
+
client.on_prompt_done = prev_done
|
|
332
|
+
client.accept_notification = prev_accept
|
|
333
|
+
|
|
334
|
+
# Drain stderr sniffer before promote (Go joins stderrDone).
|
|
335
|
+
self._stderr_done.wait(timeout=2)
|
|
336
|
+
output = "".join(output_parts)
|
|
337
|
+
status, error = promote_acp_result_on_provider_error(
|
|
338
|
+
status, error, output, self._sniffer
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
usage = None
|
|
342
|
+
with client._usage_lock:
|
|
343
|
+
u = client.usage
|
|
344
|
+
if done_result:
|
|
345
|
+
u.input_tokens += done_result[0].usage.input_tokens
|
|
346
|
+
u.output_tokens += done_result[0].usage.output_tokens
|
|
347
|
+
u.cache_read_tokens += done_result[0].usage.cache_read_tokens
|
|
348
|
+
if u.input_tokens or u.output_tokens or u.cache_read_tokens:
|
|
349
|
+
usage = {
|
|
350
|
+
"input_tokens": u.input_tokens,
|
|
351
|
+
"output_tokens": u.output_tokens,
|
|
352
|
+
"cache_read_tokens": u.cache_read_tokens,
|
|
353
|
+
}
|
|
354
|
+
return ACPPromptResult(
|
|
355
|
+
status=status,
|
|
356
|
+
output=output,
|
|
357
|
+
error=error,
|
|
358
|
+
session_id=self._session_id,
|
|
359
|
+
usage=usage,
|
|
360
|
+
duration_ms=int((time.time() - start) * 1000),
|
|
361
|
+
messages=messages,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def _start_process(
|
|
365
|
+
self,
|
|
366
|
+
argv: list[str],
|
|
367
|
+
cwd: str,
|
|
368
|
+
env: dict[str, str] | None,
|
|
369
|
+
*,
|
|
370
|
+
tool_name_fn: Callable[[str, str], str],
|
|
371
|
+
) -> None:
|
|
372
|
+
kwargs = popen_kwargs_for_killable_group()
|
|
373
|
+
kwargs.update(_hide_window())
|
|
374
|
+
self._sniffer = ACPProviderErrorSniffer(self.provider)
|
|
375
|
+
# Binary pipes + manual decode so we can raise the line-size limit.
|
|
376
|
+
self._proc = subprocess.Popen(
|
|
377
|
+
argv,
|
|
378
|
+
stdin=subprocess.PIPE,
|
|
379
|
+
stdout=subprocess.PIPE,
|
|
380
|
+
stderr=subprocess.PIPE,
|
|
381
|
+
cwd=cwd or None,
|
|
382
|
+
env=env,
|
|
383
|
+
bufsize=0,
|
|
384
|
+
**kwargs,
|
|
385
|
+
)
|
|
386
|
+
assert self._proc.stdin and self._proc.stdout and self._proc.stderr
|
|
387
|
+
import io
|
|
388
|
+
|
|
389
|
+
self._stdin_text = io.TextIOWrapper(
|
|
390
|
+
self._proc.stdin,
|
|
391
|
+
encoding="utf-8",
|
|
392
|
+
errors="replace",
|
|
393
|
+
write_through=True,
|
|
394
|
+
line_buffering=True,
|
|
395
|
+
)
|
|
396
|
+
self._client = ACPClient(self._stdin_text, tool_name_fn=tool_name_fn)
|
|
397
|
+
threading.Thread(target=self._read_stdout, name=f"{self.provider}-acp-out", daemon=True).start()
|
|
398
|
+
threading.Thread(target=self._read_stderr, name=f"{self.provider}-acp-err", daemon=True).start()
|
|
399
|
+
log.info("%s acp started pid=%s cwd=%s", self.provider, self._proc.pid, cwd)
|
|
400
|
+
|
|
401
|
+
def _read_stdout(self) -> None:
|
|
402
|
+
try:
|
|
403
|
+
assert self._proc and self._proc.stdout and self._client
|
|
404
|
+
buf = b""
|
|
405
|
+
while True:
|
|
406
|
+
chunk = self._proc.stdout.read(65536)
|
|
407
|
+
if not chunk:
|
|
408
|
+
break
|
|
409
|
+
buf += chunk
|
|
410
|
+
while True:
|
|
411
|
+
nl = buf.find(b"\n")
|
|
412
|
+
if nl < 0:
|
|
413
|
+
if len(buf) > STDOUT_MAX_LINE:
|
|
414
|
+
log.warning(
|
|
415
|
+
"%s acp stdout line exceeded %s bytes; dropping",
|
|
416
|
+
self.provider,
|
|
417
|
+
STDOUT_MAX_LINE,
|
|
418
|
+
)
|
|
419
|
+
buf = b""
|
|
420
|
+
break
|
|
421
|
+
line = buf[:nl].decode("utf-8", errors="replace")
|
|
422
|
+
buf = buf[nl + 1 :]
|
|
423
|
+
self._client.handle_line(line)
|
|
424
|
+
except Exception as e:
|
|
425
|
+
log.debug("%s acp stdout reader ended: %s", self.provider, e)
|
|
426
|
+
finally:
|
|
427
|
+
if self._client:
|
|
428
|
+
self._client.close_all_pending(
|
|
429
|
+
RuntimeError(f"{self.provider} acp process exited")
|
|
430
|
+
)
|
|
431
|
+
self._reader_done.set()
|
|
432
|
+
|
|
433
|
+
def _read_stderr(self) -> None:
|
|
434
|
+
try:
|
|
435
|
+
assert self._proc and self._proc.stderr
|
|
436
|
+
while True:
|
|
437
|
+
chunk = self._proc.stderr.read(4096)
|
|
438
|
+
if not chunk:
|
|
439
|
+
break
|
|
440
|
+
if self._sniffer:
|
|
441
|
+
self._sniffer.write(chunk)
|
|
442
|
+
text = chunk.decode("utf-8", errors="replace")
|
|
443
|
+
for line in text.splitlines():
|
|
444
|
+
line = line.rstrip()
|
|
445
|
+
if line:
|
|
446
|
+
log.info("[%s:acp:stderr] %s", self.provider, line)
|
|
447
|
+
except Exception:
|
|
448
|
+
pass
|
|
449
|
+
finally:
|
|
450
|
+
self._stderr_done.set()
|
|
451
|
+
|
|
452
|
+
def _handshake(
|
|
453
|
+
self,
|
|
454
|
+
*,
|
|
455
|
+
cwd: str,
|
|
456
|
+
model: str,
|
|
457
|
+
resume_session_id: str,
|
|
458
|
+
allow_resume: bool,
|
|
459
|
+
handshake_timeout: float,
|
|
460
|
+
hard_fail_resume: bool = False,
|
|
461
|
+
) -> None:
|
|
462
|
+
assert self._client
|
|
463
|
+
self._client.request(
|
|
464
|
+
"initialize",
|
|
465
|
+
{
|
|
466
|
+
"protocolVersion": 1,
|
|
467
|
+
"clientInfo": {"name": "aicortex-agent-sdk", "version": "0.2.0"},
|
|
468
|
+
"clientCapabilities": {},
|
|
469
|
+
},
|
|
470
|
+
timeout=handshake_timeout,
|
|
471
|
+
)
|
|
472
|
+
session_id = ""
|
|
473
|
+
if allow_resume and resume_session_id:
|
|
474
|
+
try:
|
|
475
|
+
resume_params: dict[str, Any] = {
|
|
476
|
+
"cwd": cwd,
|
|
477
|
+
"sessionId": resume_session_id,
|
|
478
|
+
}
|
|
479
|
+
if self.resume_include_mcp:
|
|
480
|
+
resume_params["mcpServers"] = []
|
|
481
|
+
result = self._client.request(
|
|
482
|
+
self.resume_method,
|
|
483
|
+
resume_params,
|
|
484
|
+
timeout=handshake_timeout,
|
|
485
|
+
)
|
|
486
|
+
session_id, changed = resolve_resumed_session_id(resume_session_id, result)
|
|
487
|
+
if changed:
|
|
488
|
+
log.warning(
|
|
489
|
+
"%s acp resume returned different session id requested=%s actual=%s",
|
|
490
|
+
self.provider,
|
|
491
|
+
resume_session_id,
|
|
492
|
+
session_id,
|
|
493
|
+
)
|
|
494
|
+
except Exception as e:
|
|
495
|
+
if hard_fail_resume or self.provider == "kiro":
|
|
496
|
+
self.close()
|
|
497
|
+
raise RuntimeError(
|
|
498
|
+
f"{self.provider} acp {self.resume_method} failed: {e}"
|
|
499
|
+
) from e
|
|
500
|
+
log.warning(
|
|
501
|
+
"%s acp %s failed, opening new session: %s",
|
|
502
|
+
self.provider,
|
|
503
|
+
self.resume_method,
|
|
504
|
+
e,
|
|
505
|
+
)
|
|
506
|
+
session_id = ""
|
|
507
|
+
if not session_id:
|
|
508
|
+
result = self._client.request(
|
|
509
|
+
"session/new",
|
|
510
|
+
{"cwd": cwd, "mcpServers": []},
|
|
511
|
+
timeout=handshake_timeout,
|
|
512
|
+
)
|
|
513
|
+
session_id = extract_session_id(result)
|
|
514
|
+
if not session_id:
|
|
515
|
+
self.close()
|
|
516
|
+
raise RuntimeError(f"{self.provider} acp session/new returned no session ID")
|
|
517
|
+
self._session_id = session_id
|
|
518
|
+
self._client.session_id = session_id
|
|
519
|
+
self._model = model
|
|
520
|
+
log.info("%s acp session ready session_id=%s", self.provider, session_id)
|
|
521
|
+
if not model:
|
|
522
|
+
return
|
|
523
|
+
try:
|
|
524
|
+
if self.model_via_config_option:
|
|
525
|
+
self._client.request(
|
|
526
|
+
"session/set_config_option",
|
|
527
|
+
{"sessionId": session_id, "configId": "model", "value": model},
|
|
528
|
+
timeout=handshake_timeout,
|
|
529
|
+
)
|
|
530
|
+
else:
|
|
531
|
+
self._client.request(
|
|
532
|
+
"session/set_model",
|
|
533
|
+
{"sessionId": session_id, "modelId": model},
|
|
534
|
+
timeout=handshake_timeout,
|
|
535
|
+
)
|
|
536
|
+
except Exception as e:
|
|
537
|
+
if self.soft_set_model:
|
|
538
|
+
log.debug("%s acp set_model skipped: %s", self.provider, e)
|
|
539
|
+
return
|
|
540
|
+
self.close()
|
|
541
|
+
raise RuntimeError(f"{self.provider} acp set model failed: {e}") from e
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class CursorACPConn(_BaseACPConn):
|
|
545
|
+
provider = "cursor"
|
|
546
|
+
require_set_model = True
|
|
547
|
+
|
|
548
|
+
@classmethod
|
|
549
|
+
def open(
|
|
550
|
+
cls,
|
|
551
|
+
executable: str,
|
|
552
|
+
*,
|
|
553
|
+
cwd: str,
|
|
554
|
+
model: str = "",
|
|
555
|
+
resume_session_id: str = "",
|
|
556
|
+
custom_args: list[str] | None = None,
|
|
557
|
+
timeout: float = 0,
|
|
558
|
+
env: dict[str, str] | None = None,
|
|
559
|
+
allow_resume: bool = False,
|
|
560
|
+
) -> CursorACPConn:
|
|
561
|
+
path = shutil.which(executable) or executable
|
|
562
|
+
blocked = {"acp", "--yolo", "--force", "--sandbox", "--trust"}
|
|
563
|
+
filtered = _filter_blocked(list(custom_args or []), blocked, {"--sandbox"})
|
|
564
|
+
acp_args = ["--yolo", "--trust", "--sandbox", "disabled", "acp", *filtered]
|
|
565
|
+
argv0, cmd_args = choose_cursor_invocation(path, acp_args)
|
|
566
|
+
argv = [argv0, *cmd_args]
|
|
567
|
+
conn = cls()
|
|
568
|
+
work = cwd or str(Path.cwd())
|
|
569
|
+
conn._start_process(argv, work, env, tool_name_fn=hermes_tool_name_from_title)
|
|
570
|
+
hs = HANDSHAKE_TIMEOUT
|
|
571
|
+
if timeout > 0:
|
|
572
|
+
hs = min(hs, timeout)
|
|
573
|
+
try:
|
|
574
|
+
conn._handshake(
|
|
575
|
+
cwd=work,
|
|
576
|
+
model=model,
|
|
577
|
+
resume_session_id=resume_session_id,
|
|
578
|
+
allow_resume=allow_resume,
|
|
579
|
+
handshake_timeout=hs,
|
|
580
|
+
)
|
|
581
|
+
except Exception:
|
|
582
|
+
conn.close()
|
|
583
|
+
raise
|
|
584
|
+
return conn
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class KiroACPConn(_BaseACPConn):
|
|
588
|
+
provider = "kiro"
|
|
589
|
+
dual_prompt_fields = True
|
|
590
|
+
require_set_model = True
|
|
591
|
+
|
|
592
|
+
@classmethod
|
|
593
|
+
def open(
|
|
594
|
+
cls,
|
|
595
|
+
executable: str,
|
|
596
|
+
*,
|
|
597
|
+
cwd: str,
|
|
598
|
+
model: str = "",
|
|
599
|
+
resume_session_id: str = "",
|
|
600
|
+
custom_args: list[str] | None = None,
|
|
601
|
+
timeout: float = 0,
|
|
602
|
+
env: dict[str, str] | None = None,
|
|
603
|
+
) -> KiroACPConn:
|
|
604
|
+
path = shutil.which(executable) or executable
|
|
605
|
+
blocked = {"acp", "-a", "--trust-all-tools", "--trust-tools"}
|
|
606
|
+
filtered = _filter_blocked(
|
|
607
|
+
list(custom_args or []), blocked, {"--trust-tools"}
|
|
608
|
+
)
|
|
609
|
+
argv = [path, "acp", "--trust-all-tools", *filtered]
|
|
610
|
+
conn = cls()
|
|
611
|
+
work = cwd or str(Path.cwd())
|
|
612
|
+
conn._start_process(argv, work, env, tool_name_fn=kiro_tool_name)
|
|
613
|
+
hs = timeout if timeout > 0 else HANDSHAKE_TIMEOUT
|
|
614
|
+
try:
|
|
615
|
+
conn._handshake(
|
|
616
|
+
cwd=work,
|
|
617
|
+
model=model,
|
|
618
|
+
resume_session_id=resume_session_id,
|
|
619
|
+
allow_resume=True,
|
|
620
|
+
handshake_timeout=hs,
|
|
621
|
+
hard_fail_resume=True,
|
|
622
|
+
)
|
|
623
|
+
except Exception:
|
|
624
|
+
conn.close()
|
|
625
|
+
raise
|
|
626
|
+
return conn
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class HermesACPConn(_BaseACPConn):
|
|
630
|
+
provider = "hermes"
|
|
631
|
+
soft_set_model = True
|
|
632
|
+
|
|
633
|
+
@classmethod
|
|
634
|
+
def open(
|
|
635
|
+
cls,
|
|
636
|
+
executable: str,
|
|
637
|
+
*,
|
|
638
|
+
cwd: str,
|
|
639
|
+
model: str = "",
|
|
640
|
+
resume_session_id: str = "",
|
|
641
|
+
custom_args: list[str] | None = None,
|
|
642
|
+
timeout: float = 0,
|
|
643
|
+
env: dict[str, str] | None = None,
|
|
644
|
+
) -> HermesACPConn:
|
|
645
|
+
path = shutil.which(executable) or executable
|
|
646
|
+
filtered = _filter_blocked(list(custom_args or []), {"acp"})
|
|
647
|
+
argv = [path, "acp", *filtered]
|
|
648
|
+
conn = cls()
|
|
649
|
+
work = cwd or str(Path.cwd())
|
|
650
|
+
environ = dict(env or {})
|
|
651
|
+
environ.setdefault("HERMES_YOLO_MODE", "1")
|
|
652
|
+
conn._start_process(argv, work, environ, tool_name_fn=hermes_tool_name_from_title)
|
|
653
|
+
hs = timeout if timeout > 0 else HANDSHAKE_TIMEOUT
|
|
654
|
+
try:
|
|
655
|
+
conn._handshake(
|
|
656
|
+
cwd=work,
|
|
657
|
+
model=model,
|
|
658
|
+
resume_session_id=resume_session_id,
|
|
659
|
+
allow_resume=True,
|
|
660
|
+
handshake_timeout=hs,
|
|
661
|
+
)
|
|
662
|
+
except Exception:
|
|
663
|
+
conn.close()
|
|
664
|
+
raise
|
|
665
|
+
return conn
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
class KimiACPConn(_BaseACPConn):
|
|
669
|
+
provider = "kimi"
|
|
670
|
+
soft_set_model = True
|
|
671
|
+
resume_method = "session/resume"
|
|
672
|
+
resume_include_mcp = False
|
|
673
|
+
|
|
674
|
+
@classmethod
|
|
675
|
+
def open(
|
|
676
|
+
cls,
|
|
677
|
+
executable: str,
|
|
678
|
+
*,
|
|
679
|
+
cwd: str,
|
|
680
|
+
model: str = "",
|
|
681
|
+
resume_session_id: str = "",
|
|
682
|
+
custom_args: list[str] | None = None,
|
|
683
|
+
timeout: float = 0,
|
|
684
|
+
env: dict[str, str] | None = None,
|
|
685
|
+
) -> KimiACPConn:
|
|
686
|
+
path = shutil.which(executable) or executable
|
|
687
|
+
filtered = _filter_blocked(list(custom_args or []), {"acp"})
|
|
688
|
+
argv = [path, "acp", *filtered]
|
|
689
|
+
conn = cls()
|
|
690
|
+
work = cwd or str(Path.cwd())
|
|
691
|
+
conn._start_process(argv, work, env, tool_name_fn=kimi_tool_name)
|
|
692
|
+
hs = timeout if timeout > 0 else HANDSHAKE_TIMEOUT
|
|
693
|
+
try:
|
|
694
|
+
conn._handshake(
|
|
695
|
+
cwd=work,
|
|
696
|
+
model=model,
|
|
697
|
+
resume_session_id=resume_session_id,
|
|
698
|
+
allow_resume=True,
|
|
699
|
+
handshake_timeout=hs,
|
|
700
|
+
)
|
|
701
|
+
except Exception:
|
|
702
|
+
conn.close()
|
|
703
|
+
raise
|
|
704
|
+
return conn
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
class DshACPConn(_BaseACPConn):
|
|
708
|
+
provider = "dsh"
|
|
709
|
+
model_via_config_option = True
|
|
710
|
+
require_set_model = True
|
|
711
|
+
|
|
712
|
+
@classmethod
|
|
713
|
+
def open(
|
|
714
|
+
cls,
|
|
715
|
+
executable: str,
|
|
716
|
+
*,
|
|
717
|
+
cwd: str,
|
|
718
|
+
model: str = "",
|
|
719
|
+
resume_session_id: str = "",
|
|
720
|
+
custom_args: list[str] | None = None,
|
|
721
|
+
timeout: float = 0,
|
|
722
|
+
env: dict[str, str] | None = None,
|
|
723
|
+
) -> DshACPConn:
|
|
724
|
+
path = shutil.which(executable) or executable
|
|
725
|
+
filtered = _filter_blocked(
|
|
726
|
+
list(custom_args or []), {"--profile", "-p"}, {"--profile", "-p"}
|
|
727
|
+
)
|
|
728
|
+
argv = [path, "--profile", "acp", *filtered]
|
|
729
|
+
conn = cls()
|
|
730
|
+
work = cwd or str(Path.cwd())
|
|
731
|
+
conn._start_process(argv, work, env, tool_name_fn=dsh_tool_name)
|
|
732
|
+
hs = timeout if timeout > 0 else HANDSHAKE_TIMEOUT
|
|
733
|
+
try:
|
|
734
|
+
conn._handshake(
|
|
735
|
+
cwd=work,
|
|
736
|
+
model=model,
|
|
737
|
+
resume_session_id=resume_session_id,
|
|
738
|
+
allow_resume=True,
|
|
739
|
+
handshake_timeout=hs,
|
|
740
|
+
)
|
|
741
|
+
except Exception:
|
|
742
|
+
conn.close()
|
|
743
|
+
raise
|
|
744
|
+
return conn
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def open_acp_conn(
|
|
748
|
+
provider: str,
|
|
749
|
+
executable: str,
|
|
750
|
+
*,
|
|
751
|
+
cwd: str,
|
|
752
|
+
model: str = "",
|
|
753
|
+
resume_session_id: str = "",
|
|
754
|
+
custom_args: list[str] | None = None,
|
|
755
|
+
timeout: float = 0,
|
|
756
|
+
env: dict[str, str] | None = None,
|
|
757
|
+
) -> _BaseACPConn:
|
|
758
|
+
if provider == "cursor":
|
|
759
|
+
return CursorACPConn.open(
|
|
760
|
+
executable,
|
|
761
|
+
cwd=cwd,
|
|
762
|
+
model=model,
|
|
763
|
+
resume_session_id=resume_session_id,
|
|
764
|
+
custom_args=custom_args,
|
|
765
|
+
timeout=timeout,
|
|
766
|
+
env=env,
|
|
767
|
+
allow_resume=False,
|
|
768
|
+
)
|
|
769
|
+
if provider == "kiro":
|
|
770
|
+
return KiroACPConn.open(
|
|
771
|
+
executable,
|
|
772
|
+
cwd=cwd,
|
|
773
|
+
model=model,
|
|
774
|
+
resume_session_id=resume_session_id,
|
|
775
|
+
custom_args=custom_args,
|
|
776
|
+
timeout=timeout,
|
|
777
|
+
env=env,
|
|
778
|
+
)
|
|
779
|
+
if provider == "hermes":
|
|
780
|
+
return HermesACPConn.open(
|
|
781
|
+
executable,
|
|
782
|
+
cwd=cwd,
|
|
783
|
+
model=model,
|
|
784
|
+
resume_session_id=resume_session_id,
|
|
785
|
+
custom_args=custom_args,
|
|
786
|
+
timeout=timeout,
|
|
787
|
+
env=env,
|
|
788
|
+
)
|
|
789
|
+
if provider == "kimi":
|
|
790
|
+
return KimiACPConn.open(
|
|
791
|
+
executable,
|
|
792
|
+
cwd=cwd,
|
|
793
|
+
model=model,
|
|
794
|
+
resume_session_id=resume_session_id,
|
|
795
|
+
custom_args=custom_args,
|
|
796
|
+
timeout=timeout,
|
|
797
|
+
env=env,
|
|
798
|
+
)
|
|
799
|
+
if provider == "dsh":
|
|
800
|
+
return DshACPConn.open(
|
|
801
|
+
executable,
|
|
802
|
+
cwd=cwd,
|
|
803
|
+
model=model,
|
|
804
|
+
resume_session_id=resume_session_id,
|
|
805
|
+
custom_args=custom_args,
|
|
806
|
+
timeout=timeout,
|
|
807
|
+
env=env,
|
|
808
|
+
)
|
|
809
|
+
raise RuntimeError(f"unsupported acp provider {provider!r}")
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def run_acp_oneshot(
|
|
813
|
+
provider: str,
|
|
814
|
+
executable: str,
|
|
815
|
+
*,
|
|
816
|
+
cwd: str,
|
|
817
|
+
prompt: str,
|
|
818
|
+
model: str = "",
|
|
819
|
+
resume_session_id: str = "",
|
|
820
|
+
custom_args: list[str] | None = None,
|
|
821
|
+
timeout: float = 0,
|
|
822
|
+
env: dict[str, str] | None = None,
|
|
823
|
+
system_prompt: str = "",
|
|
824
|
+
on_message: MessageCB | None = None,
|
|
825
|
+
cancel_event: threading.Event | None = None,
|
|
826
|
+
idle_watchdog: float = 0,
|
|
827
|
+
) -> ACPPromptResult:
|
|
828
|
+
"""One-shot ACP turn (open → prompt → close). Used for non-chat kiro/hermes/kimi/dsh."""
|
|
829
|
+
t = timeout if timeout > 0 else PROMPT_TIMEOUT
|
|
830
|
+
try:
|
|
831
|
+
conn = open_acp_conn(
|
|
832
|
+
provider,
|
|
833
|
+
executable,
|
|
834
|
+
cwd=cwd,
|
|
835
|
+
model=model,
|
|
836
|
+
resume_session_id=resume_session_id,
|
|
837
|
+
custom_args=custom_args,
|
|
838
|
+
timeout=t,
|
|
839
|
+
env=env,
|
|
840
|
+
)
|
|
841
|
+
except Exception as e:
|
|
842
|
+
return ACPPromptResult(status="failed", output="", error=f"{provider} acp open failed: {e}")
|
|
843
|
+
try:
|
|
844
|
+
return conn.run_prompt(
|
|
845
|
+
prompt,
|
|
846
|
+
timeout=t,
|
|
847
|
+
system_prompt=system_prompt,
|
|
848
|
+
on_message=on_message,
|
|
849
|
+
idle_watchdog=idle_watchdog,
|
|
850
|
+
cancel_event=cancel_event,
|
|
851
|
+
)
|
|
852
|
+
finally:
|
|
853
|
+
conn.close()
|