nanocode-cli 0.6.6__tar.gz → 0.7.0__tar.gz
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_cli-0.6.6/nanocode_cli.egg-info → nanocode_cli-0.7.0}/PKG-INFO +4 -3
- {nanocode_cli-0.6.6 → nanocode_cli-0.7.0}/nanocode.py +430 -184
- {nanocode_cli-0.6.6 → nanocode_cli-0.7.0/nanocode_cli.egg-info}/PKG-INFO +4 -3
- {nanocode_cli-0.6.6 → nanocode_cli-0.7.0}/nanocode_cli.egg-info/requires.txt +3 -2
- {nanocode_cli-0.6.6 → nanocode_cli-0.7.0}/pyproject.toml +4 -3
- {nanocode_cli-0.6.6 → nanocode_cli-0.7.0}/LICENSE +0 -0
- {nanocode_cli-0.6.6 → nanocode_cli-0.7.0}/MANIFEST.in +0 -0
- {nanocode_cli-0.6.6 → nanocode_cli-0.7.0}/README.md +0 -0
- {nanocode_cli-0.6.6 → nanocode_cli-0.7.0}/README.zh-CN.md +0 -0
- {nanocode_cli-0.6.6 → nanocode_cli-0.7.0}/nanocode_cli.egg-info/SOURCES.txt +0 -0
- {nanocode_cli-0.6.6 → nanocode_cli-0.7.0}/nanocode_cli.egg-info/dependency_links.txt +0 -0
- {nanocode_cli-0.6.6 → nanocode_cli-0.7.0}/nanocode_cli.egg-info/entry_points.txt +0 -0
- {nanocode_cli-0.6.6 → nanocode_cli-0.7.0}/nanocode_cli.egg-info/top_level.txt +0 -0
- {nanocode_cli-0.6.6 → nanocode_cli-0.7.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: A small terminal coding agent written in Python
|
|
5
5
|
Author-email: hit9 <hit9@icloud.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -22,13 +22,14 @@ Requires-Python: >=3.11
|
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: anthropic>=0.64.0
|
|
25
|
-
Requires-Dist: code-symbol-index>=0.3.
|
|
25
|
+
Requires-Dist: code-symbol-index>=0.3.1
|
|
26
26
|
Requires-Dist: json-repair
|
|
27
|
-
Requires-Dist: fastmcp<4,>=3
|
|
27
|
+
Requires-Dist: fastmcp-slim[client]<4,>=3
|
|
28
28
|
Requires-Dist: openai>=2.37.0
|
|
29
29
|
Requires-Dist: prompt-toolkit>=3.0
|
|
30
30
|
Requires-Dist: rich>=13.0
|
|
31
31
|
Requires-Dist: socksio>=1.0.0
|
|
32
|
+
Requires-Dist: websockets>=15.0.1
|
|
32
33
|
Provides-Extra: dev
|
|
33
34
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
34
35
|
Requires-Dist: ruff>=0.4.0; extra == "dev"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import codecs
|
|
6
7
|
import difflib
|
|
7
8
|
import asyncio
|
|
8
9
|
import fnmatch
|
|
@@ -59,7 +60,7 @@ from rich.console import Console
|
|
|
59
60
|
from rich.markdown import Markdown
|
|
60
61
|
from rich.rule import Rule
|
|
61
62
|
|
|
62
|
-
__version__ = "0.
|
|
63
|
+
__version__ = "0.7.0"
|
|
63
64
|
|
|
64
65
|
Json = dict[str, Any]
|
|
65
66
|
HTTP_USER_AGENT = "nanocode/" + __version__
|
|
@@ -236,6 +237,8 @@ class RuntimeSettings:
|
|
|
236
237
|
check_updates: bool = True
|
|
237
238
|
update_check_interval_hours: int = 24
|
|
238
239
|
session_retention_days: int = 7
|
|
240
|
+
# Max read-only tool calls from one model batch to execute concurrently; 1 disables parallelism.
|
|
241
|
+
max_parallel_tools: int = 4
|
|
239
242
|
mcp_selector: str = ""
|
|
240
243
|
yolo: bool = False
|
|
241
244
|
debug: bool = False
|
|
@@ -247,6 +250,7 @@ class RuntimeSettings:
|
|
|
247
250
|
shell_timeout=Config.int(runtime, "shell_timeout", 60),
|
|
248
251
|
max_steps=max(1, Config.int(runtime, "max_agent_steps", Config.int(runtime, "max_steps", 200))),
|
|
249
252
|
max_context_tokens=max(1, Config.int(runtime, "max_context_tokens", DEFAULT_MAX_CONTEXT_TOKENS)),
|
|
253
|
+
max_parallel_tools=max(1, Config.int(runtime, "max_parallel_tools", 4)),
|
|
250
254
|
check_updates=Config.bool(runtime, "check_updates", True),
|
|
251
255
|
update_check_interval_hours=max(1, Config.int(runtime, "update_check_interval_hours", 24)),
|
|
252
256
|
session_retention_days=max(0, Config.int(runtime, "session_retention_days", 7)),
|
|
@@ -357,6 +361,7 @@ data_dir = "~/.nanocode"
|
|
|
357
361
|
shell_timeout = 60
|
|
358
362
|
max_agent_steps = 200
|
|
359
363
|
max_context_tokens = 128000
|
|
364
|
+
max_parallel_tools = 4
|
|
360
365
|
check_updates = true
|
|
361
366
|
update_check_interval_hours = 24
|
|
362
367
|
session_retention_days = 7
|
|
@@ -441,10 +446,43 @@ class ModelUsage:
|
|
|
441
446
|
self.last_cached_prompt_tokens = cached_tokens
|
|
442
447
|
|
|
443
448
|
|
|
449
|
+
@dataclass
|
|
450
|
+
class PlanItem:
|
|
451
|
+
STATUSES: ClassVar[tuple[str, ...]] = ("todo", "doing", "done", "blocked")
|
|
452
|
+
SYMBOLS: ClassVar[dict[str, str]] = {"todo": " ", "doing": "~", "done": "x", "blocked": "-"}
|
|
453
|
+
LEGACY_MARKERS: ClassVar[dict[str, str]] = {" ": "todo", "~": "doing", "x": "done", "X": "done", "-": "blocked"}
|
|
454
|
+
|
|
455
|
+
status: str
|
|
456
|
+
text: str
|
|
457
|
+
|
|
458
|
+
@classmethod
|
|
459
|
+
def parse(cls, value: Any) -> "PlanItem | None":
|
|
460
|
+
if isinstance(value, cls):
|
|
461
|
+
status, text = value.status, value.text
|
|
462
|
+
elif isinstance(value, dict):
|
|
463
|
+
status = str(value.get("status") or "todo").strip().lower()
|
|
464
|
+
text = str(value.get("text") or "").strip()
|
|
465
|
+
else:
|
|
466
|
+
raw = str(value).strip()
|
|
467
|
+
match = re.fullmatch(r"\[( |x|X|~|-)\]\s+(.+)", raw)
|
|
468
|
+
status = cls.LEGACY_MARKERS[match.group(1)] if match else "todo"
|
|
469
|
+
text = match.group(2).strip() if match else raw
|
|
470
|
+
if not text:
|
|
471
|
+
return None
|
|
472
|
+
return cls(status if status in cls.STATUSES else "todo", text)
|
|
473
|
+
|
|
474
|
+
def to_json(self) -> Json:
|
|
475
|
+
return {"status": self.status, "text": self.text}
|
|
476
|
+
|
|
477
|
+
def row(self, *, status: bool = False, style: str = "text") -> str:
|
|
478
|
+
prefix = f"[{self.SYMBOLS[self.status]}] " if status and style == "symbol" else f"{self.status}: " if status else ""
|
|
479
|
+
return "- " + prefix + self.text
|
|
480
|
+
|
|
481
|
+
|
|
444
482
|
@dataclass
|
|
445
483
|
class AgentState:
|
|
446
484
|
goal: str = ""
|
|
447
|
-
plan: list[str] = field(default_factory=list)
|
|
485
|
+
plan: list[PlanItem | Json | str] = field(default_factory=list)
|
|
448
486
|
known: list[str] = field(default_factory=list)
|
|
449
487
|
check: str = ""
|
|
450
488
|
summary: str = ""
|
|
@@ -460,23 +498,23 @@ class AgentState:
|
|
|
460
498
|
manual_model_retry_requested: bool = False
|
|
461
499
|
model_retry_count: int = 0
|
|
462
500
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
return re.sub(r"^\[(?: |x|X|~|-)\]\s+", "", item.strip()).strip()
|
|
501
|
+
def __post_init__(self) -> None:
|
|
502
|
+
self.plan = self.plan_items(self.plan)
|
|
466
503
|
|
|
467
504
|
@classmethod
|
|
468
|
-
def
|
|
469
|
-
|
|
470
|
-
f"- [{'~' if index == 0 else ' '}] {item}" if status else "- " + item
|
|
471
|
-
for index, item in enumerate(filter(None, (cls.plan_text(str(item)) for item in items)))
|
|
472
|
-
]
|
|
473
|
-
return rows or ["- (empty)"]
|
|
505
|
+
def plan_items(cls, items: list[Any]) -> list[PlanItem]:
|
|
506
|
+
return [item for raw in items if (item := PlanItem.parse(raw))]
|
|
474
507
|
|
|
475
|
-
|
|
476
|
-
|
|
508
|
+
@classmethod
|
|
509
|
+
def plan_rows_for(cls, items: list[Any], *, status: bool = False, style: str = "text") -> list[str]:
|
|
510
|
+
rows = [item.row(status=status, style=style) for item in cls.plan_items(items)]
|
|
511
|
+
return rows or ["- (empty)"]
|
|
477
512
|
|
|
478
|
-
|
|
479
|
-
|
|
513
|
+
@classmethod
|
|
514
|
+
def focus_text(cls, items: list[Any]) -> str:
|
|
515
|
+
plan = cls.plan_items(items)
|
|
516
|
+
current = next((item for item in plan if item.status == "doing"), None) or next((item for item in plan if item.status != "done"), None)
|
|
517
|
+
return current.text if current else ""
|
|
480
518
|
|
|
481
519
|
def apply(self, data: Json) -> None:
|
|
482
520
|
for attr in ("goal", "summary", "check"):
|
|
@@ -485,12 +523,12 @@ class AgentState:
|
|
|
485
523
|
for attr in ("plan", "known"):
|
|
486
524
|
value = data.get(attr)
|
|
487
525
|
if isinstance(value, list):
|
|
488
|
-
items = list(filter(None, (str(item).strip() for item in value)))
|
|
489
|
-
setattr(self, attr,
|
|
526
|
+
items = list(filter(None, (str(item).strip() for item in value))) if attr == "known" else self.plan_items(value)
|
|
527
|
+
setattr(self, attr, items)
|
|
490
528
|
|
|
491
529
|
def format(self) -> str:
|
|
492
530
|
known = ["- " + item for item in self.known] or ["- (empty)"]
|
|
493
|
-
return "\n".join(["Goal: " + (self.goal or "(empty)"), "Plan:", *self.
|
|
531
|
+
return "\n".join(["Goal: " + (self.goal or "(empty)"), "Plan:", *self.plan_rows_for(self.plan), "Known:", *known, "Check: " + (self.check or "(empty)")])
|
|
494
532
|
|
|
495
533
|
|
|
496
534
|
@dataclass
|
|
@@ -769,7 +807,7 @@ class SessionSnapshotCodec:
|
|
|
769
807
|
def state(state: AgentState) -> Json:
|
|
770
808
|
return {
|
|
771
809
|
"goal": state.goal,
|
|
772
|
-
"plan": state.plan,
|
|
810
|
+
"plan": [item.to_json() for item in AgentState.plan_items(state.plan)],
|
|
773
811
|
"known": state.known,
|
|
774
812
|
"check": state.check,
|
|
775
813
|
"summary": state.summary,
|
|
@@ -1128,10 +1166,6 @@ class Session:
|
|
|
1128
1166
|
def clean_expired_snapshots(self) -> int:
|
|
1129
1167
|
return SessionSnapshotStore.clean_expired(self)
|
|
1130
1168
|
|
|
1131
|
-
@classmethod
|
|
1132
|
-
def _resolve_uid(cls, uid: str, data_dir: str = "~/.nanocode") -> str:
|
|
1133
|
-
return SessionSnapshotStore.resolve_uid(uid, data_dir)
|
|
1134
|
-
|
|
1135
1169
|
@classmethod
|
|
1136
1170
|
def load_snapshot(cls, uid: str, config: Config | None = None, settings: RuntimeSettings | None = None) -> "Session":
|
|
1137
1171
|
return SessionSnapshotStore.load(uid, config=config, settings=settings)
|
|
@@ -1334,7 +1368,7 @@ class Tool:
|
|
|
1334
1368
|
|
|
1335
1369
|
class ReadTool(Tool):
|
|
1336
1370
|
NAME = "Read"
|
|
1337
|
-
DESCRIPTION = "Read UTF-8 file line ranges; returns file stat, total lines, line:hash text, and updates FILE STATE."
|
|
1371
|
+
DESCRIPTION = "Read UTF-8 file line ranges; returns file stat, total lines, anchor=line:hash(line_content) text, and updates FILE STATE."
|
|
1338
1372
|
SIGNATURE = "Read(path,ranges=[[start,end],...]) or Read(files=[{path,ranges}]); lines are 0-based, end-exclusive"
|
|
1339
1373
|
EXAMPLE = (
|
|
1340
1374
|
'Read ranges. Example: {"path":"src/app.py","ranges":[[0,80],[120,180]]}',
|
|
@@ -1378,6 +1412,30 @@ class ReadTool(Tool):
|
|
|
1378
1412
|
def line_hash(line: str) -> str:
|
|
1379
1413
|
return Text.base36(int(hashlib.sha1(line.encode("utf-8")).hexdigest()[:6], 16)).rjust(5, "0")
|
|
1380
1414
|
|
|
1415
|
+
@classmethod
|
|
1416
|
+
def anchor(cls, index: int, line: str) -> str:
|
|
1417
|
+
return f"{index}:{cls.line_hash(line)}"
|
|
1418
|
+
|
|
1419
|
+
@classmethod
|
|
1420
|
+
def anchor_line(cls, index: int, line: str) -> str:
|
|
1421
|
+
return f"anchor={cls.anchor(index, line)} | {line.rstrip(chr(10))}"
|
|
1422
|
+
|
|
1423
|
+
@staticmethod
|
|
1424
|
+
def indexed_line_hash(line: str) -> str:
|
|
1425
|
+
return hashlib.sha256(line.rstrip("\n").encode("utf-8")).hexdigest()[:8]
|
|
1426
|
+
|
|
1427
|
+
@staticmethod
|
|
1428
|
+
def parse_anchor(value: str) -> tuple[int, str] | None:
|
|
1429
|
+
text = value.split("|", 1)[0].strip()
|
|
1430
|
+
if text.startswith("anchor="):
|
|
1431
|
+
text = text.removeprefix("anchor=").strip()
|
|
1432
|
+
match = re.fullmatch(r"(\d+):([0-9a-z]{5}|[0-9a-f]{8})", text)
|
|
1433
|
+
return (int(match.group(1)), match.group(2).lower()) if match else None
|
|
1434
|
+
|
|
1435
|
+
@classmethod
|
|
1436
|
+
def anchor_matches(cls, line: str, expected: str) -> bool:
|
|
1437
|
+
return expected == cls.line_hash(line) or expected == cls.indexed_line_hash(line)
|
|
1438
|
+
|
|
1381
1439
|
def needs_confirmation(self) -> bool:
|
|
1382
1440
|
return any(not self.session.in_cwd(path) for path, _ranges in self.targets())
|
|
1383
1441
|
|
|
@@ -1415,7 +1473,7 @@ class ReadTool(Tool):
|
|
|
1415
1473
|
end = max(start, len(lines) if requested_end == 0 else min(len(lines), requested_end))
|
|
1416
1474
|
out.append(f"<range>{start}:{end}</range>")
|
|
1417
1475
|
out.append("<content hashline-numbered>")
|
|
1418
|
-
out.
|
|
1476
|
+
out.extend(self.anchor_line(i, lines[i]) for i in range(start, end))
|
|
1419
1477
|
out.append("</content>")
|
|
1420
1478
|
out.append("</Read>")
|
|
1421
1479
|
return "\n".join(out)
|
|
@@ -1644,7 +1702,7 @@ class FindTool(Tool):
|
|
|
1644
1702
|
|
|
1645
1703
|
class SearchTool(Tool):
|
|
1646
1704
|
NAME = "Search"
|
|
1647
|
-
DESCRIPTION = "Search UTF-8 text files with case-insensitive regex; skips binary/hidden/gitignored files and returns path
|
|
1705
|
+
DESCRIPTION = "Search UTF-8 text files with case-insensitive regex; skips binary/hidden/gitignored files and returns path anchor=line:hash matches."
|
|
1648
1706
|
SIGNATURE = "Search(pattern,path?,glob?,context?) or Search(queries=[...]); pattern is regex, A|B|C is ok"
|
|
1649
1707
|
EXAMPLE = (
|
|
1650
1708
|
'Search source with context. Example: {"pattern":"class .*Tool","path":"src","glob":"*.py","context":2}',
|
|
@@ -1827,7 +1885,7 @@ class SearchTool(Tool):
|
|
|
1827
1885
|
return rows
|
|
1828
1886
|
|
|
1829
1887
|
def match_line(self, prefix: str, path: str, line_index: int, line: str) -> str:
|
|
1830
|
-
return f"{prefix} {self.session.relpath(path)}
|
|
1888
|
+
return f"{prefix} {self.session.relpath(path)} {ReadTool.anchor_line(line_index, line)}"
|
|
1831
1889
|
|
|
1832
1890
|
|
|
1833
1891
|
class CodeIndex:
|
|
@@ -2079,7 +2137,7 @@ class InspectCodeTool(Tool):
|
|
|
2079
2137
|
if mode == "find":
|
|
2080
2138
|
return csi.search(target, limit=limit or csi.DEFAULT_SEARCH_LIMIT, **common)
|
|
2081
2139
|
if mode == "inspect":
|
|
2082
|
-
return csi.inspect(target, limit=limit or csi.DEFAULT_PAGE_LIMIT, anchors=True, **common)
|
|
2140
|
+
return csi.inspect(target, limit=limit or csi.DEFAULT_PAGE_LIMIT, anchors=True, anchor_format="explicit", **common)
|
|
2083
2141
|
if mode == "refs":
|
|
2084
2142
|
ref_kinds = options.get("ref_kind") or ("all" if options.get("all_kinds") else "behavioral")
|
|
2085
2143
|
return csi.refs(target, limit=limit or csi.DEFAULT_MAX_REFERENCES, offset=int(options.get("offset") or 0), ref_kinds=ref_kinds, **common)
|
|
@@ -2319,7 +2377,7 @@ class EditTool(Tool):
|
|
|
2319
2377
|
out.append("...")
|
|
2320
2378
|
break
|
|
2321
2379
|
line = lines[index]
|
|
2322
|
-
out.append(
|
|
2380
|
+
out.append(ReadTool.anchor_line(index, line))
|
|
2323
2381
|
shown_lines += 1
|
|
2324
2382
|
out.append("</target>")
|
|
2325
2383
|
if shown_lines >= 12:
|
|
@@ -2337,7 +2395,7 @@ class EditTool(Tool):
|
|
|
2337
2395
|
shown = lines[start:end]
|
|
2338
2396
|
if shown:
|
|
2339
2397
|
out.append("<content hashline-numbered>")
|
|
2340
|
-
out.extend(
|
|
2398
|
+
out.extend(ReadTool.anchor_line(start + index, line) for index, line in enumerate(shown))
|
|
2341
2399
|
out.append("</content>")
|
|
2342
2400
|
return "\n".join(out)
|
|
2343
2401
|
|
|
@@ -2360,16 +2418,14 @@ class EditTool(Tool):
|
|
|
2360
2418
|
return value.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\t", "\t") if "\n" not in value and "\\n" in value else value
|
|
2361
2419
|
|
|
2362
2420
|
def resolve_anchor(self, lines: list[str], anchor: str) -> int:
|
|
2363
|
-
|
|
2364
|
-
if
|
|
2365
|
-
raise ToolError('invalid anchor; use "line:hash" from Read or
|
|
2366
|
-
index =
|
|
2421
|
+
parsed = ReadTool.parse_anchor(anchor)
|
|
2422
|
+
if parsed is None:
|
|
2423
|
+
raise ToolError('invalid anchor; use the "anchor=line:hash" value from Read, Search, or InspectCode')
|
|
2424
|
+
index, expected = parsed
|
|
2367
2425
|
if index >= len(lines):
|
|
2368
2426
|
raise ToolError("anchor line out of range")
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
if actual != expected:
|
|
2372
|
-
current = f"{index}:{actual}|{lines[index].rstrip()}"
|
|
2427
|
+
if not ReadTool.anchor_matches(lines[index], expected):
|
|
2428
|
+
current = ReadTool.anchor_line(index, lines[index])
|
|
2373
2429
|
raise ToolError(f"stale anchor {anchor}; current is {current}")
|
|
2374
2430
|
return index
|
|
2375
2431
|
|
|
@@ -2427,6 +2483,9 @@ class BashTool(Tool):
|
|
|
2427
2483
|
def stream_process(self, proc: subprocess.Popen[bytes]) -> str:
|
|
2428
2484
|
stdout_parts: list[str] = []
|
|
2429
2485
|
stderr_parts: list[str] = []
|
|
2486
|
+
# Per-stream incremental decoders so a multibyte UTF-8 character split across two 4096-byte
|
|
2487
|
+
# reads is decoded once it is complete, instead of being mangled into replacement chars.
|
|
2488
|
+
self._decoders = {"stdout": codecs.getincrementaldecoder("utf-8")("replace"), "stderr": codecs.getincrementaldecoder("utf-8")("replace")}
|
|
2430
2489
|
selector = selectors.DefaultSelector()
|
|
2431
2490
|
selector.register(proc.stdout, selectors.EVENT_READ, "stdout")
|
|
2432
2491
|
selector.register(proc.stderr, selectors.EVENT_READ, "stderr")
|
|
@@ -2469,6 +2528,13 @@ class BashTool(Tool):
|
|
|
2469
2528
|
data = os.read(key.fileobj.fileno(), 4096)
|
|
2470
2529
|
except OSError:
|
|
2471
2530
|
data = b""
|
|
2531
|
+
# final=True on EOF flushes any bytes still buffered in the decoder (e.g. a truncated
|
|
2532
|
+
# trailing character) so they are not silently dropped.
|
|
2533
|
+
text = self._decoders[key.data].decode(data, final=not data)
|
|
2534
|
+
if text:
|
|
2535
|
+
(stdout_parts if key.data == "stdout" else stderr_parts).append(text)
|
|
2536
|
+
if self.live_output is not None:
|
|
2537
|
+
self.live_output(str(key.data), text)
|
|
2472
2538
|
if not data:
|
|
2473
2539
|
try:
|
|
2474
2540
|
selector.unregister(key.fileobj)
|
|
@@ -2479,10 +2545,6 @@ class BashTool(Tool):
|
|
|
2479
2545
|
except Exception:
|
|
2480
2546
|
pass
|
|
2481
2547
|
return False
|
|
2482
|
-
text = data.decode("utf-8", errors="replace")
|
|
2483
|
-
(stdout_parts if key.data == "stdout" else stderr_parts).append(text)
|
|
2484
|
-
if self.live_output is not None:
|
|
2485
|
-
self.live_output(str(key.data), text)
|
|
2486
2548
|
return True
|
|
2487
2549
|
|
|
2488
2550
|
@staticmethod
|
|
@@ -2705,15 +2767,27 @@ class RecallTool(Tool):
|
|
|
2705
2767
|
|
|
2706
2768
|
class NoteTool(Tool):
|
|
2707
2769
|
NAME = "Note"
|
|
2708
|
-
DESCRIPTION = "Maintain durable working notes; set_goal, replace_plan, and set_check replace current values, append_known appends, replace_known replaces all known facts."
|
|
2770
|
+
DESCRIPTION = "Maintain durable working notes; set_goal, replace_plan, and set_check replace current values, append_known appends, replace_known replaces all known facts. Plan items are objects with status todo|doing|done|blocked and text."
|
|
2709
2771
|
SIGNATURE = "Note(set_goal?, replace_plan?, append_known?, replace_known?, set_check?)"
|
|
2710
|
-
EXAMPLE = (
|
|
2772
|
+
EXAMPLE = (
|
|
2773
|
+
'Set memory. Example: {"set_goal":"ship parser fix","replace_plan":[{"status":"doing","text":"inspect parser"},{"status":"todo","text":"patch bug"}],"append_known":["tests use pytest"],"set_check":"pytest -q passed"}',
|
|
2774
|
+
)
|
|
2711
2775
|
STORES_RESULT = False
|
|
2712
2776
|
|
|
2713
2777
|
@classmethod
|
|
2714
2778
|
def params_schema(cls) -> Json:
|
|
2715
2779
|
strings = {"type": "array", "items": {"type": "string"}}
|
|
2716
|
-
|
|
2780
|
+
plan_item = {
|
|
2781
|
+
"type": "object",
|
|
2782
|
+
"properties": {"status": {"type": "string", "enum": list(PlanItem.STATUSES)}, "text": {"type": "string"}},
|
|
2783
|
+
"required": ["status", "text"],
|
|
2784
|
+
"additionalProperties": False,
|
|
2785
|
+
}
|
|
2786
|
+
return {
|
|
2787
|
+
"type": "object",
|
|
2788
|
+
"properties": {"set_goal": {"type": "string"}, "replace_plan": {"type": "array", "items": plan_item}, "append_known": strings, "replace_known": strings, "set_check": {"type": "string"}},
|
|
2789
|
+
"additionalProperties": False,
|
|
2790
|
+
}
|
|
2717
2791
|
|
|
2718
2792
|
@classmethod
|
|
2719
2793
|
def payload_args(cls, payload: Json) -> list[Any]:
|
|
@@ -2738,8 +2812,14 @@ class NoteTool(Tool):
|
|
|
2738
2812
|
changed.append("set_check")
|
|
2739
2813
|
if "replace_plan" in data:
|
|
2740
2814
|
if not isinstance(data["replace_plan"], list):
|
|
2741
|
-
raise ToolError('Note replace_plan must be an array of
|
|
2742
|
-
|
|
2815
|
+
raise ToolError('Note replace_plan must be an array of plan items, e.g. {"replace_plan":[{"status":"doing","text":"inspect"}]}')
|
|
2816
|
+
for item in data["replace_plan"]:
|
|
2817
|
+
if isinstance(item, dict):
|
|
2818
|
+
if str(item.get("status") or "").strip().lower() not in PlanItem.STATUSES:
|
|
2819
|
+
raise ToolError("Note replace_plan status must be one of: " + ", ".join(PlanItem.STATUSES))
|
|
2820
|
+
if not str(item.get("text") or "").strip():
|
|
2821
|
+
raise ToolError("Note replace_plan text is required")
|
|
2822
|
+
plan = AgentState.plan_items(data["replace_plan"])
|
|
2743
2823
|
changed.append("replace_plan")
|
|
2744
2824
|
if "append_known" in data:
|
|
2745
2825
|
if not isinstance(data["append_known"], list):
|
|
@@ -2763,19 +2843,19 @@ class NoteTool(Tool):
|
|
|
2763
2843
|
data = self.args[0] if self.args and isinstance(self.args[0], dict) else {}
|
|
2764
2844
|
lines = []
|
|
2765
2845
|
if goal := str(data.get("set_goal") or "").strip():
|
|
2766
|
-
lines.append("
|
|
2846
|
+
lines.append("goal: " + Tool.compact(goal, 120))
|
|
2767
2847
|
if check := str(data.get("set_check") or "").strip():
|
|
2768
|
-
lines.append("
|
|
2848
|
+
lines.append("check: " + Tool.compact(check, 120))
|
|
2769
2849
|
if isinstance(data.get("replace_plan"), list):
|
|
2770
|
-
lines.extend(["
|
|
2850
|
+
lines.extend(["plan:", *(f" {row}" for row in AgentState.plan_rows_for(data["replace_plan"], status=True, style="symbol") if row != "- (empty)")])
|
|
2771
2851
|
if isinstance(data.get("append_known"), list):
|
|
2772
2852
|
known = [Tool.compact(item, 120) for item in data["append_known"] if str(item).strip() and str(item).strip() not in self.session.state.known]
|
|
2773
2853
|
if known:
|
|
2774
|
-
lines.extend(["
|
|
2854
|
+
lines.extend(["known:", *(f" + {item}" for item in known)])
|
|
2775
2855
|
if isinstance(data.get("replace_known"), list):
|
|
2776
2856
|
known = [Tool.compact(item, 120) for item in data["replace_known"] if str(item).strip()]
|
|
2777
2857
|
if known:
|
|
2778
|
-
lines.extend(["
|
|
2858
|
+
lines.extend(["known:", *(f" {item}" for item in known)])
|
|
2779
2859
|
return ["\n".join(lines) or "{}"]
|
|
2780
2860
|
|
|
2781
2861
|
|
|
@@ -3031,6 +3111,9 @@ class ToolCall:
|
|
|
3031
3111
|
id: str
|
|
3032
3112
|
name: str
|
|
3033
3113
|
args: list[Any]
|
|
3114
|
+
# A malformed-argument error captured while parsing the call. Deferred so it surfaces as a
|
|
3115
|
+
# tool result the model can correct from, instead of aborting the whole turn at parse time.
|
|
3116
|
+
error: str = ""
|
|
3034
3117
|
|
|
3035
3118
|
|
|
3036
3119
|
class ContextManager:
|
|
@@ -3147,7 +3230,7 @@ class ContextManager:
|
|
|
3147
3230
|
index_usable = "yes" if index_status in {"synced", "ready", "stale"} else "no"
|
|
3148
3231
|
rows = [
|
|
3149
3232
|
"Goal: " + (self.session.state.goal or "(empty; use Note for multi-step work)"),
|
|
3150
|
-
"Plan:\n" + "\n".join(self.session.state.
|
|
3233
|
+
"Plan:\n" + "\n".join(AgentState.plan_rows_for(self.session.state.plan, status=True)),
|
|
3151
3234
|
"Known:\n" + "\n".join("- " + item for item in self.session.state.known or ["(empty)"]),
|
|
3152
3235
|
"Check: " + (self.session.state.check or "(empty)"),
|
|
3153
3236
|
f"Code index: {index_status} (InspectCode usable: {index_usable})",
|
|
@@ -3213,9 +3296,9 @@ class ContextManager:
|
|
|
3213
3296
|
items.append(self.FileContextItem(order, 0, "clear", record.key, record.name, path, int(match.group(1)), int(match.group(2)), "", *stat))
|
|
3214
3297
|
for match in re.finditer(r"(?s)<content hashline-numbered>\n(.*?)\n</content>", body):
|
|
3215
3298
|
for line in match.group(1).splitlines():
|
|
3216
|
-
|
|
3217
|
-
if
|
|
3218
|
-
items.append(self.FileContextItem(order, 1, "line", record.key, record.name, path,
|
|
3299
|
+
parsed = ReadTool.parse_anchor(line)
|
|
3300
|
+
if parsed is not None:
|
|
3301
|
+
items.append(self.FileContextItem(order, 1, "line", record.key, record.name, path, parsed[0], 0, line, *stat))
|
|
3219
3302
|
return items
|
|
3220
3303
|
|
|
3221
3304
|
def file_count(self) -> int:
|
|
@@ -3238,8 +3321,8 @@ class ContextManager:
|
|
|
3238
3321
|
if item.path not in current_lines:
|
|
3239
3322
|
current_lines[item.path] = self.read_lines(item.path, wanted.get(item.path, set()))
|
|
3240
3323
|
lines = current_lines[item.path]
|
|
3241
|
-
|
|
3242
|
-
return bool(lines is not None and
|
|
3324
|
+
parsed = ReadTool.parse_anchor(item.line)
|
|
3325
|
+
return bool(lines is not None and parsed is not None and item.start in lines and ReadTool.anchor_matches(lines[item.start], parsed[1]))
|
|
3243
3326
|
|
|
3244
3327
|
def render_file_lines(self, lines_by_path: dict[str, dict[int, tuple[str, str, str]]], omitted: dict[str, dict[str, int]]) -> str:
|
|
3245
3328
|
def recent(path: str) -> int:
|
|
@@ -3251,7 +3334,9 @@ class ContextManager:
|
|
|
3251
3334
|
paths = sorted((path for path in lines_by_path if lines_by_path[path]), key=lambda path: (-recent(path), path))
|
|
3252
3335
|
code_edits = self.recent_code_edits()
|
|
3253
3336
|
check_status = self.check_status(code_edits)
|
|
3254
|
-
|
|
3337
|
+
state = self.session.state
|
|
3338
|
+
focus = state.focus_text(state.plan)
|
|
3339
|
+
actions, errors = self.recent_file_actions(), self.recent_tool_errors()
|
|
3255
3340
|
if not paths and not omitted and not focus and not actions and not code_edits and not check_status and not errors:
|
|
3256
3341
|
return ""
|
|
3257
3342
|
chunks = ["Read/Edit outputs update this section. Treat listed ranges as current file state."] if paths else []
|
|
@@ -3270,7 +3355,7 @@ class ContextManager:
|
|
|
3270
3355
|
if errors:
|
|
3271
3356
|
chunks.extend(["", "Recent tool errors:", *errors])
|
|
3272
3357
|
if paths:
|
|
3273
|
-
chunks.extend(["", "Content:", "Format: line:hash|text. Use line:hash as
|
|
3358
|
+
chunks.extend(["", "Content:", "Format: anchor=line:hash | text, where hash = hash(line_content). Use the full line:hash value as Edit anchors."])
|
|
3274
3359
|
for path in paths:
|
|
3275
3360
|
for start, end, source, tool, segment_lines in self.segments(lines_by_path[path]):
|
|
3276
3361
|
chunks.append(f"@@ {path} {start}:{end} current source={source} tool={tool}")
|
|
@@ -3681,18 +3766,18 @@ class EditBatchPlan:
|
|
|
3681
3766
|
return changes
|
|
3682
3767
|
|
|
3683
3768
|
def resolve_anchor(self, state: FileState, anchor: str) -> int:
|
|
3684
|
-
|
|
3685
|
-
if
|
|
3686
|
-
raise ToolError('invalid anchor; use "line:hash" from Read or
|
|
3687
|
-
index, expected =
|
|
3688
|
-
if index < len(state.lines) and ReadTool.
|
|
3769
|
+
parsed = ReadTool.parse_anchor(anchor)
|
|
3770
|
+
if parsed is None:
|
|
3771
|
+
raise ToolError('invalid anchor; use the "anchor=line:hash" value from Read, Search, or InspectCode')
|
|
3772
|
+
index, expected = parsed
|
|
3773
|
+
if index < len(state.lines) and ReadTool.anchor_matches(state.lines[index].text, expected):
|
|
3689
3774
|
return index
|
|
3690
|
-
if index < len(state.original) and ReadTool.
|
|
3775
|
+
if index < len(state.original) and ReadTool.anchor_matches(state.original[index], expected):
|
|
3691
3776
|
current = state.current_origin(index)
|
|
3692
3777
|
if current is not None:
|
|
3693
3778
|
return current
|
|
3694
3779
|
raise ToolError(f"stale anchor {anchor}; original line was changed in this batch")
|
|
3695
|
-
current =
|
|
3780
|
+
current = ReadTool.anchor_line(index, state.lines[index].text) if index < len(state.lines) else "out of range"
|
|
3696
3781
|
raise ToolError(f"stale anchor {anchor}; current is {current}")
|
|
3697
3782
|
|
|
3698
3783
|
|
|
@@ -3715,6 +3800,7 @@ class MCPManager:
|
|
|
3715
3800
|
self.server_skips: dict[str, str] = {}
|
|
3716
3801
|
self.lock = threading.Lock()
|
|
3717
3802
|
self.discovery_status: str = "stale" # stale | discovering | ready | error
|
|
3803
|
+
self.index_truncated: bool = False # set by render_tools_index when even name-only overflows the cap
|
|
3718
3804
|
self._configs_cache: list[MCPServerConfig] | None = None
|
|
3719
3805
|
self._oauth_token_store = MCPFileTokenStore(self.session.data_path("mcp-oauth", "tokens.json"))
|
|
3720
3806
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
@@ -3954,9 +4040,6 @@ class MCPManager:
|
|
|
3954
4040
|
)
|
|
3955
4041
|
return infos
|
|
3956
4042
|
|
|
3957
|
-
def resource_info(self, server: str, uri: str) -> MCPResourceInfo | None:
|
|
3958
|
-
return next((res for res in self.resources.get(server, []) if res.uri == uri), None)
|
|
3959
|
-
|
|
3960
4043
|
@staticmethod
|
|
3961
4044
|
def tool_annotations(tool: Any) -> Json:
|
|
3962
4045
|
annotations = getattr(tool, "annotations", None)
|
|
@@ -4388,19 +4471,71 @@ class MCPManager:
|
|
|
4388
4471
|
return "\n".join(lines)
|
|
4389
4472
|
|
|
4390
4473
|
def render_tools_index(self) -> str:
|
|
4474
|
+
"""Render the MCP tools block injected into every model turn (in the cached prefix).
|
|
4475
|
+
|
|
4476
|
+
The block is capped at INDEX_TOTAL_LIMIT so it cannot bloat each request. When it
|
|
4477
|
+
would overflow we degrade by shedding *detail*, never *entities*: the model can
|
|
4478
|
+
always re-fetch a dropped schema via `describe`, but it can never call a server or
|
|
4479
|
+
tool it was never told exists. So we try progressively cheaper renderings and emit
|
|
4480
|
+
the richest one that fits:
|
|
4481
|
+
|
|
4482
|
+
tier 1 "schema" — full per-tool JSON schemas inline (normal case)
|
|
4483
|
+
tier 2 "args" — schemas dropped, name + arg summary per tool
|
|
4484
|
+
tier 3 "names" — name-only, grouped per server
|
|
4485
|
+
tier 4 — hard truncate (only at thousands of tools, where 16KB
|
|
4486
|
+
physically cannot hold them); server headers come first so
|
|
4487
|
+
the model still sees most servers exist.
|
|
4488
|
+
|
|
4489
|
+
Tiers 1–3 keep every enabled server and tool name visible. See _index_body for how
|
|
4490
|
+
each detail level is rendered, and test_mcp.TestToolIndexBudget for the guarantees.
|
|
4491
|
+
"""
|
|
4391
4492
|
configs = [c for c in self.parse_configs() if c.enabled]
|
|
4392
4493
|
if not configs:
|
|
4393
4494
|
return ""
|
|
4394
4495
|
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4496
|
+
intro = [
|
|
4497
|
+
"--- MCP TOOLS ---",
|
|
4498
|
+
'Use MCP(action="call", server, tool, arguments) for external MCP server tools.',
|
|
4499
|
+
'Use MCP(action="describe", server, tool) for the full schema when one is truncated below; the result stays in the conversation, so do not describe the same tool again once its schema is shown — just call it.',
|
|
4500
|
+
'Use MCP(action="read_resource", server, uri) to read a listed resource (e.g. docs describing how to build a tool\'s arguments). Read relevant resources before calling.',
|
|
4501
|
+
"Format: server.tool(req: type; opt: type) - description",
|
|
4502
|
+
" schema: <JSON Schema for the arguments object>",
|
|
4503
|
+
"",
|
|
4504
|
+
]
|
|
4403
4505
|
|
|
4506
|
+
# A note tells the model what was shed (and that describe recovers it) so it does not
|
|
4507
|
+
# assume a tool is argument-less. Tier 1 ("schema") needs no note; tier 4 reuses the
|
|
4508
|
+
# last (tier 3) text below.
|
|
4509
|
+
notes = {
|
|
4510
|
+
"args": ['Schemas omitted to fit; use MCP(action="describe", server, tool) for a tool\'s arguments.', ""],
|
|
4511
|
+
"names": ['Only tool names shown to fit; use MCP(action="describe", server, tool) before calling.', ""],
|
|
4512
|
+
}
|
|
4513
|
+
for detail in ("schema", "args", "names"):
|
|
4514
|
+
body = self._index_body(configs, detail=detail)
|
|
4515
|
+
text = "\n".join(intro + notes.get(detail, []) + body)
|
|
4516
|
+
if len(text) <= self.INDEX_TOTAL_LIMIT:
|
|
4517
|
+
self.index_truncated = False
|
|
4518
|
+
return text
|
|
4519
|
+
|
|
4520
|
+
# Tier 4: even name-only overflows, so some tools are dropped entirely (not just
|
|
4521
|
+
# detail). Flag it so the CLI can warn the user — unlike tiers 1-3 these tools are
|
|
4522
|
+
# not callable until the index fits (fewer servers, or consult /mcp tools).
|
|
4523
|
+
self.index_truncated = True
|
|
4524
|
+
return text[: self.INDEX_TOTAL_LIMIT - 10] + "\n... MCP tools truncated; use /mcp tools for full list."
|
|
4525
|
+
|
|
4526
|
+
def _index_body(self, configs: list["MCPServerConfig"], *, detail: str = "schema") -> list[str]:
|
|
4527
|
+
"""Render the per-server body lines of the tools index at one detail level.
|
|
4528
|
+
|
|
4529
|
+
detail controls how much of each tool is emitted (richest to cheapest):
|
|
4530
|
+
"schema" — full line via _format_tool_line, including the inline JSON schema
|
|
4531
|
+
"args" — same line without the schema (name + arg summary + description)
|
|
4532
|
+
"names" — one "tools: a, b, c" line per server, names only
|
|
4533
|
+
|
|
4534
|
+
Every enabled server is represented regardless of detail: a connected server shows
|
|
4535
|
+
its tools, an unconnected one (no tools/resources) is collected into a trailing
|
|
4536
|
+
"not yet available" section so the model still knows it exists.
|
|
4537
|
+
"""
|
|
4538
|
+
lines: list[str] = []
|
|
4404
4539
|
pending: list[str] = []
|
|
4405
4540
|
for config in configs:
|
|
4406
4541
|
tools = self.tools.get(config.name, [])
|
|
@@ -4408,12 +4543,15 @@ class MCPManager:
|
|
|
4408
4543
|
if not tools and not resources:
|
|
4409
4544
|
pending.append(f"- {config.name}: {self._pending_status(config.name)}")
|
|
4410
4545
|
continue
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4546
|
+
lines.append(f"[{config.name}] {config.name.capitalize()}")
|
|
4547
|
+
if detail == "names":
|
|
4548
|
+
if tools:
|
|
4549
|
+
lines.append("tools: " + ", ".join(tool.name for tool in tools))
|
|
4550
|
+
else:
|
|
4551
|
+
for tool in tools:
|
|
4552
|
+
line = self._format_tool_line(config.name, tool, include_schema=detail == "schema")
|
|
4553
|
+
if line:
|
|
4554
|
+
lines.append(line)
|
|
4417
4555
|
if resources:
|
|
4418
4556
|
lines.append(f"resources ({len(resources)}) — read with MCP(action=\"read_resource\", server={json.dumps(config.name)}, uri=...):")
|
|
4419
4557
|
lines.extend(self._format_resource_line(res) for res in resources)
|
|
@@ -4423,11 +4561,7 @@ class MCPManager:
|
|
|
4423
4561
|
lines.append("Configured servers not yet available (they exist — do not assume otherwise):")
|
|
4424
4562
|
lines.extend(pending)
|
|
4425
4563
|
lines.append("")
|
|
4426
|
-
|
|
4427
|
-
text = "\n".join(lines)
|
|
4428
|
-
if len(text) > self.INDEX_TOTAL_LIMIT:
|
|
4429
|
-
text = text[: self.INDEX_TOTAL_LIMIT - 10] + "\n... MCP tools truncated; use /mcp tools for full list."
|
|
4430
|
-
return text
|
|
4564
|
+
return lines
|
|
4431
4565
|
|
|
4432
4566
|
def server_issue(self, name: str) -> tuple[str, str] | None:
|
|
4433
4567
|
"""Classify a server's failure state as (kind, message); error takes precedence over skip."""
|
|
@@ -4503,7 +4637,7 @@ class MCPManager:
|
|
|
4503
4637
|
lines.extend(self._format_resource_line(res) for res in resources)
|
|
4504
4638
|
return "\n".join(lines)
|
|
4505
4639
|
|
|
4506
|
-
def _format_tool_line(self, server: str, info: MCPToolInfo) -> str:
|
|
4640
|
+
def _format_tool_line(self, server: str, info: MCPToolInfo, *, include_schema: bool = True) -> str:
|
|
4507
4641
|
args_str = self._tool_args_summary(info)
|
|
4508
4642
|
desc = (info.description or "").split("\n")[0].strip()
|
|
4509
4643
|
desc = " ".join(desc.split())
|
|
@@ -4518,9 +4652,10 @@ class MCPManager:
|
|
|
4518
4652
|
uris = self._extract_uris(info.description)
|
|
4519
4653
|
if uris:
|
|
4520
4654
|
line += "\n refs (read with MCP action=\"read_resource\"): " + ", ".join(uris)
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4655
|
+
if include_schema:
|
|
4656
|
+
schema = self._schema_json(info.input_schema, self.INDEX_SCHEMA_LIMIT)
|
|
4657
|
+
if schema:
|
|
4658
|
+
line += f"\n schema: {schema}"
|
|
4524
4659
|
return line
|
|
4525
4660
|
|
|
4526
4661
|
URI_PATTERN: ClassVar[re.Pattern] = re.compile(r"[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s'\"<>)\]}]+")
|
|
@@ -4645,28 +4780,112 @@ class ToolRunner:
|
|
|
4645
4780
|
self.question_fn: Callable[[QuestionSpec, str], str] | None = None
|
|
4646
4781
|
|
|
4647
4782
|
def run(self, calls: list[ToolCall], batch_suffix: str = "") -> list[Json]:
|
|
4648
|
-
messages = []
|
|
4783
|
+
messages: list[Json] = []
|
|
4784
|
+
# Shared, mutated across segments: `first` controls which display carries batch_suffix;
|
|
4785
|
+
# `refused` short-circuits the rest of the batch once a confirmation is declined.
|
|
4786
|
+
state = {"first": True, "refused": False}
|
|
4649
4787
|
index = 0
|
|
4650
|
-
first, refused = True, False
|
|
4651
4788
|
while index < len(calls):
|
|
4789
|
+
if state["refused"]:
|
|
4790
|
+
messages.append(self.skip_message(calls[index]))
|
|
4791
|
+
index += 1
|
|
4792
|
+
continue
|
|
4793
|
+
end = self.parallel_segment_end(calls, index)
|
|
4794
|
+
if end - index >= 2 and self.session.settings.max_parallel_tools > 1:
|
|
4795
|
+
messages.extend(self.run_parallel(calls[index:end], batch_suffix, state))
|
|
4796
|
+
index = end
|
|
4797
|
+
continue
|
|
4652
4798
|
end = index + 1 if self.edit_barrier(calls[index]) else self.edit_segment_end(calls, index)
|
|
4653
|
-
|
|
4654
|
-
plan = EditBatchPlan(self.session).build(segment) if not refused and any(call.name == "Edit" for call in segment) else EditBatchPlan(self.session)
|
|
4655
|
-
for call in segment:
|
|
4656
|
-
self.session.state.turn_tool_calls += 1
|
|
4657
|
-
suffix = batch_suffix if first else ""
|
|
4658
|
-
first = False
|
|
4659
|
-
status, content = (
|
|
4660
|
-
("skipped", self.tool_message(call, "", "Skipped: previous tool call was refused", failed=True))
|
|
4661
|
-
if refused
|
|
4662
|
-
else self.run_one(call, batch_suffix=suffix, planned_edit=plan.planned.get(call.id), plan_error=plan.errors.get(call.id, ""))
|
|
4663
|
-
)
|
|
4664
|
-
messages.append({"role": "tool", "tool_call_id": call.id, "content": content})
|
|
4665
|
-
if status == "refused":
|
|
4666
|
-
refused = True
|
|
4799
|
+
messages.extend(self.run_serial(calls[index:end], batch_suffix, state))
|
|
4667
4800
|
index = end
|
|
4668
4801
|
return messages
|
|
4669
4802
|
|
|
4803
|
+
def skip_message(self, call: ToolCall) -> Json:
|
|
4804
|
+
self.session.state.turn_tool_calls += 1
|
|
4805
|
+
content = self.tool_message(call, "", "Skipped: previous tool call was refused", failed=True)
|
|
4806
|
+
return {"role": "tool", "tool_call_id": call.id, "content": content}
|
|
4807
|
+
|
|
4808
|
+
def run_serial(self, segment: list[ToolCall], batch_suffix: str, state: dict[str, bool]) -> list[Json]:
|
|
4809
|
+
messages: list[Json] = []
|
|
4810
|
+
plan = EditBatchPlan(self.session).build(segment) if any(call.name == "Edit" for call in segment) else EditBatchPlan(self.session)
|
|
4811
|
+
for call in segment:
|
|
4812
|
+
self.session.state.turn_tool_calls += 1
|
|
4813
|
+
suffix = batch_suffix if state["first"] else ""
|
|
4814
|
+
state["first"] = False
|
|
4815
|
+
status, content = self.run_one(call, batch_suffix=suffix, planned_edit=plan.planned.get(call.id), plan_error=plan.errors.get(call.id, ""))
|
|
4816
|
+
messages.append({"role": "tool", "tool_call_id": call.id, "content": content})
|
|
4817
|
+
if status == "refused":
|
|
4818
|
+
state["refused"] = True
|
|
4819
|
+
return messages
|
|
4820
|
+
|
|
4821
|
+
def run_parallel(self, segment: list[ToolCall], batch_suffix: str, state: dict[str, bool]) -> list[Json]:
|
|
4822
|
+
# Run the pure tool.call() work concurrently, but apply all side effects (display, session
|
|
4823
|
+
# bookkeeping, tool messages) on this thread in request order, so output and the results
|
|
4824
|
+
# handed back to the model match the order the model issued the calls.
|
|
4825
|
+
cap = max(1, self.session.settings.max_parallel_tools)
|
|
4826
|
+
outcomes: list[tuple[str, str, str | None, float] | None] = [None] * len(segment)
|
|
4827
|
+
with ThreadPoolExecutor(max_workers=min(len(segment), cap), thread_name_prefix="tool") as executor:
|
|
4828
|
+
futures = {executor.submit(self.execute_readonly, call): position for position, call in enumerate(segment)}
|
|
4829
|
+
for future in as_completed(futures):
|
|
4830
|
+
outcomes[futures[future]] = future.result()
|
|
4831
|
+
messages: list[Json] = []
|
|
4832
|
+
for call, outcome in zip(segment, outcomes):
|
|
4833
|
+
self.session.state.turn_tool_calls += 1
|
|
4834
|
+
suffix = batch_suffix if state["first"] else ""
|
|
4835
|
+
state["first"] = False
|
|
4836
|
+
assert outcome is not None
|
|
4837
|
+
content = self.finalize_outcome(call, outcome, batch_suffix=suffix)
|
|
4838
|
+
messages.append({"role": "tool", "tool_call_id": call.id, "content": content})
|
|
4839
|
+
return messages
|
|
4840
|
+
|
|
4841
|
+
def parallel_segment_end(self, calls: list[ToolCall], start: int) -> int:
|
|
4842
|
+
end = start
|
|
4843
|
+
while end < len(calls) and self.parallel_safe(calls[end]):
|
|
4844
|
+
end += 1
|
|
4845
|
+
return end
|
|
4846
|
+
|
|
4847
|
+
def parallel_safe(self, call: ToolCall) -> bool:
|
|
4848
|
+
# A call may run concurrently only if it neither mutates state nor blocks on interactive
|
|
4849
|
+
# input: read-only, auto-approved, non-interactive tools (Read/Search/Find/List/Recall/
|
|
4850
|
+
# InspectCode, read-only Git, read-only MCP). Edit is coordinated serially by EditBatchPlan;
|
|
4851
|
+
# Bash streams live output and mutates; Question blocks on the user.
|
|
4852
|
+
tool_class = TOOL_REGISTRY.get(call.name)
|
|
4853
|
+
if tool_class is None or call.name in {"Edit", "Question"} or tool_class is BashTool:
|
|
4854
|
+
return False
|
|
4855
|
+
try:
|
|
4856
|
+
return not tool_class(self.session, call.args).needs_confirmation()
|
|
4857
|
+
except Exception:
|
|
4858
|
+
return False
|
|
4859
|
+
|
|
4860
|
+
def execute_readonly(self, call: ToolCall) -> tuple[str, str, str | None, float]:
|
|
4861
|
+
# Pure execution for a parallel worker: returns (kind, output, display, elapsed) and performs
|
|
4862
|
+
# no display or session writes (those happen in finalize_outcome on the main thread). Mirrors
|
|
4863
|
+
# run_one's branches, minus confirmation (parallel_safe guarantees none is needed).
|
|
4864
|
+
started = time.monotonic()
|
|
4865
|
+
tool_class = TOOL_REGISTRY.get(call.name)
|
|
4866
|
+
if tool_class is None:
|
|
4867
|
+
return "reject", f"ToolError: unknown tool {call.name}", None, 0.0
|
|
4868
|
+
tool = tool_class(self.session, call.args)
|
|
4869
|
+
display = None
|
|
4870
|
+
try:
|
|
4871
|
+
display = self.short_call(call, tool.short_args())
|
|
4872
|
+
if call.error:
|
|
4873
|
+
raise ToolError(call.error)
|
|
4874
|
+
output = tool.call()
|
|
4875
|
+
except ToolError as error:
|
|
4876
|
+
return "reject", f"ToolError: {error}", display, time.monotonic() - started
|
|
4877
|
+
except Exception as error:
|
|
4878
|
+
return "error", f"ToolError: {error}", display, time.monotonic() - started
|
|
4879
|
+
return "ok", output, display, time.monotonic() - started
|
|
4880
|
+
|
|
4881
|
+
def finalize_outcome(self, call: ToolCall, outcome: tuple[str, str, str | None, float], batch_suffix: str = "") -> str:
|
|
4882
|
+
kind, output, display, elapsed = outcome
|
|
4883
|
+
if kind == "ok":
|
|
4884
|
+
return self.finish(call, output, elapsed=elapsed, display=display, batch_suffix=batch_suffix)
|
|
4885
|
+
if kind == "reject":
|
|
4886
|
+
return self.reject(call, output, elapsed=elapsed, display=display, batch_suffix=batch_suffix)
|
|
4887
|
+
return self.finish(call, output, failed=True, elapsed=elapsed, display=display, batch_suffix=batch_suffix)
|
|
4888
|
+
|
|
4670
4889
|
def edit_segment_end(self, calls: list[ToolCall], start: int) -> int:
|
|
4671
4890
|
end = start
|
|
4672
4891
|
while end < len(calls) and not self.edit_barrier(calls[end]):
|
|
@@ -4701,16 +4920,24 @@ class ToolRunner:
|
|
|
4701
4920
|
tool = tool_class(self.session, call.args)
|
|
4702
4921
|
if isinstance(tool, BashTool):
|
|
4703
4922
|
tool.live_output = self.live_output
|
|
4704
|
-
started, approved, display = time.monotonic(), False, None
|
|
4923
|
+
started, approved, auto, display = time.monotonic(), False, False, None
|
|
4705
4924
|
if isinstance(tool, QuestionTool):
|
|
4706
4925
|
tool.question_fn = self.question_fn
|
|
4707
4926
|
try:
|
|
4708
4927
|
display = self.short_call(call, tool.short_args())
|
|
4928
|
+
if call.error:
|
|
4929
|
+
raise ToolError(call.error)
|
|
4709
4930
|
if plan_error:
|
|
4710
4931
|
raise ToolError(plan_error)
|
|
4711
4932
|
needs_confirmation = tool.needs_confirmation()
|
|
4712
4933
|
if needs_confirmation and self.session.settings.yolo:
|
|
4713
|
-
|
|
4934
|
+
auto = True
|
|
4935
|
+
pre = self.approval_display(call, tool, "auto", batch_suffix=batch_suffix, planned_edit=planned_edit)
|
|
4936
|
+
# The "auto …" header duplicates the result line; only surface it when it carries a
|
|
4937
|
+
# preview the result line won't repeat (e.g. an Edit diff). The auto-approval itself
|
|
4938
|
+
# is recorded by the [auto] tag on the result line below.
|
|
4939
|
+
if "\n" in pre:
|
|
4940
|
+
self.output_fn(pre)
|
|
4714
4941
|
elif needs_confirmation:
|
|
4715
4942
|
confirmed, reason = self.confirm(call, tool, batch_suffix=batch_suffix, planned_edit=planned_edit)
|
|
4716
4943
|
if not confirmed:
|
|
@@ -4725,7 +4952,7 @@ class ToolRunner:
|
|
|
4725
4952
|
except Exception as error:
|
|
4726
4953
|
output = f"ToolError: {error}"
|
|
4727
4954
|
return "failed", self.finish(call, output, failed=True, elapsed=time.monotonic() - started, display=display, batch_suffix=batch_suffix)
|
|
4728
|
-
return "ok", self.finish(call, output, elapsed=time.monotonic() - started, approved=approved, display=display, batch_suffix=batch_suffix)
|
|
4955
|
+
return "ok", self.finish(call, output, elapsed=time.monotonic() - started, approved=approved, auto=auto, display=display, batch_suffix=batch_suffix)
|
|
4729
4956
|
|
|
4730
4957
|
def reject(self, call: ToolCall, output: str, *, elapsed: float | None = None, display: str | None = None, batch_suffix: str = "") -> str:
|
|
4731
4958
|
if self.session.settings.debug:
|
|
@@ -4749,6 +4976,7 @@ class ToolRunner:
|
|
|
4749
4976
|
failed: bool = False,
|
|
4750
4977
|
elapsed: float | None = None,
|
|
4751
4978
|
approved: bool = False,
|
|
4979
|
+
auto: bool = False,
|
|
4752
4980
|
display: str | None = None,
|
|
4753
4981
|
store: bool = True,
|
|
4754
4982
|
batch_suffix: str = "",
|
|
@@ -4763,7 +4991,7 @@ class ToolRunner:
|
|
|
4763
4991
|
self.session.record_tool_error(key or "-", call.name, call.args, output)
|
|
4764
4992
|
elif key:
|
|
4765
4993
|
self.update_code_index(call, output)
|
|
4766
|
-
self.output_fn(self.finish_display(call, key, output, failed=failed, approved=approved, display=display, batch_suffix=batch_suffix, elapsed=elapsed))
|
|
4994
|
+
self.output_fn(self.finish_display(call, key, output, failed=failed, approved=approved, auto=auto, display=display, batch_suffix=batch_suffix, elapsed=elapsed))
|
|
4767
4995
|
return self.tool_message(call, key, output, failed=failed, display=display)
|
|
4768
4996
|
|
|
4769
4997
|
def tool_message(self, call: ToolCall, key: str, output: str, *, failed: bool = False, display: str | None = None) -> str:
|
|
@@ -4839,11 +5067,11 @@ class ToolRunner:
|
|
|
4839
5067
|
return "\n".join([" preview", *(" " + line for line in lines)])
|
|
4840
5068
|
|
|
4841
5069
|
def finish_display(
|
|
4842
|
-
self, call: ToolCall, key: str, output: str, *, failed: bool, approved: bool = False, display: str | None = None, batch_suffix: str = "", elapsed: float | None = None
|
|
5070
|
+
self, call: ToolCall, key: str, output: str, *, failed: bool, approved: bool = False, auto: bool = False, display: str | None = None, batch_suffix: str = "", elapsed: float | None = None
|
|
4843
5071
|
) -> str:
|
|
4844
5072
|
if call.name == "Note" and not failed and display:
|
|
4845
5073
|
return self.with_batch_suffix(display.removeprefix("Note ").strip(), batch_suffix)
|
|
4846
|
-
tag = " [refused]" if failed and "user refused" in output else " [failed]" if failed else " [approved]" if approved else ""
|
|
5074
|
+
tag = " [refused]" if failed and "user refused" in output else " [failed]" if failed else " [approved]" if approved else " [auto]" if auto else ""
|
|
4847
5075
|
line = self.with_batch_suffix("tool " + (display or self.short_call(call)) + ((" -> " + key) if key else "") + tag, batch_suffix)
|
|
4848
5076
|
lines = [line]
|
|
4849
5077
|
if failed:
|
|
@@ -5051,6 +5279,7 @@ class ModelClient:
|
|
|
5051
5279
|
Compact the nanocode working context.
|
|
5052
5280
|
Return one JSON object only. No markdown, prose, code fences, or comments.
|
|
5053
5281
|
Use keys: summary, goal, plan, known, check.
|
|
5282
|
+
Plan must be an array of objects: {"status":"todo|doing|done|blocked","text":"..."}.
|
|
5054
5283
|
Rewrite recent conversation briefly inside summary.
|
|
5055
5284
|
Keep only durable facts needed to continue; preserve file paths, symbols, constraints, and tr.N keys.
|
|
5056
5285
|
""".strip()
|
|
@@ -5195,7 +5424,9 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
|
|
|
5195
5424
|
continue
|
|
5196
5425
|
function = raw.get("function") if isinstance(raw.get("function"), dict) else {}
|
|
5197
5426
|
try:
|
|
5198
|
-
|
|
5427
|
+
# strict=False: tool-call argument strings often contain literal newlines
|
|
5428
|
+
# (e.g. a multi-line git commit message), which are not valid JSON otherwise.
|
|
5429
|
+
payload = json.loads(str(function.get("arguments") or "{}"), strict=False)
|
|
5199
5430
|
except json.JSONDecodeError:
|
|
5200
5431
|
payload = {}
|
|
5201
5432
|
blocks.append(
|
|
@@ -5235,7 +5466,7 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
|
|
|
5235
5466
|
call_id = str(self.message_field(block, "id") or uuid.uuid4().hex)
|
|
5236
5467
|
arguments = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
5237
5468
|
tool_calls.append({"id": call_id, "type": "function", "function": {"name": name, "arguments": arguments}})
|
|
5238
|
-
calls.append(
|
|
5469
|
+
calls.append(self.tool_call(call_id, name, payload))
|
|
5239
5470
|
text = "".join(text_parts)
|
|
5240
5471
|
assistant: Json = {"role": "assistant", "content": text or None}
|
|
5241
5472
|
if tool_calls:
|
|
@@ -5298,11 +5529,13 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
|
|
|
5298
5529
|
calls = []
|
|
5299
5530
|
for raw in getattr(message, "tool_calls", None) or []:
|
|
5300
5531
|
try:
|
|
5301
|
-
|
|
5532
|
+
# strict=False so literal newlines in argument strings (e.g. a multi-line
|
|
5533
|
+
# git commit message) parse instead of dropping the call's args.
|
|
5534
|
+
payload = json.loads(raw.function.arguments or "{}", strict=False)
|
|
5302
5535
|
except json.JSONDecodeError:
|
|
5303
5536
|
calls.append(ToolCall(id=raw.id, name=raw.function.name, args=[]))
|
|
5304
5537
|
continue
|
|
5305
|
-
calls.append(
|
|
5538
|
+
calls.append(self.tool_call(raw.id, raw.function.name, payload))
|
|
5306
5539
|
return calls
|
|
5307
5540
|
|
|
5308
5541
|
@staticmethod
|
|
@@ -5311,6 +5544,16 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
|
|
|
5311
5544
|
return tool.payload_args(payload)
|
|
5312
5545
|
return [payload]
|
|
5313
5546
|
|
|
5547
|
+
@classmethod
|
|
5548
|
+
def tool_call(cls, call_id: str, name: str, payload: Any) -> ToolCall:
|
|
5549
|
+
# payload_args may reject malformed arguments (e.g. Git with an empty argv). Capture that
|
|
5550
|
+
# error on the call so it is replayed as a tool result during execution, letting the model
|
|
5551
|
+
# self-correct, rather than escaping to abort the entire agent turn.
|
|
5552
|
+
try:
|
|
5553
|
+
return ToolCall(id=call_id, name=name, args=cls.tool_payload(name, payload))
|
|
5554
|
+
except ToolError as error:
|
|
5555
|
+
return ToolCall(id=call_id, name=name, args=[], error=str(error))
|
|
5556
|
+
|
|
5314
5557
|
|
|
5315
5558
|
class Agent:
|
|
5316
5559
|
SYSTEM_PROMPT = """\
|
|
@@ -5324,6 +5567,12 @@ TOOLS:
|
|
|
5324
5567
|
- State/external: Recall retrieves tr.N outputs; Note maintains goal/plan/known/check; MCP calls configured external tools.
|
|
5325
5568
|
- Restraint: Before calling "Question", make progress with other tools first; only ask when genuinely blocked, and batch related questions into one call.
|
|
5326
5569
|
|
|
5570
|
+
GUIDE:
|
|
5571
|
+
- THINK BEFORE CODING: briefly state your approach before acting; surface important assumptions, ambiguity, and tradeoffs.
|
|
5572
|
+
- SIMPLICITY FIRST: implement the smallest non-speculative solution.
|
|
5573
|
+
- SURGICAL CHANGES: touch only lines that trace to the request; clean up only your own orphans.
|
|
5574
|
+
- GOAL-DRIVEN EXECUTION: define success criteria and loop until verified or blocked.
|
|
5575
|
+
|
|
5327
5576
|
FLOW:
|
|
5328
5577
|
- Act when clear; keep using tools until done, or return a final answer.
|
|
5329
5578
|
- Batch tool calls when practical.
|
|
@@ -5452,6 +5701,7 @@ class CommandCompleter(Completer):
|
|
|
5452
5701
|
"runtime.yolo",
|
|
5453
5702
|
"runtime.max_agent_steps",
|
|
5454
5703
|
"runtime.max_context_tokens",
|
|
5704
|
+
"runtime.max_parallel_tools",
|
|
5455
5705
|
"runtime.shell_timeout",
|
|
5456
5706
|
"runtime.check_updates",
|
|
5457
5707
|
)
|
|
@@ -5564,7 +5814,7 @@ class UiPrinter:
|
|
|
5564
5814
|
return self.tool_segments(text)
|
|
5565
5815
|
if text.startswith("approve ") or text.startswith("auto "):
|
|
5566
5816
|
return self.approval_segments(text)
|
|
5567
|
-
if text.startswith(("goal
|
|
5817
|
+
if text.startswith(("goal:", "check:", "plan:", "known:")):
|
|
5568
5818
|
return self.memory_segments(text)
|
|
5569
5819
|
if text.startswith("+ "):
|
|
5570
5820
|
return [("ansibrightblack", "+ "), ("ansiwhite", text[2:] + "\n")]
|
|
@@ -5603,10 +5853,16 @@ class UiPrinter:
|
|
|
5603
5853
|
def memory_segments(self, text: str) -> list[tuple[str, str]]:
|
|
5604
5854
|
segments = []
|
|
5605
5855
|
for line in text.splitlines() or [""]:
|
|
5606
|
-
if line.startswith(("goal
|
|
5856
|
+
if line.startswith(("goal:", "check:")):
|
|
5607
5857
|
segments.append(("ansimagenta", line))
|
|
5608
5858
|
elif line in {"summary:", "plan:", "known:"}:
|
|
5609
5859
|
segments.append(("ansicyan", line))
|
|
5860
|
+
elif line.lstrip().startswith("- [x]"):
|
|
5861
|
+
segments.append(("ansigreen", line))
|
|
5862
|
+
elif line.lstrip().startswith("- [~]"):
|
|
5863
|
+
segments.append(("ansiyellow", line))
|
|
5864
|
+
elif line.lstrip().startswith("- [-]"):
|
|
5865
|
+
segments.append(("ansired", line))
|
|
5610
5866
|
elif line.lstrip().startswith("+ "):
|
|
5611
5867
|
segments.append(("ansigreen", line))
|
|
5612
5868
|
else:
|
|
@@ -5856,17 +6112,10 @@ class StatusBar:
|
|
|
5856
6112
|
while not self.stop_event.is_set():
|
|
5857
6113
|
self.output.write_raw("\r")
|
|
5858
6114
|
self.output.erase_end_of_line()
|
|
5859
|
-
print_formatted_text(FormattedText(self.
|
|
6115
|
+
print_formatted_text(FormattedText(self.display_fragments(active=True)), output=self.output, end="", flush=True)
|
|
5860
6116
|
self.rendered = True
|
|
5861
6117
|
self.stop_event.wait(self.INTERVAL)
|
|
5862
6118
|
|
|
5863
|
-
def refresh_retry_state(self) -> None:
|
|
5864
|
-
count = self.session.state.model_retry_count
|
|
5865
|
-
if count == self.seen_retry_count:
|
|
5866
|
-
return
|
|
5867
|
-
self.seen_retry_count = count
|
|
5868
|
-
self.retry_notice_until = time.monotonic() + 2.0
|
|
5869
|
-
|
|
5870
6119
|
def model_elapsed(self) -> float:
|
|
5871
6120
|
return max(0.0, time.monotonic() - started) if (started := self.session.state.current_model_call_started_at) > 0 else 0.0
|
|
5872
6121
|
|
|
@@ -5877,11 +6126,13 @@ class StatusBar:
|
|
|
5877
6126
|
self.output.flush()
|
|
5878
6127
|
self.rendered = False
|
|
5879
6128
|
|
|
5880
|
-
def
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
self.
|
|
6129
|
+
def display_fragments(self, *, active: bool) -> list[tuple[str, str]]:
|
|
6130
|
+
if not active:
|
|
6131
|
+
return self.fragments(0.0, sweep=False, show_elapsed=False)
|
|
6132
|
+
count = self.session.state.model_retry_count
|
|
6133
|
+
if count != self.seen_retry_count:
|
|
6134
|
+
self.seen_retry_count = count
|
|
6135
|
+
self.retry_notice_until = time.monotonic() + 2.0
|
|
5885
6136
|
elapsed = max(0.0, time.monotonic() - self.started_at) if self.started_at else 0.0
|
|
5886
6137
|
return self.fragments(elapsed, sweep=True, show_elapsed=True)
|
|
5887
6138
|
|
|
@@ -5928,7 +6179,8 @@ class StatusBar:
|
|
|
5928
6179
|
if show_elapsed and self.session.pending_user_inputs:
|
|
5929
6180
|
parts.append(("+" + str(len(self.session.pending_user_inputs)), "warn"))
|
|
5930
6181
|
if show_elapsed:
|
|
5931
|
-
|
|
6182
|
+
minutes, rest = divmod(int(elapsed), 60)
|
|
6183
|
+
parts.append((f"{elapsed:.1f}s" if elapsed < 60 else f"{minutes}m{rest:02d}s", "runtime"))
|
|
5932
6184
|
if self.retry_notice_until > time.monotonic():
|
|
5933
6185
|
parts.append(("retrying", "warn"))
|
|
5934
6186
|
elif self.model_elapsed() >= self.stress_after():
|
|
@@ -5993,16 +6245,14 @@ class StatusBar:
|
|
|
5993
6245
|
return f"mcp {loaded}/{total}{spinner}"
|
|
5994
6246
|
if status == "error":
|
|
5995
6247
|
return "mcp err"
|
|
5996
|
-
|
|
6248
|
+
if status != "ready":
|
|
6249
|
+
return ""
|
|
6250
|
+
# "!" flags that the tools index overflowed the cap and some tools are hidden.
|
|
6251
|
+
return f"mcp {len(self.session.mcp.tools)}{'!' if self.session.mcp.index_truncated else ''}"
|
|
5997
6252
|
|
|
5998
6253
|
def stress_after(self) -> float:
|
|
5999
6254
|
return max(30.0, self.session.config.provider.timeout * 0.5)
|
|
6000
6255
|
|
|
6001
|
-
@staticmethod
|
|
6002
|
-
def duration(seconds: float) -> str:
|
|
6003
|
-
minutes, rest = divmod(int(seconds), 60)
|
|
6004
|
-
return f"{seconds:.1f}s" if seconds < 60 else f"{minutes}m{rest:02d}s"
|
|
6005
|
-
|
|
6006
6256
|
|
|
6007
6257
|
class CommandLoop:
|
|
6008
6258
|
# Commands safe to run from the background queue-input thread while the agent works: read-only
|
|
@@ -6307,7 +6557,7 @@ Tools:
|
|
|
6307
6557
|
if not self.session.resumed:
|
|
6308
6558
|
return
|
|
6309
6559
|
self.session.resumed = False
|
|
6310
|
-
messages = [message for message in self.session.messages if
|
|
6560
|
+
messages = [message for message in self.session.messages if not SessionSnapshotCodec.is_internal_message(message) and message.get("role") != "tool"]
|
|
6311
6561
|
if not messages:
|
|
6312
6562
|
return
|
|
6313
6563
|
self.emit(f"Restored session: {self.session.uid}")
|
|
@@ -6315,15 +6565,6 @@ Tools:
|
|
|
6315
6565
|
for message in messages:
|
|
6316
6566
|
tool_record_index = self.render_transcript_message(message, tool_record_index)
|
|
6317
6567
|
|
|
6318
|
-
@staticmethod
|
|
6319
|
-
def is_resume_marker(message: Json) -> bool:
|
|
6320
|
-
return SessionSnapshotCodec.is_internal_message(message)
|
|
6321
|
-
|
|
6322
|
-
def should_render_resumed_message(self, message: Json) -> bool:
|
|
6323
|
-
if self.is_resume_marker(message):
|
|
6324
|
-
return False
|
|
6325
|
-
return message.get("role") != "tool"
|
|
6326
|
-
|
|
6327
6568
|
def render_transcript_message(self, message: Json, tool_record_index: int = 0) -> int:
|
|
6328
6569
|
role = str(message.get("role") or "")
|
|
6329
6570
|
content = str(message.get("content") or "").strip()
|
|
@@ -6359,10 +6600,18 @@ Tools:
|
|
|
6359
6600
|
return None
|
|
6360
6601
|
arguments = function.get("arguments")
|
|
6361
6602
|
try:
|
|
6362
|
-
|
|
6603
|
+
# strict=False tolerates literal newlines in argument strings (e.g. multi-line
|
|
6604
|
+
# git commit messages) that would otherwise be rejected as invalid JSON.
|
|
6605
|
+
payload = json.loads(arguments, strict=False) if isinstance(arguments, str) else (arguments or {})
|
|
6363
6606
|
except json.JSONDecodeError:
|
|
6364
6607
|
payload = {}
|
|
6365
|
-
|
|
6608
|
+
try:
|
|
6609
|
+
args = ModelClient.tool_payload(name, payload)
|
|
6610
|
+
except ToolError:
|
|
6611
|
+
# A malformed historical call (e.g. tool args that fail validation) must not crash
|
|
6612
|
+
# the resume; render it without parsed args.
|
|
6613
|
+
args = [payload] if payload else []
|
|
6614
|
+
return ToolCall(id=str(raw.get("id") or ""), name=name, args=args)
|
|
6366
6615
|
|
|
6367
6616
|
def transcript_tool_record(self, call: ToolCall, tool_record_index: int) -> tuple[ToolResultRecord | None, int]:
|
|
6368
6617
|
tool_class = TOOL_REGISTRY.get(call.name)
|
|
@@ -6386,6 +6635,11 @@ Tools:
|
|
|
6386
6635
|
notice = self.mcp_error_notice()
|
|
6387
6636
|
if notice:
|
|
6388
6637
|
self.emit(notice)
|
|
6638
|
+
# render once so index_truncated reflects the freshly discovered tools, then warn if
|
|
6639
|
+
# the index is too large to fit even as name-only (some tools are hidden from the model).
|
|
6640
|
+
self.session.mcp.render_tools_index()
|
|
6641
|
+
if self.session.mcp.index_truncated:
|
|
6642
|
+
self.emit("mcp: tools index exceeds the size budget; some tools are hidden from the model. Reduce enabled servers or run /mcp tools to see the full list.")
|
|
6389
6643
|
|
|
6390
6644
|
def mcp_error_notice(self) -> str:
|
|
6391
6645
|
errors = [
|
|
@@ -6426,7 +6680,7 @@ Tools:
|
|
|
6426
6680
|
|
|
6427
6681
|
def status_window(self, *, active: bool = False) -> Window:
|
|
6428
6682
|
return Window(
|
|
6429
|
-
FormattedTextControl(self.status_bar.
|
|
6683
|
+
FormattedTextControl(lambda: self.status_bar.display_fragments(active=active), style="class:bottom-toolbar.text"),
|
|
6430
6684
|
style="class:bottom-toolbar",
|
|
6431
6685
|
height=1,
|
|
6432
6686
|
dont_extend_height=True,
|
|
@@ -7003,16 +7257,6 @@ Tools:
|
|
|
7003
7257
|
self.emit(result + "\n")
|
|
7004
7258
|
return result
|
|
7005
7259
|
|
|
7006
|
-
def select_model(self, choices: tuple[str, ...]) -> str | object | None:
|
|
7007
|
-
current = self.session.config.provider.model
|
|
7008
|
-
labels = {label: label for label in self.MODEL_LABELS if label in choices}
|
|
7009
|
-
labels.update({current: current + " (current)"} if current in choices else {})
|
|
7010
|
-
return self.select_choice("Model", choices, labels=labels, current=current, disabled=self.MODEL_LABELS)
|
|
7011
|
-
|
|
7012
|
-
def select_provider(self, choices: tuple[str, ...]) -> str | object | None:
|
|
7013
|
-
current = self.session.config.active_provider
|
|
7014
|
-
return self.select_choice("Provider", choices, labels={current: current + " (current)"}, current=current)
|
|
7015
|
-
|
|
7016
7260
|
def select_reasoning(self) -> str | object | None:
|
|
7017
7261
|
current = self.session.config.provider.reasoning
|
|
7018
7262
|
labels = {"off": "off - disable reasoning"}
|
|
@@ -7072,7 +7316,7 @@ Tools:
|
|
|
7072
7316
|
state = self.session.state
|
|
7073
7317
|
known = ["- " + item for item in state.known] or ["- (empty)"]
|
|
7074
7318
|
return "\n".join(
|
|
7075
|
-
["goal: " + (state.goal or "(empty)"), "summary:", state.summary or "(empty)", "plan:", *state.
|
|
7319
|
+
["goal: " + (state.goal or "(empty)"), "summary:", state.summary or "(empty)", "plan:", *AgentState.plan_rows_for(state.plan, status=True, style="symbol"), "known:", *known]
|
|
7076
7320
|
)
|
|
7077
7321
|
|
|
7078
7322
|
def config(self, args: str) -> str:
|
|
@@ -7097,6 +7341,7 @@ Tools:
|
|
|
7097
7341
|
f"runtime.shell_timeout: {self.session.settings.shell_timeout}",
|
|
7098
7342
|
f"runtime.max_agent_steps: {self.session.settings.max_steps}",
|
|
7099
7343
|
f"runtime.max_context_tokens: {self.session.settings.max_context_tokens}",
|
|
7344
|
+
f"runtime.max_parallel_tools: {self.session.settings.max_parallel_tools}",
|
|
7100
7345
|
f"runtime.check_updates: {'on' if self.session.settings.check_updates else 'off'}",
|
|
7101
7346
|
f"runtime.update_check_interval_hours: {self.session.settings.update_check_interval_hours}",
|
|
7102
7347
|
f"runtime.session_retention_days: {self.session.settings.session_retention_days}",
|
|
@@ -7181,13 +7426,12 @@ Tools:
|
|
|
7181
7426
|
if parts:
|
|
7182
7427
|
return self.set_provider(parts[0])
|
|
7183
7428
|
choices = tuple(sorted(self.session.config.providers))
|
|
7429
|
+
summary = "provider: " + self.session.config.active_provider + "\nproviders: " + ", ".join(choices)
|
|
7184
7430
|
if len(choices) <= 1:
|
|
7185
|
-
return
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
7189
|
-
def provider_summary(self) -> str:
|
|
7190
|
-
return "provider: " + self.session.config.active_provider + "\nproviders: " + ", ".join(sorted(self.session.config.providers))
|
|
7431
|
+
return summary
|
|
7432
|
+
current = self.session.config.active_provider
|
|
7433
|
+
choice = self.select_choice("Provider", choices, labels={current: current + " (current)"}, current=current)
|
|
7434
|
+
return self.set_provider(choice) if isinstance(choice, str) else ("No change" if choice is SELECTION_BACK else summary)
|
|
7191
7435
|
|
|
7192
7436
|
def set_provider(self, name: str) -> str:
|
|
7193
7437
|
if name not in self.session.config.providers:
|
|
@@ -7202,11 +7446,22 @@ Tools:
|
|
|
7202
7446
|
if parts:
|
|
7203
7447
|
result = self.set_model(parts[0])
|
|
7204
7448
|
return "No change" if result is SELECTION_BACK else str(result)
|
|
7205
|
-
|
|
7449
|
+
provider = self.session.config.provider
|
|
7450
|
+
configured = tuple(dict.fromkeys(provider.available_models))
|
|
7451
|
+
remote = tuple(model for model in self.remote_models(provider) if model not in configured)
|
|
7452
|
+
choices: list[str] = []
|
|
7453
|
+
if configured:
|
|
7454
|
+
choices.extend((self.MODEL_CONFIGURED_LABEL, *configured))
|
|
7455
|
+
if remote:
|
|
7456
|
+
choices.extend((self.MODEL_DISCOVERED_LABEL, *remote))
|
|
7457
|
+
choices = tuple(choices)
|
|
7206
7458
|
if not choices:
|
|
7207
7459
|
return "Current provider.model is " + (self.session.config.provider.model or "(empty)")
|
|
7208
7460
|
while True:
|
|
7209
|
-
|
|
7461
|
+
current = self.session.config.provider.model
|
|
7462
|
+
labels = {label: label for label in self.MODEL_LABELS if label in choices}
|
|
7463
|
+
labels.update({current: current + " (current)"} if current in choices else {})
|
|
7464
|
+
choice = self.select_choice("Model", choices, labels=labels, current=current, disabled=self.MODEL_LABELS)
|
|
7210
7465
|
if choice is SELECTION_BACK:
|
|
7211
7466
|
return "No change"
|
|
7212
7467
|
if not isinstance(choice, str):
|
|
@@ -7218,17 +7473,6 @@ Tools:
|
|
|
7218
7473
|
continue
|
|
7219
7474
|
return str(result)
|
|
7220
7475
|
|
|
7221
|
-
def model_choices(self) -> tuple[str, ...]:
|
|
7222
|
-
provider = self.session.config.provider
|
|
7223
|
-
configured = tuple(dict.fromkeys(provider.available_models))
|
|
7224
|
-
remote = tuple(model for model in self.remote_models(provider) if model not in configured)
|
|
7225
|
-
choices: list[str] = []
|
|
7226
|
-
if configured:
|
|
7227
|
-
choices.extend((self.MODEL_CONFIGURED_LABEL, *configured))
|
|
7228
|
-
if remote:
|
|
7229
|
-
choices.extend((self.MODEL_DISCOVERED_LABEL, *remote))
|
|
7230
|
-
return tuple(choices)
|
|
7231
|
-
|
|
7232
7476
|
def remote_models(self, provider: ProviderConfig) -> tuple[str, ...]:
|
|
7233
7477
|
if not provider.url or not provider.key:
|
|
7234
7478
|
return ()
|
|
@@ -7308,6 +7552,8 @@ Tools:
|
|
|
7308
7552
|
runtime.max_context_tokens = max(1, int(value))
|
|
7309
7553
|
elif key == "runtime.shell_timeout":
|
|
7310
7554
|
runtime.shell_timeout = max(1, int(value))
|
|
7555
|
+
elif key == "runtime.max_parallel_tools":
|
|
7556
|
+
runtime.max_parallel_tools = max(1, int(value))
|
|
7311
7557
|
else:
|
|
7312
7558
|
return "Unknown config key: " + key
|
|
7313
7559
|
except (ConfigError, ValueError):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: A small terminal coding agent written in Python
|
|
5
5
|
Author-email: hit9 <hit9@icloud.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -22,13 +22,14 @@ Requires-Python: >=3.11
|
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: anthropic>=0.64.0
|
|
25
|
-
Requires-Dist: code-symbol-index>=0.3.
|
|
25
|
+
Requires-Dist: code-symbol-index>=0.3.1
|
|
26
26
|
Requires-Dist: json-repair
|
|
27
|
-
Requires-Dist: fastmcp<4,>=3
|
|
27
|
+
Requires-Dist: fastmcp-slim[client]<4,>=3
|
|
28
28
|
Requires-Dist: openai>=2.37.0
|
|
29
29
|
Requires-Dist: prompt-toolkit>=3.0
|
|
30
30
|
Requires-Dist: rich>=13.0
|
|
31
31
|
Requires-Dist: socksio>=1.0.0
|
|
32
|
+
Requires-Dist: websockets>=15.0.1
|
|
32
33
|
Provides-Extra: dev
|
|
33
34
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
34
35
|
Requires-Dist: ruff>=0.4.0; extra == "dev"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nanocode-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "A small terminal coding agent written in Python"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -28,13 +28,14 @@ classifiers = [
|
|
|
28
28
|
]
|
|
29
29
|
dependencies = [
|
|
30
30
|
"anthropic>=0.64.0",
|
|
31
|
-
"code-symbol-index>=0.3.
|
|
31
|
+
"code-symbol-index>=0.3.1",
|
|
32
32
|
"json-repair",
|
|
33
|
-
"fastmcp>=3,<4",
|
|
33
|
+
"fastmcp-slim[client]>=3,<4",
|
|
34
34
|
"openai>=2.37.0",
|
|
35
35
|
"prompt-toolkit>=3.0",
|
|
36
36
|
"rich>=13.0",
|
|
37
37
|
"socksio>=1.0.0",
|
|
38
|
+
"websockets>=15.0.1",
|
|
38
39
|
]
|
|
39
40
|
|
|
40
41
|
[project.urls]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|