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 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())