nanocode-cli 0.2.2__tar.gz → 0.2.4__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.4
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.4"
45
45
 
46
46
 
47
47
  class Error(Exception): ...
@@ -1704,7 +1704,7 @@ Core:
1704
1704
  - Follow the plan, but revise it when facts require it.
1705
1705
 
1706
1706
  Tools:
1707
- - Call tools only by emitting JSON in tool_calls. Do not use native tool calls.
1707
+ - MUST use JSON tool_calls. Do not use native <tool_call>Tool(args...) syntax.
1708
1708
  - Use multiple tool calls in one turn when they are independent.
1709
1709
  - Prefer specific tools first; use Bash only when no provided tool fits.
1710
1710
  - Prefer Search before Read when locating code or facts; Read only known small ranges or exact files needed for editing.
@@ -1729,6 +1729,7 @@ Input:
1729
1729
  - Current_Context: task-local facts that may expire
1730
1730
  - Goal: current objective
1731
1731
  - Plan: ordered plan
1732
+ - Agent_Feedback: latest retry/gate warning for you; follow it before continuing
1732
1733
  - Latest_Tool_Call_Results: latest raw tool call results
1733
1734
  - Latest_User_Input: latest user message
1734
1735
  - Tools: available tool specs
@@ -1834,6 +1835,10 @@ MAIN_AGENT_USER_PROMPT_TEMPLATE = """
1834
1835
  {verification_state}
1835
1836
  -------- Verification_State End -----------
1836
1837
 
1838
+ ----------- Agent_Feedback Begin ------
1839
+ {agent_feedback}
1840
+ -------- Agent_Feedback End -----------
1841
+
1837
1842
  ----------- Latest_Tool_Call_Results Begin ------
1838
1843
  {latest_tool_call_results}
1839
1844
  -------- Latest_Tool_Call_Results End -----------
@@ -1888,7 +1893,7 @@ class PromptBuilder:
1888
1893
  def system_prompt(self) -> str:
1889
1894
  return MAIN_AGENT_SYSTEM_PROMPT.replace("{ __tools__ }", self._format_tools()).strip()
1890
1895
 
1891
- def user_prompt(self, latest_tool_call_results: str) -> str:
1896
+ def user_prompt(self, latest_tool_call_results: str, agent_feedback: str) -> str:
1892
1897
  current = self.session.current
1893
1898
  return MAIN_AGENT_USER_PROMPT_TEMPLATE.format(
1894
1899
  environment=self._format_environment(),
@@ -1898,6 +1903,7 @@ class PromptBuilder:
1898
1903
  goal=current.goal or "(empty)",
1899
1904
  plan=self._format_plan(),
1900
1905
  verification_state=current.verification.format(),
1906
+ agent_feedback=agent_feedback or "(empty)",
1901
1907
  latest_tool_call_results=latest_tool_call_results or "(empty)",
1902
1908
  latest_user_input=current.user_input or "(empty)",
1903
1909
  ).strip()
@@ -1995,6 +2001,8 @@ class ModelClient:
1995
2001
 
1996
2002
  self._record_usage(_json_dict(result.get("usage") if isinstance(result, dict) else None))
1997
2003
  content = self._message_content(result)
2004
+ if content is None:
2005
+ return self._invalid_model_response(self._format_missing_message_content(result))
1998
2006
  return self._parse_model_content(content)
1999
2007
 
2000
2008
  def _write_debug_prompt(self, *, activity: str, messages: list[Json]) -> str:
@@ -2024,13 +2032,9 @@ class ModelClient:
2024
2032
 
2025
2033
  def _parse_model_content(self, content: str) -> Json:
2026
2034
  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()
2035
+ text = self._strip_leaked_think_tags(text)
2036
+ text = self._strip_json_fence(text)
2037
+ text = self._strip_leaked_think_tags(text)
2034
2038
  try:
2035
2039
  value = json.loads(text)
2036
2040
  except json.JSONDecodeError:
@@ -2039,15 +2043,49 @@ class ModelClient:
2039
2043
  return self._invalid_model_response(content)
2040
2044
  return value
2041
2045
 
2046
+ def _strip_json_fence(self, text: str) -> str:
2047
+ if not text.startswith("```"):
2048
+ return text
2049
+ lines = text.splitlines()
2050
+ if lines and lines[0].startswith("```"):
2051
+ lines = lines[1:]
2052
+ if lines and lines[-1].strip() == "```":
2053
+ lines = lines[:-1]
2054
+ return "\n".join(lines).strip()
2055
+
2056
+ def _strip_leaked_think_tags(self, text: str) -> str:
2057
+ text = text.strip()
2058
+ while text.startswith("</think>"):
2059
+ text = text[len("</think>") :].lstrip()
2060
+ while text.startswith("<think>"):
2061
+ end = text.find("</think>")
2062
+ if end < 0:
2063
+ return text
2064
+ text = text[end + len("</think>") :].lstrip()
2065
+ while text.startswith("</think>"):
2066
+ text = text[len("</think>") :].lstrip()
2067
+ return text
2068
+
2042
2069
  def _invalid_model_response(self, content: str) -> Json:
2070
+ guidance = ""
2071
+ if self._looks_like_native_tool_call(content):
2072
+ guidance = (
2073
+ " Native tool_call syntax is not supported; return one JSON object with tool_calls entries like "
2074
+ '{"name":"Read","intention":"...","args":["nanocode.py","0","100"]}.'
2075
+ )
2043
2076
  return {
2044
2077
  "goal_reached": False,
2045
2078
  "tool_calls": None,
2046
2079
  "message_to_user": None,
2047
2080
  "_format_error": "Invalid model output: expected one JSON object matching the Output JSON schema. Return strict JSON only. Bad output: "
2048
- + _shorten(content),
2081
+ + _shorten(content)
2082
+ + guidance,
2049
2083
  }
2050
2084
 
2085
+ def _looks_like_native_tool_call(self, content: str) -> bool:
2086
+ text = self._strip_leaked_think_tags(content.strip())
2087
+ return text.startswith("<tool_call>")
2088
+
2051
2089
  def _chat_completions_url(self) -> str:
2052
2090
  url = self.session.api_url.rstrip("/")
2053
2091
  if url.endswith("/chat/completions"):
@@ -2061,7 +2099,7 @@ class ModelClient:
2061
2099
  return {"reasoning": {"effort": self.session.reasoning_effort}}
2062
2100
  return {}
2063
2101
 
2064
- def _message_content(self, result: JsonValue) -> str:
2102
+ def _message_content(self, result: JsonValue) -> str | None:
2065
2103
  data = _json_dict(result)
2066
2104
  choices = _json_list(data.get("choices"))
2067
2105
  if not choices:
@@ -2069,9 +2107,18 @@ class ModelClient:
2069
2107
  message = _json_dict(_json_dict(choices[0]).get("message"))
2070
2108
  content = message.get("content")
2071
2109
  if not isinstance(content, str):
2072
- raise LLMError("API response missing message content")
2110
+ return None
2073
2111
  return content
2074
2112
 
2113
+ def _format_missing_message_content(self, result: JsonValue) -> str:
2114
+ choice = _json_dict(_json_list(_json_dict(result).get("choices"))[0])
2115
+ message = _json_dict(choice.get("message"))
2116
+ details: Json = {
2117
+ "finish_reason": choice.get("finish_reason"),
2118
+ "message_keys": sorted(str(key) for key in message.keys()),
2119
+ }
2120
+ return "API response missing message content: " + json.dumps(details, ensure_ascii=False)
2121
+
2075
2122
  def _record_usage(self, usage: Json) -> None:
2076
2123
  prompt_tokens = _json_int(usage.get("prompt_tokens"))
2077
2124
  completion_tokens = _json_int(usage.get("completion_tokens"))
@@ -2627,6 +2674,9 @@ class ConversationCompactor:
2627
2674
 
2628
2675
  @final
2629
2676
  class Agent:
2677
+ MAX_CONSECUTIVE_FORMAT_ERRORS: ClassVar[int] = 3
2678
+ MAX_CONSECUTIVE_SUMMARY_GATES: ClassVar[int] = 1
2679
+
2630
2680
  def __init__(self, session: Session):
2631
2681
  self.session = session
2632
2682
  self.prompt_builder = PromptBuilder(session)
@@ -2635,6 +2685,7 @@ class Agent:
2635
2685
  self.state_updater = AgentStateUpdater(session, self.tool_runner)
2636
2686
  self.compactor = ConversationCompactor(session, self.model_client)
2637
2687
  self.latest_tool_call_results = ""
2688
+ self.latest_agent_feedback = ""
2638
2689
 
2639
2690
  @property
2640
2691
  def latest_tool_call_events(self) -> list[ToolCallEvent]:
@@ -2644,9 +2695,10 @@ class Agent:
2644
2695
  return self.prompt_builder.system_prompt()
2645
2696
 
2646
2697
  def build_user_prompt(self, *, consume_latest_tool_results: bool = True) -> str:
2647
- prompt = self.prompt_builder.user_prompt(self.latest_tool_call_results)
2698
+ prompt = self.prompt_builder.user_prompt(self.latest_tool_call_results, self.latest_agent_feedback)
2648
2699
  if consume_latest_tool_results:
2649
2700
  self.latest_tool_call_results = ""
2701
+ self.latest_agent_feedback = ""
2650
2702
  return prompt
2651
2703
 
2652
2704
  def request(self, system_prompt: str, user_prompt: str, *, activity: str = "main") -> Json:
@@ -2667,23 +2719,64 @@ class Agent:
2667
2719
  on_message: MessageCallback | None = None,
2668
2720
  ) -> Json:
2669
2721
  self.latest_tool_call_results = ""
2722
+ self.latest_agent_feedback = ""
2670
2723
  self.session.current.user_input = user_input
2671
2724
  self.session.current.goal_reached = False
2672
2725
  self.maybe_auto_compact()
2673
2726
  self.session.append_conversation(UserMessage(content=user_input))
2727
+ consecutive_format_errors = 0
2728
+ consecutive_summary_gates = 0
2674
2729
 
2675
2730
  for _ in range(self.session.max_agent_steps):
2676
2731
  response = self.step()
2677
2732
  format_error = _json_str(response.get("_format_error"))
2678
2733
  if format_error:
2679
- self.latest_tool_call_results = format_error
2734
+ consecutive_format_errors += 1
2735
+ self.latest_agent_feedback = format_error
2736
+ if consecutive_format_errors >= self.MAX_CONSECUTIVE_FORMAT_ERRORS:
2737
+ self._report_gate(
2738
+ on_message,
2739
+ "Stopped: model returned invalid output "
2740
+ + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS)
2741
+ + " times in a row.",
2742
+ "Format_Gate: stopped after "
2743
+ + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS)
2744
+ + " consecutive invalid model outputs. "
2745
+ + _shorten(format_error, 180),
2746
+ )
2747
+ raise LLMError(
2748
+ "model returned invalid output "
2749
+ + str(self.MAX_CONSECUTIVE_FORMAT_ERRORS)
2750
+ + " times in a row: "
2751
+ + _shorten(format_error, 300)
2752
+ )
2753
+ self._report_gate(
2754
+ on_message,
2755
+ "Retrying: model returned invalid output.",
2756
+ "Format_Gate: retrying model response. " + _shorten(format_error, 180),
2757
+ )
2680
2758
  continue
2759
+ consecutive_format_errors = 0
2681
2760
  tool_calls = _json_list(response.get("tool_calls"))
2682
2761
  summary_gate = self._format_tool_summary_gate(tool_calls)
2683
2762
  if summary_gate:
2684
- self.state_updater.latest_report = ""
2685
- self.latest_tool_call_results = summary_gate
2686
- continue
2763
+ consecutive_summary_gates += 1
2764
+ if consecutive_summary_gates <= self.MAX_CONSECUTIVE_SUMMARY_GATES:
2765
+ self.state_updater.latest_report = ""
2766
+ self.latest_agent_feedback = summary_gate
2767
+ self._report_gate(
2768
+ on_message,
2769
+ "Retrying: model needs to summarize the latest tool results.",
2770
+ self._compact_gate_report(summary_gate),
2771
+ )
2772
+ continue
2773
+ self._report_gate(
2774
+ on_message,
2775
+ "Continuing: model did not summarize tool results after one retry.",
2776
+ "Tool_Summary_Gate: allowing continuation after one missing-summary retry.",
2777
+ )
2778
+ else:
2779
+ consecutive_summary_gates = 0
2687
2780
  self.apply_response(response)
2688
2781
  if on_message is not None and self.state_updater.latest_report:
2689
2782
  on_message(self.state_updater.latest_report)
@@ -2704,14 +2797,36 @@ class Agent:
2704
2797
  return response
2705
2798
  if self.session.current.verification.status == VerificationStatus.REQUIRED:
2706
2799
  self.session.current.goal_reached = False
2707
- self.latest_tool_call_results = self._format_verification_gate()
2800
+ self.latest_agent_feedback = self._format_verification_gate()
2801
+ self._report_gate(
2802
+ on_message,
2803
+ "Retrying: verification is required before completion.",
2804
+ "Verification_Gate: retrying until verification is passed or blocked.",
2805
+ )
2708
2806
  continue
2709
2807
  if not self.session.current.goal_reached:
2710
- self.latest_tool_call_results = self._format_continuation_hint()
2808
+ self.latest_agent_feedback = self._format_continuation_hint()
2809
+ self._report_gate(
2810
+ on_message,
2811
+ "Continuing: goal is not complete yet.",
2812
+ "Continuation_Gate: goal not reached; retrying next useful action.",
2813
+ )
2711
2814
  continue
2712
2815
  return response
2713
2816
  raise LLMError("agent step limit reached")
2714
2817
 
2818
+ def _report_gate(self, on_message: MessageCallback | None, message: str, debug_message: str) -> None:
2819
+ if on_message is not None:
2820
+ on_message(debug_message if self.session.debug else message)
2821
+
2822
+ def _compact_gate_report(self, gate: str) -> str:
2823
+ lines = gate.splitlines()
2824
+ headline = lines[0] if lines else "Gate"
2825
+ details = [line for line in lines[1:] if line.startswith("- ")]
2826
+ if details:
2827
+ return headline + ": " + _shorten("; ".join(details[:3]), 220)
2828
+ return headline
2829
+
2715
2830
  def step(self) -> Json:
2716
2831
  response = self.request(self.build_system_prompt(), self.build_user_prompt(), activity="main")
2717
2832
  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.4
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.4"
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