nanocode-cli 0.3.28__tar.gz → 0.3.34__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.
- {nanocode_cli-0.3.28/nanocode_cli.egg-info → nanocode_cli-0.3.34}/PKG-INFO +2 -2
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.34}/README.md +1 -1
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.34}/nanocode.py +175 -24
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.34/nanocode_cli.egg-info}/PKG-INFO +2 -2
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.34}/pyproject.toml +1 -1
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.34}/LICENSE +0 -0
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.34}/MANIFEST.in +0 -0
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.34}/nanocode_cli.egg-info/SOURCES.txt +0 -0
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.34}/nanocode_cli.egg-info/dependency_links.txt +0 -0
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.34}/nanocode_cli.egg-info/entry_points.txt +0 -0
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.34}/nanocode_cli.egg-info/requires.txt +0 -0
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.34}/nanocode_cli.egg-info/top_level.txt +0 -0
- {nanocode_cli-0.3.28 → nanocode_cli-0.3.34}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.34
|
|
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
|
|
@@ -119,7 +119,7 @@ USE AT YOUR OWN RISK.
|
|
|
119
119
|
- Maintenance: `/clean`.
|
|
120
120
|
- Exit: `/exit`, `/quit`.
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
Selectors support `j`/`k`, arrows, `/keyword`, Enter, and Esc. `/model` lists configured models before discovered ones, then prompts for reasoning; `/model <name>` and `/reason` are direct shortcuts.
|
|
123
123
|
|
|
124
124
|
## Configuration
|
|
125
125
|
|
|
@@ -89,7 +89,7 @@ USE AT YOUR OWN RISK.
|
|
|
89
89
|
- Maintenance: `/clean`.
|
|
90
90
|
- Exit: `/exit`, `/quit`.
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
Selectors support `j`/`k`, arrows, `/keyword`, Enter, and Esc. `/model` lists configured models before discovered ones, then prompts for reasoning; `/model <name>` and `/reason` are direct shortcuts.
|
|
93
93
|
|
|
94
94
|
## Configuration
|
|
95
95
|
|
|
@@ -51,7 +51,7 @@ from prompt_toolkit.output.defaults import create_output
|
|
|
51
51
|
from prompt_toolkit.patch_stdout import patch_stdout
|
|
52
52
|
from prompt_toolkit.styles import Style
|
|
53
53
|
|
|
54
|
-
__version__ = "0.3.
|
|
54
|
+
__version__ = "0.3.34"
|
|
55
55
|
HTTP_USER_AGENT = "nanocode/" + __version__
|
|
56
56
|
|
|
57
57
|
|
|
@@ -823,6 +823,8 @@ class RuntimeState:
|
|
|
823
823
|
current_model_call_reasoning_label: str = ""
|
|
824
824
|
current_model_call_activity: str = ""
|
|
825
825
|
current_model_call_has_content: bool = False
|
|
826
|
+
current_model_call_streaming_chars: int = 0
|
|
827
|
+
last_model_call_rate: float = 0.0
|
|
826
828
|
status_notice: str = ""
|
|
827
829
|
status_notice_until: float = 0.0
|
|
828
830
|
conversation: list[ConversationItem] = field(default_factory=list)
|
|
@@ -1167,6 +1169,17 @@ class ToolResultContext:
|
|
|
1167
1169
|
self.recent = [compact(block) for block in self.recent]
|
|
1168
1170
|
self.latest = [compact(block) for block in self.latest]
|
|
1169
1171
|
|
|
1172
|
+
def act_blocks(self) -> list[str]:
|
|
1173
|
+
# Pending observe blocks are unresolved raw results. Keep them visible to
|
|
1174
|
+
# ACT until observe explicitly keeps/forgets them; otherwise an older
|
|
1175
|
+
# pending result can degrade to a compact recent summary before observe
|
|
1176
|
+
# gets a chance to select it.
|
|
1177
|
+
latest_keys = set(self.blocks_by_key(self.latest))
|
|
1178
|
+
pending = [block for block in self.pending_observe if self.result_key(block) not in latest_keys]
|
|
1179
|
+
raw_keys = set(self.blocks_by_key(pending)) | latest_keys
|
|
1180
|
+
recent = [block for block in self.recent if self.result_key(block) not in raw_keys]
|
|
1181
|
+
return recent + pending + self.latest
|
|
1182
|
+
|
|
1170
1183
|
def visible_counter(self, mode: AgentMode) -> int:
|
|
1171
1184
|
if mode == AgentMode.OBSERVE and self.pending_observe:
|
|
1172
1185
|
return self.max_counter(self.pending_observe)
|
|
@@ -1670,7 +1683,7 @@ class SearchTool(Tool):
|
|
|
1670
1683
|
MAX_CONTEXT_LINES: ClassVar[int] = 30
|
|
1671
1684
|
EFFECT: ClassVar[ToolEffect] = ToolEffect.READONLY
|
|
1672
1685
|
DESCRIPTION: ClassVar[tuple[str, ...]] = (
|
|
1673
|
-
"Case-insensitive regex search before Read; use A|B|C for alternatives.",
|
|
1686
|
+
"Case-insensitive regex search before Read; use A|B|C for alternatives and \\n for multiline matches.",
|
|
1674
1687
|
"For exact text, escape regex metacharacters like braces, parens, dots, stars, and brackets.",
|
|
1675
1688
|
"Scope with path=FILE_OR_DIR, optionally filter with one glob=*.py, set context=N for 0..30 lines; omitted path defaults to current directory.",
|
|
1676
1689
|
"Second positional arg is always path, third positional arg is always glob; with path=, extra leading positional args are joined as regex alternatives.",
|
|
@@ -1713,8 +1726,7 @@ class SearchTool(Tool):
|
|
|
1713
1726
|
pattern = raw_pattern[3:] if raw_pattern.startswith("re:") else raw_pattern
|
|
1714
1727
|
if not pattern:
|
|
1715
1728
|
raise ToolCallArgError("pattern cannot be empty")
|
|
1716
|
-
|
|
1717
|
-
raise ToolCallArgError("multiline regex is not supported; Search is line-oriented. Search each line separately or Read a nearby range.")
|
|
1729
|
+
pattern = pattern.replace("\\n", "\n").replace("\\r", "\r")
|
|
1718
1730
|
target_path_arg = "."
|
|
1719
1731
|
glob_pattern = ""
|
|
1720
1732
|
context_lines = cls.CONTEXT_LINES
|
|
@@ -1863,7 +1875,9 @@ class SearchTool(Tool):
|
|
|
1863
1875
|
yield path
|
|
1864
1876
|
|
|
1865
1877
|
def _make_match(self, path: str, line_number: int, text: str) -> Match:
|
|
1866
|
-
|
|
1878
|
+
text = text.rstrip("\n")
|
|
1879
|
+
preview = _shorten(" ".join(text.split()), 300) if "\n" in text or "\r" in text else text[:300]
|
|
1880
|
+
return self.Match(path=path, line_number=line_number, text=preview, context=self._read_match_context(path, line_number))
|
|
1867
1881
|
|
|
1868
1882
|
def _read_match_context(self, path: str, line_number: int) -> list[tuple[int, str]]:
|
|
1869
1883
|
if line_number <= 0:
|
|
@@ -1902,6 +1916,8 @@ class SearchTool(Tool):
|
|
|
1902
1916
|
|
|
1903
1917
|
def _rg_command(self, rg: str, *, pcre2: bool = False) -> list[str]:
|
|
1904
1918
|
cmd = [rg, "--json", "--line-number", "--max-filesize", self.RG_MAX_FILESIZE]
|
|
1919
|
+
if self._is_multiline():
|
|
1920
|
+
cmd.extend(["-U", "--multiline-dotall"])
|
|
1905
1921
|
if pcre2:
|
|
1906
1922
|
cmd.append("--pcre2")
|
|
1907
1923
|
cmd.append("-i")
|
|
@@ -1955,8 +1971,13 @@ class SearchTool(Tool):
|
|
|
1955
1971
|
text = stderr.lower()
|
|
1956
1972
|
return "pcre2" in text and ("look-around" in text or "look-ahead" in text or "look-behind" in text)
|
|
1957
1973
|
|
|
1974
|
+
def _is_multiline(self) -> bool:
|
|
1975
|
+
return "\n" in self.pattern or "\r" in self.pattern
|
|
1976
|
+
|
|
1958
1977
|
def _call_python(self) -> str:
|
|
1959
1978
|
matches = []
|
|
1979
|
+
if self._is_multiline():
|
|
1980
|
+
return self._call_python_multiline()
|
|
1960
1981
|
for path in self._iter_files():
|
|
1961
1982
|
try:
|
|
1962
1983
|
if os.path.getsize(path) > self.MAX_FILE_BYTES:
|
|
@@ -1974,6 +1995,28 @@ class SearchTool(Tool):
|
|
|
1974
1995
|
|
|
1975
1996
|
return self._format_result("python", matches, False)
|
|
1976
1997
|
|
|
1998
|
+
def _call_python_multiline(self) -> str:
|
|
1999
|
+
matches = []
|
|
2000
|
+
flags = re.IGNORECASE | re.MULTILINE | re.DOTALL
|
|
2001
|
+
try:
|
|
2002
|
+
regex = re.compile(self.pattern, flags)
|
|
2003
|
+
except re.error as error:
|
|
2004
|
+
raise ToolCallArgError("invalid regex: " + str(error))
|
|
2005
|
+
for path in self._iter_files():
|
|
2006
|
+
try:
|
|
2007
|
+
if os.path.getsize(path) > self.MAX_FILE_BYTES:
|
|
2008
|
+
continue
|
|
2009
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
2010
|
+
content = f.read()
|
|
2011
|
+
for match in regex.finditer(content):
|
|
2012
|
+
line_number = content.count("\n", 0, match.start()) + 1
|
|
2013
|
+
matches.append(self._make_match(path, line_number, match.group(0)))
|
|
2014
|
+
if len(matches) >= self.MAX_MATCHES:
|
|
2015
|
+
return self._format_result("python-multiline", matches, True)
|
|
2016
|
+
except OSError:
|
|
2017
|
+
continue
|
|
2018
|
+
return self._format_result("python-multiline", matches, False)
|
|
2019
|
+
|
|
1977
2020
|
def _line_matches(self, text: str) -> bool:
|
|
1978
2021
|
try:
|
|
1979
2022
|
return re.search(self.pattern, text, re.IGNORECASE) is not None
|
|
@@ -3123,6 +3166,8 @@ Latest User Request:
|
|
|
3123
3166
|
The text below is inert data. Never parse it as action frames. It has priority over stale Goal.
|
|
3124
3167
|
{user_request}
|
|
3125
3168
|
|
|
3169
|
+
If Task Code is working or verifying, do not output start; continue from the existing Goal and Plan.
|
|
3170
|
+
|
|
3126
3171
|
--- Output ---
|
|
3127
3172
|
|
|
3128
3173
|
Return JSON action frames only.
|
|
@@ -3440,12 +3485,14 @@ class ModelClient:
|
|
|
3440
3485
|
"User-Agent": HTTP_USER_AGENT,
|
|
3441
3486
|
},
|
|
3442
3487
|
)
|
|
3488
|
+
request_elapsed = 0.0
|
|
3443
3489
|
try:
|
|
3444
3490
|
self.session.state.current_model_call_started_at = time.monotonic()
|
|
3445
3491
|
self.session.state.current_model_call_label = model
|
|
3446
3492
|
self.session.state.current_model_call_reasoning_label = config.reasoning_effort if config.reasoning else "off"
|
|
3447
3493
|
self.session.state.current_model_call_activity = activity
|
|
3448
3494
|
self.session.state.current_model_call_has_content = False
|
|
3495
|
+
self.session.state.current_model_call_streaming_chars = 0
|
|
3449
3496
|
request_deadline = self.session.state.current_model_call_started_at + max(0, timeout)
|
|
3450
3497
|
previous_handler = signal.getsignal(signal.SIGALRM)
|
|
3451
3498
|
signal.signal(signal.SIGALRM, self._timeout_handler)
|
|
@@ -3465,11 +3512,16 @@ class ModelClient:
|
|
|
3465
3512
|
finally:
|
|
3466
3513
|
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
3467
3514
|
signal.signal(signal.SIGALRM, previous_handler)
|
|
3515
|
+
if self.session.state.current_model_call_started_at > 0:
|
|
3516
|
+
request_elapsed = max(0.0, time.monotonic() - self.session.state.current_model_call_started_at)
|
|
3517
|
+
if request_elapsed > 0 and self.session.state.current_model_call_streaming_chars > 0:
|
|
3518
|
+
self.session.state.last_model_call_rate = self._estimate_stream_rate(request_elapsed)
|
|
3468
3519
|
self.session.state.current_model_call_started_at = 0.0
|
|
3469
3520
|
self.session.state.current_model_call_label = ""
|
|
3470
3521
|
self.session.state.current_model_call_reasoning_label = ""
|
|
3471
3522
|
self.session.state.current_model_call_activity = ""
|
|
3472
3523
|
self.session.state.current_model_call_has_content = False
|
|
3524
|
+
self.session.state.current_model_call_streaming_chars = 0
|
|
3473
3525
|
except ModelRequestTimeout as error:
|
|
3474
3526
|
raise LLMError(str(error) or "request model timeout")
|
|
3475
3527
|
except (socket.timeout, TimeoutError):
|
|
@@ -3490,7 +3542,7 @@ class ModelClient:
|
|
|
3490
3542
|
except json.JSONDecodeError:
|
|
3491
3543
|
raise LLMError("API response is not JSON: " + _shorten(body))
|
|
3492
3544
|
|
|
3493
|
-
self._record_usage(_json_dict(result.get("usage") if isinstance(result, dict) else None), config)
|
|
3545
|
+
self._record_usage(_json_dict(result.get("usage") if isinstance(result, dict) else None), config, elapsed=request_elapsed)
|
|
3494
3546
|
if not stream:
|
|
3495
3547
|
content = self._message_content(result)
|
|
3496
3548
|
if content is None:
|
|
@@ -3538,8 +3590,12 @@ class ModelClient:
|
|
|
3538
3590
|
self.session.state.current_model_call_has_content = True
|
|
3539
3591
|
self._arm_stream_timeout(request_deadline=request_deadline, first_content_seen=True, first_token_timeout=first_token_timeout)
|
|
3540
3592
|
parts.append(content)
|
|
3593
|
+
self.session.state.current_model_call_streaming_chars += len(content)
|
|
3541
3594
|
return "".join(parts), usage
|
|
3542
3595
|
|
|
3596
|
+
def _estimate_stream_rate(self, elapsed: float) -> float:
|
|
3597
|
+
return self.session.state.current_model_call_streaming_chars / 4 / elapsed if elapsed > 0 else 0.0
|
|
3598
|
+
|
|
3543
3599
|
def _arm_stream_timeout(self, *, request_deadline: float, first_content_seen: bool, first_token_timeout: int | None) -> None:
|
|
3544
3600
|
remaining = request_deadline - time.monotonic()
|
|
3545
3601
|
if remaining <= 0:
|
|
@@ -3579,8 +3635,11 @@ class ModelClient:
|
|
|
3579
3635
|
def _parse_model_content(self, content: str) -> Json:
|
|
3580
3636
|
text = content.strip()
|
|
3581
3637
|
text = self._strip_leaked_think_tags(text)
|
|
3638
|
+
text = self._strip_leaked_tool_code(text)
|
|
3582
3639
|
text = self._strip_json_fence(text)
|
|
3640
|
+
text = self._strip_fence_marker_lines(text)
|
|
3583
3641
|
text = self._strip_leaked_think_tags(text)
|
|
3642
|
+
text = self._strip_leaked_tool_code(text)
|
|
3584
3643
|
if not self._has_action_frame_end(text):
|
|
3585
3644
|
actions, error = self._parse_unmarked_actions(text)
|
|
3586
3645
|
if actions:
|
|
@@ -3677,9 +3736,10 @@ class ModelClient:
|
|
|
3677
3736
|
|
|
3678
3737
|
def _normalize_tool_type(self, action: Json) -> None:
|
|
3679
3738
|
action_type = _json_str(action.get("type"))
|
|
3680
|
-
if action_type
|
|
3739
|
+
tool_name = next((name for name in TOOL_REGISTRY if name.lower() == action_type.lower()), "") if action_type else ""
|
|
3740
|
+
if tool_name:
|
|
3681
3741
|
action["type"] = "tool"
|
|
3682
|
-
action.setdefault("name",
|
|
3742
|
+
action.setdefault("name", tool_name)
|
|
3683
3743
|
|
|
3684
3744
|
def _parse_unmarked_actions(self, text: str) -> tuple[list[Json], str]:
|
|
3685
3745
|
actions: list[Json] = []
|
|
@@ -3704,12 +3764,15 @@ class ModelClient:
|
|
|
3704
3764
|
return parsed, ""
|
|
3705
3765
|
action_start = text.find("{", index)
|
|
3706
3766
|
if action_start < 0:
|
|
3767
|
+
progress = self._plain_progress_text(text[index:])
|
|
3768
|
+
if progress:
|
|
3769
|
+
return [{"type": "progress", "text": progress}], ""
|
|
3707
3770
|
try:
|
|
3708
3771
|
decoder.raw_decode(text, index)
|
|
3709
3772
|
except json.JSONDecodeError as error:
|
|
3710
3773
|
return [], str(error)
|
|
3711
3774
|
return [], "expected JSON object action"
|
|
3712
|
-
prefix =
|
|
3775
|
+
prefix = self._progress_text(text[:action_start])
|
|
3713
3776
|
index = action_start
|
|
3714
3777
|
while True:
|
|
3715
3778
|
while index < len(text) and text[index].isspace():
|
|
@@ -3721,6 +3784,14 @@ class ModelClient:
|
|
|
3721
3784
|
try:
|
|
3722
3785
|
value, index = decoder.raw_decode(text, index)
|
|
3723
3786
|
except json.JSONDecodeError as error:
|
|
3787
|
+
if actions:
|
|
3788
|
+
return [], str(error)
|
|
3789
|
+
if self._should_repair_json_decode_error(str(error), text):
|
|
3790
|
+
repaired, repair_error = self._repair_single_json_action(text)
|
|
3791
|
+
if not repair_error:
|
|
3792
|
+
if prefix:
|
|
3793
|
+
repaired.insert(0, {"type": "progress", "text": prefix})
|
|
3794
|
+
return repaired, ""
|
|
3724
3795
|
return [], str(error)
|
|
3725
3796
|
parsed, error = self._actions_from_json_value(value)
|
|
3726
3797
|
if error:
|
|
@@ -3734,12 +3805,40 @@ class ModelClient:
|
|
|
3734
3805
|
if index < len(text) and text[index] != "{":
|
|
3735
3806
|
next_action = text.find("{", index)
|
|
3736
3807
|
if next_action < 0:
|
|
3808
|
+
if self._should_repair_trailing_json_text(text[index:]):
|
|
3809
|
+
repaired, error = self._repair_single_json_action(text)
|
|
3810
|
+
if not error:
|
|
3811
|
+
return repaired, ""
|
|
3737
3812
|
return [], "unexpected text after JSON action"
|
|
3738
|
-
progress =
|
|
3813
|
+
progress = self._progress_text(text[index:next_action])
|
|
3739
3814
|
if progress:
|
|
3740
3815
|
actions.append({"type": "progress", "text": progress})
|
|
3741
3816
|
index = next_action
|
|
3742
3817
|
|
|
3818
|
+
def _progress_text(self, text: str) -> str:
|
|
3819
|
+
text = re.sub(r"```[a-zA-Z0-9_-]*", "", text)
|
|
3820
|
+
text = text.replace("```", "")
|
|
3821
|
+
return _shorten(" ".join(text.split()), 500)
|
|
3822
|
+
|
|
3823
|
+
def _plain_progress_text(self, text: str) -> str:
|
|
3824
|
+
progress = self._progress_text(text)
|
|
3825
|
+
if not progress or "{" in progress or "}" in progress:
|
|
3826
|
+
return ""
|
|
3827
|
+
starters = (
|
|
3828
|
+
"let me ",
|
|
3829
|
+
"i need ",
|
|
3830
|
+
"i will ",
|
|
3831
|
+
"i'll ",
|
|
3832
|
+
"now ",
|
|
3833
|
+
"next ",
|
|
3834
|
+
"我需要",
|
|
3835
|
+
"让我",
|
|
3836
|
+
"我会",
|
|
3837
|
+
"现在",
|
|
3838
|
+
"接下来",
|
|
3839
|
+
)
|
|
3840
|
+
return progress if progress.lower().startswith(starters) else ""
|
|
3841
|
+
|
|
3743
3842
|
def _decode_json_array_text(self, text: str, index: int) -> tuple[JsonValue, int]:
|
|
3744
3843
|
decoder = json.JSONDecoder()
|
|
3745
3844
|
value, end = decoder.raw_decode(text, index)
|
|
@@ -3753,6 +3852,21 @@ class ModelClient:
|
|
|
3753
3852
|
raise ValueError("expected JSON action array")
|
|
3754
3853
|
return value, len(text)
|
|
3755
3854
|
|
|
3855
|
+
def _repair_single_json_action(self, text: str) -> tuple[list[Json], str]:
|
|
3856
|
+
try:
|
|
3857
|
+
value = json_repair.loads(text)
|
|
3858
|
+
except Exception as error:
|
|
3859
|
+
return [], str(error)
|
|
3860
|
+
if isinstance(value, list):
|
|
3861
|
+
return [], "unexpected text after JSON action"
|
|
3862
|
+
return self._actions_from_json_value(value)
|
|
3863
|
+
|
|
3864
|
+
def _should_repair_json_decode_error(self, error: str, text: str) -> bool:
|
|
3865
|
+
return "Invalid control character" in error or re.fullmatch(r".*[}\]]\s*[}\]]+\s*", text, re.DOTALL) is not None
|
|
3866
|
+
|
|
3867
|
+
def _should_repair_trailing_json_text(self, text: str) -> bool:
|
|
3868
|
+
return re.fullmatch(r"\s*[}\]]+\s*", text) is not None
|
|
3869
|
+
|
|
3756
3870
|
def _has_action_frame_end(self, line: str) -> bool:
|
|
3757
3871
|
return self.ACTION_FRAME_END_SPLIT_PATTERN.search(line) is not None
|
|
3758
3872
|
|
|
@@ -3766,6 +3880,9 @@ class ModelClient:
|
|
|
3766
3880
|
lines = lines[:-1]
|
|
3767
3881
|
return "\n".join(lines).strip()
|
|
3768
3882
|
|
|
3883
|
+
def _strip_fence_marker_lines(self, text: str) -> str:
|
|
3884
|
+
return re.sub(r"(?m)^\s*```[a-zA-Z0-9_-]*\s*$\n?", "", text).strip()
|
|
3885
|
+
|
|
3769
3886
|
def _strip_leaked_think_tags(self, text: str) -> str:
|
|
3770
3887
|
text = text.strip()
|
|
3771
3888
|
while text.startswith("</think>"):
|
|
@@ -3779,6 +3896,9 @@ class ModelClient:
|
|
|
3779
3896
|
text = text[len("</think>") :].lstrip()
|
|
3780
3897
|
return text
|
|
3781
3898
|
|
|
3899
|
+
def _strip_leaked_tool_code(self, text: str) -> str:
|
|
3900
|
+
return re.sub(r"<tool_code>.*?</tool_code>", "", text, flags=re.DOTALL).strip()
|
|
3901
|
+
|
|
3782
3902
|
def _invalid_model_response(self, content: str, reason: str = "expected one JSON object matching the Output JSON schema") -> Json:
|
|
3783
3903
|
guidance = ""
|
|
3784
3904
|
if self._strip_leaked_think_tags(content.strip()).startswith("<tool_call>"):
|
|
@@ -3812,10 +3932,12 @@ class ModelClient:
|
|
|
3812
3932
|
}
|
|
3813
3933
|
return "API response missing message content: " + json.dumps(details, ensure_ascii=False)
|
|
3814
3934
|
|
|
3815
|
-
def _record_usage(self, usage: Json, config: ProviderConfig) -> None:
|
|
3935
|
+
def _record_usage(self, usage: Json, config: ProviderConfig, *, elapsed: float = 0.0) -> None:
|
|
3816
3936
|
prompt_tokens = self._json_int(usage.get("prompt_tokens"))
|
|
3817
3937
|
completion_tokens = self._json_int(usage.get("completion_tokens"))
|
|
3818
3938
|
total_tokens = self._json_int(usage.get("total_tokens"))
|
|
3939
|
+
if completion_tokens > 0 and elapsed > 0:
|
|
3940
|
+
self.session.state.last_model_call_rate = completion_tokens / elapsed
|
|
3819
3941
|
self.session.state.last_prompt_tokens = prompt_tokens
|
|
3820
3942
|
self.session.state.last_completion_tokens = completion_tokens
|
|
3821
3943
|
self.session.state.last_total_tokens = total_tokens
|
|
@@ -4786,9 +4908,8 @@ class Agent:
|
|
|
4786
4908
|
RECENT_TOOL_CALL_CHARS: ClassVar[int] = 72_000
|
|
4787
4909
|
KEPT_TOOL_RESULT_CHARS: ClassVar[int] = 96_000
|
|
4788
4910
|
RECENT_TOOL_CALL_SUMMARIES: ClassVar[int] = 40
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
PENDING_OBSERVE_TOOL_TURNS: ClassVar[int] = 2
|
|
4911
|
+
# Trigger observe after this many unresolved raw tool result blocks accumulate.
|
|
4912
|
+
OBSERVE_AFTER_PENDING_RESULT_COUNT: ClassVar[int] = 8
|
|
4792
4913
|
PLAN_MODE_GIT_READONLY: ClassVar[frozenset[str]] = GIT_READONLY_COMMANDS
|
|
4793
4914
|
|
|
4794
4915
|
def __init__(self, session: Session):
|
|
@@ -4810,6 +4931,7 @@ class Agent:
|
|
|
4810
4931
|
self.failed_tool_call_count = 0
|
|
4811
4932
|
self.agent_feedback_errors: list[str] = []
|
|
4812
4933
|
self.observe_feedback_errors: list[str] = []
|
|
4934
|
+
self.task_alignment_required = False
|
|
4813
4935
|
self.mode = AgentMode.ACT
|
|
4814
4936
|
|
|
4815
4937
|
def build_user_prompt(self) -> str:
|
|
@@ -4926,7 +5048,7 @@ class Agent:
|
|
|
4926
5048
|
def _format_recent_tool_call_context(self) -> str:
|
|
4927
5049
|
if self.mode == AgentMode.OBSERVE and self.tool_context.pending_observe:
|
|
4928
5050
|
return "\n\n".join(self.tool_context.pending_observe)
|
|
4929
|
-
return "\n\n".join(self.tool_context.
|
|
5051
|
+
return "\n\n".join(self.tool_context.act_blocks())
|
|
4930
5052
|
|
|
4931
5053
|
def _prune_tool_result_store(self) -> None:
|
|
4932
5054
|
keep = self._protected_tool_result_keys()
|
|
@@ -5092,11 +5214,7 @@ class Agent:
|
|
|
5092
5214
|
return False
|
|
5093
5215
|
if any(self._tool_failure_needs_observe(execution) for execution in self.tool_runner.latest_executions):
|
|
5094
5216
|
return True
|
|
5095
|
-
|
|
5096
|
-
return True
|
|
5097
|
-
if len("\n\n".join(pending)) >= int(self.RECENT_TOOL_CALL_CHARS * self.PENDING_OBSERVE_CHAR_RATIO):
|
|
5098
|
-
return True
|
|
5099
|
-
return self.runtime.consecutive_tool_turns >= self.PENDING_OBSERVE_TOOL_TURNS
|
|
5217
|
+
return len(pending) >= self.OBSERVE_AFTER_PENDING_RESULT_COUNT
|
|
5100
5218
|
|
|
5101
5219
|
def _tool_failure_needs_observe(self, execution: ToolCallExecution) -> bool:
|
|
5102
5220
|
if execution.outcome == "success":
|
|
@@ -5481,6 +5599,24 @@ class Agent:
|
|
|
5481
5599
|
"PlanMode_Gate: " + plan_mode_tool_error + ".",
|
|
5482
5600
|
)
|
|
5483
5601
|
return True
|
|
5602
|
+
if (
|
|
5603
|
+
self.blackboard.task_code == TaskCode.NEW
|
|
5604
|
+
and self.task_alignment_required
|
|
5605
|
+
and (ctx.tool_calls or ctx.pending_verify_requested)
|
|
5606
|
+
and not ctx.has_goal_action
|
|
5607
|
+
and not ctx.has_plan_action
|
|
5608
|
+
and not ctx.has_user_rule_action
|
|
5609
|
+
):
|
|
5610
|
+
self._remember_agent_error(
|
|
5611
|
+
"Error: previous task context is still present. Rule: before work, emit start if the latest request changes the task; "
|
|
5612
|
+
"if continuing, update or confirm the plan first."
|
|
5613
|
+
)
|
|
5614
|
+
self._report_gate(
|
|
5615
|
+
on_message,
|
|
5616
|
+
"Retrying: align this request with the task before work.",
|
|
5617
|
+
"GoalPlan_Gate: work before task alignment with previous task context.",
|
|
5618
|
+
)
|
|
5619
|
+
return True
|
|
5484
5620
|
if self.blackboard.task_code != TaskCode.NEW and any(_json_str(action.get("type")) == "start" for action in ctx.actions):
|
|
5485
5621
|
self._remember_agent_error(
|
|
5486
5622
|
"Error: repeated start is invalid after the current task is active. Rule: follow Current Task Code and continue with plan/tool/verify/goal."
|
|
@@ -5962,10 +6098,16 @@ class Agent:
|
|
|
5962
6098
|
self.mode = AgentMode.ACT
|
|
5963
6099
|
self.session.state.turn_tool_calls = 0
|
|
5964
6100
|
self.session.state.turn_model_calls = 0
|
|
6101
|
+
old_goal = self.blackboard.goal
|
|
6102
|
+
old_task_context = bool(self.blackboard.goal or self.blackboard.plan or self.blackboard.hypotheses)
|
|
5965
6103
|
self.blackboard.user_input = user_input
|
|
5966
6104
|
previous_task_done = self.blackboard.task_code == TaskCode.DONE
|
|
5967
6105
|
if previous_task_done:
|
|
5968
6106
|
self.blackboard.work_mode = WorkMode.NORMAL
|
|
6107
|
+
# Keep previous task state at a new user turn so short follow-ups like
|
|
6108
|
+
# "continue" can resume. The first response must align with it before work
|
|
6109
|
+
# when the new request does not match the previous goal.
|
|
6110
|
+
self.task_alignment_required = old_task_context and self._task_text_key(user_input) != self._task_text_key(old_goal)
|
|
5969
6111
|
self.blackboard.task_code = TaskCode.NEW
|
|
5970
6112
|
self.blackboard.goal_reached = False
|
|
5971
6113
|
self.blackboard.verification_required = False
|
|
@@ -5988,6 +6130,9 @@ class Agent:
|
|
|
5988
6130
|
on_step_limit=lambda: (_ for _ in ()).throw(LLMError("agent step limit reached")),
|
|
5989
6131
|
)
|
|
5990
6132
|
|
|
6133
|
+
def _task_text_key(self, text: str) -> str:
|
|
6134
|
+
return re.sub(r"\s+", " ", text).strip(" \t\r\n。.;;").lower()
|
|
6135
|
+
|
|
5991
6136
|
def handle_response(
|
|
5992
6137
|
self,
|
|
5993
6138
|
response: Json,
|
|
@@ -6656,21 +6801,27 @@ class StatusBar:
|
|
|
6656
6801
|
context = str(len(session.state.conversation)) + "/" + str(session.settings.compact_at)
|
|
6657
6802
|
last_tokens = _format_count(session.state.last_total_tokens)
|
|
6658
6803
|
session_tokens = _format_count(session.state.session_total_tokens)
|
|
6659
|
-
|
|
6660
|
-
|
|
6804
|
+
rate = session.state.last_model_call_rate
|
|
6805
|
+
token_summary = "last:" + last_tokens + " sess:" + session_tokens
|
|
6806
|
+
parts = [model + " (" + reasoning + ")" + modes, "ctx:" + context, "tool:" + str(session.state.turn_tool_calls), "tok:" + token_summary]
|
|
6661
6807
|
if show_elapsed:
|
|
6662
|
-
parts.append(f"{turn_elapsed:.1f}s")
|
|
6808
|
+
parts.append(f"turn:{turn_elapsed:.1f}s")
|
|
6663
6809
|
if session.state.current_model_call_started_at > 0:
|
|
6664
6810
|
activity = self._activity_label(session.state.current_model_call_activity)
|
|
6665
6811
|
if session.state.current_model_call_has_content:
|
|
6666
6812
|
activity += "*"
|
|
6813
|
+
elapsed = max(0.0, now - session.state.current_model_call_started_at)
|
|
6814
|
+
if session.state.current_model_call_has_content and elapsed > 0:
|
|
6815
|
+
rate = session.state.current_model_call_streaming_chars / 4 / elapsed
|
|
6667
6816
|
parts.append(
|
|
6668
6817
|
activity
|
|
6669
6818
|
+ "("
|
|
6670
6819
|
+ str(session.state.turn_model_calls)
|
|
6671
6820
|
+ "):"
|
|
6672
|
-
+ f"{
|
|
6821
|
+
+ f"{elapsed:.1f}s"
|
|
6673
6822
|
)
|
|
6823
|
+
if rate > 0:
|
|
6824
|
+
parts[3] += " " + _format_count(int(rate)) + "t/s"
|
|
6674
6825
|
if session.state.status_notice and session.state.status_notice_until > now:
|
|
6675
6826
|
parts.append(session.state.status_notice)
|
|
6676
6827
|
return " | ".join(parts)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanocode-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.34
|
|
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
|
|
@@ -119,7 +119,7 @@ USE AT YOUR OWN RISK.
|
|
|
119
119
|
- Maintenance: `/clean`.
|
|
120
120
|
- Exit: `/exit`, `/quit`.
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
Selectors support `j`/`k`, arrows, `/keyword`, Enter, and Esc. `/model` lists configured models before discovered ones, then prompts for reasoning; `/model <name>` and `/reason` are direct shortcuts.
|
|
123
123
|
|
|
124
124
|
## Configuration
|
|
125
125
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|