nanocode-cli 0.2.7__tar.gz → 0.2.8__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.2.7
3
+ Version: 0.2.8
4
4
  Summary: A lightweight terminal-based AI coding assistant
5
5
  Author-email: hit9 <hit9@icloud.com>
6
6
  License-Expression: BSD-3-Clause
@@ -21,6 +21,7 @@ Classifier: Topic :: Terminals
21
21
  Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
+ Requires-Dist: json-repair>=0.39
24
25
  Requires-Dist: prompt-toolkit>=3.0
25
26
  Requires-Dist: typing-extensions>=4.7
26
27
  Provides-Extra: dev
@@ -76,6 +77,7 @@ export NANOCODE_DIR=".nanocode"
76
77
  export NANOCODE_TEMPERATURE="0.7"
77
78
  export NANOCODE_REASONING="on"
78
79
  export NANOCODE_REASONING_EFFORT="medium"
80
+ export NANOCODE_STREAM="on"
79
81
  export NANOCODE_MODEL_TIMEOUT="60"
80
82
  export NANOCODE_SHELL_TIMEOUT="60"
81
83
  export NANOCODE_COMPACT_AT="100"
@@ -46,6 +46,7 @@ export NANOCODE_DIR=".nanocode"
46
46
  export NANOCODE_TEMPERATURE="0.7"
47
47
  export NANOCODE_REASONING="on"
48
48
  export NANOCODE_REASONING_EFFORT="medium"
49
+ export NANOCODE_STREAM="on"
49
50
  export NANOCODE_MODEL_TIMEOUT="60"
50
51
  export NANOCODE_SHELL_TIMEOUT="60"
51
52
  export NANOCODE_COMPACT_AT="100"
@@ -11,6 +11,7 @@ import fnmatch
11
11
  import hashlib
12
12
  import itertools
13
13
  import json
14
+ import json_repair
14
15
  import os
15
16
  import platform
16
17
  import re
@@ -41,7 +42,7 @@ from prompt_toolkit.patch_stdout import patch_stdout
41
42
 
42
43
  JsonValue: TypeAlias = Any
43
44
  Json: TypeAlias = dict[str, JsonValue]
44
- __version__ = "0.2.7"
45
+ __version__ = "0.2.8"
45
46
 
46
47
 
47
48
  class Error(Exception): ...
@@ -350,14 +351,23 @@ class RangeFingerprintStore:
350
351
  if current_fingerprint == fingerprint:
351
352
  return self.Resolved(start=resolved_start, end=resolved_end, fingerprint=current_fingerprint)
352
353
 
353
- matches = self._find_matches(lines, filepath=filepath, start=start, end=end, fingerprint=fingerprint)
354
+ for content in self._candidate_contents(
355
+ filepath=filepath,
356
+ start=resolved_start,
357
+ end=resolved_end,
358
+ fingerprint=fingerprint,
359
+ ):
360
+ if _range_fingerprint(content) == current_fingerprint:
361
+ return self.Resolved(start=resolved_start, end=resolved_end, fingerprint=current_fingerprint)
362
+
363
+ matches = self._find_matches(lines, filepath=filepath, start=resolved_start, end=resolved_end, fingerprint=fingerprint)
354
364
  message = (
355
365
  f"fingerprint mismatch for range {start}:{end}: expected {fingerprint}, current {current_fingerprint}; "
356
366
  f"call Read(filepath, {start}, {end}) and reuse that range fingerprint"
357
367
  )
358
368
  other_ranges = self._ranges_for_fingerprint(filepath=filepath, fingerprint=fingerprint)
359
369
  if other_ranges:
360
- message += "; this fingerprint was cached for exact range(s): " + ", ".join(f"{range_start}:{range_end}" for range_start, range_end in other_ranges)
370
+ message += "; this fingerprint was cached for range(s): " + ", ".join(f"{range_start}:{range_end}" for range_start, range_end in other_ranges)
361
371
  if not matches:
362
372
  raise ToolCallError(message)
363
373
  if len(matches) > 1:
@@ -366,18 +376,16 @@ class RangeFingerprintStore:
366
376
  return self.Resolved(
367
377
  start=relocated_start,
368
378
  end=relocated_end,
369
- fingerprint=fingerprint,
379
+ fingerprint=_range_fingerprint("".join(lines[relocated_start:relocated_end])),
370
380
  relocated_from=(resolved_start, resolved_end),
371
381
  )
372
382
 
373
383
  def _find_matches(self, lines: list[str], *, filepath: str, start: int, end: int, fingerprint: str) -> list[tuple[int, int]]:
374
- filepath = os.path.realpath(filepath)
375
- contents = []
376
- for entry in self._entries:
377
- if entry.fingerprint != fingerprint or entry.filepath != filepath or entry.start != start or entry.end != end or not entry.content:
378
- continue
379
- if entry.content not in contents:
380
- contents.append(entry.content)
384
+ contents = [
385
+ content
386
+ for content in self._candidate_contents(filepath=filepath, start=start, end=end, fingerprint=fingerprint)
387
+ if content
388
+ ]
381
389
 
382
390
  matches = []
383
391
  for content in contents:
@@ -392,6 +400,25 @@ class RangeFingerprintStore:
392
400
  return matches
393
401
  return matches
394
402
 
403
+ def _candidate_contents(self, *, filepath: str, start: int, end: int, fingerprint: str) -> list[str]:
404
+ filepath = os.path.realpath(filepath)
405
+ contents: list[str] = []
406
+ for entry in self._entries:
407
+ if entry.fingerprint != fingerprint or entry.filepath != filepath:
408
+ continue
409
+ if start == end:
410
+ if entry.start == start and entry.end == end and entry.content == "":
411
+ contents.append("")
412
+ continue
413
+ entry_lines = entry.content.splitlines(keepends=True)
414
+ cached_end = entry.start + len(entry_lines)
415
+ if start < entry.start or end > cached_end:
416
+ continue
417
+ candidate = "".join(entry_lines[start - entry.start : end - entry.start])
418
+ if candidate not in contents:
419
+ contents.append(candidate)
420
+ return contents
421
+
395
422
  def _ranges_for_fingerprint(self, *, filepath: str, fingerprint: str) -> list[tuple[int, int]]:
396
423
  filepath = os.path.realpath(filepath)
397
424
  ranges = []
@@ -427,6 +454,7 @@ class Session:
427
454
  temperature: float = field(default_factory=lambda: float(os.environ.get("NANOCODE_TEMPERATURE", "0.7")))
428
455
  reasoning: bool = field(default_factory=lambda: os.environ.get("NANOCODE_REASONING", "on") == "on")
429
456
  reasoning_effort: str = field(default_factory=lambda: os.environ.get("NANOCODE_REASONING_EFFORT", "medium"))
457
+ stream: bool = field(default_factory=lambda: os.environ.get("NANOCODE_STREAM", "on") == "on")
430
458
  model_timeout: int = field(default_factory=lambda: int(os.environ.get("NANOCODE_MODEL_TIMEOUT", "60")))
431
459
  shell_timeout: int = field(default_factory=lambda: int(os.environ.get("NANOCODE_SHELL_TIMEOUT", "60")))
432
460
  compact_at: int = field(default_factory=lambda: int(os.environ.get("NANOCODE_COMPACT_AT", "100")))
@@ -546,6 +574,7 @@ ConfirmationResult: TypeAlias = bool | str
546
574
  ConfirmCallback: TypeAlias = Callable[[ParsedToolCall, Tool], ConfirmationResult]
547
575
  ToolDisplayCallback: TypeAlias = Callable[[ParsedToolCall, Tool], None]
548
576
  MessageCallback: TypeAlias = Callable[[str], None]
577
+ ActionCallback: TypeAlias = Callable[[Json], None]
549
578
  StatusAction: TypeAlias = Callable[[], str]
550
579
  StatusRunner: TypeAlias = Callable[[StatusAction], str]
551
580
 
@@ -607,7 +636,7 @@ class ReadTool(Tool):
607
636
  "Optional range is 0-based [start,end); end=0 means EOF.",
608
637
  "Returns at most 1000 lines; truncated results include total lines and next-step guidance.",
609
638
  "Prefer Search before Read for large or unknown files; use bounded reads when exact context is needed.",
610
- "For ReplaceRange, the fingerprint is valid only for this exact filepath/start/end; read the exact edit range immediately before replacing.",
639
+ "For ReplaceRange, non-empty subranges may use a wider cached Read fingerprint that covers them; empty insert ranges require an exact empty-range Read.",
611
640
  ]
612
641
 
613
642
  @classmethod
@@ -850,11 +879,12 @@ class SearchTool(Tool):
850
879
 
851
880
  @classmethod
852
881
  def signature(cls) -> str:
853
- return "Search(pattern, path[, option...]) -> SearchToolResult<matches>; option is context=N|N (0..30) or glob_pattern"
882
+ return "Search(pattern[, path][, option...]) -> SearchToolResult<matches>; option is context=N|N (0..30) or glob_pattern"
854
883
 
855
884
  @classmethod
856
885
  def example(cls) -> list[str]:
857
886
  return [
887
+ 'Example args: ["TODO"]',
858
888
  'Example args: ["class Foo", "code.py"]',
859
889
  'Example args: ["TODO", ".", "*.py"]',
860
890
  'Example args: ["class Bar|def main", "nanocode.py", "context=6"]',
@@ -864,8 +894,8 @@ class SearchTool(Tool):
864
894
 
865
895
  @classmethod
866
896
  def make(cls, session: Session, args: list[str]) -> Self:
867
- if len(args) not in (2, 3, 4):
868
- raise ToolCallError("requires 2 to 4 args: pattern, path[, glob_pattern][, context=N]")
897
+ if len(args) not in (1, 2, 3, 4):
898
+ raise ToolCallError("requires 1 to 4 args: pattern[, path][, glob_pattern][, context=N]")
869
899
  raw_pattern = str(args[0])
870
900
  if not raw_pattern:
871
901
  raise ToolCallError("pattern cannot be empty")
@@ -875,6 +905,9 @@ class SearchTool(Tool):
875
905
  raise ToolCallError("pattern cannot be empty")
876
906
  if regex and "\n" in pattern:
877
907
  raise ToolCallError("multiline regex is not supported; Search is line-oriented. Search each line separately or Read a nearby range.")
908
+ target_path_arg = str(args[1]) if len(args) >= 2 else "."
909
+ if not target_path_arg:
910
+ target_path_arg = "."
878
911
  glob_pattern = ""
879
912
  context_lines = cls.CONTEXT_LINES
880
913
  for raw_option in args[2:]:
@@ -904,7 +937,7 @@ class SearchTool(Tool):
904
937
  pattern=raw_pattern,
905
938
  patterns=patterns,
906
939
  regex=regex,
907
- target_path=session.resolve_path(args[1]),
940
+ target_path=session.resolve_path(target_path_arg),
908
941
  glob_pattern=glob_pattern,
909
942
  context_lines=context_lines,
910
943
  cwd=session.cwd,
@@ -1205,8 +1238,8 @@ class ReplaceRangeTool(Tool):
1205
1238
  @classmethod
1206
1239
  def description(cls) -> list[str]:
1207
1240
  return [
1208
- "Replace one 0-based line range when its fingerprint comes from Read(filepath, exact same start, exact same end).",
1209
- "Never use a wider Read fingerprint for a narrower edit; if mismatch happens, Read the exact target range and retry once.",
1241
+ "Replace one 0-based line range when its fingerprint comes from Read(filepath, ...).",
1242
+ "If a wider cached Read range covers this target range, the tool may slice and validate the narrower range automatically; otherwise Read the exact target range and retry once.",
1210
1243
  "If earlier edits shifted lines, a cached Read fingerprint for the same original range can relocate only when old content still matches exactly once.",
1211
1244
  ]
1212
1245
 
@@ -1314,7 +1347,7 @@ class BatchReplaceRangesTool(Tool):
1314
1347
  return [
1315
1348
  "Replace multiple 0-based line ranges in one file against one snapshot.",
1316
1349
  "Use this for multiple edits in the same file; earlier edits in this call do not shift later ranges.",
1317
- "Each edit fingerprint must come from Read(filepath, exact same start, exact same end); never reuse a wider Read fingerprint for a narrower edit.",
1350
+ "Each edit fingerprint must come from Read(filepath, ...); if a wider cached range covers the target range, it may be sliced and validated automatically.",
1318
1351
  "Same-range cached fingerprints can relocate shifted old content.",
1319
1352
  ]
1320
1353
 
@@ -1465,7 +1498,7 @@ class ApplyPatchTool(Tool):
1465
1498
  try:
1466
1499
  with open(self.filepath, "r", encoding="utf-8") as f:
1467
1500
  original = f.read()
1468
- new_content, _ = self._apply_unified_diff(original, self.unified_diff)
1501
+ new_content, _ = self._apply_unified_diff(original, self._normalized_unified_diff())
1469
1502
  except (OSError, ToolCallError) as error:
1470
1503
  return label + "\n# preview unavailable: " + str(error)
1471
1504
  return _make_unified_diff(original, new_content, self.filepath) or label
@@ -1473,7 +1506,7 @@ class ApplyPatchTool(Tool):
1473
1506
  def call(self) -> str:
1474
1507
  with open(self.filepath, "r", encoding="utf-8") as f:
1475
1508
  original = f.read()
1476
- new_content, hunks = self._apply_unified_diff(original, self.unified_diff)
1509
+ new_content, hunks = self._apply_unified_diff(original, self._normalized_unified_diff())
1477
1510
  if new_content == original:
1478
1511
  raise ToolCallError("patch produced no changes")
1479
1512
  with open(self.filepath, "w", encoding="utf-8") as f:
@@ -1488,6 +1521,58 @@ class ApplyPatchTool(Tool):
1488
1521
  ]
1489
1522
  )
1490
1523
 
1524
+ def _normalized_unified_diff(self) -> str:
1525
+ lines = self.unified_diff.splitlines(keepends=True)
1526
+ begin_index = next((index for index, line in enumerate(lines) if line.strip()), -1)
1527
+ if begin_index < 0 or lines[begin_index].strip() != "*** Begin Patch":
1528
+ return self.unified_diff
1529
+ return self._codex_update_patch_to_unified_diff(lines, begin_index)
1530
+
1531
+ def _codex_update_patch_to_unified_diff(self, lines: list[str], begin_index: int) -> str:
1532
+ update_seen = False
1533
+ end_seen = False
1534
+ hunk_lines: list[str] = []
1535
+ for line in lines[begin_index + 1 :]:
1536
+ stripped = line.strip()
1537
+ if stripped == "*** End Patch":
1538
+ end_seen = True
1539
+ break
1540
+ if stripped.startswith("*** Update File: "):
1541
+ if update_seen:
1542
+ raise ToolCallError("ApplyPatch supports one Update File per call")
1543
+ self._validate_codex_patch_path(stripped[len("*** Update File: ") :].strip())
1544
+ update_seen = True
1545
+ continue
1546
+ if stripped.startswith(("*** Add File:", "*** Delete File:", "*** Move to:")):
1547
+ raise ToolCallError("ApplyPatch supports only Update File patches")
1548
+ if stripped == "*** End of File":
1549
+ continue
1550
+ if not update_seen:
1551
+ if stripped:
1552
+ raise ToolCallError("invalid ApplyPatch wrapper")
1553
+ continue
1554
+ hunk_lines.append(self._normalize_codex_hunk_header(line))
1555
+ if not update_seen:
1556
+ raise ToolCallError("ApplyPatch wrapper missing Update File")
1557
+ if not end_seen:
1558
+ raise ToolCallError("ApplyPatch wrapper missing End Patch")
1559
+ return "".join(hunk_lines)
1560
+
1561
+ def _validate_codex_patch_path(self, patch_path: str) -> None:
1562
+ if not patch_path:
1563
+ raise ToolCallError("ApplyPatch wrapper missing Update File path")
1564
+ candidate = patch_path if os.path.isabs(patch_path) else os.path.join(self.cwd, patch_path)
1565
+ if os.path.realpath(candidate) != os.path.realpath(self.filepath):
1566
+ raise ToolCallError("patch target does not match filepath: " + patch_path)
1567
+
1568
+ @staticmethod
1569
+ def _normalize_codex_hunk_header(line: str) -> str:
1570
+ body = line.rstrip("\r\n")
1571
+ newline = line[len(body) :]
1572
+ if body.startswith("@@ ") and not body.startswith("@@ -"):
1573
+ return "@@" + newline
1574
+ return line
1575
+
1491
1576
  @staticmethod
1492
1577
  def _apply_unified_diff(content: str, unified_diff: str) -> tuple[str, int]:
1493
1578
  lines = content.splitlines(keepends=True)
@@ -1813,17 +1898,20 @@ Core:
1813
1898
  - Follow the plan, but revise it when facts require it.
1814
1899
 
1815
1900
  Tools:
1816
- - MUST use JSON tool_calls. Do not use native <tool_call>Tool(args...) syntax.
1817
- - Use multiple tool calls in one turn when they are independent.
1901
+ - MUST use tool actions. Do not use native <tool_call>Tool(args...) syntax.
1902
+ - Keep each tool batch small: never more than 5 tool actions unless the user explicitly asked for broad parallel work.
1903
+ - Use multiple tool calls in one turn only when they are independent and do not require seeing another tool's result first.
1904
+ - If you need discovery (ListDir/Search/Read/LineCount), do only the necessary discovery batch, then stop and wait for results before editing, patching, testing, or cleanup.
1905
+ - Do not queue speculative follow-up tools whose arguments depend on unread files, unknown search results, or unverified command output.
1818
1906
  - Prefer specific tools first; use Bash only when no provided tool fits.
1819
1907
  - Prefer Search before Read when locating code or facts; Read only known small ranges or exact files needed for editing.
1820
1908
  - Read returns at most 1000 lines; if truncated, use Search or smaller Read ranges in batches.
1821
- - ReplaceRange/BatchReplaceRanges fingerprints are valid only for the exact filepath/start/end returned by Read. Never use a wider Read fingerprint for a narrower edit. If fingerprint mismatch happens, immediately Read the exact target range and retry once.
1822
- - Summarize every latest tool result in last_tool_calls_summaries; raw results are shown once only, so include key_evidence when paths, lines, errors, or decisions matter later.
1909
+ - ReplaceRange/BatchReplaceRanges may use a wider cached Read fingerprint for a non-empty subrange that it covers. Empty insert ranges require an exact empty-range Read. If fingerprint mismatch happens, immediately Read the exact target range and retry once.
1910
+ - Summarize every latest tool result with tool_summary actions; raw results are shown once only, so include key_evidence when paths, lines, errors, or decisions matter later.
1823
1911
  - Latest tool results are already shown in Latest_Tool_Call_Results; use result_file logs only as a fallback when needed.
1824
1912
  - If an older tool result lacks detail that is needed for the task, prefer re-running a targeted source tool; Read result_file logs only when that is the cheapest accurate source.
1825
- - known_append is stable memory; current_context_update is task-local memory.
1826
- - tool_call.intention must state the question to answer, not just the action.
1913
+ - known actions are stable memory; context actions are task-local memory.
1914
+ - tool action intention must state the question to answer, not just the action.
1827
1915
 
1828
1916
  Verification:
1829
1917
  - Verification_State belongs only to its <goal>.
@@ -1844,77 +1932,40 @@ Input:
1844
1932
  - Latest_User_Input: latest user message
1845
1933
  - Tools: available tool specs
1846
1934
 
1847
- Output MUST be exactly one JSON object.
1848
- No markdown, prose, code fences, XML tags, native tool calls, or text outside JSON.
1849
- Put normal replies in message_to_user.
1850
- Put tool calls only in JSON tool_calls.
1851
-
1852
- Schema:
1935
+ Output format is mandatory:
1936
+ - Output action frames only.
1937
+ - Each action frame MUST contain exactly one JSON object action.
1938
+ - Each action frame MUST end with a separator line containing only __END_ACTION__.
1939
+ - The separator is required after every action, including the final action.
1940
+ - Pretty-printed multi-line JSON is allowed inside a frame.
1941
+ - Do not wrap actions in {"actions":[...]}.
1942
+ - Do not output a JSON array.
1943
+ - Do not output markdown, prose, code fences, XML tags, native tool calls, or text outside action frames.
1944
+ - Include only actions that are needed; do not emit null/no-op fields.
1945
+
1946
+ Action schemas:
1947
+ {"type": "message", "text": "string"}
1948
+ {"type": "tool", "name": "string", "intention": "string", "args": ["string"]}
1949
+ {"type": "tool_summary", "tool": "string", "intention": "string", "outcome": "success|failure|partial", "summary": "string", "key_evidence": null | ["string"], "result_file": null | "string", "needs_raw_read": true | false}
1950
+ {"type": "goal", "text": "string"}
1951
+ {"type": "plan", "mode": "replace|patch", "items": [{"op": "add|update|remove", "id": "string", "after": null | "string", "text": null | "string", "status": null | "todo|doing|done|blocked", "evidence": null | "string"}]}
1952
+ {"type": "known", "items": [{"fact": "string", "details": null | ["string"]}]}
1953
+ {"type": "context", "mode": "replace|append", "items": [{"note": "string", "details": null | ["string"]}]}
1954
+ {"type": "verify", "method": null | "string", "status": "pending|passed|blocked", "evidence": null | "string"}
1955
+
1956
+ Example:
1957
+ {
1958
+ "type": "tool",
1959
+ "name": "Read",
1960
+ "intention": "Inspect SearchTool.make.",
1961
+ "args": ["nanocode.py", "880", "930"]
1962
+ }
1963
+ __END_ACTION__
1853
1964
  {
1854
- "user_language": "string",
1855
-
1856
- "goal_update": null | "string",
1857
- "goal_reached": true | false,
1858
-
1859
- "plan_update": null | {
1860
- "mode": "replace" | "patch",
1861
- "items": [
1862
- {
1863
- "op": "add|update|remove",
1864
- "id": "string",
1865
- "after": null | "string",
1866
- "text": null | "string",
1867
- "status": null | "todo|doing|done|blocked",
1868
- "evidence": null | "string"
1869
- }
1870
- ]
1871
- },
1872
-
1873
- "known_append": null | [
1874
- {
1875
- "fact": "string",
1876
- "details": null | ["string"]
1877
- }
1878
- ],
1879
-
1880
- "current_context_update": null | {
1881
- "mode": "replace" | "append",
1882
- "items": [
1883
- {
1884
- "note": "string",
1885
- "details": null | ["string"]
1886
- }
1887
- ]
1888
- },
1889
-
1890
- "verification": {
1891
- "method": null | "string",
1892
- "status": "pending" | "passed" | "blocked",
1893
- "evidence": null | "string"
1894
- },
1895
-
1896
- "tool_calls": null | [
1897
- {
1898
- "name": "string",
1899
- "intention": "string",
1900
- "args": ["string"]
1901
- }
1902
- ],
1903
-
1904
- "last_tool_calls_summaries": [
1905
- {
1906
- "tool": "string",
1907
- "intention": "string",
1908
- "outcome": "success" | "failure" | "partial",
1909
- "summary": "string",
1910
- "key_evidence": null | ["string"],
1911
- "result_file": null | "string",
1912
- "needs_raw_read": true | false
1913
- }
1914
- ],
1915
-
1916
- "message_to_user": null | "string"
1965
+ "type": "message",
1966
+ "text": "Done."
1917
1967
  }
1968
+ __END_ACTION__
1918
1969
  """
1919
1970
 
1920
1971
  MAIN_AGENT_USER_PROMPT_TEMPLATE = """
@@ -2059,10 +2110,14 @@ class PromptBuilder:
2059
2110
 
2060
2111
  @final
2061
2112
  class ModelClient:
2113
+ ACTION_FRAME_END: ClassVar[str] = "__END_ACTION__"
2114
+ ACTION_FRAME_END_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^\s*\**_*\s*END[\s_-]*ACTION\s*_*\**\s*$", re.IGNORECASE)
2115
+ ACTION_FRAME_END_SPLIT_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"\**_*\s*END[\s_-]*ACTION\s*_*\**", re.IGNORECASE)
2116
+
2062
2117
  def __init__(self, session: Session):
2063
2118
  self.session = session
2064
2119
 
2065
- def request(self, system_prompt: str, user_prompt: str, *, activity: str = "main") -> Json:
2120
+ def request(self, system_prompt: str, user_prompt: str, *, activity: str = "main", on_action: ActionCallback | None = None) -> Json:
2066
2121
  if not self.session.api_url:
2067
2122
  raise LLMError("NANOCODE_API_URL is required")
2068
2123
  if not self.session.api_key:
@@ -2078,8 +2133,10 @@ class ModelClient:
2078
2133
  "model": self.session.model,
2079
2134
  "messages": messages,
2080
2135
  "temperature": self.session.temperature,
2081
- "response_format": {"type": "json_object"},
2082
2136
  }
2137
+ if self.session.stream:
2138
+ payload["stream"] = True
2139
+ payload["stream_options"] = {"include_usage": True}
2083
2140
  extra_params = self._reasoning_params()
2084
2141
  payload.update(extra_params)
2085
2142
  self._write_debug_prompt(activity=activity, messages=messages)
@@ -2096,7 +2153,11 @@ class ModelClient:
2096
2153
  self.session.current_model_call_started_at = time.monotonic()
2097
2154
  try:
2098
2155
  with urllib.request.urlopen(request, timeout=self.session.model_timeout) as response:
2099
- body = response.read().decode("utf-8")
2156
+ if self.session.stream:
2157
+ content, usage = self._read_streaming_content(response, on_action=on_action)
2158
+ result: Json = {"usage": usage}
2159
+ else:
2160
+ body = response.read().decode("utf-8")
2100
2161
  finally:
2101
2162
  self.session.current_model_call_started_at = 0.0
2102
2163
  except socket.timeout:
@@ -2107,17 +2168,57 @@ class ModelClient:
2107
2168
  except Exception as error:
2108
2169
  raise LLMError(str(error))
2109
2170
 
2110
- try:
2111
- result = json.loads(body)
2112
- except json.JSONDecodeError:
2113
- raise LLMError("API response is not JSON: " + _shorten(body))
2171
+ if not self.session.stream:
2172
+ try:
2173
+ result = json.loads(body)
2174
+ except json.JSONDecodeError:
2175
+ raise LLMError("API response is not JSON: " + _shorten(body))
2114
2176
 
2115
2177
  self._record_usage(_json_dict(result.get("usage") if isinstance(result, dict) else None))
2116
- content = self._message_content(result)
2178
+ if not self.session.stream:
2179
+ content = self._message_content(result)
2117
2180
  if content is None:
2118
2181
  return self._invalid_model_response(self._format_missing_message_content(result))
2119
2182
  return self._parse_model_content(content)
2120
2183
 
2184
+ def _read_streaming_content(self, response: Any, *, on_action: ActionCallback | None = None) -> tuple[str, Json]:
2185
+ parts: list[str] = []
2186
+ usage: Json = {}
2187
+ buffer = ""
2188
+ frame_number = 0
2189
+ for raw_line in response:
2190
+ line = raw_line.decode("utf-8", errors="replace").strip()
2191
+ if not line or line.startswith(":") or not line.startswith("data:"):
2192
+ continue
2193
+ data = line[len("data:") :].strip()
2194
+ if data == "[DONE]":
2195
+ break
2196
+ try:
2197
+ event = json.loads(data)
2198
+ except json.JSONDecodeError:
2199
+ continue
2200
+ event_data = _json_dict(event)
2201
+ event_usage = _json_dict(event_data.get("usage"))
2202
+ if event_usage:
2203
+ usage = event_usage
2204
+ choices = _json_list(event_data.get("choices"))
2205
+ if not choices:
2206
+ continue
2207
+ delta = _json_dict(_json_dict(choices[0]).get("delta"))
2208
+ content = delta.get("content")
2209
+ if not isinstance(content, str):
2210
+ continue
2211
+ parts.append(content)
2212
+ if on_action is not None:
2213
+ buffer += content
2214
+ frames, buffer = self._completed_action_frames(buffer)
2215
+ for frame in frames:
2216
+ frame_number += 1
2217
+ action, _error = self._parse_action_frame(frame, frame_number)
2218
+ if action is not None:
2219
+ on_action(action)
2220
+ return "".join(parts), usage
2221
+
2121
2222
  def _write_debug_prompt(self, *, activity: str, messages: list[Json]) -> str:
2122
2223
  if not self.session.debug:
2123
2224
  return ""
@@ -2148,13 +2249,79 @@ class ModelClient:
2148
2249
  text = self._strip_leaked_think_tags(text)
2149
2250
  text = self._strip_json_fence(text)
2150
2251
  text = self._strip_leaked_think_tags(text)
2252
+ actions: list[Json] = []
2253
+ frame_errors: list[str] = []
2254
+ for frame_number, frame in enumerate(self._action_frames(text), start=1):
2255
+ action, error = self._parse_action_frame(frame, frame_number)
2256
+ if action is not None:
2257
+ actions.append(action)
2258
+ continue
2259
+ if error:
2260
+ frame_errors.append(error)
2261
+ if not actions:
2262
+ reason = "expected at least one valid action frame ending with " + self.ACTION_FRAME_END
2263
+ if frame_errors:
2264
+ reason += "; " + "; ".join(frame_errors[:3])
2265
+ return self._invalid_model_response(content, reason)
2266
+ response: Json = {"actions": actions}
2267
+ if frame_errors:
2268
+ response["_format_frame_errors"] = frame_errors
2269
+ return response
2270
+
2271
+ def _action_frames(self, text: str) -> list[str]:
2272
+ frames: list[str] = []
2273
+ current: list[str] = []
2274
+ for line in text.splitlines():
2275
+ if not self._has_action_frame_end(line):
2276
+ current.append(line)
2277
+ continue
2278
+ parts = self.ACTION_FRAME_END_SPLIT_PATTERN.split(line)
2279
+ for index, part in enumerate(parts):
2280
+ if part:
2281
+ current.append(part)
2282
+ if index < len(parts) - 1:
2283
+ frames.append("\n".join(current).strip())
2284
+ current = []
2285
+ trailing = "\n".join(current).strip()
2286
+ if trailing:
2287
+ frames.append(trailing)
2288
+ return frames
2289
+
2290
+ def _completed_action_frames(self, text: str) -> tuple[list[str], str]:
2291
+ frames: list[str] = []
2292
+ current: list[str] = []
2293
+ for line in text.splitlines(keepends=True):
2294
+ if not self._has_action_frame_end(line):
2295
+ current.append(line)
2296
+ continue
2297
+ parts = self.ACTION_FRAME_END_SPLIT_PATTERN.split(line)
2298
+ for index, part in enumerate(parts):
2299
+ if part:
2300
+ current.append(part)
2301
+ if index < len(parts) - 1:
2302
+ frames.append("".join(current).strip())
2303
+ current = []
2304
+ return frames, "".join(current)
2305
+
2306
+ def _parse_action_frame(self, frame: str, frame_number: int) -> tuple[Json | None, str]:
2307
+ frame = frame.strip()
2308
+ if not frame:
2309
+ return None, ""
2151
2310
  try:
2152
- value = json.loads(text)
2153
- except json.JSONDecodeError:
2154
- return self._invalid_model_response(content)
2311
+ value = json_repair.loads(frame)
2312
+ except Exception as error:
2313
+ return None, "frame " + str(frame_number) + ": " + str(error)
2155
2314
  if not isinstance(value, dict):
2156
- return self._invalid_model_response(content)
2157
- return value
2315
+ return None, "frame " + str(frame_number) + ": expected JSON object action"
2316
+ if not _json_str(value.get("type")):
2317
+ return None, "frame " + str(frame_number) + ": action missing type"
2318
+ return value, ""
2319
+
2320
+ def _has_action_frame_end(self, line: str) -> bool:
2321
+ return self.ACTION_FRAME_END_SPLIT_PATTERN.search(line) is not None
2322
+
2323
+ def _is_action_frame_end(self, line: str) -> bool:
2324
+ return self.ACTION_FRAME_END_PATTERN.match(line) is not None
2158
2325
 
2159
2326
  def _strip_json_fence(self, text: str) -> str:
2160
2327
  if not text.startswith("```"):
@@ -2179,18 +2346,19 @@ class ModelClient:
2179
2346
  text = text[len("</think>") :].lstrip()
2180
2347
  return text
2181
2348
 
2182
- def _invalid_model_response(self, content: str) -> Json:
2349
+ def _invalid_model_response(self, content: str, reason: str = "expected one JSON object matching the Output JSON schema") -> Json:
2183
2350
  guidance = ""
2184
2351
  if self._looks_like_native_tool_call(content):
2185
2352
  guidance = (
2186
- " Native tool_call syntax is not supported; return one JSON object with tool_calls entries like "
2187
- '{"name":"Read","intention":"...","args":["nanocode.py","0","100"]}.'
2353
+ " Native tool_call syntax is not supported; return an action frame like "
2354
+ '{"type":"tool","name":"Read","intention":"...","args":["nanocode.py","0","100"]}\n__END_ACTION__.'
2188
2355
  )
2189
2356
  return {
2190
- "goal_reached": False,
2191
- "tool_calls": None,
2192
- "message_to_user": None,
2193
- "_format_error": "Invalid model output: expected one JSON object matching the Output JSON schema. Return strict JSON only. Bad output: "
2357
+ "actions": [],
2358
+ "_format_bad_output": content,
2359
+ "_format_error": "Invalid model output: "
2360
+ + reason
2361
+ + ". Return action frames only. Bad output: "
2194
2362
  + _shorten(content)
2195
2363
  + guidance,
2196
2364
  }
@@ -2471,6 +2639,9 @@ class AgentStateUpdater:
2471
2639
  before_verification,
2472
2640
  )
2473
2641
 
2642
+ def _actions(self, response: Json) -> list[Json]:
2643
+ return [action for action in (_json_dict(item) for item in _json_list(response.get("actions"))) if action]
2644
+
2474
2645
  def apply_tool_call_summaries(self, response: Json) -> None:
2475
2646
  self._apply_tool_call_summaries(response)
2476
2647
 
@@ -2567,7 +2738,7 @@ class AgentStateUpdater:
2567
2738
  return text if len(text) <= limit else text[: limit - 3] + "..."
2568
2739
 
2569
2740
  def _apply_tool_call_summaries(self, response: Json) -> None:
2570
- summaries = _json_list(response.get("last_tool_calls_summaries"))
2741
+ summaries = [action for action in self._actions(response) if _json_str(action.get("type")) == "tool_summary"]
2571
2742
  if not summaries:
2572
2743
  return
2573
2744
  pending = [event for event in self.tool_runner.latest_events if not event.summary]
@@ -2615,42 +2786,42 @@ class AgentStateUpdater:
2615
2786
  return [detail for detail in details if detail]
2616
2787
 
2617
2788
  def _apply_goal(self, response: Json) -> bool:
2618
- update = _json_str(response.get("goal_update"))
2619
2789
  changed = False
2620
- if update is not None:
2621
- changed = update != self.session.current.goal
2622
- self.session.current.goal = update
2623
- reached = response.get("goal_reached")
2624
- if isinstance(reached, bool):
2625
- self.session.current.goal_reached = reached
2790
+ for action in self._actions(response):
2791
+ action_type = _json_str(action.get("type"))
2792
+ if action_type == "goal":
2793
+ update = _json_str(action.get("text"))
2794
+ if update is not None:
2795
+ changed = changed or update != self.session.current.goal
2796
+ self.session.current.goal = update
2626
2797
  return changed
2627
2798
 
2628
2799
  def _apply_plan(self, response: Json) -> bool:
2629
- update = _json_dict(response.get("plan_update"))
2630
- if not update:
2631
- return False
2632
- items = _json_list(update.get("items"))
2633
- if update.get("mode") == "replace":
2634
- self.session.current.plan = [item for item in (self._plan_item_from_json(raw) for raw in items) if item]
2635
- return True
2636
- for raw in items:
2637
- patch = _json_dict(raw)
2638
- op = _json_str(patch.get("op")) or "add"
2639
- item_id = _json_str(patch.get("id")) or ""
2640
- if op == "remove":
2641
- self.session.current.plan = [item for item in self.session.current.plan if item.id != item_id]
2642
- continue
2643
- plan_item = self._plan_item_from_json(patch)
2644
- if plan_item is None:
2800
+ replaced = False
2801
+ for update in [action for action in self._actions(response) if _json_str(action.get("type")) == "plan"]:
2802
+ items = _json_list(update.get("items"))
2803
+ if update.get("mode") == "replace":
2804
+ self.session.current.plan = [item for item in (self._plan_item_from_json(raw) for raw in items) if item]
2805
+ replaced = True
2645
2806
  continue
2646
- existing = next((item for item in self.session.current.plan if item.id == plan_item.id and item.id), None)
2647
- if existing:
2648
- existing.text = plan_item.text
2649
- existing.status = plan_item.status
2650
- existing.evidence = plan_item.evidence
2651
- else:
2652
- self.session.current.plan.append(plan_item)
2653
- return False
2807
+ for raw in items:
2808
+ patch = _json_dict(raw)
2809
+ op = _json_str(patch.get("op")) or "add"
2810
+ item_id = _json_str(patch.get("id")) or ""
2811
+ if op == "remove":
2812
+ self.session.current.plan = [item for item in self.session.current.plan if item.id != item_id]
2813
+ continue
2814
+ plan_item = self._plan_item_from_json(patch)
2815
+ if plan_item is None:
2816
+ continue
2817
+ existing = next((item for item in self.session.current.plan if item.id == plan_item.id and item.id), None)
2818
+ if existing:
2819
+ existing.text = plan_item.text
2820
+ existing.status = plan_item.status
2821
+ existing.evidence = plan_item.evidence
2822
+ else:
2823
+ self.session.current.plan.append(plan_item)
2824
+ return replaced
2654
2825
 
2655
2826
  def _plan_item_from_json(self, value: JsonValue) -> PlanItem | None:
2656
2827
  item = _json_dict(value)
@@ -2668,58 +2839,55 @@ class AgentStateUpdater:
2668
2839
  )
2669
2840
 
2670
2841
  def _apply_known(self, response: Json) -> None:
2671
- for raw in _json_list(response.get("known_append")):
2672
- item = _json_dict(raw)
2673
- fact = _json_str(item.get("fact")) if item else _json_str(raw)
2674
- if not fact:
2675
- continue
2676
- details = [_json_str(detail) or "" for detail in _json_list(item.get("details") if item else None)]
2677
- details = [detail for detail in details if detail]
2678
- if not any(known.fact == fact for known in self.session.current.known):
2679
- self.session.current.known.append(KnownItem(fact=fact, details=details))
2842
+ for action in [action for action in self._actions(response) if _json_str(action.get("type")) == "known"]:
2843
+ for raw in _json_list(action.get("items")):
2844
+ item = _json_dict(raw)
2845
+ fact = _json_str(item.get("fact")) if item else _json_str(raw)
2846
+ if not fact:
2847
+ continue
2848
+ details = [_json_str(detail) or "" for detail in _json_list(item.get("details") if item else None)]
2849
+ details = [detail for detail in details if detail]
2850
+ if not any(known.fact == fact for known in self.session.current.known):
2851
+ self.session.current.known.append(KnownItem(fact=fact, details=details))
2680
2852
 
2681
2853
  def _apply_current_context(self, response: Json) -> None:
2682
- update = _json_dict(response.get("current_context_update"))
2683
- if not update:
2684
- return
2685
- if update.get("mode") == "replace":
2686
- self.session.current.current_context = []
2687
- for raw in _json_list(update.get("items")):
2688
- item = _json_dict(raw)
2689
- note = _json_str(item.get("note")) if item else _json_str(raw)
2690
- if not note:
2691
- continue
2692
- details = [_json_str(detail) or "" for detail in _json_list(item.get("details") if item else None)]
2693
- details = [detail for detail in details if detail]
2694
- existing = next((context for context in self.session.current.current_context if context.note == note), None)
2695
- if existing:
2696
- existing.details = details
2697
- else:
2698
- self.session.current.current_context.append(CurrentContextItem(note=note, details=details))
2854
+ for update in [action for action in self._actions(response) if _json_str(action.get("type")) == "context"]:
2855
+ if update.get("mode") == "replace":
2856
+ self.session.current.current_context = []
2857
+ for raw in _json_list(update.get("items")):
2858
+ item = _json_dict(raw)
2859
+ note = _json_str(item.get("note")) if item else _json_str(raw)
2860
+ if not note:
2861
+ continue
2862
+ details = [_json_str(detail) or "" for detail in _json_list(item.get("details") if item else None)]
2863
+ details = [detail for detail in details if detail]
2864
+ existing = next((context for context in self.session.current.current_context if context.note == note), None)
2865
+ if existing:
2866
+ existing.details = details
2867
+ else:
2868
+ self.session.current.current_context.append(CurrentContextItem(note=note, details=details))
2699
2869
  if len(self.session.current.current_context) > self.CURRENT_CONTEXT_LIMIT:
2700
2870
  self.session.current.current_context = self.session.current.current_context[-self.CURRENT_CONTEXT_LIMIT :]
2701
2871
 
2702
2872
  def _apply_verification(self, response: Json) -> None:
2703
- data = _json_dict(response.get("verification"))
2704
- if not data:
2705
- return
2706
- method = _json_str(data.get("method"))
2707
- if method is not None:
2708
- if method != self.session.current.verification.method:
2709
- self.session.current.verification.evidence = ""
2710
- self.session.current.verification.method = method
2711
- status = _json_str(data.get("status"))
2712
- if status == "pending":
2713
- self.session.current.verification.status = VerificationStatus.REQUIRED
2714
- if "evidence" not in data:
2715
- self.session.current.verification.evidence = ""
2716
- elif status == "passed":
2717
- self.session.current.verification.status = VerificationStatus.DONE
2718
- elif status == "blocked":
2719
- self.session.current.verification.status = VerificationStatus.BLOCKED
2720
- evidence = _json_str(data.get("evidence"))
2721
- if evidence is not None:
2722
- self.session.current.verification.evidence = evidence
2873
+ for data in [action for action in self._actions(response) if _json_str(action.get("type")) == "verify"]:
2874
+ method = _json_str(data.get("method"))
2875
+ if method is not None:
2876
+ if method != self.session.current.verification.method:
2877
+ self.session.current.verification.evidence = ""
2878
+ self.session.current.verification.method = method
2879
+ status = _json_str(data.get("status"))
2880
+ if status == "pending":
2881
+ self.session.current.verification.status = VerificationStatus.REQUIRED
2882
+ if "evidence" not in data:
2883
+ self.session.current.verification.evidence = ""
2884
+ elif status == "passed":
2885
+ self.session.current.verification.status = VerificationStatus.DONE
2886
+ elif status == "blocked":
2887
+ self.session.current.verification.status = VerificationStatus.BLOCKED
2888
+ evidence = _json_str(data.get("evidence"))
2889
+ if evidence is not None:
2890
+ self.session.current.verification.evidence = evidence
2723
2891
 
2724
2892
  def _reset_stale_verification(self, response: Json, *, goal_changed: bool, plan_replaced: bool) -> None:
2725
2893
  verification = self.session.current.verification
@@ -2731,7 +2899,7 @@ class AgentStateUpdater:
2731
2899
  return
2732
2900
  if (
2733
2901
  plan_replaced
2734
- and not _json_dict(response.get("verification"))
2902
+ and not any(_json_str(action.get("type")) == "verify" for action in self._actions(response))
2735
2903
  and verification.status
2736
2904
  in {
2737
2905
  VerificationStatus.REQUIRED,
@@ -2822,7 +2990,9 @@ class Agent:
2822
2990
  self.latest_agent_feedback = ""
2823
2991
  return prompt
2824
2992
 
2825
- def request(self, system_prompt: str, user_prompt: str, *, activity: str = "main") -> Json:
2993
+ def request(self, system_prompt: str, user_prompt: str, *, activity: str = "main", on_action: ActionCallback | None = None) -> Json:
2994
+ if isinstance(self.model_client, ModelClient):
2995
+ return self.model_client.request(system_prompt, user_prompt, activity=activity, on_action=on_action)
2826
2996
  return self.model_client.request(system_prompt, user_prompt, activity=activity)
2827
2997
 
2828
2998
  def compact_history(self) -> int:
@@ -2848,7 +3018,7 @@ class Agent:
2848
3018
  consecutive_format_errors = 0
2849
3019
 
2850
3020
  for _ in range(self.session.max_agent_steps):
2851
- response = self.step()
3021
+ response = self.step(on_action=self._stream_action_preview_callback(on_message) if on_message is not None else None)
2852
3022
  format_error = _json_str(response.get("_format_error"))
2853
3023
  if format_error:
2854
3024
  consecutive_format_errors += 1
@@ -2860,24 +3030,29 @@ class Agent:
2860
3030
  "Format_Gate: stopped after "
2861
3031
  + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS)
2862
3032
  + " consecutive invalid model outputs. "
2863
- + _shorten(format_error, 180),
3033
+ + self._format_gate_debug_details(response, format_error),
2864
3034
  )
2865
3035
  raise LLMError(
2866
3036
  "model returned invalid output " + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS) + " times in a row: " + _shorten(format_error, 300)
2867
3037
  )
2868
3038
  self._report_gate(
2869
3039
  on_message,
2870
- "Retrying: model returned invalid output.",
2871
- "Format_Gate: retrying model response. " + _shorten(format_error, 180),
3040
+ self._format_gate_user_message("Retrying: model returned invalid output", format_error),
3041
+ "Format_Gate: retrying model response. " + self._format_gate_debug_details(response, format_error),
2872
3042
  )
2873
3043
  continue
2874
3044
  consecutive_format_errors = 0
2875
- tool_calls = _json_list(response.get("tool_calls"))
3045
+ actions = self._response_actions(response)
3046
+ tool_calls = self._tool_calls_from_actions(actions)
3047
+ messages = self._messages_from_actions(actions)
3048
+ if self.session.debug and on_message is not None:
3049
+ frame_error_report = self._format_frame_error_report(response)
3050
+ if frame_error_report:
3051
+ on_message(frame_error_report)
2876
3052
  self.apply_response(response)
2877
3053
  if on_message is not None and self.state_updater.latest_report:
2878
3054
  on_message(self.state_updater.latest_report)
2879
- message = _json_str(response.get("message_to_user"))
2880
- if message:
3055
+ for message in messages:
2881
3056
  self.session.append_conversation(AssistantMessage(content=message))
2882
3057
  if on_message is not None:
2883
3058
  on_message(message)
@@ -2889,8 +3064,6 @@ class Agent:
2889
3064
  on_message(report)
2890
3065
  self.maybe_auto_compact()
2891
3066
  continue
2892
- if self.session.current.goal_reached and self.session.current.verification.status != VerificationStatus.REQUIRED:
2893
- return response
2894
3067
  if self.session.current.verification.status == VerificationStatus.REQUIRED:
2895
3068
  self.session.current.goal_reached = False
2896
3069
  self.latest_agent_feedback = self._format_verification_gate()
@@ -2900,7 +3073,10 @@ class Agent:
2900
3073
  "Verification_Gate: retrying until verification is passed or blocked.",
2901
3074
  )
2902
3075
  continue
2903
- if not self.session.current.goal_reached:
3076
+ if messages:
3077
+ self.session.current.goal_reached = True
3078
+ return response
3079
+ if not messages:
2904
3080
  self.latest_agent_feedback = self._format_continuation_hint()
2905
3081
  self._report_gate(
2906
3082
  on_message,
@@ -2915,6 +3091,22 @@ class Agent:
2915
3091
  if on_message is not None:
2916
3092
  on_message(debug_message if self.session.debug else message)
2917
3093
 
3094
+ def _format_gate_user_message(self, prefix: str, format_error: str) -> str:
3095
+ detail = format_error
3096
+ for marker in (". Bad output:", " Bad output:"):
3097
+ if marker in detail:
3098
+ detail = detail.split(marker, 1)[0]
3099
+ break
3100
+ if detail.startswith("Invalid model output: "):
3101
+ detail = detail[len("Invalid model output: ") :]
3102
+ return prefix + ": " + _shorten(detail, 180)
3103
+
3104
+ def _format_gate_debug_details(self, response: Json, format_error: str) -> str:
3105
+ bad_output = _json_str(response.get("_format_bad_output"))
3106
+ if bad_output is None:
3107
+ return _shorten(format_error, 180)
3108
+ return _shorten(format_error, 180) + "\nFull bad output:\n" + bad_output
3109
+
2918
3110
  def _compact_gate_report(self, gate: str) -> str:
2919
3111
  lines = gate.splitlines()
2920
3112
  headline = lines[0] if lines else "Gate"
@@ -2923,10 +3115,13 @@ class Agent:
2923
3115
  return headline + ": " + _shorten("; ".join(details[:3]), 220)
2924
3116
  return headline
2925
3117
 
2926
- def step(self) -> Json:
2927
- response = self.request(self.build_system_prompt(), self.build_user_prompt(consume_latest_tool_results=False), activity="main")
3118
+ def step(self, *, on_action: ActionCallback | None = None) -> Json:
3119
+ response = self.request(self.build_system_prompt(), self.build_user_prompt(consume_latest_tool_results=False), activity="main", on_action=on_action)
2928
3120
  if _json_str(response.get("_format_error")):
2929
3121
  return response
3122
+ invalid_response = self._validate_action_response(response)
3123
+ if invalid_response is not None:
3124
+ return invalid_response
2930
3125
  self.latest_tool_call_results = ""
2931
3126
  self.latest_agent_feedback = ""
2932
3127
  self.state_updater.apply_tool_call_summaries(response)
@@ -2935,6 +3130,47 @@ class Agent:
2935
3130
  def apply_response(self, response: Json) -> None:
2936
3131
  self.state_updater.apply(response)
2937
3132
 
3133
+ def _stream_action_preview_callback(self, on_message: MessageCallback | None) -> ActionCallback:
3134
+ def preview(action: Json) -> None:
3135
+ if on_message is None:
3136
+ return
3137
+ report = self._format_stream_action_preview(action)
3138
+ if report:
3139
+ on_message(report)
3140
+
3141
+ return preview
3142
+
3143
+ def _format_stream_action_preview(self, action: Json) -> str:
3144
+ if _json_str(action.get("type")) != "tool":
3145
+ return ""
3146
+ try:
3147
+ call = self.tool_runner.parse_tool_call(action)
3148
+ except ToolCallError:
3149
+ return ""
3150
+ label = "Queued: " + self._format_stream_tool_label(call)
3151
+ if call.intention:
3152
+ label += " - " + _shorten(call.intention, 80)
3153
+ return label
3154
+
3155
+ def _format_stream_tool_label(self, call: ParsedToolCall) -> str:
3156
+ args = call.args
3157
+ if call.name == "Bash":
3158
+ return "Bash"
3159
+ if call.name in {"Read", "ReplaceRange"} and args:
3160
+ return self._format_stream_path_range_label(call.name, args)
3161
+ if call.name == "Search":
3162
+ path = args[1] if len(args) >= 2 and args[1] else ""
3163
+ return "Search" + ((" " + _shorten(path, 48)) if path else "")
3164
+ if args:
3165
+ return call.name + " " + _shorten(args[0], 48)
3166
+ return call.name
3167
+
3168
+ def _format_stream_path_range_label(self, name: str, args: list[str]) -> str:
3169
+ label = name + " " + _shorten(args[0], 48)
3170
+ if len(args) >= 3:
3171
+ label += ":" + args[1] + "-" + args[2]
3172
+ return label
3173
+
2938
3174
  def execute_tool_calls(
2939
3175
  self,
2940
3176
  tool_calls: list[JsonValue],
@@ -2952,12 +3188,12 @@ class Agent:
2952
3188
  [
2953
3189
  "Verification_Gate: required before completion.",
2954
3190
  "Method: " + method,
2955
- 'Next_Action: run a relevant verification tool call, or return verification.status="passed" or "blocked" with evidence if verification is already resolved.',
3191
+ 'Next_Action: run a relevant tool action, or return a verify action with status="passed" or "blocked" and evidence if verification is already resolved.',
2956
3192
  ]
2957
3193
  )
2958
3194
 
2959
3195
  def _format_continuation_hint(self) -> str:
2960
- return "No tool calls and goal not reached. Continue with the next useful action."
3196
+ return "No tool actions and no message action. Continue with the next useful action."
2961
3197
 
2962
3198
  def _missing_tool_summaries(self) -> list[ToolCallEvent]:
2963
3199
  return [event for event in self.tool_runner.latest_events if not event.summary]
@@ -2986,7 +3222,7 @@ class Agent:
2986
3222
  for event in needs_read:
2987
3223
  lines.append("- Read(" + event.result_file + ") before continuing")
2988
3224
  lines.append(
2989
- "Next_Action: return last_tool_calls_summaries for missing results, read result_file only when it is the cheapest accurate fallback, or continue with source tools such as Search/ListDir."
3225
+ "Next_Action: return tool_summary actions for missing results, read result_file with a tool action only when it is the cheapest accurate fallback, or continue with source tools such as Search/ListDir."
2990
3226
  )
2991
3227
  return "\n".join(lines)
2992
3228
 
@@ -3003,6 +3239,39 @@ class Agent:
3003
3239
  return True
3004
3240
  return False
3005
3241
 
3242
+ def _invalid_action_response(self, response: Json, reason: str) -> Json:
3243
+ return {
3244
+ "actions": [],
3245
+ "_format_error": "Invalid model output: "
3246
+ + reason
3247
+ + ". Return action frames only. Bad output: "
3248
+ + _shorten(json.dumps(response, ensure_ascii=False)),
3249
+ }
3250
+
3251
+ def _validate_action_response(self, response: Json) -> Json | None:
3252
+ if not isinstance(response.get("actions"), list):
3253
+ return self._invalid_action_response(response, "expected actions array")
3254
+ extra_keys = sorted(str(key) for key in response.keys() if key != "actions" and not str(key).startswith("_format_"))
3255
+ if extra_keys:
3256
+ return self._invalid_action_response(response, "unexpected top-level keys: " + ", ".join(extra_keys))
3257
+ return None
3258
+
3259
+ def _format_frame_error_report(self, response: Json) -> str:
3260
+ errors = [_json_str(error) or "" for error in _json_list(response.get("_format_frame_errors"))]
3261
+ errors = [error for error in errors if error]
3262
+ if not errors:
3263
+ return ""
3264
+ return "Format_Warning: ignored invalid action frame(s).\n" + "\n".join("- " + _shorten(error, 220) for error in errors)
3265
+
3266
+ def _response_actions(self, response: Json) -> list[Json]:
3267
+ return [action for action in (_json_dict(item) for item in _json_list(response.get("actions"))) if action]
3268
+
3269
+ def _tool_calls_from_actions(self, actions: list[Json]) -> list[JsonValue]:
3270
+ return [action for action in actions if _json_str(action.get("type")) == "tool"]
3271
+
3272
+ def _messages_from_actions(self, actions: list[Json]) -> list[str]:
3273
+ return [message for message in (_json_str(action.get("text")) for action in actions if _json_str(action.get("type")) == "message") if message]
3274
+
3006
3275
 
3007
3276
  ############################
3008
3277
  # Commands
@@ -3042,6 +3311,7 @@ COMMANDS: tuple[CommandSpec, ...] = (
3042
3311
  CommandSpec("/compact-at", "Show or set auto-compact threshold", "Config", "/compact-at [number]"),
3043
3312
  CommandSpec("/reason", "Show or toggle reasoning", "Config", "/reason [on|off|status]"),
3044
3313
  CommandSpec("/reason_effort", "Show or set reasoning effort", "Config", "/reason_effort [minimal|low|medium|high|xhigh]"),
3314
+ CommandSpec("/stream", "Show or toggle streaming responses", "Config", "/stream [on|off|status]"),
3045
3315
  CommandSpec("/yolo", "Show or toggle confirmation bypass", "Config", "/yolo [on|off|status]"),
3046
3316
  CommandSpec("/exit", "Exit nanocode", "Control", "/exit"),
3047
3317
  CommandSpec("/quit", "Exit nanocode", "Control", "/quit"),
@@ -3071,6 +3341,7 @@ class CommandDispatcher:
3071
3341
  "/compact-at": self._compact_at,
3072
3342
  "/reason": self._reason,
3073
3343
  "/reason_effort": self._reason_effort,
3344
+ "/stream": self._stream,
3074
3345
  "/yolo": self._yolo,
3075
3346
  "/blackboard": self._blackboard,
3076
3347
  }
@@ -3129,11 +3400,13 @@ class CommandDispatcher:
3129
3400
  return "Usage: /status"
3130
3401
  session = self.agent.session
3131
3402
  reasoning = session.reasoning_effort if session.reasoning else "off"
3403
+ stream = "on" if session.stream else "off"
3132
3404
  yolo = "on" if session.yolo else "off"
3133
3405
  return "\n".join(
3134
3406
  [
3135
3407
  "model: " + (session.model or "(empty)"),
3136
3408
  "reasoning: " + reasoning,
3409
+ "stream: " + stream,
3137
3410
  "yolo: " + yolo,
3138
3411
  "conversation: " + str(len(session.conversation)) + "/" + str(session.compact_at),
3139
3412
  "tokens: last=" + _format_count(session.last_total_tokens) + " session=" + _format_count(session.session_total_tokens),
@@ -3198,6 +3471,17 @@ class CommandDispatcher:
3198
3471
  self.agent.session.reasoning_effort = args
3199
3472
  return "Reasoning effort set to: " + args
3200
3473
 
3474
+ def _stream(self, args: str) -> str:
3475
+ if args == "on":
3476
+ self.agent.session.stream = True
3477
+ return "Streaming enabled"
3478
+ if args == "off":
3479
+ self.agent.session.stream = False
3480
+ return "Streaming disabled"
3481
+ if args in {"", "status"}:
3482
+ return "Streaming is " + ("on" if self.agent.session.stream else "off")
3483
+ return "Usage: /stream [on|off|status]"
3484
+
3201
3485
  def _yolo(self, args: str) -> str:
3202
3486
  if args == "on":
3203
3487
  self.agent.session.yolo = True
@@ -3558,6 +3842,9 @@ class AgentLoop:
3558
3842
  if message.startswith("Tool Calls"):
3559
3843
  self._emit_segments(self._tool_segments(message), message)
3560
3844
  return
3845
+ if message.startswith("Queued:"):
3846
+ self._emit_segments(self._queued_segments(message), message)
3847
+ return
3561
3848
  if message.startswith("Error:"):
3562
3849
  self._emit_segments([("bold ansired", message + "\n")], message)
3563
3850
  return
@@ -3661,6 +3948,15 @@ class AgentLoop:
3661
3948
  segments.extend([("ansibrightblack", line + "\n")])
3662
3949
  return segments
3663
3950
 
3951
+ def _queued_segments(self, message: str) -> list[tuple[str, str]]:
3952
+ body = message[len("Queued:") :].strip()
3953
+ target, separator, reason = body.partition(" - ")
3954
+ segments: list[tuple[str, str]] = [("ansibrightblack", "Queued: "), ("ansicyan", target)]
3955
+ if separator:
3956
+ segments.extend([("ansibrightblack", " - "), ("ansimagenta", reason)])
3957
+ segments.append(("", "\n"))
3958
+ return segments
3959
+
3664
3960
  def _verify_style(self, badge: str) -> str:
3665
3961
  if "required" in badge:
3666
3962
  return "bold ansimagenta"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.2.7
3
+ Version: 0.2.8
4
4
  Summary: A lightweight terminal-based AI coding assistant
5
5
  Author-email: hit9 <hit9@icloud.com>
6
6
  License-Expression: BSD-3-Clause
@@ -21,6 +21,7 @@ Classifier: Topic :: Terminals
21
21
  Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
+ Requires-Dist: json-repair>=0.39
24
25
  Requires-Dist: prompt-toolkit>=3.0
25
26
  Requires-Dist: typing-extensions>=4.7
26
27
  Provides-Extra: dev
@@ -76,6 +77,7 @@ export NANOCODE_DIR=".nanocode"
76
77
  export NANOCODE_TEMPERATURE="0.7"
77
78
  export NANOCODE_REASONING="on"
78
79
  export NANOCODE_REASONING_EFFORT="medium"
80
+ export NANOCODE_STREAM="on"
79
81
  export NANOCODE_MODEL_TIMEOUT="60"
80
82
  export NANOCODE_SHELL_TIMEOUT="60"
81
83
  export NANOCODE_COMPACT_AT="100"
@@ -1,3 +1,4 @@
1
+ json-repair>=0.39
1
2
  prompt-toolkit>=3.0
2
3
  typing-extensions>=4.7
3
4
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nanocode-cli"
7
- version = "0.2.7"
7
+ version = "0.2.8"
8
8
  description = "A lightweight terminal-based AI coding assistant"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -27,6 +27,7 @@ classifiers = [
27
27
  "Topic :: Terminals",
28
28
  ]
29
29
  dependencies = [
30
+ "json-repair>=0.39",
30
31
  "prompt-toolkit>=3.0",
31
32
  "typing-extensions>=4.7",
32
33
  ]
File without changes
File without changes
File without changes