nanocode-cli 0.3.26__tar.gz → 0.3.28__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.3.26
3
+ Version: 0.3.28
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
@@ -51,7 +51,8 @@ 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.26"
54
+ __version__ = "0.3.28"
55
+ HTTP_USER_AGENT = "nanocode/" + __version__
55
56
 
56
57
 
57
58
  JsonValue: TypeAlias = Any
@@ -820,6 +821,10 @@ class RuntimeState:
820
821
  current_model_call_started_at: float = 0.0
821
822
  current_model_call_label: str = ""
822
823
  current_model_call_reasoning_label: str = ""
824
+ current_model_call_activity: str = ""
825
+ current_model_call_has_content: bool = False
826
+ status_notice: str = ""
827
+ status_notice_until: float = 0.0
823
828
  conversation: list[ConversationItem] = field(default_factory=list)
824
829
  user_rules: UserRules = field(default_factory=UserRules)
825
830
  range_fingerprints: RangeFingerprintStore = field(default_factory=RangeFingerprintStore)
@@ -3382,9 +3387,10 @@ class ModelClient:
3382
3387
 
3383
3388
  def __init__(self, session: Session):
3384
3389
  self.session = session
3390
+ self._timeout_reason = "request model timeout"
3385
3391
 
3386
3392
  def _timeout_handler(self, signum: int, frame: Any) -> None:
3387
- raise ModelRequestTimeout()
3393
+ raise ModelRequestTimeout(self._timeout_reason)
3388
3394
 
3389
3395
  def request(
3390
3396
  self,
@@ -3431,15 +3437,19 @@ class ModelClient:
3431
3437
  headers={
3432
3438
  "Authorization": "Bearer " + config.key,
3433
3439
  "Content-Type": "application/json",
3440
+ "User-Agent": HTTP_USER_AGENT,
3434
3441
  },
3435
3442
  )
3436
3443
  try:
3437
3444
  self.session.state.current_model_call_started_at = time.monotonic()
3438
3445
  self.session.state.current_model_call_label = model
3439
3446
  self.session.state.current_model_call_reasoning_label = config.reasoning_effort if config.reasoning else "off"
3447
+ self.session.state.current_model_call_activity = activity
3448
+ self.session.state.current_model_call_has_content = False
3440
3449
  request_deadline = self.session.state.current_model_call_started_at + max(0, timeout)
3441
3450
  previous_handler = signal.getsignal(signal.SIGALRM)
3442
3451
  signal.signal(signal.SIGALRM, self._timeout_handler)
3452
+ self._timeout_reason = "request model timeout"
3443
3453
  signal.setitimer(signal.ITIMER_REAL, max(0, timeout))
3444
3454
  try:
3445
3455
  with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -3458,8 +3468,10 @@ class ModelClient:
3458
3468
  self.session.state.current_model_call_started_at = 0.0
3459
3469
  self.session.state.current_model_call_label = ""
3460
3470
  self.session.state.current_model_call_reasoning_label = ""
3461
- except ModelRequestTimeout:
3462
- raise LLMError("request model timeout")
3471
+ self.session.state.current_model_call_activity = ""
3472
+ self.session.state.current_model_call_has_content = False
3473
+ except ModelRequestTimeout as error:
3474
+ raise LLMError(str(error) or "request model timeout")
3463
3475
  except (socket.timeout, TimeoutError):
3464
3476
  raise LLMError("request model timeout")
3465
3477
  except urllib.error.HTTPError as error:
@@ -3523,6 +3535,7 @@ class ModelClient:
3523
3535
  continue
3524
3536
  if not first_content_seen:
3525
3537
  first_content_seen = True
3538
+ self.session.state.current_model_call_has_content = True
3526
3539
  self._arm_stream_timeout(request_deadline=request_deadline, first_content_seen=True, first_token_timeout=first_token_timeout)
3527
3540
  parts.append(content)
3528
3541
  return "".join(parts), usage
@@ -3530,9 +3543,12 @@ class ModelClient:
3530
3543
  def _arm_stream_timeout(self, *, request_deadline: float, first_content_seen: bool, first_token_timeout: int | None) -> None:
3531
3544
  remaining = request_deadline - time.monotonic()
3532
3545
  if remaining <= 0:
3533
- raise ModelRequestTimeout()
3546
+ raise ModelRequestTimeout("request model timeout")
3547
+ self._timeout_reason = "request model timeout"
3534
3548
  if not first_content_seen and first_token_timeout is not None and first_token_timeout > 0:
3535
- remaining = min(remaining, first_token_timeout)
3549
+ if first_token_timeout < remaining:
3550
+ remaining = first_token_timeout
3551
+ self._timeout_reason = "request first token timeout"
3536
3552
  signal.setitimer(signal.ITIMER_REAL, remaining)
3537
3553
 
3538
3554
  def _write_debug_prompt(self, *, activity: str, messages: list[Json]) -> str:
@@ -3642,6 +3658,7 @@ class ModelClient:
3642
3658
  if isinstance(value, dict):
3643
3659
  if "actions" in value:
3644
3660
  return self._actions_from_json_value(value.get("actions"))
3661
+ self._normalize_tool_type(value)
3645
3662
  if not _json_str(value.get("type")):
3646
3663
  return [], "action missing type"
3647
3664
  return [value], ""
@@ -3651,12 +3668,19 @@ class ModelClient:
3651
3668
  action = _json_dict(raw)
3652
3669
  if not action:
3653
3670
  return [], "array item " + str(index) + ": expected JSON object action"
3671
+ self._normalize_tool_type(action)
3654
3672
  if not _json_str(action.get("type")):
3655
3673
  return [], "array item " + str(index) + ": action missing type"
3656
3674
  actions.append(action)
3657
3675
  return actions, ""
3658
3676
  return [], "expected JSON object action"
3659
3677
 
3678
+ def _normalize_tool_type(self, action: Json) -> None:
3679
+ action_type = _json_str(action.get("type"))
3680
+ if action_type in TOOL_REGISTRY:
3681
+ action["type"] = "tool"
3682
+ action.setdefault("name", action_type)
3683
+
3660
3684
  def _parse_unmarked_actions(self, text: str) -> tuple[list[Json], str]:
3661
3685
  actions: list[Json] = []
3662
3686
  decoder = json.JSONDecoder()
@@ -3667,8 +3691,8 @@ class ModelClient:
3667
3691
  if index < len(text) and text[index] != "{":
3668
3692
  if text[index] == "[":
3669
3693
  try:
3670
- value, index = decoder.raw_decode(text, index)
3671
- except json.JSONDecodeError as error:
3694
+ value, index = self._decode_json_array_text(text, index)
3695
+ except (json.JSONDecodeError, ValueError) as error:
3672
3696
  return [], str(error)
3673
3697
  parsed, error = self._actions_from_json_value(value)
3674
3698
  if error:
@@ -3706,6 +3730,28 @@ class ModelClient:
3706
3730
  index += 1
3707
3731
  if index < len(text) and text[index] == ",":
3708
3732
  index += 1
3733
+ continue
3734
+ if index < len(text) and text[index] != "{":
3735
+ next_action = text.find("{", index)
3736
+ if next_action < 0:
3737
+ return [], "unexpected text after JSON action"
3738
+ progress = _shorten(" ".join(text[index:next_action].split()), 500)
3739
+ if progress:
3740
+ actions.append({"type": "progress", "text": progress})
3741
+ index = next_action
3742
+
3743
+ def _decode_json_array_text(self, text: str, index: int) -> tuple[JsonValue, int]:
3744
+ decoder = json.JSONDecoder()
3745
+ value, end = decoder.raw_decode(text, index)
3746
+ cursor = end
3747
+ while cursor < len(text) and text[cursor].isspace():
3748
+ cursor += 1
3749
+ if cursor >= len(text):
3750
+ return value, cursor
3751
+ value = json_repair.loads(text[index:])
3752
+ if not isinstance(value, list):
3753
+ raise ValueError("expected JSON action array")
3754
+ return value, len(text)
3709
3755
 
3710
3756
  def _has_action_frame_end(self, line: str) -> bool:
3711
3757
  return self.ACTION_FRAME_END_SPLIT_PATTERN.search(line) is not None
@@ -4791,12 +4837,14 @@ class Agent:
4791
4837
  self.session.state.turn_model_calls += 1
4792
4838
  return self.model_client.request(system_prompt, user_prompt, activity=activity)
4793
4839
  except LLMError as error:
4794
- if str(error) != "request model timeout" or attempt >= len(self.MODEL_TIMEOUT_RETRY_DELAYS):
4840
+ timeout_reason = str(error)
4841
+ if timeout_reason not in ("request model timeout", "request first token timeout") or attempt >= len(self.MODEL_TIMEOUT_RETRY_DELAYS):
4795
4842
  raise
4796
4843
  delay = self.MODEL_TIMEOUT_RETRY_DELAYS[attempt]
4844
+ self._set_status_notice("err:first_token" if timeout_reason == "request first token timeout" else "err:timeout")
4797
4845
  if on_message is not None and self.session.settings.debug:
4798
4846
  on_message(
4799
- "Retrying: request model timeout; retry "
4847
+ "Retrying: " + timeout_reason + "; retry "
4800
4848
  + str(attempt + 1)
4801
4849
  + "/"
4802
4850
  + str(len(self.MODEL_TIMEOUT_RETRY_DELAYS))
@@ -4807,6 +4855,10 @@ class Agent:
4807
4855
  time.sleep(delay)
4808
4856
  raise LLMError("request model timeout")
4809
4857
 
4858
+ def _set_status_notice(self, text: str, ttl: float = 5.0) -> None:
4859
+ self.session.state.status_notice = text
4860
+ self.session.state.status_notice_until = time.monotonic() + ttl
4861
+
4810
4862
  def compact_history(self) -> int:
4811
4863
  return self.compactor.compact()
4812
4864
 
@@ -4832,6 +4884,7 @@ class Agent:
4832
4884
  format_error = _json_str(response.get("_format_error"))
4833
4885
  if format_error:
4834
4886
  consecutive_format_errors += 1
4887
+ self._set_status_notice("err:format")
4835
4888
  remember_error = self._remember_observe_error if self.mode == AgentMode.OBSERVE else self._remember_agent_error
4836
4889
  remember_error(
4837
4890
  self._format_gate_user_message("Error: model returned invalid output", format_error) + " Rule: return valid JSON action frames only."
@@ -4917,6 +4970,8 @@ class Agent:
4917
4970
  def _report_gate(self, on_message: MessageCallback | None, message: str, debug_message: str) -> None:
4918
4971
  if on_message is None:
4919
4972
  return
4973
+ if message.startswith(("Retrying:", "Continuing:")) and self.session.state.status_notice_until <= time.monotonic():
4974
+ self._set_status_notice("err:gate")
4920
4975
  if self.session.settings.debug:
4921
4976
  on_message(debug_message)
4922
4977
  return
@@ -4943,13 +4998,15 @@ class Agent:
4943
4998
  if self.mode == AgentMode.OBSERVE:
4944
4999
  system_prompt = self.prompt_builder.system_prompt(AGENT_OBSERVE_SYSTEM_PROMPT, tools=())
4945
5000
  user_prompt = self.build_observe_prompt()
5001
+ activity = "observe"
4946
5002
  else:
4947
5003
  system_prompt = self.prompt_builder.system_prompt(
4948
5004
  AGENT_PLAN_SYSTEM_PROMPT if self.session.settings.plan_mode else None,
4949
5005
  tools=PLAN_MODE_TOOLS if self.session.settings.plan_mode else None,
4950
5006
  )
4951
5007
  user_prompt = self.build_user_prompt()
4952
- response = self.request(system_prompt, user_prompt, activity="agent", on_message=on_message)
5008
+ activity = "agent"
5009
+ response = self.request(system_prompt, user_prompt, activity=activity, on_message=on_message)
4953
5010
  if _json_str(response.get("_format_error")):
4954
5011
  return response
4955
5012
  invalid_response = self._validate_action_response(response)
@@ -5295,29 +5352,6 @@ class Agent:
5295
5352
  conflict = sorted((forgotten & protected) - released)
5296
5353
  return "active hypothesis source: " + ", ".join(conflict) if conflict else ""
5297
5354
 
5298
- def _plan_shape_error(self, actions: list[Json]) -> str:
5299
- plan = [PlanItem(text=item.text, status=item.status, id=item.id, context=item.context) for item in self.blackboard.plan]
5300
- changed = False
5301
- for action in actions:
5302
- action_type = _json_str(action.get("type"))
5303
- if action_type == "start":
5304
- items = self._plan_items_from_json(action.get("plan"))
5305
- if items:
5306
- plan = items
5307
- changed = True
5308
- elif action_type == "plan":
5309
- items = self._plan_items_from_json(action.get("items"))
5310
- if action.get("mode") != "patch":
5311
- if items:
5312
- plan = items
5313
- changed = True
5314
- continue
5315
- changed = self.state_updater._apply_plan_patches(plan, action.get("items")) or changed
5316
- doing = [item for item in plan if item.status == PlanStatus.DOING]
5317
- if changed and len(doing) > 1:
5318
- return "multiple doing plan items: " + self._format_plan_gate_items(doing)
5319
- return ""
5320
-
5321
5355
  def _plan_items_from_json(self, value: JsonValue) -> list[PlanItem]:
5322
5356
  return [item for item in (self.state_updater._plan_item_from_json(raw) for raw in _json_list(value)) if item]
5323
5357
 
@@ -5425,15 +5459,6 @@ class Agent:
5425
5459
  "ToolResult_Gate: " + forget_hypothesis_error + ".",
5426
5460
  )
5427
5461
  return True
5428
- plan_shape_error = self._plan_shape_error(ctx.actions)
5429
- if plan_shape_error:
5430
- self._remember_agent_error("Error: Plan is invalid: " + plan_shape_error + ". Rule: at most one Plan item may be doing.")
5431
- self._report_gate(
5432
- on_message,
5433
- "Retrying: keep only one plan item doing.",
5434
- "Plan_Gate: " + plan_shape_error + ".",
5435
- )
5436
- return True
5437
5462
  repeated_tool_retry_error = self._repeated_tool_retry_error(ctx.tool_calls)
5438
5463
  if repeated_tool_retry_error:
5439
5464
  self._remember_agent_error(
@@ -5730,15 +5755,6 @@ class Agent:
5730
5755
  "ToolResult_Gate: " + forget_hypothesis_error + ".",
5731
5756
  )
5732
5757
  return AgentRunResult()
5733
- plan_shape_error = self._plan_shape_error(ctx.actions)
5734
- if plan_shape_error:
5735
- self._remember_observe_error("Error: Plan is invalid: " + plan_shape_error + ". Rule: at most one Plan item may be doing.")
5736
- self._report_gate(
5737
- on_message,
5738
- "Retrying: keep only one plan item doing.",
5739
- "Plan_Gate: " + plan_shape_error + ".",
5740
- )
5741
- return AgentRunResult()
5742
5758
  if any(_json_str(action.get("type")) == "verify" and _json_str(action.get("status")) == "pending" for action in ctx.actions):
5743
5759
  self._remember_observe_error("Error: cannot request new verification before observing latest results. Rule: keep or forget latest results first.")
5744
5760
  self._report_gate(
@@ -6239,7 +6255,7 @@ class CommandDispatcher:
6239
6255
  base_url = base_url[: -len("/chat/completions")]
6240
6256
  request = urllib.request.Request(
6241
6257
  base_url + "/models",
6242
- headers={"Authorization": "Bearer " + provider.key},
6258
+ headers={"Authorization": "Bearer " + provider.key, "User-Agent": HTTP_USER_AGENT},
6243
6259
  )
6244
6260
  try:
6245
6261
  with urllib.request.urlopen(request, timeout=3) as response:
@@ -6645,9 +6661,24 @@ class StatusBar:
6645
6661
  if show_elapsed:
6646
6662
  parts.append(f"{turn_elapsed:.1f}s")
6647
6663
  if session.state.current_model_call_started_at > 0:
6648
- parts.append("calling(" + str(session.state.turn_model_calls) + "):" + f"{max(0.0, now - session.state.current_model_call_started_at):.1f}s")
6664
+ activity = self._activity_label(session.state.current_model_call_activity)
6665
+ if session.state.current_model_call_has_content:
6666
+ activity += "*"
6667
+ parts.append(
6668
+ activity
6669
+ + "("
6670
+ + str(session.state.turn_model_calls)
6671
+ + "):"
6672
+ + f"{max(0.0, now - session.state.current_model_call_started_at):.1f}s"
6673
+ )
6674
+ if session.state.status_notice and session.state.status_notice_until > now:
6675
+ parts.append(session.state.status_notice)
6649
6676
  return " | ".join(parts)
6650
6677
 
6678
+ @staticmethod
6679
+ def _activity_label(activity: str) -> str:
6680
+ return {"compact": "compacting", "observe": "observing"}.get(activity, "working")
6681
+
6651
6682
  def _sweep_fragments(self, text: str, now: float) -> list[tuple[str, str]]:
6652
6683
  if not text:
6653
6684
  return [("", "")]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.3.26
3
+ Version: 0.3.28
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.3.26"
7
+ version = "0.3.28"
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