nanocode-cli 0.7.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.7.1
3
+ Version: 0.7.2
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
@@ -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.1"
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": "high", "xhigh": "max"},
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 base_url(self) -> str:
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.base_url()).hostname or "").lower()
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"
@@ -1245,6 +1285,59 @@ class UpdateChecker:
1245
1285
  )
1246
1286
 
1247
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
+
1248
1341
  class Tool:
1249
1342
  NAME: ClassVar[str] = ""
1250
1343
  DESCRIPTION: ClassVar[str] = ""
@@ -1260,9 +1353,13 @@ class Tool:
1260
1353
  self.args = args
1261
1354
 
1262
1355
  @classmethod
1263
- def schema(cls) -> Json:
1356
+ def schema(cls, strict: bool = False) -> Json:
1264
1357
  description = "\n".join([cls.DESCRIPTION, "Signature: " + cls.SIGNATURE, *(("- " + item) for item in cls.EXAMPLE if item)])
1265
- return {"type": "function", "function": {"name": cls.NAME, "description": description, "parameters": cls.params_schema()}}
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}
1266
1363
 
1267
1364
  @classmethod
1268
1365
  def params_schema(cls) -> Json:
@@ -1378,7 +1475,10 @@ class ReadTool(Tool):
1378
1475
  def arg_schema(cls) -> Json:
1379
1476
  return {
1380
1477
  "type": "object",
1381
- "properties": {"path": {"type": "string"}, "ranges": {"type": "array", "minItems": 1, "items": cls.RANGE_SCHEMA}},
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
+ },
1382
1482
  "required": ["path"],
1383
1483
  "additionalProperties": False,
1384
1484
  }
@@ -1388,9 +1488,9 @@ class ReadTool(Tool):
1388
1488
  return {
1389
1489
  "type": "object",
1390
1490
  "properties": {
1391
- "path": {"type": "string"},
1392
- "ranges": {"type": "array", "items": cls.RANGE_SCHEMA, "minItems": 1},
1393
- "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"},
1394
1494
  },
1395
1495
  "additionalProperties": False,
1396
1496
  }
@@ -1488,7 +1588,7 @@ class LineCountTool(Tool):
1488
1588
  def params_schema(cls) -> Json:
1489
1589
  return {
1490
1590
  "type": "object",
1491
- "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"}},
1492
1592
  "required": ["paths"],
1493
1593
  "additionalProperties": False,
1494
1594
  }
@@ -1533,7 +1633,15 @@ class ListTool(Tool):
1533
1633
 
1534
1634
  @classmethod
1535
1635
  def params_schema(cls) -> Json:
1536
- return {"type": "object", "properties": {"path": {"type": "string"}, "glob": {"type": "string"}}, "required": ["path"], "additionalProperties": False}
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
+ }
1537
1645
 
1538
1646
  @classmethod
1539
1647
  def payload_args(cls, payload: Json) -> list[Any]:
@@ -1598,10 +1706,10 @@ class FindTool(Tool):
1598
1706
  return {
1599
1707
  "type": "object",
1600
1708
  "properties": {
1601
- "name": {"type": "string"},
1602
- "path": {"type": "string"},
1603
- "type": {"type": "string", "enum": ["file", "dir", "any"]},
1604
- "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"},
1605
1713
  },
1606
1714
  "required": ["name"],
1607
1715
  "additionalProperties": False,
@@ -1610,7 +1718,7 @@ class FindTool(Tool):
1610
1718
  @classmethod
1611
1719
  def params_schema(cls) -> Json:
1612
1720
  props = dict(cls.arg_schema()["properties"])
1613
- 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"}
1614
1722
  return {"type": "object", "properties": props, "additionalProperties": False}
1615
1723
 
1616
1724
  @classmethod
@@ -1716,10 +1824,10 @@ class SearchTool(Tool):
1716
1824
  return {
1717
1825
  "type": "object",
1718
1826
  "properties": {
1719
- "pattern": {"type": "string"},
1720
- "path": {"type": "string"},
1721
- "glob": {"type": "string"},
1722
- "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}"},
1723
1831
  },
1724
1832
  "required": ["pattern"],
1725
1833
  "additionalProperties": False,
@@ -1728,7 +1836,7 @@ class SearchTool(Tool):
1728
1836
  @classmethod
1729
1837
  def params_schema(cls) -> Json:
1730
1838
  props = dict(cls.arg_schema()["properties"])
1731
- 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"}
1732
1840
  return {"type": "object", "properties": props, "additionalProperties": False}
1733
1841
 
1734
1842
  @classmethod
@@ -2073,18 +2181,18 @@ class InspectCodeTool(Tool):
2073
2181
  @classmethod
2074
2182
  def params_schema(cls) -> Json:
2075
2183
  props = {
2076
- "mode": {"type": "string", "enum": list(cls.MODES)},
2077
- "target": {"type": "string"},
2078
- "limit": {"type": "integer", "minimum": 1, "maximum": cls.MAX_OUTLINE_LIMIT},
2079
- "kind": {"type": "string"},
2080
- "path": {"type": "string"},
2081
- "symbol": {"type": "string"},
2082
- "exact_only": {"type": "boolean"},
2083
- "depth": {"type": "integer", "minimum": 1, "maximum": cls.MAX_DEPTH},
2084
- "offset": {"type": "integer", "minimum": 0},
2085
- "all_kinds": {"type": "boolean"},
2086
- "ref_kind": {"type": "string"},
2087
- "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)"},
2088
2196
  }
2089
2197
  return {"type": "object", "properties": props, "required": ["mode", "target"], "additionalProperties": False}
2090
2198
 
@@ -2208,13 +2316,23 @@ class EditTool(Tool):
2208
2316
  def params_schema(cls) -> Json:
2209
2317
  edit = {
2210
2318
  "type": "object",
2211
- "properties": {key: {"type": "string"} for key in ("op", "start", "end", "content", "old", "new")},
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
+ },
2212
2327
  "required": ["op"],
2213
2328
  "additionalProperties": False,
2214
2329
  }
2215
2330
  return {
2216
2331
  "type": "object",
2217
- "properties": {"path": {"type": "string"}, "edits": {"type": "array", "items": edit, "minItems": 1}},
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
+ },
2218
2336
  "required": ["path", "edits"],
2219
2337
  "additionalProperties": False,
2220
2338
  }
@@ -2463,7 +2581,7 @@ class BashTool(Tool):
2463
2581
  def params_schema(cls) -> Json:
2464
2582
  return {
2465
2583
  "type": "object",
2466
- "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"}},
2467
2585
  "required": ["command"],
2468
2586
  "additionalProperties": False,
2469
2587
  }
@@ -2600,7 +2718,10 @@ class GitTool(Tool):
2600
2718
  def params_schema(cls) -> Json:
2601
2719
  return {
2602
2720
  "type": "object",
2603
- "properties": {"cwd": {"type": "string"}, "argv": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1}},
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
+ },
2604
2725
  "required": ["argv"],
2605
2726
  "additionalProperties": False,
2606
2727
  }
@@ -2711,8 +2832,8 @@ class RecallTool(Tool):
2711
2832
  return {
2712
2833
  "type": "object",
2713
2834
  "properties": {
2714
- "keys": {"type": "array", "items": {"type": "string", "pattern": "^tr\\.\\d+$"}, "minItems": 1},
2715
- "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"},
2716
2837
  },
2717
2838
  "required": ["keys"],
2718
2839
  "additionalProperties": False,
@@ -2792,21 +2913,23 @@ class NoteTool(Tool):
2792
2913
 
2793
2914
  @classmethod
2794
2915
  def params_schema(cls) -> Json:
2795
- strings = {"type": "array", "items": {"type": "string"}}
2796
2916
  plan_item = {
2797
2917
  "type": "object",
2798
- "properties": {"status": {"type": "string", "enum": list(PlanItem.STATUSES)}, "text": {"type": "string"}},
2918
+ "properties": {
2919
+ "status": {"type": "string", "enum": list(PlanItem.STATUSES), "description": "todo|doing|done|blocked"},
2920
+ "text": {"type": "string", "description": "Plan step description"},
2921
+ },
2799
2922
  "required": ["status", "text"],
2800
2923
  "additionalProperties": False,
2801
2924
  }
2802
2925
  return {
2803
2926
  "type": "object",
2804
2927
  "properties": {
2805
- "set_goal": {"type": "string"},
2806
- "replace_plan": {"type": "array", "items": plan_item},
2807
- "append_known": strings,
2808
- "replace_known": strings,
2809
- "set_check": {"type": "string"},
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"},
2810
2933
  },
2811
2934
  "additionalProperties": False,
2812
2935
  }
@@ -4114,6 +4237,33 @@ class MCPManager:
4114
4237
  def run_async(self, coroutine: Any) -> Any:
4115
4238
  return asyncio.run_coroutine_threadsafe(coroutine, self._async_loop()).result()
4116
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
+
4117
4267
  def oauth_client(self, config: MCPServerConfig, *, interactive: bool = False, notify: Callable[[str], None] | None = None) -> Any:
4118
4268
  from fastmcp.client.auth import OAuth
4119
4269
 
@@ -4816,7 +4966,7 @@ class ToolRunner:
4816
4966
  self.preview_fn: Callable[[str], bool] | None = None
4817
4967
  self.preview_full_fn: Callable[[str], None] | None = None
4818
4968
  self.live_output: Callable[[str, str], None] | None = None
4819
- self.live_start: Callable[[], None] | None = None
4969
+ self.live_start: Callable[[str], None] | None = None
4820
4970
  self.question_fn: Callable[[QuestionSpec, str], str] | None = None
4821
4971
 
4822
4972
  def run(self, calls: list[ToolCall], batch_suffix: str = "") -> list[Json]:
@@ -4985,7 +5135,7 @@ class ToolRunner:
4985
5135
  return "refused", self.finish(call, output, failed=True, elapsed=time.monotonic() - started, display=display, batch_suffix=batch_suffix)
4986
5136
  approved = True
4987
5137
  if isinstance(tool, BashTool) and self.live_start is not None:
4988
- self.live_start()
5138
+ self.live_start(str(call.args[0]) if call.args else "")
4989
5139
  output = planned_edit.call(tool) if planned_edit and isinstance(tool, EditTool) else tool.call()
4990
5140
  except ToolError as error:
4991
5141
  return "failed", self.reject(call, f"ToolError: {error}", elapsed=time.monotonic() - started, display=display, batch_suffix=batch_suffix)
@@ -5260,7 +5410,7 @@ class ModelClient:
5260
5410
  provider = self.session.config.provider
5261
5411
  if missing := self.session.missing_config():
5262
5412
  raise ModelError("missing config: " + ", ".join(missing))
5263
- tools = [tool.schema() for tool in TOOL_REGISTRY.values()]
5413
+ tools = [tool.schema(provider.resolved_strict_tools()) for tool in TOOL_REGISTRY.values()]
5264
5414
  for attempt in range(MODEL_REQUEST_RETRIES + 1):
5265
5415
  self.session.state.current_model_call_started_at = time.monotonic()
5266
5416
  try:
@@ -5301,6 +5451,8 @@ class ModelClient:
5301
5451
  messages = Text.value(messages)
5302
5452
  provider = self.session.config.provider
5303
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
5304
5456
  if tools:
5305
5457
  params["tools"] = tools
5306
5458
  params["tool_choice"] = "auto"
@@ -5391,6 +5543,8 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
5391
5543
  return ""
5392
5544
  if configured != "auto":
5393
5545
  return configured
5546
+ if not provider.supports_prompt_cache_key():
5547
+ return ""
5394
5548
  payload = {
5395
5549
  "api": provider.resolved_api(),
5396
5550
  "cwd": self.session.cwd,
@@ -5526,11 +5680,12 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
5526
5680
  return assistant, calls, text
5527
5681
 
5528
5682
  def apply_provider_params(self, params: Json, provider: ProviderConfig) -> None:
5529
- if provider.temperature is not None:
5530
- params["temperature"] = provider.temperature
5531
5683
  chat_reasoning = provider.resolved_chat_reasoning()
5532
5684
  reasoning_enabled = provider.reasoning != "off"
5533
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
5534
5689
  extra: Json = {}
5535
5690
  if reasoning_enabled and chat_reasoning == "reasoning":
5536
5691
  extra["reasoning"] = {"effort": effort}
@@ -5590,12 +5745,22 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
5590
5745
  calls.append(self.tool_call(raw.id, raw.function.name, payload))
5591
5746
  return calls
5592
5747
 
5593
- @staticmethod
5594
- def tool_payload(name: str, payload: Any) -> list[Any]:
5748
+ @classmethod
5749
+ def tool_payload(cls, name: str, payload: Any) -> list[Any]:
5595
5750
  if isinstance(payload, dict) and (tool := TOOL_REGISTRY.get(name)):
5596
- return tool.payload_args(payload)
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))
5597
5754
  return [payload]
5598
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
+
5599
5764
  @classmethod
5600
5765
  def tool_call(cls, call_id: str, name: str, payload: Any) -> ToolCall:
5601
5766
  # payload_args may reject malformed arguments (e.g. Git with an empty argv). Capture that
@@ -5639,7 +5804,8 @@ FILE STATE:
5639
5804
  - Read and Edit refresh FILE STATE; after Edit, trust the edited range.
5640
5805
 
5641
5806
  FINAL:
5642
- - Default to a few lines; scale to the task. Lead with the result; no preamble or recap.
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.
5643
5809
  - Concise markdown in the user's language; note changed files and checks run (or not run).\
5644
5810
  """
5645
5811
 
@@ -5736,6 +5902,7 @@ class CommandCompleter(Completer):
5736
5902
  "/reason",
5737
5903
  "/set",
5738
5904
  "/yolo",
5905
+ "/strict",
5739
5906
  "/exit",
5740
5907
  "/quit",
5741
5908
  )
@@ -5749,6 +5916,8 @@ class CommandCompleter(Completer):
5749
5916
  "provider.chat_reasoning",
5750
5917
  "provider.available_models",
5751
5918
  "provider.temperature",
5919
+ "provider.max_tokens",
5920
+ "provider.strict_tools",
5752
5921
  "provider.timeout",
5753
5922
  "runtime.yolo",
5754
5923
  "runtime.max_agent_steps",
@@ -5756,6 +5925,7 @@ class CommandCompleter(Completer):
5756
5925
  "runtime.max_parallel_tools",
5757
5926
  "runtime.shell_timeout",
5758
5927
  "runtime.check_updates",
5928
+ "runtime.tips",
5759
5929
  )
5760
5930
  SET_VALUES = {
5761
5931
  "provider.api": PROVIDER_API_CHOICES,
@@ -5763,8 +5933,10 @@ class CommandCompleter(Completer):
5763
5933
  "provider.reasoning": REASONING_CHOICES,
5764
5934
  "provider.chat_reasoning": CHAT_REASONING_CHOICES,
5765
5935
  "provider.temperature": ("off",),
5936
+ "provider.strict_tools": ("on", "off", "true", "false"),
5766
5937
  "runtime.yolo": ("on", "off", "true", "false"),
5767
5938
  "runtime.check_updates": ("on", "off", "true", "false"),
5939
+ "runtime.tips": ("on", "off", "true", "false"),
5768
5940
  }
5769
5941
 
5770
5942
  def __init__(
@@ -5874,10 +6046,21 @@ class UiPrinter:
5874
6046
  return [("ansibrightblack", text + "\n")]
5875
6047
  if text.startswith("nanocode "):
5876
6048
  return [("ansicyan", text + "\n")]
6049
+ if text.startswith("tip: "):
6050
+ return self.tip_segments(text[len("tip: "):])
5877
6051
  if text.startswith("Error:") or text.startswith("ConfigError:") or text.startswith("Unknown command:"):
5878
6052
  return [("ansired", text + "\n")]
5879
6053
  return [("ansiwhite", line + "\n") for line in text.splitlines() or [""]]
5880
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
+
5881
6064
  def tool_segments(self, text: str) -> list[tuple[str, str]]:
5882
6065
  segments = []
5883
6066
  for line in text.splitlines() or [""]:
@@ -6005,28 +6188,69 @@ class UiPrinter:
6005
6188
  class BashLivePreview:
6006
6189
  HEIGHT: ClassVar[int] = 6
6007
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
6008
6195
 
6009
6196
  def __init__(self):
6010
6197
  self.output = create_output(sys.stderr)
6011
6198
  self.active = False
6012
6199
  self.rendered_lines = 0
6013
6200
  self.text = ""
6201
+ self.command = ""
6202
+ self.started_at = 0.0
6203
+ self.lock = threading.Lock()
6204
+ self.timer: threading.Thread | None = None
6014
6205
 
6015
- def start(self) -> None:
6206
+ def start(self, command: str = "") -> None:
6016
6207
  if not sys.stderr.isatty():
6017
6208
  return
6018
- self.active, self.rendered_lines, self.text = True, 0, ""
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()
6019
6224
 
6020
6225
  def update(self, text: str) -> None:
6021
- if not self.active:
6022
- return
6023
- self.text = (self.text + text)[-self.MAX_CHARS :]
6024
- self.render()
6226
+ with self.lock:
6227
+ if not self.active:
6228
+ return
6229
+ self.text = (self.text + text)[-self.MAX_CHARS :]
6230
+ self.render()
6025
6231
 
6026
6232
  def finish(self) -> None:
6027
- if not self.active:
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:
6028
6246
  return
6029
- self.active, self.rendered_lines = False, 0
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()
6030
6254
 
6031
6255
  def render(self) -> None:
6032
6256
  if not self.active:
@@ -6049,11 +6273,26 @@ class BashLivePreview:
6049
6273
  self.output.flush()
6050
6274
  self.rendered_lines = len(lines)
6051
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
+
6052
6283
  def frame_lines(self) -> list[str]:
6053
6284
  width = max(20, shutil.get_terminal_size((120, 20)).columns)
6054
6285
  body = [line.expandtabs(4) for line in self.text.replace("\r", "\n").splitlines()[-self.HEIGHT :]]
6055
- limit = width - 2
6056
- return [" output", *(" " + (line if len(line) <= limit else line[: max(0, limit - 3)] + "...") for line in body)] if body else []
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)]
6057
6296
 
6058
6297
 
6059
6298
  class ModelRetryShortcut:
@@ -6320,6 +6559,46 @@ class CommandLoop:
6320
6559
  "logout": (1, 1, "Usage: /mcp logout <server>\nExample: /mcp logout myOAuthServer"),
6321
6560
  "refresh": (0, 1, "Usage: /mcp refresh [server]"),
6322
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 ""
6323
6602
  MCP_HELP = "Try /mcp, /mcp tools [server], /mcp login <server>, /mcp logout <server>, /mcp refresh [server]"
6324
6603
 
6325
6604
  HELP = """Commands:
@@ -6336,6 +6615,7 @@ class CommandLoop:
6336
6615
  /reason Select reasoning effort.
6337
6616
  /set KEY VALUE Set provider.* and runtime.*.
6338
6617
  /yolo Toggle tool confirmations.
6618
+ /strict Toggle strict tool-call schemas (OpenAI / DeepSeek).
6339
6619
  /mcp Show MCP server status.
6340
6620
  /mcp tools [NAME] List MCP tools.
6341
6621
  /mcp login NAME Start OAuth login for a server.
@@ -6550,6 +6830,8 @@ Tools:
6550
6830
 
6551
6831
  def run(self) -> int:
6552
6832
  self.emit(f"nanocode {__version__}. /help for commands.")
6833
+ if tip := self.startup_tip():
6834
+ self.emit("tip: " + tip)
6553
6835
  self.session.clean_expired_snapshots()
6554
6836
  self.render_resumed_session()
6555
6837
  CodeIndex(self.session).refresh_existing_async()
@@ -6975,7 +7257,7 @@ Tools:
6975
7257
  sys.stdout.flush()
6976
7258
  self.transient_tool_lines = 0
6977
7259
 
6978
- def tool_live_start(self) -> None:
7260
+ def tool_live_start(self, command: str = "") -> None:
6979
7261
  if not self.ui.color:
6980
7262
  return
6981
7263
  self.live_queue_paused = self.interactive_input and not self.queue_input_paused.is_set()
@@ -6984,7 +7266,7 @@ Tools:
6984
7266
  self.live_status_paused = self.status_bar.is_running()
6985
7267
  if self.live_status_paused:
6986
7268
  self.status_bar.stop()
6987
- self.live_preview.start()
7269
+ self.live_preview.start(command)
6988
7270
 
6989
7271
  def tool_live_output(self, _stream: str, text: str) -> None:
6990
7272
  if not self.ui.color:
@@ -7030,6 +7312,7 @@ Tools:
7030
7312
  "/reason": self.reason,
7031
7313
  "/set": self.set_value,
7032
7314
  "/yolo": self.yolo,
7315
+ "/strict": self.strict,
7033
7316
  "/mcp": self.mcp_command,
7034
7317
  }
7035
7318
  handler = handlers.get(name)
@@ -7401,6 +7684,8 @@ Tools:
7401
7684
  f"provider.resolved_chat_reasoning: {provider.resolved_chat_reasoning()}",
7402
7685
  f"provider.chat_reasoning: {provider.chat_reasoning}",
7403
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()})",
7404
7689
  f"provider.timeout: {provider.timeout}",
7405
7690
  f"paths.data_dir: {self.session.data_path()}",
7406
7691
  f"runtime.shell_timeout: {self.session.settings.shell_timeout}",
@@ -7583,6 +7868,16 @@ Tools:
7583
7868
  self.session.settings.yolo = not self.session.settings.yolo
7584
7869
  return "yolo: " + ("on" if self.session.settings.yolo else "off")
7585
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
+
7586
7881
  def set_value(self, args: str) -> str:
7587
7882
  key, _, value = args.partition(" ")
7588
7883
  if not key or not value:
@@ -7603,6 +7898,10 @@ Tools:
7603
7898
  provider.available_models = tuple(item.strip() for item in value.split(",") if item.strip())
7604
7899
  elif key == "provider.temperature":
7605
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)
7606
7905
  elif key == "provider.timeout":
7607
7906
  provider.timeout = max(1, int(value))
7608
7907
  elif key == "runtime.yolo":
@@ -7619,6 +7918,8 @@ Tools:
7619
7918
  runtime.shell_timeout = max(1, int(value))
7620
7919
  elif key == "runtime.max_parallel_tools":
7621
7920
  runtime.max_parallel_tools = max(1, int(value))
7921
+ elif key == "runtime.tips":
7922
+ runtime.tips = Config.bool({key: value}, key)
7622
7923
  else:
7623
7924
  return "Unknown config key: " + key
7624
7925
  except (ConfigError, ValueError):
@@ -7653,7 +7954,11 @@ def main(argv: list[str] | None = None) -> int:
7653
7954
  )
7654
7955
  else:
7655
7956
  session = Session.from_config_file(path=args.config, yolo=args.yolo, debug=args.debug, mcp_selector=args.mcp)
7656
- return CommandLoop(Agent(session)).run()
7957
+ try:
7958
+ return CommandLoop(Agent(session)).run()
7959
+ finally:
7960
+ if session.mcp is not None:
7961
+ session.mcp.close()
7657
7962
  except ConfigError as error:
7658
7963
  print("ConfigError: " + str(error), file=sys.stderr)
7659
7964
  return 2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.7.1
3
+ Version: 0.7.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nanocode-cli"
7
- version = "0.7.1"
7
+ version = "0.7.2"
8
8
  description = "A small terminal coding agent written in Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
File without changes
File without changes
File without changes
File without changes