nanocode-cli 0.7.0__tar.gz → 0.7.2__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.7.0/nanocode_cli.egg-info → nanocode_cli-0.7.2}/PKG-INFO +1 -1
- {nanocode_cli-0.7.0 → nanocode_cli-0.7.2}/nanocode.py +479 -110
- {nanocode_cli-0.7.0 → nanocode_cli-0.7.2/nanocode_cli.egg-info}/PKG-INFO +1 -1
- {nanocode_cli-0.7.0 → nanocode_cli-0.7.2}/pyproject.toml +1 -1
- {nanocode_cli-0.7.0 → nanocode_cli-0.7.2}/LICENSE +0 -0
- {nanocode_cli-0.7.0 → nanocode_cli-0.7.2}/MANIFEST.in +0 -0
- {nanocode_cli-0.7.0 → nanocode_cli-0.7.2}/README.md +0 -0
- {nanocode_cli-0.7.0 → nanocode_cli-0.7.2}/README.zh-CN.md +0 -0
- {nanocode_cli-0.7.0 → nanocode_cli-0.7.2}/nanocode_cli.egg-info/SOURCES.txt +0 -0
- {nanocode_cli-0.7.0 → nanocode_cli-0.7.2}/nanocode_cli.egg-info/dependency_links.txt +0 -0
- {nanocode_cli-0.7.0 → nanocode_cli-0.7.2}/nanocode_cli.egg-info/entry_points.txt +0 -0
- {nanocode_cli-0.7.0 → nanocode_cli-0.7.2}/nanocode_cli.egg-info/requires.txt +0 -0
- {nanocode_cli-0.7.0 → nanocode_cli-0.7.2}/nanocode_cli.egg-info/top_level.txt +0 -0
- {nanocode_cli-0.7.0 → nanocode_cli-0.7.2}/setup.cfg +0 -0
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import codecs
|
|
7
|
+
import contextlib
|
|
8
|
+
import copy
|
|
7
9
|
import difflib
|
|
8
10
|
import asyncio
|
|
9
11
|
import fnmatch
|
|
@@ -12,6 +14,7 @@ import json
|
|
|
12
14
|
import logging
|
|
13
15
|
import os
|
|
14
16
|
import platform
|
|
17
|
+
import random
|
|
15
18
|
import re
|
|
16
19
|
import selectors
|
|
17
20
|
import shlex
|
|
@@ -60,7 +63,7 @@ from rich.console import Console
|
|
|
60
63
|
from rich.markdown import Markdown
|
|
61
64
|
from rich.rule import Rule
|
|
62
65
|
|
|
63
|
-
__version__ = "0.7.
|
|
66
|
+
__version__ = "0.7.2"
|
|
64
67
|
|
|
65
68
|
Json = dict[str, Any]
|
|
66
69
|
HTTP_USER_AGENT = "nanocode/" + __version__
|
|
@@ -77,8 +80,9 @@ REASONING_LEVELS = ("minimal", "low", "medium", "high", "xhigh")
|
|
|
77
80
|
REASONING_CHOICES = ("off", *REASONING_LEVELS)
|
|
78
81
|
CHAT_REASONING_CHOICES = ("auto", "off", "reasoning", "reasoning_effort", "thinking", "enable_thinking")
|
|
79
82
|
ANTHROPIC_DEFAULT_MAX_TOKENS = 16_384
|
|
83
|
+
DEEPSEEK_DEFAULT_MAX_TOKENS = 32_768
|
|
80
84
|
CHAT_REASONING_EFFORT_VALUES: dict[str, dict[str, str | int]] = {
|
|
81
|
-
"thinking": {"minimal": "high", "low": "high", "medium": "high", "high": "
|
|
85
|
+
"thinking": {"minimal": "high", "low": "high", "medium": "high", "high": "max", "xhigh": "max"},
|
|
82
86
|
"enable_thinking": {"minimal": 256, "low": 1024, "medium": 4096, "high": 8192, "xhigh": 16384},
|
|
83
87
|
}
|
|
84
88
|
SELECTION_BACK = object()
|
|
@@ -139,10 +143,10 @@ class ProviderConfig:
|
|
|
139
143
|
("thinking", ("deepseek-v4",)),
|
|
140
144
|
)
|
|
141
145
|
PROFILES: ClassVar[dict[str, dict[str, Any]]] = {
|
|
142
|
-
"api.openai.com": {"chat_reasoning_rules": (("reasoning_effort", ("o1", "o3", "o4", "gpt-5")),)},
|
|
146
|
+
"api.openai.com": {"chat_reasoning_rules": (("reasoning_effort", ("o1", "o3", "o4", "gpt-5")),), "strict_tools": True},
|
|
143
147
|
"openrouter.ai": {"chat_reasoning": "reasoning"},
|
|
144
148
|
"opencode.ai": {"api_rules": (("anthropic", ("claude-", "qwen3.")),), "chat_reasoning_rules": (("reasoning", ("deepseek-v4",)),)},
|
|
145
|
-
"api.deepseek.com": {"chat_reasoning": "thinking"},
|
|
149
|
+
"api.deepseek.com": {"chat_reasoning": "thinking", "max_tokens": DEEPSEEK_DEFAULT_MAX_TOKENS, "prompt_cache_key": False, "strict_tools": True, "strict_beta": True},
|
|
146
150
|
"dashscope.aliyuncs.com": {"chat_reasoning_rules": ALIYUN_CHAT_REASONING_RULES},
|
|
147
151
|
"dashscope-intl.aliyuncs.com": {"chat_reasoning_rules": ALIYUN_CHAT_REASONING_RULES},
|
|
148
152
|
"dashscope-us.aliyuncs.com": {"chat_reasoning_rules": ALIYUN_CHAT_REASONING_RULES},
|
|
@@ -155,6 +159,8 @@ class ProviderConfig:
|
|
|
155
159
|
prompt_cache_key: str = "auto"
|
|
156
160
|
available_models: tuple[str, ...] = ()
|
|
157
161
|
temperature: float | None = None
|
|
162
|
+
max_tokens: int = 0
|
|
163
|
+
strict_tools: bool = False
|
|
158
164
|
reasoning: str = "medium"
|
|
159
165
|
chat_reasoning: str = "auto"
|
|
160
166
|
timeout: int = 180
|
|
@@ -180,20 +186,30 @@ class ProviderConfig:
|
|
|
180
186
|
prompt_cache_key=prompt_cache_key,
|
|
181
187
|
available_models=Config.str_tuple(data, "available_models"),
|
|
182
188
|
temperature=Config.float(data, "temperature", None),
|
|
189
|
+
max_tokens=max(0, Config.int(data, "max_tokens", 0)),
|
|
190
|
+
strict_tools=Config.bool(data, "strict_tools", False),
|
|
183
191
|
reasoning=reasoning,
|
|
184
192
|
chat_reasoning=chat_reasoning,
|
|
185
193
|
timeout=Config.int(data, "timeout", 180),
|
|
186
194
|
)
|
|
187
195
|
|
|
188
|
-
def
|
|
196
|
+
def _stripped_url(self) -> str:
|
|
189
197
|
url = self.url.rstrip("/")
|
|
190
198
|
for suffix in ("/chat/completions", "/responses", "/messages"):
|
|
191
199
|
if url.endswith(suffix):
|
|
192
200
|
return url[: -len(suffix)]
|
|
193
201
|
return url
|
|
194
202
|
|
|
203
|
+
def base_url(self) -> str:
|
|
204
|
+
url = self._stripped_url()
|
|
205
|
+
# Strict tool calling is a beta feature on some hosts (DeepSeek); route to /beta only
|
|
206
|
+
# when strict is actually active, so non-strict users stay on the stable endpoint.
|
|
207
|
+
if self.resolved_strict_tools() and (self.PROFILES.get(self.host()) or {}).get("strict_beta") and not url.endswith("/beta"):
|
|
208
|
+
url = url + "/beta"
|
|
209
|
+
return url
|
|
210
|
+
|
|
195
211
|
def host(self) -> str:
|
|
196
|
-
return (urlparse(self.
|
|
212
|
+
return (urlparse(self._stripped_url()).hostname or "").lower()
|
|
197
213
|
|
|
198
214
|
def resolved_chat_reasoning(self) -> str:
|
|
199
215
|
return self.profile_value(self.chat_reasoning, "off", "chat_reasoning", "chat_reasoning_rules")
|
|
@@ -216,6 +232,25 @@ class ProviderConfig:
|
|
|
216
232
|
def reasoning_effort(self) -> str:
|
|
217
233
|
return self.reasoning if self.reasoning in REASONING_LEVELS else "medium"
|
|
218
234
|
|
|
235
|
+
def resolved_max_tokens(self) -> int:
|
|
236
|
+
if self.max_tokens > 0:
|
|
237
|
+
return self.max_tokens
|
|
238
|
+
# No global default: generic OpenAI-compatible providers keep their own server-side cap.
|
|
239
|
+
# Only profiles that opt in (e.g. DeepSeek thinking mode) get an explicit ceiling.
|
|
240
|
+
return int((self.PROFILES.get(self.host()) or {}).get("max_tokens", 0))
|
|
241
|
+
|
|
242
|
+
def supports_prompt_cache_key(self) -> bool:
|
|
243
|
+
# Default on for unknown OpenAI-compatible hosts (status quo); profiles opt out
|
|
244
|
+
# (e.g. DeepSeek caches automatically by prefix and ignores the key).
|
|
245
|
+
return bool((self.PROFILES.get(self.host()) or {}).get("prompt_cache_key", True))
|
|
246
|
+
|
|
247
|
+
def supports_strict_tools(self) -> bool:
|
|
248
|
+
return bool((self.PROFILES.get(self.host()) or {}).get("strict_tools"))
|
|
249
|
+
|
|
250
|
+
def resolved_strict_tools(self) -> bool:
|
|
251
|
+
# Only emit strict schemas on the chat path of a host known to support strict mode.
|
|
252
|
+
return self.strict_tools and self.supports_strict_tools() and self.resolved_api() == "chat"
|
|
253
|
+
|
|
219
254
|
@staticmethod
|
|
220
255
|
def clean_prompt_cache_key(value: str) -> str:
|
|
221
256
|
value = value.strip()
|
|
@@ -242,6 +277,7 @@ class RuntimeSettings:
|
|
|
242
277
|
mcp_selector: str = ""
|
|
243
278
|
yolo: bool = False
|
|
244
279
|
debug: bool = False
|
|
280
|
+
tips: bool = True
|
|
245
281
|
|
|
246
282
|
@classmethod
|
|
247
283
|
def from_dict(cls, data: Json, *, yolo: bool = False, debug: bool = False, mcp_selector: str = "") -> "RuntimeSettings":
|
|
@@ -257,6 +293,7 @@ class RuntimeSettings:
|
|
|
257
293
|
mcp_selector=mcp_selector,
|
|
258
294
|
yolo=yolo or Config.bool(runtime, "yolo", False),
|
|
259
295
|
debug=debug or Config.bool(runtime, "debug", False),
|
|
296
|
+
tips=Config.bool(runtime, "tips", True),
|
|
260
297
|
)
|
|
261
298
|
|
|
262
299
|
|
|
@@ -350,6 +387,8 @@ api = "auto"
|
|
|
350
387
|
prompt_cache_key = "auto"
|
|
351
388
|
# available_models = ["gpt-5", "gpt-5-mini"]
|
|
352
389
|
# temperature = 0.2
|
|
390
|
+
# max_tokens = 16384
|
|
391
|
+
# strict_tools = false # constrain tool-call args to the schema (OpenAI / DeepSeek beta)
|
|
353
392
|
reasoning = "medium"
|
|
354
393
|
# chat_reasoning = "auto"
|
|
355
394
|
timeout = 180
|
|
@@ -366,6 +405,7 @@ check_updates = true
|
|
|
366
405
|
update_check_interval_hours = 24
|
|
367
406
|
session_retention_days = 7
|
|
368
407
|
yolo = false
|
|
408
|
+
tips = true
|
|
369
409
|
|
|
370
410
|
# [mcp.example]
|
|
371
411
|
# url = "https://example.com/mcp"
|
|
@@ -490,6 +530,7 @@ class AgentState:
|
|
|
490
530
|
code_index_error: str = ""
|
|
491
531
|
code_index_notice: str = ""
|
|
492
532
|
code_index_refreshing: bool = False
|
|
533
|
+
code_index_checking: bool = False
|
|
493
534
|
context_percent: int = 0
|
|
494
535
|
turn_step: int = 0
|
|
495
536
|
turn_tool_calls: int = 0
|
|
@@ -528,7 +569,9 @@ class AgentState:
|
|
|
528
569
|
|
|
529
570
|
def format(self) -> str:
|
|
530
571
|
known = ["- " + item for item in self.known] or ["- (empty)"]
|
|
531
|
-
return "\n".join(
|
|
572
|
+
return "\n".join(
|
|
573
|
+
["Goal: " + (self.goal or "(empty)"), "Plan:", *self.plan_rows_for(self.plan), "Known:", *known, "Check: " + (self.check or "(empty)")]
|
|
574
|
+
)
|
|
532
575
|
|
|
533
576
|
|
|
534
577
|
@dataclass
|
|
@@ -915,16 +958,12 @@ class SessionSnapshotCodec:
|
|
|
915
958
|
@staticmethod
|
|
916
959
|
def tool_records(data: list[Json]) -> list[ToolResultRecord]:
|
|
917
960
|
return [
|
|
918
|
-
ToolResultRecord(key=rec["key"], name=rec["name"], args=rec.get("args", []), output=rec.get("output", ""), note=rec.get("note", ""))
|
|
919
|
-
for rec in data
|
|
961
|
+
ToolResultRecord(key=rec["key"], name=rec["name"], args=rec.get("args", []), output=rec.get("output", ""), note=rec.get("note", "")) for rec in data
|
|
920
962
|
]
|
|
921
963
|
|
|
922
964
|
@staticmethod
|
|
923
965
|
def tool_errors(data: list[Json]) -> list[ToolErrorRecord]:
|
|
924
|
-
return [
|
|
925
|
-
ToolErrorRecord(key=err["key"], name=err["name"], args=err.get("args", []), error=err.get("error", ""))
|
|
926
|
-
for err in data
|
|
927
|
-
]
|
|
966
|
+
return [ToolErrorRecord(key=err["key"], name=err["name"], args=err.get("args", []), error=err.get("error", "")) for err in data]
|
|
928
967
|
|
|
929
968
|
|
|
930
969
|
class SessionSnapshotStore:
|
|
@@ -1246,6 +1285,59 @@ class UpdateChecker:
|
|
|
1246
1285
|
)
|
|
1247
1286
|
|
|
1248
1287
|
|
|
1288
|
+
def strict_tool_schema(schema: Json) -> Json:
|
|
1289
|
+
"""Rewrite a JSON Schema to satisfy strict function-calling (OpenAI / DeepSeek beta):
|
|
1290
|
+
every object property becomes required (genuine optionals turned nullable),
|
|
1291
|
+
additionalProperties is forced false, and unsupported keywords are dropped."""
|
|
1292
|
+
|
|
1293
|
+
def transform(node: Any) -> Any:
|
|
1294
|
+
if isinstance(node, list):
|
|
1295
|
+
return [transform(item) for item in node]
|
|
1296
|
+
if not isinstance(node, dict):
|
|
1297
|
+
return node
|
|
1298
|
+
node = {key: transform(value) for key, value in node.items() if key not in ("minItems", "maxItems", "minLength", "maxLength")}
|
|
1299
|
+
if isinstance(node.get("properties"), dict):
|
|
1300
|
+
required = set(node.get("required") or [])
|
|
1301
|
+
for key, sub in node["properties"].items():
|
|
1302
|
+
if key not in required and isinstance(sub, dict):
|
|
1303
|
+
node["properties"][key] = nullable(sub)
|
|
1304
|
+
node["required"] = list(node["properties"].keys())
|
|
1305
|
+
node["additionalProperties"] = False
|
|
1306
|
+
return node
|
|
1307
|
+
|
|
1308
|
+
# Strict validators only allow scalar types in a `type` union; object/array nullability
|
|
1309
|
+
# must be expressed with anyOf instead (e.g. {"anyOf": [<array schema>, {"type": "null"}]}).
|
|
1310
|
+
scalars = ("string", "number", "integer", "boolean")
|
|
1311
|
+
|
|
1312
|
+
def nullable(sub: Json) -> Json:
|
|
1313
|
+
kind = sub.get("type")
|
|
1314
|
+
if isinstance(kind, str) and kind in scalars:
|
|
1315
|
+
sub["type"] = [kind, "null"]
|
|
1316
|
+
elif isinstance(kind, list) and all(item in (*scalars, "null") for item in kind):
|
|
1317
|
+
if "null" not in kind:
|
|
1318
|
+
sub["type"] = [*kind, "null"]
|
|
1319
|
+
else:
|
|
1320
|
+
return {"anyOf": [sub, {"type": "null"}]}
|
|
1321
|
+
# An enum must accept null too, otherwise strict validation rejects the "omitted" value.
|
|
1322
|
+
if isinstance(sub.get("enum"), list) and None not in sub["enum"]:
|
|
1323
|
+
sub["enum"] = [*sub["enum"], None]
|
|
1324
|
+
return sub
|
|
1325
|
+
|
|
1326
|
+
return transform(copy.deepcopy(schema))
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def strictifiable(schema: Any) -> bool:
|
|
1330
|
+
"""False if the schema contains a free-form object (an `object` with no `properties`),
|
|
1331
|
+
which strict function calling cannot represent — such tools fall back to non-strict."""
|
|
1332
|
+
if isinstance(schema, dict):
|
|
1333
|
+
if schema.get("type") == "object" and "properties" not in schema:
|
|
1334
|
+
return False
|
|
1335
|
+
return all(strictifiable(value) for value in schema.values())
|
|
1336
|
+
if isinstance(schema, list):
|
|
1337
|
+
return all(strictifiable(item) for item in schema)
|
|
1338
|
+
return True
|
|
1339
|
+
|
|
1340
|
+
|
|
1249
1341
|
class Tool:
|
|
1250
1342
|
NAME: ClassVar[str] = ""
|
|
1251
1343
|
DESCRIPTION: ClassVar[str] = ""
|
|
@@ -1261,9 +1353,13 @@ class Tool:
|
|
|
1261
1353
|
self.args = args
|
|
1262
1354
|
|
|
1263
1355
|
@classmethod
|
|
1264
|
-
def schema(cls) -> Json:
|
|
1356
|
+
def schema(cls, strict: bool = False) -> Json:
|
|
1265
1357
|
description = "\n".join([cls.DESCRIPTION, "Signature: " + cls.SIGNATURE, *(("- " + item) for item in cls.EXAMPLE if item)])
|
|
1266
|
-
|
|
1358
|
+
function: Json = {"name": cls.NAME, "description": description, "parameters": cls.params_schema()}
|
|
1359
|
+
if strict and strictifiable(function["parameters"]):
|
|
1360
|
+
function["parameters"] = strict_tool_schema(function["parameters"])
|
|
1361
|
+
function["strict"] = True
|
|
1362
|
+
return {"type": "function", "function": function}
|
|
1267
1363
|
|
|
1268
1364
|
@classmethod
|
|
1269
1365
|
def params_schema(cls) -> Json:
|
|
@@ -1379,7 +1475,10 @@ class ReadTool(Tool):
|
|
|
1379
1475
|
def arg_schema(cls) -> Json:
|
|
1380
1476
|
return {
|
|
1381
1477
|
"type": "object",
|
|
1382
|
-
"properties": {
|
|
1478
|
+
"properties": {
|
|
1479
|
+
"path": {"type": "string", "description": "File path to read"},
|
|
1480
|
+
"ranges": {"type": "array", "minItems": 1, "items": cls.RANGE_SCHEMA, "description": "Line ranges [[start,end],...], 0-based and end-exclusive; omit to read the whole file"},
|
|
1481
|
+
},
|
|
1383
1482
|
"required": ["path"],
|
|
1384
1483
|
"additionalProperties": False,
|
|
1385
1484
|
}
|
|
@@ -1389,9 +1488,9 @@ class ReadTool(Tool):
|
|
|
1389
1488
|
return {
|
|
1390
1489
|
"type": "object",
|
|
1391
1490
|
"properties": {
|
|
1392
|
-
"path": {"type": "string"},
|
|
1393
|
-
"ranges": {"type": "array", "items": cls.RANGE_SCHEMA, "minItems": 1},
|
|
1394
|
-
"files": {"type": "array", "items": cls.arg_schema(), "minItems": 1},
|
|
1491
|
+
"path": {"type": "string", "description": "File path to read (single-file form)"},
|
|
1492
|
+
"ranges": {"type": "array", "items": cls.RANGE_SCHEMA, "minItems": 1, "description": "Line ranges [[start,end],...], 0-based and end-exclusive; omit to read the whole file"},
|
|
1493
|
+
"files": {"type": "array", "items": cls.arg_schema(), "minItems": 1, "description": "Batch form: list of {path, ranges} to read several files in one call"},
|
|
1395
1494
|
},
|
|
1396
1495
|
"additionalProperties": False,
|
|
1397
1496
|
}
|
|
@@ -1489,7 +1588,7 @@ class LineCountTool(Tool):
|
|
|
1489
1588
|
def params_schema(cls) -> Json:
|
|
1490
1589
|
return {
|
|
1491
1590
|
"type": "object",
|
|
1492
|
-
"properties": {"paths": {"type": "array", "items": {"type": "string"}, "minItems": 1}},
|
|
1591
|
+
"properties": {"paths": {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "File paths to count lines for"}},
|
|
1493
1592
|
"required": ["paths"],
|
|
1494
1593
|
"additionalProperties": False,
|
|
1495
1594
|
}
|
|
@@ -1534,7 +1633,15 @@ class ListTool(Tool):
|
|
|
1534
1633
|
|
|
1535
1634
|
@classmethod
|
|
1536
1635
|
def params_schema(cls) -> Json:
|
|
1537
|
-
return {
|
|
1636
|
+
return {
|
|
1637
|
+
"type": "object",
|
|
1638
|
+
"properties": {
|
|
1639
|
+
"path": {"type": "string", "description": "Directory to list"},
|
|
1640
|
+
"glob": {"type": "string", "description": "Optional glob filtering child names (non-recursive), e.g. test_*.py"},
|
|
1641
|
+
},
|
|
1642
|
+
"required": ["path"],
|
|
1643
|
+
"additionalProperties": False,
|
|
1644
|
+
}
|
|
1538
1645
|
|
|
1539
1646
|
@classmethod
|
|
1540
1647
|
def payload_args(cls, payload: Json) -> list[Any]:
|
|
@@ -1599,10 +1706,10 @@ class FindTool(Tool):
|
|
|
1599
1706
|
return {
|
|
1600
1707
|
"type": "object",
|
|
1601
1708
|
"properties": {
|
|
1602
|
-
"name": {"type": "string"},
|
|
1603
|
-
"path": {"type": "string"},
|
|
1604
|
-
"type": {"type": "string", "enum": ["file", "dir", "any"]},
|
|
1605
|
-
"limit": {"type": "integer", "minimum": 1, "maximum": cls.MAX_LIMIT},
|
|
1709
|
+
"name": {"type": "string", "description": "Glob or exact name to match, e.g. *.py or migrations"},
|
|
1710
|
+
"path": {"type": "string", "description": "Directory to search under; defaults to repo root"},
|
|
1711
|
+
"type": {"type": "string", "enum": ["file", "dir", "any"], "description": "Match files, dirs, or any; default file"},
|
|
1712
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": cls.MAX_LIMIT, "description": f"Max results, 1..{cls.MAX_LIMIT}; default 100"},
|
|
1606
1713
|
},
|
|
1607
1714
|
"required": ["name"],
|
|
1608
1715
|
"additionalProperties": False,
|
|
@@ -1611,7 +1718,7 @@ class FindTool(Tool):
|
|
|
1611
1718
|
@classmethod
|
|
1612
1719
|
def params_schema(cls) -> Json:
|
|
1613
1720
|
props = dict(cls.arg_schema()["properties"])
|
|
1614
|
-
props["queries"] = {"type": "array", "items": cls.arg_schema(), "minItems": 1}
|
|
1721
|
+
props["queries"] = {"type": "array", "items": cls.arg_schema(), "minItems": 1, "description": "Batch form: list of find queries to run in one call"}
|
|
1615
1722
|
return {"type": "object", "properties": props, "additionalProperties": False}
|
|
1616
1723
|
|
|
1617
1724
|
@classmethod
|
|
@@ -1717,10 +1824,10 @@ class SearchTool(Tool):
|
|
|
1717
1824
|
return {
|
|
1718
1825
|
"type": "object",
|
|
1719
1826
|
"properties": {
|
|
1720
|
-
"pattern": {"type": "string"},
|
|
1721
|
-
"path": {"type": "string"},
|
|
1722
|
-
"glob": {"type": "string"},
|
|
1723
|
-
"context": {"type": "integer", "minimum": 0, "maximum": cls.MAX_CONTEXT},
|
|
1827
|
+
"pattern": {"type": "string", "description": "Case-insensitive regex; alternation A|B|C is allowed"},
|
|
1828
|
+
"path": {"type": "string", "description": "File or directory to search under; defaults to repo root"},
|
|
1829
|
+
"glob": {"type": "string", "description": "Optional glob limiting which files are searched, e.g. *.py"},
|
|
1830
|
+
"context": {"type": "integer", "minimum": 0, "maximum": cls.MAX_CONTEXT, "description": f"Context lines around each match, 0..{cls.MAX_CONTEXT}"},
|
|
1724
1831
|
},
|
|
1725
1832
|
"required": ["pattern"],
|
|
1726
1833
|
"additionalProperties": False,
|
|
@@ -1729,7 +1836,7 @@ class SearchTool(Tool):
|
|
|
1729
1836
|
@classmethod
|
|
1730
1837
|
def params_schema(cls) -> Json:
|
|
1731
1838
|
props = dict(cls.arg_schema()["properties"])
|
|
1732
|
-
props["queries"] = {"type": "array", "items": cls.arg_schema(), "minItems": 1}
|
|
1839
|
+
props["queries"] = {"type": "array", "items": cls.arg_schema(), "minItems": 1, "description": "Batch form: list of search queries to run in one call"}
|
|
1733
1840
|
return {"type": "object", "properties": props, "additionalProperties": False}
|
|
1734
1841
|
|
|
1735
1842
|
@classmethod
|
|
@@ -2001,6 +2108,25 @@ class CodeIndex:
|
|
|
2001
2108
|
return ""
|
|
2002
2109
|
return self.update([self.session.resolve_path(path) for path in files])
|
|
2003
2110
|
|
|
2111
|
+
def update_pending_async(self) -> None:
|
|
2112
|
+
"""Run the working-tree check (and any auto-update) off the UI critical path.
|
|
2113
|
+
|
|
2114
|
+
``update_pending`` does a ``check=True`` scan that walks/hashes the tree — slow on
|
|
2115
|
+
large repos. Running it inline blocks answer emission and /status, so spawn it in a
|
|
2116
|
+
daemon thread. Guarded so only one scan/update runs at a time.
|
|
2117
|
+
"""
|
|
2118
|
+
if self.session.state.code_index_checking or self.session.state.code_index_refreshing:
|
|
2119
|
+
return
|
|
2120
|
+
self.session.state.code_index_checking = True
|
|
2121
|
+
|
|
2122
|
+
def run() -> None:
|
|
2123
|
+
try:
|
|
2124
|
+
self.update_pending()
|
|
2125
|
+
finally:
|
|
2126
|
+
self.session.state.code_index_checking = False
|
|
2127
|
+
|
|
2128
|
+
threading.Thread(target=run, daemon=True).start()
|
|
2129
|
+
|
|
2004
2130
|
def refresh_existing_async(self) -> bool:
|
|
2005
2131
|
if self.session.state.code_index_refreshing:
|
|
2006
2132
|
return False
|
|
@@ -2055,18 +2181,18 @@ class InspectCodeTool(Tool):
|
|
|
2055
2181
|
@classmethod
|
|
2056
2182
|
def params_schema(cls) -> Json:
|
|
2057
2183
|
props = {
|
|
2058
|
-
"mode": {"type": "string", "enum": list(cls.MODES)},
|
|
2059
|
-
"target": {"type": "string"},
|
|
2060
|
-
"limit": {"type": "integer", "minimum": 1, "maximum": cls.MAX_OUTLINE_LIMIT},
|
|
2061
|
-
"kind": {"type": "string"},
|
|
2062
|
-
"path": {"type": "string"},
|
|
2063
|
-
"symbol": {"type": "string"},
|
|
2064
|
-
"exact_only": {"type": "boolean"},
|
|
2065
|
-
"depth": {"type": "integer", "minimum": 1, "maximum": cls.MAX_DEPTH},
|
|
2066
|
-
"offset": {"type": "integer", "minimum": 0},
|
|
2067
|
-
"all_kinds": {"type": "boolean"},
|
|
2068
|
-
"ref_kind": {"type": "string"},
|
|
2069
|
-
"loose": {"type": "boolean"},
|
|
2184
|
+
"mode": {"type": "string", "enum": list(cls.MODES), "description": "Query type: find|inspect|outline|refs|impls|callers|callees"},
|
|
2185
|
+
"target": {"type": "string", "description": "Symbol name (find/inspect/refs/impls/callers/callees) or file path (outline)"},
|
|
2186
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": cls.MAX_OUTLINE_LIMIT, "description": "Max results"},
|
|
2187
|
+
"kind": {"type": "string", "description": "Restrict to a symbol kind, e.g. function, class, method"},
|
|
2188
|
+
"path": {"type": "string", "description": "Restrict the search to this file or directory"},
|
|
2189
|
+
"symbol": {"type": "string", "description": "Disambiguate target when multiple symbols share a name"},
|
|
2190
|
+
"exact_only": {"type": "boolean", "description": "Match the target name exactly instead of fuzzily"},
|
|
2191
|
+
"depth": {"type": "integer", "minimum": 1, "maximum": cls.MAX_DEPTH, "description": "Call-chain depth for callers/callees"},
|
|
2192
|
+
"offset": {"type": "integer", "minimum": 0, "description": "Pagination offset for refs/impls"},
|
|
2193
|
+
"all_kinds": {"type": "boolean", "description": "Include all reference kinds, not just behavioral ones (refs)"},
|
|
2194
|
+
"ref_kind": {"type": "string", "description": "Restrict refs to a specific reference kind"},
|
|
2195
|
+
"loose": {"type": "boolean", "description": "Loosen call-chain matching (callees)"},
|
|
2070
2196
|
}
|
|
2071
2197
|
return {"type": "object", "properties": props, "required": ["mode", "target"], "additionalProperties": False}
|
|
2072
2198
|
|
|
@@ -2190,13 +2316,23 @@ class EditTool(Tool):
|
|
|
2190
2316
|
def params_schema(cls) -> Json:
|
|
2191
2317
|
edit = {
|
|
2192
2318
|
"type": "object",
|
|
2193
|
-
"properties": {
|
|
2319
|
+
"properties": {
|
|
2320
|
+
"op": {"type": "string", "description": "create|replace|delete|insert_before|insert_after|replace_all"},
|
|
2321
|
+
"start": {"type": "string", "description": "Start anchor line:hash (inclusive) for replace/delete/insert"},
|
|
2322
|
+
"end": {"type": "string", "description": "End anchor line:hash (inclusive) for replace/delete"},
|
|
2323
|
+
"content": {"type": "string", "description": "New text for create/replace/insert"},
|
|
2324
|
+
"old": {"type": "string", "description": "Text to find for replace_all"},
|
|
2325
|
+
"new": {"type": "string", "description": "Replacement text for replace_all"},
|
|
2326
|
+
},
|
|
2194
2327
|
"required": ["op"],
|
|
2195
2328
|
"additionalProperties": False,
|
|
2196
2329
|
}
|
|
2197
2330
|
return {
|
|
2198
2331
|
"type": "object",
|
|
2199
|
-
"properties": {
|
|
2332
|
+
"properties": {
|
|
2333
|
+
"path": {"type": "string", "description": "File to create or patch"},
|
|
2334
|
+
"edits": {"type": "array", "items": edit, "minItems": 1, "description": "Ordered edit operations to apply"},
|
|
2335
|
+
},
|
|
2200
2336
|
"required": ["path", "edits"],
|
|
2201
2337
|
"additionalProperties": False,
|
|
2202
2338
|
}
|
|
@@ -2445,7 +2581,7 @@ class BashTool(Tool):
|
|
|
2445
2581
|
def params_schema(cls) -> Json:
|
|
2446
2582
|
return {
|
|
2447
2583
|
"type": "object",
|
|
2448
|
-
"properties": {"command": {"type": "string", "minLength": 1, "pattern": "^.*\\S.*$"}},
|
|
2584
|
+
"properties": {"command": {"type": "string", "minLength": 1, "pattern": "^.*\\S.*$", "description": "Bash command to run in the workspace; filter noisy output with head/tail/rg"}},
|
|
2449
2585
|
"required": ["command"],
|
|
2450
2586
|
"additionalProperties": False,
|
|
2451
2587
|
}
|
|
@@ -2565,7 +2701,9 @@ class BashTool(Tool):
|
|
|
2565
2701
|
|
|
2566
2702
|
class GitTool(Tool):
|
|
2567
2703
|
NAME = "Git"
|
|
2568
|
-
DESCRIPTION =
|
|
2704
|
+
DESCRIPTION = (
|
|
2705
|
+
"Run git with explicit argv (default: cwd from Environment; use cwd= for other directories). For add, pass explicit file paths; broad add is rejected."
|
|
2706
|
+
)
|
|
2569
2707
|
SIGNATURE = "Git(argv=[command,...], cwd?)"
|
|
2570
2708
|
EXAMPLE = (
|
|
2571
2709
|
'Status. Example: {"argv":["status","--short"]}',
|
|
@@ -2580,7 +2718,10 @@ class GitTool(Tool):
|
|
|
2580
2718
|
def params_schema(cls) -> Json:
|
|
2581
2719
|
return {
|
|
2582
2720
|
"type": "object",
|
|
2583
|
-
"properties": {
|
|
2721
|
+
"properties": {
|
|
2722
|
+
"cwd": {"type": "string", "description": "Working directory for the git command; defaults to repo root"},
|
|
2723
|
+
"argv": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1, "description": "Git command and args as a list, e.g. [\"status\",\"--short\"]"},
|
|
2724
|
+
},
|
|
2584
2725
|
"required": ["argv"],
|
|
2585
2726
|
"additionalProperties": False,
|
|
2586
2727
|
}
|
|
@@ -2589,11 +2730,7 @@ class GitTool(Tool):
|
|
|
2589
2730
|
def payload_args(cls, payload: Json) -> list[Any]:
|
|
2590
2731
|
argv = payload.get("argv")
|
|
2591
2732
|
if not isinstance(argv, list) or not argv:
|
|
2592
|
-
raise ToolError(
|
|
2593
|
-
"Git requires a non-empty 'argv' list. "
|
|
2594
|
-
'Signature: Git(argv=[command,...], cwd?) '
|
|
2595
|
-
'Example: {"argv":["status","--short"]}'
|
|
2596
|
-
)
|
|
2733
|
+
raise ToolError('Git requires a non-empty \'argv\' list. Signature: Git(argv=[command,...], cwd?) Example: {"argv":["status","--short"]}')
|
|
2597
2734
|
argv = [str(a) for a in argv]
|
|
2598
2735
|
return [("cwd=" + str(payload["cwd"])), *argv] if payload.get("cwd") else argv
|
|
2599
2736
|
|
|
@@ -2695,8 +2832,8 @@ class RecallTool(Tool):
|
|
|
2695
2832
|
return {
|
|
2696
2833
|
"type": "object",
|
|
2697
2834
|
"properties": {
|
|
2698
|
-
"keys": {"type": "array", "items": {"type": "string", "pattern": "^tr\\.\\d+$"}, "minItems": 1},
|
|
2699
|
-
"ranges": {"type": "array", "items": cls.RANGE_SCHEMA, "minItems": 1},
|
|
2835
|
+
"keys": {"type": "array", "items": {"type": "string", "pattern": "^tr\\.\\d+$"}, "minItems": 1, "description": "Stored result keys to recall, e.g. [\"tr.3\",\"tr.5\"]"},
|
|
2836
|
+
"ranges": {"type": "array", "items": cls.RANGE_SCHEMA, "minItems": 1, "description": "Optional 0-based [start,end] output-line slices to limit recalled context"},
|
|
2700
2837
|
},
|
|
2701
2838
|
"required": ["keys"],
|
|
2702
2839
|
"additionalProperties": False,
|
|
@@ -2776,16 +2913,24 @@ class NoteTool(Tool):
|
|
|
2776
2913
|
|
|
2777
2914
|
@classmethod
|
|
2778
2915
|
def params_schema(cls) -> Json:
|
|
2779
|
-
strings = {"type": "array", "items": {"type": "string"}}
|
|
2780
2916
|
plan_item = {
|
|
2781
2917
|
"type": "object",
|
|
2782
|
-
"properties": {
|
|
2918
|
+
"properties": {
|
|
2919
|
+
"status": {"type": "string", "enum": list(PlanItem.STATUSES), "description": "todo|doing|done|blocked"},
|
|
2920
|
+
"text": {"type": "string", "description": "Plan step description"},
|
|
2921
|
+
},
|
|
2783
2922
|
"required": ["status", "text"],
|
|
2784
2923
|
"additionalProperties": False,
|
|
2785
2924
|
}
|
|
2786
2925
|
return {
|
|
2787
2926
|
"type": "object",
|
|
2788
|
-
"properties": {
|
|
2927
|
+
"properties": {
|
|
2928
|
+
"set_goal": {"type": "string", "description": "Replace the current goal"},
|
|
2929
|
+
"replace_plan": {"type": "array", "items": plan_item, "description": "Replace the plan with these status/text items"},
|
|
2930
|
+
"append_known": {"type": "array", "items": {"type": "string"}, "description": "Append these facts to known"},
|
|
2931
|
+
"replace_known": {"type": "array", "items": {"type": "string"}, "description": "Replace all known facts with these"},
|
|
2932
|
+
"set_check": {"type": "string", "description": "Replace the success/verification criteria"},
|
|
2933
|
+
},
|
|
2789
2934
|
"additionalProperties": False,
|
|
2790
2935
|
}
|
|
2791
2936
|
|
|
@@ -2872,7 +3017,7 @@ class QuestionSpec:
|
|
|
2872
3017
|
class QuestionTool(Tool):
|
|
2873
3018
|
NAME = "Question"
|
|
2874
3019
|
DESCRIPTION = "Ask the user one or more questions (asked in sequence) and wait for their answers. Use when intent is genuinely ambiguous, a choice affects the codebase's external shape (module layout, public API, naming), or you need prioritization; prefer offering choices with previews, and optionally a recommended index when one option is clearly best. Do NOT ask about trivial internal details or anything determinable from context (Read/InspectCode/Bash) or already specified; if a reasonable default exists, proceed."
|
|
2875
|
-
SIGNATURE =
|
|
3020
|
+
SIGNATURE = "Question(questions=[{question, choices?, previews?, recommended?}, ...])"
|
|
2876
3021
|
EXAMPLE = (
|
|
2877
3022
|
'One question, recommending a choice. Example: {"questions":[{"question":"Which approach?","choices":["Refactor","Rewrite"],"previews":["Extract module +87 -12","Rewrite from scratch"],"recommended":0}]}',
|
|
2878
3023
|
'Batch related questions. Example: {"questions":[{"question":"Target runtime?","choices":["Node","Deno"]},{"question":"Name the module?"}]}',
|
|
@@ -2971,6 +3116,7 @@ class QuestionTool(Tool):
|
|
|
2971
3116
|
label = Tool.compact(first, 80)
|
|
2972
3117
|
return [label + (f" (+{len(questions) - 1} more)" if len(questions) > 1 else "")]
|
|
2973
3118
|
|
|
3119
|
+
|
|
2974
3120
|
class MCPTool(Tool):
|
|
2975
3121
|
NAME = "MCP"
|
|
2976
3122
|
DESCRIPTION = "Call/describe external MCP server tools, and list/read MCP resources"
|
|
@@ -3085,9 +3231,10 @@ class MCPTool(Tool):
|
|
|
3085
3231
|
return mcp.read_resource(server, str(payload.get("uri") or ""))
|
|
3086
3232
|
raise ToolError(
|
|
3087
3233
|
f"unknown MCP action {action!r}. Valid actions: {', '.join(self.ACTIONS)}. "
|
|
3088
|
-
f
|
|
3234
|
+
f'To invoke a remote tool named {action!r}, use action="call", tool={action!r}.'
|
|
3089
3235
|
)
|
|
3090
3236
|
|
|
3237
|
+
|
|
3091
3238
|
TOOLS: tuple[type[Tool], ...] = (
|
|
3092
3239
|
MCPTool,
|
|
3093
3240
|
ReadTool,
|
|
@@ -3121,8 +3268,7 @@ class ContextManager:
|
|
|
3121
3268
|
COMPACT_RECENT_MESSAGES: ClassVar[int] = 8
|
|
3122
3269
|
MCP_DESCRIBE_BLOCK: ClassVar[re.Pattern] = re.compile(r"<MCPDescribe server=(\".*?\") tool=(\".*?\")>.*?</MCPDescribe>", re.DOTALL)
|
|
3123
3270
|
CODE_EXTENSIONS: ClassVar[set[str]] = set(
|
|
3124
|
-
".c .cc .cpp .cxx .css .go .h .hpp .html .java .js .json .jsx .kt .lua .php .py .rb .rs .scss .sh .sql "
|
|
3125
|
-
".swift .toml .ts .tsx .vue .yaml .yml".split()
|
|
3271
|
+
".c .cc .cpp .cxx .css .go .h .hpp .html .java .js .json .jsx .kt .lua .php .py .rb .rs .scss .sh .sql .swift .toml .ts .tsx .vue .yaml .yml".split()
|
|
3126
3272
|
)
|
|
3127
3273
|
CODE_FILENAMES: ClassVar[set[str]] = {"CMakeLists.txt", "Dockerfile", "Makefile", "go.mod", "package.json", "pyproject.toml"}
|
|
3128
3274
|
|
|
@@ -4091,6 +4237,33 @@ class MCPManager:
|
|
|
4091
4237
|
def run_async(self, coroutine: Any) -> Any:
|
|
4092
4238
|
return asyncio.run_coroutine_threadsafe(coroutine, self._async_loop()).result()
|
|
4093
4239
|
|
|
4240
|
+
def close(self) -> None:
|
|
4241
|
+
# Stop and join the background loop before the interpreter tears down its
|
|
4242
|
+
# default executors. Otherwise an in-flight client cleanup (HTTP session
|
|
4243
|
+
# termination, DNS via run_in_executor) races the concurrent.futures atexit
|
|
4244
|
+
# shutdown and prints "cannot schedule new futures after shutdown".
|
|
4245
|
+
with self._loop_lock:
|
|
4246
|
+
loop = self._loop
|
|
4247
|
+
thread = self._loop_thread
|
|
4248
|
+
self._loop = None
|
|
4249
|
+
self._loop_thread = None
|
|
4250
|
+
if loop is None or thread is None:
|
|
4251
|
+
return
|
|
4252
|
+
|
|
4253
|
+
async def _shutdown() -> None:
|
|
4254
|
+
pending = [t for t in asyncio.all_tasks(loop) if t is not asyncio.current_task()]
|
|
4255
|
+
for task in pending:
|
|
4256
|
+
task.cancel()
|
|
4257
|
+
for task in pending:
|
|
4258
|
+
with contextlib.suppress(BaseException):
|
|
4259
|
+
await task
|
|
4260
|
+
|
|
4261
|
+
if loop.is_running():
|
|
4262
|
+
with contextlib.suppress(BaseException):
|
|
4263
|
+
asyncio.run_coroutine_threadsafe(_shutdown(), loop).result(timeout=5)
|
|
4264
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
4265
|
+
thread.join(timeout=5)
|
|
4266
|
+
|
|
4094
4267
|
def oauth_client(self, config: MCPServerConfig, *, interactive: bool = False, notify: Callable[[str], None] | None = None) -> Any:
|
|
4095
4268
|
from fastmcp.client.auth import OAuth
|
|
4096
4269
|
|
|
@@ -4185,7 +4358,9 @@ class MCPManager:
|
|
|
4185
4358
|
|
|
4186
4359
|
try:
|
|
4187
4360
|
result = self.run_async(
|
|
4188
|
-
self._call_oauth_tool(config, headers, tool_name, arguments)
|
|
4361
|
+
self._call_oauth_tool(config, headers, tool_name, arguments)
|
|
4362
|
+
if config.auth == "oauth"
|
|
4363
|
+
else self._call_tool(config, headers, tool_name, arguments)
|
|
4189
4364
|
)
|
|
4190
4365
|
except Exception as e:
|
|
4191
4366
|
raise ToolError("MCP call failed: " + self.error_text(e))
|
|
@@ -4226,9 +4401,7 @@ class MCPManager:
|
|
|
4226
4401
|
raise ToolError("MCP read_resource requires a uri")
|
|
4227
4402
|
config, headers = self._resource_preamble(server)
|
|
4228
4403
|
try:
|
|
4229
|
-
result = self.run_async(
|
|
4230
|
-
self._read_oauth_resource(config, headers, uri) if config.auth == "oauth" else self._read_resource(config, headers, uri)
|
|
4231
|
-
)
|
|
4404
|
+
result = self.run_async(self._read_oauth_resource(config, headers, uri) if config.auth == "oauth" else self._read_resource(config, headers, uri))
|
|
4232
4405
|
except Exception as e:
|
|
4233
4406
|
raise ToolError("MCP resource read failed: " + self.error_text(e))
|
|
4234
4407
|
text = self.normalize_resource(result)
|
|
@@ -4346,7 +4519,9 @@ class MCPManager:
|
|
|
4346
4519
|
from fastmcp.client.transports import StreamableHttpTransport
|
|
4347
4520
|
|
|
4348
4521
|
timeout = self.discovery_timeout()
|
|
4349
|
-
async with Client(
|
|
4522
|
+
async with Client(
|
|
4523
|
+
StreamableHttpTransport(config.url, headers=headers), auth=self.oauth_client(config), timeout=timeout, init_timeout=timeout
|
|
4524
|
+
) as client:
|
|
4350
4525
|
return await asyncio.wait_for(client.list_resources(), timeout=timeout)
|
|
4351
4526
|
|
|
4352
4527
|
async def _read_resource(self, config: MCPServerConfig, headers: dict[str, str], uri: str) -> Any:
|
|
@@ -4361,7 +4536,9 @@ class MCPManager:
|
|
|
4361
4536
|
from fastmcp.client.transports import StreamableHttpTransport
|
|
4362
4537
|
|
|
4363
4538
|
timeout = self.call_timeout()
|
|
4364
|
-
async with Client(
|
|
4539
|
+
async with Client(
|
|
4540
|
+
StreamableHttpTransport(config.url, headers=headers), auth=self.oauth_client(config), timeout=timeout, init_timeout=timeout
|
|
4541
|
+
) as client:
|
|
4365
4542
|
return await asyncio.wait_for(client.read_resource(uri), timeout=timeout)
|
|
4366
4543
|
|
|
4367
4544
|
async def _call_oauth_tool(self, config: MCPServerConfig, headers: dict[str, str], name: str, arguments: Json) -> Any:
|
|
@@ -4369,7 +4546,9 @@ class MCPManager:
|
|
|
4369
4546
|
from fastmcp.client.transports import StreamableHttpTransport
|
|
4370
4547
|
|
|
4371
4548
|
timeout = self.call_timeout()
|
|
4372
|
-
async with Client(
|
|
4549
|
+
async with Client(
|
|
4550
|
+
StreamableHttpTransport(config.url, headers=headers), auth=self.oauth_client(config), timeout=timeout, init_timeout=timeout
|
|
4551
|
+
) as client:
|
|
4373
4552
|
return await asyncio.wait_for(client.call_tool(name, arguments), timeout=timeout)
|
|
4374
4553
|
|
|
4375
4554
|
def login_server(self, name: str, notify: Callable[[str], None] | None = None) -> str:
|
|
@@ -4553,7 +4732,7 @@ class MCPManager:
|
|
|
4553
4732
|
if line:
|
|
4554
4733
|
lines.append(line)
|
|
4555
4734
|
if resources:
|
|
4556
|
-
lines.append(f
|
|
4735
|
+
lines.append(f'resources ({len(resources)}) — read with MCP(action="read_resource", server={json.dumps(config.name)}, uri=...):')
|
|
4557
4736
|
lines.extend(self._format_resource_line(res) for res in resources)
|
|
4558
4737
|
lines.append("")
|
|
4559
4738
|
|
|
@@ -4633,7 +4812,7 @@ class MCPManager:
|
|
|
4633
4812
|
lines.append(line)
|
|
4634
4813
|
resources = self.resources.get(server, [])
|
|
4635
4814
|
if resources:
|
|
4636
|
-
lines.append(f
|
|
4815
|
+
lines.append(f'resources ({len(resources)}) — read with MCP(action="read_resource", server={json.dumps(server)}, uri=...):')
|
|
4637
4816
|
lines.extend(self._format_resource_line(res) for res in resources)
|
|
4638
4817
|
return "\n".join(lines)
|
|
4639
4818
|
|
|
@@ -4651,7 +4830,7 @@ class MCPManager:
|
|
|
4651
4830
|
# truncated above, so surface any resource-like URIs it mentions explicitly.
|
|
4652
4831
|
uris = self._extract_uris(info.description)
|
|
4653
4832
|
if uris:
|
|
4654
|
-
line +=
|
|
4833
|
+
line += '\n refs (read with MCP action="read_resource"): ' + ", ".join(uris)
|
|
4655
4834
|
if include_schema:
|
|
4656
4835
|
schema = self._schema_json(info.input_schema, self.INDEX_SCHEMA_LIMIT)
|
|
4657
4836
|
if schema:
|
|
@@ -4760,13 +4939,24 @@ class MCPManager:
|
|
|
4760
4939
|
if config.env_http_headers:
|
|
4761
4940
|
for header_name in config.env_http_headers:
|
|
4762
4941
|
auth.append("env_header(" + header_name + ")")
|
|
4763
|
-
lines.append(
|
|
4942
|
+
lines.append(
|
|
4943
|
+
"| `"
|
|
4944
|
+
+ self.markdown_cell(config.name)
|
|
4945
|
+
+ "` | "
|
|
4946
|
+
+ self.markdown_cell(status)
|
|
4947
|
+
+ " | "
|
|
4948
|
+
+ self.markdown_cell(tools or "-")
|
|
4949
|
+
+ " | "
|
|
4950
|
+
+ self.markdown_cell(", ".join(auth) or "-")
|
|
4951
|
+
+ " |"
|
|
4952
|
+
)
|
|
4764
4953
|
return "\n".join(lines) if len(lines) > 2 else "(no MCP servers configured)"
|
|
4765
4954
|
|
|
4766
4955
|
@staticmethod
|
|
4767
4956
|
def markdown_cell(text: str) -> str:
|
|
4768
4957
|
return Text.clean(str(text)).replace("\n", " ").replace("|", "\\|")
|
|
4769
4958
|
|
|
4959
|
+
|
|
4770
4960
|
class ToolRunner:
|
|
4771
4961
|
def __init__(self, session: Session, context: ContextManager, input_fn=input, output_fn=print):
|
|
4772
4962
|
self.session = session
|
|
@@ -4776,7 +4966,7 @@ class ToolRunner:
|
|
|
4776
4966
|
self.preview_fn: Callable[[str], bool] | None = None
|
|
4777
4967
|
self.preview_full_fn: Callable[[str], None] | None = None
|
|
4778
4968
|
self.live_output: Callable[[str, str], None] | None = None
|
|
4779
|
-
self.live_start: Callable[[], None] | None = None
|
|
4969
|
+
self.live_start: Callable[[str], None] | None = None
|
|
4780
4970
|
self.question_fn: Callable[[QuestionSpec, str], str] | None = None
|
|
4781
4971
|
|
|
4782
4972
|
def run(self, calls: list[ToolCall], batch_suffix: str = "") -> list[Json]:
|
|
@@ -4945,7 +5135,7 @@ class ToolRunner:
|
|
|
4945
5135
|
return "refused", self.finish(call, output, failed=True, elapsed=time.monotonic() - started, display=display, batch_suffix=batch_suffix)
|
|
4946
5136
|
approved = True
|
|
4947
5137
|
if isinstance(tool, BashTool) and self.live_start is not None:
|
|
4948
|
-
self.live_start()
|
|
5138
|
+
self.live_start(str(call.args[0]) if call.args else "")
|
|
4949
5139
|
output = planned_edit.call(tool) if planned_edit and isinstance(tool, EditTool) else tool.call()
|
|
4950
5140
|
except ToolError as error:
|
|
4951
5141
|
return "failed", self.reject(call, f"ToolError: {error}", elapsed=time.monotonic() - started, display=display, batch_suffix=batch_suffix)
|
|
@@ -4991,7 +5181,9 @@ class ToolRunner:
|
|
|
4991
5181
|
self.session.record_tool_error(key or "-", call.name, call.args, output)
|
|
4992
5182
|
elif key:
|
|
4993
5183
|
self.update_code_index(call, output)
|
|
4994
|
-
self.output_fn(
|
|
5184
|
+
self.output_fn(
|
|
5185
|
+
self.finish_display(call, key, output, failed=failed, approved=approved, auto=auto, display=display, batch_suffix=batch_suffix, elapsed=elapsed)
|
|
5186
|
+
)
|
|
4995
5187
|
return self.tool_message(call, key, output, failed=failed, display=display)
|
|
4996
5188
|
|
|
4997
5189
|
def tool_message(self, call: ToolCall, key: str, output: str, *, failed: bool = False, display: str | None = None) -> str:
|
|
@@ -5067,7 +5259,17 @@ class ToolRunner:
|
|
|
5067
5259
|
return "\n".join([" preview", *(" " + line for line in lines)])
|
|
5068
5260
|
|
|
5069
5261
|
def finish_display(
|
|
5070
|
-
self,
|
|
5262
|
+
self,
|
|
5263
|
+
call: ToolCall,
|
|
5264
|
+
key: str,
|
|
5265
|
+
output: str,
|
|
5266
|
+
*,
|
|
5267
|
+
failed: bool,
|
|
5268
|
+
approved: bool = False,
|
|
5269
|
+
auto: bool = False,
|
|
5270
|
+
display: str | None = None,
|
|
5271
|
+
batch_suffix: str = "",
|
|
5272
|
+
elapsed: float | None = None,
|
|
5071
5273
|
) -> str:
|
|
5072
5274
|
if call.name == "Note" and not failed and display:
|
|
5073
5275
|
return self.with_batch_suffix(display.removeprefix("Note ").strip(), batch_suffix)
|
|
@@ -5208,7 +5410,7 @@ class ModelClient:
|
|
|
5208
5410
|
provider = self.session.config.provider
|
|
5209
5411
|
if missing := self.session.missing_config():
|
|
5210
5412
|
raise ModelError("missing config: " + ", ".join(missing))
|
|
5211
|
-
tools = [tool.schema() for tool in TOOL_REGISTRY.values()]
|
|
5413
|
+
tools = [tool.schema(provider.resolved_strict_tools()) for tool in TOOL_REGISTRY.values()]
|
|
5212
5414
|
for attempt in range(MODEL_REQUEST_RETRIES + 1):
|
|
5213
5415
|
self.session.state.current_model_call_started_at = time.monotonic()
|
|
5214
5416
|
try:
|
|
@@ -5249,6 +5451,8 @@ class ModelClient:
|
|
|
5249
5451
|
messages = Text.value(messages)
|
|
5250
5452
|
provider = self.session.config.provider
|
|
5251
5453
|
params: Json = {"model": provider.model, "messages": messages, "stream": False}
|
|
5454
|
+
if (max_tokens := provider.resolved_max_tokens()) > 0:
|
|
5455
|
+
params["max_tokens"] = max_tokens
|
|
5252
5456
|
if tools:
|
|
5253
5457
|
params["tools"] = tools
|
|
5254
5458
|
params["tool_choice"] = "auto"
|
|
@@ -5339,6 +5543,8 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
|
|
|
5339
5543
|
return ""
|
|
5340
5544
|
if configured != "auto":
|
|
5341
5545
|
return configured
|
|
5546
|
+
if not provider.supports_prompt_cache_key():
|
|
5547
|
+
return ""
|
|
5342
5548
|
payload = {
|
|
5343
5549
|
"api": provider.resolved_api(),
|
|
5344
5550
|
"cwd": self.session.cwd,
|
|
@@ -5474,11 +5680,12 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
|
|
|
5474
5680
|
return assistant, calls, text
|
|
5475
5681
|
|
|
5476
5682
|
def apply_provider_params(self, params: Json, provider: ProviderConfig) -> None:
|
|
5477
|
-
if provider.temperature is not None:
|
|
5478
|
-
params["temperature"] = provider.temperature
|
|
5479
5683
|
chat_reasoning = provider.resolved_chat_reasoning()
|
|
5480
5684
|
reasoning_enabled = provider.reasoning != "off"
|
|
5481
5685
|
effort = provider.reasoning_effort()
|
|
5686
|
+
# Native thinking modes (DeepSeek, Qwen) ignore or reject temperature while thinking is on.
|
|
5687
|
+
if provider.temperature is not None and not (reasoning_enabled and chat_reasoning in ("thinking", "enable_thinking")):
|
|
5688
|
+
params["temperature"] = provider.temperature
|
|
5482
5689
|
extra: Json = {}
|
|
5483
5690
|
if reasoning_enabled and chat_reasoning == "reasoning":
|
|
5484
5691
|
extra["reasoning"] = {"effort": effort}
|
|
@@ -5538,12 +5745,22 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
|
|
|
5538
5745
|
calls.append(self.tool_call(raw.id, raw.function.name, payload))
|
|
5539
5746
|
return calls
|
|
5540
5747
|
|
|
5541
|
-
@
|
|
5542
|
-
def tool_payload(name: str, payload: Any) -> list[Any]:
|
|
5748
|
+
@classmethod
|
|
5749
|
+
def tool_payload(cls, name: str, payload: Any) -> list[Any]:
|
|
5543
5750
|
if isinstance(payload, dict) and (tool := TOOL_REGISTRY.get(name)):
|
|
5544
|
-
|
|
5751
|
+
# Strict schemas express optional params as nullable, so the model may send explicit
|
|
5752
|
+
# null for an omitted argument. In every nanocode tool null means "absent", so drop it.
|
|
5753
|
+
return tool.payload_args(cls.drop_nulls(payload))
|
|
5545
5754
|
return [payload]
|
|
5546
5755
|
|
|
5756
|
+
@classmethod
|
|
5757
|
+
def drop_nulls(cls, value: Any) -> Any:
|
|
5758
|
+
if isinstance(value, dict):
|
|
5759
|
+
return {key: cls.drop_nulls(item) for key, item in value.items() if item is not None}
|
|
5760
|
+
if isinstance(value, list):
|
|
5761
|
+
return [cls.drop_nulls(item) for item in value]
|
|
5762
|
+
return value
|
|
5763
|
+
|
|
5547
5764
|
@classmethod
|
|
5548
5765
|
def tool_call(cls, call_id: str, name: str, payload: Any) -> ToolCall:
|
|
5549
5766
|
# payload_args may reject malformed arguments (e.g. Git with an empty argv). Capture that
|
|
@@ -5587,7 +5804,8 @@ FILE STATE:
|
|
|
5587
5804
|
- Read and Edit refresh FILE STATE; after Edit, trust the edited range.
|
|
5588
5805
|
|
|
5589
5806
|
FINAL:
|
|
5590
|
-
-
|
|
5807
|
+
- Be concise by default: answer in as few lines as the task allows (often 1-3), and stop. Lead with the result; no preamble, recap, or filler.
|
|
5808
|
+
- Do not over-explain, enumerate options, or add background unless the user asks for detail, a walkthrough, or "why". Match length to what was requested; only go long when the user requests it or the task genuinely requires it.
|
|
5591
5809
|
- Concise markdown in the user's language; note changed files and checks run (or not run).\
|
|
5592
5810
|
"""
|
|
5593
5811
|
|
|
@@ -5684,6 +5902,7 @@ class CommandCompleter(Completer):
|
|
|
5684
5902
|
"/reason",
|
|
5685
5903
|
"/set",
|
|
5686
5904
|
"/yolo",
|
|
5905
|
+
"/strict",
|
|
5687
5906
|
"/exit",
|
|
5688
5907
|
"/quit",
|
|
5689
5908
|
)
|
|
@@ -5697,6 +5916,8 @@ class CommandCompleter(Completer):
|
|
|
5697
5916
|
"provider.chat_reasoning",
|
|
5698
5917
|
"provider.available_models",
|
|
5699
5918
|
"provider.temperature",
|
|
5919
|
+
"provider.max_tokens",
|
|
5920
|
+
"provider.strict_tools",
|
|
5700
5921
|
"provider.timeout",
|
|
5701
5922
|
"runtime.yolo",
|
|
5702
5923
|
"runtime.max_agent_steps",
|
|
@@ -5704,6 +5925,7 @@ class CommandCompleter(Completer):
|
|
|
5704
5925
|
"runtime.max_parallel_tools",
|
|
5705
5926
|
"runtime.shell_timeout",
|
|
5706
5927
|
"runtime.check_updates",
|
|
5928
|
+
"runtime.tips",
|
|
5707
5929
|
)
|
|
5708
5930
|
SET_VALUES = {
|
|
5709
5931
|
"provider.api": PROVIDER_API_CHOICES,
|
|
@@ -5711,8 +5933,10 @@ class CommandCompleter(Completer):
|
|
|
5711
5933
|
"provider.reasoning": REASONING_CHOICES,
|
|
5712
5934
|
"provider.chat_reasoning": CHAT_REASONING_CHOICES,
|
|
5713
5935
|
"provider.temperature": ("off",),
|
|
5936
|
+
"provider.strict_tools": ("on", "off", "true", "false"),
|
|
5714
5937
|
"runtime.yolo": ("on", "off", "true", "false"),
|
|
5715
5938
|
"runtime.check_updates": ("on", "off", "true", "false"),
|
|
5939
|
+
"runtime.tips": ("on", "off", "true", "false"),
|
|
5716
5940
|
}
|
|
5717
5941
|
|
|
5718
5942
|
def __init__(
|
|
@@ -5822,10 +6046,21 @@ class UiPrinter:
|
|
|
5822
6046
|
return [("ansibrightblack", text + "\n")]
|
|
5823
6047
|
if text.startswith("nanocode "):
|
|
5824
6048
|
return [("ansicyan", text + "\n")]
|
|
6049
|
+
if text.startswith("tip: "):
|
|
6050
|
+
return self.tip_segments(text[len("tip: "):])
|
|
5825
6051
|
if text.startswith("Error:") or text.startswith("ConfigError:") or text.startswith("Unknown command:"):
|
|
5826
6052
|
return [("ansired", text + "\n")]
|
|
5827
6053
|
return [("ansiwhite", line + "\n") for line in text.splitlines() or [""]]
|
|
5828
6054
|
|
|
6055
|
+
def tip_segments(self, text: str) -> list[tuple[str, str]]:
|
|
6056
|
+
# Muted hint line with a labeled marker; `code` spans are highlighted so commands stand out.
|
|
6057
|
+
segments: list[tuple[str, str]] = [("ansibrightblack", " "), ("ansiyellow", "💡 tip "), ("ansibrightblack", "· ")]
|
|
6058
|
+
for index, part in enumerate(text.split("`")):
|
|
6059
|
+
if part:
|
|
6060
|
+
segments.append(("ansicyan" if index % 2 else "ansibrightblack", part))
|
|
6061
|
+
segments.append(("", "\n"))
|
|
6062
|
+
return segments
|
|
6063
|
+
|
|
5829
6064
|
def tool_segments(self, text: str) -> list[tuple[str, str]]:
|
|
5830
6065
|
segments = []
|
|
5831
6066
|
for line in text.splitlines() or [""]:
|
|
@@ -5949,31 +6184,73 @@ class UiPrinter:
|
|
|
5949
6184
|
at_start = part.endswith("\n")
|
|
5950
6185
|
return indented
|
|
5951
6186
|
|
|
6187
|
+
|
|
5952
6188
|
class BashLivePreview:
|
|
5953
6189
|
HEIGHT: ClassVar[int] = 6
|
|
5954
6190
|
MAX_CHARS: ClassVar[int] = 8000
|
|
6191
|
+
# Heartbeat tick so the elapsed timer advances even while a command produces no output
|
|
6192
|
+
# (e.g. quiet long-runners or `... | tail` that buffers until EOF), so the terminal never
|
|
6193
|
+
# looks frozen during a blocking command.
|
|
6194
|
+
TICK: ClassVar[float] = 0.1
|
|
5955
6195
|
|
|
5956
6196
|
def __init__(self):
|
|
5957
6197
|
self.output = create_output(sys.stderr)
|
|
5958
6198
|
self.active = False
|
|
5959
6199
|
self.rendered_lines = 0
|
|
5960
6200
|
self.text = ""
|
|
6201
|
+
self.command = ""
|
|
6202
|
+
self.started_at = 0.0
|
|
6203
|
+
self.lock = threading.Lock()
|
|
6204
|
+
self.timer: threading.Thread | None = None
|
|
5961
6205
|
|
|
5962
|
-
def start(self) -> None:
|
|
6206
|
+
def start(self, command: str = "") -> None:
|
|
5963
6207
|
if not sys.stderr.isatty():
|
|
5964
6208
|
return
|
|
5965
|
-
|
|
6209
|
+
with self.lock:
|
|
6210
|
+
self.active, self.rendered_lines, self.text = True, 0, ""
|
|
6211
|
+
self.command = " ".join(command.split())
|
|
6212
|
+
self.started_at = time.monotonic()
|
|
6213
|
+
self.render()
|
|
6214
|
+
self.timer = threading.Thread(target=self.tick, daemon=True)
|
|
6215
|
+
self.timer.start()
|
|
6216
|
+
|
|
6217
|
+
def tick(self) -> None:
|
|
6218
|
+
while True:
|
|
6219
|
+
time.sleep(self.TICK)
|
|
6220
|
+
with self.lock:
|
|
6221
|
+
if not self.active:
|
|
6222
|
+
return
|
|
6223
|
+
self.render()
|
|
5966
6224
|
|
|
5967
6225
|
def update(self, text: str) -> None:
|
|
5968
|
-
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
|
|
6226
|
+
with self.lock:
|
|
6227
|
+
if not self.active:
|
|
6228
|
+
return
|
|
6229
|
+
self.text = (self.text + text)[-self.MAX_CHARS :]
|
|
6230
|
+
self.render()
|
|
5972
6231
|
|
|
5973
6232
|
def finish(self) -> None:
|
|
5974
|
-
|
|
6233
|
+
with self.lock:
|
|
6234
|
+
if not self.active:
|
|
6235
|
+
return
|
|
6236
|
+
self.active = False
|
|
6237
|
+
timer = self.timer
|
|
6238
|
+
if timer is not None:
|
|
6239
|
+
timer.join()
|
|
6240
|
+
with self.lock:
|
|
6241
|
+
self.clear()
|
|
6242
|
+
self.rendered_lines, self.text = 0, ""
|
|
6243
|
+
|
|
6244
|
+
def clear(self) -> None:
|
|
6245
|
+
if not self.rendered_lines:
|
|
5975
6246
|
return
|
|
5976
|
-
self.
|
|
6247
|
+
self.output.write_raw(f"\x1b[{self.rendered_lines}A")
|
|
6248
|
+
for _ in range(self.rendered_lines):
|
|
6249
|
+
self.output.write_raw("\r")
|
|
6250
|
+
self.output.erase_end_of_line()
|
|
6251
|
+
self.output.write_raw("\n")
|
|
6252
|
+
self.output.write_raw(f"\x1b[{self.rendered_lines}A")
|
|
6253
|
+
self.output.flush()
|
|
5977
6254
|
|
|
5978
6255
|
def render(self) -> None:
|
|
5979
6256
|
if not self.active:
|
|
@@ -5996,11 +6273,26 @@ class BashLivePreview:
|
|
|
5996
6273
|
self.output.flush()
|
|
5997
6274
|
self.rendered_lines = len(lines)
|
|
5998
6275
|
|
|
6276
|
+
def elapsed_label(self) -> str:
|
|
6277
|
+
elapsed = max(0.0, time.monotonic() - self.started_at) if self.started_at else 0.0
|
|
6278
|
+
if elapsed < 60:
|
|
6279
|
+
return f"{elapsed:.1f}s"
|
|
6280
|
+
minutes, rest = divmod(int(elapsed), 60)
|
|
6281
|
+
return f"{minutes}m{rest:02d}s"
|
|
6282
|
+
|
|
5999
6283
|
def frame_lines(self) -> list[str]:
|
|
6000
6284
|
width = max(20, shutil.get_terminal_size((120, 20)).columns)
|
|
6001
6285
|
body = [line.expandtabs(4) for line in self.text.replace("\r", "\n").splitlines()[-self.HEIGHT :]]
|
|
6002
|
-
|
|
6003
|
-
|
|
6286
|
+
label = self.elapsed_label()
|
|
6287
|
+
# `limit` leaves a column of slack so a full-width line cannot auto-wrap and desync the
|
|
6288
|
+
# cursor-up math in render().
|
|
6289
|
+
limit = width - 3
|
|
6290
|
+
clip = lambda line: line if len(line) <= limit else line[: max(0, limit - 3)] + "..."
|
|
6291
|
+
# Always emit a header (the running command + a live elapsed timer) so the frame is visible
|
|
6292
|
+
# even before any output arrives and the user can see what is executing.
|
|
6293
|
+
status = f"output · {label}" if body else f"running… {label}"
|
|
6294
|
+
header = [clip(" $ " + self.command)] if self.command else []
|
|
6295
|
+
return [*header, " " + status, *(" " + clip(line) for line in body)]
|
|
6004
6296
|
|
|
6005
6297
|
|
|
6006
6298
|
class ModelRetryShortcut:
|
|
@@ -6267,6 +6559,46 @@ class CommandLoop:
|
|
|
6267
6559
|
"logout": (1, 1, "Usage: /mcp logout <server>\nExample: /mcp logout myOAuthServer"),
|
|
6268
6560
|
"refresh": (0, 1, "Usage: /mcp refresh [server]"),
|
|
6269
6561
|
}
|
|
6562
|
+
# (predicate, tip): predicate gates a tip to contexts where it is actually useful.
|
|
6563
|
+
ALWAYS = staticmethod(lambda s: True)
|
|
6564
|
+
TIPS: ClassVar[tuple[tuple[Callable[["Session"], bool], str], ...]] = (
|
|
6565
|
+
# Sessions & input
|
|
6566
|
+
(ALWAYS, "Resume your last session anytime with `nanocode --resume`."),
|
|
6567
|
+
(ALWAYS, "Keep typing while the agent works — your input is picked up at the next step."),
|
|
6568
|
+
(ALWAYS, "Press Ctrl+C to cancel the current input or interrupt a running turn."),
|
|
6569
|
+
(ALWAYS, "Search your input history with Ctrl+R."),
|
|
6570
|
+
(ALWAYS, "Tab completes commands, file paths, and mentions."),
|
|
6571
|
+
# Context & memory
|
|
6572
|
+
(ALWAYS, "`/compact` summarizes a long conversation to reclaim context."),
|
|
6573
|
+
(ALWAYS, "`/memory` shows the agent's current goal, plan, and known facts."),
|
|
6574
|
+
(ALWAYS, "`/status` shows token usage, context %, and prompt-cache hit rate."),
|
|
6575
|
+
(ALWAYS, "Stable context is kept early so the prompt cache is reused — cheaper, faster turns."),
|
|
6576
|
+
# Model & reasoning
|
|
6577
|
+
(ALWAYS, "`/model` switches model and `/reason` sets reasoning effort on the fly."),
|
|
6578
|
+
(ALWAYS, "`/set provider.reasoning high` digs deeper on hard tasks; `off` is fastest."),
|
|
6579
|
+
(ALWAYS, "`/set provider.max_tokens N` caps the model's output length."),
|
|
6580
|
+
(ALWAYS, "`/api` shows or switches the API protocol (auto / chat / anthropic)."),
|
|
6581
|
+
(lambda s: len(s.config.providers) > 1, "`/provider` switches between your configured providers."),
|
|
6582
|
+
(lambda s: s.config.provider.supports_strict_tools(), "`/strict` constrains tool-call arguments to each tool's schema (OpenAI / DeepSeek)."),
|
|
6583
|
+
# Tools & navigation
|
|
6584
|
+
(ALWAYS, "`/index` manages the code symbol index for fast symbol navigation."),
|
|
6585
|
+
(ALWAYS, "`/yolo` skips tool confirmations when you want to move fast."),
|
|
6586
|
+
(ALWAYS, "`/set runtime.max_parallel_tools N` tunes how many reads run at once."),
|
|
6587
|
+
(lambda s: bool(s.config.mcp), "Mention an MCP tool inline with `@server.tool` to pull in its schema."),
|
|
6588
|
+
(lambda s: bool(s.config.mcp), "`/mcp` manages servers; `/mcp login NAME` starts an OAuth flow."),
|
|
6589
|
+
# Config & setup
|
|
6590
|
+
(ALWAYS, "`/config` opens your config; `/set KEY VALUE` changes settings live."),
|
|
6591
|
+
(ALWAYS, "Scaffold a fresh config with `nanocode --init-config`."),
|
|
6592
|
+
(ALWAYS, "Launch with `--yolo` to skip confirmations, or `--debug` to record request traces."),
|
|
6593
|
+
(ALWAYS, "Filter MCP servers at launch with `--mcp \"name*,!exclude\"`."),
|
|
6594
|
+
(ALWAYS, "Silence these hints with `/set runtime.tips off`."),
|
|
6595
|
+
)
|
|
6596
|
+
|
|
6597
|
+
def startup_tip(self) -> str:
|
|
6598
|
+
if not self.session.settings.tips:
|
|
6599
|
+
return ""
|
|
6600
|
+
eligible = [tip for predicate, tip in self.TIPS if predicate(self.session)]
|
|
6601
|
+
return random.choice(eligible) if eligible else ""
|
|
6270
6602
|
MCP_HELP = "Try /mcp, /mcp tools [server], /mcp login <server>, /mcp logout <server>, /mcp refresh [server]"
|
|
6271
6603
|
|
|
6272
6604
|
HELP = """Commands:
|
|
@@ -6283,6 +6615,7 @@ class CommandLoop:
|
|
|
6283
6615
|
/reason Select reasoning effort.
|
|
6284
6616
|
/set KEY VALUE Set provider.* and runtime.*.
|
|
6285
6617
|
/yolo Toggle tool confirmations.
|
|
6618
|
+
/strict Toggle strict tool-call schemas (OpenAI / DeepSeek).
|
|
6286
6619
|
/mcp Show MCP server status.
|
|
6287
6620
|
/mcp tools [NAME] List MCP tools.
|
|
6288
6621
|
/mcp login NAME Start OAuth login for a server.
|
|
@@ -6484,7 +6817,6 @@ Tools:
|
|
|
6484
6817
|
while self.queue_input_active.is_set() and time.monotonic() < deadline:
|
|
6485
6818
|
time.sleep(0.01)
|
|
6486
6819
|
|
|
6487
|
-
|
|
6488
6820
|
def drain_queued_input(self) -> str:
|
|
6489
6821
|
"""Return queued user input, or empty string if nothing queued."""
|
|
6490
6822
|
texts = []
|
|
@@ -6495,8 +6827,11 @@ Tools:
|
|
|
6495
6827
|
texts.append(self.queue_input_text)
|
|
6496
6828
|
self.queue_input_text = ""
|
|
6497
6829
|
return "\n".join(texts)
|
|
6830
|
+
|
|
6498
6831
|
def run(self) -> int:
|
|
6499
6832
|
self.emit(f"nanocode {__version__}. /help for commands.")
|
|
6833
|
+
if tip := self.startup_tip():
|
|
6834
|
+
self.emit("tip: " + tip)
|
|
6500
6835
|
self.session.clean_expired_snapshots()
|
|
6501
6836
|
self.render_resumed_session()
|
|
6502
6837
|
CodeIndex(self.session).refresh_existing_async()
|
|
@@ -6546,7 +6881,7 @@ Tools:
|
|
|
6546
6881
|
watcher.join(timeout=1.0)
|
|
6547
6882
|
self.queue_input_paused.clear()
|
|
6548
6883
|
self.session.state.manual_model_retry_requested = False
|
|
6549
|
-
CodeIndex(self.session).
|
|
6884
|
+
CodeIndex(self.session).update_pending_async()
|
|
6550
6885
|
self.status_bar.stop()
|
|
6551
6886
|
elapsed = time.monotonic() - started
|
|
6552
6887
|
self.ui.emit_answer(answer)
|
|
@@ -6639,14 +6974,12 @@ Tools:
|
|
|
6639
6974
|
# the index is too large to fit even as name-only (some tools are hidden from the model).
|
|
6640
6975
|
self.session.mcp.render_tools_index()
|
|
6641
6976
|
if self.session.mcp.index_truncated:
|
|
6642
|
-
self.emit(
|
|
6977
|
+
self.emit(
|
|
6978
|
+
"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."
|
|
6979
|
+
)
|
|
6643
6980
|
|
|
6644
6981
|
def mcp_error_notice(self) -> str:
|
|
6645
|
-
errors = [
|
|
6646
|
-
(name, error)
|
|
6647
|
-
for name, error in sorted(self.session.mcp.server_errors.items())
|
|
6648
|
-
if error and not error.startswith("oauth login required")
|
|
6649
|
-
]
|
|
6982
|
+
errors = [(name, error) for name, error in sorted(self.session.mcp.server_errors.items()) if error and not error.startswith("oauth login required")]
|
|
6650
6983
|
if not errors:
|
|
6651
6984
|
return ""
|
|
6652
6985
|
shown = errors if self.session.settings.debug else errors[:3]
|
|
@@ -6924,7 +7257,7 @@ Tools:
|
|
|
6924
7257
|
sys.stdout.flush()
|
|
6925
7258
|
self.transient_tool_lines = 0
|
|
6926
7259
|
|
|
6927
|
-
def tool_live_start(self) -> None:
|
|
7260
|
+
def tool_live_start(self, command: str = "") -> None:
|
|
6928
7261
|
if not self.ui.color:
|
|
6929
7262
|
return
|
|
6930
7263
|
self.live_queue_paused = self.interactive_input and not self.queue_input_paused.is_set()
|
|
@@ -6933,7 +7266,7 @@ Tools:
|
|
|
6933
7266
|
self.live_status_paused = self.status_bar.is_running()
|
|
6934
7267
|
if self.live_status_paused:
|
|
6935
7268
|
self.status_bar.stop()
|
|
6936
|
-
self.live_preview.start()
|
|
7269
|
+
self.live_preview.start(command)
|
|
6937
7270
|
|
|
6938
7271
|
def tool_live_output(self, _stream: str, text: str) -> None:
|
|
6939
7272
|
if not self.ui.color:
|
|
@@ -6979,6 +7312,7 @@ Tools:
|
|
|
6979
7312
|
"/reason": self.reason,
|
|
6980
7313
|
"/set": self.set_value,
|
|
6981
7314
|
"/yolo": self.yolo,
|
|
7315
|
+
"/strict": self.strict,
|
|
6982
7316
|
"/mcp": self.mcp_command,
|
|
6983
7317
|
}
|
|
6984
7318
|
handler = handlers.get(name)
|
|
@@ -7239,7 +7573,11 @@ Tools:
|
|
|
7239
7573
|
previews = spec.previews
|
|
7240
7574
|
preview_map = {c: previews[i] for i, c in enumerate(choices) if previews and i < len(previews) and previews[i]}
|
|
7241
7575
|
result = self.choice_application(
|
|
7242
|
-
"Select:",
|
|
7576
|
+
"Select:",
|
|
7577
|
+
tuple(choices),
|
|
7578
|
+
labels,
|
|
7579
|
+
current,
|
|
7580
|
+
set(),
|
|
7243
7581
|
preview_fn=lambda choice: preview_map.get(choice, ""),
|
|
7244
7582
|
free_text=True,
|
|
7245
7583
|
)
|
|
@@ -7270,7 +7608,9 @@ Tools:
|
|
|
7270
7608
|
usage = self.session.usage
|
|
7271
7609
|
provider = self.session.config.provider
|
|
7272
7610
|
self.agent.context.update_percent(self.agent.context.model_messages(self.agent.SYSTEM_PROMPT))
|
|
7273
|
-
|
|
7611
|
+
index = CodeIndex(self.session)
|
|
7612
|
+
index_status, index_message = index.status(check=False)
|
|
7613
|
+
index.update_pending_async()
|
|
7274
7614
|
if self.session.state.code_index_refreshing:
|
|
7275
7615
|
index_status, index_message = self.session.state.code_index_notice or "syncing", ""
|
|
7276
7616
|
elif self.session.state.code_index_error:
|
|
@@ -7316,7 +7656,15 @@ Tools:
|
|
|
7316
7656
|
state = self.session.state
|
|
7317
7657
|
known = ["- " + item for item in state.known] or ["- (empty)"]
|
|
7318
7658
|
return "\n".join(
|
|
7319
|
-
[
|
|
7659
|
+
[
|
|
7660
|
+
"goal: " + (state.goal or "(empty)"),
|
|
7661
|
+
"summary:",
|
|
7662
|
+
state.summary or "(empty)",
|
|
7663
|
+
"plan:",
|
|
7664
|
+
*AgentState.plan_rows_for(state.plan, status=True, style="symbol"),
|
|
7665
|
+
"known:",
|
|
7666
|
+
*known,
|
|
7667
|
+
]
|
|
7320
7668
|
)
|
|
7321
7669
|
|
|
7322
7670
|
def config(self, args: str) -> str:
|
|
@@ -7336,6 +7684,8 @@ Tools:
|
|
|
7336
7684
|
f"provider.resolved_chat_reasoning: {provider.resolved_chat_reasoning()}",
|
|
7337
7685
|
f"provider.chat_reasoning: {provider.chat_reasoning}",
|
|
7338
7686
|
f"provider.temperature: {provider.temperature if provider.temperature is not None else '(off)'}",
|
|
7687
|
+
f"provider.max_tokens: {provider.max_tokens or ('(resolved ' + str(provider.resolved_max_tokens() or 'server default') + ')')}",
|
|
7688
|
+
f"provider.strict_tools: {provider.strict_tools} (active {provider.resolved_strict_tools()})",
|
|
7339
7689
|
f"provider.timeout: {provider.timeout}",
|
|
7340
7690
|
f"paths.data_dir: {self.session.data_path()}",
|
|
7341
7691
|
f"runtime.shell_timeout: {self.session.settings.shell_timeout}",
|
|
@@ -7518,6 +7868,16 @@ Tools:
|
|
|
7518
7868
|
self.session.settings.yolo = not self.session.settings.yolo
|
|
7519
7869
|
return "yolo: " + ("on" if self.session.settings.yolo else "off")
|
|
7520
7870
|
|
|
7871
|
+
def strict(self, args: str) -> str:
|
|
7872
|
+
if args:
|
|
7873
|
+
return "Usage: /strict"
|
|
7874
|
+
provider = self.session.config.provider
|
|
7875
|
+
provider.strict_tools = not provider.strict_tools
|
|
7876
|
+
state = "on" if provider.strict_tools else "off"
|
|
7877
|
+
if provider.strict_tools and not provider.resolved_strict_tools():
|
|
7878
|
+
return f"strict_tools: {state} (inactive: {provider.host() or 'this provider'} does not support strict tool calling)"
|
|
7879
|
+
return f"strict_tools: {state}"
|
|
7880
|
+
|
|
7521
7881
|
def set_value(self, args: str) -> str:
|
|
7522
7882
|
key, _, value = args.partition(" ")
|
|
7523
7883
|
if not key or not value:
|
|
@@ -7538,6 +7898,10 @@ Tools:
|
|
|
7538
7898
|
provider.available_models = tuple(item.strip() for item in value.split(",") if item.strip())
|
|
7539
7899
|
elif key == "provider.temperature":
|
|
7540
7900
|
provider.temperature = None if value == "off" else float(value)
|
|
7901
|
+
elif key == "provider.max_tokens":
|
|
7902
|
+
provider.max_tokens = max(0, int(value))
|
|
7903
|
+
elif key == "provider.strict_tools":
|
|
7904
|
+
provider.strict_tools = Config.bool({key: value}, key)
|
|
7541
7905
|
elif key == "provider.timeout":
|
|
7542
7906
|
provider.timeout = max(1, int(value))
|
|
7543
7907
|
elif key == "runtime.yolo":
|
|
@@ -7554,6 +7918,8 @@ Tools:
|
|
|
7554
7918
|
runtime.shell_timeout = max(1, int(value))
|
|
7555
7919
|
elif key == "runtime.max_parallel_tools":
|
|
7556
7920
|
runtime.max_parallel_tools = max(1, int(value))
|
|
7921
|
+
elif key == "runtime.tips":
|
|
7922
|
+
runtime.tips = Config.bool({key: value}, key)
|
|
7557
7923
|
else:
|
|
7558
7924
|
return "Unknown config key: " + key
|
|
7559
7925
|
except (ConfigError, ValueError):
|
|
@@ -7568,8 +7934,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
7568
7934
|
parser.add_argument("--yolo", action="store_true", help="Skip confirmations for mutating tools")
|
|
7569
7935
|
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
|
|
7570
7936
|
parser.add_argument("--mcp", default="", help='Filter MCP servers, e.g. "orion*,!orionEval", "all", or "none"')
|
|
7571
|
-
parser.add_argument("--resume", default="", nargs="?", const="latest",
|
|
7572
|
-
help='Resume a session by UID, or "latest"/"last" for most recent')
|
|
7937
|
+
parser.add_argument("--resume", default="", nargs="?", const="latest", help='Resume a session by UID, or "latest"/"last" for most recent')
|
|
7573
7938
|
parser.add_argument("-v", "--version", action="store_true", help="Show version")
|
|
7574
7939
|
args = parser.parse_args(argv)
|
|
7575
7940
|
if args.version:
|
|
@@ -7589,7 +7954,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
7589
7954
|
)
|
|
7590
7955
|
else:
|
|
7591
7956
|
session = Session.from_config_file(path=args.config, yolo=args.yolo, debug=args.debug, mcp_selector=args.mcp)
|
|
7592
|
-
|
|
7957
|
+
try:
|
|
7958
|
+
return CommandLoop(Agent(session)).run()
|
|
7959
|
+
finally:
|
|
7960
|
+
if session.mcp is not None:
|
|
7961
|
+
session.mcp.close()
|
|
7593
7962
|
except ConfigError as error:
|
|
7594
7963
|
print("ConfigError: " + str(error), file=sys.stderr)
|
|
7595
7964
|
return 2
|
|
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
|
|
File without changes
|