nanocode-cli 0.2.1__tar.gz → 0.2.3__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.1
3
+ Version: 0.2.3
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
@@ -41,7 +41,7 @@ from prompt_toolkit.patch_stdout import patch_stdout
41
41
 
42
42
  JsonValue: TypeAlias = Any
43
43
  Json: TypeAlias = dict[str, JsonValue]
44
- __version__ = "0.2.1"
44
+ __version__ = "0.2.3"
45
45
 
46
46
 
47
47
  class Error(Exception): ...
@@ -524,6 +524,7 @@ class ToolCallExecution:
524
524
  output: str
525
525
  result_file: str
526
526
  result_file_lines: int
527
+ log_written: bool = True
527
528
 
528
529
 
529
530
  ConfirmationResult: TypeAlias = bool | str
@@ -565,6 +566,8 @@ def _range_fingerprint(content: str) -> str:
565
566
  @final
566
567
  @dataclass
567
568
  class ReadTool(Tool):
569
+ MAX_LINES: ClassVar[int] = 1000
570
+
568
571
  filepath: str = ""
569
572
  start: int = 0
570
573
  end: int = 0
@@ -580,7 +583,8 @@ class ReadTool(Tool):
580
583
  return [
581
584
  "Read exact file lines with a fingerprint.",
582
585
  "Optional range is 0-based [start,end); end=0 means EOF.",
583
- "Prefer bounded reads over full-file reads; use LineCount first when unsure.",
586
+ "Returns at most 1000 lines; truncated results include total lines and next-step guidance.",
587
+ "Prefer Search before Read for large or unknown files; use bounded reads when exact context is needed.",
584
588
  "For ReplaceRange, call Read with the exact same filepath/start/end and reuse that range fingerprint.",
585
589
  ]
586
590
 
@@ -607,26 +611,64 @@ class ReadTool(Tool):
607
611
  return f"Read({self.filepath}, {self.start}, {self.end})"
608
612
 
609
613
  def call(self) -> str:
610
- with open(self.filepath, "r", encoding="utf-8") as f:
611
- lines = itertools.islice(f, self.start, self.end or None)
612
- content = "".join(lines)
614
+ total_lines = 0
615
+ selected_lines = []
616
+ truncated = False
617
+ bounded_read_lines = self.end - self.start if self.end else 0
618
+ if self.end and bounded_read_lines <= self.MAX_LINES:
619
+ with open(self.filepath, "r", encoding="utf-8") as f:
620
+ selected_lines = list(itertools.islice(f, self.start, self.end))
621
+ else:
622
+ with open(self.filepath, "r", encoding="utf-8") as f:
623
+ for index, line in enumerate(f):
624
+ total_lines = index + 1
625
+ if index < self.start:
626
+ continue
627
+ if self.end and index >= self.end:
628
+ continue
629
+ if len(selected_lines) < self.MAX_LINES:
630
+ selected_lines.append(line)
631
+ continue
632
+ truncated = True
633
+ content = "".join(selected_lines)
634
+ returned_end = self.start + len(selected_lines)
635
+ fingerprint_end = returned_end if truncated else self.end
613
636
  fingerprint = self.range_fingerprints.remember(
614
637
  filepath=self.filepath,
615
638
  start=self.start,
616
- end=self.end,
639
+ end=fingerprint_end,
617
640
  content=content,
618
641
  )
619
- return "\n".join(
642
+ lines = [
643
+ "<ReadToolResult>",
644
+ " <range>" + str(self.start) + ":" + str(fingerprint_end) + "</range>",
645
+ " <fingerprint>" + fingerprint + "</fingerprint>",
646
+ ]
647
+ if truncated:
648
+ lines.extend(
649
+ [
650
+ " <truncated>true</truncated>",
651
+ " <total_lines>" + str(total_lines) + "</total_lines>",
652
+ " <note>Read returned "
653
+ + str(len(selected_lines))
654
+ + " lines from "
655
+ + str(self.start)
656
+ + ":"
657
+ + str(returned_end)
658
+ + " of "
659
+ + str(total_lines)
660
+ + " total lines. Use Search to locate relevant text or Read smaller ranges in batches.</note>",
661
+ ]
662
+ )
663
+ lines.extend(
620
664
  [
621
- "<ReadToolResult>",
622
- " <range>" + str(self.start) + ":" + str(self.end) + "</range>",
623
- " <fingerprint>" + fingerprint + "</fingerprint>",
624
665
  " <content no-indention>",
625
666
  content,
626
667
  " </content>",
627
668
  "</ReadToolResult>",
628
669
  ]
629
670
  )
671
+ return "\n".join(lines)
630
672
 
631
673
 
632
674
  @final
@@ -687,18 +729,19 @@ class ListDirTool(Tool):
687
729
 
688
730
  @classmethod
689
731
  def signature(cls) -> str:
690
- return "ListDir(dir_path[, glob_pattern]) -> ListDirToolResult<entries>"
732
+ return 'ListDir(dir_path?: "."[, glob_pattern]) -> ListDirToolResult<entries>'
691
733
 
692
734
  @classmethod
693
735
  def example(cls) -> list[str]:
694
- return ['Example args: ["."]', 'Example args: ["src", "*.py"]']
736
+ return ['Example args: []', 'Example args: ["."]', 'Example args: ["src", "*.py"]']
695
737
 
696
738
  @classmethod
697
739
  def make(cls, session: Session, args: list[str]) -> Self:
698
- if len(args) not in (1, 2):
699
- raise ToolCallError("requires 1 or 2 args: dir_path[, glob_pattern]")
740
+ if len(args) not in (0, 1, 2):
741
+ raise ToolCallError("requires 0 to 2 args: [dir_path][, glob_pattern]")
742
+ dir_path = str(args[0]) if args else "."
700
743
  glob_pattern = str(args[1]) if len(args) == 2 else ""
701
- return cls(dirpath=session.resolve_path(args[0]), glob_pattern=glob_pattern, cwd=session.cwd)
744
+ return cls(dirpath=session.resolve_path(dir_path), glob_pattern=glob_pattern, cwd=session.cwd)
702
745
 
703
746
  def display(self) -> str:
704
747
  if self.glob_pattern:
@@ -1664,8 +1707,11 @@ Tools:
1664
1707
  - Call tools only by emitting JSON in tool_calls. Do not use native tool calls.
1665
1708
  - Use multiple tool calls in one turn when they are independent.
1666
1709
  - Prefer specific tools first; use Bash only when no provided tool fits.
1667
- - Summarize every latest tool result in last_tool_calls_summaries; raw results are shown once only, so key_evidence keeps paths, lines, errors, decisions.
1668
- - If a prior tool result lacks detail, use Read on its result_file.
1710
+ - Prefer Search before Read when locating code or facts; Read only known small ranges or exact files needed for editing.
1711
+ - Read returns at most 1000 lines; if truncated, use Search or smaller Read ranges in batches.
1712
+ - 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.
1713
+ - Latest tool results are already shown in Latest_Tool_Call_Results; use result_file logs only as a fallback when needed.
1714
+ - 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.
1669
1715
  - known_append is stable memory; current_context_update is task-local memory.
1670
1716
  - tool_call.intention must state the question to answer, not just the action.
1671
1717
 
@@ -1949,6 +1995,8 @@ class ModelClient:
1949
1995
 
1950
1996
  self._record_usage(_json_dict(result.get("usage") if isinstance(result, dict) else None))
1951
1997
  content = self._message_content(result)
1998
+ if content is None:
1999
+ return self._invalid_model_response(self._format_missing_message_content(result))
1952
2000
  return self._parse_model_content(content)
1953
2001
 
1954
2002
  def _write_debug_prompt(self, *, activity: str, messages: list[Json]) -> str:
@@ -1978,13 +2026,9 @@ class ModelClient:
1978
2026
 
1979
2027
  def _parse_model_content(self, content: str) -> Json:
1980
2028
  text = content.strip()
1981
- if text.startswith("```"):
1982
- lines = text.splitlines()
1983
- if lines and lines[0].startswith("```"):
1984
- lines = lines[1:]
1985
- if lines and lines[-1].strip() == "```":
1986
- lines = lines[:-1]
1987
- text = "\n".join(lines).strip()
2029
+ text = self._strip_leaked_think_tags(text)
2030
+ text = self._strip_json_fence(text)
2031
+ text = self._strip_leaked_think_tags(text)
1988
2032
  try:
1989
2033
  value = json.loads(text)
1990
2034
  except json.JSONDecodeError:
@@ -1993,15 +2037,49 @@ class ModelClient:
1993
2037
  return self._invalid_model_response(content)
1994
2038
  return value
1995
2039
 
2040
+ def _strip_json_fence(self, text: str) -> str:
2041
+ if not text.startswith("```"):
2042
+ return text
2043
+ lines = text.splitlines()
2044
+ if lines and lines[0].startswith("```"):
2045
+ lines = lines[1:]
2046
+ if lines and lines[-1].strip() == "```":
2047
+ lines = lines[:-1]
2048
+ return "\n".join(lines).strip()
2049
+
2050
+ def _strip_leaked_think_tags(self, text: str) -> str:
2051
+ text = text.strip()
2052
+ while text.startswith("</think>"):
2053
+ text = text[len("</think>") :].lstrip()
2054
+ while text.startswith("<think>"):
2055
+ end = text.find("</think>")
2056
+ if end < 0:
2057
+ return text
2058
+ text = text[end + len("</think>") :].lstrip()
2059
+ while text.startswith("</think>"):
2060
+ text = text[len("</think>") :].lstrip()
2061
+ return text
2062
+
1996
2063
  def _invalid_model_response(self, content: str) -> Json:
2064
+ guidance = ""
2065
+ if self._looks_like_native_tool_call(content):
2066
+ guidance = (
2067
+ " Native tool_call syntax is not supported; return one JSON object with tool_calls entries like "
2068
+ '{"name":"Read","intention":"...","args":["nanocode.py","0","100"]}.'
2069
+ )
1997
2070
  return {
1998
2071
  "goal_reached": False,
1999
2072
  "tool_calls": None,
2000
2073
  "message_to_user": None,
2001
2074
  "_format_error": "Invalid model output: expected one JSON object matching the Output JSON schema. Return strict JSON only. Bad output: "
2002
- + _shorten(content),
2075
+ + _shorten(content)
2076
+ + guidance,
2003
2077
  }
2004
2078
 
2079
+ def _looks_like_native_tool_call(self, content: str) -> bool:
2080
+ text = self._strip_leaked_think_tags(content.strip())
2081
+ return text.startswith("<tool_call>")
2082
+
2005
2083
  def _chat_completions_url(self) -> str:
2006
2084
  url = self.session.api_url.rstrip("/")
2007
2085
  if url.endswith("/chat/completions"):
@@ -2015,7 +2093,7 @@ class ModelClient:
2015
2093
  return {"reasoning": {"effort": self.session.reasoning_effort}}
2016
2094
  return {}
2017
2095
 
2018
- def _message_content(self, result: JsonValue) -> str:
2096
+ def _message_content(self, result: JsonValue) -> str | None:
2019
2097
  data = _json_dict(result)
2020
2098
  choices = _json_list(data.get("choices"))
2021
2099
  if not choices:
@@ -2023,9 +2101,18 @@ class ModelClient:
2023
2101
  message = _json_dict(_json_dict(choices[0]).get("message"))
2024
2102
  content = message.get("content")
2025
2103
  if not isinstance(content, str):
2026
- raise LLMError("API response missing message content")
2104
+ return None
2027
2105
  return content
2028
2106
 
2107
+ def _format_missing_message_content(self, result: JsonValue) -> str:
2108
+ choice = _json_dict(_json_list(_json_dict(result).get("choices"))[0])
2109
+ message = _json_dict(choice.get("message"))
2110
+ details: Json = {
2111
+ "finish_reason": choice.get("finish_reason"),
2112
+ "message_keys": sorted(str(key) for key in message.keys()),
2113
+ }
2114
+ return "API response missing message content: " + json.dumps(details, ensure_ascii=False)
2115
+
2029
2116
  def _record_usage(self, usage: Json) -> None:
2030
2117
  prompt_tokens = _json_int(usage.get("prompt_tokens"))
2031
2118
  completion_tokens = _json_int(usage.get("completion_tokens"))
@@ -2092,25 +2179,32 @@ class ToolCallRunner:
2092
2179
  if call is None:
2093
2180
  call = self._invalid_tool_call(item)
2094
2181
 
2095
- result_file, result_file_lines = self._write_tool_result_log(call, outcome, output)
2182
+ log_written = not self._is_tool_result_file_read(call)
2183
+ if log_written:
2184
+ result_file, result_file_lines = self._write_tool_result_log(call, outcome, output)
2185
+ else:
2186
+ result_file = os.path.relpath(self.session.resolve_path(call.args[0]), self.session.cwd)
2187
+ result_file_lines = len(output.splitlines())
2096
2188
  execution = ToolCallExecution(
2097
2189
  call=call,
2098
2190
  outcome=outcome,
2099
2191
  output=output,
2100
2192
  result_file=result_file,
2101
2193
  result_file_lines=result_file_lines,
2194
+ log_written=log_written,
2102
2195
  )
2103
- event = ToolCallEvent(
2104
- intent=call.intention,
2105
- executed=call.executed,
2106
- outcome=outcome,
2107
- summary="",
2108
- result_file=result_file,
2109
- result_file_lines=result_file_lines,
2110
- )
2111
- self.session.append_conversation(event)
2196
+ if log_written:
2197
+ event = ToolCallEvent(
2198
+ intent=call.intention,
2199
+ executed=call.executed,
2200
+ outcome=outcome,
2201
+ summary="",
2202
+ result_file=result_file,
2203
+ result_file_lines=result_file_lines,
2204
+ )
2205
+ self.session.append_conversation(event)
2206
+ events.append(event)
2112
2207
  executions.append(execution)
2113
- events.append(event)
2114
2208
 
2115
2209
  self.latest_events = events
2116
2210
  self.latest_executions = executions
@@ -2128,7 +2222,8 @@ class ToolCallRunner:
2128
2222
  lines.append(" " + str(index) + ". [" + execution.outcome + "] " + execution.call.executed)
2129
2223
  if execution.call.intention:
2130
2224
  lines.append(" why: " + execution.call.intention)
2131
- lines.append(" log: " + execution.result_file + " (" + str(execution.result_file_lines) + " lines)")
2225
+ label = "log" if execution.log_written else "source"
2226
+ lines.append(" " + label + ": " + execution.result_file + " (" + str(execution.result_file_lines) + " lines)")
2132
2227
  return "\n".join(lines)
2133
2228
 
2134
2229
  def parse_tool_call(self, value: JsonValue) -> ParsedToolCall:
@@ -2153,6 +2248,16 @@ class ToolCallRunner:
2153
2248
  raise ToolCallError("tool not found: " + call.name)
2154
2249
  return tool_class.make(self.session, call.args)
2155
2250
 
2251
+ def _is_tool_result_file_read(self, call: ParsedToolCall) -> bool:
2252
+ if call.name != ReadTool.name() or not call.args:
2253
+ return False
2254
+ tool_results_dir = os.path.realpath(self.session.tool_results_dir())
2255
+ path = os.path.realpath(self.session.resolve_path(call.args[0]))
2256
+ try:
2257
+ return os.path.commonpath([tool_results_dir, path]) == tool_results_dir
2258
+ except ValueError:
2259
+ return False
2260
+
2156
2261
  def _write_tool_result_log(self, call: ParsedToolCall, outcome: str, output: str) -> tuple[str, int]:
2157
2262
  directory = self.session.tool_results_dir()
2158
2263
  os.makedirs(directory, exist_ok=True)
@@ -2563,8 +2668,7 @@ class ConversationCompactor:
2563
2668
 
2564
2669
  @final
2565
2670
  class Agent:
2566
- EVIDENCE_OUTPUT_LINES: ClassVar[int] = 40
2567
- EVIDENCE_OUTPUT_CHARS: ClassVar[int] = 4000
2671
+ MAX_CONSECUTIVE_FORMAT_ERRORS: ClassVar[int] = 3
2568
2672
 
2569
2673
  def __init__(self, session: Session):
2570
2674
  self.session = session
@@ -2610,18 +2714,48 @@ class Agent:
2610
2714
  self.session.current.goal_reached = False
2611
2715
  self.maybe_auto_compact()
2612
2716
  self.session.append_conversation(UserMessage(content=user_input))
2717
+ consecutive_format_errors = 0
2613
2718
 
2614
2719
  for _ in range(self.session.max_agent_steps):
2615
2720
  response = self.step()
2616
2721
  format_error = _json_str(response.get("_format_error"))
2617
2722
  if format_error:
2723
+ consecutive_format_errors += 1
2618
2724
  self.latest_tool_call_results = format_error
2725
+ if consecutive_format_errors >= self.MAX_CONSECUTIVE_FORMAT_ERRORS:
2726
+ self._report_gate(
2727
+ on_message,
2728
+ "Stopped: model returned invalid output "
2729
+ + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS)
2730
+ + " times in a row.",
2731
+ "Format_Gate: stopped after "
2732
+ + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS)
2733
+ + " consecutive invalid model outputs. "
2734
+ + _shorten(format_error, 180),
2735
+ )
2736
+ raise LLMError(
2737
+ "model returned invalid output "
2738
+ + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS)
2739
+ + " times in a row: "
2740
+ + _shorten(format_error, 300)
2741
+ )
2742
+ self._report_gate(
2743
+ on_message,
2744
+ "Retrying: model returned invalid output.",
2745
+ "Format_Gate: retrying model response. " + _shorten(format_error, 180),
2746
+ )
2619
2747
  continue
2748
+ consecutive_format_errors = 0
2620
2749
  tool_calls = _json_list(response.get("tool_calls"))
2621
2750
  summary_gate = self._format_tool_summary_gate(tool_calls)
2622
2751
  if summary_gate:
2623
2752
  self.state_updater.latest_report = ""
2624
2753
  self.latest_tool_call_results = summary_gate
2754
+ self._report_gate(
2755
+ on_message,
2756
+ "Retrying: model needs to summarize the latest tool results.",
2757
+ self._compact_gate_report(summary_gate),
2758
+ )
2625
2759
  continue
2626
2760
  self.apply_response(response)
2627
2761
  if on_message is not None and self.state_updater.latest_report:
@@ -2644,13 +2778,35 @@ class Agent:
2644
2778
  if self.session.current.verification.status == VerificationStatus.REQUIRED:
2645
2779
  self.session.current.goal_reached = False
2646
2780
  self.latest_tool_call_results = self._format_verification_gate()
2781
+ self._report_gate(
2782
+ on_message,
2783
+ "Retrying: verification is required before completion.",
2784
+ "Verification_Gate: retrying until verification is passed or blocked.",
2785
+ )
2647
2786
  continue
2648
2787
  if not self.session.current.goal_reached:
2649
2788
  self.latest_tool_call_results = self._format_continuation_hint()
2789
+ self._report_gate(
2790
+ on_message,
2791
+ "Continuing: goal is not complete yet.",
2792
+ "Continuation_Gate: goal not reached; retrying next useful action.",
2793
+ )
2650
2794
  continue
2651
2795
  return response
2652
2796
  raise LLMError("agent step limit reached")
2653
2797
 
2798
+ def _report_gate(self, on_message: MessageCallback | None, message: str, debug_message: str) -> None:
2799
+ if on_message is not None:
2800
+ on_message(debug_message if self.session.debug else message)
2801
+
2802
+ def _compact_gate_report(self, gate: str) -> str:
2803
+ lines = gate.splitlines()
2804
+ headline = lines[0] if lines else "Gate"
2805
+ details = [line for line in lines[1:] if line.startswith("- ")]
2806
+ if details:
2807
+ return headline + ": " + _shorten("; ".join(details[:3]), 220)
2808
+ return headline
2809
+
2654
2810
  def step(self) -> Json:
2655
2811
  response = self.request(self.build_system_prompt(), self.build_user_prompt(), activity="main")
2656
2812
  self.state_updater.apply_tool_call_summaries(response)
@@ -2688,49 +2844,32 @@ class Agent:
2688
2844
 
2689
2845
  def _format_tool_summary_gate(self, tool_calls: list[JsonValue]) -> str:
2690
2846
  missing = self._missing_tool_summaries()
2691
- missing_evidence = []
2692
2847
  needs_read = []
2693
- for event, execution in zip(self.tool_runner.latest_events, self.tool_runner.latest_executions):
2848
+ for event in self.tool_runner.latest_events:
2694
2849
  if not event.summary:
2695
2850
  continue
2696
2851
  is_reading_result_file = self._has_read_result_file_call(tool_calls, event.result_file)
2697
- if event.needs_raw_read and not is_reading_result_file:
2698
- needs_read.append(event)
2699
- if event.outcome in {"failure", "partial"}:
2700
- if not event.key_details and not is_reading_result_file:
2701
- missing_evidence.append(event)
2852
+ if event.needs_raw_read:
2853
+ if not is_reading_result_file:
2854
+ needs_read.append(event)
2702
2855
  continue
2703
- if (
2704
- self._is_large_tool_output(execution.output)
2705
- and not event.key_details
2706
- and not event.needs_raw_read
2707
- and not is_reading_result_file
2708
- ):
2709
- missing_evidence.append(event)
2710
- if not missing and not missing_evidence and not needs_read:
2856
+ if not missing and not needs_read:
2711
2857
  return ""
2712
2858
 
2713
- lines = ["Tool_Summary_Gate: extract durable evidence before continuing.", "Raw tool results are visible only once."]
2859
+ lines = ["Tool_Summary_Gate: summarize latest tool results before continuing.", "Raw tool results are visible only once."]
2714
2860
  if missing:
2715
2861
  lines.append("Missing summaries:")
2716
2862
  for event in missing:
2717
2863
  lines.append("- " + event.executed + " -> " + event.result_file)
2718
- if missing_evidence:
2719
- lines.append("Missing key_evidence:")
2720
- for event in missing_evidence:
2721
- lines.append("- " + event.executed + " -> " + event.result_file)
2722
2864
  if needs_read:
2723
2865
  lines.append("Needs raw read:")
2724
2866
  for event in needs_read:
2725
2867
  lines.append("- Read(" + event.result_file + ") before continuing")
2726
2868
  lines.append(
2727
- "Next_Action: return last_tool_calls_summaries with key_evidence, update current_context_update for task-local facts, or call Read(result_file) for needs_raw_read logs."
2869
+ "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."
2728
2870
  )
2729
2871
  return "\n".join(lines)
2730
2872
 
2731
- def _is_large_tool_output(self, output: str) -> bool:
2732
- return len(output) > self.EVIDENCE_OUTPUT_CHARS or len(output.splitlines()) > self.EVIDENCE_OUTPUT_LINES
2733
-
2734
2873
  def _has_read_result_file_call(self, tool_calls: list[JsonValue], result_file: str) -> bool:
2735
2874
  if not result_file:
2736
2875
  return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.2.1
3
+ Version: 0.2.3
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nanocode-cli"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "A lightweight terminal-based AI coding assistant"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
File without changes
File without changes
File without changes
File without changes