From db4185c8b7f8a80084cf1e8cfb397b60ce409ed9 Mon Sep 17 00:00:00 2001 From: aiguozhi123456 Date: Fri, 27 Feb 2026 11:11:42 +0000 Subject: [PATCH 01/13] Add timestamp format hint for HISTORY.md grep searching --- nanobot/agent/context.py | 2 +- nanobot/skills/memory/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 03a9a89..be0ec59 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -68,7 +68,7 @@ You are nanobot, a helpful AI assistant. ## Workspace Your workspace is at: {workspace_path} - Long-term memory: {workspace_path}/memory/MEMORY.md (write important facts here) -- History log: {workspace_path}/memory/HISTORY.md (grep-searchable) +- History log: {workspace_path}/memory/HISTORY.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM]. - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md ## nanobot Guidelines diff --git a/nanobot/skills/memory/SKILL.md b/nanobot/skills/memory/SKILL.md index 39adbde..529a02d 100644 --- a/nanobot/skills/memory/SKILL.md +++ b/nanobot/skills/memory/SKILL.md @@ -9,7 +9,7 @@ always: true ## Structure - `memory/MEMORY.md` — Long-term facts (preferences, project context, relationships). Always loaded into your context. -- `memory/HISTORY.md` — Append-only event log. NOT loaded into context. Search it with grep. +- `memory/HISTORY.md` — Append-only event log. NOT loaded into context. Search it with grep. Each entry starts with [YYYY-MM-DD HH:MM]. ## Search Past Events From 7229d86bb31d19d51937548fdce91fa74820c986 Mon Sep 17 00:00:00 2001 From: fengxiaohu <975326527@qq.com> Date: Fri, 27 Feb 2026 21:46:46 +0800 Subject: [PATCH 02/13] fix(shell): parse full Windows absolute paths in workspace guard --- nanobot/agent/tools/shell.py | 18 +++++++++++------- tests/test_tool_validation.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index e3592a7..796d1fb 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -134,13 +134,7 @@ class ExecTool(Tool): cwd_path = Path(cwd).resolve() - win_paths = re.findall(r"[A-Za-z]:\\[^\\\"']+", cmd) - # Only match absolute paths — avoid false positives on relative - # paths like ".venv/bin/python" where "/bin/python" would be - # incorrectly extracted by the old pattern. - posix_paths = re.findall(r"(?:^|[\s|>])(/[^\s\"'>]+)", cmd) - - for raw in win_paths + posix_paths: + for raw in self._extract_absolute_paths(cmd): try: p = Path(raw.strip()).resolve() except Exception: @@ -149,3 +143,13 @@ class ExecTool(Tool): return "Error: Command blocked by safety guard (path outside working dir)" return None + + @staticmethod + def _extract_absolute_paths(command: str) -> list[str]: + # Match Windows absolute paths without truncating at backslashes. + win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]+", command) + # Only match absolute paths — avoid false positives on relative + # paths like ".venv/bin/python" where "/bin/python" would be + # incorrectly extracted by the old pattern. + posix_paths = re.findall(r"(?:^|[\s|>])(/[^\s\"'>]+)", command) + return win_paths + posix_paths diff --git a/tests/test_tool_validation.py b/tests/test_tool_validation.py index f11c667..cb50fb0 100644 --- a/tests/test_tool_validation.py +++ b/tests/test_tool_validation.py @@ -2,6 +2,7 @@ from typing import Any from nanobot.agent.tools.base import Tool from nanobot.agent.tools.registry import ToolRegistry +from nanobot.agent.tools.shell import ExecTool class SampleTool(Tool): @@ -86,3 +87,22 @@ async def test_registry_returns_validation_error() -> None: reg.register(SampleTool()) result = await reg.execute("sample", {"query": "hi"}) assert "Invalid parameters" in result + + +def test_exec_extract_absolute_paths_keeps_full_windows_path() -> None: + cmd = r"type C:\user\workspace\txt" + paths = ExecTool._extract_absolute_paths(cmd) + assert paths == [r"C:\user\workspace\txt"] + + +def test_exec_extract_absolute_paths_ignores_relative_posix_segments() -> None: + cmd = ".venv/bin/python script.py" + paths = ExecTool._extract_absolute_paths(cmd) + assert "/bin/python" not in paths + + +def test_exec_extract_absolute_paths_captures_posix_absolute_paths() -> None: + cmd = "cat /tmp/data.txt > /tmp/out.txt" + paths = ExecTool._extract_absolute_paths(cmd) + assert "/tmp/data.txt" in paths + assert "/tmp/out.txt" in paths From 1fe94898f68a71c1befd645ea8cece61b9673d79 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 27 Feb 2026 16:13:26 +0000 Subject: [PATCH 03/13] fix: generate short alphanumeric tool_call_id for Mistral compatibility --- nanobot/providers/litellm_provider.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 0918954..5427d97 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -3,6 +3,8 @@ import json import json_repair import os +import secrets +import string from typing import Any import litellm @@ -15,6 +17,11 @@ from nanobot.providers.registry import find_by_model, find_gateway # Standard OpenAI chat-completion message keys plus reasoning_content for # thinking-enabled models (Kimi k2.5, DeepSeek-R1, etc.). _ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"}) +_ALNUM = string.ascii_letters + string.digits + +def _short_tool_id() -> str: + """Generate a 9-char alphanumeric ID compatible with all providers (incl. Mistral).""" + return "".join(secrets.choice(_ALNUM) for _ in range(9)) class LiteLLMProvider(LLMProvider): @@ -245,7 +252,7 @@ class LiteLLMProvider(LLMProvider): args = json_repair.loads(args) tool_calls.append(ToolCallRequest( - id=tc.id, + id=_short_tool_id(), name=tc.function.name, arguments=args, )) From 11f1880c02167eed52cb13474b4891f5948f95d4 Mon Sep 17 00:00:00 2001 From: Michael-lhh Date: Sat, 28 Feb 2026 00:18:00 +0800 Subject: [PATCH 04/13] fix: handle list-type tool arguments in _tool_hint Some models (e.g., Kimi K2.5 via OpenRouter) return tool call arguments as a list instead of a dict. This caused an AttributeError when trying to call .values() on the list. The fix checks if arguments is a list and extracts the first element before accessing .values(). Made-with: Cursor --- nanobot/agent/loop.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6fe37e9..e30ed23 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -163,7 +163,10 @@ class AgentLoop: def _tool_hint(tool_calls: list) -> str: """Format tool calls as concise hint, e.g. 'web_search("query")'.""" def _fmt(tc): - val = next(iter(tc.arguments.values()), None) if tc.arguments else None + args = tc.arguments + if isinstance(args, list) and args: + args = args[0] + val = next(iter(args.values()), None) if isinstance(args, dict) and args else None if not isinstance(val, str): return tc.name return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")' From 8842fb2b4d734e06caded189a129a432bfa31731 Mon Sep 17 00:00:00 2001 From: GabrielWithTina Date: Sat, 28 Feb 2026 09:44:28 +0800 Subject: [PATCH 05/13] fix: pass msg_id in QQ C2C reply to avoid proactive message permission error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QQ's bot API requires a msg_id (original inbound message ID) to send a passive reply. Without it the request is treated as a proactive message and fails with error 40034102 (无权限). The message_id was already stored in InboundMessage.metadata and forwarded to OutboundMessage, but was never read in send(). Co-Authored-By: Claude Sonnet 4.6 --- nanobot/channels/qq.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 5352a30..50dbbde 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -100,10 +100,12 @@ class QQChannel(BaseChannel): logger.warning("QQ client not initialized") return try: + msg_id = msg.metadata.get("message_id") await self._client.api.post_c2c_message( openid=msg.chat_id, msg_type=0, content=msg.content, + msg_id=msg_id, ) except Exception as e: logger.error("Error sending QQ message: {}", e) From 66063abb8cc6d79371bbfd3ae28c9c7a13784c6e Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Sat, 28 Feb 2026 00:57:08 -0300 Subject: [PATCH 06/13] fix: prevent session poisoning from null/error LLM responses When an LLM returns content: null on a plain assistant message (no tool_calls), the null gets saved to session history and causes permanent 400 errors on every subsequent request. - Sanitize None content on plain assistant messages to "(empty)" in _sanitize_empty_content(), matching the existing empty-string handling - Skip persisting error responses (finish_reason="error") to the message history in _run_agent_loop(), preventing poison loops Closes #1303 --- nanobot/agent/loop.py | 6 ++++++ nanobot/providers/base.py | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6fe37e9..6cd8e56 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -224,6 +224,12 @@ class AgentLoop: ) else: clean = self._strip_think(response.content) + # Don't persist error responses to session history — they can + # poison the context and cause permanent 400 loops (#1303). + if response.finish_reason == "error": + logger.error("LLM returned error: {}", (clean or "")[:200]) + final_content = clean or "Sorry, I encountered an error calling the AI model." + break messages = self.context.add_assistant_message( messages, clean, reasoning_content=response.reasoning_content, ) diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index eb1599a..f52a951 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -51,6 +51,14 @@ class LLMProvider(ABC): for msg in messages: content = msg.get("content") + # None content on a plain assistant message (no tool_calls) crashes + # providers with "invalid message content type: ". + if content is None and msg.get("role") == "assistant" and not msg.get("tool_calls"): + clean = dict(msg) + clean["content"] = "(empty)" + result.append(clean) + continue + if isinstance(content, str) and not content: clean = dict(msg) clean["content"] = None if (msg.get("role") == "assistant" and msg.get("tool_calls")) else "(empty)" From cc8864dc1f049d617b13bbbe973901304b210115 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Sat, 28 Feb 2026 01:01:20 -0300 Subject: [PATCH 07/13] fix: remove overly broad "codex" keyword from openai_codex provider The bare keyword "codex" causes false positive matches when any model name happens to contain "codex" (e.g. "gpt-5.3-codex" on a custom provider). This incorrectly routes the request through the OAuth-based OpenAI Codex provider, producing "OAuth credentials not found" errors even when a valid custom api_key and api_base are configured. Keep only the explicit "openai-codex" keyword so that auto-detection requires the canonical prefix. Users can still set provider: "custom" to force the custom endpoint, but auto-detection should not collide. Closes #1311 --- nanobot/providers/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 2766929..df915b7 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -201,7 +201,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( # OpenAI Codex: uses OAuth, not API key. ProviderSpec( name="openai_codex", - keywords=("openai-codex", "codex"), + keywords=("openai-codex",), env_key="", # OAuth-based, no API key display_name="OpenAI Codex", litellm_prefix="", # Not routed through LiteLLM From 936e094a7f8446fdb1835bf28e7a1df8480fdd0d Mon Sep 17 00:00:00 2001 From: Yan-ke Guo Date: Sat, 28 Feb 2026 14:03:36 +0800 Subject: [PATCH 08/13] Modify Feishu bot permissions in README Updated permissions for Feishu bot setup instructions. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 251181b..d788e5e 100644 --- a/README.md +++ b/README.md @@ -420,7 +420,7 @@ Uses **WebSocket** long connection — no public IP required. **1. Create a Feishu bot** - Visit [Feishu Open Platform](https://open.feishu.cn/app) - Create a new app → Enable **Bot** capability -- **Permissions**: Add `im:message` (send messages) +- **Permissions**: Add `im:message` (send messages) and `im:message.p2p_msg:readonly` (receive messages) - **Events**: Add `im.message.receive_v1` (receive messages) - Select **Long Connection** mode (requires running nanobot first to establish connection) - Get **App ID** and **App Secret** from "Credentials & Basic Info" From e440aa72c59cc0c8d39374a28d05a6003d9adda3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E6=AD=A3?= <30361780+azhengzz@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:10:35 +0800 Subject: [PATCH 09/13] fix the interactive message text cannot be extracted --- nanobot/channels/feishu.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 4a6312e..6703f21 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -89,8 +89,9 @@ def _extract_interactive_content(content: dict) -> list[str]: elif isinstance(title, str): parts.append(f"title: {title}") - for element in content.get("elements", []) if isinstance(content.get("elements"), list) else []: - parts.extend(_extract_element_content(element)) + for elements in content.get("elements", []) if isinstance(content.get("elements"), list) else []: + for element in elements: + parts.extend(_extract_element_content(element)) card = content.get("card", {}) if card: From 0036116e0ba94b2b7a1889a570d0a345ddc538a3 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 07:35:07 +0000 Subject: [PATCH 10/13] fix: filter empty assistant messages in _save_turn instead of patching at send time --- nanobot/agent/loop.py | 2 ++ nanobot/providers/base.py | 8 -------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6cd8e56..9bca0a2 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -451,6 +451,8 @@ class AgentLoop: for m in messages[skip:]: entry = {k: v for k, v in m.items() if k != "reasoning_content"} role, content = entry.get("role"), entry.get("content") + if role == "assistant" and not content and not entry.get("tool_calls"): + continue # skip empty assistant messages — they poison session context if role == "tool" and isinstance(content, str) and len(content) > self._TOOL_RESULT_MAX_CHARS: entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)" elif role == "user": diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index f52a951..eb1599a 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -51,14 +51,6 @@ class LLMProvider(ABC): for msg in messages: content = msg.get("content") - # None content on a plain assistant message (no tool_calls) crashes - # providers with "invalid message content type: ". - if content is None and msg.get("role") == "assistant" and not msg.get("tool_calls"): - clean = dict(msg) - clean["content"] = "(empty)" - result.append(clean) - continue - if isinstance(content, str) and not content: clean = dict(msg) clean["content"] = None if (msg.get("role") == "assistant" and msg.get("tool_calls")) else "(empty)" From 89c0f4cae99adb1c2b4a6ad2f3066cd9c37a8a78 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 08:06:20 +0000 Subject: [PATCH 11/13] refactor: compress tool hint args handling to two lines --- nanobot/agent/loop.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b93c477..b605ae4 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -163,10 +163,8 @@ class AgentLoop: def _tool_hint(tool_calls: list) -> str: """Format tool calls as concise hint, e.g. 'web_search("query")'.""" def _fmt(tc): - args = tc.arguments - if isinstance(args, list) and args: - args = args[0] - val = next(iter(args.values()), None) if isinstance(args, dict) and args else None + args = (tc.arguments[0] if isinstance(tc.arguments, list) else tc.arguments) or {} + val = next(iter(args.values()), None) if isinstance(args, dict) else None if not isinstance(val, str): return tc.name return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")' From b89b5a7e2c339278f493883ecc36a5fd7f3b1266 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 08:09:56 +0000 Subject: [PATCH 12/13] refactor: compress _extract_absolute_paths comments --- nanobot/agent/tools/shell.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 71cbd00..6b57874 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -153,10 +153,6 @@ class ExecTool(Tool): @staticmethod def _extract_absolute_paths(command: str) -> list[str]: - # Match Windows absolute paths without truncating at backslashes. - win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]+", command) - # Only match absolute paths — avoid false positives on relative - # paths like ".venv/bin/python" where "/bin/python" would be - # incorrectly extracted by the old pattern. - posix_paths = re.findall(r"(?:^|[\s|>])(/[^\s\"'>]+)", command) + win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]+", command) # Windows: C:\... + posix_paths = re.findall(r"(?:^|[\s|>])(/[^\s\"'>]+)", command) # POSIX: /absolute only return win_paths + posix_paths From 8410f859f734372f3a97cac413f847dc297b588d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 08:26:55 +0000 Subject: [PATCH 13/13] =?UTF-8?q?refactor:=20use=20WeakValueDictionary=20f?= =?UTF-8?q?or=20consolidation=20locks=20=E2=80=94=20auto-cleanup,=20no=20m?= =?UTF-8?q?anual=20pop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nanobot/agent/loop.py | 7 ++----- tests/test_consolidate_offset.py | 13 +++---------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b605ae4..d8e5cad 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import json import re +import weakref from contextlib import AsyncExitStack from pathlib import Path from typing import TYPE_CHECKING, Any, Awaitable, Callable @@ -100,7 +101,7 @@ class AgentLoop: self._mcp_connecting = False self._consolidating: set[str] = set() # Session keys with consolidation in progress self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks - self._consolidation_locks: dict[str, asyncio.Lock] = {} + self._consolidation_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary() self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks self._processing_lock = asyncio.Lock() self._register_default_tools() @@ -373,8 +374,6 @@ class AgentLoop: ) finally: self._consolidating.discard(session.key) - if not lock.locked(): - self._consolidation_locks.pop(session.key, None) session.clear() self.sessions.save(session) @@ -396,8 +395,6 @@ class AgentLoop: await self._consolidate_memory(session) finally: self._consolidating.discard(session.key) - if not lock.locked(): - self._consolidation_locks.pop(session.key, None) _task = asyncio.current_task() if _task is not None: self._consolidation_tasks.discard(_task) diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py index 6755124..a3213dd 100644 --- a/tests/test_consolidate_offset.py +++ b/tests/test_consolidate_offset.py @@ -786,10 +786,8 @@ class TestConsolidationDeduplicationGuard: ) @pytest.mark.asyncio - async def test_new_cleans_up_consolidation_lock_for_invalidated_session( - self, tmp_path: Path - ) -> None: - """/new should remove lock entry for fully invalidated session key.""" + async def test_new_clears_session_and_responds(self, tmp_path: Path) -> None: + """/new clears session and returns confirmation.""" from nanobot.agent.loop import AgentLoop from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus @@ -801,7 +799,6 @@ class TestConsolidationDeduplicationGuard: loop = AgentLoop( bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 ) - loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[])) loop.tools.get_definitions = MagicMock(return_value=[]) @@ -811,10 +808,6 @@ class TestConsolidationDeduplicationGuard: session.add_message("assistant", f"resp{i}") loop.sessions.save(session) - # Ensure lock exists before /new. - loop._consolidation_locks.setdefault(session.key, asyncio.Lock()) - assert session.key in loop._consolidation_locks - async def _ok_consolidate(sess, archive_all: bool = False) -> bool: return True @@ -825,4 +818,4 @@ class TestConsolidationDeduplicationGuard: assert response is not None assert "new session started" in response.content.lower() - assert session.key not in loop._consolidation_locks + assert loop.sessions.get_or_create("cli:test").messages == []