local-coding-agent 1.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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
"""ACP Server implementation (JSON-RPC 2.0 stdio event loop and handlers)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, BinaryIO, Callable, TextIO
|
|
12
|
+
|
|
13
|
+
from ..ast_compactor import skeletonize_file
|
|
14
|
+
from ..controller import Controller
|
|
15
|
+
from ..lsp import LspManager
|
|
16
|
+
from ..ollama_adapter import ModelProfile, OllamaError, build_client # noqa: F401
|
|
17
|
+
from ..profiles import get_profile
|
|
18
|
+
from ..ripgrep import ripgrep_search
|
|
19
|
+
from ..semantic_linter import lint_patch_in_memory
|
|
20
|
+
from ..spill import read_spill
|
|
21
|
+
from ..task import TaskEnvelope
|
|
22
|
+
from ..validators import check_patch_applies
|
|
23
|
+
|
|
24
|
+
from ._codec import AcpCodec
|
|
25
|
+
from ._protocol import (
|
|
26
|
+
JSONRPC_INTERNAL_ERROR,
|
|
27
|
+
JSONRPC_INVALID_PARAMS,
|
|
28
|
+
JSONRPC_INVALID_REQUEST,
|
|
29
|
+
JSONRPC_METHOD_NOT_FOUND,
|
|
30
|
+
JSONRPC_PARSE_ERROR,
|
|
31
|
+
PROTOCOL_VERSION,
|
|
32
|
+
SERVER_NAME,
|
|
33
|
+
SERVER_VERSION,
|
|
34
|
+
)
|
|
35
|
+
from ._session import AcpSession
|
|
36
|
+
from ._tools import ACP_TOOLS
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AcpServer:
|
|
40
|
+
"""Standard JSON-RPC 2.0 ACP Server & Interop Gateway."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
default_workspace: str | Path | None = None,
|
|
46
|
+
default_profile: str = "qwen2.5-1.5b",
|
|
47
|
+
model_factory: Callable[[Any], Any] | None = None,
|
|
48
|
+
framing: str = "auto",
|
|
49
|
+
max_request_bytes: int = 10 * 1024 * 1024,
|
|
50
|
+
server_name: str = SERVER_NAME,
|
|
51
|
+
server_version: str = SERVER_VERSION,
|
|
52
|
+
protocol_version: str = PROTOCOL_VERSION,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.default_workspace = Path(default_workspace).resolve() if default_workspace else Path.cwd().resolve()
|
|
55
|
+
self.default_profile = default_profile
|
|
56
|
+
self.model_factory = model_factory
|
|
57
|
+
self.framing = framing
|
|
58
|
+
self.max_request_bytes = max_request_bytes
|
|
59
|
+
self.server_name = server_name
|
|
60
|
+
self.server_version = server_version
|
|
61
|
+
self.protocol_version = protocol_version
|
|
62
|
+
|
|
63
|
+
self._sessions: dict[str, AcpSession] = {}
|
|
64
|
+
self._sessions_lock = threading.RLock()
|
|
65
|
+
self._write_lock = threading.RLock()
|
|
66
|
+
self._output_stream: Any | None = None
|
|
67
|
+
self._closed = False
|
|
68
|
+
self._detected_framing: str = "jsonl"
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------------
|
|
71
|
+
# Core Stream Loop & Message Processing
|
|
72
|
+
# ------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def serve(
|
|
75
|
+
self,
|
|
76
|
+
input_stream: BinaryIO | TextIO | None = None,
|
|
77
|
+
output_stream: BinaryIO | TextIO | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Run the JSON-RPC stdio event loop until EOF or shutdown."""
|
|
80
|
+
source = input_stream or getattr(sys.stdin, "buffer", sys.stdin)
|
|
81
|
+
target = output_stream or getattr(sys.stdout, "buffer", sys.stdout)
|
|
82
|
+
self._output_stream = target
|
|
83
|
+
|
|
84
|
+
while not self._closed:
|
|
85
|
+
try:
|
|
86
|
+
msg, detected_framing = AcpCodec.read_message(source, max_bytes=self.max_request_bytes)
|
|
87
|
+
if msg is None:
|
|
88
|
+
break # EOF reached
|
|
89
|
+
if detected_framing:
|
|
90
|
+
self._detected_framing = detected_framing
|
|
91
|
+
|
|
92
|
+
response = self.process_request(msg)
|
|
93
|
+
if response is not None:
|
|
94
|
+
self.write_message(response)
|
|
95
|
+
except json.JSONDecodeError as error:
|
|
96
|
+
err_resp = self._make_error(None, JSONRPC_PARSE_ERROR, f"Parse error: {error}")
|
|
97
|
+
self.write_message(err_resp)
|
|
98
|
+
except ValueError as error:
|
|
99
|
+
err_msg = str(error)
|
|
100
|
+
if "JSON-RPC message must be an object" in err_msg or "method" in err_msg:
|
|
101
|
+
err_resp = self._make_error(None, JSONRPC_INVALID_REQUEST, f"Invalid Request: {error}")
|
|
102
|
+
else:
|
|
103
|
+
err_resp = self._make_error(None, JSONRPC_PARSE_ERROR, f"Parse error: {error}")
|
|
104
|
+
self.write_message(err_resp)
|
|
105
|
+
except Exception as error:
|
|
106
|
+
err_resp = self._make_error(None, JSONRPC_INTERNAL_ERROR, f"Internal server error: {error}")
|
|
107
|
+
self.write_message(err_resp)
|
|
108
|
+
|
|
109
|
+
def write_message(self, message: dict[str, Any], framing: str | None = None) -> None:
|
|
110
|
+
"""Write a framed message to the active output stream in a thread-safe manner."""
|
|
111
|
+
target = self._output_stream
|
|
112
|
+
if target is None:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
effective_framing = framing or (self._detected_framing if self.framing == "auto" else self.framing)
|
|
116
|
+
data_bytes = AcpCodec.format_message(message, effective_framing)
|
|
117
|
+
|
|
118
|
+
with self._write_lock:
|
|
119
|
+
try:
|
|
120
|
+
target.write(data_bytes)
|
|
121
|
+
except TypeError:
|
|
122
|
+
# If target is TextIO, write decoded string
|
|
123
|
+
target.write(data_bytes.decode("utf-8", errors="replace"))
|
|
124
|
+
target.flush()
|
|
125
|
+
|
|
126
|
+
def notify(self, method: str, params: dict[str, Any]) -> None:
|
|
127
|
+
"""Send an asynchronous JSON-RPC notification to the client."""
|
|
128
|
+
notification = {
|
|
129
|
+
"jsonrpc": "2.0",
|
|
130
|
+
"method": method,
|
|
131
|
+
"params": params,
|
|
132
|
+
}
|
|
133
|
+
self.write_message(notification)
|
|
134
|
+
|
|
135
|
+
def process_request(self, raw_or_dict: str | bytes | dict[str, Any]) -> dict[str, Any] | None:
|
|
136
|
+
"""Process a single JSON-RPC message and return the response dictionary."""
|
|
137
|
+
if isinstance(raw_or_dict, (str, bytes)):
|
|
138
|
+
try:
|
|
139
|
+
text = raw_or_dict.decode("utf-8") if isinstance(raw_or_dict, bytes) else raw_or_dict
|
|
140
|
+
msg = json.loads(text)
|
|
141
|
+
except Exception as err:
|
|
142
|
+
return self._make_error(None, JSONRPC_PARSE_ERROR, f"Parse error: {err}")
|
|
143
|
+
elif isinstance(raw_or_dict, dict):
|
|
144
|
+
msg = raw_or_dict
|
|
145
|
+
else:
|
|
146
|
+
return self._make_error(None, JSONRPC_INVALID_REQUEST, "Message must be an object")
|
|
147
|
+
|
|
148
|
+
if not isinstance(msg, dict):
|
|
149
|
+
return self._make_error(None, JSONRPC_INVALID_REQUEST, "Message must be an object")
|
|
150
|
+
|
|
151
|
+
# JSON-RPC 2.0 version validation
|
|
152
|
+
if "jsonrpc" in msg and msg.get("jsonrpc") != "2.0":
|
|
153
|
+
return self._make_error(msg.get("id"), JSONRPC_INVALID_REQUEST, "Invalid JSON-RPC protocol version; expected '2.0'")
|
|
154
|
+
|
|
155
|
+
is_notification = "id" not in msg
|
|
156
|
+
req_id = msg.get("id")
|
|
157
|
+
method = msg.get("method")
|
|
158
|
+
params = msg.get("params")
|
|
159
|
+
if params is None:
|
|
160
|
+
params = {}
|
|
161
|
+
|
|
162
|
+
if not isinstance(method, str) or not method.strip():
|
|
163
|
+
if is_notification:
|
|
164
|
+
return None
|
|
165
|
+
return self._make_error(req_id, JSONRPC_INVALID_REQUEST, "Missing or invalid 'method' field")
|
|
166
|
+
if not isinstance(params, (dict, list)):
|
|
167
|
+
if is_notification:
|
|
168
|
+
return None
|
|
169
|
+
return self._make_error(req_id, JSONRPC_INVALID_PARAMS, "'params' must be an object or array")
|
|
170
|
+
|
|
171
|
+
# Normalize method names
|
|
172
|
+
normalized_method = self._normalize_method(method)
|
|
173
|
+
handler = getattr(self, f"handle_{normalized_method}", None)
|
|
174
|
+
if handler is None:
|
|
175
|
+
if not is_notification:
|
|
176
|
+
return self._make_error(req_id, JSONRPC_METHOD_NOT_FOUND, f"Method not found: {method}")
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
result = handler(params if isinstance(params, dict) else {"_args": params})
|
|
181
|
+
if not is_notification:
|
|
182
|
+
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
|
183
|
+
return None
|
|
184
|
+
except ValueError as err:
|
|
185
|
+
if not is_notification:
|
|
186
|
+
return self._make_error(req_id, JSONRPC_INVALID_PARAMS, str(err))
|
|
187
|
+
return None
|
|
188
|
+
except Exception as err:
|
|
189
|
+
if not is_notification:
|
|
190
|
+
return self._make_error(req_id, JSONRPC_INTERNAL_ERROR, str(err))
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _make_error(req_id: Any, code: int, message: str, data: Any = None) -> dict[str, Any]:
|
|
195
|
+
err_obj: dict[str, Any] = {"code": code, "message": message}
|
|
196
|
+
if data is not None:
|
|
197
|
+
err_obj["data"] = data
|
|
198
|
+
return {"jsonrpc": "2.0", "id": req_id, "error": err_obj}
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _normalize_method(method: str) -> str:
|
|
202
|
+
mapping = {
|
|
203
|
+
"initialize": "initialize",
|
|
204
|
+
"init": "initialize",
|
|
205
|
+
"session/new": "session_new",
|
|
206
|
+
"session_new": "session_new",
|
|
207
|
+
"new_session": "session_new",
|
|
208
|
+
"newSession": "session_new",
|
|
209
|
+
"session/load": "session_load",
|
|
210
|
+
"session_load": "session_load",
|
|
211
|
+
"load_session": "session_load",
|
|
212
|
+
"loadSession": "session_load",
|
|
213
|
+
"session/prompt": "session_prompt",
|
|
214
|
+
"session_prompt": "session_prompt",
|
|
215
|
+
"prompt": "session_prompt",
|
|
216
|
+
"session/cancel": "session_cancel",
|
|
217
|
+
"session_cancel": "session_cancel",
|
|
218
|
+
"cancel": "session_cancel",
|
|
219
|
+
"tools/list": "tools_list",
|
|
220
|
+
"tools_list": "tools_list",
|
|
221
|
+
"list_tools": "tools_list",
|
|
222
|
+
"listTools": "tools_list",
|
|
223
|
+
"tools/call": "tools_call",
|
|
224
|
+
"tools_call": "tools_call",
|
|
225
|
+
"call_tool": "tools_call",
|
|
226
|
+
"callTool": "tools_call",
|
|
227
|
+
"ping": "ping",
|
|
228
|
+
"shutdown": "shutdown",
|
|
229
|
+
"exit": "exit",
|
|
230
|
+
}
|
|
231
|
+
return mapping.get(method, method.replace("/", "_"))
|
|
232
|
+
|
|
233
|
+
# ------------------------------------------------------------------------
|
|
234
|
+
# Protocol Handlers
|
|
235
|
+
# ------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def handle_initialize(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
238
|
+
"""Handle initialize handshake."""
|
|
239
|
+
return {
|
|
240
|
+
"protocol_version": self.protocol_version,
|
|
241
|
+
"protocolVersion": self.protocol_version,
|
|
242
|
+
"server_info": {
|
|
243
|
+
"name": self.server_name,
|
|
244
|
+
"version": self.server_version,
|
|
245
|
+
},
|
|
246
|
+
"serverInfo": {
|
|
247
|
+
"name": self.server_name,
|
|
248
|
+
"version": self.server_version,
|
|
249
|
+
},
|
|
250
|
+
"agentInfo": {
|
|
251
|
+
"name": self.server_name,
|
|
252
|
+
"version": self.server_version,
|
|
253
|
+
},
|
|
254
|
+
"capabilities": {
|
|
255
|
+
"sessions": True,
|
|
256
|
+
"streaming": True,
|
|
257
|
+
"tools": True,
|
|
258
|
+
"cancellation": True,
|
|
259
|
+
"load_session": True,
|
|
260
|
+
"promptCapabilities": {
|
|
261
|
+
"image": False,
|
|
262
|
+
"audio": False,
|
|
263
|
+
"embeddedContext": True,
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
"agentCapabilities": {
|
|
267
|
+
"promptCapabilities": {
|
|
268
|
+
"image": False,
|
|
269
|
+
"audio": False,
|
|
270
|
+
"embeddedContext": True,
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
"authMethods": [],
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
def handle_session_new(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
277
|
+
"""Create a new ACP session with workspace and profile configuration."""
|
|
278
|
+
raw_ws = (
|
|
279
|
+
params.get("workspace_path")
|
|
280
|
+
or params.get("workspacePath")
|
|
281
|
+
or params.get("cwd")
|
|
282
|
+
or params.get("workspace")
|
|
283
|
+
or str(self.default_workspace)
|
|
284
|
+
)
|
|
285
|
+
ws_path = Path(raw_ws).resolve()
|
|
286
|
+
if not ws_path.is_dir():
|
|
287
|
+
raise ValueError(f"Workspace path does not exist or is not a directory: {ws_path}")
|
|
288
|
+
|
|
289
|
+
profile = (
|
|
290
|
+
params.get("profile")
|
|
291
|
+
or params.get("model_profile")
|
|
292
|
+
or params.get("model")
|
|
293
|
+
or self.default_profile
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
session_id = params.get("session_id") or params.get("sessionId") or f"session_{uuid.uuid4().hex[:12]}"
|
|
297
|
+
|
|
298
|
+
session = AcpSession(
|
|
299
|
+
session_id=session_id,
|
|
300
|
+
workspace_path=ws_path,
|
|
301
|
+
profile=profile,
|
|
302
|
+
metadata={"created_via": "acp"},
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
with self._sessions_lock:
|
|
306
|
+
self._sessions[session_id] = session
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
"session_id": session_id,
|
|
310
|
+
"sessionId": session_id,
|
|
311
|
+
"workspace_path": str(ws_path),
|
|
312
|
+
"profile": profile,
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
def handle_session_load(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
316
|
+
"""Load an existing session's history and configuration."""
|
|
317
|
+
session_id = params.get("session_id") or params.get("sessionId")
|
|
318
|
+
if not session_id:
|
|
319
|
+
raise ValueError("session_id parameter is required")
|
|
320
|
+
|
|
321
|
+
with self._sessions_lock:
|
|
322
|
+
session = self._sessions.get(session_id)
|
|
323
|
+
|
|
324
|
+
if session is None:
|
|
325
|
+
raise ValueError(f"Session not found: {session_id}")
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
"session_id": session.session_id,
|
|
329
|
+
"sessionId": session.session_id,
|
|
330
|
+
"workspace_path": str(session.workspace_path),
|
|
331
|
+
"profile": session.profile,
|
|
332
|
+
"history": list(session.history),
|
|
333
|
+
"created_at": session.created_at,
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
def handle_session_prompt(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
337
|
+
"""Execute a prompt turn in the given session."""
|
|
338
|
+
session_id = params.get("session_id") or params.get("sessionId")
|
|
339
|
+
if not session_id:
|
|
340
|
+
raise ValueError("session_id parameter is required")
|
|
341
|
+
|
|
342
|
+
with self._sessions_lock:
|
|
343
|
+
session = self._sessions.get(session_id)
|
|
344
|
+
|
|
345
|
+
if session is None:
|
|
346
|
+
raise ValueError(f"Session not found: {session_id}")
|
|
347
|
+
|
|
348
|
+
raw_prompt = params.get("prompt") or params.get("message") or params.get("content")
|
|
349
|
+
if raw_prompt is None:
|
|
350
|
+
raise ValueError("prompt parameter is required")
|
|
351
|
+
|
|
352
|
+
# Extract textual prompt
|
|
353
|
+
if isinstance(raw_prompt, str):
|
|
354
|
+
prompt_text = raw_prompt
|
|
355
|
+
elif isinstance(raw_prompt, dict):
|
|
356
|
+
prompt_text = raw_prompt.get("content") or raw_prompt.get("text") or json.dumps(raw_prompt, ensure_ascii=False)
|
|
357
|
+
elif isinstance(raw_prompt, list):
|
|
358
|
+
blocks = []
|
|
359
|
+
for b in raw_prompt:
|
|
360
|
+
if isinstance(b, dict) and "text" in b:
|
|
361
|
+
blocks.append(str(b["text"]))
|
|
362
|
+
elif isinstance(b, str):
|
|
363
|
+
blocks.append(b)
|
|
364
|
+
prompt_text = "\n".join(blocks)
|
|
365
|
+
else:
|
|
366
|
+
prompt_text = str(raw_prompt)
|
|
367
|
+
|
|
368
|
+
stream = bool(params.get("stream", params.get("streaming", False)))
|
|
369
|
+
|
|
370
|
+
with session.lock:
|
|
371
|
+
session.cancel_event.clear()
|
|
372
|
+
session.active_prompt = True
|
|
373
|
+
session.history.append({"role": "user", "content": prompt_text})
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
# Check for instant cancellation
|
|
377
|
+
if session.cancel_event.is_set():
|
|
378
|
+
return self._cancel_turn_result(session)
|
|
379
|
+
|
|
380
|
+
# Instantiate client
|
|
381
|
+
client = self._get_model_client(session.profile)
|
|
382
|
+
|
|
383
|
+
# If prompt is a TaskEnvelope representation, execute Controller
|
|
384
|
+
if isinstance(raw_prompt, dict) and "goal" in raw_prompt and "files" in raw_prompt:
|
|
385
|
+
task = TaskEnvelope.from_mapping(raw_prompt)
|
|
386
|
+
controller = Controller(
|
|
387
|
+
client,
|
|
388
|
+
session.workspace_path,
|
|
389
|
+
cancel_event=session.cancel_event,
|
|
390
|
+
)
|
|
391
|
+
task_res = controller.run(task, apply=bool(params.get("apply", False)))
|
|
392
|
+
resp_text = json.dumps(task_res, ensure_ascii=False, indent=2)
|
|
393
|
+
else:
|
|
394
|
+
# Interactive chat turn
|
|
395
|
+
resp = client.chat(session.history)
|
|
396
|
+
if session.cancel_event.is_set():
|
|
397
|
+
return self._cancel_turn_result(session)
|
|
398
|
+
|
|
399
|
+
msg_obj = resp.get("message", {}) if isinstance(resp, dict) else {}
|
|
400
|
+
resp_text = msg_obj.get("content", "")
|
|
401
|
+
|
|
402
|
+
if stream and resp_text:
|
|
403
|
+
self.notify(
|
|
404
|
+
"session/update",
|
|
405
|
+
{
|
|
406
|
+
"sessionId": session.session_id,
|
|
407
|
+
"session_id": session.session_id,
|
|
408
|
+
"update": {
|
|
409
|
+
"sessionUpdate": "agent_message_chunk",
|
|
410
|
+
"content": [{"type": "text", "text": resp_text}],
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
session.history.append({"role": "assistant", "content": resp_text})
|
|
416
|
+
turn_idx = len(session.history) // 2
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
"session_id": session.session_id,
|
|
420
|
+
"sessionId": session.session_id,
|
|
421
|
+
"stop_reason": "end_turn",
|
|
422
|
+
"stopReason": "end_turn",
|
|
423
|
+
"content": resp_text,
|
|
424
|
+
"message": {"role": "assistant", "content": resp_text},
|
|
425
|
+
"turn": turn_idx,
|
|
426
|
+
"status": "completed",
|
|
427
|
+
}
|
|
428
|
+
except Exception as error:
|
|
429
|
+
if session.cancel_event.is_set():
|
|
430
|
+
return self._cancel_turn_result(session)
|
|
431
|
+
raise
|
|
432
|
+
finally:
|
|
433
|
+
session.active_prompt = False
|
|
434
|
+
|
|
435
|
+
def _cancel_turn_result(self, session: AcpSession) -> dict[str, Any]:
|
|
436
|
+
session.history.append({"role": "assistant", "content": "", "status": "cancelled"})
|
|
437
|
+
return {
|
|
438
|
+
"session_id": session.session_id,
|
|
439
|
+
"sessionId": session.session_id,
|
|
440
|
+
"stop_reason": "cancelled",
|
|
441
|
+
"stopReason": "cancelled",
|
|
442
|
+
"content": "",
|
|
443
|
+
"message": {"role": "assistant", "content": ""},
|
|
444
|
+
"status": "cancelled",
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
def handle_session_cancel(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
448
|
+
"""Cancel an in-flight prompt on the active session."""
|
|
449
|
+
session_id = params.get("session_id") or params.get("sessionId")
|
|
450
|
+
if not session_id:
|
|
451
|
+
raise ValueError("session_id parameter is required")
|
|
452
|
+
|
|
453
|
+
with self._sessions_lock:
|
|
454
|
+
session = self._sessions.get(session_id)
|
|
455
|
+
|
|
456
|
+
if session is None:
|
|
457
|
+
return {"session_id": session_id, "sessionId": session_id, "cancelled": False, "error": "session not found"}
|
|
458
|
+
|
|
459
|
+
session.cancel_event.set()
|
|
460
|
+
return {
|
|
461
|
+
"session_id": session.session_id,
|
|
462
|
+
"sessionId": session.session_id,
|
|
463
|
+
"cancelled": True,
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
def handle_tools_list(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
467
|
+
"""List available tools exposed to ACP clients."""
|
|
468
|
+
return {"tools": ACP_TOOLS}
|
|
469
|
+
|
|
470
|
+
def handle_tools_call(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
471
|
+
"""Execute a specific tool call over ACP."""
|
|
472
|
+
name = params.get("name") or params.get("tool")
|
|
473
|
+
if not name:
|
|
474
|
+
raise ValueError("tool name parameter is required")
|
|
475
|
+
|
|
476
|
+
args = params.get("arguments") or params.get("args") or {}
|
|
477
|
+
if not isinstance(args, dict):
|
|
478
|
+
raise ValueError("tool arguments must be an object")
|
|
479
|
+
|
|
480
|
+
ws_str = params.get("workspace") or args.get("workspace") or str(self.default_workspace)
|
|
481
|
+
ws_path = Path(ws_str).resolve()
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
if name == "spill_read":
|
|
485
|
+
locator = args.get("locator", "")
|
|
486
|
+
offset = int(args.get("offset", 0))
|
|
487
|
+
limit = int(args.get("limit", 1000))
|
|
488
|
+
content = read_spill(locator, offset_line=offset, limit_lines=limit)
|
|
489
|
+
return self._tool_success(content, {"locator": locator, "offset": offset, "limit": limit, "content": content})
|
|
490
|
+
|
|
491
|
+
elif name == "grep":
|
|
492
|
+
query = args.get("query", "")
|
|
493
|
+
paths = args.get("paths")
|
|
494
|
+
if paths and isinstance(paths, list):
|
|
495
|
+
for p in paths:
|
|
496
|
+
if isinstance(p, str) and (".." in p or p.startswith("/") or (len(p) > 1 and p[1] == ":")):
|
|
497
|
+
raise ValueError(f"Grep path escapes workspace boundary: {p}")
|
|
498
|
+
regex = bool(args.get("regex", args.get("is_regex", False)))
|
|
499
|
+
case_sens = bool(args.get("case_sensitive", False))
|
|
500
|
+
max_res = int(args.get("max_results", 100))
|
|
501
|
+
matches = ripgrep_search(
|
|
502
|
+
query,
|
|
503
|
+
root=ws_path,
|
|
504
|
+
globs=paths,
|
|
505
|
+
is_regex=regex,
|
|
506
|
+
case_sensitive=case_sens,
|
|
507
|
+
max_results=max_res,
|
|
508
|
+
)
|
|
509
|
+
match_dicts = [
|
|
510
|
+
{"file": m.file, "line": m.line_number, "text": m.line_content}
|
|
511
|
+
for m in matches
|
|
512
|
+
]
|
|
513
|
+
formatted = "\n".join(f"{m['file']}:{m['line']}: {m['text']}" for m in match_dicts)
|
|
514
|
+
return self._tool_success(formatted, {"count": len(matches), "matches": match_dicts})
|
|
515
|
+
|
|
516
|
+
elif name == "lsp":
|
|
517
|
+
op = args.get("operation")
|
|
518
|
+
file_rel = args.get("file", "")
|
|
519
|
+
target_file = (ws_path / file_rel).resolve()
|
|
520
|
+
if not self._is_safe_path(target_file, ws_path):
|
|
521
|
+
raise ValueError(f"Path escapes workspace boundary: {file_rel}")
|
|
522
|
+
line = int(args.get("line", 0))
|
|
523
|
+
char = int(args.get("char", args.get("character", 0)))
|
|
524
|
+
lsp_mgr = LspManager(workspace_root=ws_path)
|
|
525
|
+
|
|
526
|
+
if op == "definition":
|
|
527
|
+
res = lsp_mgr.go_to_definition(file_rel, line=line, character=char, workspace_root=ws_path)
|
|
528
|
+
data = [r.to_dict() for r in res]
|
|
529
|
+
elif op == "references":
|
|
530
|
+
res = lsp_mgr.find_references(file_rel, line=line, character=char, workspace_root=ws_path)
|
|
531
|
+
data = [r.to_dict() for r in res]
|
|
532
|
+
elif op == "hover":
|
|
533
|
+
h = lsp_mgr.hover(file_rel, line=line, character=char, workspace_root=ws_path)
|
|
534
|
+
data = h.to_dict() if h else None
|
|
535
|
+
elif op == "symbols":
|
|
536
|
+
syms = lsp_mgr.document_symbols(file_rel, workspace_root=ws_path)
|
|
537
|
+
data = [s.to_dict() for s in syms]
|
|
538
|
+
else:
|
|
539
|
+
raise ValueError(f"Unknown LSP operation: {op}")
|
|
540
|
+
|
|
541
|
+
return self._tool_success(json.dumps(data, ensure_ascii=False, indent=2), data)
|
|
542
|
+
|
|
543
|
+
elif name == "skeletonize":
|
|
544
|
+
file_rel = args.get("file") or args.get("path", "")
|
|
545
|
+
target_file = (ws_path / file_rel).resolve()
|
|
546
|
+
if not self._is_safe_path(target_file, ws_path):
|
|
547
|
+
raise ValueError(f"Path escapes workspace boundary: {file_rel}")
|
|
548
|
+
symbols = args.get("symbols")
|
|
549
|
+
skeleton = skeletonize_file(str(target_file), target_symbols=symbols)
|
|
550
|
+
return self._tool_success(skeleton, {"file": str(file_rel), "skeleton": skeleton})
|
|
551
|
+
|
|
552
|
+
elif name == "lint_patch":
|
|
553
|
+
patch = args.get("patch", "")
|
|
554
|
+
report = lint_patch_in_memory(str(ws_path), patch)
|
|
555
|
+
data = {
|
|
556
|
+
"valid": report.valid,
|
|
557
|
+
"diagnostics": [
|
|
558
|
+
{"file": d.file, "line": d.line, "message": d.message, "rule": d.rule}
|
|
559
|
+
for d in report.diagnostics
|
|
560
|
+
],
|
|
561
|
+
"prescriptions": list(report.prescriptions),
|
|
562
|
+
}
|
|
563
|
+
text_out = "Valid patch" if report.valid else f"Lint issues: {list(report.prescriptions)}"
|
|
564
|
+
return self._tool_success(text_out, data)
|
|
565
|
+
|
|
566
|
+
elif name == "read_file":
|
|
567
|
+
rel = args.get("path") or args.get("file", "")
|
|
568
|
+
target = (ws_path / rel).resolve()
|
|
569
|
+
if not self._is_safe_path(target, ws_path):
|
|
570
|
+
raise ValueError(f"Path escapes workspace boundary: {rel}")
|
|
571
|
+
content = target.read_text(encoding="utf-8", errors="replace")
|
|
572
|
+
return self._tool_success(content, {"path": rel, "content": content})
|
|
573
|
+
|
|
574
|
+
elif name == "list_files":
|
|
575
|
+
rel = args.get("path", ".")
|
|
576
|
+
target = (ws_path / rel).resolve()
|
|
577
|
+
if not self._is_safe_path(target, ws_path):
|
|
578
|
+
raise ValueError(f"Path escapes workspace boundary: {rel}")
|
|
579
|
+
files = []
|
|
580
|
+
for p in target.rglob("*"):
|
|
581
|
+
if p.is_file() and not any(part.startswith(".") or part in {"__pycache__", "node_modules", ".venv"} for part in p.parts):
|
|
582
|
+
files.append(p.relative_to(ws_path).as_posix())
|
|
583
|
+
return self._tool_success("\n".join(files), {"files": files})
|
|
584
|
+
|
|
585
|
+
elif name == "run_tests":
|
|
586
|
+
cmd = args.get("command", "")
|
|
587
|
+
timeout = float(args.get("timeout", 60))
|
|
588
|
+
cp = subprocess.run(
|
|
589
|
+
cmd,
|
|
590
|
+
shell=True,
|
|
591
|
+
cwd=ws_path,
|
|
592
|
+
stdout=subprocess.PIPE,
|
|
593
|
+
stderr=subprocess.PIPE,
|
|
594
|
+
text=True,
|
|
595
|
+
timeout=timeout,
|
|
596
|
+
)
|
|
597
|
+
passed = cp.returncode == 0
|
|
598
|
+
out = (cp.stdout + cp.stderr).strip()
|
|
599
|
+
return self._tool_success(out, {"command": cmd, "exit_code": cp.returncode, "passed": passed, "stdout": cp.stdout, "stderr": cp.stderr})
|
|
600
|
+
|
|
601
|
+
elif name == "propose_patch":
|
|
602
|
+
patch = args.get("patch", "")
|
|
603
|
+
applies, err = check_patch_applies(ws_path, patch)
|
|
604
|
+
return self._tool_success(
|
|
605
|
+
"Applies cleanly" if applies else f"Check failed: {err}",
|
|
606
|
+
{"applies": applies, "error": err if not applies else None},
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
else:
|
|
610
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
611
|
+
|
|
612
|
+
except Exception as error:
|
|
613
|
+
return self._tool_error(str(error))
|
|
614
|
+
|
|
615
|
+
def handle_ping(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
616
|
+
"""Health-check ping."""
|
|
617
|
+
return {"status": "ok", "pong": True}
|
|
618
|
+
|
|
619
|
+
def handle_shutdown(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
620
|
+
"""Gracefully shutdown server."""
|
|
621
|
+
self._closed = True
|
|
622
|
+
return {"status": "shutdown_acknowledged"}
|
|
623
|
+
|
|
624
|
+
def handle_exit(self, params: dict[str, Any] | None = None) -> None:
|
|
625
|
+
"""Exit server."""
|
|
626
|
+
self._closed = True
|
|
627
|
+
|
|
628
|
+
# ------------------------------------------------------------------------
|
|
629
|
+
# Helpers
|
|
630
|
+
# ------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
def _get_model_client(self, profile_name: str) -> Any:
|
|
633
|
+
if self.model_factory is not None:
|
|
634
|
+
return self.model_factory(profile_name)
|
|
635
|
+
profile = get_profile(profile_name)
|
|
636
|
+
return build_client(profile)
|
|
637
|
+
|
|
638
|
+
@staticmethod
|
|
639
|
+
def _is_safe_path(target: Path, root: Path) -> bool:
|
|
640
|
+
try:
|
|
641
|
+
target.resolve().relative_to(root.resolve())
|
|
642
|
+
return True
|
|
643
|
+
except ValueError:
|
|
644
|
+
return False
|
|
645
|
+
|
|
646
|
+
@staticmethod
|
|
647
|
+
def _tool_success(text_out: str, data: Any = None) -> dict[str, Any]:
|
|
648
|
+
return {
|
|
649
|
+
"content": [{"type": "text", "text": text_out}],
|
|
650
|
+
"result": data,
|
|
651
|
+
"is_error": False,
|
|
652
|
+
"isError": False,
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
@staticmethod
|
|
656
|
+
def _tool_error(err_msg: str) -> dict[str, Any]:
|
|
657
|
+
return {
|
|
658
|
+
"content": [{"type": "text", "text": f"Error: {err_msg}"}],
|
|
659
|
+
"result": None,
|
|
660
|
+
"is_error": True,
|
|
661
|
+
"isError": True,
|
|
662
|
+
"error": err_msg,
|
|
663
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""ACP session model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AcpSession:
|
|
14
|
+
"""An active Agent Client Protocol session."""
|
|
15
|
+
|
|
16
|
+
session_id: str
|
|
17
|
+
workspace_path: Path
|
|
18
|
+
profile: str
|
|
19
|
+
history: list[dict[str, Any]] = field(default_factory=list)
|
|
20
|
+
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
21
|
+
cancel_event: threading.Event = field(default_factory=threading.Event)
|
|
22
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
23
|
+
active_prompt: bool = False
|
|
24
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|