lcode-cli 0.18.0__py3-none-any.whl → 0.20.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.
- lcode/__init__.py +1 -1
- lcode/acp.py +635 -0
- lcode/agent.py +39 -1
- lcode/api.py +6 -2
- lcode/cli.py +12 -0
- lcode/config.py +2 -0
- lcode/jobs.py +165 -0
- lcode/notify.py +43 -0
- lcode/permissions.py +5 -3
- lcode/planning.py +8 -1
- lcode/repl.py +26 -3
- lcode/subagents.py +1 -0
- lcode/tools.py +100 -8
- {lcode_cli-0.18.0.dist-info → lcode_cli-0.20.0.dist-info}/METADATA +3 -3
- {lcode_cli-0.18.0.dist-info → lcode_cli-0.20.0.dist-info}/RECORD +18 -15
- {lcode_cli-0.18.0.dist-info → lcode_cli-0.20.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.18.0.dist-info → lcode_cli-0.20.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.18.0.dist-info → lcode_cli-0.20.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/acp.py
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"""`lcode acp`: lcode as the agent inside an editor, over the Agent Client Protocol (ACP, version 1).
|
|
2
|
+
|
|
3
|
+
The editor starts `lcode acp` and talks JSON-RPC 2.0 over its stdin and stdout, one message per
|
|
4
|
+
line. The editor shows what happens:
|
|
5
|
+
- the model's answers and thinking stream as message chunks;
|
|
6
|
+
- each tool call is a tool call with its kind, files and, for edits, a diff;
|
|
7
|
+
- permission requests and plan approvals use the editor's own dialog;
|
|
8
|
+
- the todo list becomes the editor's plan;
|
|
9
|
+
- lcode's permission modes are the session's modes.
|
|
10
|
+
|
|
11
|
+
Files are read from and written through the editor's buffers when it supports that, so the model
|
|
12
|
+
sees unsaved changes and the editor tracks the edits. Everything else (tools, checkpoints, MCP
|
|
13
|
+
servers, memory) works as in the terminal.
|
|
14
|
+
|
|
15
|
+
Nothing but protocol messages may reach stdout: lcode's own output is discarded (or written to
|
|
16
|
+
the file in LCODE_ACP_LOG), and stdin is the protocol channel only.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import IO, TYPE_CHECKING, Any
|
|
29
|
+
from urllib.parse import unquote, urlparse
|
|
30
|
+
|
|
31
|
+
from rich.console import Console
|
|
32
|
+
|
|
33
|
+
from lcode import __version__
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from lcode.agent import Agent
|
|
37
|
+
|
|
38
|
+
PROTOCOL_VERSION = 1
|
|
39
|
+
MODES = [
|
|
40
|
+
("ask", "Ask", "Ask before changing files or running commands"),
|
|
41
|
+
("auto-edit", "Auto-edit", "Change files without asking; ask before commands"),
|
|
42
|
+
("plan", "Plan", "Look around and plan; change nothing until you approve the plan"),
|
|
43
|
+
("yolo", "Yolo", "Never ask"),
|
|
44
|
+
]
|
|
45
|
+
KINDS = {
|
|
46
|
+
"read_file": "read", "view_image": "read", "list_dir": "search", "glob": "search", "grep": "search",
|
|
47
|
+
"repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit",
|
|
48
|
+
"bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think",
|
|
49
|
+
"present_plan": "switch_mode", "bash_output": "read", "bash_stop": "execute",
|
|
50
|
+
} # fmt: skip
|
|
51
|
+
STOP = {"success": "end_turn", "max_steps": "max_turn_requests", "interrupted": "cancelled"}
|
|
52
|
+
MAX_INLINE = 200_000 # bytes of a file the editor attaches that go into the prompt
|
|
53
|
+
METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR, NOT_FOUND = -32601, -32602, -32603, -32002
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RpcError(Exception):
|
|
57
|
+
def __init__(self, code: int, message: str):
|
|
58
|
+
super().__init__(message)
|
|
59
|
+
self.code = code
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ----------------------------------------------------------------------------- JSON-RPC over stdio
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Connection:
|
|
66
|
+
"""Newline-delimited JSON-RPC 2.0, both ways: the editor's requests and lcode's own."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, inp: IO[bytes], out: IO[bytes]):
|
|
69
|
+
self.inp, self.out = inp, out
|
|
70
|
+
self.lock = threading.Lock()
|
|
71
|
+
self.pending: dict[int, tuple[threading.Event, dict]] = {}
|
|
72
|
+
self.next_id = 0
|
|
73
|
+
self.closed = False
|
|
74
|
+
|
|
75
|
+
def send(self, message: dict) -> None:
|
|
76
|
+
data = (json.dumps({"jsonrpc": "2.0", **message}, ensure_ascii=False) + "\n").encode()
|
|
77
|
+
with self.lock:
|
|
78
|
+
self.out.write(data)
|
|
79
|
+
self.out.flush()
|
|
80
|
+
|
|
81
|
+
def notify(self, method: str, params: dict) -> None:
|
|
82
|
+
self.send({"method": method, "params": params})
|
|
83
|
+
|
|
84
|
+
def request(self, method: str, params: dict) -> dict:
|
|
85
|
+
"""Ask the editor and wait for its answer."""
|
|
86
|
+
with self.lock:
|
|
87
|
+
self.next_id += 1
|
|
88
|
+
request_id = self.next_id
|
|
89
|
+
done, slot = threading.Event(), {}
|
|
90
|
+
self.pending[request_id] = (done, slot)
|
|
91
|
+
self.send({"id": request_id, "method": method, "params": params})
|
|
92
|
+
done.wait()
|
|
93
|
+
if "error" in slot:
|
|
94
|
+
error = slot["error"] or {}
|
|
95
|
+
raise RpcError(int(error.get("code", INTERNAL_ERROR)), str(error.get("message", "the editor failed")))
|
|
96
|
+
return slot.get("result") or {}
|
|
97
|
+
|
|
98
|
+
def serve(self, handle) -> None:
|
|
99
|
+
"""Read messages until the editor closes stdin; requests are handled in threads of their own."""
|
|
100
|
+
for raw in self.inp:
|
|
101
|
+
line = raw.strip()
|
|
102
|
+
if not line:
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
message = json.loads(line)
|
|
106
|
+
except ValueError:
|
|
107
|
+
self.send({"id": None, "error": {"code": -32700, "message": "parse error"}})
|
|
108
|
+
continue
|
|
109
|
+
if "method" in message:
|
|
110
|
+
threading.Thread(target=self._dispatch, args=(handle, message), daemon=True).start()
|
|
111
|
+
elif message.get("id") in self.pending:
|
|
112
|
+
done, slot = self.pending.pop(message["id"])
|
|
113
|
+
slot.update({k: message[k] for k in ("result", "error") if k in message})
|
|
114
|
+
done.set()
|
|
115
|
+
self.closed = True
|
|
116
|
+
for done, slot in list(self.pending.values()): # nobody will answer now
|
|
117
|
+
slot["error"] = {"code": INTERNAL_ERROR, "message": "the editor closed the connection"}
|
|
118
|
+
done.set()
|
|
119
|
+
|
|
120
|
+
def _dispatch(self, handle, message: dict) -> None:
|
|
121
|
+
request_id = message.get("id")
|
|
122
|
+
try:
|
|
123
|
+
result = handle(message["method"], message.get("params") or {})
|
|
124
|
+
if request_id is not None:
|
|
125
|
+
self.send({"id": request_id, "result": result if result is not None else {}})
|
|
126
|
+
except RpcError as e:
|
|
127
|
+
if request_id is not None:
|
|
128
|
+
self.send({"id": request_id, "error": {"code": e.code, "message": str(e)}})
|
|
129
|
+
except Exception as e: # never leave the editor waiting
|
|
130
|
+
if request_id is not None:
|
|
131
|
+
self.send({"id": request_id, "error": {"code": INTERNAL_ERROR, "message": f"{type(e).__name__}: {e}"}})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ----------------------------------------------------------------------------- one editor session
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def file_path(uri: str) -> Path | None:
|
|
138
|
+
parsed = urlparse(uri)
|
|
139
|
+
if parsed.scheme in ("", "file"):
|
|
140
|
+
return Path(unquote(parsed.path)) if parsed.path else None
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def call_title(agent: Agent, name: str, args: dict) -> str:
|
|
145
|
+
path = args.get("path")
|
|
146
|
+
rel = agent.tools.rel(agent.tools.resolve(path)) if isinstance(path, str) and path else ""
|
|
147
|
+
titles = {
|
|
148
|
+
"read_file": f"Read {rel}",
|
|
149
|
+
"list_dir": f"List {rel or '.'}",
|
|
150
|
+
"glob": f"Find {args.get('pattern', '')}",
|
|
151
|
+
"grep": f"Search for {args.get('pattern', '')}",
|
|
152
|
+
"edit_file": f"Edit {rel}",
|
|
153
|
+
"write_file": f"Write {rel}",
|
|
154
|
+
"bash": str(args.get("command", "")).strip()[:200],
|
|
155
|
+
"web_search": f"Search the web for {args.get('query', '')}",
|
|
156
|
+
"web_fetch": f"Fetch {args.get('url', '')}",
|
|
157
|
+
"todo_write": "Update the plan",
|
|
158
|
+
"present_plan": f"Plan: {args.get('title', '')}",
|
|
159
|
+
"agent": f"Subagent: {args.get('description') or args.get('type', '')}",
|
|
160
|
+
}
|
|
161
|
+
return titles.get(name) or agent.describe_call(name, args)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Session:
|
|
165
|
+
"""An lcode agent wired to the editor: its output becomes session/update notifications."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, server: Server, agent: Agent):
|
|
168
|
+
self.server, self.agent, self.id = server, agent, agent.session_id
|
|
169
|
+
self.conn = server.conn
|
|
170
|
+
self.before: dict[str, str | None] = {} # file contents before an edit, by tool call id
|
|
171
|
+
self.mode = agent.perms.mode
|
|
172
|
+
agent.cancel = threading.Event()
|
|
173
|
+
agent.on_delta = self.delta
|
|
174
|
+
agent.on_event = self.event
|
|
175
|
+
agent.on_tool = self.started
|
|
176
|
+
agent.perms.approve = self.permission
|
|
177
|
+
agent.plan_review = self.review_plan
|
|
178
|
+
caps = server.client_caps.get("fs") or {}
|
|
179
|
+
if caps.get("readTextFile"):
|
|
180
|
+
agent.tools.reader = self.read_buffer
|
|
181
|
+
if caps.get("writeTextFile"):
|
|
182
|
+
agent.tools.writer = self.write_buffer
|
|
183
|
+
|
|
184
|
+
def update(self, update: dict) -> None:
|
|
185
|
+
self.conn.notify("session/update", {"sessionId": self.id, "update": update})
|
|
186
|
+
|
|
187
|
+
# -- the model's output
|
|
188
|
+
def delta(self, kind: str, text: str) -> None:
|
|
189
|
+
chunk = "agent_message_chunk" if kind == "text" else "agent_thought_chunk"
|
|
190
|
+
self.update({"sessionUpdate": chunk, "content": {"type": "text", "text": text}})
|
|
191
|
+
|
|
192
|
+
def call(self, call_id: str, name: str, args: dict, status: str) -> dict:
|
|
193
|
+
update: dict[str, Any] = {
|
|
194
|
+
"toolCallId": call_id,
|
|
195
|
+
"title": call_title(self.agent, name, args),
|
|
196
|
+
"kind": KINDS.get(name, "other"),
|
|
197
|
+
"status": status,
|
|
198
|
+
"rawInput": args,
|
|
199
|
+
}
|
|
200
|
+
path = args.get("path")
|
|
201
|
+
if isinstance(path, str) and path:
|
|
202
|
+
location: dict[str, Any] = {"path": str(self.agent.tools.resolve(path))}
|
|
203
|
+
if isinstance(args.get("offset"), int):
|
|
204
|
+
location["line"] = args["offset"]
|
|
205
|
+
update["locations"] = [location]
|
|
206
|
+
diff = self.proposed_diff(call_id, name, args)
|
|
207
|
+
if diff:
|
|
208
|
+
update["content"] = [diff]
|
|
209
|
+
return update
|
|
210
|
+
|
|
211
|
+
def proposed_diff(self, call_id: str, name: str, args: dict) -> dict | None:
|
|
212
|
+
"""What an edit will change, as the editor's diff: the whole file before and after."""
|
|
213
|
+
if name not in ("edit_file", "write_file") or not isinstance(args.get("path"), str):
|
|
214
|
+
return None
|
|
215
|
+
path = self.agent.tools.resolve(args["path"])
|
|
216
|
+
try:
|
|
217
|
+
old = self.agent.tools.read_text(path) if path.is_file() else None
|
|
218
|
+
except OSError:
|
|
219
|
+
old = None
|
|
220
|
+
if name == "write_file":
|
|
221
|
+
new = str(args.get("content", ""))
|
|
222
|
+
else:
|
|
223
|
+
find, replace = str(args.get("old_string", "")), str(args.get("new_string", ""))
|
|
224
|
+
if old is None or not find or find not in old:
|
|
225
|
+
return {"type": "diff", "path": str(path), "oldText": find, "newText": replace}
|
|
226
|
+
new = old.replace(find, replace) if args.get("replace_all") else old.replace(find, replace, 1)
|
|
227
|
+
self.before[call_id] = old
|
|
228
|
+
return {"type": "diff", "path": str(path), "oldText": old, "newText": new}
|
|
229
|
+
|
|
230
|
+
def event(self, event: dict) -> None:
|
|
231
|
+
if event["type"] == "assistant":
|
|
232
|
+
for call in event["tool_calls"]:
|
|
233
|
+
self.update(
|
|
234
|
+
{"sessionUpdate": "tool_call", **self.call(call["id"], call["name"], call["arguments"], "pending")}
|
|
235
|
+
)
|
|
236
|
+
elif event["type"] == "tool_result":
|
|
237
|
+
update: dict[str, Any] = {
|
|
238
|
+
"sessionUpdate": "tool_call_update",
|
|
239
|
+
"toolCallId": event["id"],
|
|
240
|
+
"status": "failed" if event["error"] else "completed",
|
|
241
|
+
"rawOutput": {"output": event["output"]},
|
|
242
|
+
}
|
|
243
|
+
content = [{"type": "content", "content": {"type": "text", "text": event["output"]}}]
|
|
244
|
+
if event["name"] in ("edit_file", "write_file") and not event["error"]:
|
|
245
|
+
path = next((c for c in self.pending_paths(event["id"])), None)
|
|
246
|
+
if path is not None:
|
|
247
|
+
try:
|
|
248
|
+
after = path.read_text(errors="replace")
|
|
249
|
+
content = [{"type": "diff", "path": str(path), "oldText": self.before.pop(event["id"], None),
|
|
250
|
+
"newText": after}] # fmt: skip
|
|
251
|
+
except OSError:
|
|
252
|
+
pass
|
|
253
|
+
update["content"] = content
|
|
254
|
+
self.update(update)
|
|
255
|
+
if event["name"] == "todo_write":
|
|
256
|
+
self.send_plan()
|
|
257
|
+
if self.agent.perms.mode != self.mode: # an approved plan switched the mode
|
|
258
|
+
self.mode = self.agent.perms.mode
|
|
259
|
+
self.update({"sessionUpdate": "current_mode_update", "currentModeId": self.mode})
|
|
260
|
+
|
|
261
|
+
def pending_paths(self, call_id: str):
|
|
262
|
+
for message in reversed(self.agent.messages):
|
|
263
|
+
for call in message.get("tool_calls") or []:
|
|
264
|
+
if call.get("id") == call_id:
|
|
265
|
+
args = call.get("function", {}).get("arguments") or {}
|
|
266
|
+
if isinstance(args, dict) and isinstance(args.get("path"), str):
|
|
267
|
+
yield self.agent.tools.resolve(args["path"])
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
def started(self, name: str, args: dict) -> None:
|
|
271
|
+
self.update(
|
|
272
|
+
{"sessionUpdate": "tool_call_update", "toolCallId": self.agent.current_call, "status": "in_progress"}
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def send_plan(self) -> None:
|
|
276
|
+
entries = [
|
|
277
|
+
{"content": str(t.get("content", "")), "priority": "medium",
|
|
278
|
+
"status": t.get("status") if t.get("status") in ("pending", "in_progress", "completed") else "pending"}
|
|
279
|
+
for t in self.agent.tools.todos
|
|
280
|
+
] # fmt: skip
|
|
281
|
+
self.update({"sessionUpdate": "plan", "entries": entries})
|
|
282
|
+
|
|
283
|
+
# -- asking the user, in the editor
|
|
284
|
+
def ask(self, tool_call: dict, options: list[dict]) -> str | None:
|
|
285
|
+
"""The option the user picked, or None if the request was cancelled."""
|
|
286
|
+
try:
|
|
287
|
+
reply = self.conn.request(
|
|
288
|
+
"session/request_permission", {"sessionId": self.id, "toolCall": tool_call, "options": options}
|
|
289
|
+
)
|
|
290
|
+
except RpcError:
|
|
291
|
+
return None
|
|
292
|
+
outcome = reply.get("outcome") or {}
|
|
293
|
+
return outcome.get("optionId") if outcome.get("outcome") == "selected" else None
|
|
294
|
+
|
|
295
|
+
def permission(self, request: dict) -> bool | str:
|
|
296
|
+
call_id = self.agent.current_call
|
|
297
|
+
name = next(
|
|
298
|
+
(c.get("function", {}).get("name", "") for m in reversed(self.agent.messages)
|
|
299
|
+
for c in m.get("tool_calls") or [] if c.get("id") == call_id), "",
|
|
300
|
+
) # fmt: skip
|
|
301
|
+
args = next(
|
|
302
|
+
(c.get("function", {}).get("arguments") or {} for m in reversed(self.agent.messages)
|
|
303
|
+
for c in m.get("tool_calls") or [] if c.get("id") == call_id), {},
|
|
304
|
+
) # fmt: skip
|
|
305
|
+
tool_call = self.call(call_id, name, args if isinstance(args, dict) else {}, "pending") if name else {
|
|
306
|
+
"toolCallId": call_id or "permission", "title": request["title"], "kind": "other", "status": "pending"
|
|
307
|
+
} # fmt: skip
|
|
308
|
+
tool_call["title"] = request["title"]
|
|
309
|
+
scope = "file edits" if request["kind"] == "edit" else request["key"].split(":", 1)[-1]
|
|
310
|
+
choice = self.ask(
|
|
311
|
+
tool_call,
|
|
312
|
+
[
|
|
313
|
+
{"optionId": "allow", "name": "Allow", "kind": "allow_once"},
|
|
314
|
+
{"optionId": "always", "name": f"Always allow {scope} in this session", "kind": "allow_always"},
|
|
315
|
+
{"optionId": "reject", "name": "Reject", "kind": "reject_once"},
|
|
316
|
+
],
|
|
317
|
+
)
|
|
318
|
+
if choice == "always":
|
|
319
|
+
self.agent.perms.always.add("edit" if request["kind"] == "edit" else request["key"])
|
|
320
|
+
if choice in ("allow", "always"):
|
|
321
|
+
return True
|
|
322
|
+
if choice is None and self.agent.cancel is not None and self.agent.cancel.is_set():
|
|
323
|
+
return "The user denied this: they cancelled the request."
|
|
324
|
+
return "The user denied this in the editor. Ask how to proceed, or choose a different approach."
|
|
325
|
+
|
|
326
|
+
def review_plan(self, title: str, plan: str) -> str:
|
|
327
|
+
tool_call = {
|
|
328
|
+
"toolCallId": self.agent.current_call or "plan",
|
|
329
|
+
"title": f"Plan: {title}",
|
|
330
|
+
"kind": "switch_mode",
|
|
331
|
+
"status": "pending",
|
|
332
|
+
"content": [{"type": "content", "content": {"type": "text", "text": plan}}],
|
|
333
|
+
}
|
|
334
|
+
choice = self.ask(
|
|
335
|
+
tool_call,
|
|
336
|
+
[
|
|
337
|
+
{"optionId": "ask", "name": "Approve, and ask before changes", "kind": "allow_once"},
|
|
338
|
+
{"optionId": "auto-edit", "name": "Approve, and change files without asking", "kind": "allow_always"},
|
|
339
|
+
{"optionId": "keep", "name": "Keep planning", "kind": "reject_once"},
|
|
340
|
+
],
|
|
341
|
+
)
|
|
342
|
+
if choice in ("ask", "auto-edit"):
|
|
343
|
+
return choice
|
|
344
|
+
return "The user wants to keep planning. Ask what they'd like to change in the plan; don't change anything yet."
|
|
345
|
+
|
|
346
|
+
# -- the editor's buffers
|
|
347
|
+
def read_buffer(self, path: Path) -> str | None:
|
|
348
|
+
try:
|
|
349
|
+
return self.conn.request("fs/read_text_file", {"sessionId": self.id, "path": str(path)}).get("content")
|
|
350
|
+
except RpcError:
|
|
351
|
+
return None # not open in the editor, or not readable there: lcode reads the file itself
|
|
352
|
+
|
|
353
|
+
def write_buffer(self, path: Path, content: str) -> None:
|
|
354
|
+
try:
|
|
355
|
+
self.conn.request("fs/write_text_file", {"sessionId": self.id, "path": str(path), "content": content})
|
|
356
|
+
except RpcError:
|
|
357
|
+
pass # lcode writes the file itself
|
|
358
|
+
|
|
359
|
+
# -- requests
|
|
360
|
+
def prompt(self, blocks: list[dict]) -> dict:
|
|
361
|
+
from lcode import api
|
|
362
|
+
|
|
363
|
+
text = self.render(blocks)
|
|
364
|
+
if not text.strip():
|
|
365
|
+
return {"stopReason": "end_turn"}
|
|
366
|
+
command = self.command(text)
|
|
367
|
+
if command == "":
|
|
368
|
+
return {"stopReason": "end_turn"}
|
|
369
|
+
if command is not None:
|
|
370
|
+
text = command
|
|
371
|
+
self.agent.cancel.clear()
|
|
372
|
+
result = api.run_request(self.agent, text)
|
|
373
|
+
self.update({"sessionUpdate": "usage_update", "used": self.agent.ctx_used, "size": self.agent.settings.context})
|
|
374
|
+
self.update({"sessionUpdate": "session_info_update", "title": self.agent.session_title or None})
|
|
375
|
+
if result.status == "error":
|
|
376
|
+
raise RpcError(INTERNAL_ERROR, result.error)
|
|
377
|
+
return {"stopReason": STOP.get(result.status, "end_turn")}
|
|
378
|
+
|
|
379
|
+
def command(self, text: str) -> str | None:
|
|
380
|
+
"""A /command from the editor: the user's own commands, and /review."""
|
|
381
|
+
if not text.startswith("/"):
|
|
382
|
+
return None
|
|
383
|
+
name, _, arg = text[1:].partition(" ")
|
|
384
|
+
commands = self.agent.extensions().commands
|
|
385
|
+
if name in commands:
|
|
386
|
+
return commands[name].render(arg.strip())
|
|
387
|
+
if name == "review":
|
|
388
|
+
from lcode import gitflow
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
prompt = gitflow.review_prompt(self.agent, arg.strip())
|
|
392
|
+
except gitflow.GitError as e:
|
|
393
|
+
self.delta("text", f"Nothing to review: {e}")
|
|
394
|
+
return ""
|
|
395
|
+
self.agent.no_changes = "This is a review, so nothing can be changed: report what should change instead."
|
|
396
|
+
return prompt
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
def render(self, blocks: list[dict]) -> str:
|
|
400
|
+
"""The editor's prompt (text, attached files, images) as one request for lcode."""
|
|
401
|
+
parts = []
|
|
402
|
+
for block in blocks:
|
|
403
|
+
kind = block.get("type")
|
|
404
|
+
if kind == "text":
|
|
405
|
+
parts.append(str(block.get("text", "")))
|
|
406
|
+
elif kind == "resource_link":
|
|
407
|
+
parts.append(self.attached(block.get("uri", ""), None))
|
|
408
|
+
elif kind == "resource":
|
|
409
|
+
resource = block.get("resource") or {}
|
|
410
|
+
parts.append(self.attached(resource.get("uri", ""), resource.get("text")))
|
|
411
|
+
elif kind == "image" and block.get("data"):
|
|
412
|
+
parts.append(self.image(block["data"], block.get("mimeType", "image/png")))
|
|
413
|
+
return "\n\n".join(p for p in parts if p)
|
|
414
|
+
|
|
415
|
+
def attached(self, uri: str, text: str | None) -> str:
|
|
416
|
+
path = file_path(uri)
|
|
417
|
+
where = self.agent.tools.rel(path) if path else uri
|
|
418
|
+
if text is None and path is not None and path.is_file() and path.stat().st_size <= MAX_INLINE:
|
|
419
|
+
text = self.agent.tools.read_text(path)
|
|
420
|
+
if text is None:
|
|
421
|
+
return f"[The user points at {where}]"
|
|
422
|
+
return f"[The user attached {where}]\n```\n{text[:MAX_INLINE].rstrip(chr(10))}\n```"
|
|
423
|
+
|
|
424
|
+
def image(self, data: str, mime: str) -> str:
|
|
425
|
+
from lcode import config
|
|
426
|
+
|
|
427
|
+
raw = base64.b64decode(data)
|
|
428
|
+
folder = config.STATE_DIR / "acp-images"
|
|
429
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
430
|
+
path = folder / f"{hashlib.sha256(raw).hexdigest()[:16]}.{mime.split('/')[-1].split('+')[0] or 'png'}"
|
|
431
|
+
path.write_bytes(raw)
|
|
432
|
+
return f"@{path}"
|
|
433
|
+
|
|
434
|
+
def replay(self) -> None:
|
|
435
|
+
"""A loaded session's conversation, sent to the editor as it happened."""
|
|
436
|
+
results: dict[str, str] = {}
|
|
437
|
+
names: dict[str, str] = {}
|
|
438
|
+
for message in self.agent.messages[1:]:
|
|
439
|
+
role, content = message.get("role"), message.get("content") or ""
|
|
440
|
+
if role == "user" and content:
|
|
441
|
+
self.update({"sessionUpdate": "user_message_chunk", "content": {"type": "text", "text": content}})
|
|
442
|
+
elif role == "assistant":
|
|
443
|
+
if content:
|
|
444
|
+
self.update({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": content}})
|
|
445
|
+
for i, call in enumerate(message.get("tool_calls") or []):
|
|
446
|
+
call.setdefault("id", f"call_loaded_{len(names)}_{i}")
|
|
447
|
+
fn = call.get("function", {})
|
|
448
|
+
args = fn.get("arguments") if isinstance(fn.get("arguments"), dict) else {}
|
|
449
|
+
names[call["id"]] = fn.get("name", "")
|
|
450
|
+
update = {"sessionUpdate": "tool_call", "toolCallId": call["id"],
|
|
451
|
+
"title": call_title(self.agent, fn.get("name", ""), args),
|
|
452
|
+
"kind": KINDS.get(fn.get("name", ""), "other"), "status": "completed", "rawInput": args} # fmt: skip
|
|
453
|
+
self.update(update)
|
|
454
|
+
elif role == "tool":
|
|
455
|
+
call_id = message.get("tool_call_id") or next((i for i in reversed(names) if i not in results), "")
|
|
456
|
+
if call_id:
|
|
457
|
+
results[call_id] = content
|
|
458
|
+
self.update({"sessionUpdate": "tool_call_update", "toolCallId": call_id, "status": "completed",
|
|
459
|
+
"content": [{"type": "content", "content": {"type": "text", "text": content[:4000]}}]}) # fmt: skip
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ----------------------------------------------------------------------------- the agent side
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class Server:
|
|
466
|
+
def __init__(self, conn: Connection, console: Console):
|
|
467
|
+
self.conn, self.console = conn, console
|
|
468
|
+
self.client_caps: dict = {}
|
|
469
|
+
self.sessions: dict[str, Session] = {}
|
|
470
|
+
|
|
471
|
+
def handle(self, method: str, params: dict) -> dict | None:
|
|
472
|
+
handlers = {
|
|
473
|
+
"initialize": self.initialize,
|
|
474
|
+
"authenticate": lambda p: {},
|
|
475
|
+
"session/new": self.new_session,
|
|
476
|
+
"session/load": self.load_session,
|
|
477
|
+
"session/prompt": self.prompt,
|
|
478
|
+
"session/cancel": self.cancel,
|
|
479
|
+
"session/set_mode": self.set_mode,
|
|
480
|
+
}
|
|
481
|
+
if method not in handlers:
|
|
482
|
+
raise RpcError(METHOD_NOT_FOUND, f"lcode doesn't support {method}")
|
|
483
|
+
return handlers[method](params)
|
|
484
|
+
|
|
485
|
+
def initialize(self, params: dict) -> dict:
|
|
486
|
+
self.client_caps = params.get("clientCapabilities") or {}
|
|
487
|
+
return {
|
|
488
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
489
|
+
"agentCapabilities": {
|
|
490
|
+
"loadSession": True,
|
|
491
|
+
"promptCapabilities": {"image": True, "audio": False, "embeddedContext": True},
|
|
492
|
+
"mcpCapabilities": {"http": True, "sse": False},
|
|
493
|
+
},
|
|
494
|
+
"agentInfo": {"name": "lcode", "title": "lcode", "version": __version__},
|
|
495
|
+
"authMethods": [],
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
def open(self, params: dict) -> Session:
|
|
499
|
+
from lcode import api
|
|
500
|
+
|
|
501
|
+
cwd = Path(str(params.get("cwd") or ""))
|
|
502
|
+
if not cwd.is_absolute():
|
|
503
|
+
raise RpcError(INVALID_PARAMS, "cwd must be an absolute path")
|
|
504
|
+
try:
|
|
505
|
+
agent = api.open_agent(cwd, api.Options(interactive=False), self.console)
|
|
506
|
+
except api.SetupError as e:
|
|
507
|
+
raise RpcError(INTERNAL_ERROR, str(e)) from e
|
|
508
|
+
agent.mcp = self.mcp(cwd, params.get("mcpServers") or [], agent)
|
|
509
|
+
session = Session(self, agent)
|
|
510
|
+
self.sessions[session.id] = session
|
|
511
|
+
return session
|
|
512
|
+
|
|
513
|
+
def mcp(self, cwd: Path, editor_servers: list[dict], agent: Agent):
|
|
514
|
+
"""lcode's MCP servers, plus the ones the editor passes."""
|
|
515
|
+
from lcode import config
|
|
516
|
+
from lcode.mcp import config as mcp_config
|
|
517
|
+
from lcode.mcp.manager import McpManager
|
|
518
|
+
|
|
519
|
+
project = mcp_config.project_file(cwd)
|
|
520
|
+
servers, problems = mcp_config.load_all(cwd, include_project=bool(project and mcp_config.is_approved(project)))
|
|
521
|
+
names = {s.name for s in servers}
|
|
522
|
+
for entry in editor_servers:
|
|
523
|
+
name = str(entry.get("name") or "editor")
|
|
524
|
+
if name in names:
|
|
525
|
+
continue
|
|
526
|
+
if entry.get("url"):
|
|
527
|
+
raw = {
|
|
528
|
+
"type": "http",
|
|
529
|
+
"url": entry["url"],
|
|
530
|
+
"headers": {h["name"]: h["value"] for h in entry.get("headers") or []},
|
|
531
|
+
}
|
|
532
|
+
else:
|
|
533
|
+
raw = {"command": entry.get("command", ""), "args": entry.get("args") or [],
|
|
534
|
+
"env": {e["name"]: e["value"] for e in entry.get("env") or []}} # fmt: skip
|
|
535
|
+
server = mcp_config.parse(name, raw, source="editor")
|
|
536
|
+
if server.error:
|
|
537
|
+
problems.append(f"{name}: {server.error}")
|
|
538
|
+
else:
|
|
539
|
+
servers.append(server)
|
|
540
|
+
for problem in problems:
|
|
541
|
+
self.console.print(f"MCP: {problem}")
|
|
542
|
+
if not servers:
|
|
543
|
+
return None
|
|
544
|
+
manager = McpManager(cwd, servers, config.load()["mcp_tools"])
|
|
545
|
+
manager.start()
|
|
546
|
+
return manager
|
|
547
|
+
|
|
548
|
+
def modes(self, session: Session) -> dict:
|
|
549
|
+
return {
|
|
550
|
+
"currentModeId": session.agent.perms.mode,
|
|
551
|
+
"availableModes": [{"id": i, "name": n, "description": d} for i, n, d in MODES],
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
def new_session(self, params: dict) -> dict:
|
|
555
|
+
session = self.open(params)
|
|
556
|
+
self.commands(session)
|
|
557
|
+
return {"sessionId": session.id, "modes": self.modes(session)}
|
|
558
|
+
|
|
559
|
+
def load_session(self, params: dict) -> dict:
|
|
560
|
+
from lcode import sessions
|
|
561
|
+
|
|
562
|
+
session = self.open(params)
|
|
563
|
+
wanted = str(params.get("sessionId", ""))
|
|
564
|
+
found = next((s for s in sessions.list_sessions(session.agent.cwd, limit=1000) if s.id == wanted), None)
|
|
565
|
+
if found is None:
|
|
566
|
+
self.sessions.pop(session.id, None)
|
|
567
|
+
raise RpcError(NOT_FOUND, f"no saved lcode session {wanted} in {session.agent.cwd}")
|
|
568
|
+
self.sessions.pop(session.id, None)
|
|
569
|
+
session.agent.load(found)
|
|
570
|
+
session.id = session.agent.session_id
|
|
571
|
+
self.sessions[session.id] = session
|
|
572
|
+
session.replay()
|
|
573
|
+
self.commands(session)
|
|
574
|
+
return {"modes": self.modes(session)}
|
|
575
|
+
|
|
576
|
+
def commands(self, session: Session) -> None:
|
|
577
|
+
available = [{"name": "review", "description": "Review the uncommitted changes, or the branch against a base",
|
|
578
|
+
"input": {"hint": "base branch (optional)"}}] # fmt: skip
|
|
579
|
+
for name, command in session.agent.extensions().commands.items():
|
|
580
|
+
if name != "review":
|
|
581
|
+
entry: dict = {"name": name, "description": command.description}
|
|
582
|
+
if command.argument_hint:
|
|
583
|
+
entry["input"] = {"hint": command.argument_hint}
|
|
584
|
+
available.append(entry)
|
|
585
|
+
session.update({"sessionUpdate": "available_commands_update", "availableCommands": available})
|
|
586
|
+
|
|
587
|
+
def session(self, params: dict) -> Session:
|
|
588
|
+
session = self.sessions.get(str(params.get("sessionId", "")))
|
|
589
|
+
if session is None:
|
|
590
|
+
raise RpcError(NOT_FOUND, f"no session {params.get('sessionId')}")
|
|
591
|
+
return session
|
|
592
|
+
|
|
593
|
+
def prompt(self, params: dict) -> dict:
|
|
594
|
+
session = self.session(params)
|
|
595
|
+
try:
|
|
596
|
+
return session.prompt(params.get("prompt") or [])
|
|
597
|
+
finally:
|
|
598
|
+
session.agent.no_changes = ""
|
|
599
|
+
|
|
600
|
+
def cancel(self, params: dict) -> None:
|
|
601
|
+
session = self.sessions.get(str(params.get("sessionId", "")))
|
|
602
|
+
if session is not None and session.agent.cancel is not None:
|
|
603
|
+
session.agent.cancel.set()
|
|
604
|
+
from lcode import subagents
|
|
605
|
+
|
|
606
|
+
if session.agent.response is not None:
|
|
607
|
+
subagents.abort(session.agent.response) # stop the model's answer now
|
|
608
|
+
return None
|
|
609
|
+
|
|
610
|
+
def set_mode(self, params: dict) -> dict:
|
|
611
|
+
session = self.session(params)
|
|
612
|
+
mode = str(params.get("modeId", ""))
|
|
613
|
+
if mode not in {m[0] for m in MODES}:
|
|
614
|
+
raise RpcError(INVALID_PARAMS, f"unknown mode {mode}")
|
|
615
|
+
session.agent.perms.mode = session.mode = mode
|
|
616
|
+
return {}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def main() -> int:
|
|
620
|
+
"""Serve one editor over stdin and stdout until it disconnects."""
|
|
621
|
+
inp, out = sys.stdin.buffer, sys.stdout.buffer
|
|
622
|
+
sys.stdout = sys.stderr # a stray print must not corrupt the protocol
|
|
623
|
+
# Open for the life of the process:
|
|
624
|
+
sys.stdin = open(os.devnull) # noqa: SIM115 # nothing may wait for typed input: stdin is the protocol
|
|
625
|
+
log = os.environ.get("LCODE_ACP_LOG")
|
|
626
|
+
output = open(log, "a") if log else open(os.devnull, "w") # noqa: SIM115 # lcode's own output
|
|
627
|
+
console = Console(file=output, width=120, force_terminal=False)
|
|
628
|
+
conn = Connection(inp, out)
|
|
629
|
+
server = Server(conn, console)
|
|
630
|
+
conn.serve(server.handle)
|
|
631
|
+
for session in server.sessions.values():
|
|
632
|
+
from lcode import api
|
|
633
|
+
|
|
634
|
+
api.close_agent(session.agent)
|
|
635
|
+
return 0
|