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" 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/agent/loop.py b/nanobot/agent/loop.py index 6fe37e9..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() @@ -163,7 +164,8 @@ 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[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}")' @@ -224,6 +226,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, ) @@ -366,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) @@ -389,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) @@ -445,6 +449,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/agent/tools/shell.py b/nanobot/agent/tools/shell.py index c3810b2..6b57874 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -141,13 +141,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: @@ -156,3 +150,9 @@ 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]: + 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 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: 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) 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, )) 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 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 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 == [] 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