kiwimatecoder 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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
"""The ACP agent server: request dispatch, sessions, turns, and permissions.
|
|
2
|
+
|
|
3
|
+
This implements the agent side of a focused Agent Client Protocol subset over
|
|
4
|
+
newline-delimited JSON-RPC 2.0:
|
|
5
|
+
|
|
6
|
+
* client -> agent requests: ``initialize``, ``session/new``, ``session/prompt``
|
|
7
|
+
and the ``session/cancel`` notification;
|
|
8
|
+
* agent -> client requests: ``session/request_permission`` (used to bridge the
|
|
9
|
+
regular permission gate to the editor);
|
|
10
|
+
* agent -> client notifications: ``session/update`` for assistant text chunks
|
|
11
|
+
and tool-call progress;
|
|
12
|
+
* optional file delegation through ``fs/read_text_file`` / ``fs/write_text_file``
|
|
13
|
+
when the client advertises filesystem capabilities.
|
|
14
|
+
|
|
15
|
+
The transport is injected as two callables (``read_line``/``write_line``), so
|
|
16
|
+
tests drive the server with in-memory queues and the real process uses
|
|
17
|
+
:mod:`kiwimatecoder.acp.stdio`. Every wait is bounded: unanswered permission
|
|
18
|
+
requests are denied after ``acp.permission_timeout`` seconds (default 300).
|
|
19
|
+
|
|
20
|
+
Turns reuse :func:`kiwimatecoder.headless.run_agent_once`; the agent's block
|
|
21
|
+
and gate callbacks run inside the normal permission flow, so an editor in the
|
|
22
|
+
configured ``ask`` mode sees exactly the approvals the terminal would ask for.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import inspect
|
|
29
|
+
import logging
|
|
30
|
+
import uuid
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Callable
|
|
34
|
+
|
|
35
|
+
from kiwimatecoder import __version__, config
|
|
36
|
+
from kiwimatecoder.acp.protocol import (
|
|
37
|
+
ERROR_INTERNAL,
|
|
38
|
+
ERROR_INVALID_PARAMS,
|
|
39
|
+
ERROR_INVALID_REQUEST,
|
|
40
|
+
ERROR_METHOD_NOT_FOUND,
|
|
41
|
+
ERROR_PARSE,
|
|
42
|
+
ERROR_SERVER,
|
|
43
|
+
AcpError,
|
|
44
|
+
AcpTimeoutError,
|
|
45
|
+
IdAllocator,
|
|
46
|
+
decode_lines,
|
|
47
|
+
encode,
|
|
48
|
+
error_response,
|
|
49
|
+
notification_message,
|
|
50
|
+
request_message,
|
|
51
|
+
success_response,
|
|
52
|
+
)
|
|
53
|
+
from kiwimatecoder.headless import build_session, run_agent_once, shutdown_runtime
|
|
54
|
+
from kiwimatecoder.session import Session
|
|
55
|
+
|
|
56
|
+
_log = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
PROTOCOL_VERSION = 1
|
|
59
|
+
DEFAULT_PERMISSION_TIMEOUT = 300.0
|
|
60
|
+
SHUTDOWN_GRACE_SECONDS = 5.0
|
|
61
|
+
|
|
62
|
+
# Incoming transport hooks. ``read_line`` returns None (or "") at EOF; either
|
|
63
|
+
# callable may be sync or async.
|
|
64
|
+
ReadLine = Callable[[], Any]
|
|
65
|
+
WriteLine = Callable[[str], Any]
|
|
66
|
+
|
|
67
|
+
PERMISSION_OPTIONS: list[dict[str, Any]] = [
|
|
68
|
+
{"optionId": "allow_once", "name": "Allow once", "kind": "allow_once"},
|
|
69
|
+
{"optionId": "allow_always", "name": "Allow always", "kind": "allow_always"},
|
|
70
|
+
{"optionId": "reject_once", "name": "Reject", "kind": "reject_once"},
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# Tools whose result/args are read-like, edit-like, and so on. Anything not
|
|
74
|
+
# listed maps to ACP's generic "other".
|
|
75
|
+
TOOL_KINDS: dict[str, str] = {
|
|
76
|
+
"read_file": "read",
|
|
77
|
+
"list_dir": "read",
|
|
78
|
+
"view_image": "read",
|
|
79
|
+
"write_file": "edit",
|
|
80
|
+
"edit_file": "edit",
|
|
81
|
+
"search": "search",
|
|
82
|
+
"run_bash": "execute",
|
|
83
|
+
"shell": "execute",
|
|
84
|
+
"shell_jobs": "execute",
|
|
85
|
+
"web_fetch": "fetch",
|
|
86
|
+
"web_search": "fetch",
|
|
87
|
+
"http_get": "fetch",
|
|
88
|
+
"http_request": "fetch",
|
|
89
|
+
"task": "think",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def tool_kind(tool_name: str) -> str:
|
|
94
|
+
"""Map a KiwiMateCoder tool name to an ACP tool-call kind."""
|
|
95
|
+
return TOOL_KINDS.get(tool_name, "other")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _describe_tool(tool_name: str, args: dict[str, Any]) -> str:
|
|
99
|
+
"""A short human-readable title for a tool-call update."""
|
|
100
|
+
for key in ("path", "command", "query", "url", "description", "action"):
|
|
101
|
+
value = args.get(key)
|
|
102
|
+
if value:
|
|
103
|
+
text = str(value)
|
|
104
|
+
if len(text) > 80:
|
|
105
|
+
text = text[:77] + "..."
|
|
106
|
+
return f"{tool_name} {text}"
|
|
107
|
+
return tool_name
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def prompt_text(prompt: Any) -> tuple[str, int]:
|
|
111
|
+
"""Concatenate a prompt's text parts; count any image parts skipped."""
|
|
112
|
+
if not isinstance(prompt, list):
|
|
113
|
+
return "", 0
|
|
114
|
+
parts: list[str] = []
|
|
115
|
+
images = 0
|
|
116
|
+
for item in prompt:
|
|
117
|
+
if not isinstance(item, dict):
|
|
118
|
+
continue
|
|
119
|
+
kind = item.get("type")
|
|
120
|
+
if kind == "text":
|
|
121
|
+
text = item.get("text")
|
|
122
|
+
if isinstance(text, str):
|
|
123
|
+
parts.append(text)
|
|
124
|
+
elif kind == "image":
|
|
125
|
+
images += 1
|
|
126
|
+
return "\n".join(parts), images
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class _SessionState:
|
|
131
|
+
"""One ``session/new`` conversation plus its in-flight tool calls."""
|
|
132
|
+
|
|
133
|
+
session: Session
|
|
134
|
+
tool_counter: int = 0
|
|
135
|
+
in_progress: list[tuple[str, str]] = field(default_factory=list)
|
|
136
|
+
pending_permission_id: str | None = None
|
|
137
|
+
|
|
138
|
+
def allocate_call_id(self) -> str:
|
|
139
|
+
self.tool_counter += 1
|
|
140
|
+
return f"call-{self.tool_counter}"
|
|
141
|
+
|
|
142
|
+
def begin_tool(self, tool_name: str, args: dict[str, Any]) -> tuple[str, str, str]:
|
|
143
|
+
"""Register a starting tool call and return ``(id, title, kind)``."""
|
|
144
|
+
call_id = self.pending_permission_id or self.allocate_call_id()
|
|
145
|
+
self.pending_permission_id = None
|
|
146
|
+
self.in_progress.append((call_id, tool_name))
|
|
147
|
+
return call_id, _describe_tool(tool_name, args), tool_kind(tool_name)
|
|
148
|
+
|
|
149
|
+
def end_tool(self, tool_name: str) -> str | None:
|
|
150
|
+
"""Resolve the call id for a finished tool, preferring a name match."""
|
|
151
|
+
for index, (call_id, name) in enumerate(self.in_progress):
|
|
152
|
+
if name == tool_name:
|
|
153
|
+
del self.in_progress[index]
|
|
154
|
+
return call_id
|
|
155
|
+
if self.in_progress:
|
|
156
|
+
return self.in_progress.pop(0)[0]
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class _TurnState:
|
|
162
|
+
"""Bookkeeping for one running ``session/prompt``."""
|
|
163
|
+
|
|
164
|
+
session_id: str
|
|
165
|
+
done_reasons: list[str] = field(default_factory=list)
|
|
166
|
+
saw_text: bool = False
|
|
167
|
+
cancelled: bool = False
|
|
168
|
+
loop: asyncio.AbstractEventLoop | None = None
|
|
169
|
+
task: asyncio.Task[Any] | None = None
|
|
170
|
+
pending_updates: list[Any] = field(default_factory=list)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class _PendingRequest:
|
|
175
|
+
"""An agent -> client request awaiting its response."""
|
|
176
|
+
|
|
177
|
+
future: asyncio.Future[Any]
|
|
178
|
+
method: str
|
|
179
|
+
session_id: str | None = None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _consume_future_exception(future: Any) -> None:
|
|
183
|
+
"""Swallow an unobserved exception on a fire-and-forget future."""
|
|
184
|
+
try:
|
|
185
|
+
future.exception()
|
|
186
|
+
except BaseException:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class AcpServer:
|
|
191
|
+
"""A transport-agnostic ACP agent.
|
|
192
|
+
|
|
193
|
+
``workspace`` is the default session root when ``session/new`` omits
|
|
194
|
+
``cwd``; ``permission_timeout`` overrides the configured
|
|
195
|
+
``acp.permission_timeout`` (seconds) and is mainly useful in tests.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
*,
|
|
201
|
+
workspace: str | Path | None = None,
|
|
202
|
+
permission_timeout: float | None = None,
|
|
203
|
+
) -> None:
|
|
204
|
+
self.default_workspace = (
|
|
205
|
+
Path(workspace).expanduser() if workspace is not None else None
|
|
206
|
+
)
|
|
207
|
+
if permission_timeout is None:
|
|
208
|
+
permission_timeout = float(
|
|
209
|
+
config.get_acp().get("permission_timeout", DEFAULT_PERMISSION_TIMEOUT)
|
|
210
|
+
)
|
|
211
|
+
self.permission_timeout = float(permission_timeout)
|
|
212
|
+
|
|
213
|
+
self._sessions: dict[str, _SessionState] = {}
|
|
214
|
+
self._turns: dict[str, _TurnState] = {}
|
|
215
|
+
self._pending: dict[int, _PendingRequest] = {}
|
|
216
|
+
self._inflight: set[asyncio.Task[Any]] = set()
|
|
217
|
+
self._ids = IdAllocator()
|
|
218
|
+
self._client_capabilities: dict[str, Any] = {}
|
|
219
|
+
self._fs_read = False
|
|
220
|
+
self._fs_write = False
|
|
221
|
+
|
|
222
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
223
|
+
self._read_line: ReadLine | None = None
|
|
224
|
+
self._write_line: WriteLine | None = None
|
|
225
|
+
self._write_lock: asyncio.Lock | None = None
|
|
226
|
+
self._closed = False
|
|
227
|
+
|
|
228
|
+
# -- lifecycle ----------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
async def serve(self, read_line: ReadLine, write_line: WriteLine) -> int:
|
|
231
|
+
"""Read and dispatch messages until EOF, then shut down cleanly.
|
|
232
|
+
|
|
233
|
+
``read_line`` returns the next line (with or without a trailing
|
|
234
|
+
newline) or ``None``/``""`` at end of input; ``write_line`` accepts a
|
|
235
|
+
terminated line. Both may be synchronous or asynchronous callables.
|
|
236
|
+
Returns ``0`` after a clean EOF.
|
|
237
|
+
"""
|
|
238
|
+
self._loop = asyncio.get_running_loop()
|
|
239
|
+
self._read_line = read_line
|
|
240
|
+
self._write_line = write_line
|
|
241
|
+
self._write_lock = asyncio.Lock()
|
|
242
|
+
self._closed = False
|
|
243
|
+
try:
|
|
244
|
+
while not self._closed:
|
|
245
|
+
line = read_line()
|
|
246
|
+
if inspect.isawaitable(line):
|
|
247
|
+
line = await line
|
|
248
|
+
if line is None or line == "":
|
|
249
|
+
break
|
|
250
|
+
await self._handle_line(str(line))
|
|
251
|
+
finally:
|
|
252
|
+
await self.shutdown()
|
|
253
|
+
return 0
|
|
254
|
+
|
|
255
|
+
async def shutdown(self) -> None:
|
|
256
|
+
"""Cancel active turns, release pending requests, and stop managers."""
|
|
257
|
+
self._closed = True
|
|
258
|
+
for turn in list(self._turns.values()):
|
|
259
|
+
self._cancel_turn(turn)
|
|
260
|
+
for request_id, pending in list(self._pending.items()):
|
|
261
|
+
if not pending.future.done():
|
|
262
|
+
if pending.method == "session/request_permission":
|
|
263
|
+
pending.future.set_result({"outcome": {"outcome": "cancelled"}})
|
|
264
|
+
else:
|
|
265
|
+
pending.future.set_exception(
|
|
266
|
+
AcpError(ERROR_SERVER, "Server is shutting down")
|
|
267
|
+
)
|
|
268
|
+
self._pending.pop(request_id, None)
|
|
269
|
+
if self._inflight:
|
|
270
|
+
done, still = await asyncio.wait(
|
|
271
|
+
list(self._inflight), timeout=SHUTDOWN_GRACE_SECONDS
|
|
272
|
+
)
|
|
273
|
+
for task in still:
|
|
274
|
+
task.cancel()
|
|
275
|
+
if still:
|
|
276
|
+
await asyncio.wait(still, timeout=SHUTDOWN_GRACE_SECONDS)
|
|
277
|
+
try:
|
|
278
|
+
shutdown_runtime()
|
|
279
|
+
except Exception: # pragma: no cover - defensive: cleanup must not raise
|
|
280
|
+
_log.debug("ACP shutdown cleanup failed", exc_info=True)
|
|
281
|
+
|
|
282
|
+
# -- incoming messages --------------------------------------------------
|
|
283
|
+
|
|
284
|
+
async def _handle_line(self, line: str) -> None:
|
|
285
|
+
"""Parse one transport line and dispatch every message it contains."""
|
|
286
|
+
text = line if line.endswith("\n") else line + "\n"
|
|
287
|
+
messages, _remainder = decode_lines(text)
|
|
288
|
+
if not messages:
|
|
289
|
+
if line.strip():
|
|
290
|
+
await self._send(
|
|
291
|
+
error_response(None, ERROR_PARSE, "Parse error: invalid JSON")
|
|
292
|
+
)
|
|
293
|
+
return
|
|
294
|
+
for message in messages:
|
|
295
|
+
await self._dispatch(message)
|
|
296
|
+
|
|
297
|
+
async def _dispatch(self, message: dict[str, Any]) -> None:
|
|
298
|
+
method = message.get("method")
|
|
299
|
+
if isinstance(method, str):
|
|
300
|
+
if "id" in message:
|
|
301
|
+
if method == "session/prompt":
|
|
302
|
+
task = asyncio.create_task(self._handle_prompt_request(message))
|
|
303
|
+
self._inflight.add(task)
|
|
304
|
+
task.add_done_callback(self._inflight.discard)
|
|
305
|
+
return
|
|
306
|
+
await self._handle_request(message["id"], method, message.get("params"))
|
|
307
|
+
return
|
|
308
|
+
await self._handle_notification(method, message.get("params"))
|
|
309
|
+
return
|
|
310
|
+
if "id" in message and ("result" in message or "error" in message):
|
|
311
|
+
self._resolve_response(message)
|
|
312
|
+
return
|
|
313
|
+
await self._send(
|
|
314
|
+
error_response(
|
|
315
|
+
message.get("id"), ERROR_INVALID_REQUEST, "Invalid JSON-RPC message"
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
async def _handle_request(
|
|
320
|
+
self, request_id: Any, method: str, params: Any
|
|
321
|
+
) -> None:
|
|
322
|
+
payload = params if isinstance(params, dict) else {}
|
|
323
|
+
try:
|
|
324
|
+
result = await self._dispatch_request(method, payload)
|
|
325
|
+
except AcpError as exc:
|
|
326
|
+
await self._send(
|
|
327
|
+
error_response(request_id, exc.code, exc.message, exc.data)
|
|
328
|
+
)
|
|
329
|
+
return
|
|
330
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
331
|
+
_log.debug("ACP request %s failed", method, exc_info=True)
|
|
332
|
+
await self._send(
|
|
333
|
+
error_response(request_id, ERROR_INTERNAL, f"Internal error: {exc}")
|
|
334
|
+
)
|
|
335
|
+
return
|
|
336
|
+
await self._send(success_response(request_id, result))
|
|
337
|
+
|
|
338
|
+
async def _dispatch_request(self, method: str, params: dict[str, Any]) -> Any:
|
|
339
|
+
if method == "initialize":
|
|
340
|
+
return await self._initialize(params)
|
|
341
|
+
if method == "session/new":
|
|
342
|
+
return await self._new_session(params)
|
|
343
|
+
# ``session/prompt`` never reaches here (dispatched as a background
|
|
344
|
+
# task), and ``session/request_permission`` is agent -> client only.
|
|
345
|
+
raise AcpError(ERROR_METHOD_NOT_FOUND, f"Method not found: {method}")
|
|
346
|
+
|
|
347
|
+
async def _handle_notification(self, method: str, params: Any) -> None:
|
|
348
|
+
payload = params if isinstance(params, dict) else {}
|
|
349
|
+
if method == "session/cancel":
|
|
350
|
+
await self._cancel(payload)
|
|
351
|
+
# Unknown notifications are ignored per JSON-RPC 2.0.
|
|
352
|
+
|
|
353
|
+
async def _handle_prompt_request(self, message: dict[str, Any]) -> None:
|
|
354
|
+
request_id = message.get("id")
|
|
355
|
+
params = message.get("params")
|
|
356
|
+
try:
|
|
357
|
+
result = await self._prompt(params if isinstance(params, dict) else {})
|
|
358
|
+
except AcpError as exc:
|
|
359
|
+
await self._send(
|
|
360
|
+
error_response(request_id, exc.code, exc.message, exc.data)
|
|
361
|
+
)
|
|
362
|
+
return
|
|
363
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
364
|
+
_log.debug("ACP prompt failed", exc_info=True)
|
|
365
|
+
await self._send(
|
|
366
|
+
error_response(request_id, ERROR_INTERNAL, f"Internal error: {exc}")
|
|
367
|
+
)
|
|
368
|
+
return
|
|
369
|
+
await self._send(success_response(request_id, result))
|
|
370
|
+
|
|
371
|
+
# -- client -> agent handlers -------------------------------------------
|
|
372
|
+
|
|
373
|
+
async def _initialize(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
374
|
+
# The agent reports the protocol version it implements; a client on a
|
|
375
|
+
# newer version negotiates down (or disconnects if incompatible).
|
|
376
|
+
capabilities = params.get("clientCapabilities")
|
|
377
|
+
self._client_capabilities = (
|
|
378
|
+
capabilities if isinstance(capabilities, dict) else {}
|
|
379
|
+
)
|
|
380
|
+
fs = self._client_capabilities.get("fs")
|
|
381
|
+
if isinstance(fs, dict):
|
|
382
|
+
self._fs_read = bool(fs.get("readTextFile"))
|
|
383
|
+
self._fs_write = bool(fs.get("writeTextFile"))
|
|
384
|
+
return {
|
|
385
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
386
|
+
"agentCapabilities": {
|
|
387
|
+
"loadSession": False,
|
|
388
|
+
"promptCapabilities": {"image": True},
|
|
389
|
+
},
|
|
390
|
+
"agentInfo": {"name": "kiwimatecoder", "version": __version__},
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async def _new_session(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
394
|
+
if self.default_workspace is not None:
|
|
395
|
+
raw_cwd: Any = params.get("cwd") or self.default_workspace
|
|
396
|
+
else:
|
|
397
|
+
raw_cwd = params.get("cwd") or Path.cwd()
|
|
398
|
+
root = Path(str(raw_cwd)).expanduser()
|
|
399
|
+
if not root.is_dir():
|
|
400
|
+
raise AcpError(
|
|
401
|
+
ERROR_INVALID_PARAMS, f"Workspace is not a directory: {root}"
|
|
402
|
+
)
|
|
403
|
+
try:
|
|
404
|
+
session = build_session(workspace=root)
|
|
405
|
+
except (ValueError, KeyError) as exc:
|
|
406
|
+
raise AcpError(ERROR_INVALID_PARAMS, str(exc)) from exc
|
|
407
|
+
# ``mcpServers`` is accepted but not wired: MCP servers come from the
|
|
408
|
+
# user's KiwiMateCoder config. The session uses the configured
|
|
409
|
+
# permission mode (never an assumed auto-accept).
|
|
410
|
+
session_id = uuid.uuid4().hex
|
|
411
|
+
self._sessions[session_id] = _SessionState(session=session)
|
|
412
|
+
return {"sessionId": session_id}
|
|
413
|
+
|
|
414
|
+
async def _prompt(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
415
|
+
session_id = str(params.get("sessionId") or "")
|
|
416
|
+
state = self._sessions.get(session_id)
|
|
417
|
+
if state is None:
|
|
418
|
+
raise AcpError(ERROR_INVALID_PARAMS, f"Unknown session '{session_id}'.")
|
|
419
|
+
if session_id in self._turns:
|
|
420
|
+
raise AcpError(
|
|
421
|
+
ERROR_SERVER, "A turn is already running for this session."
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
text, images = prompt_text(params.get("prompt"))
|
|
425
|
+
if not text.strip() and not images:
|
|
426
|
+
raise AcpError(
|
|
427
|
+
ERROR_INVALID_PARAMS, "prompt must contain at least one text part."
|
|
428
|
+
)
|
|
429
|
+
if images:
|
|
430
|
+
note = f"[{images} image part(s) were ignored: this server has no image input]"
|
|
431
|
+
text = f"{text}\n{note}".strip()
|
|
432
|
+
|
|
433
|
+
turn = _TurnState(session_id=session_id)
|
|
434
|
+
self._turns[session_id] = turn
|
|
435
|
+
|
|
436
|
+
def on_event(name: str, payload: dict[str, Any]) -> None:
|
|
437
|
+
self._translate_event(state, turn, name, payload)
|
|
438
|
+
|
|
439
|
+
def confirm(summary: str, preview: str | None) -> bool:
|
|
440
|
+
return self._request_permission_sync(turn, state, summary)
|
|
441
|
+
|
|
442
|
+
try:
|
|
443
|
+
outcome = await asyncio.to_thread(
|
|
444
|
+
self._run_turn_thread, state, turn, text, confirm, on_event
|
|
445
|
+
)
|
|
446
|
+
finally:
|
|
447
|
+
if self._turns.get(session_id) is turn:
|
|
448
|
+
self._turns.pop(session_id, None)
|
|
449
|
+
|
|
450
|
+
# The worker thread schedules notifications onto this loop; wait for
|
|
451
|
+
# them so updates always precede the prompt's response.
|
|
452
|
+
await self._flush_updates(turn)
|
|
453
|
+
|
|
454
|
+
if turn.cancelled:
|
|
455
|
+
return {"stopReason": "cancelled"}
|
|
456
|
+
# Some paths (budget stop, provider error before text) never stream a
|
|
457
|
+
# delta; surface their text as the one agent message chunk.
|
|
458
|
+
if not turn.saw_text and outcome is not None and outcome.text:
|
|
459
|
+
await self._emit_update(
|
|
460
|
+
session_id,
|
|
461
|
+
{
|
|
462
|
+
"sessionUpdate": "agent_message_chunk",
|
|
463
|
+
"content": {"type": "text", "text": outcome.text},
|
|
464
|
+
},
|
|
465
|
+
)
|
|
466
|
+
return {"stopReason": self._stop_reason(turn, outcome)}
|
|
467
|
+
|
|
468
|
+
async def _cancel(self, params: dict[str, Any]) -> None:
|
|
469
|
+
session_id = str(params.get("sessionId") or "")
|
|
470
|
+
turn = self._turns.get(session_id)
|
|
471
|
+
if turn is not None:
|
|
472
|
+
self._cancel_turn(turn)
|
|
473
|
+
|
|
474
|
+
def _cancel_turn(self, turn: _TurnState) -> None:
|
|
475
|
+
turn.cancelled = True
|
|
476
|
+
# A thread blocked waiting for the editor's permission answer is woken
|
|
477
|
+
# with a denial so cancellation is never stuck behind that wait.
|
|
478
|
+
self._resolve_pending_for_session(
|
|
479
|
+
turn.session_id, {"outcome": {"outcome": "cancelled"}}
|
|
480
|
+
)
|
|
481
|
+
state = self._sessions.get(turn.session_id)
|
|
482
|
+
if state is not None and state.in_progress:
|
|
483
|
+
for call_id, tool_name in state.in_progress:
|
|
484
|
+
self._emit_update_threadsafe(
|
|
485
|
+
turn,
|
|
486
|
+
{
|
|
487
|
+
"sessionUpdate": "tool_call_update",
|
|
488
|
+
"toolCallId": call_id,
|
|
489
|
+
"status": "failed",
|
|
490
|
+
"content": [
|
|
491
|
+
{
|
|
492
|
+
"type": "content",
|
|
493
|
+
"content": {
|
|
494
|
+
"type": "text",
|
|
495
|
+
"text": f"{tool_name} cancelled",
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
],
|
|
499
|
+
},
|
|
500
|
+
)
|
|
501
|
+
state.in_progress.clear()
|
|
502
|
+
loop, task = turn.loop, turn.task
|
|
503
|
+
if loop is not None and task is not None:
|
|
504
|
+
try:
|
|
505
|
+
loop.call_soon_threadsafe(task.cancel)
|
|
506
|
+
except RuntimeError:
|
|
507
|
+
pass
|
|
508
|
+
|
|
509
|
+
def _resolve_pending_for_session(
|
|
510
|
+
self, session_id: str, result: dict[str, Any]
|
|
511
|
+
) -> None:
|
|
512
|
+
for request_id, pending in list(self._pending.items()):
|
|
513
|
+
if (
|
|
514
|
+
pending.method == "session/request_permission"
|
|
515
|
+
and pending.session_id == session_id
|
|
516
|
+
):
|
|
517
|
+
if not pending.future.done():
|
|
518
|
+
pending.future.set_result(result)
|
|
519
|
+
self._pending.pop(request_id, None)
|
|
520
|
+
|
|
521
|
+
# -- turn execution (worker thread) -------------------------------------
|
|
522
|
+
|
|
523
|
+
def _run_turn_thread(
|
|
524
|
+
self,
|
|
525
|
+
state: _SessionState,
|
|
526
|
+
turn: _TurnState,
|
|
527
|
+
text: str,
|
|
528
|
+
confirm: Callable[[str, str | None], bool],
|
|
529
|
+
on_event: Callable[[str, dict[str, Any]], None],
|
|
530
|
+
) -> Any:
|
|
531
|
+
"""Run one ``run_agent_once`` turn on a dedicated event loop.
|
|
532
|
+
|
|
533
|
+
The agent's confirm callback is synchronous, so the turn runs on its
|
|
534
|
+
own loop in a worker thread while this server's loop stays free to
|
|
535
|
+
answer ``session/request_permission`` and ``session/cancel``.
|
|
536
|
+
"""
|
|
537
|
+
loop = asyncio.new_event_loop()
|
|
538
|
+
turn.loop = loop
|
|
539
|
+
try:
|
|
540
|
+
asyncio.set_event_loop(loop)
|
|
541
|
+
task = loop.create_task(
|
|
542
|
+
run_agent_once(
|
|
543
|
+
text,
|
|
544
|
+
session=state.session,
|
|
545
|
+
confirm=confirm,
|
|
546
|
+
on_event=on_event,
|
|
547
|
+
render_text=False,
|
|
548
|
+
)
|
|
549
|
+
)
|
|
550
|
+
turn.task = task
|
|
551
|
+
if turn.cancelled:
|
|
552
|
+
task.cancel()
|
|
553
|
+
try:
|
|
554
|
+
return loop.run_until_complete(task)
|
|
555
|
+
except asyncio.CancelledError:
|
|
556
|
+
turn.cancelled = True
|
|
557
|
+
return None
|
|
558
|
+
finally:
|
|
559
|
+
turn.loop = None
|
|
560
|
+
turn.task = None
|
|
561
|
+
loop.close()
|
|
562
|
+
|
|
563
|
+
def _stop_reason(self, turn: _TurnState, outcome: Any) -> str:
|
|
564
|
+
if turn.cancelled:
|
|
565
|
+
return "cancelled"
|
|
566
|
+
if "max_turns" in turn.done_reasons or "budget" in turn.done_reasons:
|
|
567
|
+
return "max_tokens"
|
|
568
|
+
if outcome is None or not getattr(outcome, "success", False):
|
|
569
|
+
return "refusal"
|
|
570
|
+
return "end_turn"
|
|
571
|
+
|
|
572
|
+
# -- permission bridge --------------------------------------------------
|
|
573
|
+
|
|
574
|
+
def _request_permission_sync(
|
|
575
|
+
self, turn: _TurnState, state: _SessionState, summary: str
|
|
576
|
+
) -> bool:
|
|
577
|
+
"""Ask the editor to approve an action, denied on timeout/EOF."""
|
|
578
|
+
loop = self._loop
|
|
579
|
+
if loop is None or loop.is_closed():
|
|
580
|
+
return False
|
|
581
|
+
tool_name = summary.split("(", 1)[0].strip()
|
|
582
|
+
call_id = state.allocate_call_id()
|
|
583
|
+
state.pending_permission_id = call_id
|
|
584
|
+
params = {
|
|
585
|
+
"sessionId": turn.session_id,
|
|
586
|
+
"toolCall": {
|
|
587
|
+
"toolCallId": call_id,
|
|
588
|
+
"title": summary,
|
|
589
|
+
"kind": tool_kind(tool_name),
|
|
590
|
+
"status": "pending",
|
|
591
|
+
},
|
|
592
|
+
"options": PERMISSION_OPTIONS,
|
|
593
|
+
}
|
|
594
|
+
try:
|
|
595
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
596
|
+
self._request(
|
|
597
|
+
"session/request_permission",
|
|
598
|
+
params,
|
|
599
|
+
session_id=turn.session_id,
|
|
600
|
+
timeout=self.permission_timeout,
|
|
601
|
+
),
|
|
602
|
+
loop,
|
|
603
|
+
)
|
|
604
|
+
result = future.result(timeout=self.permission_timeout + 5.0)
|
|
605
|
+
except Exception:
|
|
606
|
+
state.pending_permission_id = None
|
|
607
|
+
return False
|
|
608
|
+
|
|
609
|
+
selected = _selected_option(result)
|
|
610
|
+
if selected is None:
|
|
611
|
+
state.pending_permission_id = None
|
|
612
|
+
return False
|
|
613
|
+
if selected == "allow_always" and tool_name:
|
|
614
|
+
try:
|
|
615
|
+
config.persist_always_allowed_tool(tool_name)
|
|
616
|
+
except OSError: # pragma: no cover - unwritable config dir
|
|
617
|
+
pass
|
|
618
|
+
state.session.allow_always(tool_name)
|
|
619
|
+
return True
|
|
620
|
+
|
|
621
|
+
# -- agent -> client plumbing -------------------------------------------
|
|
622
|
+
|
|
623
|
+
async def _request(
|
|
624
|
+
self,
|
|
625
|
+
method: str,
|
|
626
|
+
params: dict[str, Any],
|
|
627
|
+
*,
|
|
628
|
+
session_id: str | None = None,
|
|
629
|
+
timeout: float | None = None,
|
|
630
|
+
) -> Any:
|
|
631
|
+
loop = self._loop
|
|
632
|
+
assert loop is not None
|
|
633
|
+
request_id = self._ids.next()
|
|
634
|
+
future: asyncio.Future[Any] = loop.create_future()
|
|
635
|
+
self._pending[request_id] = _PendingRequest(
|
|
636
|
+
future=future, method=method, session_id=session_id
|
|
637
|
+
)
|
|
638
|
+
try:
|
|
639
|
+
await self._send(request_message(request_id, method, params))
|
|
640
|
+
try:
|
|
641
|
+
return await asyncio.wait_for(future, timeout)
|
|
642
|
+
except asyncio.TimeoutError:
|
|
643
|
+
raise AcpTimeoutError(
|
|
644
|
+
f"{method} timed out after {timeout:g}s"
|
|
645
|
+
) from None
|
|
646
|
+
finally:
|
|
647
|
+
self._pending.pop(request_id, None)
|
|
648
|
+
|
|
649
|
+
def _resolve_response(self, message: dict[str, Any]) -> None:
|
|
650
|
+
request_id = message.get("id")
|
|
651
|
+
if not isinstance(request_id, int):
|
|
652
|
+
return
|
|
653
|
+
pending = self._pending.get(request_id)
|
|
654
|
+
if pending is None or pending.future.done():
|
|
655
|
+
return
|
|
656
|
+
error = message.get("error")
|
|
657
|
+
if isinstance(error, dict):
|
|
658
|
+
code = error.get("code")
|
|
659
|
+
pending.future.set_exception(
|
|
660
|
+
AcpError(
|
|
661
|
+
code if isinstance(code, int) else ERROR_INTERNAL,
|
|
662
|
+
str(error.get("message") or "Client request failed"),
|
|
663
|
+
error.get("data"),
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
return
|
|
667
|
+
pending.future.set_result(message.get("result"))
|
|
668
|
+
|
|
669
|
+
def _translate_event(
|
|
670
|
+
self, state: _SessionState, turn: _TurnState, name: str, payload: dict[str, Any]
|
|
671
|
+
) -> None:
|
|
672
|
+
"""Forward one agent render event to the client as ``session/update``."""
|
|
673
|
+
if name == "text_delta":
|
|
674
|
+
text = str(payload.get("text") or "")
|
|
675
|
+
if text:
|
|
676
|
+
turn.saw_text = True
|
|
677
|
+
self._emit_update_threadsafe(
|
|
678
|
+
turn,
|
|
679
|
+
{
|
|
680
|
+
"sessionUpdate": "agent_message_chunk",
|
|
681
|
+
"content": {"type": "text", "text": text},
|
|
682
|
+
},
|
|
683
|
+
)
|
|
684
|
+
elif name == "tool_start":
|
|
685
|
+
tool_name = str(payload.get("tool") or "")
|
|
686
|
+
args = payload.get("args")
|
|
687
|
+
call_id, title, kind = state.begin_tool(
|
|
688
|
+
tool_name, args if isinstance(args, dict) else {}
|
|
689
|
+
)
|
|
690
|
+
self._emit_update_threadsafe(
|
|
691
|
+
turn,
|
|
692
|
+
{
|
|
693
|
+
"sessionUpdate": "tool_call",
|
|
694
|
+
"toolCallId": call_id,
|
|
695
|
+
"title": title,
|
|
696
|
+
"kind": kind,
|
|
697
|
+
"status": "in_progress",
|
|
698
|
+
},
|
|
699
|
+
)
|
|
700
|
+
elif name == "tool_end":
|
|
701
|
+
tool_name = str(payload.get("tool") or "")
|
|
702
|
+
finished_id = state.end_tool(tool_name)
|
|
703
|
+
if finished_id is not None:
|
|
704
|
+
ok = bool(payload.get("ok"))
|
|
705
|
+
duration = payload.get("duration_ms")
|
|
706
|
+
detail = f"{tool_name} {'completed' if ok else 'failed'}"
|
|
707
|
+
if isinstance(duration, int):
|
|
708
|
+
detail += f" ({duration}ms)"
|
|
709
|
+
self._emit_update_threadsafe(
|
|
710
|
+
turn,
|
|
711
|
+
{
|
|
712
|
+
"sessionUpdate": "tool_call_update",
|
|
713
|
+
"toolCallId": finished_id,
|
|
714
|
+
"status": "completed" if ok else "failed",
|
|
715
|
+
"content": [
|
|
716
|
+
{
|
|
717
|
+
"type": "content",
|
|
718
|
+
"content": {"type": "text", "text": detail},
|
|
719
|
+
}
|
|
720
|
+
],
|
|
721
|
+
},
|
|
722
|
+
)
|
|
723
|
+
elif name == "done":
|
|
724
|
+
turn.done_reasons.append(str(payload.get("reason") or ""))
|
|
725
|
+
|
|
726
|
+
def _emit_update_threadsafe(
|
|
727
|
+
self, turn: _TurnState, update: dict[str, Any]
|
|
728
|
+
) -> None:
|
|
729
|
+
loop = self._loop
|
|
730
|
+
if loop is None or loop.is_closed():
|
|
731
|
+
return
|
|
732
|
+
try:
|
|
733
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
734
|
+
self._emit_update(turn.session_id, update), loop
|
|
735
|
+
)
|
|
736
|
+
except RuntimeError:
|
|
737
|
+
return
|
|
738
|
+
turn.pending_updates.append(future)
|
|
739
|
+
future.add_done_callback(_consume_future_exception)
|
|
740
|
+
|
|
741
|
+
async def _flush_updates(self, turn: _TurnState) -> None:
|
|
742
|
+
"""Await every ``session/update`` scheduled during the turn."""
|
|
743
|
+
pending, turn.pending_updates = list(turn.pending_updates), []
|
|
744
|
+
if not pending:
|
|
745
|
+
return
|
|
746
|
+
await asyncio.gather(
|
|
747
|
+
*(asyncio.wrap_future(future) for future in pending),
|
|
748
|
+
return_exceptions=True,
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
async def _emit_update(self, session_id: str, update: dict[str, Any]) -> None:
|
|
752
|
+
await self._send(
|
|
753
|
+
notification_message(
|
|
754
|
+
"session/update", {"sessionId": session_id, "update": update}
|
|
755
|
+
)
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
async def _send(self, message: dict[str, Any]) -> None:
|
|
759
|
+
if self._write_line is None:
|
|
760
|
+
return
|
|
761
|
+
line = encode(message)
|
|
762
|
+
lock = self._write_lock
|
|
763
|
+
if lock is None:
|
|
764
|
+
await self._write(line)
|
|
765
|
+
return
|
|
766
|
+
async with lock:
|
|
767
|
+
await self._write(line)
|
|
768
|
+
|
|
769
|
+
async def _write(self, line: str) -> None:
|
|
770
|
+
if self._write_line is None:
|
|
771
|
+
return
|
|
772
|
+
result = self._write_line(line)
|
|
773
|
+
if inspect.isawaitable(result):
|
|
774
|
+
await result
|
|
775
|
+
|
|
776
|
+
# -- optional client filesystem delegation ------------------------------
|
|
777
|
+
|
|
778
|
+
async def read_text_file(self, session_id: str, path: str) -> str:
|
|
779
|
+
"""Read a file, delegating to the client when it advertises fs reads.
|
|
780
|
+
|
|
781
|
+
Falls back to the local filesystem otherwise. This is an opt-in layer
|
|
782
|
+
for hosts that call it; the built-in agent tools always operate on the
|
|
783
|
+
local filesystem.
|
|
784
|
+
"""
|
|
785
|
+
if self._fs_read and session_id in self._sessions:
|
|
786
|
+
result = await self._request(
|
|
787
|
+
"fs/read_text_file",
|
|
788
|
+
{"sessionId": session_id, "path": path},
|
|
789
|
+
session_id=session_id,
|
|
790
|
+
timeout=self.permission_timeout,
|
|
791
|
+
)
|
|
792
|
+
content = result.get("content") if isinstance(result, dict) else None
|
|
793
|
+
return str(content) if content is not None else ""
|
|
794
|
+
return Path(path).read_text(encoding="utf-8")
|
|
795
|
+
|
|
796
|
+
async def write_text_file(self, session_id: str, path: str, content: str) -> None:
|
|
797
|
+
"""Write a file, delegating to the client when it advertises fs writes."""
|
|
798
|
+
if self._fs_write and session_id in self._sessions:
|
|
799
|
+
await self._request(
|
|
800
|
+
"fs/write_text_file",
|
|
801
|
+
{"sessionId": session_id, "path": path, "content": content},
|
|
802
|
+
session_id=session_id,
|
|
803
|
+
timeout=self.permission_timeout,
|
|
804
|
+
)
|
|
805
|
+
return
|
|
806
|
+
target = Path(path)
|
|
807
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
808
|
+
target.write_text(content, encoding="utf-8")
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _selected_option(result: Any) -> str | None:
|
|
812
|
+
"""Return the selected option id when the client approved the action."""
|
|
813
|
+
if not isinstance(result, dict):
|
|
814
|
+
return None
|
|
815
|
+
outcome = result.get("outcome")
|
|
816
|
+
if not isinstance(outcome, dict):
|
|
817
|
+
return None
|
|
818
|
+
if outcome.get("outcome") != "selected":
|
|
819
|
+
return None
|
|
820
|
+
option_id = outcome.get("optionId")
|
|
821
|
+
if option_id in {"allow_once", "allow_always"}:
|
|
822
|
+
return str(option_id)
|
|
823
|
+
return None
|