nanocode-cli 0.2.2__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.2
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.2"
44
+ __version__ = "0.2.3"
45
45
 
46
46
 
47
47
  class Error(Exception): ...
@@ -1995,6 +1995,8 @@ class ModelClient:
1995
1995
 
1996
1996
  self._record_usage(_json_dict(result.get("usage") if isinstance(result, dict) else None))
1997
1997
  content = self._message_content(result)
1998
+ if content is None:
1999
+ return self._invalid_model_response(self._format_missing_message_content(result))
1998
2000
  return self._parse_model_content(content)
1999
2001
 
2000
2002
  def _write_debug_prompt(self, *, activity: str, messages: list[Json]) -> str:
@@ -2024,13 +2026,9 @@ class ModelClient:
2024
2026
 
2025
2027
  def _parse_model_content(self, content: str) -> Json:
2026
2028
  text = content.strip()
2027
- if text.startswith("```"):
2028
- lines = text.splitlines()
2029
- if lines and lines[0].startswith("```"):
2030
- lines = lines[1:]
2031
- if lines and lines[-1].strip() == "```":
2032
- lines = lines[:-1]
2033
- 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)
2034
2032
  try:
2035
2033
  value = json.loads(text)
2036
2034
  except json.JSONDecodeError:
@@ -2039,15 +2037,49 @@ class ModelClient:
2039
2037
  return self._invalid_model_response(content)
2040
2038
  return value
2041
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
+
2042
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
+ )
2043
2070
  return {
2044
2071
  "goal_reached": False,
2045
2072
  "tool_calls": None,
2046
2073
  "message_to_user": None,
2047
2074
  "_format_error": "Invalid model output: expected one JSON object matching the Output JSON schema. Return strict JSON only. Bad output: "
2048
- + _shorten(content),
2075
+ + _shorten(content)
2076
+ + guidance,
2049
2077
  }
2050
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
+
2051
2083
  def _chat_completions_url(self) -> str:
2052
2084
  url = self.session.api_url.rstrip("/")
2053
2085
  if url.endswith("/chat/completions"):
@@ -2061,7 +2093,7 @@ class ModelClient:
2061
2093
  return {"reasoning": {"effort": self.session.reasoning_effort}}
2062
2094
  return {}
2063
2095
 
2064
- def _message_content(self, result: JsonValue) -> str:
2096
+ def _message_content(self, result: JsonValue) -> str | None:
2065
2097
  data = _json_dict(result)
2066
2098
  choices = _json_list(data.get("choices"))
2067
2099
  if not choices:
@@ -2069,9 +2101,18 @@ class ModelClient:
2069
2101
  message = _json_dict(_json_dict(choices[0]).get("message"))
2070
2102
  content = message.get("content")
2071
2103
  if not isinstance(content, str):
2072
- raise LLMError("API response missing message content")
2104
+ return None
2073
2105
  return content
2074
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
+
2075
2116
  def _record_usage(self, usage: Json) -> None:
2076
2117
  prompt_tokens = _json_int(usage.get("prompt_tokens"))
2077
2118
  completion_tokens = _json_int(usage.get("completion_tokens"))
@@ -2627,6 +2668,8 @@ class ConversationCompactor:
2627
2668
 
2628
2669
  @final
2629
2670
  class Agent:
2671
+ MAX_CONSECUTIVE_FORMAT_ERRORS: ClassVar[int] = 3
2672
+
2630
2673
  def __init__(self, session: Session):
2631
2674
  self.session = session
2632
2675
  self.prompt_builder = PromptBuilder(session)
@@ -2671,18 +2714,48 @@ class Agent:
2671
2714
  self.session.current.goal_reached = False
2672
2715
  self.maybe_auto_compact()
2673
2716
  self.session.append_conversation(UserMessage(content=user_input))
2717
+ consecutive_format_errors = 0
2674
2718
 
2675
2719
  for _ in range(self.session.max_agent_steps):
2676
2720
  response = self.step()
2677
2721
  format_error = _json_str(response.get("_format_error"))
2678
2722
  if format_error:
2723
+ consecutive_format_errors += 1
2679
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
+ )
2680
2747
  continue
2748
+ consecutive_format_errors = 0
2681
2749
  tool_calls = _json_list(response.get("tool_calls"))
2682
2750
  summary_gate = self._format_tool_summary_gate(tool_calls)
2683
2751
  if summary_gate:
2684
2752
  self.state_updater.latest_report = ""
2685
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
+ )
2686
2759
  continue
2687
2760
  self.apply_response(response)
2688
2761
  if on_message is not None and self.state_updater.latest_report:
@@ -2705,13 +2778,35 @@ class Agent:
2705
2778
  if self.session.current.verification.status == VerificationStatus.REQUIRED:
2706
2779
  self.session.current.goal_reached = False
2707
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
+ )
2708
2786
  continue
2709
2787
  if not self.session.current.goal_reached:
2710
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
+ )
2711
2794
  continue
2712
2795
  return response
2713
2796
  raise LLMError("agent step limit reached")
2714
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
+
2715
2810
  def step(self) -> Json:
2716
2811
  response = self.request(self.build_system_prompt(), self.build_user_prompt(), activity="main")
2717
2812
  self.state_updater.apply_tool_call_summaries(response)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.2.2
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.2"
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