nanocode-cli 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.
- nanocode.py +3444 -0
- nanocode_cli-0.1.0.dist-info/METADATA +81 -0
- nanocode_cli-0.1.0.dist-info/RECORD +7 -0
- nanocode_cli-0.1.0.dist-info/WHEEL +5 -0
- nanocode_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nanocode_cli-0.1.0.dist-info/licenses/LICENSE +28 -0
- nanocode_cli-0.1.0.dist-info/top_level.txt +1 -0
nanocode.py
ADDED
|
@@ -0,0 +1,3444 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nanocode
|
|
3
|
+
~~~~~~~~
|
|
4
|
+
A lightweight terminal-based AI coding assistant
|
|
5
|
+
https://github.com/hit9/nanocode
|
|
6
|
+
Install: uv tool install nanocode-cli
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import fnmatch
|
|
11
|
+
import hashlib
|
|
12
|
+
import itertools
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import signal
|
|
19
|
+
import socket
|
|
20
|
+
import subprocess
|
|
21
|
+
import tempfile
|
|
22
|
+
import threading
|
|
23
|
+
import difflib
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.request
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from datetime import datetime
|
|
30
|
+
from abc import abstractmethod
|
|
31
|
+
from enum import StrEnum
|
|
32
|
+
from typing import Any, Callable, ClassVar, final, Iterator, Protocol, Self, Type, TypeAlias
|
|
33
|
+
from typing_extensions import override
|
|
34
|
+
from prompt_toolkit import PromptSession, print_formatted_text
|
|
35
|
+
from prompt_toolkit.completion import WordCompleter
|
|
36
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
37
|
+
from prompt_toolkit.history import FileHistory
|
|
38
|
+
from prompt_toolkit.output.defaults import create_output
|
|
39
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
JsonValue: TypeAlias = Any
|
|
43
|
+
Json: TypeAlias = dict[str, JsonValue]
|
|
44
|
+
__version__ = "0.1.0"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Error(Exception): ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ToolCallError(Exception): ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LLMError(Exception): ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Cancellation(Exception): ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PromptItem:
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def format(self, indent: str = "") -> str:
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
############################
|
|
66
|
+
# Conversation (dataclasses)
|
|
67
|
+
############################
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Role(StrEnum):
|
|
71
|
+
USER = "user"
|
|
72
|
+
ASSISTANT = "assistant"
|
|
73
|
+
TOOL_CALL = "tool_call"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ConversationItem(PromptItem):
|
|
78
|
+
role: Role
|
|
79
|
+
time: datetime = field(default_factory=datetime.now)
|
|
80
|
+
|
|
81
|
+
def format_ts(self) -> str:
|
|
82
|
+
return self.time.strftime("%Y-%m-%d %H:%M:%S")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@final
|
|
86
|
+
@dataclass
|
|
87
|
+
class UserMessage(ConversationItem):
|
|
88
|
+
role: Role = Role.USER
|
|
89
|
+
content: str = ""
|
|
90
|
+
|
|
91
|
+
@override
|
|
92
|
+
def format(self, indent: str = "") -> str:
|
|
93
|
+
lines = [f'<UserMessage at="{self.format_ts()}">', f"{self.content}", "</UserMessage>"]
|
|
94
|
+
return _format_lines(lines, indent)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@final
|
|
98
|
+
@dataclass
|
|
99
|
+
class AssistantMessage(ConversationItem):
|
|
100
|
+
role: Role = Role.ASSISTANT
|
|
101
|
+
content: str = ""
|
|
102
|
+
|
|
103
|
+
@override
|
|
104
|
+
def format(self, indent: str = "") -> str:
|
|
105
|
+
lines = [f'<AssistantMessage at="{self.format_ts()}">', self.content, "</AssistantMessage>"]
|
|
106
|
+
return _format_lines(lines, indent)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@final
|
|
110
|
+
@dataclass
|
|
111
|
+
class ToolCallEvent(ConversationItem):
|
|
112
|
+
role: Role = Role.TOOL_CALL
|
|
113
|
+
intent: str = ""
|
|
114
|
+
executed: str = ""
|
|
115
|
+
outcome: str = ""
|
|
116
|
+
summary: str = ""
|
|
117
|
+
result_file: str = ""
|
|
118
|
+
result_file_lines: int = 0
|
|
119
|
+
key_details: list[str] = field(default_factory=list)
|
|
120
|
+
needs_raw_read: bool = False
|
|
121
|
+
|
|
122
|
+
@override
|
|
123
|
+
def format(self, indent: str = "") -> str:
|
|
124
|
+
lines = [
|
|
125
|
+
f'<ToolCall at="{self.format_ts()}">',
|
|
126
|
+
" <intent>" + self.intent + "</intent>",
|
|
127
|
+
" <executed>" + self.executed + "</executed>",
|
|
128
|
+
" <outcome>" + self.outcome + "</outcome>",
|
|
129
|
+
" <summary>" + self.summary + "</summary>",
|
|
130
|
+
]
|
|
131
|
+
if self.key_details:
|
|
132
|
+
lines.append(" <key_details>")
|
|
133
|
+
for detail in self.key_details:
|
|
134
|
+
lines.append(" <detail>" + detail + "</detail>")
|
|
135
|
+
lines.append(" </key_details>")
|
|
136
|
+
if self.needs_raw_read:
|
|
137
|
+
lines.append(" <needs_raw_read>true</needs_raw_read>")
|
|
138
|
+
if self.result_file:
|
|
139
|
+
lines.append(" <result_file>" + self.result_file + "</result_file>")
|
|
140
|
+
lines.append(" <result_file_lines>" + str(self.result_file_lines) + "</result_file_lines>")
|
|
141
|
+
lines.append("</ToolCall>")
|
|
142
|
+
return _format_lines(lines, indent)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
############################
|
|
146
|
+
# Current (dataclasses)
|
|
147
|
+
############################
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class PlanStatus(StrEnum):
|
|
151
|
+
TODO = "todo"
|
|
152
|
+
DOING = "doing"
|
|
153
|
+
DONE = "done"
|
|
154
|
+
BLOCKED = "blocked"
|
|
155
|
+
|
|
156
|
+
def __str__(self) -> str:
|
|
157
|
+
symbols = {
|
|
158
|
+
PlanStatus.TODO: "○",
|
|
159
|
+
PlanStatus.DOING: "◔",
|
|
160
|
+
PlanStatus.DONE: "✓",
|
|
161
|
+
PlanStatus.BLOCKED: "☒",
|
|
162
|
+
}
|
|
163
|
+
return f"{symbols.get(self, '')} {self.value}".strip()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@final
|
|
167
|
+
@dataclass
|
|
168
|
+
class PlanItem(PromptItem):
|
|
169
|
+
text: str
|
|
170
|
+
status: PlanStatus = PlanStatus.TODO
|
|
171
|
+
id: str = ""
|
|
172
|
+
evidence: str = ""
|
|
173
|
+
|
|
174
|
+
@override
|
|
175
|
+
def format(self, indent: str = "") -> str:
|
|
176
|
+
parts = [f"({self.status})"]
|
|
177
|
+
if self.id:
|
|
178
|
+
parts.append("id=" + self.id)
|
|
179
|
+
parts.append(self.text)
|
|
180
|
+
if self.evidence:
|
|
181
|
+
parts.append("evidence=" + self.evidence)
|
|
182
|
+
return indent + "<PlanItem>" + " ".join(parts) + "</PlanItem>"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class VerificationStatus(StrEnum):
|
|
186
|
+
IDLE = "idle"
|
|
187
|
+
PLANNED = "planned"
|
|
188
|
+
REQUIRED = "required"
|
|
189
|
+
DONE = "done"
|
|
190
|
+
BLOCKED = "blocked"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@final
|
|
194
|
+
@dataclass
|
|
195
|
+
class Verification(PromptItem):
|
|
196
|
+
goal: str = ""
|
|
197
|
+
status: VerificationStatus = VerificationStatus.IDLE
|
|
198
|
+
method: str = ""
|
|
199
|
+
evidence: str = ""
|
|
200
|
+
|
|
201
|
+
@override
|
|
202
|
+
def format(self, indent: str = "") -> str:
|
|
203
|
+
lines = [
|
|
204
|
+
"<Verification>",
|
|
205
|
+
" <goal>" + self.goal + "</goal>",
|
|
206
|
+
" <status>" + self.status + "</status>",
|
|
207
|
+
" <method>" + self.method + "</method>",
|
|
208
|
+
" <evidence>" + self.evidence + "</evidence>",
|
|
209
|
+
"</Verification>",
|
|
210
|
+
]
|
|
211
|
+
return _format_lines(lines, indent)
|
|
212
|
+
|
|
213
|
+
def reset(self) -> None:
|
|
214
|
+
self.goal = ""
|
|
215
|
+
self.status = VerificationStatus.IDLE
|
|
216
|
+
self.method = ""
|
|
217
|
+
self.evidence = ""
|
|
218
|
+
|
|
219
|
+
def has_context(self) -> bool:
|
|
220
|
+
return bool(self.goal or self.method or self.evidence or self.status != VerificationStatus.IDLE)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@final
|
|
224
|
+
@dataclass
|
|
225
|
+
class KnownItem(PromptItem):
|
|
226
|
+
fact: str
|
|
227
|
+
details: list[str] = field(default_factory=list)
|
|
228
|
+
|
|
229
|
+
@override
|
|
230
|
+
def format(self, indent: str = "") -> str:
|
|
231
|
+
lines = ["<KnownItem>", " <fact>" + self.fact + "</fact>"]
|
|
232
|
+
if self.details:
|
|
233
|
+
lines.append(" <details>")
|
|
234
|
+
for detail in self.details:
|
|
235
|
+
lines.append(" <detail>" + detail + "</detail>")
|
|
236
|
+
lines.append(" </details>")
|
|
237
|
+
lines.append("</KnownItem>")
|
|
238
|
+
return _format_lines(lines, indent)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@final
|
|
242
|
+
@dataclass
|
|
243
|
+
class CurrentContextItem(PromptItem):
|
|
244
|
+
note: str
|
|
245
|
+
details: list[str] = field(default_factory=list)
|
|
246
|
+
|
|
247
|
+
@override
|
|
248
|
+
def format(self, indent: str = "") -> str:
|
|
249
|
+
lines = ["<CurrentContextItem>", " <note>" + self.note + "</note>"]
|
|
250
|
+
if self.details:
|
|
251
|
+
lines.append(" <details>")
|
|
252
|
+
for detail in self.details:
|
|
253
|
+
lines.append(" <detail>" + detail + "</detail>")
|
|
254
|
+
lines.append(" </details>")
|
|
255
|
+
lines.append("</CurrentContextItem>")
|
|
256
|
+
return _format_lines(lines, indent)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@final
|
|
260
|
+
@dataclass
|
|
261
|
+
class Current:
|
|
262
|
+
user_input: str = ""
|
|
263
|
+
goal: str = ""
|
|
264
|
+
goal_reached: bool = False
|
|
265
|
+
plan: list[PlanItem] = field(default_factory=list)
|
|
266
|
+
known: list[KnownItem] = field(default_factory=list)
|
|
267
|
+
current_context: list[CurrentContextItem] = field(default_factory=list)
|
|
268
|
+
verification: Verification = field(default_factory=Verification)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@final
|
|
272
|
+
class RangeFingerprintStore:
|
|
273
|
+
MAX_ENTRIES: ClassVar[int] = 200
|
|
274
|
+
|
|
275
|
+
@final
|
|
276
|
+
@dataclass
|
|
277
|
+
class Entry:
|
|
278
|
+
fingerprint: str
|
|
279
|
+
filepath: str
|
|
280
|
+
start: int
|
|
281
|
+
end: int
|
|
282
|
+
content: str
|
|
283
|
+
|
|
284
|
+
@final
|
|
285
|
+
@dataclass
|
|
286
|
+
class Resolved:
|
|
287
|
+
start: int
|
|
288
|
+
end: int
|
|
289
|
+
fingerprint: str
|
|
290
|
+
relocated_from: tuple[int, int] | None = None
|
|
291
|
+
|
|
292
|
+
def __init__(self):
|
|
293
|
+
self._entries: list[RangeFingerprintStore.Entry] = []
|
|
294
|
+
|
|
295
|
+
def remember(self, *, filepath: str, start: int, end: int, content: str) -> str:
|
|
296
|
+
fingerprint = _range_fingerprint(content)
|
|
297
|
+
entry = self.Entry(fingerprint=fingerprint, filepath=os.path.realpath(filepath), start=start, end=end, content=content)
|
|
298
|
+
if not any(
|
|
299
|
+
existing.fingerprint == entry.fingerprint
|
|
300
|
+
and existing.filepath == entry.filepath
|
|
301
|
+
and existing.start == entry.start
|
|
302
|
+
and existing.end == entry.end
|
|
303
|
+
and existing.content == entry.content
|
|
304
|
+
for existing in self._entries
|
|
305
|
+
):
|
|
306
|
+
self._entries.append(entry)
|
|
307
|
+
del self._entries[: max(0, len(self._entries) - self.MAX_ENTRIES)]
|
|
308
|
+
return fingerprint
|
|
309
|
+
|
|
310
|
+
def clear(self) -> None:
|
|
311
|
+
self._entries = []
|
|
312
|
+
|
|
313
|
+
def __len__(self) -> int:
|
|
314
|
+
return len(self._entries)
|
|
315
|
+
|
|
316
|
+
def resolve(self, lines: list[str], *, filepath: str, start: int, end: int, fingerprint: str) -> Resolved:
|
|
317
|
+
resolved_start = min(start, len(lines))
|
|
318
|
+
resolved_end = len(lines) if end == 0 else min(end, len(lines))
|
|
319
|
+
resolved_end = max(resolved_end, resolved_start)
|
|
320
|
+
current = "".join(lines[resolved_start:resolved_end])
|
|
321
|
+
current_fingerprint = _range_fingerprint(current)
|
|
322
|
+
if current_fingerprint == fingerprint:
|
|
323
|
+
return self.Resolved(start=resolved_start, end=resolved_end, fingerprint=current_fingerprint)
|
|
324
|
+
|
|
325
|
+
matches = self._find_matches(lines, filepath=filepath, start=start, end=end, fingerprint=fingerprint)
|
|
326
|
+
message = (
|
|
327
|
+
f"fingerprint mismatch for range {start}:{end}: expected {fingerprint}, current {current_fingerprint}; "
|
|
328
|
+
f"call Read(filepath, {start}, {end}) and reuse that range fingerprint"
|
|
329
|
+
)
|
|
330
|
+
if not matches:
|
|
331
|
+
raise ToolCallError(message)
|
|
332
|
+
if len(matches) > 1:
|
|
333
|
+
raise ToolCallError(message + "; cached range matched multiple locations")
|
|
334
|
+
relocated_start, relocated_end = matches[0]
|
|
335
|
+
return self.Resolved(
|
|
336
|
+
start=relocated_start,
|
|
337
|
+
end=relocated_end,
|
|
338
|
+
fingerprint=fingerprint,
|
|
339
|
+
relocated_from=(resolved_start, resolved_end),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def _find_matches(self, lines: list[str], *, filepath: str, start: int, end: int, fingerprint: str) -> list[tuple[int, int]]:
|
|
343
|
+
filepath = os.path.realpath(filepath)
|
|
344
|
+
contents = []
|
|
345
|
+
for entry in self._entries:
|
|
346
|
+
if entry.fingerprint != fingerprint or entry.filepath != filepath or entry.start != start or entry.end != end or not entry.content:
|
|
347
|
+
continue
|
|
348
|
+
if entry.content not in contents:
|
|
349
|
+
contents.append(entry.content)
|
|
350
|
+
|
|
351
|
+
matches = []
|
|
352
|
+
for content in contents:
|
|
353
|
+
expected = content.splitlines(keepends=True)
|
|
354
|
+
if not expected:
|
|
355
|
+
continue
|
|
356
|
+
last_start = len(lines) - len(expected)
|
|
357
|
+
for position in range(max(0, last_start + 1)):
|
|
358
|
+
if lines[position : position + len(expected)] == expected:
|
|
359
|
+
matches.append((position, position + len(expected)))
|
|
360
|
+
if len(matches) > 1:
|
|
361
|
+
return matches
|
|
362
|
+
return matches
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@final
|
|
366
|
+
@dataclass
|
|
367
|
+
class Session:
|
|
368
|
+
REQUIRED_ENVS: ClassVar[tuple[tuple[str, str], ...]] = (
|
|
369
|
+
("NANOCODE_API_URL", "api_url"),
|
|
370
|
+
("NANOCODE_API_KEY", "api_key"),
|
|
371
|
+
("NANOCODE_MODEL", "model"),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# ---- system ----
|
|
375
|
+
system: str = field(default_factory=platform.system)
|
|
376
|
+
arch: str = field(default_factory=platform.machine)
|
|
377
|
+
cwd: str = field(default_factory=os.getcwd)
|
|
378
|
+
bash: str = field(default_factory=lambda: shutil.which("bash") or "")
|
|
379
|
+
|
|
380
|
+
# ---- env configs ----
|
|
381
|
+
api_url: str = field(default_factory=lambda: os.environ.get("NANOCODE_API_URL", "")) # reqiured
|
|
382
|
+
api_key: str = field(default_factory=lambda: os.environ.get("NANOCODE_API_KEY", "")) # reqiured
|
|
383
|
+
model: str = field(default_factory=lambda: os.environ.get("NANOCODE_MODEL", "")) # reqiured
|
|
384
|
+
nanocode_dir: str = field(default_factory=lambda: os.environ.get("NANOCODE_DIR", ".nanocode"))
|
|
385
|
+
temperature: float = field(default_factory=lambda: float(os.environ.get("NANOCODE_TEMPERATURE", "0.7")))
|
|
386
|
+
reasoning: bool = field(default_factory=lambda: os.environ.get("NANOCODE_REASONING", "on") == "on")
|
|
387
|
+
reasoning_effort: str = field(default_factory=lambda: os.environ.get("NANOCODE_REASONING_EFFORT", "medium"))
|
|
388
|
+
model_timeout: int = field(default_factory=lambda: int(os.environ.get("NANOCODE_MODEL_TIMEOUT", "60")))
|
|
389
|
+
shell_timeout: int = field(default_factory=lambda: int(os.environ.get("NANOCODE_SHELL_TIMEOUT", "60")))
|
|
390
|
+
compact_at: int = field(default_factory=lambda: int(os.environ.get("NANOCODE_COMPACT_AT", "100")))
|
|
391
|
+
max_agent_steps: int = field(default_factory=lambda: int(os.environ.get("NANOCODE_MAX_AGENT_STEPS", "50")))
|
|
392
|
+
|
|
393
|
+
# ---- runtime variables ----
|
|
394
|
+
yolo: bool = False
|
|
395
|
+
debug: bool = False
|
|
396
|
+
debug_prompt_count: int = 0
|
|
397
|
+
|
|
398
|
+
# ---- stats ---
|
|
399
|
+
last_prompt_tokens: int = 0
|
|
400
|
+
last_completion_tokens: int = 0
|
|
401
|
+
last_total_tokens: int = 0
|
|
402
|
+
session_prompt_tokens: int = 0
|
|
403
|
+
session_completion_tokens: int = 0
|
|
404
|
+
session_total_tokens: int = 0
|
|
405
|
+
current_model_call_started_at: float = 0.0
|
|
406
|
+
|
|
407
|
+
# ---- current and conversation ---
|
|
408
|
+
current: Current = field(default_factory=Current)
|
|
409
|
+
conversation: list[ConversationItem] = field(default_factory=list)
|
|
410
|
+
range_fingerprints: RangeFingerprintStore = field(default_factory=RangeFingerprintStore)
|
|
411
|
+
|
|
412
|
+
def resolve_path(self, path: str) -> str:
|
|
413
|
+
path = os.path.expanduser(path)
|
|
414
|
+
if not os.path.isabs(path):
|
|
415
|
+
path = os.path.join(self.cwd, path)
|
|
416
|
+
return os.path.abspath(path)
|
|
417
|
+
|
|
418
|
+
def is_path_in_cwd(self, path: str) -> bool:
|
|
419
|
+
cwd = os.path.realpath(self.cwd)
|
|
420
|
+
path = os.path.realpath(path)
|
|
421
|
+
try:
|
|
422
|
+
return os.path.commonpath([cwd, path]) == cwd
|
|
423
|
+
except ValueError:
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
def append_conversation(self, item: ConversationItem) -> None:
|
|
427
|
+
self.conversation.append(item)
|
|
428
|
+
|
|
429
|
+
def tool_results_dir(self) -> str:
|
|
430
|
+
return self.resolve_path(os.path.join(self.nanocode_dir, ToolCallRunner.TOOL_RESULTS_DIR))
|
|
431
|
+
|
|
432
|
+
def debug_dir(self) -> str:
|
|
433
|
+
return self.resolve_path(os.path.join(self.nanocode_dir, "debug"))
|
|
434
|
+
|
|
435
|
+
def cleanup_old_logs(self, *, days: int = 3, now: float | None = None) -> None:
|
|
436
|
+
directory = self.tool_results_dir()
|
|
437
|
+
if not os.path.isdir(directory):
|
|
438
|
+
return
|
|
439
|
+
cutoff = (time.time() if now is None else now) - days * 86400
|
|
440
|
+
for root, _, filenames in os.walk(directory):
|
|
441
|
+
for filename in filenames:
|
|
442
|
+
if not filename.endswith(".log"):
|
|
443
|
+
continue
|
|
444
|
+
filepath = os.path.join(root, filename)
|
|
445
|
+
try:
|
|
446
|
+
if os.path.getmtime(filepath) < cutoff:
|
|
447
|
+
os.remove(filepath)
|
|
448
|
+
except OSError:
|
|
449
|
+
pass
|
|
450
|
+
|
|
451
|
+
def missing_required_envs(self) -> list[str]:
|
|
452
|
+
return [env_name for env_name, attr_name in self.REQUIRED_ENVS if not getattr(self, attr_name)]
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
###########
|
|
456
|
+
# Tools
|
|
457
|
+
###########
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class Tool(Protocol):
|
|
461
|
+
@classmethod
|
|
462
|
+
def name(cls) -> str: ...
|
|
463
|
+
@classmethod
|
|
464
|
+
def description(cls) -> list[str]: ...
|
|
465
|
+
@classmethod
|
|
466
|
+
def signature(cls) -> str: ...
|
|
467
|
+
@classmethod
|
|
468
|
+
def example(cls) -> list[str]: ...
|
|
469
|
+
@classmethod
|
|
470
|
+
def make(cls, session: Session, args: list[str]) -> Self: ...
|
|
471
|
+
def requires_confirmation(self, session: Session) -> bool: ...
|
|
472
|
+
def display(self) -> str: ...
|
|
473
|
+
def call(self) -> str: ...
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
ToolClass: TypeAlias = Type[Tool]
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@final
|
|
480
|
+
@dataclass
|
|
481
|
+
class ParsedToolCall:
|
|
482
|
+
name: str
|
|
483
|
+
intention: str
|
|
484
|
+
args: list[str]
|
|
485
|
+
|
|
486
|
+
@property
|
|
487
|
+
def executed(self) -> str:
|
|
488
|
+
return self.name + "(" + ", ".join(json.dumps(arg, ensure_ascii=False) for arg in self.args) + ")"
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@final
|
|
492
|
+
@dataclass
|
|
493
|
+
class ToolCallExecution:
|
|
494
|
+
call: ParsedToolCall
|
|
495
|
+
outcome: str
|
|
496
|
+
output: str
|
|
497
|
+
result_file: str
|
|
498
|
+
result_file_lines: int
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
ConfirmationResult: TypeAlias = bool | str
|
|
502
|
+
ConfirmCallback: TypeAlias = Callable[[ParsedToolCall, Tool], ConfirmationResult]
|
|
503
|
+
ToolDisplayCallback: TypeAlias = Callable[[ParsedToolCall, Tool], None]
|
|
504
|
+
MessageCallback: TypeAlias = Callable[[str], None]
|
|
505
|
+
StatusAction: TypeAlias = Callable[[], str]
|
|
506
|
+
StatusRunner: TypeAlias = Callable[[StatusAction], str]
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
####################
|
|
510
|
+
# Tools Helpers
|
|
511
|
+
####################
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _parse_line_range(start_arg: str, end_arg: str) -> tuple[int, int]:
|
|
515
|
+
try:
|
|
516
|
+
start = max(0, int(start_arg))
|
|
517
|
+
except (ValueError, TypeError):
|
|
518
|
+
raise ToolCallError("invalid start: should be an integer")
|
|
519
|
+
try:
|
|
520
|
+
end = max(0, int(end_arg))
|
|
521
|
+
except (ValueError, TypeError):
|
|
522
|
+
raise ToolCallError("invalid end: should be an integer")
|
|
523
|
+
if end:
|
|
524
|
+
end = max(end, start)
|
|
525
|
+
return start, end
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _range_fingerprint(content: str) -> str:
|
|
529
|
+
return hashlib.blake2s(content.encode("utf-8"), digest_size=3).hexdigest()
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
####################
|
|
533
|
+
# Tools Impl
|
|
534
|
+
####################
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
@final
|
|
538
|
+
@dataclass
|
|
539
|
+
class ReadTool(Tool):
|
|
540
|
+
filepath: str = ""
|
|
541
|
+
start: int = 0
|
|
542
|
+
end: int = 0
|
|
543
|
+
cwd: str = ""
|
|
544
|
+
range_fingerprints: RangeFingerprintStore = field(default_factory=RangeFingerprintStore)
|
|
545
|
+
|
|
546
|
+
@classmethod
|
|
547
|
+
def name(cls) -> str:
|
|
548
|
+
return "Read"
|
|
549
|
+
|
|
550
|
+
@classmethod
|
|
551
|
+
def description(cls) -> list[str]:
|
|
552
|
+
return [
|
|
553
|
+
"Read exact file lines with a fingerprint.",
|
|
554
|
+
"Optional range is 0-based [start,end); end=0 means EOF.",
|
|
555
|
+
"Prefer bounded reads over full-file reads; use LineCount first when unsure.",
|
|
556
|
+
"For ReplaceRange, call Read with the exact same filepath/start/end and reuse that range fingerprint.",
|
|
557
|
+
]
|
|
558
|
+
|
|
559
|
+
@classmethod
|
|
560
|
+
def signature(cls) -> str:
|
|
561
|
+
return "Read(filepath, start?: 0-N, end?: 0-N) -> ReadToolResult<fingerprint, content>"
|
|
562
|
+
|
|
563
|
+
@classmethod
|
|
564
|
+
def example(cls) -> list[str]:
|
|
565
|
+
return ['Example: ["code.py", "0", "120"]', 'Example: ["code.py"]']
|
|
566
|
+
|
|
567
|
+
@classmethod
|
|
568
|
+
def make(cls, session: Session, args: list[str]) -> Self:
|
|
569
|
+
if len(args) not in {1, 3}:
|
|
570
|
+
raise ToolCallError("requires 1 or 3 args: filepath[, start, end]")
|
|
571
|
+
filepath = session.resolve_path(args[0])
|
|
572
|
+
start, end = (0, 0) if len(args) == 1 else _parse_line_range(args[1], args[2])
|
|
573
|
+
return cls(filepath=filepath, start=start, end=end, cwd=session.cwd, range_fingerprints=session.range_fingerprints)
|
|
574
|
+
|
|
575
|
+
def requires_confirmation(self, session: Session) -> bool:
|
|
576
|
+
return not session.is_path_in_cwd(self.filepath)
|
|
577
|
+
|
|
578
|
+
def display(self) -> str:
|
|
579
|
+
return f"Read({self.filepath}, {self.start}, {self.end})"
|
|
580
|
+
|
|
581
|
+
def call(self) -> str:
|
|
582
|
+
with open(self.filepath, "r", encoding="utf-8") as f:
|
|
583
|
+
lines = itertools.islice(f, self.start, self.end or None)
|
|
584
|
+
content = "".join(lines)
|
|
585
|
+
fingerprint = self.range_fingerprints.remember(
|
|
586
|
+
filepath=self.filepath,
|
|
587
|
+
start=self.start,
|
|
588
|
+
end=self.end,
|
|
589
|
+
content=content,
|
|
590
|
+
)
|
|
591
|
+
return "\n".join(
|
|
592
|
+
[
|
|
593
|
+
"<ReadToolResult>",
|
|
594
|
+
" <range>" + str(self.start) + ":" + str(self.end) + "</range>",
|
|
595
|
+
" <fingerprint>" + fingerprint + "</fingerprint>",
|
|
596
|
+
" <content no-indention>",
|
|
597
|
+
content,
|
|
598
|
+
" </content>",
|
|
599
|
+
"</ReadToolResult>",
|
|
600
|
+
]
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@final
|
|
605
|
+
@dataclass
|
|
606
|
+
class LineCountTool(Tool):
|
|
607
|
+
filepath: str = ""
|
|
608
|
+
|
|
609
|
+
@classmethod
|
|
610
|
+
def name(cls) -> str:
|
|
611
|
+
return "LineCount"
|
|
612
|
+
|
|
613
|
+
@classmethod
|
|
614
|
+
def description(cls) -> list[str]:
|
|
615
|
+
return ["Count file lines before choosing a Read range."]
|
|
616
|
+
|
|
617
|
+
@classmethod
|
|
618
|
+
def signature(cls) -> str:
|
|
619
|
+
return "LineCount(filepath) -> LineCountToolResult<lines>"
|
|
620
|
+
|
|
621
|
+
@classmethod
|
|
622
|
+
def example(cls) -> list[str]:
|
|
623
|
+
return ['Example args: ["code.py"]']
|
|
624
|
+
|
|
625
|
+
@classmethod
|
|
626
|
+
def make(cls, session: Session, args: list[str]) -> Self:
|
|
627
|
+
if len(args) != 1:
|
|
628
|
+
raise ToolCallError("requires exactly one arg: filepath")
|
|
629
|
+
return cls(filepath=session.resolve_path(args[0]))
|
|
630
|
+
|
|
631
|
+
def requires_confirmation(self, session: Session) -> bool:
|
|
632
|
+
return not session.is_path_in_cwd(self.filepath)
|
|
633
|
+
|
|
634
|
+
def display(self) -> str:
|
|
635
|
+
return f"LineCount({self.filepath})"
|
|
636
|
+
|
|
637
|
+
def call(self) -> str:
|
|
638
|
+
with open(self.filepath, "r", encoding="utf-8") as f:
|
|
639
|
+
return "<LineCountToolResult>" + str(sum(1 for _ in f)) + "</LineCountToolResult>"
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
@final
|
|
643
|
+
@dataclass
|
|
644
|
+
class ListDirTool(Tool):
|
|
645
|
+
dirpath: str = ""
|
|
646
|
+
glob_pattern: str = ""
|
|
647
|
+
cwd: str = ""
|
|
648
|
+
|
|
649
|
+
@classmethod
|
|
650
|
+
def name(cls) -> str:
|
|
651
|
+
return "ListDir"
|
|
652
|
+
|
|
653
|
+
@classmethod
|
|
654
|
+
def description(cls) -> list[str]:
|
|
655
|
+
return [
|
|
656
|
+
"List immediate directory entries, optionally filtered by entry-name glob.",
|
|
657
|
+
"Returns entries sorted by type then name.",
|
|
658
|
+
]
|
|
659
|
+
|
|
660
|
+
@classmethod
|
|
661
|
+
def signature(cls) -> str:
|
|
662
|
+
return "ListDir(dir_path[, glob_pattern]) -> ListDirToolResult<entries>"
|
|
663
|
+
|
|
664
|
+
@classmethod
|
|
665
|
+
def example(cls) -> list[str]:
|
|
666
|
+
return ['Example args: ["."]', 'Example args: ["src", "*.py"]']
|
|
667
|
+
|
|
668
|
+
@classmethod
|
|
669
|
+
def make(cls, session: Session, args: list[str]) -> Self:
|
|
670
|
+
if len(args) not in (1, 2):
|
|
671
|
+
raise ToolCallError("requires 1 or 2 args: dir_path[, glob_pattern]")
|
|
672
|
+
glob_pattern = str(args[1]) if len(args) == 2 else ""
|
|
673
|
+
return cls(dirpath=session.resolve_path(args[0]), glob_pattern=glob_pattern, cwd=session.cwd)
|
|
674
|
+
|
|
675
|
+
def display(self) -> str:
|
|
676
|
+
if self.glob_pattern:
|
|
677
|
+
return f'ListDir({self.dirpath}, "{self.glob_pattern}")'
|
|
678
|
+
return f"ListDir({self.dirpath})"
|
|
679
|
+
|
|
680
|
+
def requires_confirmation(self, session: Session) -> bool:
|
|
681
|
+
return not session.is_path_in_cwd(self.dirpath)
|
|
682
|
+
|
|
683
|
+
def _dir_entry_type(self, entry: os.DirEntry[str]) -> str:
|
|
684
|
+
if entry.is_symlink():
|
|
685
|
+
return "symlink"
|
|
686
|
+
if entry.is_dir(follow_symlinks=False):
|
|
687
|
+
return "dir"
|
|
688
|
+
if entry.is_file(follow_symlinks=False):
|
|
689
|
+
return "file"
|
|
690
|
+
return "other"
|
|
691
|
+
|
|
692
|
+
def _entry_type_sort_key(self, entry_type: str) -> int:
|
|
693
|
+
return {"dir": 0, "file": 1, "symlink": 2, "other": 3}.get(entry_type, 4)
|
|
694
|
+
|
|
695
|
+
def call(self) -> str:
|
|
696
|
+
if not os.path.isdir(self.dirpath):
|
|
697
|
+
raise ToolCallError("not a directory")
|
|
698
|
+
entries = []
|
|
699
|
+
with os.scandir(self.dirpath) as scan:
|
|
700
|
+
for entry in scan:
|
|
701
|
+
if self.glob_pattern and not fnmatch.fnmatch(entry.name, self.glob_pattern):
|
|
702
|
+
continue
|
|
703
|
+
entries.append(
|
|
704
|
+
{
|
|
705
|
+
"name": entry.name,
|
|
706
|
+
"path": entry.path,
|
|
707
|
+
"type": self._dir_entry_type(entry),
|
|
708
|
+
}
|
|
709
|
+
)
|
|
710
|
+
entries.sort(key=lambda item: (self._entry_type_sort_key(str(item["type"])), str(item["name"])))
|
|
711
|
+
lines = ["<ListDirToolResult>"]
|
|
712
|
+
for e in entries:
|
|
713
|
+
lines.append(f"* ({e['type']}): {os.path.relpath(str(e['path']), self.cwd)}")
|
|
714
|
+
lines.append("</ListDirToolResult>")
|
|
715
|
+
return "\n".join(lines)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
@final
|
|
719
|
+
@dataclass
|
|
720
|
+
class SearchTool(Tool):
|
|
721
|
+
MAX_MATCHES: ClassVar[int] = 100
|
|
722
|
+
MAX_FILE_BYTES: ClassVar[int] = 2_000_000
|
|
723
|
+
RG_MAX_FILESIZE: ClassVar[str] = "2M"
|
|
724
|
+
CONTEXT_LINES: ClassVar[int] = 4
|
|
725
|
+
MAX_CONTEXT_LINES: ClassVar[int] = 20
|
|
726
|
+
|
|
727
|
+
@dataclass(frozen=True)
|
|
728
|
+
class Match:
|
|
729
|
+
path: str
|
|
730
|
+
line_number: int
|
|
731
|
+
text: str
|
|
732
|
+
context: list[tuple[int, str]]
|
|
733
|
+
|
|
734
|
+
pattern: str = ""
|
|
735
|
+
patterns: list[str] = field(default_factory=list)
|
|
736
|
+
regex: bool = False
|
|
737
|
+
target_path: str = ""
|
|
738
|
+
glob_pattern: str = ""
|
|
739
|
+
context_lines: int = CONTEXT_LINES
|
|
740
|
+
cwd: str = ""
|
|
741
|
+
gitignore_patterns: list[str] = field(default_factory=list)
|
|
742
|
+
|
|
743
|
+
@classmethod
|
|
744
|
+
def name(cls) -> str:
|
|
745
|
+
return "Search"
|
|
746
|
+
|
|
747
|
+
@classmethod
|
|
748
|
+
def description(cls) -> list[str]:
|
|
749
|
+
return [
|
|
750
|
+
"Search files or directories before Read; default is fixed text.",
|
|
751
|
+
"Prefix pattern with re: for regex search.",
|
|
752
|
+
"Use A|B|C for literal OR search in fixed mode.",
|
|
753
|
+
"Optional context=N or N sets nearby context lines.",
|
|
754
|
+
"Optional glob matches file basename or path relative to cwd.",
|
|
755
|
+
]
|
|
756
|
+
|
|
757
|
+
@classmethod
|
|
758
|
+
def signature(cls) -> str:
|
|
759
|
+
return "Search(pattern, path[, glob_pattern][, context=N|N]) -> SearchToolResult<matches>"
|
|
760
|
+
|
|
761
|
+
@classmethod
|
|
762
|
+
def example(cls) -> list[str]:
|
|
763
|
+
return [
|
|
764
|
+
'Example args: ["class Foo", "code.py"]',
|
|
765
|
+
'Example args: ["TODO", ".", "*.py"]',
|
|
766
|
+
'Example args: ["class Bar|def main", "nanocode.py", "context=6"]',
|
|
767
|
+
'Example args: ["TODO", ".", "*.py", "8"]',
|
|
768
|
+
'Example args: ["re:def __init__\\([^)]*,[^)]*\\)", ".", "*.py"]',
|
|
769
|
+
]
|
|
770
|
+
|
|
771
|
+
@classmethod
|
|
772
|
+
def make(cls, session: Session, args: list[str]) -> Self:
|
|
773
|
+
if len(args) not in (2, 3, 4):
|
|
774
|
+
raise ToolCallError("requires 2 to 4 args: pattern, path[, glob_pattern][, context=N]")
|
|
775
|
+
raw_pattern = str(args[0])
|
|
776
|
+
if not raw_pattern:
|
|
777
|
+
raise ToolCallError("pattern cannot be empty")
|
|
778
|
+
regex = raw_pattern.startswith("re:")
|
|
779
|
+
pattern = raw_pattern[3:] if regex else raw_pattern
|
|
780
|
+
if not pattern:
|
|
781
|
+
raise ToolCallError("pattern cannot be empty")
|
|
782
|
+
glob_pattern = ""
|
|
783
|
+
context_lines = cls.CONTEXT_LINES
|
|
784
|
+
for raw_option in args[2:]:
|
|
785
|
+
option = str(raw_option)
|
|
786
|
+
if option.startswith("context=") or option.isdigit():
|
|
787
|
+
try:
|
|
788
|
+
context_lines = cls._parse_context_arg(option)
|
|
789
|
+
except ValueError:
|
|
790
|
+
raise ToolCallError("context must be an integer between 0 and " + str(cls.MAX_CONTEXT_LINES))
|
|
791
|
+
continue
|
|
792
|
+
if glob_pattern:
|
|
793
|
+
raise ToolCallError("unexpected search option: " + option)
|
|
794
|
+
glob_pattern = option
|
|
795
|
+
patterns = [pattern] if regex else [part for part in pattern.split("|") if part]
|
|
796
|
+
if not patterns:
|
|
797
|
+
raise ToolCallError("no valid search patterns")
|
|
798
|
+
if regex:
|
|
799
|
+
try:
|
|
800
|
+
re.compile(pattern)
|
|
801
|
+
except re.error as error:
|
|
802
|
+
raise ToolCallError("invalid regex: " + str(error))
|
|
803
|
+
return cls(
|
|
804
|
+
pattern=raw_pattern,
|
|
805
|
+
patterns=patterns,
|
|
806
|
+
regex=regex,
|
|
807
|
+
target_path=session.resolve_path(args[1]),
|
|
808
|
+
glob_pattern=glob_pattern,
|
|
809
|
+
context_lines=context_lines,
|
|
810
|
+
cwd=session.cwd,
|
|
811
|
+
gitignore_patterns=cls._load_gitignore_patterns(session.cwd),
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
@classmethod
|
|
815
|
+
def _parse_context_arg(cls, value: str) -> int:
|
|
816
|
+
raw_context = value[len("context=") :] if value.startswith("context=") else value
|
|
817
|
+
context = int(raw_context)
|
|
818
|
+
if context < 0 or context > cls.MAX_CONTEXT_LINES:
|
|
819
|
+
raise ValueError
|
|
820
|
+
return context
|
|
821
|
+
|
|
822
|
+
def requires_confirmation(self, session: Session) -> bool:
|
|
823
|
+
return not session.is_path_in_cwd(self.target_path)
|
|
824
|
+
|
|
825
|
+
def display(self) -> str:
|
|
826
|
+
if self.glob_pattern:
|
|
827
|
+
return f'Search("{self.pattern}", {self.target_path}, "{self.glob_pattern}")'
|
|
828
|
+
return f'Search("{self.pattern}", {self.target_path})'
|
|
829
|
+
|
|
830
|
+
def _relpath(self, path: str) -> str:
|
|
831
|
+
try:
|
|
832
|
+
return os.path.relpath(path, self.cwd)
|
|
833
|
+
except ValueError:
|
|
834
|
+
return path
|
|
835
|
+
|
|
836
|
+
def _matches_glob(self, path: str) -> bool:
|
|
837
|
+
if not self.glob_pattern:
|
|
838
|
+
return True
|
|
839
|
+
return fnmatch.fnmatch(os.path.basename(path), self.glob_pattern) or fnmatch.fnmatch(self._relpath(path), self.glob_pattern)
|
|
840
|
+
|
|
841
|
+
@staticmethod
|
|
842
|
+
def _load_gitignore_patterns(cwd: str) -> list[str]:
|
|
843
|
+
path = os.path.join(cwd, ".gitignore")
|
|
844
|
+
patterns = []
|
|
845
|
+
try:
|
|
846
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
847
|
+
for line in f:
|
|
848
|
+
pattern = line.strip()
|
|
849
|
+
if not pattern or pattern.startswith("#") or pattern.startswith("!"):
|
|
850
|
+
continue
|
|
851
|
+
patterns.append(pattern.lstrip("/"))
|
|
852
|
+
except OSError:
|
|
853
|
+
pass
|
|
854
|
+
return patterns
|
|
855
|
+
|
|
856
|
+
def _is_hidden_path(self, path: str) -> bool:
|
|
857
|
+
return any(part.startswith(".") for part in self._relpath(path).split(os.sep) if part and part != ".")
|
|
858
|
+
|
|
859
|
+
def _is_gitignored(self, path: str, is_dir: bool = False) -> bool:
|
|
860
|
+
relpath = self._relpath(path).replace(os.sep, "/")
|
|
861
|
+
name = os.path.basename(path)
|
|
862
|
+
parts = relpath.split("/")
|
|
863
|
+
for pattern in self.gitignore_patterns:
|
|
864
|
+
directory_only = pattern.endswith("/")
|
|
865
|
+
pattern = pattern.rstrip("/")
|
|
866
|
+
if not pattern:
|
|
867
|
+
continue
|
|
868
|
+
if directory_only:
|
|
869
|
+
if "/" in pattern:
|
|
870
|
+
matched = relpath == pattern or relpath.startswith(pattern + "/")
|
|
871
|
+
else:
|
|
872
|
+
matched = pattern in parts
|
|
873
|
+
if matched:
|
|
874
|
+
return True
|
|
875
|
+
continue
|
|
876
|
+
if "/" in pattern:
|
|
877
|
+
if fnmatch.fnmatch(relpath, pattern):
|
|
878
|
+
return True
|
|
879
|
+
elif fnmatch.fnmatch(name, pattern) or any(fnmatch.fnmatch(part, pattern) for part in parts):
|
|
880
|
+
return True
|
|
881
|
+
return False
|
|
882
|
+
|
|
883
|
+
def _is_skipped_path(self, path: str, is_dir: bool = False) -> bool:
|
|
884
|
+
return self._is_hidden_path(path) or self._is_gitignored(path, is_dir)
|
|
885
|
+
|
|
886
|
+
def _iter_files(self) -> Iterator[str]:
|
|
887
|
+
if os.path.isfile(self.target_path):
|
|
888
|
+
if self._matches_glob(self.target_path) and not self._is_skipped_path(self.target_path):
|
|
889
|
+
yield self.target_path
|
|
890
|
+
return
|
|
891
|
+
|
|
892
|
+
for root, dirs, names in os.walk(self.target_path):
|
|
893
|
+
dirs[:] = [name for name in dirs if not self._is_skipped_path(os.path.join(root, name), is_dir=True)]
|
|
894
|
+
for name in names:
|
|
895
|
+
path = os.path.join(root, name)
|
|
896
|
+
if self._matches_glob(path) and not self._is_skipped_path(path):
|
|
897
|
+
yield path
|
|
898
|
+
|
|
899
|
+
def _make_match(self, path: str, line_number: int, text: str) -> Match:
|
|
900
|
+
return self.Match(path=path, line_number=line_number, text=text[:300], context=self._read_match_context(path, line_number))
|
|
901
|
+
|
|
902
|
+
def _read_match_context(self, path: str, line_number: int) -> list[tuple[int, str]]:
|
|
903
|
+
if line_number <= 0:
|
|
904
|
+
return []
|
|
905
|
+
start = max(1, line_number - self.context_lines)
|
|
906
|
+
end = line_number + self.context_lines
|
|
907
|
+
context = []
|
|
908
|
+
try:
|
|
909
|
+
if os.path.getsize(path) > self.MAX_FILE_BYTES:
|
|
910
|
+
return []
|
|
911
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
912
|
+
for lineno, line in enumerate(f, start=1):
|
|
913
|
+
if lineno > end:
|
|
914
|
+
break
|
|
915
|
+
if lineno >= start:
|
|
916
|
+
context.append((lineno, line.rstrip("\n")[:300]))
|
|
917
|
+
except OSError:
|
|
918
|
+
return []
|
|
919
|
+
return context
|
|
920
|
+
|
|
921
|
+
def _format_result(self, engine: str, matches: list[Match], truncated: bool) -> str:
|
|
922
|
+
lines = ["<SearchToolResult>"]
|
|
923
|
+
lines.append(f"* engine: {engine}")
|
|
924
|
+
if matches:
|
|
925
|
+
for match in matches:
|
|
926
|
+
lines.append(f"* {self._relpath(match.path)}:{match.line_number}: {match.text}")
|
|
927
|
+
for lineno, text in match.context:
|
|
928
|
+
marker = ">" if lineno == match.line_number else " "
|
|
929
|
+
lines.append(f" {marker} {lineno}: {text}")
|
|
930
|
+
else:
|
|
931
|
+
lines.append("No matches.")
|
|
932
|
+
if truncated:
|
|
933
|
+
lines.append("* truncated: true")
|
|
934
|
+
lines.append("</SearchToolResult>")
|
|
935
|
+
return "\n".join(lines)
|
|
936
|
+
|
|
937
|
+
def _call_rg(self, rg: str) -> str:
|
|
938
|
+
cmd = [rg, "--json", "--line-number", "--max-filesize", self.RG_MAX_FILESIZE]
|
|
939
|
+
if not self.regex:
|
|
940
|
+
cmd.append("--fixed-strings")
|
|
941
|
+
if self.glob_pattern:
|
|
942
|
+
cmd.extend(["--glob", self.glob_pattern])
|
|
943
|
+
for pattern in self.patterns:
|
|
944
|
+
cmd.extend(["-e", pattern])
|
|
945
|
+
cmd.extend(["--", self.target_path])
|
|
946
|
+
|
|
947
|
+
try:
|
|
948
|
+
proc = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
|
|
949
|
+
except subprocess.TimeoutExpired:
|
|
950
|
+
raise ToolCallError("rg timed out")
|
|
951
|
+
if proc.returncode not in (0, 1):
|
|
952
|
+
raise ToolCallError(proc.stderr.strip() or "rg failed")
|
|
953
|
+
|
|
954
|
+
matches = []
|
|
955
|
+
truncated = False
|
|
956
|
+
for line in proc.stdout.splitlines():
|
|
957
|
+
try:
|
|
958
|
+
event = json.loads(line)
|
|
959
|
+
except json.JSONDecodeError:
|
|
960
|
+
continue
|
|
961
|
+
if not isinstance(event, dict) or event.get("type") != "match":
|
|
962
|
+
continue
|
|
963
|
+
data = event.get("data")
|
|
964
|
+
if not isinstance(data, dict):
|
|
965
|
+
continue
|
|
966
|
+
path_data = data.get("path")
|
|
967
|
+
lines_data = data.get("lines")
|
|
968
|
+
path = path_data.get("text", "") if isinstance(path_data, dict) else ""
|
|
969
|
+
text = lines_data.get("text", "") if isinstance(lines_data, dict) else ""
|
|
970
|
+
if not isinstance(path, str) or not self._matches_glob(path):
|
|
971
|
+
continue
|
|
972
|
+
if not isinstance(text, str):
|
|
973
|
+
text = ""
|
|
974
|
+
matches.append(self._make_match(path, int(data.get("line_number", 0)), text.rstrip("\n")))
|
|
975
|
+
if len(matches) >= self.MAX_MATCHES:
|
|
976
|
+
truncated = True
|
|
977
|
+
break
|
|
978
|
+
engine = "rg-regex" if self.regex else "rg"
|
|
979
|
+
return self._format_result(engine, matches, truncated)
|
|
980
|
+
|
|
981
|
+
def _call_python(self) -> str:
|
|
982
|
+
matches = []
|
|
983
|
+
truncated = False
|
|
984
|
+
for path in self._iter_files():
|
|
985
|
+
try:
|
|
986
|
+
if os.path.getsize(path) > self.MAX_FILE_BYTES:
|
|
987
|
+
continue
|
|
988
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
989
|
+
for lineno, line in enumerate(f, start=1):
|
|
990
|
+
text = line.rstrip("\n")
|
|
991
|
+
if not self._line_matches(text):
|
|
992
|
+
continue
|
|
993
|
+
matches.append(self._make_match(path, lineno, text))
|
|
994
|
+
if len(matches) >= self.MAX_MATCHES:
|
|
995
|
+
truncated = True
|
|
996
|
+
return self._format_result("python", matches, truncated)
|
|
997
|
+
except OSError:
|
|
998
|
+
continue
|
|
999
|
+
|
|
1000
|
+
return self._format_result("python", matches, truncated)
|
|
1001
|
+
|
|
1002
|
+
def _line_matches(self, text: str) -> bool:
|
|
1003
|
+
if not self.regex:
|
|
1004
|
+
return any(pattern in text for pattern in self.patterns)
|
|
1005
|
+
try:
|
|
1006
|
+
return re.search(self.patterns[0], text) is not None
|
|
1007
|
+
except re.error as error:
|
|
1008
|
+
raise ToolCallError("invalid regex: " + str(error))
|
|
1009
|
+
|
|
1010
|
+
def call(self) -> str:
|
|
1011
|
+
if not (os.path.isdir(self.target_path) or os.path.isfile(self.target_path)):
|
|
1012
|
+
raise ToolCallError("not a file or directory")
|
|
1013
|
+
if os.path.isfile(self.target_path) and not self._matches_glob(self.target_path):
|
|
1014
|
+
return self._format_result("python", [], False)
|
|
1015
|
+
|
|
1016
|
+
rg = shutil.which("rg")
|
|
1017
|
+
if rg:
|
|
1018
|
+
return self._call_rg(rg)
|
|
1019
|
+
return self._call_python()
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
@final
|
|
1023
|
+
@dataclass
|
|
1024
|
+
class EditTool(Tool):
|
|
1025
|
+
filepath: str = ""
|
|
1026
|
+
find: str = ""
|
|
1027
|
+
replace: str = ""
|
|
1028
|
+
cwd: str = ""
|
|
1029
|
+
|
|
1030
|
+
@classmethod
|
|
1031
|
+
def name(cls) -> str:
|
|
1032
|
+
return "Edit"
|
|
1033
|
+
|
|
1034
|
+
@classmethod
|
|
1035
|
+
def description(cls) -> list[str]:
|
|
1036
|
+
return ["Replace the first exact text block in a file."]
|
|
1037
|
+
|
|
1038
|
+
@classmethod
|
|
1039
|
+
def signature(cls) -> str:
|
|
1040
|
+
return "Edit(filepath, find, replace) -> EditToolResult<path, replacements>"
|
|
1041
|
+
|
|
1042
|
+
@classmethod
|
|
1043
|
+
def example(cls) -> list[str]:
|
|
1044
|
+
return ['Example args: ["code.py", "old text", "new text"]']
|
|
1045
|
+
|
|
1046
|
+
@classmethod
|
|
1047
|
+
def make(cls, session: Session, args: list[str]) -> Self:
|
|
1048
|
+
if len(args) != 3:
|
|
1049
|
+
raise ToolCallError("requires exactly 3 args: filepath, find, replace")
|
|
1050
|
+
find = str(args[1])
|
|
1051
|
+
if not find:
|
|
1052
|
+
raise ToolCallError("find text cannot be empty")
|
|
1053
|
+
return cls(filepath=session.resolve_path(args[0]), find=find, replace=str(args[2]), cwd=session.cwd)
|
|
1054
|
+
|
|
1055
|
+
def requires_confirmation(self, session: Session) -> bool:
|
|
1056
|
+
return True
|
|
1057
|
+
|
|
1058
|
+
def display(self) -> str:
|
|
1059
|
+
label = f'Edit({self.filepath}, find="{self.find}")'
|
|
1060
|
+
try:
|
|
1061
|
+
with open(self.filepath, "r", encoding="utf-8") as f:
|
|
1062
|
+
content = f.read()
|
|
1063
|
+
except OSError:
|
|
1064
|
+
return label
|
|
1065
|
+
if self.find not in content:
|
|
1066
|
+
return label
|
|
1067
|
+
return _make_unified_diff(content, content.replace(self.find, self.replace, 1), self.filepath) or label
|
|
1068
|
+
|
|
1069
|
+
def call(self) -> str:
|
|
1070
|
+
with open(self.filepath, "r", encoding="utf-8") as f:
|
|
1071
|
+
content = f.read()
|
|
1072
|
+
if self.find not in content:
|
|
1073
|
+
raise ToolCallError("target `find` text not found")
|
|
1074
|
+
|
|
1075
|
+
with open(self.filepath, "w", encoding="utf-8") as f:
|
|
1076
|
+
f.write(content.replace(self.find, self.replace, 1))
|
|
1077
|
+
|
|
1078
|
+
return "\n".join(
|
|
1079
|
+
[
|
|
1080
|
+
"<EditToolResult>",
|
|
1081
|
+
f"* path: {os.path.relpath(self.filepath, self.cwd)}",
|
|
1082
|
+
"* replacements: 1",
|
|
1083
|
+
"</EditToolResult>",
|
|
1084
|
+
]
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
@final
|
|
1089
|
+
@dataclass
|
|
1090
|
+
class ReplaceRangeTool(Tool):
|
|
1091
|
+
filepath: str = ""
|
|
1092
|
+
start: int = 0
|
|
1093
|
+
end: int = 0
|
|
1094
|
+
fingerprint: str = ""
|
|
1095
|
+
content: str = ""
|
|
1096
|
+
cwd: str = ""
|
|
1097
|
+
range_fingerprints: RangeFingerprintStore = field(default_factory=RangeFingerprintStore)
|
|
1098
|
+
|
|
1099
|
+
@classmethod
|
|
1100
|
+
def name(cls) -> str:
|
|
1101
|
+
return "ReplaceRange"
|
|
1102
|
+
|
|
1103
|
+
@classmethod
|
|
1104
|
+
def description(cls) -> list[str]:
|
|
1105
|
+
return [
|
|
1106
|
+
"Replace one 0-based line range when its fingerprint comes from Read(filepath, same start, same end).",
|
|
1107
|
+
"If earlier edits shifted lines, a cached Read fingerprint for the same original range can relocate only when old content still matches exactly once.",
|
|
1108
|
+
]
|
|
1109
|
+
|
|
1110
|
+
@classmethod
|
|
1111
|
+
def signature(cls) -> str:
|
|
1112
|
+
return "ReplaceRange(filepath, start: 0-N, end: 0-N, fingerprint, content) -> ReplaceRangeToolResult<path, range>"
|
|
1113
|
+
|
|
1114
|
+
@classmethod
|
|
1115
|
+
def example(cls) -> list[str]:
|
|
1116
|
+
return ['Example args: ["code.py", "10", "12", "a1b2c3", "new text\\n"]']
|
|
1117
|
+
|
|
1118
|
+
@classmethod
|
|
1119
|
+
def make(cls, session: Session, args: list[str]) -> Self:
|
|
1120
|
+
if len(args) != 5:
|
|
1121
|
+
raise ToolCallError("requires exactly 5 args: filepath, start, end, fingerprint, content")
|
|
1122
|
+
start, end = _parse_line_range(args[1], args[2])
|
|
1123
|
+
fingerprint = str(args[3])
|
|
1124
|
+
if not fingerprint:
|
|
1125
|
+
raise ToolCallError("fingerprint cannot be empty")
|
|
1126
|
+
return cls(
|
|
1127
|
+
filepath=session.resolve_path(args[0]),
|
|
1128
|
+
start=start,
|
|
1129
|
+
end=end,
|
|
1130
|
+
fingerprint=fingerprint,
|
|
1131
|
+
content=str(args[4]),
|
|
1132
|
+
cwd=session.cwd,
|
|
1133
|
+
range_fingerprints=session.range_fingerprints,
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
def requires_confirmation(self, session: Session) -> bool:
|
|
1137
|
+
return True
|
|
1138
|
+
|
|
1139
|
+
def display(self) -> str:
|
|
1140
|
+
label = f"ReplaceRange({self.filepath}, {self.start}, {self.end}, {self.fingerprint})"
|
|
1141
|
+
try:
|
|
1142
|
+
original, new_content, _, _ = self._preview()
|
|
1143
|
+
except (OSError, ToolCallError) as error:
|
|
1144
|
+
return label + "\n# preview unavailable: " + str(error)
|
|
1145
|
+
return _make_unified_diff(original, new_content, self.filepath) or label
|
|
1146
|
+
|
|
1147
|
+
def call(self) -> str:
|
|
1148
|
+
original, new_content, resolved, _ = self._preview()
|
|
1149
|
+
if new_content == original:
|
|
1150
|
+
raise ToolCallError("range replacement produced no changes")
|
|
1151
|
+
with open(self.filepath, "w", encoding="utf-8") as f:
|
|
1152
|
+
f.write(new_content)
|
|
1153
|
+
|
|
1154
|
+
lines = [
|
|
1155
|
+
"<ReplaceRangeToolResult>",
|
|
1156
|
+
f"* path: {os.path.relpath(self.filepath, self.cwd)}",
|
|
1157
|
+
f"* range: {resolved.start}:{resolved.end}",
|
|
1158
|
+
f"* fingerprint: {resolved.fingerprint}",
|
|
1159
|
+
]
|
|
1160
|
+
if resolved.relocated_from:
|
|
1161
|
+
old_start, old_end = resolved.relocated_from
|
|
1162
|
+
lines.append(f"* relocated_from: {old_start}:{old_end}")
|
|
1163
|
+
lines.append("</ReplaceRangeToolResult>")
|
|
1164
|
+
return "\n".join(lines)
|
|
1165
|
+
|
|
1166
|
+
def _preview(self) -> tuple[str, str, RangeFingerprintStore.Resolved, list[str]]:
|
|
1167
|
+
with open(self.filepath, "r", encoding="utf-8") as f:
|
|
1168
|
+
original = f.read()
|
|
1169
|
+
lines = original.splitlines(keepends=True)
|
|
1170
|
+
resolved = self.range_fingerprints.resolve(
|
|
1171
|
+
lines,
|
|
1172
|
+
filepath=self.filepath,
|
|
1173
|
+
start=self.start,
|
|
1174
|
+
end=self.end,
|
|
1175
|
+
fingerprint=self.fingerprint,
|
|
1176
|
+
)
|
|
1177
|
+
replacement = self.content.splitlines(keepends=True)
|
|
1178
|
+
new_lines = lines[: resolved.start] + replacement + lines[resolved.end :]
|
|
1179
|
+
return original, "".join(new_lines), resolved, replacement
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
@final
|
|
1183
|
+
@dataclass
|
|
1184
|
+
class BatchReplaceRangesTool(Tool):
|
|
1185
|
+
@final
|
|
1186
|
+
@dataclass
|
|
1187
|
+
class Edit:
|
|
1188
|
+
start: int
|
|
1189
|
+
end: int
|
|
1190
|
+
fingerprint: str
|
|
1191
|
+
content: str
|
|
1192
|
+
|
|
1193
|
+
filepath: str = ""
|
|
1194
|
+
edits: list[Edit] = field(default_factory=list)
|
|
1195
|
+
cwd: str = ""
|
|
1196
|
+
range_fingerprints: RangeFingerprintStore = field(default_factory=RangeFingerprintStore)
|
|
1197
|
+
|
|
1198
|
+
@classmethod
|
|
1199
|
+
def name(cls) -> str:
|
|
1200
|
+
return "BatchReplaceRanges"
|
|
1201
|
+
|
|
1202
|
+
@classmethod
|
|
1203
|
+
def description(cls) -> list[str]:
|
|
1204
|
+
return [
|
|
1205
|
+
"Replace multiple 0-based line ranges in one file against one snapshot.",
|
|
1206
|
+
"Use this for multiple edits in the same file; earlier edits in this call do not shift later ranges.",
|
|
1207
|
+
"Each edit fingerprint must come from Read(filepath, same start, same end); same-range cached fingerprints can relocate shifted old content.",
|
|
1208
|
+
]
|
|
1209
|
+
|
|
1210
|
+
@classmethod
|
|
1211
|
+
def signature(cls) -> str:
|
|
1212
|
+
return "BatchReplaceRanges(filepath, edits_json) -> BatchReplaceRangesToolResult<path, edits>"
|
|
1213
|
+
|
|
1214
|
+
@classmethod
|
|
1215
|
+
def example(cls) -> list[str]:
|
|
1216
|
+
return ['Example args: ["code.py", "[{\\"start\\":10,\\"end\\":12,\\"fingerprint\\":\\"a1b2c3\\",\\"content\\":\\"new text\\\\n\\"}]"]']
|
|
1217
|
+
|
|
1218
|
+
@classmethod
|
|
1219
|
+
def make(cls, session: Session, args: list[str]) -> Self:
|
|
1220
|
+
if len(args) != 2:
|
|
1221
|
+
raise ToolCallError("requires exactly 2 args: filepath, edits_json")
|
|
1222
|
+
try:
|
|
1223
|
+
raw_edits = json.loads(str(args[1]))
|
|
1224
|
+
except json.JSONDecodeError as error:
|
|
1225
|
+
raise ToolCallError("invalid edits_json: " + str(error))
|
|
1226
|
+
if not isinstance(raw_edits, list) or not raw_edits:
|
|
1227
|
+
raise ToolCallError("edits_json must be a non-empty JSON array")
|
|
1228
|
+
edits = [cls._edit_from_json(raw) for raw in raw_edits]
|
|
1229
|
+
return cls(
|
|
1230
|
+
filepath=session.resolve_path(args[0]),
|
|
1231
|
+
edits=edits,
|
|
1232
|
+
cwd=session.cwd,
|
|
1233
|
+
range_fingerprints=session.range_fingerprints,
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
@classmethod
|
|
1237
|
+
def _edit_from_json(cls, raw: JsonValue) -> Edit:
|
|
1238
|
+
if not isinstance(raw, dict):
|
|
1239
|
+
raise ToolCallError("each edit must be a JSON object")
|
|
1240
|
+
start, end = _parse_line_range(str(raw.get("start", "")), str(raw.get("end", "")))
|
|
1241
|
+
fingerprint = str(raw.get("fingerprint", ""))
|
|
1242
|
+
if not fingerprint:
|
|
1243
|
+
raise ToolCallError("edit fingerprint cannot be empty")
|
|
1244
|
+
content = raw.get("content")
|
|
1245
|
+
if not isinstance(content, str):
|
|
1246
|
+
raise ToolCallError("edit content must be a string")
|
|
1247
|
+
return cls.Edit(start=start, end=end, fingerprint=fingerprint, content=content)
|
|
1248
|
+
|
|
1249
|
+
def requires_confirmation(self, session: Session) -> bool:
|
|
1250
|
+
return True
|
|
1251
|
+
|
|
1252
|
+
def display(self) -> str:
|
|
1253
|
+
label = f"BatchReplaceRanges({self.filepath}, edits={len(self.edits)})"
|
|
1254
|
+
try:
|
|
1255
|
+
original, new_content, _ = self._preview()
|
|
1256
|
+
except (OSError, ToolCallError) as error:
|
|
1257
|
+
return label + "\n# preview unavailable: " + str(error)
|
|
1258
|
+
return _make_unified_diff(original, new_content, self.filepath) or label
|
|
1259
|
+
|
|
1260
|
+
def call(self) -> str:
|
|
1261
|
+
original, new_content, resolved_edits = self._preview()
|
|
1262
|
+
if new_content == original:
|
|
1263
|
+
raise ToolCallError("range replacements produced no changes")
|
|
1264
|
+
with open(self.filepath, "w", encoding="utf-8") as f:
|
|
1265
|
+
f.write(new_content)
|
|
1266
|
+
|
|
1267
|
+
lines = [
|
|
1268
|
+
"<BatchReplaceRangesToolResult>",
|
|
1269
|
+
f"* path: {os.path.relpath(self.filepath, self.cwd)}",
|
|
1270
|
+
f"* edits: {len(resolved_edits)}",
|
|
1271
|
+
]
|
|
1272
|
+
for index, (resolved, _) in enumerate(resolved_edits, start=1):
|
|
1273
|
+
line = f"* range {index}: {resolved.start}:{resolved.end}"
|
|
1274
|
+
if resolved.relocated_from:
|
|
1275
|
+
old_start, old_end = resolved.relocated_from
|
|
1276
|
+
line += f" relocated_from={old_start}:{old_end}"
|
|
1277
|
+
lines.append(line)
|
|
1278
|
+
lines.append("</BatchReplaceRangesToolResult>")
|
|
1279
|
+
return "\n".join(lines)
|
|
1280
|
+
|
|
1281
|
+
def _preview(self) -> tuple[str, str, list[tuple[RangeFingerprintStore.Resolved, list[str]]]]:
|
|
1282
|
+
with open(self.filepath, "r", encoding="utf-8") as f:
|
|
1283
|
+
original = f.read()
|
|
1284
|
+
lines = original.splitlines(keepends=True)
|
|
1285
|
+
resolved_edits = []
|
|
1286
|
+
for edit in self.edits:
|
|
1287
|
+
resolved = self.range_fingerprints.resolve(
|
|
1288
|
+
lines,
|
|
1289
|
+
filepath=self.filepath,
|
|
1290
|
+
start=edit.start,
|
|
1291
|
+
end=edit.end,
|
|
1292
|
+
fingerprint=edit.fingerprint,
|
|
1293
|
+
)
|
|
1294
|
+
resolved_edits.append((resolved, edit.content.splitlines(keepends=True)))
|
|
1295
|
+
self._ensure_non_overlapping(resolved_edits)
|
|
1296
|
+
|
|
1297
|
+
new_lines = list(lines)
|
|
1298
|
+
for resolved, replacement in sorted(resolved_edits, key=lambda item: item[0].start, reverse=True):
|
|
1299
|
+
new_lines[resolved.start : resolved.end] = replacement
|
|
1300
|
+
return original, "".join(new_lines), resolved_edits
|
|
1301
|
+
|
|
1302
|
+
def _ensure_non_overlapping(self, resolved_edits: list[tuple[RangeFingerprintStore.Resolved, list[str]]]) -> None:
|
|
1303
|
+
last_end = -1
|
|
1304
|
+
for resolved, _ in sorted(resolved_edits, key=lambda item: (item[0].start, item[0].end)):
|
|
1305
|
+
if resolved.start < last_end:
|
|
1306
|
+
raise ToolCallError("resolved ranges overlap")
|
|
1307
|
+
last_end = max(last_end, resolved.end)
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
@final
|
|
1311
|
+
@dataclass
|
|
1312
|
+
class ApplyPatchTool(Tool):
|
|
1313
|
+
filepath: str = ""
|
|
1314
|
+
unified_diff: str = ""
|
|
1315
|
+
cwd: str = ""
|
|
1316
|
+
|
|
1317
|
+
@classmethod
|
|
1318
|
+
def name(cls) -> str:
|
|
1319
|
+
return "ApplyPatch"
|
|
1320
|
+
|
|
1321
|
+
@classmethod
|
|
1322
|
+
def description(cls) -> list[str]:
|
|
1323
|
+
return ["Apply a simple single-file unified diff."]
|
|
1324
|
+
|
|
1325
|
+
@classmethod
|
|
1326
|
+
def signature(cls) -> str:
|
|
1327
|
+
return "ApplyPatch(filepath, unified_diff) -> ApplyPatchToolResult<path, hunks>"
|
|
1328
|
+
|
|
1329
|
+
@classmethod
|
|
1330
|
+
def example(cls) -> list[str]:
|
|
1331
|
+
return ['Example args: ["code.py", "@@ -1,2 +1,2 @@\\n-old\\n+new\\n"]']
|
|
1332
|
+
|
|
1333
|
+
@classmethod
|
|
1334
|
+
def make(cls, session: Session, args: list[str]) -> Self:
|
|
1335
|
+
if len(args) != 2:
|
|
1336
|
+
raise ToolCallError("requires exactly 2 args: filepath, unified_diff")
|
|
1337
|
+
unified_diff = str(args[1])
|
|
1338
|
+
if not unified_diff.strip():
|
|
1339
|
+
raise ToolCallError("unified_diff cannot be empty")
|
|
1340
|
+
return cls(filepath=session.resolve_path(args[0]), unified_diff=unified_diff, cwd=session.cwd)
|
|
1341
|
+
|
|
1342
|
+
def requires_confirmation(self, session: Session) -> bool:
|
|
1343
|
+
return True
|
|
1344
|
+
|
|
1345
|
+
def display(self) -> str:
|
|
1346
|
+
label = f"ApplyPatch({self.filepath}, unified_diff=...)"
|
|
1347
|
+
try:
|
|
1348
|
+
with open(self.filepath, "r", encoding="utf-8") as f:
|
|
1349
|
+
original = f.read()
|
|
1350
|
+
new_content, _ = self._apply_unified_diff(original, self.unified_diff)
|
|
1351
|
+
except (OSError, ToolCallError) as error:
|
|
1352
|
+
return label + "\n# preview unavailable: " + str(error)
|
|
1353
|
+
return _make_unified_diff(original, new_content, self.filepath) or label
|
|
1354
|
+
|
|
1355
|
+
def call(self) -> str:
|
|
1356
|
+
with open(self.filepath, "r", encoding="utf-8") as f:
|
|
1357
|
+
original = f.read()
|
|
1358
|
+
new_content, hunks = self._apply_unified_diff(original, self.unified_diff)
|
|
1359
|
+
if new_content == original:
|
|
1360
|
+
raise ToolCallError("patch produced no changes")
|
|
1361
|
+
with open(self.filepath, "w", encoding="utf-8") as f:
|
|
1362
|
+
f.write(new_content)
|
|
1363
|
+
|
|
1364
|
+
return "\n".join(
|
|
1365
|
+
[
|
|
1366
|
+
"<ApplyPatchToolResult>",
|
|
1367
|
+
f"* path: {os.path.relpath(self.filepath, self.cwd)}",
|
|
1368
|
+
f"* hunks: {hunks}",
|
|
1369
|
+
"</ApplyPatchToolResult>",
|
|
1370
|
+
]
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
@staticmethod
|
|
1374
|
+
def _apply_unified_diff(content: str, unified_diff: str) -> tuple[str, int]:
|
|
1375
|
+
lines = content.splitlines(keepends=True)
|
|
1376
|
+
patch_lines = unified_diff.splitlines(keepends=True)
|
|
1377
|
+
offset = 0
|
|
1378
|
+
hunks = 0
|
|
1379
|
+
i = 0
|
|
1380
|
+
|
|
1381
|
+
while i < len(patch_lines):
|
|
1382
|
+
header = patch_lines[i].strip()
|
|
1383
|
+
if header == "@@":
|
|
1384
|
+
old_start = 0
|
|
1385
|
+
fuzzy = True
|
|
1386
|
+
elif header.startswith("@@ "):
|
|
1387
|
+
fuzzy = False
|
|
1388
|
+
parts = header.split()
|
|
1389
|
+
if len(parts) < 3 or not parts[1].startswith("-"):
|
|
1390
|
+
raise ToolCallError("invalid hunk header")
|
|
1391
|
+
try:
|
|
1392
|
+
old_start = int(parts[1][1:].split(",", 1)[0])
|
|
1393
|
+
except ValueError:
|
|
1394
|
+
raise ToolCallError("invalid hunk header")
|
|
1395
|
+
elif header.startswith("@@"):
|
|
1396
|
+
raise ToolCallError("invalid hunk header")
|
|
1397
|
+
else:
|
|
1398
|
+
i += 1
|
|
1399
|
+
continue
|
|
1400
|
+
|
|
1401
|
+
i += 1
|
|
1402
|
+
hunk_lines = []
|
|
1403
|
+
while i < len(patch_lines):
|
|
1404
|
+
next_header = patch_lines[i].strip()
|
|
1405
|
+
if next_header == "@@" or next_header.startswith("@@ "):
|
|
1406
|
+
break
|
|
1407
|
+
if next_header.startswith("@@"):
|
|
1408
|
+
raise ToolCallError("invalid hunk header")
|
|
1409
|
+
hunk_lines.append(patch_lines[i])
|
|
1410
|
+
i += 1
|
|
1411
|
+
|
|
1412
|
+
expected = []
|
|
1413
|
+
replacement = []
|
|
1414
|
+
for raw in hunk_lines:
|
|
1415
|
+
if raw.startswith("\\"):
|
|
1416
|
+
continue
|
|
1417
|
+
if not raw:
|
|
1418
|
+
continue
|
|
1419
|
+
marker = raw[0]
|
|
1420
|
+
text = raw[1:]
|
|
1421
|
+
if marker == " ":
|
|
1422
|
+
expected.append(text)
|
|
1423
|
+
replacement.append(text)
|
|
1424
|
+
elif marker == "-":
|
|
1425
|
+
expected.append(text)
|
|
1426
|
+
elif marker == "+":
|
|
1427
|
+
replacement.append(text)
|
|
1428
|
+
else:
|
|
1429
|
+
raise ToolCallError("invalid hunk line")
|
|
1430
|
+
|
|
1431
|
+
target = -1 if fuzzy else max(old_start - 1, 0) + offset
|
|
1432
|
+
index = ApplyPatchTool._find_hunk_position(lines, expected, target)
|
|
1433
|
+
lines[index : index + len(expected)] = replacement
|
|
1434
|
+
offset += len(replacement) - len(expected)
|
|
1435
|
+
hunks += 1
|
|
1436
|
+
|
|
1437
|
+
if hunks == 0:
|
|
1438
|
+
raise ToolCallError("patch has no hunks")
|
|
1439
|
+
return "".join(lines), hunks
|
|
1440
|
+
|
|
1441
|
+
@staticmethod
|
|
1442
|
+
def _find_hunk_position(lines: list[str], expected: list[str], target: int) -> int:
|
|
1443
|
+
if not expected:
|
|
1444
|
+
if target < 0 or target > len(lines):
|
|
1445
|
+
raise ToolCallError("hunk insertion target outside file")
|
|
1446
|
+
return target
|
|
1447
|
+
if 0 <= target <= len(lines) and lines[target : target + len(expected)] == expected:
|
|
1448
|
+
return target
|
|
1449
|
+
matches = []
|
|
1450
|
+
last_start = len(lines) - len(expected)
|
|
1451
|
+
for position in range(max(0, last_start + 1)):
|
|
1452
|
+
if lines[position : position + len(expected)] == expected:
|
|
1453
|
+
matches.append(position)
|
|
1454
|
+
if len(matches) > 1:
|
|
1455
|
+
break
|
|
1456
|
+
if not matches:
|
|
1457
|
+
raise ToolCallError("hunk context did not match")
|
|
1458
|
+
if len(matches) > 1:
|
|
1459
|
+
raise ToolCallError("hunk context matched multiple locations; add more context")
|
|
1460
|
+
return matches[0]
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
@final
|
|
1464
|
+
@dataclass
|
|
1465
|
+
class BashTool(Tool):
|
|
1466
|
+
command: str = ""
|
|
1467
|
+
bash_path: str = ""
|
|
1468
|
+
cwd: str = ""
|
|
1469
|
+
timeout: int = 60
|
|
1470
|
+
|
|
1471
|
+
@classmethod
|
|
1472
|
+
def name(cls) -> str:
|
|
1473
|
+
return "Bash"
|
|
1474
|
+
|
|
1475
|
+
@classmethod
|
|
1476
|
+
def description(cls) -> list[str]:
|
|
1477
|
+
return ["Run a shell command with bash -lc."]
|
|
1478
|
+
|
|
1479
|
+
@classmethod
|
|
1480
|
+
def signature(cls) -> str:
|
|
1481
|
+
return "Bash(command) -> BashToolResult<exit_code, stdout, stderr>"
|
|
1482
|
+
|
|
1483
|
+
@classmethod
|
|
1484
|
+
def example(cls) -> list[str]:
|
|
1485
|
+
return ['Example args: ["python3 -m py_compile nanocode.py"]']
|
|
1486
|
+
|
|
1487
|
+
@classmethod
|
|
1488
|
+
def make(cls, session: Session, args: list[str]) -> Self:
|
|
1489
|
+
if len(args) != 1:
|
|
1490
|
+
raise ToolCallError("requires exactly one arg: command")
|
|
1491
|
+
if not session.bash:
|
|
1492
|
+
raise ToolCallError("bash not found")
|
|
1493
|
+
return cls(command=str(args[0]), bash_path=session.bash, cwd=session.cwd, timeout=session.shell_timeout)
|
|
1494
|
+
|
|
1495
|
+
def requires_confirmation(self, session: Session) -> bool:
|
|
1496
|
+
return True
|
|
1497
|
+
|
|
1498
|
+
def display(self) -> str:
|
|
1499
|
+
return f'Bash("{self.command}")'
|
|
1500
|
+
|
|
1501
|
+
def call(self) -> str:
|
|
1502
|
+
stdout = tempfile.TemporaryFile("w+", encoding="utf-8")
|
|
1503
|
+
stderr = tempfile.TemporaryFile("w+", encoding="utf-8")
|
|
1504
|
+
try:
|
|
1505
|
+
proc = subprocess.Popen(
|
|
1506
|
+
[self.bash_path, "-lc", self.command],
|
|
1507
|
+
cwd=self.cwd,
|
|
1508
|
+
text=True,
|
|
1509
|
+
stdin=subprocess.DEVNULL,
|
|
1510
|
+
stdout=stdout,
|
|
1511
|
+
stderr=stderr,
|
|
1512
|
+
start_new_session=True,
|
|
1513
|
+
)
|
|
1514
|
+
try:
|
|
1515
|
+
proc.wait(timeout=self.timeout)
|
|
1516
|
+
except subprocess.TimeoutExpired:
|
|
1517
|
+
try:
|
|
1518
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
1519
|
+
except OSError:
|
|
1520
|
+
proc.kill()
|
|
1521
|
+
proc.wait()
|
|
1522
|
+
stderr_text = self._read_temp_file(stderr)
|
|
1523
|
+
if stderr_text:
|
|
1524
|
+
stderr_text += "\n"
|
|
1525
|
+
return _format_process_result("BashToolResult", -1, self._read_temp_file(stdout), stderr_text + "timeout")
|
|
1526
|
+
return _format_process_result("BashToolResult", proc.returncode, self._read_temp_file(stdout), self._read_temp_file(stderr))
|
|
1527
|
+
finally:
|
|
1528
|
+
stdout.close()
|
|
1529
|
+
stderr.close()
|
|
1530
|
+
|
|
1531
|
+
@staticmethod
|
|
1532
|
+
def _read_temp_file(file) -> str:
|
|
1533
|
+
file.seek(0)
|
|
1534
|
+
return file.read()
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
@final
|
|
1538
|
+
@dataclass
|
|
1539
|
+
class GitTool(Tool):
|
|
1540
|
+
args: list[str] = field(default_factory=list)
|
|
1541
|
+
git_path: str = ""
|
|
1542
|
+
cwd: str = ""
|
|
1543
|
+
timeout: int = 60
|
|
1544
|
+
|
|
1545
|
+
@classmethod
|
|
1546
|
+
def name(cls) -> str:
|
|
1547
|
+
return "Git"
|
|
1548
|
+
|
|
1549
|
+
@classmethod
|
|
1550
|
+
def description(cls) -> list[str]:
|
|
1551
|
+
return ["Run git without a shell; pass each argument separately."]
|
|
1552
|
+
|
|
1553
|
+
@classmethod
|
|
1554
|
+
def signature(cls) -> str:
|
|
1555
|
+
return "Git(args...[, cwd=path]) -> GitToolResult<exit_code, stdout, stderr>"
|
|
1556
|
+
|
|
1557
|
+
@classmethod
|
|
1558
|
+
def example(cls) -> list[str]:
|
|
1559
|
+
return ['Example args: ["status", "--short"]', 'Example args: ["diff", "--", "nanocode.py"]']
|
|
1560
|
+
|
|
1561
|
+
@classmethod
|
|
1562
|
+
def make(cls, session: Session, args: list[str]) -> Self:
|
|
1563
|
+
if not args:
|
|
1564
|
+
raise ToolCallError("requires at least one git arg")
|
|
1565
|
+
git_path = shutil.which("git")
|
|
1566
|
+
if not git_path:
|
|
1567
|
+
raise ToolCallError("git not found")
|
|
1568
|
+
|
|
1569
|
+
cwd = session.cwd
|
|
1570
|
+
git_args = [str(arg) for arg in args]
|
|
1571
|
+
if git_args[0].startswith("cwd="):
|
|
1572
|
+
cwd_arg = git_args.pop(0)[len("cwd=") :]
|
|
1573
|
+
if not cwd_arg:
|
|
1574
|
+
raise ToolCallError("cwd= requires a path")
|
|
1575
|
+
cwd = session.resolve_path(cwd_arg)
|
|
1576
|
+
if not session.is_path_in_cwd(cwd):
|
|
1577
|
+
raise ToolCallError(f"path outside cwd: {cwd_arg}")
|
|
1578
|
+
if not os.path.isdir(cwd):
|
|
1579
|
+
raise ToolCallError(f"cwd is not a directory: {cwd_arg}")
|
|
1580
|
+
if not git_args:
|
|
1581
|
+
raise ToolCallError("requires at least one git arg")
|
|
1582
|
+
return cls(args=git_args, git_path=git_path, cwd=cwd, timeout=session.shell_timeout)
|
|
1583
|
+
|
|
1584
|
+
def requires_confirmation(self, session: Session) -> bool:
|
|
1585
|
+
readonly = {"status", "diff", "log", "show", "rev-parse", "ls-files", "grep", "blame"}
|
|
1586
|
+
return not self.args or self.args[0] not in readonly
|
|
1587
|
+
|
|
1588
|
+
def display(self) -> str:
|
|
1589
|
+
return "Git(" + " ".join(self.args) + ")"
|
|
1590
|
+
|
|
1591
|
+
def call(self) -> str:
|
|
1592
|
+
try:
|
|
1593
|
+
proc = subprocess.run(
|
|
1594
|
+
[self.git_path, *self.args],
|
|
1595
|
+
cwd=self.cwd,
|
|
1596
|
+
text=True,
|
|
1597
|
+
stdout=subprocess.PIPE,
|
|
1598
|
+
stderr=subprocess.PIPE,
|
|
1599
|
+
timeout=self.timeout,
|
|
1600
|
+
)
|
|
1601
|
+
return _format_process_result("GitToolResult", proc.returncode, proc.stdout, proc.stderr)
|
|
1602
|
+
except subprocess.TimeoutExpired as error:
|
|
1603
|
+
return _format_process_result("GitToolResult", -1, error.stdout or "", (error.stderr or "") + "timeout")
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
TOOL_REGISTRY: dict[str, ToolClass] = {
|
|
1607
|
+
ReadTool.name(): ReadTool,
|
|
1608
|
+
LineCountTool.name(): LineCountTool,
|
|
1609
|
+
ListDirTool.name(): ListDirTool,
|
|
1610
|
+
SearchTool.name(): SearchTool,
|
|
1611
|
+
EditTool.name(): EditTool,
|
|
1612
|
+
ReplaceRangeTool.name(): ReplaceRangeTool,
|
|
1613
|
+
BatchReplaceRangesTool.name(): BatchReplaceRangesTool,
|
|
1614
|
+
ApplyPatchTool.name(): ApplyPatchTool,
|
|
1615
|
+
BashTool.name(): BashTool,
|
|
1616
|
+
GitTool.name(): GitTool,
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
#######################
|
|
1621
|
+
# Prompt
|
|
1622
|
+
#######################
|
|
1623
|
+
|
|
1624
|
+
MAIN_AGENT_SYSTEM_PROMPT = """You are nanocode, a minimal coding agent.
|
|
1625
|
+
|
|
1626
|
+
Core:
|
|
1627
|
+
- First determine the current Goal.
|
|
1628
|
+
- KEEP GOAL UNLESS IT IS DONE, CLEARLY WRONG, OR THE USER CHANGED IT.
|
|
1629
|
+
- Never guess without evidence.
|
|
1630
|
+
- Prefer the smallest correct change.
|
|
1631
|
+
- Define success criteria (verification). Loop until verified.
|
|
1632
|
+
- Plan before action.
|
|
1633
|
+
- Follow the plan, but revise it when facts require it.
|
|
1634
|
+
|
|
1635
|
+
Tools:
|
|
1636
|
+
- Call tools only by emitting JSON in tool_calls. Do not use native tool calls.
|
|
1637
|
+
- Use multiple tool calls in one turn when they are independent.
|
|
1638
|
+
- Prefer specific tools first; use Bash only when no provided tool fits.
|
|
1639
|
+
- Summarize every latest tool result in last_tool_calls_summaries; raw results are shown once only, so key_evidence keeps paths, lines, errors, decisions.
|
|
1640
|
+
- If a prior tool result lacks detail, use Read on its result_file.
|
|
1641
|
+
- known_append is stable memory; current_context_update is task-local memory.
|
|
1642
|
+
- tool_call.intention must state the question to answer, not just the action.
|
|
1643
|
+
|
|
1644
|
+
Verification:
|
|
1645
|
+
- Verification_State belongs only to its <goal>.
|
|
1646
|
+
- If the user changed the goal, replace goal/plan and verify the new goal.
|
|
1647
|
+
|
|
1648
|
+
Available tools:
|
|
1649
|
+
|
|
1650
|
+
{ __tools__ }
|
|
1651
|
+
|
|
1652
|
+
Input:
|
|
1653
|
+
- Conversation_History: summarized recent events
|
|
1654
|
+
- Known: stable facts
|
|
1655
|
+
- Current_Context: task-local facts that may expire
|
|
1656
|
+
- Goal: current objective
|
|
1657
|
+
- Plan: ordered plan
|
|
1658
|
+
- Latest_Tool_Call_Results: latest raw tool call results
|
|
1659
|
+
- Latest_User_Input: latest user message
|
|
1660
|
+
- Tools: available tool specs
|
|
1661
|
+
|
|
1662
|
+
Output strict JSON only. No markdown. No comments. No extra text.
|
|
1663
|
+
Never answer outside JSON, including help/status/explanation requests; put user-facing text only in message_to_user.
|
|
1664
|
+
|
|
1665
|
+
Schema:
|
|
1666
|
+
{
|
|
1667
|
+
"user_language": "string",
|
|
1668
|
+
|
|
1669
|
+
"goal_update": null | "string",
|
|
1670
|
+
"goal_reached": true | false,
|
|
1671
|
+
|
|
1672
|
+
"plan_update": null | {
|
|
1673
|
+
"mode": "replace" | "patch",
|
|
1674
|
+
"items": [
|
|
1675
|
+
{
|
|
1676
|
+
"op": "add|update|remove",
|
|
1677
|
+
"id": "string",
|
|
1678
|
+
"after": null | "string",
|
|
1679
|
+
"text": null | "string",
|
|
1680
|
+
"status": null | "todo|doing|done|blocked",
|
|
1681
|
+
"evidence": null | "string"
|
|
1682
|
+
}
|
|
1683
|
+
]
|
|
1684
|
+
},
|
|
1685
|
+
|
|
1686
|
+
"known_append": null | [
|
|
1687
|
+
{
|
|
1688
|
+
"fact": "string",
|
|
1689
|
+
"details": null | ["string"]
|
|
1690
|
+
}
|
|
1691
|
+
],
|
|
1692
|
+
|
|
1693
|
+
"current_context_update": null | {
|
|
1694
|
+
"mode": "replace" | "append",
|
|
1695
|
+
"items": [
|
|
1696
|
+
{
|
|
1697
|
+
"note": "string",
|
|
1698
|
+
"details": null | ["string"]
|
|
1699
|
+
}
|
|
1700
|
+
]
|
|
1701
|
+
},
|
|
1702
|
+
|
|
1703
|
+
"verification": {
|
|
1704
|
+
"method": null | "string",
|
|
1705
|
+
"status": "pending" | "passed" | "blocked",
|
|
1706
|
+
"evidence": null | "string"
|
|
1707
|
+
},
|
|
1708
|
+
|
|
1709
|
+
"tool_calls": null | [
|
|
1710
|
+
{
|
|
1711
|
+
"name": "string",
|
|
1712
|
+
"intention": "string",
|
|
1713
|
+
"args": ["string"]
|
|
1714
|
+
}
|
|
1715
|
+
],
|
|
1716
|
+
|
|
1717
|
+
"last_tool_calls_summaries": [
|
|
1718
|
+
{
|
|
1719
|
+
"tool": "string",
|
|
1720
|
+
"intention": "string",
|
|
1721
|
+
"outcome": "success" | "failure" | "partial",
|
|
1722
|
+
"summary": "string",
|
|
1723
|
+
"key_evidence": null | ["string"],
|
|
1724
|
+
"result_file": null | "string",
|
|
1725
|
+
"needs_raw_read": true | false
|
|
1726
|
+
}
|
|
1727
|
+
],
|
|
1728
|
+
|
|
1729
|
+
"message_to_user": null | "string"
|
|
1730
|
+
}
|
|
1731
|
+
"""
|
|
1732
|
+
|
|
1733
|
+
MAIN_AGENT_USER_PROMPT_TEMPLATE = """
|
|
1734
|
+
|
|
1735
|
+
----------- Environment Begin ------
|
|
1736
|
+
{environment}
|
|
1737
|
+
-------- Environment End -----------
|
|
1738
|
+
|
|
1739
|
+
----------- Conversation_History Begin ------
|
|
1740
|
+
{conversation_history}
|
|
1741
|
+
-------- Conversation_History End -----------
|
|
1742
|
+
|
|
1743
|
+
----------- Known Begin ------
|
|
1744
|
+
{known}
|
|
1745
|
+
-------- Known End -----------
|
|
1746
|
+
|
|
1747
|
+
----------- Current_Context Begin ------
|
|
1748
|
+
{current_context}
|
|
1749
|
+
-------- Current_Context End -----------
|
|
1750
|
+
|
|
1751
|
+
----------- Goal Begin ------
|
|
1752
|
+
{goal}
|
|
1753
|
+
-------- Goal End -----------
|
|
1754
|
+
|
|
1755
|
+
----------- Plan Begin ------
|
|
1756
|
+
{plan}
|
|
1757
|
+
-------- Plan End -----------
|
|
1758
|
+
|
|
1759
|
+
----------- Verification_State Begin ------
|
|
1760
|
+
{verification_state}
|
|
1761
|
+
-------- Verification_State End -----------
|
|
1762
|
+
|
|
1763
|
+
----------- Latest_Tool_Call_Results Begin ------
|
|
1764
|
+
{latest_tool_call_results}
|
|
1765
|
+
-------- Latest_Tool_Call_Results End -----------
|
|
1766
|
+
|
|
1767
|
+
----------- Latest_User_Input Begin ------
|
|
1768
|
+
{latest_user_input}
|
|
1769
|
+
-------- Latest_User_Input End -----------
|
|
1770
|
+
"""
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
SUMMARIZER_AGENT_COMPACT_PROMPT = """You are nanocode's conversation-history compactor.
|
|
1774
|
+
|
|
1775
|
+
Compress conversation history so the main coding agent can continue later.
|
|
1776
|
+
Do not solve the task or add unsupported facts.
|
|
1777
|
+
|
|
1778
|
+
Preserve continuity-critical facts:
|
|
1779
|
+
- user requests and changes
|
|
1780
|
+
- decisions made
|
|
1781
|
+
- current goal and commitments
|
|
1782
|
+
- plan/status
|
|
1783
|
+
- files, paths, symbols, and APIs touched
|
|
1784
|
+
- commands run and outcomes
|
|
1785
|
+
- result_refs/log refs needed later
|
|
1786
|
+
- unresolved blockers and open questions
|
|
1787
|
+
- verification evidence
|
|
1788
|
+
|
|
1789
|
+
Omit noise:
|
|
1790
|
+
- raw logs
|
|
1791
|
+
- repeated output
|
|
1792
|
+
- full stack traces
|
|
1793
|
+
- chatter
|
|
1794
|
+
- details already recoverable from result_refs unless needed for continuity
|
|
1795
|
+
|
|
1796
|
+
Write the shortest complete continuation summary.
|
|
1797
|
+
|
|
1798
|
+
Output strict JSON only: {"summary": "string"}
|
|
1799
|
+
"""
|
|
1800
|
+
|
|
1801
|
+
|
|
1802
|
+
COMPACT_USER_PROMPT_TEMPLATE = """
|
|
1803
|
+
----------- Conversation_To_Compact Begin ------
|
|
1804
|
+
{conversation}
|
|
1805
|
+
-------- Conversation_To_Compact End -----------
|
|
1806
|
+
"""
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
@final
|
|
1810
|
+
class PromptBuilder:
|
|
1811
|
+
def __init__(self, session: Session):
|
|
1812
|
+
self.session = session
|
|
1813
|
+
|
|
1814
|
+
def system_prompt(self) -> str:
|
|
1815
|
+
return MAIN_AGENT_SYSTEM_PROMPT.replace("{ __tools__ }", self._format_tools()).strip()
|
|
1816
|
+
|
|
1817
|
+
def user_prompt(self, latest_tool_call_results: str) -> str:
|
|
1818
|
+
current = self.session.current
|
|
1819
|
+
return MAIN_AGENT_USER_PROMPT_TEMPLATE.format(
|
|
1820
|
+
environment=self._format_environment(),
|
|
1821
|
+
conversation_history=self._format_conversation_history(),
|
|
1822
|
+
known=self._format_known(),
|
|
1823
|
+
current_context=self._format_current_context(),
|
|
1824
|
+
goal=current.goal or "(empty)",
|
|
1825
|
+
plan=self._format_plan(),
|
|
1826
|
+
verification_state=current.verification.format(),
|
|
1827
|
+
latest_tool_call_results=latest_tool_call_results or "(empty)",
|
|
1828
|
+
latest_user_input=current.user_input or "(empty)",
|
|
1829
|
+
).strip()
|
|
1830
|
+
|
|
1831
|
+
def _format_tools(self) -> str:
|
|
1832
|
+
lines = []
|
|
1833
|
+
for tool in TOOL_REGISTRY.values():
|
|
1834
|
+
lines.append("- " + tool.signature())
|
|
1835
|
+
for item in tool.description():
|
|
1836
|
+
lines.append(" - " + item)
|
|
1837
|
+
return "\n".join(lines)
|
|
1838
|
+
|
|
1839
|
+
def _format_environment(self) -> str:
|
|
1840
|
+
return "\n".join(["- system: " + self.session.system, "- arch: " + self.session.arch, "- cwd: " + self.session.cwd])
|
|
1841
|
+
|
|
1842
|
+
def _format_conversation_history(self) -> str:
|
|
1843
|
+
if not self.session.conversation:
|
|
1844
|
+
return "(empty)"
|
|
1845
|
+
return "\n\n".join(item.format() for item in self.session.conversation)
|
|
1846
|
+
|
|
1847
|
+
def _format_known(self) -> str:
|
|
1848
|
+
if not self.session.current.known:
|
|
1849
|
+
return "(empty)"
|
|
1850
|
+
return "\n\n".join(item.format() for item in self.session.current.known)
|
|
1851
|
+
|
|
1852
|
+
def _format_current_context(self) -> str:
|
|
1853
|
+
if not self.session.current.current_context:
|
|
1854
|
+
return "(empty)"
|
|
1855
|
+
return "\n\n".join(item.format() for item in self.session.current.current_context)
|
|
1856
|
+
|
|
1857
|
+
def _format_plan(self) -> str:
|
|
1858
|
+
if not self.session.current.plan:
|
|
1859
|
+
return "(empty)"
|
|
1860
|
+
return "\n".join(item.format() for item in self.session.current.plan)
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
############################
|
|
1864
|
+
# LLM Request (ModelClient)
|
|
1865
|
+
############################
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
@final
|
|
1869
|
+
class ModelClient:
|
|
1870
|
+
def __init__(self, session: Session):
|
|
1871
|
+
self.session = session
|
|
1872
|
+
|
|
1873
|
+
def request(self, system_prompt: str, user_prompt: str, *, activity: str = "main") -> Json:
|
|
1874
|
+
if not self.session.api_url:
|
|
1875
|
+
raise LLMError("NANOCODE_API_URL is required")
|
|
1876
|
+
if not self.session.api_key:
|
|
1877
|
+
raise LLMError("NANOCODE_API_KEY is required")
|
|
1878
|
+
if not self.session.model:
|
|
1879
|
+
raise LLMError("NANOCODE_MODEL is required")
|
|
1880
|
+
|
|
1881
|
+
messages = [
|
|
1882
|
+
{"role": "system", "content": system_prompt},
|
|
1883
|
+
{"role": "user", "content": user_prompt},
|
|
1884
|
+
]
|
|
1885
|
+
payload: Json = {
|
|
1886
|
+
"model": self.session.model,
|
|
1887
|
+
"messages": messages,
|
|
1888
|
+
"temperature": self.session.temperature,
|
|
1889
|
+
}
|
|
1890
|
+
extra_params = self._reasoning_params()
|
|
1891
|
+
payload.update(extra_params)
|
|
1892
|
+
self._write_debug_prompt(activity=activity, messages=messages)
|
|
1893
|
+
|
|
1894
|
+
request = urllib.request.Request(
|
|
1895
|
+
url=self._chat_completions_url(),
|
|
1896
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
1897
|
+
headers={
|
|
1898
|
+
"Authorization": "Bearer " + self.session.api_key,
|
|
1899
|
+
"Content-Type": "application/json",
|
|
1900
|
+
},
|
|
1901
|
+
)
|
|
1902
|
+
try:
|
|
1903
|
+
self.session.current_model_call_started_at = time.monotonic()
|
|
1904
|
+
try:
|
|
1905
|
+
with urllib.request.urlopen(request, timeout=self.session.model_timeout) as response:
|
|
1906
|
+
body = response.read().decode("utf-8")
|
|
1907
|
+
finally:
|
|
1908
|
+
self.session.current_model_call_started_at = 0.0
|
|
1909
|
+
except socket.timeout:
|
|
1910
|
+
raise LLMError("request model timeout")
|
|
1911
|
+
except urllib.error.HTTPError as error:
|
|
1912
|
+
body = error.read().decode("utf-8", errors="replace")
|
|
1913
|
+
raise LLMError("API request failed: HTTP " + str(error.code) + ": " + _shorten(body))
|
|
1914
|
+
except Exception as error:
|
|
1915
|
+
raise LLMError(str(error))
|
|
1916
|
+
|
|
1917
|
+
try:
|
|
1918
|
+
result = json.loads(body)
|
|
1919
|
+
except json.JSONDecodeError:
|
|
1920
|
+
raise LLMError("API response is not JSON: " + _shorten(body))
|
|
1921
|
+
|
|
1922
|
+
self._record_usage(_json_dict(result.get("usage") if isinstance(result, dict) else None))
|
|
1923
|
+
content = self._message_content(result)
|
|
1924
|
+
return self._parse_model_content(content)
|
|
1925
|
+
|
|
1926
|
+
def _write_debug_prompt(self, *, activity: str, messages: list[Json]) -> str:
|
|
1927
|
+
if not self.session.debug:
|
|
1928
|
+
return ""
|
|
1929
|
+
self.session.debug_prompt_count += 1
|
|
1930
|
+
directory = self.session.debug_dir()
|
|
1931
|
+
os.makedirs(directory, exist_ok=True)
|
|
1932
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
|
|
1933
|
+
filepath = os.path.join(directory, f"{timestamp}-{self.session.debug_prompt_count:04d}-{activity or 'request'}.txt")
|
|
1934
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
1935
|
+
f.write(self._format_debug_prompt(messages=messages))
|
|
1936
|
+
return filepath
|
|
1937
|
+
|
|
1938
|
+
def _format_debug_prompt(self, *, messages: list[Json]) -> str:
|
|
1939
|
+
lines = []
|
|
1940
|
+
for index, message in enumerate(messages, start=1):
|
|
1941
|
+
role = _json_str(message.get("role")) or "(unknown)"
|
|
1942
|
+
content = message.get("content")
|
|
1943
|
+
lines.append(f"--- {role} message {index} ---")
|
|
1944
|
+
if isinstance(content, str):
|
|
1945
|
+
lines.append(content)
|
|
1946
|
+
else:
|
|
1947
|
+
lines.append(json.dumps(content, ensure_ascii=False, indent=2))
|
|
1948
|
+
lines.append("")
|
|
1949
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
1950
|
+
|
|
1951
|
+
def _parse_model_content(self, content: str) -> Json:
|
|
1952
|
+
text = content.strip()
|
|
1953
|
+
if text.startswith("```"):
|
|
1954
|
+
lines = text.splitlines()
|
|
1955
|
+
if lines and lines[0].startswith("```"):
|
|
1956
|
+
lines = lines[1:]
|
|
1957
|
+
if lines and lines[-1].strip() == "```":
|
|
1958
|
+
lines = lines[:-1]
|
|
1959
|
+
text = "\n".join(lines).strip()
|
|
1960
|
+
try:
|
|
1961
|
+
value = json.loads(text)
|
|
1962
|
+
except json.JSONDecodeError:
|
|
1963
|
+
return self._invalid_model_response(content)
|
|
1964
|
+
if not isinstance(value, dict):
|
|
1965
|
+
return self._invalid_model_response(content)
|
|
1966
|
+
return value
|
|
1967
|
+
|
|
1968
|
+
def _invalid_model_response(self, content: str) -> Json:
|
|
1969
|
+
return {
|
|
1970
|
+
"goal_reached": False,
|
|
1971
|
+
"tool_calls": None,
|
|
1972
|
+
"message_to_user": None,
|
|
1973
|
+
"_format_error": "Invalid model output: expected one JSON object matching the Output JSON schema. Return strict JSON only. Bad output: "
|
|
1974
|
+
+ _shorten(content),
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
def _chat_completions_url(self) -> str:
|
|
1978
|
+
url = self.session.api_url.rstrip("/")
|
|
1979
|
+
if url.endswith("/chat/completions"):
|
|
1980
|
+
return url
|
|
1981
|
+
return url + "/chat/completions"
|
|
1982
|
+
|
|
1983
|
+
def _reasoning_params(self) -> Json:
|
|
1984
|
+
if not self.session.reasoning:
|
|
1985
|
+
return {}
|
|
1986
|
+
if "openrouter.ai" in self.session.api_url:
|
|
1987
|
+
return {"reasoning": {"effort": self.session.reasoning_effort}}
|
|
1988
|
+
return {}
|
|
1989
|
+
|
|
1990
|
+
def _message_content(self, result: JsonValue) -> str:
|
|
1991
|
+
data = _json_dict(result)
|
|
1992
|
+
choices = _json_list(data.get("choices"))
|
|
1993
|
+
if not choices:
|
|
1994
|
+
raise LLMError("API response missing choices")
|
|
1995
|
+
message = _json_dict(_json_dict(choices[0]).get("message"))
|
|
1996
|
+
content = message.get("content")
|
|
1997
|
+
if not isinstance(content, str):
|
|
1998
|
+
raise LLMError("API response missing message content")
|
|
1999
|
+
return content
|
|
2000
|
+
|
|
2001
|
+
def _record_usage(self, usage: Json) -> None:
|
|
2002
|
+
prompt_tokens = _json_int(usage.get("prompt_tokens"))
|
|
2003
|
+
completion_tokens = _json_int(usage.get("completion_tokens"))
|
|
2004
|
+
total_tokens = _json_int(usage.get("total_tokens"))
|
|
2005
|
+
self.session.last_prompt_tokens = prompt_tokens
|
|
2006
|
+
self.session.last_completion_tokens = completion_tokens
|
|
2007
|
+
self.session.last_total_tokens = total_tokens
|
|
2008
|
+
self.session.session_prompt_tokens += prompt_tokens
|
|
2009
|
+
self.session.session_completion_tokens += completion_tokens
|
|
2010
|
+
self.session.session_total_tokens += total_tokens
|
|
2011
|
+
|
|
2012
|
+
|
|
2013
|
+
############################
|
|
2014
|
+
# ToolCallRunner
|
|
2015
|
+
############################
|
|
2016
|
+
|
|
2017
|
+
|
|
2018
|
+
@final
|
|
2019
|
+
class ToolCallRunner:
|
|
2020
|
+
TOOL_RESULTS_DIR: ClassVar[str] = "tool_results"
|
|
2021
|
+
DISPLAY_LIMIT: ClassVar[int] = 5
|
|
2022
|
+
|
|
2023
|
+
def __init__(self, session: Session):
|
|
2024
|
+
self.session = session
|
|
2025
|
+
self.latest_events: list[ToolCallEvent] = []
|
|
2026
|
+
self.latest_executions: list[ToolCallExecution] = []
|
|
2027
|
+
|
|
2028
|
+
def execute(
|
|
2029
|
+
self,
|
|
2030
|
+
tool_calls: list[JsonValue],
|
|
2031
|
+
*,
|
|
2032
|
+
confirm: ConfirmCallback | None = None,
|
|
2033
|
+
on_auto_approve: ToolDisplayCallback | None = None,
|
|
2034
|
+
) -> str:
|
|
2035
|
+
executions = []
|
|
2036
|
+
events = []
|
|
2037
|
+
for item in tool_calls:
|
|
2038
|
+
call: ParsedToolCall | None = None
|
|
2039
|
+
outcome = "success"
|
|
2040
|
+
output = ""
|
|
2041
|
+
try:
|
|
2042
|
+
call = self.parse_tool_call(item)
|
|
2043
|
+
tool = self._make_tool(call)
|
|
2044
|
+
if tool.requires_confirmation(self.session):
|
|
2045
|
+
if self.session.yolo:
|
|
2046
|
+
if on_auto_approve is not None:
|
|
2047
|
+
on_auto_approve(call, tool)
|
|
2048
|
+
elif confirm is None:
|
|
2049
|
+
raise Cancellation("user confirmation required")
|
|
2050
|
+
else:
|
|
2051
|
+
confirmation = confirm(call, tool)
|
|
2052
|
+
if confirmation is not True:
|
|
2053
|
+
reason = " ".join(confirmation.split()) if isinstance(confirmation, str) else ""
|
|
2054
|
+
if reason:
|
|
2055
|
+
raise Cancellation("user refused: " + reason)
|
|
2056
|
+
raise Cancellation("user refused")
|
|
2057
|
+
output = tool.call()
|
|
2058
|
+
except Cancellation as error:
|
|
2059
|
+
outcome = "failure"
|
|
2060
|
+
output = "Cancelled: " + str(error)
|
|
2061
|
+
except Exception as error:
|
|
2062
|
+
outcome = "failure"
|
|
2063
|
+
output = "ToolCallError: " + str(error)
|
|
2064
|
+
if call is None:
|
|
2065
|
+
call = self._invalid_tool_call(item)
|
|
2066
|
+
|
|
2067
|
+
result_file, result_file_lines = self._write_tool_result_log(call, outcome, output)
|
|
2068
|
+
execution = ToolCallExecution(
|
|
2069
|
+
call=call,
|
|
2070
|
+
outcome=outcome,
|
|
2071
|
+
output=output,
|
|
2072
|
+
result_file=result_file,
|
|
2073
|
+
result_file_lines=result_file_lines,
|
|
2074
|
+
)
|
|
2075
|
+
event = ToolCallEvent(
|
|
2076
|
+
intent=call.intention,
|
|
2077
|
+
executed=call.executed,
|
|
2078
|
+
outcome=outcome,
|
|
2079
|
+
summary="",
|
|
2080
|
+
result_file=result_file,
|
|
2081
|
+
result_file_lines=result_file_lines,
|
|
2082
|
+
)
|
|
2083
|
+
self.session.append_conversation(event)
|
|
2084
|
+
executions.append(execution)
|
|
2085
|
+
events.append(event)
|
|
2086
|
+
|
|
2087
|
+
self.latest_events = events
|
|
2088
|
+
self.latest_executions = executions
|
|
2089
|
+
return self._format_latest_tool_call_results(executions)
|
|
2090
|
+
|
|
2091
|
+
def format_latest_report(self) -> str:
|
|
2092
|
+
if not self.latest_executions:
|
|
2093
|
+
return ""
|
|
2094
|
+
offset = max(0, len(self.latest_executions) - self.DISPLAY_LIMIT)
|
|
2095
|
+
visible = self.latest_executions[offset:]
|
|
2096
|
+
lines = ["Tool Calls"]
|
|
2097
|
+
if offset:
|
|
2098
|
+
lines.append(" ... " + str(offset) + " older")
|
|
2099
|
+
for index, execution in enumerate(visible, start=offset + 1):
|
|
2100
|
+
lines.append(" " + str(index) + ". [" + execution.outcome + "] " + execution.call.executed)
|
|
2101
|
+
if execution.call.intention:
|
|
2102
|
+
lines.append(" why: " + execution.call.intention)
|
|
2103
|
+
lines.append(" log: " + execution.result_file + " (" + str(execution.result_file_lines) + " lines)")
|
|
2104
|
+
return "\n".join(lines)
|
|
2105
|
+
|
|
2106
|
+
def parse_tool_call(self, value: JsonValue) -> ParsedToolCall:
|
|
2107
|
+
item = _json_dict(value)
|
|
2108
|
+
name = _json_str(item.get("name"))
|
|
2109
|
+
if not name:
|
|
2110
|
+
raise ToolCallError("tool call missing name")
|
|
2111
|
+
intention = _json_str(item.get("intention")) or ""
|
|
2112
|
+
args = [_json_str(arg) or "" for arg in _json_list(item.get("args"))]
|
|
2113
|
+
return ParsedToolCall(name=name, intention=intention, args=args)
|
|
2114
|
+
|
|
2115
|
+
def _invalid_tool_call(self, value: JsonValue) -> ParsedToolCall:
|
|
2116
|
+
try:
|
|
2117
|
+
raw = json.dumps(value, ensure_ascii=False)
|
|
2118
|
+
except (TypeError, ValueError):
|
|
2119
|
+
raw = repr(value)
|
|
2120
|
+
return ParsedToolCall(name="InvalidToolCall", intention="parse malformed tool call", args=[_shorten(raw, 300)])
|
|
2121
|
+
|
|
2122
|
+
def _make_tool(self, call: ParsedToolCall) -> Tool:
|
|
2123
|
+
tool_class = TOOL_REGISTRY.get(call.name)
|
|
2124
|
+
if tool_class is None:
|
|
2125
|
+
raise ToolCallError("tool not found: " + call.name)
|
|
2126
|
+
return tool_class.make(self.session, call.args)
|
|
2127
|
+
|
|
2128
|
+
def _write_tool_result_log(self, call: ParsedToolCall, outcome: str, output: str) -> tuple[str, int]:
|
|
2129
|
+
directory = self.session.tool_results_dir()
|
|
2130
|
+
os.makedirs(directory, exist_ok=True)
|
|
2131
|
+
filepath = os.path.join(directory, datetime.now().strftime("%Y%m%d-%H%M%S-%f") + ".log")
|
|
2132
|
+
content = "\n".join(
|
|
2133
|
+
[
|
|
2134
|
+
"<Tool_Call_Result_Log>",
|
|
2135
|
+
" <tool>" + call.name + "</tool>",
|
|
2136
|
+
" <intention>" + call.intention + "</intention>",
|
|
2137
|
+
" <executed>" + call.executed + "</executed>",
|
|
2138
|
+
" <outcome>" + outcome + "</outcome>",
|
|
2139
|
+
" <raw_result>",
|
|
2140
|
+
output,
|
|
2141
|
+
" </raw_result>",
|
|
2142
|
+
"</Tool_Call_Result_Log>",
|
|
2143
|
+
]
|
|
2144
|
+
)
|
|
2145
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
2146
|
+
f.write(content)
|
|
2147
|
+
return os.path.relpath(filepath, self.session.cwd), len(content.splitlines())
|
|
2148
|
+
|
|
2149
|
+
def _format_latest_tool_call_results(self, executions: list[ToolCallExecution]) -> str:
|
|
2150
|
+
if not executions:
|
|
2151
|
+
return "(empty)"
|
|
2152
|
+
blocks = []
|
|
2153
|
+
for execution in executions:
|
|
2154
|
+
blocks.append(
|
|
2155
|
+
"\n".join(
|
|
2156
|
+
[
|
|
2157
|
+
"<Latest_Tool_Call_Result>",
|
|
2158
|
+
" <tool>" + execution.call.name + "</tool>",
|
|
2159
|
+
" <intention>" + execution.call.intention + "</intention>",
|
|
2160
|
+
" <executed>" + execution.call.executed + "</executed>",
|
|
2161
|
+
" <outcome>" + execution.outcome + "</outcome>",
|
|
2162
|
+
" <result_file>" + execution.result_file + "</result_file>",
|
|
2163
|
+
" <raw_result>",
|
|
2164
|
+
execution.output,
|
|
2165
|
+
" </raw_result>",
|
|
2166
|
+
"</Latest_Tool_Call_Result>",
|
|
2167
|
+
]
|
|
2168
|
+
)
|
|
2169
|
+
)
|
|
2170
|
+
return "\n\n".join(blocks)
|
|
2171
|
+
|
|
2172
|
+
|
|
2173
|
+
############################
|
|
2174
|
+
# AgentStateUpdater
|
|
2175
|
+
############################
|
|
2176
|
+
|
|
2177
|
+
|
|
2178
|
+
@final
|
|
2179
|
+
class AgentStateUpdater:
|
|
2180
|
+
DISPLAY_LIMIT: ClassVar[int] = 5
|
|
2181
|
+
CURRENT_CONTEXT_LIMIT: ClassVar[int] = 50
|
|
2182
|
+
|
|
2183
|
+
def __init__(self, session: Session, tool_runner: ToolCallRunner):
|
|
2184
|
+
self.session = session
|
|
2185
|
+
self.tool_runner = tool_runner
|
|
2186
|
+
self.latest_report = ""
|
|
2187
|
+
|
|
2188
|
+
def apply(self, response: Json) -> None:
|
|
2189
|
+
before_goal = self.session.current.goal
|
|
2190
|
+
before_plan = [item.format() for item in self.session.current.plan]
|
|
2191
|
+
before_known = [item.format() for item in self.session.current.known]
|
|
2192
|
+
before_context = [item.format() for item in self.session.current.current_context]
|
|
2193
|
+
before_verification = self.session.current.verification.format()
|
|
2194
|
+
goal_changed = self._apply_goal(response)
|
|
2195
|
+
plan_replaced = self._apply_plan(response)
|
|
2196
|
+
self._reset_stale_verification(response, goal_changed=goal_changed, plan_replaced=plan_replaced)
|
|
2197
|
+
if goal_changed:
|
|
2198
|
+
self.session.current.current_context = []
|
|
2199
|
+
self.session.range_fingerprints.clear()
|
|
2200
|
+
self._apply_known(response)
|
|
2201
|
+
self._apply_current_context(response)
|
|
2202
|
+
self._apply_verification(response)
|
|
2203
|
+
self._bind_verification_goal()
|
|
2204
|
+
self._apply_tool_call_summaries(response)
|
|
2205
|
+
self.latest_report = self._format_state_report(
|
|
2206
|
+
before_goal,
|
|
2207
|
+
before_plan,
|
|
2208
|
+
before_known,
|
|
2209
|
+
before_context,
|
|
2210
|
+
before_verification,
|
|
2211
|
+
)
|
|
2212
|
+
|
|
2213
|
+
def apply_tool_call_summaries(self, response: Json) -> None:
|
|
2214
|
+
self._apply_tool_call_summaries(response)
|
|
2215
|
+
|
|
2216
|
+
def _format_state_report(
|
|
2217
|
+
self,
|
|
2218
|
+
before_goal: str,
|
|
2219
|
+
before_plan: list[str],
|
|
2220
|
+
before_known: list[str],
|
|
2221
|
+
before_context: list[str],
|
|
2222
|
+
before_verification: str,
|
|
2223
|
+
) -> str:
|
|
2224
|
+
current = self.session.current
|
|
2225
|
+
lines = []
|
|
2226
|
+
if current.goal != before_goal:
|
|
2227
|
+
lines.append("State Updated | " + self._verification_badge())
|
|
2228
|
+
lines.append(" Goal " + self._compact(current.goal or "(empty)"))
|
|
2229
|
+
plan = [item.format() for item in current.plan]
|
|
2230
|
+
if plan != before_plan:
|
|
2231
|
+
if not lines:
|
|
2232
|
+
lines.append("State Updated | " + self._verification_badge())
|
|
2233
|
+
lines.append(" Plan")
|
|
2234
|
+
lines.extend(self._format_plan_rows())
|
|
2235
|
+
known = [item.format() for item in current.known]
|
|
2236
|
+
if known != before_known:
|
|
2237
|
+
if not lines:
|
|
2238
|
+
lines.append("State Updated | " + self._verification_badge())
|
|
2239
|
+
lines.append(" Known")
|
|
2240
|
+
lines.extend(self._format_known_rows())
|
|
2241
|
+
current_context = [item.format() for item in current.current_context]
|
|
2242
|
+
if current_context != before_context:
|
|
2243
|
+
if not lines:
|
|
2244
|
+
lines.append("State Updated | " + self._verification_badge())
|
|
2245
|
+
lines.append(" Context")
|
|
2246
|
+
lines.extend(self._format_context_rows())
|
|
2247
|
+
verification = current.verification.format()
|
|
2248
|
+
if verification != before_verification:
|
|
2249
|
+
if not lines:
|
|
2250
|
+
lines.append("State Updated | " + self._verification_badge())
|
|
2251
|
+
lines.append(" Verify " + self._format_verification())
|
|
2252
|
+
return "\n".join(lines)
|
|
2253
|
+
|
|
2254
|
+
def _format_plan_rows(self) -> list[str]:
|
|
2255
|
+
items = self.session.current.plan
|
|
2256
|
+
if not items:
|
|
2257
|
+
return [" (empty)"]
|
|
2258
|
+
offset = max(0, len(items) - self.DISPLAY_LIMIT)
|
|
2259
|
+
rows = [" ... " + str(offset) + " older"] if offset else []
|
|
2260
|
+
for index, item in enumerate(items[offset:], start=offset + 1):
|
|
2261
|
+
rows.append(" " + str(index) + ". [" + str(item.status) + "] " + self._compact(item.text))
|
|
2262
|
+
if item.evidence:
|
|
2263
|
+
rows.append(" evidence: " + self._compact(item.evidence))
|
|
2264
|
+
return rows
|
|
2265
|
+
|
|
2266
|
+
def _format_known_rows(self) -> list[str]:
|
|
2267
|
+
items = self.session.current.known
|
|
2268
|
+
if not items:
|
|
2269
|
+
return [" (empty)"]
|
|
2270
|
+
offset = max(0, len(items) - self.DISPLAY_LIMIT)
|
|
2271
|
+
rows = [" ... " + str(offset) + " older"] if offset else []
|
|
2272
|
+
for index, item in enumerate(items[offset:], start=offset + 1):
|
|
2273
|
+
text = self._compact(item.fact)
|
|
2274
|
+
if item.details:
|
|
2275
|
+
text += " | " + "; ".join(self._compact(detail) for detail in item.details)
|
|
2276
|
+
rows.append(" " + str(index) + ". " + text)
|
|
2277
|
+
return rows
|
|
2278
|
+
|
|
2279
|
+
def _format_context_rows(self) -> list[str]:
|
|
2280
|
+
items = self.session.current.current_context
|
|
2281
|
+
if not items:
|
|
2282
|
+
return [" (empty)"]
|
|
2283
|
+
offset = max(0, len(items) - self.DISPLAY_LIMIT)
|
|
2284
|
+
rows = [" ... " + str(offset) + " older"] if offset else []
|
|
2285
|
+
for index, item in enumerate(items[offset:], start=offset + 1):
|
|
2286
|
+
text = self._compact(item.note)
|
|
2287
|
+
if item.details:
|
|
2288
|
+
text += " | " + "; ".join(self._compact(detail) for detail in item.details)
|
|
2289
|
+
rows.append(" " + str(index) + ". " + text)
|
|
2290
|
+
return rows
|
|
2291
|
+
|
|
2292
|
+
def _format_verification(self) -> str:
|
|
2293
|
+
verification = self.session.current.verification
|
|
2294
|
+
parts = [verification.status]
|
|
2295
|
+
if verification.method:
|
|
2296
|
+
parts.append(self._compact(verification.method))
|
|
2297
|
+
if verification.evidence:
|
|
2298
|
+
parts.append("evidence: " + self._compact(verification.evidence))
|
|
2299
|
+
return " | ".join(parts)
|
|
2300
|
+
|
|
2301
|
+
def _verification_badge(self) -> str:
|
|
2302
|
+
return "VERIFY:" + self.session.current.verification.status
|
|
2303
|
+
|
|
2304
|
+
def _compact(self, text: str, limit: int = 140) -> str:
|
|
2305
|
+
text = " ".join(text.split())
|
|
2306
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
2307
|
+
|
|
2308
|
+
def _apply_tool_call_summaries(self, response: Json) -> None:
|
|
2309
|
+
summaries = _json_list(response.get("last_tool_calls_summaries"))
|
|
2310
|
+
if not summaries:
|
|
2311
|
+
return
|
|
2312
|
+
pending = [event for event in self.tool_runner.latest_events if not event.summary]
|
|
2313
|
+
for raw_summary in summaries:
|
|
2314
|
+
summary = _json_dict(raw_summary)
|
|
2315
|
+
if not summary:
|
|
2316
|
+
continue
|
|
2317
|
+
event = self._find_summary_event(summary, pending)
|
|
2318
|
+
if event is None:
|
|
2319
|
+
continue
|
|
2320
|
+
event.summary = self._format_tool_call_summary(summary)
|
|
2321
|
+
event.key_details = self._key_details_from_summary(summary)
|
|
2322
|
+
event.needs_raw_read = summary.get("needs_raw_read") is True
|
|
2323
|
+
if event in pending:
|
|
2324
|
+
pending.remove(event)
|
|
2325
|
+
|
|
2326
|
+
def _find_summary_event(self, summary: Json, pending: list[ToolCallEvent]) -> ToolCallEvent | None:
|
|
2327
|
+
result_file = _json_str(summary.get("result_file"))
|
|
2328
|
+
if result_file:
|
|
2329
|
+
for event in self.tool_runner.latest_events:
|
|
2330
|
+
if event.result_file == result_file:
|
|
2331
|
+
return event
|
|
2332
|
+
tool = _json_str(summary.get("tool"))
|
|
2333
|
+
intention = _json_str(summary.get("intention"))
|
|
2334
|
+
if tool or intention:
|
|
2335
|
+
for event in pending:
|
|
2336
|
+
if (not tool or event.executed.startswith(tool + "(")) and (not intention or event.intent == intention):
|
|
2337
|
+
return event
|
|
2338
|
+
return pending[0] if pending else None
|
|
2339
|
+
|
|
2340
|
+
def _format_tool_call_summary(self, summary: Json) -> str:
|
|
2341
|
+
parts = []
|
|
2342
|
+
outcome = _json_str(summary.get("outcome"))
|
|
2343
|
+
text = _json_str(summary.get("summary"))
|
|
2344
|
+
if outcome:
|
|
2345
|
+
parts.append("outcome: " + outcome)
|
|
2346
|
+
if text:
|
|
2347
|
+
parts.append("summary: " + text)
|
|
2348
|
+
if summary.get("needs_raw_read") is True:
|
|
2349
|
+
parts.append("needs_raw_read: true")
|
|
2350
|
+
return "\n".join(parts)
|
|
2351
|
+
|
|
2352
|
+
def _key_details_from_summary(self, summary: Json) -> list[str]:
|
|
2353
|
+
details = [_json_str(item) or "" for item in _json_list(summary.get("key_evidence"))]
|
|
2354
|
+
return [detail for detail in details if detail]
|
|
2355
|
+
|
|
2356
|
+
def _apply_goal(self, response: Json) -> bool:
|
|
2357
|
+
update = _json_str(response.get("goal_update"))
|
|
2358
|
+
changed = False
|
|
2359
|
+
if update is not None:
|
|
2360
|
+
changed = update != self.session.current.goal
|
|
2361
|
+
self.session.current.goal = update
|
|
2362
|
+
reached = response.get("goal_reached")
|
|
2363
|
+
if isinstance(reached, bool):
|
|
2364
|
+
self.session.current.goal_reached = reached
|
|
2365
|
+
return changed
|
|
2366
|
+
|
|
2367
|
+
def _apply_plan(self, response: Json) -> bool:
|
|
2368
|
+
update = _json_dict(response.get("plan_update"))
|
|
2369
|
+
if not update:
|
|
2370
|
+
return False
|
|
2371
|
+
items = _json_list(update.get("items"))
|
|
2372
|
+
if update.get("mode") == "replace":
|
|
2373
|
+
self.session.current.plan = [item for item in (self._plan_item_from_json(raw) for raw in items) if item]
|
|
2374
|
+
return True
|
|
2375
|
+
for raw in items:
|
|
2376
|
+
patch = _json_dict(raw)
|
|
2377
|
+
op = _json_str(patch.get("op")) or "add"
|
|
2378
|
+
item_id = _json_str(patch.get("id")) or ""
|
|
2379
|
+
if op == "remove":
|
|
2380
|
+
self.session.current.plan = [item for item in self.session.current.plan if item.id != item_id]
|
|
2381
|
+
continue
|
|
2382
|
+
plan_item = self._plan_item_from_json(patch)
|
|
2383
|
+
if plan_item is None:
|
|
2384
|
+
continue
|
|
2385
|
+
existing = next((item for item in self.session.current.plan if item.id == plan_item.id and item.id), None)
|
|
2386
|
+
if existing:
|
|
2387
|
+
existing.text = plan_item.text
|
|
2388
|
+
existing.status = plan_item.status
|
|
2389
|
+
existing.evidence = plan_item.evidence
|
|
2390
|
+
else:
|
|
2391
|
+
self.session.current.plan.append(plan_item)
|
|
2392
|
+
return False
|
|
2393
|
+
|
|
2394
|
+
def _plan_item_from_json(self, value: JsonValue) -> PlanItem | None:
|
|
2395
|
+
item = _json_dict(value)
|
|
2396
|
+
text = _json_str(item.get("text"))
|
|
2397
|
+
if not text:
|
|
2398
|
+
return None
|
|
2399
|
+
status = _json_str(item.get("status")) or PlanStatus.TODO
|
|
2400
|
+
if status not in {PlanStatus.TODO, PlanStatus.DOING, PlanStatus.DONE, PlanStatus.BLOCKED}:
|
|
2401
|
+
status = PlanStatus.TODO
|
|
2402
|
+
return PlanItem(
|
|
2403
|
+
text=text,
|
|
2404
|
+
status=PlanStatus(status),
|
|
2405
|
+
id=_json_str(item.get("id")) or "",
|
|
2406
|
+
evidence=_json_str(item.get("evidence")) or "",
|
|
2407
|
+
)
|
|
2408
|
+
|
|
2409
|
+
def _apply_known(self, response: Json) -> None:
|
|
2410
|
+
for raw in _json_list(response.get("known_append")):
|
|
2411
|
+
item = _json_dict(raw)
|
|
2412
|
+
fact = _json_str(item.get("fact")) if item else _json_str(raw)
|
|
2413
|
+
if not fact:
|
|
2414
|
+
continue
|
|
2415
|
+
details = [_json_str(detail) or "" for detail in _json_list(item.get("details") if item else None)]
|
|
2416
|
+
details = [detail for detail in details if detail]
|
|
2417
|
+
if not any(known.fact == fact for known in self.session.current.known):
|
|
2418
|
+
self.session.current.known.append(KnownItem(fact=fact, details=details))
|
|
2419
|
+
|
|
2420
|
+
def _apply_current_context(self, response: Json) -> None:
|
|
2421
|
+
update = _json_dict(response.get("current_context_update"))
|
|
2422
|
+
if not update:
|
|
2423
|
+
return
|
|
2424
|
+
if update.get("mode") == "replace":
|
|
2425
|
+
self.session.current.current_context = []
|
|
2426
|
+
for raw in _json_list(update.get("items")):
|
|
2427
|
+
item = _json_dict(raw)
|
|
2428
|
+
note = _json_str(item.get("note")) if item else _json_str(raw)
|
|
2429
|
+
if not note:
|
|
2430
|
+
continue
|
|
2431
|
+
details = [_json_str(detail) or "" for detail in _json_list(item.get("details") if item else None)]
|
|
2432
|
+
details = [detail for detail in details if detail]
|
|
2433
|
+
existing = next((context for context in self.session.current.current_context if context.note == note), None)
|
|
2434
|
+
if existing:
|
|
2435
|
+
existing.details = details
|
|
2436
|
+
else:
|
|
2437
|
+
self.session.current.current_context.append(CurrentContextItem(note=note, details=details))
|
|
2438
|
+
if len(self.session.current.current_context) > self.CURRENT_CONTEXT_LIMIT:
|
|
2439
|
+
self.session.current.current_context = self.session.current.current_context[-self.CURRENT_CONTEXT_LIMIT :]
|
|
2440
|
+
|
|
2441
|
+
def _apply_verification(self, response: Json) -> None:
|
|
2442
|
+
data = _json_dict(response.get("verification"))
|
|
2443
|
+
if not data:
|
|
2444
|
+
return
|
|
2445
|
+
method = _json_str(data.get("method"))
|
|
2446
|
+
if method is not None:
|
|
2447
|
+
if method != self.session.current.verification.method:
|
|
2448
|
+
self.session.current.verification.evidence = ""
|
|
2449
|
+
self.session.current.verification.method = method
|
|
2450
|
+
status = _json_str(data.get("status"))
|
|
2451
|
+
if status == "pending":
|
|
2452
|
+
self.session.current.verification.status = VerificationStatus.REQUIRED
|
|
2453
|
+
if "evidence" not in data:
|
|
2454
|
+
self.session.current.verification.evidence = ""
|
|
2455
|
+
elif status == "passed":
|
|
2456
|
+
self.session.current.verification.status = VerificationStatus.DONE
|
|
2457
|
+
elif status == "blocked":
|
|
2458
|
+
self.session.current.verification.status = VerificationStatus.BLOCKED
|
|
2459
|
+
evidence = _json_str(data.get("evidence"))
|
|
2460
|
+
if evidence is not None:
|
|
2461
|
+
self.session.current.verification.evidence = evidence
|
|
2462
|
+
|
|
2463
|
+
def _reset_stale_verification(self, response: Json, *, goal_changed: bool, plan_replaced: bool) -> None:
|
|
2464
|
+
verification = self.session.current.verification
|
|
2465
|
+
if goal_changed:
|
|
2466
|
+
verification.reset()
|
|
2467
|
+
return
|
|
2468
|
+
if verification.goal and verification.goal != self.session.current.goal:
|
|
2469
|
+
verification.reset()
|
|
2470
|
+
return
|
|
2471
|
+
if (
|
|
2472
|
+
plan_replaced
|
|
2473
|
+
and not _json_dict(response.get("verification"))
|
|
2474
|
+
and verification.status
|
|
2475
|
+
in {
|
|
2476
|
+
VerificationStatus.REQUIRED,
|
|
2477
|
+
VerificationStatus.DONE,
|
|
2478
|
+
VerificationStatus.BLOCKED,
|
|
2479
|
+
}
|
|
2480
|
+
):
|
|
2481
|
+
verification.reset()
|
|
2482
|
+
|
|
2483
|
+
def _bind_verification_goal(self) -> None:
|
|
2484
|
+
verification = self.session.current.verification
|
|
2485
|
+
if not verification.has_context():
|
|
2486
|
+
verification.goal = ""
|
|
2487
|
+
return
|
|
2488
|
+
if self.session.current.goal:
|
|
2489
|
+
verification.goal = self.session.current.goal
|
|
2490
|
+
|
|
2491
|
+
|
|
2492
|
+
############################
|
|
2493
|
+
# ConversationCompactor
|
|
2494
|
+
############################
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
@final
|
|
2498
|
+
class ConversationCompactor:
|
|
2499
|
+
KEEP_RECENT: ClassVar[int] = 5
|
|
2500
|
+
|
|
2501
|
+
def __init__(self, session: Session, model_client: ModelClient):
|
|
2502
|
+
self.session = session
|
|
2503
|
+
self.model_client = model_client
|
|
2504
|
+
|
|
2505
|
+
def compact(self) -> int:
|
|
2506
|
+
count = len(self.session.conversation)
|
|
2507
|
+
if count <= self.KEEP_RECENT:
|
|
2508
|
+
return 0
|
|
2509
|
+
old_items = self.session.conversation[: -self.KEEP_RECENT]
|
|
2510
|
+
keep_items = self.session.conversation[-self.KEEP_RECENT :]
|
|
2511
|
+
summary = self._summarize(old_items)
|
|
2512
|
+
self.session.conversation = [AssistantMessage(content="Conversation compact summary:\n" + summary)] + keep_items
|
|
2513
|
+
return count
|
|
2514
|
+
|
|
2515
|
+
def maybe_compact(self) -> bool:
|
|
2516
|
+
if self.session.compact_at <= 0:
|
|
2517
|
+
return False
|
|
2518
|
+
if len(self.session.conversation) <= self.session.compact_at:
|
|
2519
|
+
return False
|
|
2520
|
+
return self.compact() > 0
|
|
2521
|
+
|
|
2522
|
+
def _summarize(self, items: list[ConversationItem]) -> str:
|
|
2523
|
+
user_prompt = COMPACT_USER_PROMPT_TEMPLATE.format(conversation="\n\n".join(item.format() for item in items)).strip()
|
|
2524
|
+
response = self.model_client.request(SUMMARIZER_AGENT_COMPACT_PROMPT.strip(), user_prompt, activity="compact")
|
|
2525
|
+
summary = _json_str(response.get("summary"))
|
|
2526
|
+
if not summary:
|
|
2527
|
+
raise LLMError("compact response missing summary")
|
|
2528
|
+
return summary
|
|
2529
|
+
|
|
2530
|
+
|
|
2531
|
+
############################
|
|
2532
|
+
# Agent
|
|
2533
|
+
############################
|
|
2534
|
+
|
|
2535
|
+
|
|
2536
|
+
@final
|
|
2537
|
+
class Agent:
|
|
2538
|
+
EVIDENCE_OUTPUT_LINES: ClassVar[int] = 40
|
|
2539
|
+
EVIDENCE_OUTPUT_CHARS: ClassVar[int] = 4000
|
|
2540
|
+
|
|
2541
|
+
def __init__(self, session: Session):
|
|
2542
|
+
self.session = session
|
|
2543
|
+
self.prompt_builder = PromptBuilder(session)
|
|
2544
|
+
self.model_client = ModelClient(session)
|
|
2545
|
+
self.tool_runner = ToolCallRunner(session)
|
|
2546
|
+
self.state_updater = AgentStateUpdater(session, self.tool_runner)
|
|
2547
|
+
self.compactor = ConversationCompactor(session, self.model_client)
|
|
2548
|
+
self.latest_tool_call_results = ""
|
|
2549
|
+
|
|
2550
|
+
@property
|
|
2551
|
+
def latest_tool_call_events(self) -> list[ToolCallEvent]:
|
|
2552
|
+
return self.tool_runner.latest_events
|
|
2553
|
+
|
|
2554
|
+
def build_system_prompt(self) -> str:
|
|
2555
|
+
return self.prompt_builder.system_prompt()
|
|
2556
|
+
|
|
2557
|
+
def build_user_prompt(self, *, consume_latest_tool_results: bool = True) -> str:
|
|
2558
|
+
prompt = self.prompt_builder.user_prompt(self.latest_tool_call_results)
|
|
2559
|
+
if consume_latest_tool_results:
|
|
2560
|
+
self.latest_tool_call_results = ""
|
|
2561
|
+
return prompt
|
|
2562
|
+
|
|
2563
|
+
def request(self, system_prompt: str, user_prompt: str, *, activity: str = "main") -> Json:
|
|
2564
|
+
return self.model_client.request(system_prompt, user_prompt, activity=activity)
|
|
2565
|
+
|
|
2566
|
+
def compact_history(self) -> int:
|
|
2567
|
+
return self.compactor.compact()
|
|
2568
|
+
|
|
2569
|
+
def maybe_auto_compact(self) -> bool:
|
|
2570
|
+
return self.compactor.maybe_compact()
|
|
2571
|
+
|
|
2572
|
+
def run(
|
|
2573
|
+
self,
|
|
2574
|
+
user_input: str,
|
|
2575
|
+
*,
|
|
2576
|
+
confirm: ConfirmCallback | None = None,
|
|
2577
|
+
on_auto_approve: ToolDisplayCallback | None = None,
|
|
2578
|
+
on_message: MessageCallback | None = None,
|
|
2579
|
+
) -> Json:
|
|
2580
|
+
self.latest_tool_call_results = ""
|
|
2581
|
+
self.session.current.user_input = user_input
|
|
2582
|
+
self.session.current.goal_reached = False
|
|
2583
|
+
self.maybe_auto_compact()
|
|
2584
|
+
self.session.append_conversation(UserMessage(content=user_input))
|
|
2585
|
+
|
|
2586
|
+
for _ in range(self.session.max_agent_steps):
|
|
2587
|
+
response = self.step()
|
|
2588
|
+
format_error = _json_str(response.get("_format_error"))
|
|
2589
|
+
if format_error:
|
|
2590
|
+
self.latest_tool_call_results = format_error
|
|
2591
|
+
continue
|
|
2592
|
+
tool_calls = _json_list(response.get("tool_calls"))
|
|
2593
|
+
summary_gate = self._format_tool_summary_gate(tool_calls)
|
|
2594
|
+
if summary_gate:
|
|
2595
|
+
self.state_updater.latest_report = ""
|
|
2596
|
+
self.latest_tool_call_results = summary_gate
|
|
2597
|
+
continue
|
|
2598
|
+
self.apply_response(response)
|
|
2599
|
+
if on_message is not None and self.state_updater.latest_report:
|
|
2600
|
+
on_message(self.state_updater.latest_report)
|
|
2601
|
+
message = _json_str(response.get("message_to_user"))
|
|
2602
|
+
if message:
|
|
2603
|
+
self.session.append_conversation(AssistantMessage(content=message))
|
|
2604
|
+
if on_message is not None:
|
|
2605
|
+
on_message(message)
|
|
2606
|
+
if tool_calls:
|
|
2607
|
+
self.execute_tool_calls(tool_calls, confirm=confirm, on_auto_approve=on_auto_approve)
|
|
2608
|
+
if on_message is not None:
|
|
2609
|
+
report = self.tool_runner.format_latest_report()
|
|
2610
|
+
if report:
|
|
2611
|
+
on_message(report)
|
|
2612
|
+
self.maybe_auto_compact()
|
|
2613
|
+
continue
|
|
2614
|
+
if self.session.current.goal_reached and self.session.current.verification.status != VerificationStatus.REQUIRED:
|
|
2615
|
+
return response
|
|
2616
|
+
if self.session.current.verification.status == VerificationStatus.REQUIRED:
|
|
2617
|
+
self.session.current.goal_reached = False
|
|
2618
|
+
self.latest_tool_call_results = self._format_verification_gate()
|
|
2619
|
+
continue
|
|
2620
|
+
if not self.session.current.goal_reached:
|
|
2621
|
+
self.latest_tool_call_results = self._format_continuation_hint()
|
|
2622
|
+
continue
|
|
2623
|
+
return response
|
|
2624
|
+
raise LLMError("agent step limit reached")
|
|
2625
|
+
|
|
2626
|
+
def step(self) -> Json:
|
|
2627
|
+
response = self.request(self.build_system_prompt(), self.build_user_prompt(), activity="main")
|
|
2628
|
+
self.state_updater.apply_tool_call_summaries(response)
|
|
2629
|
+
return response
|
|
2630
|
+
|
|
2631
|
+
def apply_response(self, response: Json) -> None:
|
|
2632
|
+
self.state_updater.apply(response)
|
|
2633
|
+
|
|
2634
|
+
def execute_tool_calls(
|
|
2635
|
+
self,
|
|
2636
|
+
tool_calls: list[JsonValue],
|
|
2637
|
+
*,
|
|
2638
|
+
confirm: ConfirmCallback | None = None,
|
|
2639
|
+
on_auto_approve: ToolDisplayCallback | None = None,
|
|
2640
|
+
) -> str:
|
|
2641
|
+
self.latest_tool_call_results = self.tool_runner.execute(tool_calls, confirm=confirm, on_auto_approve=on_auto_approve)
|
|
2642
|
+
return self.latest_tool_call_results
|
|
2643
|
+
|
|
2644
|
+
def _format_verification_gate(self) -> str:
|
|
2645
|
+
verification = self.session.current.verification
|
|
2646
|
+
method = verification.method or "Pick the cheapest relevant compile, lint, smoke test, targeted command, or prompt/file inspection."
|
|
2647
|
+
return "\n".join(
|
|
2648
|
+
[
|
|
2649
|
+
"Verification_Gate: required before completion.",
|
|
2650
|
+
"Method: " + method,
|
|
2651
|
+
'Next_Action: run a relevant verification tool call, or return verification.status="passed" or "blocked" with evidence if verification is already resolved.',
|
|
2652
|
+
]
|
|
2653
|
+
)
|
|
2654
|
+
|
|
2655
|
+
def _format_continuation_hint(self) -> str:
|
|
2656
|
+
return "No tool calls and goal not reached. Continue with the next useful action."
|
|
2657
|
+
|
|
2658
|
+
def _missing_tool_summaries(self) -> list[ToolCallEvent]:
|
|
2659
|
+
return [event for event in self.tool_runner.latest_events if not event.summary]
|
|
2660
|
+
|
|
2661
|
+
def _format_tool_summary_gate(self, tool_calls: list[JsonValue]) -> str:
|
|
2662
|
+
missing = self._missing_tool_summaries()
|
|
2663
|
+
missing_evidence = []
|
|
2664
|
+
needs_read = []
|
|
2665
|
+
for event, execution in zip(self.tool_runner.latest_events, self.tool_runner.latest_executions):
|
|
2666
|
+
if not event.summary:
|
|
2667
|
+
continue
|
|
2668
|
+
if event.needs_raw_read and not self._has_read_result_file_call(tool_calls, event.result_file):
|
|
2669
|
+
needs_read.append(event)
|
|
2670
|
+
if event.outcome in {"failure", "partial"}:
|
|
2671
|
+
if not event.key_details:
|
|
2672
|
+
missing_evidence.append(event)
|
|
2673
|
+
continue
|
|
2674
|
+
if self._is_large_tool_output(execution.output) and not event.key_details and not event.needs_raw_read:
|
|
2675
|
+
missing_evidence.append(event)
|
|
2676
|
+
if not missing and not missing_evidence and not needs_read:
|
|
2677
|
+
return ""
|
|
2678
|
+
|
|
2679
|
+
lines = ["Tool_Summary_Gate: extract durable evidence before continuing.", "Raw tool results are visible only once."]
|
|
2680
|
+
if missing:
|
|
2681
|
+
lines.append("Missing summaries:")
|
|
2682
|
+
for event in missing:
|
|
2683
|
+
lines.append("- " + event.executed + " -> " + event.result_file)
|
|
2684
|
+
if missing_evidence:
|
|
2685
|
+
lines.append("Missing key_evidence:")
|
|
2686
|
+
for event in missing_evidence:
|
|
2687
|
+
lines.append("- " + event.executed + " -> " + event.result_file)
|
|
2688
|
+
if needs_read:
|
|
2689
|
+
lines.append("Needs raw read:")
|
|
2690
|
+
for event in needs_read:
|
|
2691
|
+
lines.append("- Read(" + event.result_file + ") before continuing")
|
|
2692
|
+
lines.append(
|
|
2693
|
+
"Next_Action: return last_tool_calls_summaries with key_evidence, update current_context_update for task-local facts, or call Read(result_file) for needs_raw_read logs."
|
|
2694
|
+
)
|
|
2695
|
+
return "\n".join(lines)
|
|
2696
|
+
|
|
2697
|
+
def _is_large_tool_output(self, output: str) -> bool:
|
|
2698
|
+
return len(output) > self.EVIDENCE_OUTPUT_CHARS or len(output.splitlines()) > self.EVIDENCE_OUTPUT_LINES
|
|
2699
|
+
|
|
2700
|
+
def _has_read_result_file_call(self, tool_calls: list[JsonValue], result_file: str) -> bool:
|
|
2701
|
+
if not result_file:
|
|
2702
|
+
return False
|
|
2703
|
+
for raw_call in tool_calls:
|
|
2704
|
+
call = _json_dict(raw_call)
|
|
2705
|
+
if _json_str(call.get("name")) != ReadTool.name():
|
|
2706
|
+
continue
|
|
2707
|
+
args = [_json_str(arg) or "" for arg in _json_list(call.get("args"))]
|
|
2708
|
+
if args and args[0] == result_file:
|
|
2709
|
+
return True
|
|
2710
|
+
return False
|
|
2711
|
+
|
|
2712
|
+
|
|
2713
|
+
############################
|
|
2714
|
+
# Commands
|
|
2715
|
+
############################
|
|
2716
|
+
|
|
2717
|
+
|
|
2718
|
+
class CommandStatus(StrEnum):
|
|
2719
|
+
HANDLED = "handled"
|
|
2720
|
+
EXIT = "exit"
|
|
2721
|
+
UNHANDLED = "unhandled"
|
|
2722
|
+
|
|
2723
|
+
|
|
2724
|
+
@final
|
|
2725
|
+
@dataclass(frozen=True)
|
|
2726
|
+
class CommandResult:
|
|
2727
|
+
status: CommandStatus
|
|
2728
|
+
message: str = ""
|
|
2729
|
+
|
|
2730
|
+
|
|
2731
|
+
@final
|
|
2732
|
+
@dataclass(frozen=True)
|
|
2733
|
+
class CommandSpec:
|
|
2734
|
+
name: str
|
|
2735
|
+
description: str
|
|
2736
|
+
category: str
|
|
2737
|
+
usage: str = ""
|
|
2738
|
+
|
|
2739
|
+
def display_name(self) -> str:
|
|
2740
|
+
return self.usage or self.name
|
|
2741
|
+
|
|
2742
|
+
|
|
2743
|
+
COMMANDS: tuple[CommandSpec, ...] = (
|
|
2744
|
+
CommandSpec("/help", "Show commands or ask about nanocode", "Info", "/help [question]"),
|
|
2745
|
+
CommandSpec("/status", "Show session status", "Info", "/status"),
|
|
2746
|
+
CommandSpec("/compact", "Compact conversation history", "Info", "/compact"),
|
|
2747
|
+
CommandSpec("/model", "Show or set the model", "Config", "/model [name]"),
|
|
2748
|
+
CommandSpec("/compact-at", "Show or set auto-compact threshold", "Config", "/compact-at [number]"),
|
|
2749
|
+
CommandSpec("/reason", "Show or toggle reasoning", "Config", "/reason [on|off|status]"),
|
|
2750
|
+
CommandSpec("/reason_effort", "Show or set reasoning effort", "Config", "/reason_effort [minimal|low|medium|high|xhigh]"),
|
|
2751
|
+
CommandSpec("/yolo", "Show or toggle confirmation bypass", "Config", "/yolo [on|off|status]"),
|
|
2752
|
+
CommandSpec("/exit", "Exit nanocode", "Control", "/exit"),
|
|
2753
|
+
CommandSpec("/quit", "Exit nanocode", "Control", "/quit"),
|
|
2754
|
+
)
|
|
2755
|
+
|
|
2756
|
+
|
|
2757
|
+
@final
|
|
2758
|
+
class CommandDispatcher:
|
|
2759
|
+
EFFORTS: ClassVar[set[str]] = {"minimal", "low", "medium", "high", "xhigh"}
|
|
2760
|
+
|
|
2761
|
+
def __init__(
|
|
2762
|
+
self,
|
|
2763
|
+
agent: Agent,
|
|
2764
|
+
run_agent: MessageCallback | None = None,
|
|
2765
|
+
run_with_status: StatusRunner | None = None,
|
|
2766
|
+
):
|
|
2767
|
+
self.agent = agent
|
|
2768
|
+
self.run_agent = run_agent
|
|
2769
|
+
self.run_with_status = run_with_status
|
|
2770
|
+
self.handlers: dict[str, Callable[[str], str]] = {
|
|
2771
|
+
"/help": self._help,
|
|
2772
|
+
"/status": self._status,
|
|
2773
|
+
"/compact": self._compact,
|
|
2774
|
+
"/model": self._model,
|
|
2775
|
+
"/compact-at": self._compact_at,
|
|
2776
|
+
"/reason": self._reason,
|
|
2777
|
+
"/reason_effort": self._reason_effort,
|
|
2778
|
+
"/yolo": self._yolo,
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
def dispatch(self, user_input: str) -> CommandResult:
|
|
2782
|
+
command, args = self._parse(user_input)
|
|
2783
|
+
if command in {"/exit", "/quit", "exit", "quit"}:
|
|
2784
|
+
return CommandResult(CommandStatus.EXIT, "Exit")
|
|
2785
|
+
handler = self.handlers.get(command)
|
|
2786
|
+
if handler is None:
|
|
2787
|
+
return CommandResult(CommandStatus.UNHANDLED, "")
|
|
2788
|
+
return CommandResult(CommandStatus.HANDLED, handler(args))
|
|
2789
|
+
|
|
2790
|
+
def _parse(self, user_input: str) -> tuple[str, str]:
|
|
2791
|
+
command, _, args = user_input.strip().partition(" ")
|
|
2792
|
+
return command, args.strip()
|
|
2793
|
+
|
|
2794
|
+
def _help(self, args: str) -> str:
|
|
2795
|
+
if args:
|
|
2796
|
+
question = self._format_source_help_question(args)
|
|
2797
|
+
if self.run_agent is not None:
|
|
2798
|
+
self.run_agent(question)
|
|
2799
|
+
else:
|
|
2800
|
+
self.agent.run(question)
|
|
2801
|
+
return ""
|
|
2802
|
+
lines = ["Commands:"]
|
|
2803
|
+
current_category = ""
|
|
2804
|
+
for spec in COMMANDS:
|
|
2805
|
+
if spec.category != current_category:
|
|
2806
|
+
current_category = spec.category
|
|
2807
|
+
lines.append(current_category + ":")
|
|
2808
|
+
lines.append(" " + spec.display_name() + " - " + spec.description)
|
|
2809
|
+
return "\n".join(lines)
|
|
2810
|
+
|
|
2811
|
+
def _format_source_help_question(self, question: str) -> str:
|
|
2812
|
+
source_path = os.path.abspath(__file__)
|
|
2813
|
+
project_metadata = os.path.join(os.path.dirname(source_path), "pyproject.toml")
|
|
2814
|
+
return "\n".join(
|
|
2815
|
+
[
|
|
2816
|
+
"Answer this question about nanocode itself.",
|
|
2817
|
+
"First inspect the nanocode source file at this exact path:",
|
|
2818
|
+
source_path,
|
|
2819
|
+
"Inspect this project metadata file too when useful, if it exists:",
|
|
2820
|
+
project_metadata,
|
|
2821
|
+
"Base the answer on the source you inspected, cite concrete functions/classes/options when relevant, and keep the answer concise.",
|
|
2822
|
+
"",
|
|
2823
|
+
"Question:",
|
|
2824
|
+
question,
|
|
2825
|
+
]
|
|
2826
|
+
)
|
|
2827
|
+
|
|
2828
|
+
def _status(self, args: str) -> str:
|
|
2829
|
+
if args:
|
|
2830
|
+
return "Usage: /status"
|
|
2831
|
+
session = self.agent.session
|
|
2832
|
+
reasoning = session.reasoning_effort if session.reasoning else "off"
|
|
2833
|
+
yolo = "on" if session.yolo else "off"
|
|
2834
|
+
return "\n".join(
|
|
2835
|
+
[
|
|
2836
|
+
"model: " + (session.model or "(empty)"),
|
|
2837
|
+
"reasoning: " + reasoning,
|
|
2838
|
+
"yolo: " + yolo,
|
|
2839
|
+
"conversation: " + str(len(session.conversation)) + "/" + str(session.compact_at),
|
|
2840
|
+
"tokens: last=" + _format_count(session.last_total_tokens) + " session=" + _format_count(session.session_total_tokens),
|
|
2841
|
+
"goal: " + (session.current.goal or "(empty)"),
|
|
2842
|
+
"verification: " + session.current.verification.status,
|
|
2843
|
+
]
|
|
2844
|
+
)
|
|
2845
|
+
|
|
2846
|
+
def _compact(self, args: str) -> str:
|
|
2847
|
+
if args:
|
|
2848
|
+
return "Usage: /compact"
|
|
2849
|
+
return self._with_status(self._compact_history)
|
|
2850
|
+
|
|
2851
|
+
def _compact_history(self) -> str:
|
|
2852
|
+
count = self.agent.compact_history()
|
|
2853
|
+
if count == 0:
|
|
2854
|
+
return "Conversation history is empty"
|
|
2855
|
+
return "Compacted conversation history: " + str(count) + " item(s) -> " + str(len(self.agent.session.conversation)) + " item(s)"
|
|
2856
|
+
|
|
2857
|
+
def _model(self, args: str) -> str:
|
|
2858
|
+
if not args:
|
|
2859
|
+
return "Current model: " + (self.agent.session.model or "(empty)")
|
|
2860
|
+
self.agent.session.model = args
|
|
2861
|
+
return "Model set to: " + args
|
|
2862
|
+
|
|
2863
|
+
def _compact_at(self, args: str) -> str:
|
|
2864
|
+
if not args:
|
|
2865
|
+
return "Current auto-compact threshold: " + str(self.agent.session.compact_at)
|
|
2866
|
+
try:
|
|
2867
|
+
value = int(args)
|
|
2868
|
+
except ValueError:
|
|
2869
|
+
return "Usage: /compact-at [number]"
|
|
2870
|
+
if value <= 0:
|
|
2871
|
+
return "Usage: /compact-at [number] (must be positive)"
|
|
2872
|
+
self.agent.session.compact_at = value
|
|
2873
|
+
compacted = self._with_status(lambda: "yes" if self.agent.maybe_auto_compact() else "") == "yes"
|
|
2874
|
+
suffix = " and compacted history" if compacted else ""
|
|
2875
|
+
return "Auto-compact threshold set to: " + str(value) + suffix
|
|
2876
|
+
|
|
2877
|
+
def _with_status(self, action: StatusAction) -> str:
|
|
2878
|
+
if self.run_with_status is None:
|
|
2879
|
+
return action()
|
|
2880
|
+
return self.run_with_status(action)
|
|
2881
|
+
|
|
2882
|
+
def _reason(self, args: str) -> str:
|
|
2883
|
+
if args == "on":
|
|
2884
|
+
self.agent.session.reasoning = True
|
|
2885
|
+
return "Reasoning enabled"
|
|
2886
|
+
if args == "off":
|
|
2887
|
+
self.agent.session.reasoning = False
|
|
2888
|
+
return "Reasoning disabled"
|
|
2889
|
+
if args in {"", "status"}:
|
|
2890
|
+
return "Reasoning is " + ("on" if self.agent.session.reasoning else "off")
|
|
2891
|
+
return "Usage: /reason [on|off|status]"
|
|
2892
|
+
|
|
2893
|
+
def _reason_effort(self, args: str) -> str:
|
|
2894
|
+
if not args:
|
|
2895
|
+
return "Current reasoning effort: " + self.agent.session.reasoning_effort
|
|
2896
|
+
if args not in self.EFFORTS:
|
|
2897
|
+
return "Usage: /reason_effort [minimal|low|medium|high|xhigh]"
|
|
2898
|
+
self.agent.session.reasoning_effort = args
|
|
2899
|
+
return "Reasoning effort set to: " + args
|
|
2900
|
+
|
|
2901
|
+
def _yolo(self, args: str) -> str:
|
|
2902
|
+
if args == "on":
|
|
2903
|
+
self.agent.session.yolo = True
|
|
2904
|
+
return "YOLO enabled"
|
|
2905
|
+
if args == "off":
|
|
2906
|
+
self.agent.session.yolo = False
|
|
2907
|
+
return "YOLO disabled"
|
|
2908
|
+
if args in {"", "status"}:
|
|
2909
|
+
return "YOLO is " + ("on" if self.agent.session.yolo else "off")
|
|
2910
|
+
return "Usage: /yolo [on|off|status]"
|
|
2911
|
+
|
|
2912
|
+
|
|
2913
|
+
def _format_count(value: int) -> str:
|
|
2914
|
+
if value <= 0:
|
|
2915
|
+
return "-"
|
|
2916
|
+
if value >= 1_000_000:
|
|
2917
|
+
return str(value // 1_000_000) + "m"
|
|
2918
|
+
if value >= 1_000:
|
|
2919
|
+
return str(value // 1_000) + "k"
|
|
2920
|
+
return str(value)
|
|
2921
|
+
|
|
2922
|
+
|
|
2923
|
+
############################
|
|
2924
|
+
# Interactive Loop
|
|
2925
|
+
############################
|
|
2926
|
+
|
|
2927
|
+
|
|
2928
|
+
@final
|
|
2929
|
+
class StatusBar:
|
|
2930
|
+
INTERVAL: ClassVar[float] = 0.2
|
|
2931
|
+
|
|
2932
|
+
def __init__(self, session: Session):
|
|
2933
|
+
self.session = session
|
|
2934
|
+
self.started_at = 0.0
|
|
2935
|
+
self.last_elapsed = 0.0
|
|
2936
|
+
self.stop_event = threading.Event()
|
|
2937
|
+
self.thread: threading.Thread | None = None
|
|
2938
|
+
self.rendered = False
|
|
2939
|
+
self.output = create_output(sys.stderr)
|
|
2940
|
+
|
|
2941
|
+
def __enter__(self) -> Self:
|
|
2942
|
+
self.started_at = time.monotonic()
|
|
2943
|
+
return self
|
|
2944
|
+
|
|
2945
|
+
def __exit__(self, *args) -> None:
|
|
2946
|
+
self.pause()
|
|
2947
|
+
|
|
2948
|
+
def reset_timer(self) -> None:
|
|
2949
|
+
self.started_at = time.monotonic()
|
|
2950
|
+
self.last_elapsed = 0.0
|
|
2951
|
+
|
|
2952
|
+
def elapsed(self) -> float:
|
|
2953
|
+
if self.started_at <= 0:
|
|
2954
|
+
return 0.0
|
|
2955
|
+
return time.monotonic() - self.started_at
|
|
2956
|
+
|
|
2957
|
+
def is_running(self) -> bool:
|
|
2958
|
+
return self.thread is not None
|
|
2959
|
+
|
|
2960
|
+
def snapshot(self, turn_elapsed: float = 0.0) -> str:
|
|
2961
|
+
return self._plain(self._fragments(turn_elapsed, now=time.monotonic(), show_sweep=False, show_elapsed=False))
|
|
2962
|
+
|
|
2963
|
+
def toolbar(self):
|
|
2964
|
+
elapsed = self.elapsed() if self.is_running() else self.last_elapsed
|
|
2965
|
+
return FormattedText(self._fragments(elapsed, now=time.monotonic(), show_sweep=True, show_elapsed=self.is_running()))
|
|
2966
|
+
|
|
2967
|
+
def resume(self) -> None:
|
|
2968
|
+
if self.thread is not None or not sys.stderr.isatty():
|
|
2969
|
+
return
|
|
2970
|
+
self.stop_event.clear()
|
|
2971
|
+
self.thread = threading.Thread(target=self._run, daemon=True)
|
|
2972
|
+
self.thread.start()
|
|
2973
|
+
|
|
2974
|
+
def pause(self) -> None:
|
|
2975
|
+
if self.thread is None:
|
|
2976
|
+
return
|
|
2977
|
+
self.last_elapsed = self.elapsed()
|
|
2978
|
+
self.stop_event.set()
|
|
2979
|
+
self.thread.join()
|
|
2980
|
+
self.thread = None
|
|
2981
|
+
self._clear()
|
|
2982
|
+
|
|
2983
|
+
def _run(self) -> None:
|
|
2984
|
+
while not self.stop_event.is_set():
|
|
2985
|
+
now = time.monotonic()
|
|
2986
|
+
elapsed = self.elapsed()
|
|
2987
|
+
self.last_elapsed = elapsed
|
|
2988
|
+
self.output.write_raw("\r")
|
|
2989
|
+
self.output.erase_end_of_line()
|
|
2990
|
+
print_formatted_text(FormattedText(self._fragments(elapsed, now=now, show_sweep=True, show_elapsed=True)), output=self.output, end="", flush=True)
|
|
2991
|
+
self.rendered = True
|
|
2992
|
+
self.stop_event.wait(self.INTERVAL)
|
|
2993
|
+
|
|
2994
|
+
def _clear(self) -> None:
|
|
2995
|
+
if not self.rendered:
|
|
2996
|
+
return
|
|
2997
|
+
self.output.write_raw("\r")
|
|
2998
|
+
self.output.erase_end_of_line()
|
|
2999
|
+
self.output.flush()
|
|
3000
|
+
self.rendered = False
|
|
3001
|
+
|
|
3002
|
+
def _text(self, turn_elapsed: float, *, now: float) -> str:
|
|
3003
|
+
return self._plain(self._fragments(turn_elapsed, now=now, show_sweep=True, show_elapsed=True))
|
|
3004
|
+
|
|
3005
|
+
def _fragments(self, turn_elapsed: float, *, now: float, show_sweep: bool, show_elapsed: bool) -> list[tuple[str, str]]:
|
|
3006
|
+
text = self._format_line(turn_elapsed, now=now, show_elapsed=show_elapsed)
|
|
3007
|
+
columns = shutil.get_terminal_size((120, 20)).columns
|
|
3008
|
+
if len(text) >= columns:
|
|
3009
|
+
text = text[: max(0, columns - 4)] + "..."
|
|
3010
|
+
return self._sweep_fragments(text, now) if show_sweep else [("ansicyan", text)]
|
|
3011
|
+
|
|
3012
|
+
def _format_line(self, turn_elapsed: float, *, now: float, show_elapsed: bool) -> str:
|
|
3013
|
+
session = self.session
|
|
3014
|
+
model = session.model.rsplit("/", 1)[-1] or session.model or "(no model)"
|
|
3015
|
+
reasoning = session.reasoning_effort if session.reasoning else "off"
|
|
3016
|
+
yolo = " | yolo" if session.yolo else ""
|
|
3017
|
+
context = str(len(session.conversation)) + "/" + str(session.compact_at)
|
|
3018
|
+
tokens = "last:" + self._format_count(session.last_total_tokens) + " session:" + self._format_count(session.session_total_tokens)
|
|
3019
|
+
parts = [model + " (" + reasoning + ")" + yolo, "ctx:" + context, "tok:" + tokens]
|
|
3020
|
+
if show_elapsed:
|
|
3021
|
+
parts.append(f"{turn_elapsed:.1f}s")
|
|
3022
|
+
if session.current_model_call_started_at > 0:
|
|
3023
|
+
parts.append("calling:" + f"{max(0.0, now - session.current_model_call_started_at):.1f}s")
|
|
3024
|
+
return " | ".join(parts)
|
|
3025
|
+
|
|
3026
|
+
def _sweep_fragments(self, text: str, now: float) -> list[tuple[str, str]]:
|
|
3027
|
+
if not text:
|
|
3028
|
+
return [("", "")]
|
|
3029
|
+
width = max(1, len(text) - 1)
|
|
3030
|
+
sweep = (now * 0.55) % 1.0
|
|
3031
|
+
fragments = []
|
|
3032
|
+
for index, char in enumerate(text):
|
|
3033
|
+
ratio = index / width
|
|
3034
|
+
red = round(75 + (180 - 75) * ratio)
|
|
3035
|
+
green = round(180 + (130 - 180) * ratio)
|
|
3036
|
+
blue = 235
|
|
3037
|
+
distance = abs(ratio - sweep)
|
|
3038
|
+
intensity = max(0.0, 1.0 - distance * 5.0) ** 2
|
|
3039
|
+
red = round(red + (230 - red) * intensity)
|
|
3040
|
+
green = round(green + (245 - green) * intensity)
|
|
3041
|
+
blue = round(blue + (255 - blue) * intensity)
|
|
3042
|
+
fragments.append((f"#{red:02x}{green:02x}{blue:02x}", char))
|
|
3043
|
+
return fragments
|
|
3044
|
+
|
|
3045
|
+
def _plain(self, fragments: list[tuple[str, str]]) -> str:
|
|
3046
|
+
return "".join(text for _, text in fragments)
|
|
3047
|
+
|
|
3048
|
+
def _format_count(self, value: int) -> str:
|
|
3049
|
+
return _format_count(value)
|
|
3050
|
+
|
|
3051
|
+
|
|
3052
|
+
@final
|
|
3053
|
+
class AgentLoop:
|
|
3054
|
+
def __init__(
|
|
3055
|
+
self,
|
|
3056
|
+
agent: Agent,
|
|
3057
|
+
*,
|
|
3058
|
+
input_fn: Callable[[str], str] = input,
|
|
3059
|
+
output_fn: MessageCallback = print,
|
|
3060
|
+
prompt_session=None,
|
|
3061
|
+
):
|
|
3062
|
+
self.agent = agent
|
|
3063
|
+
self.input_fn = input_fn
|
|
3064
|
+
self.output_fn = output_fn
|
|
3065
|
+
self.status_bar = StatusBar(agent.session)
|
|
3066
|
+
self.history_path = agent.session.resolve_path(os.path.join(agent.session.nanocode_dir, "history"))
|
|
3067
|
+
self.prompt_session = prompt_session
|
|
3068
|
+
if self.prompt_session is None and input_fn is input and sys.stdin.isatty():
|
|
3069
|
+
self.prompt_session = self._make_prompt_session()
|
|
3070
|
+
|
|
3071
|
+
def run(self) -> int:
|
|
3072
|
+
self._print_welcome()
|
|
3073
|
+
with self.status_bar:
|
|
3074
|
+
dispatcher = CommandDispatcher(self.agent, run_agent=self._run_agent, run_with_status=self._run_with_status)
|
|
3075
|
+
while True:
|
|
3076
|
+
try:
|
|
3077
|
+
user_input = self._read_input(self._prompt()).strip()
|
|
3078
|
+
except EOFError:
|
|
3079
|
+
self._emit("")
|
|
3080
|
+
return 0
|
|
3081
|
+
except KeyboardInterrupt:
|
|
3082
|
+
self._emit("Cancelled")
|
|
3083
|
+
continue
|
|
3084
|
+
if not user_input:
|
|
3085
|
+
continue
|
|
3086
|
+
try:
|
|
3087
|
+
result = dispatcher.dispatch(user_input)
|
|
3088
|
+
except Exception as error:
|
|
3089
|
+
self._emit("Error: " + str(error))
|
|
3090
|
+
continue
|
|
3091
|
+
if result.status == CommandStatus.EXIT:
|
|
3092
|
+
return 0
|
|
3093
|
+
if result.status == CommandStatus.HANDLED:
|
|
3094
|
+
if result.message:
|
|
3095
|
+
self._emit(result.message)
|
|
3096
|
+
continue
|
|
3097
|
+
self._run_agent(user_input)
|
|
3098
|
+
|
|
3099
|
+
def _prompt(self) -> str:
|
|
3100
|
+
return "[yolo] > " if self.agent.session.yolo else "> "
|
|
3101
|
+
|
|
3102
|
+
def _read_input(self, prompt: str) -> str:
|
|
3103
|
+
if self.prompt_session is None:
|
|
3104
|
+
return self.input_fn(prompt)
|
|
3105
|
+
with patch_stdout():
|
|
3106
|
+
return self.prompt_session.prompt(
|
|
3107
|
+
prompt,
|
|
3108
|
+
multiline=False,
|
|
3109
|
+
enable_history_search=True,
|
|
3110
|
+
refresh_interval=StatusBar.INTERVAL,
|
|
3111
|
+
)
|
|
3112
|
+
|
|
3113
|
+
def _make_prompt_session(self):
|
|
3114
|
+
os.makedirs(os.path.dirname(self.history_path), exist_ok=True)
|
|
3115
|
+
return PromptSession(
|
|
3116
|
+
history=FileHistory(self.history_path),
|
|
3117
|
+
completer=self._command_completer(),
|
|
3118
|
+
complete_while_typing=True,
|
|
3119
|
+
)
|
|
3120
|
+
|
|
3121
|
+
def _command_completer(self) -> WordCompleter:
|
|
3122
|
+
return WordCompleter([spec.name for spec in COMMANDS], ignore_case=False, WORD=True)
|
|
3123
|
+
|
|
3124
|
+
def _run_agent(self, user_input: str) -> None:
|
|
3125
|
+
try:
|
|
3126
|
+
self.status_bar.reset_timer()
|
|
3127
|
+
self.status_bar.resume()
|
|
3128
|
+
self.agent.run(
|
|
3129
|
+
user_input,
|
|
3130
|
+
confirm=self._confirm_tool_call,
|
|
3131
|
+
on_auto_approve=self._show_auto_tool_call,
|
|
3132
|
+
on_message=self._emit,
|
|
3133
|
+
)
|
|
3134
|
+
except KeyboardInterrupt:
|
|
3135
|
+
self._emit("Cancelled")
|
|
3136
|
+
except Cancellation as error:
|
|
3137
|
+
self._emit("Cancelled: " + str(error))
|
|
3138
|
+
except Exception as error:
|
|
3139
|
+
self._emit("Error: " + str(error))
|
|
3140
|
+
finally:
|
|
3141
|
+
self.status_bar.pause()
|
|
3142
|
+
|
|
3143
|
+
def _run_with_status(self, action: StatusAction) -> str:
|
|
3144
|
+
self.status_bar.reset_timer()
|
|
3145
|
+
self.status_bar.resume()
|
|
3146
|
+
try:
|
|
3147
|
+
return action()
|
|
3148
|
+
finally:
|
|
3149
|
+
self.status_bar.pause()
|
|
3150
|
+
|
|
3151
|
+
def _confirm_tool_call(self, call: ParsedToolCall, tool: Tool) -> ConfirmationResult:
|
|
3152
|
+
was_running = self.status_bar.is_running()
|
|
3153
|
+
if was_running:
|
|
3154
|
+
self.status_bar.pause()
|
|
3155
|
+
try:
|
|
3156
|
+
self._print_tool_call_display("Confirm Tool Call", "manual approval required", call, tool, title_style="bold ansiyellow")
|
|
3157
|
+
return self._wait_confirm("Proceed?", default=True)
|
|
3158
|
+
finally:
|
|
3159
|
+
if was_running:
|
|
3160
|
+
self.status_bar.resume()
|
|
3161
|
+
|
|
3162
|
+
def _show_auto_tool_call(self, call: ParsedToolCall, tool: Tool) -> None:
|
|
3163
|
+
was_running = self.status_bar.is_running()
|
|
3164
|
+
if was_running:
|
|
3165
|
+
self.status_bar.pause()
|
|
3166
|
+
try:
|
|
3167
|
+
self._print_tool_call_display("Auto Tool Call", "auto approved", call, tool, title_style="bold ansiblue")
|
|
3168
|
+
finally:
|
|
3169
|
+
if was_running:
|
|
3170
|
+
self.status_bar.resume()
|
|
3171
|
+
|
|
3172
|
+
def _print_tool_call_display(
|
|
3173
|
+
self,
|
|
3174
|
+
title: str,
|
|
3175
|
+
status: str,
|
|
3176
|
+
call: ParsedToolCall,
|
|
3177
|
+
tool: Tool,
|
|
3178
|
+
*,
|
|
3179
|
+
title_style: str,
|
|
3180
|
+
) -> None:
|
|
3181
|
+
self._emit_segments(
|
|
3182
|
+
[
|
|
3183
|
+
("ansibrightblack", "-" * 48 + "\n"),
|
|
3184
|
+
(title_style, title),
|
|
3185
|
+
("ansibrightblack", " | " + status + "\n"),
|
|
3186
|
+
("ansibrightblack", " Run "),
|
|
3187
|
+
("ansicyan", call.executed + "\n"),
|
|
3188
|
+
],
|
|
3189
|
+
title + " | " + status + "\n Run " + call.executed,
|
|
3190
|
+
)
|
|
3191
|
+
if call.intention:
|
|
3192
|
+
self._emit_segments(
|
|
3193
|
+
[("ansibrightblack", " Why "), ("ansimagenta", call.intention + "\n")],
|
|
3194
|
+
" Why " + call.intention,
|
|
3195
|
+
)
|
|
3196
|
+
preview = tool.display()
|
|
3197
|
+
if preview:
|
|
3198
|
+
self._emit_segments(self._preview_segments(preview), " Preview\n" + preview)
|
|
3199
|
+
|
|
3200
|
+
def _emit(self, message: str) -> None:
|
|
3201
|
+
was_running = self.status_bar.is_running()
|
|
3202
|
+
if was_running:
|
|
3203
|
+
self.status_bar.pause()
|
|
3204
|
+
try:
|
|
3205
|
+
self._print_message(message)
|
|
3206
|
+
finally:
|
|
3207
|
+
if was_running:
|
|
3208
|
+
self.status_bar.resume()
|
|
3209
|
+
|
|
3210
|
+
def _print_welcome(self) -> None:
|
|
3211
|
+
self._emit_segments([("bold ansicyan", "nanocode"), ("ansiwhite", " - AI coding assistant\n")], "nanocode - AI coding assistant")
|
|
3212
|
+
self._emit_segments(
|
|
3213
|
+
[("ansibrightblack", " "), ("ansicyan", "/help [question]"), ("ansiwhite", " for help or source-aware questions\n")],
|
|
3214
|
+
" /help [question] for help or source-aware questions",
|
|
3215
|
+
)
|
|
3216
|
+
self._emit_segments(
|
|
3217
|
+
[("ansibrightblack", " "), ("ansicyan", "/status"), ("ansiwhite", " for current session state\n")],
|
|
3218
|
+
" /status for current session state",
|
|
3219
|
+
)
|
|
3220
|
+
self._emit_segments(
|
|
3221
|
+
self.status_bar._fragments(0.0, now=time.monotonic(), show_sweep=False, show_elapsed=False) + [("", "\n")],
|
|
3222
|
+
self.status_bar.snapshot(),
|
|
3223
|
+
)
|
|
3224
|
+
|
|
3225
|
+
def _wait_confirm(self, prompt: str, *, default: bool) -> ConfirmationResult:
|
|
3226
|
+
suffix = "[Y/n/reason]" if default else "[y/N/reason]"
|
|
3227
|
+
while True:
|
|
3228
|
+
raw_answer = self._read_input(prompt + " " + suffix + " ").strip()
|
|
3229
|
+
answer = raw_answer.lower()
|
|
3230
|
+
if not answer:
|
|
3231
|
+
self.output_fn("Answer: " + ("yes" if default else "no"))
|
|
3232
|
+
return default
|
|
3233
|
+
if answer in {"y", "yes"}:
|
|
3234
|
+
self.output_fn("Answer: yes")
|
|
3235
|
+
return True
|
|
3236
|
+
if answer in {"n", "no"}:
|
|
3237
|
+
self.output_fn("Answer: no")
|
|
3238
|
+
return False
|
|
3239
|
+
self.output_fn("Answer: no - " + raw_answer)
|
|
3240
|
+
return raw_answer
|
|
3241
|
+
|
|
3242
|
+
def _print_message(self, message: str) -> None:
|
|
3243
|
+
if message.startswith("State Updated"):
|
|
3244
|
+
self._emit_segments(self._state_segments(message), message)
|
|
3245
|
+
return
|
|
3246
|
+
if message.startswith("Tool Calls"):
|
|
3247
|
+
self._emit_segments(self._tool_segments(message), message)
|
|
3248
|
+
return
|
|
3249
|
+
if message.startswith("Error:"):
|
|
3250
|
+
self._emit_segments([("bold ansired", message + "\n")], message)
|
|
3251
|
+
return
|
|
3252
|
+
if message.startswith("Cancelled"):
|
|
3253
|
+
self._emit_segments([("ansiyellow", message + "\n")], message)
|
|
3254
|
+
return
|
|
3255
|
+
self._emit_segments([("ansicyan", message + "\n")], message)
|
|
3256
|
+
|
|
3257
|
+
def _emit_segments(self, segments: list[tuple[str, str]], plain: str) -> None:
|
|
3258
|
+
if self.output_fn is print:
|
|
3259
|
+
print_formatted_text(FormattedText(segments), flush=True)
|
|
3260
|
+
else:
|
|
3261
|
+
self.output_fn(plain)
|
|
3262
|
+
|
|
3263
|
+
def _preview_segments(self, preview: str) -> list[tuple[str, str]]:
|
|
3264
|
+
segments: list[tuple[str, str]] = [("ansibrightblack", " Preview\n")]
|
|
3265
|
+
if self._looks_like_unified_diff(preview):
|
|
3266
|
+
return segments + self._indent_segments(self._diff_segments(preview), " ")
|
|
3267
|
+
return segments + self._indented_text_segments(preview, indent=" ", style="ansicyan")
|
|
3268
|
+
|
|
3269
|
+
def _looks_like_unified_diff(self, text: str) -> bool:
|
|
3270
|
+
return text.startswith("--- ") and "\n+++ " in text and "\n@@ " in text
|
|
3271
|
+
|
|
3272
|
+
def _diff_segments(self, text: str) -> list[tuple[str, str]]:
|
|
3273
|
+
segments: list[tuple[str, str]] = []
|
|
3274
|
+
lines = text.splitlines()
|
|
3275
|
+
for index, line in enumerate(lines):
|
|
3276
|
+
if line.startswith("@@"):
|
|
3277
|
+
style = "ansicyan"
|
|
3278
|
+
elif line.startswith(("---", "+++")):
|
|
3279
|
+
style = "ansibrightblack"
|
|
3280
|
+
elif line.startswith("+"):
|
|
3281
|
+
style = "ansigreen"
|
|
3282
|
+
elif line.startswith("-"):
|
|
3283
|
+
style = "ansired"
|
|
3284
|
+
else:
|
|
3285
|
+
style = "ansiwhite"
|
|
3286
|
+
if index < len(lines) - 1:
|
|
3287
|
+
line += "\n"
|
|
3288
|
+
segments.append((style, line))
|
|
3289
|
+
return segments
|
|
3290
|
+
|
|
3291
|
+
def _indented_text_segments(self, text: str, *, indent: str, style: str) -> list[tuple[str, str]]:
|
|
3292
|
+
segments: list[tuple[str, str]] = []
|
|
3293
|
+
for line in text.splitlines() or [""]:
|
|
3294
|
+
segments.extend([("ansibrightblack", indent), (style, line + "\n")])
|
|
3295
|
+
return segments
|
|
3296
|
+
|
|
3297
|
+
def _indent_segments(self, segments: list[tuple[str, str]], indent: str) -> list[tuple[str, str]]:
|
|
3298
|
+
indented: list[tuple[str, str]] = []
|
|
3299
|
+
at_line_start = True
|
|
3300
|
+
for style, text in segments:
|
|
3301
|
+
for part in text.splitlines(keepends=True):
|
|
3302
|
+
if at_line_start:
|
|
3303
|
+
indented.append(("ansibrightblack", indent))
|
|
3304
|
+
indented.append((style, part))
|
|
3305
|
+
at_line_start = part.endswith("\n")
|
|
3306
|
+
return indented
|
|
3307
|
+
|
|
3308
|
+
def _state_segments(self, message: str) -> list[tuple[str, str]]:
|
|
3309
|
+
lines = message.splitlines()
|
|
3310
|
+
segments: list[tuple[str, str]] = [("ansibrightblack", "-" * 48 + "\n")]
|
|
3311
|
+
for index, line in enumerate(lines):
|
|
3312
|
+
if index == 0:
|
|
3313
|
+
title, _, badge = line.partition("|")
|
|
3314
|
+
badge = badge.strip()
|
|
3315
|
+
segments.extend([("bold ansicyan", title.strip()), ("ansibrightblack", " | "), (self._verify_style(badge), badge), ("", "\n")])
|
|
3316
|
+
elif line.startswith(" Goal"):
|
|
3317
|
+
segments.extend([("ansibrightblack", line[:10]), ("bold ansigreen", line[10:] + "\n")])
|
|
3318
|
+
elif line.startswith(" Plan"):
|
|
3319
|
+
segments.extend([("ansibrightblack", " "), ("bold ansicyan", line.strip()), ("", "\n")])
|
|
3320
|
+
elif line.startswith(" Known"):
|
|
3321
|
+
segments.extend([("ansibrightblack", " "), ("bold ansiyellow", line.strip()), ("", "\n")])
|
|
3322
|
+
elif line.startswith(" Context"):
|
|
3323
|
+
segments.extend([("ansibrightblack", " "), ("bold ansimagenta", line.strip()), ("", "\n")])
|
|
3324
|
+
elif line.startswith(" Verify"):
|
|
3325
|
+
status = line[10:].strip().split(" ", 1)[0]
|
|
3326
|
+
segments.extend([("ansibrightblack", line[:10]), (self._verify_style("VERIFY:" + status), line[10:] + "\n")])
|
|
3327
|
+
elif line.startswith(" ..."):
|
|
3328
|
+
segments.extend([("ansibrightblack", line + "\n")])
|
|
3329
|
+
elif line.startswith(" "):
|
|
3330
|
+
segments.extend([("ansibrightblack", " "), ("ansiwhite", line[4:] + "\n")])
|
|
3331
|
+
else:
|
|
3332
|
+
segments.extend([("ansiwhite", line + "\n")])
|
|
3333
|
+
return segments
|
|
3334
|
+
|
|
3335
|
+
def _tool_segments(self, message: str) -> list[tuple[str, str]]:
|
|
3336
|
+
lines = message.splitlines()
|
|
3337
|
+
segments: list[tuple[str, str]] = [("ansibrightblack", "-" * 48 + "\n")]
|
|
3338
|
+
for index, line in enumerate(lines):
|
|
3339
|
+
if index == 0:
|
|
3340
|
+
segments.extend([("bold ansiblue", line), ("", "\n")])
|
|
3341
|
+
elif line.startswith(" ") and ". [" in line:
|
|
3342
|
+
style = "ansigreen" if "[success]" in line else "ansired"
|
|
3343
|
+
segments.extend([("ansibrightblack", line[:5]), (style, line[5:] + "\n")])
|
|
3344
|
+
elif line.startswith(" why:"):
|
|
3345
|
+
segments.extend([("ansibrightblack", " why: "), ("ansimagenta", line[10:] + "\n")])
|
|
3346
|
+
elif line.startswith(" log:"):
|
|
3347
|
+
segments.extend([("ansibrightblack", " log: "), ("ansiblue", line[10:] + "\n")])
|
|
3348
|
+
else:
|
|
3349
|
+
segments.extend([("ansibrightblack", line + "\n")])
|
|
3350
|
+
return segments
|
|
3351
|
+
|
|
3352
|
+
def _verify_style(self, badge: str) -> str:
|
|
3353
|
+
if "required" in badge:
|
|
3354
|
+
return "bold ansimagenta"
|
|
3355
|
+
if "done" in badge:
|
|
3356
|
+
return "bold ansigreen"
|
|
3357
|
+
if "blocked" in badge:
|
|
3358
|
+
return "bold ansired"
|
|
3359
|
+
return "ansibrightblack"
|
|
3360
|
+
|
|
3361
|
+
|
|
3362
|
+
###################
|
|
3363
|
+
# Helpers
|
|
3364
|
+
###################
|
|
3365
|
+
|
|
3366
|
+
|
|
3367
|
+
def _format_lines(lines: list[str], indent: str) -> str:
|
|
3368
|
+
return "\n".join([(indent + line) for line in lines])
|
|
3369
|
+
|
|
3370
|
+
|
|
3371
|
+
def _make_unified_diff(old_content: str, new_content: str, filepath: str) -> str:
|
|
3372
|
+
return "".join(
|
|
3373
|
+
difflib.unified_diff(
|
|
3374
|
+
old_content.splitlines(keepends=True),
|
|
3375
|
+
new_content.splitlines(keepends=True),
|
|
3376
|
+
fromfile=filepath,
|
|
3377
|
+
tofile=filepath,
|
|
3378
|
+
)
|
|
3379
|
+
)
|
|
3380
|
+
|
|
3381
|
+
|
|
3382
|
+
def _format_process_result(tag: str, exit_code: int, stdout: str, stderr: str) -> str:
|
|
3383
|
+
lines = [f"<{tag}>", f"* exit_code: {exit_code}"]
|
|
3384
|
+
if stdout:
|
|
3385
|
+
lines.extend(["<stdout>", stdout.rstrip("\n"), "</stdout>"])
|
|
3386
|
+
if stderr:
|
|
3387
|
+
lines.extend(["<stderr>", stderr.rstrip("\n"), "</stderr>"])
|
|
3388
|
+
lines.append(f"</{tag}>")
|
|
3389
|
+
return "\n".join(lines)
|
|
3390
|
+
|
|
3391
|
+
|
|
3392
|
+
def _json_dict(value: JsonValue) -> Json:
|
|
3393
|
+
return value if isinstance(value, dict) else {}
|
|
3394
|
+
|
|
3395
|
+
|
|
3396
|
+
def _json_list(value: JsonValue) -> list[JsonValue]:
|
|
3397
|
+
return value if isinstance(value, list) else []
|
|
3398
|
+
|
|
3399
|
+
|
|
3400
|
+
def _json_str(value: JsonValue) -> str | None:
|
|
3401
|
+
if isinstance(value, str):
|
|
3402
|
+
return value
|
|
3403
|
+
if value is None:
|
|
3404
|
+
return None
|
|
3405
|
+
return str(value)
|
|
3406
|
+
|
|
3407
|
+
|
|
3408
|
+
def _json_int(value: JsonValue) -> int:
|
|
3409
|
+
return value if isinstance(value, int) else 0
|
|
3410
|
+
|
|
3411
|
+
|
|
3412
|
+
def _shorten(text: str, limit: int = 500) -> str:
|
|
3413
|
+
return text if len(text) <= limit else text[:limit] + "..."
|
|
3414
|
+
|
|
3415
|
+
|
|
3416
|
+
##############
|
|
3417
|
+
# Entrypoint
|
|
3418
|
+
##############
|
|
3419
|
+
|
|
3420
|
+
|
|
3421
|
+
def main(argv: list[str] | None = None) -> int:
|
|
3422
|
+
try:
|
|
3423
|
+
parser = argparse.ArgumentParser(description="nanocode: AI coding assistant")
|
|
3424
|
+
parser.add_argument("-v", "--version", action="version", version=__version__)
|
|
3425
|
+
parser.add_argument("--yolo", action="store_true", help="Skip tool execution confirmations")
|
|
3426
|
+
parser.add_argument("--debug", action="store_true", help="Write request prompts to .nanocode/debug")
|
|
3427
|
+
args = parser.parse_args(argv)
|
|
3428
|
+
session = Session(yolo=args.yolo, debug=args.debug)
|
|
3429
|
+
session.cleanup_old_logs(days=3)
|
|
3430
|
+
missing = session.missing_required_envs()
|
|
3431
|
+
if missing:
|
|
3432
|
+
print("Missing env: " + ", ".join(missing), file=sys.stderr)
|
|
3433
|
+
return 2
|
|
3434
|
+
return AgentLoop(Agent(session)).run()
|
|
3435
|
+
except KeyboardInterrupt:
|
|
3436
|
+
print("Cancelled", file=sys.stderr)
|
|
3437
|
+
return 130
|
|
3438
|
+
except Exception as error:
|
|
3439
|
+
print("Error: " + str(error), file=sys.stderr)
|
|
3440
|
+
return 1
|
|
3441
|
+
|
|
3442
|
+
|
|
3443
|
+
if __name__ == "__main__":
|
|
3444
|
+
raise SystemExit(main())
|