Merge remote-tracking branch 'origin/main' into pr-1325
This commit is contained in:
@@ -420,7 +420,7 @@ Uses **WebSocket** long connection — no public IP required.
|
|||||||
**1. Create a Feishu bot**
|
**1. Create a Feishu bot**
|
||||||
- Visit [Feishu Open Platform](https://open.feishu.cn/app)
|
- Visit [Feishu Open Platform](https://open.feishu.cn/app)
|
||||||
- Create a new app → Enable **Bot** capability
|
- 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)
|
- **Events**: Add `im.message.receive_v1` (receive messages)
|
||||||
- Select **Long Connection** mode (requires running nanobot first to establish connection)
|
- Select **Long Connection** mode (requires running nanobot first to establish connection)
|
||||||
- Get **App ID** and **App Secret** from "Credentials & Basic Info"
|
- Get **App ID** and **App Secret** from "Credentials & Basic Info"
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ You are nanobot, a helpful AI assistant.
|
|||||||
## Workspace
|
## Workspace
|
||||||
Your workspace is at: {workspace_path}
|
Your workspace is at: {workspace_path}
|
||||||
- Long-term memory: {workspace_path}/memory/MEMORY.md (write important facts here)
|
- 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
|
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
|
||||||
|
|
||||||
## nanobot Guidelines
|
## nanobot Guidelines
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import weakref
|
||||||
from contextlib import AsyncExitStack
|
from contextlib import AsyncExitStack
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||||
@@ -100,7 +101,7 @@ class AgentLoop:
|
|||||||
self._mcp_connecting = False
|
self._mcp_connecting = False
|
||||||
self._consolidating: set[str] = set() # Session keys with consolidation in progress
|
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_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._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
|
||||||
self._processing_lock = asyncio.Lock()
|
self._processing_lock = asyncio.Lock()
|
||||||
self._register_default_tools()
|
self._register_default_tools()
|
||||||
@@ -163,7 +164,8 @@ class AgentLoop:
|
|||||||
def _tool_hint(tool_calls: list) -> str:
|
def _tool_hint(tool_calls: list) -> str:
|
||||||
"""Format tool calls as concise hint, e.g. 'web_search("query")'."""
|
"""Format tool calls as concise hint, e.g. 'web_search("query")'."""
|
||||||
def _fmt(tc):
|
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):
|
if not isinstance(val, str):
|
||||||
return tc.name
|
return tc.name
|
||||||
return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")'
|
return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")'
|
||||||
@@ -224,6 +226,12 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
clean = self._strip_think(response.content)
|
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 = self.context.add_assistant_message(
|
||||||
messages, clean, reasoning_content=response.reasoning_content,
|
messages, clean, reasoning_content=response.reasoning_content,
|
||||||
)
|
)
|
||||||
@@ -366,8 +374,6 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
self._consolidating.discard(session.key)
|
self._consolidating.discard(session.key)
|
||||||
if not lock.locked():
|
|
||||||
self._consolidation_locks.pop(session.key, None)
|
|
||||||
|
|
||||||
session.clear()
|
session.clear()
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
@@ -389,8 +395,6 @@ class AgentLoop:
|
|||||||
await self._consolidate_memory(session)
|
await self._consolidate_memory(session)
|
||||||
finally:
|
finally:
|
||||||
self._consolidating.discard(session.key)
|
self._consolidating.discard(session.key)
|
||||||
if not lock.locked():
|
|
||||||
self._consolidation_locks.pop(session.key, None)
|
|
||||||
_task = asyncio.current_task()
|
_task = asyncio.current_task()
|
||||||
if _task is not None:
|
if _task is not None:
|
||||||
self._consolidation_tasks.discard(_task)
|
self._consolidation_tasks.discard(_task)
|
||||||
@@ -445,6 +449,8 @@ class AgentLoop:
|
|||||||
for m in messages[skip:]:
|
for m in messages[skip:]:
|
||||||
entry = {k: v for k, v in m.items() if k != "reasoning_content"}
|
entry = {k: v for k, v in m.items() if k != "reasoning_content"}
|
||||||
role, content = entry.get("role"), entry.get("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:
|
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)"
|
entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
|
||||||
elif role == "user":
|
elif role == "user":
|
||||||
|
|||||||
@@ -141,13 +141,7 @@ class ExecTool(Tool):
|
|||||||
|
|
||||||
cwd_path = Path(cwd).resolve()
|
cwd_path = Path(cwd).resolve()
|
||||||
|
|
||||||
win_paths = re.findall(r"[A-Za-z]:\\[^\\\"']+", cmd)
|
for raw in self._extract_absolute_paths(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:
|
|
||||||
try:
|
try:
|
||||||
p = Path(raw.strip()).resolve()
|
p = Path(raw.strip()).resolve()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -156,3 +150,9 @@ class ExecTool(Tool):
|
|||||||
return "Error: Command blocked by safety guard (path outside working dir)"
|
return "Error: Command blocked by safety guard (path outside working dir)"
|
||||||
|
|
||||||
return None
|
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
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ def _extract_interactive_content(content: dict) -> list[str]:
|
|||||||
elif isinstance(title, str):
|
elif isinstance(title, str):
|
||||||
parts.append(f"title: {title}")
|
parts.append(f"title: {title}")
|
||||||
|
|
||||||
for element in content.get("elements", []) if isinstance(content.get("elements"), list) else []:
|
for elements in content.get("elements", []) if isinstance(content.get("elements"), list) else []:
|
||||||
|
for element in elements:
|
||||||
parts.extend(_extract_element_content(element))
|
parts.extend(_extract_element_content(element))
|
||||||
|
|
||||||
card = content.get("card", {})
|
card = content.get("card", {})
|
||||||
|
|||||||
@@ -100,10 +100,12 @@ class QQChannel(BaseChannel):
|
|||||||
logger.warning("QQ client not initialized")
|
logger.warning("QQ client not initialized")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
msg_id = msg.metadata.get("message_id")
|
||||||
await self._client.api.post_c2c_message(
|
await self._client.api.post_c2c_message(
|
||||||
openid=msg.chat_id,
|
openid=msg.chat_id,
|
||||||
msg_type=0,
|
msg_type=0,
|
||||||
content=msg.content,
|
content=msg.content,
|
||||||
|
msg_id=msg_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error sending QQ message: {}", e)
|
logger.error("Error sending QQ message: {}", e)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import json
|
import json
|
||||||
import json_repair
|
import json_repair
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import litellm
|
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
|
# Standard OpenAI chat-completion message keys plus reasoning_content for
|
||||||
# thinking-enabled models (Kimi k2.5, DeepSeek-R1, etc.).
|
# thinking-enabled models (Kimi k2.5, DeepSeek-R1, etc.).
|
||||||
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"})
|
_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):
|
class LiteLLMProvider(LLMProvider):
|
||||||
@@ -245,7 +252,7 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
args = json_repair.loads(args)
|
args = json_repair.loads(args)
|
||||||
|
|
||||||
tool_calls.append(ToolCallRequest(
|
tool_calls.append(ToolCallRequest(
|
||||||
id=tc.id,
|
id=_short_tool_id(),
|
||||||
name=tc.function.name,
|
name=tc.function.name,
|
||||||
arguments=args,
|
arguments=args,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
# OpenAI Codex: uses OAuth, not API key.
|
# OpenAI Codex: uses OAuth, not API key.
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
name="openai_codex",
|
name="openai_codex",
|
||||||
keywords=("openai-codex", "codex"),
|
keywords=("openai-codex",),
|
||||||
env_key="", # OAuth-based, no API key
|
env_key="", # OAuth-based, no API key
|
||||||
display_name="OpenAI Codex",
|
display_name="OpenAI Codex",
|
||||||
litellm_prefix="", # Not routed through LiteLLM
|
litellm_prefix="", # Not routed through LiteLLM
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ always: true
|
|||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
- `memory/MEMORY.md` — Long-term facts (preferences, project context, relationships). Always loaded into your context.
|
- `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
|
## Search Past Events
|
||||||
|
|
||||||
|
|||||||
@@ -786,10 +786,8 @@ class TestConsolidationDeduplicationGuard:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_new_cleans_up_consolidation_lock_for_invalidated_session(
|
async def test_new_clears_session_and_responds(self, tmp_path: Path) -> None:
|
||||||
self, tmp_path: Path
|
"""/new clears session and returns confirmation."""
|
||||||
) -> None:
|
|
||||||
"""/new should remove lock entry for fully invalidated session key."""
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
@@ -801,7 +799,6 @@ class TestConsolidationDeduplicationGuard:
|
|||||||
loop = AgentLoop(
|
loop = AgentLoop(
|
||||||
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
|
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.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
|
||||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||||
|
|
||||||
@@ -811,10 +808,6 @@ class TestConsolidationDeduplicationGuard:
|
|||||||
session.add_message("assistant", f"resp{i}")
|
session.add_message("assistant", f"resp{i}")
|
||||||
loop.sessions.save(session)
|
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:
|
async def _ok_consolidate(sess, archive_all: bool = False) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -825,4 +818,4 @@ class TestConsolidationDeduplicationGuard:
|
|||||||
|
|
||||||
assert response is not None
|
assert response is not None
|
||||||
assert "new session started" in response.content.lower()
|
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 == []
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from typing import Any
|
|||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
|
|
||||||
|
|
||||||
class SampleTool(Tool):
|
class SampleTool(Tool):
|
||||||
@@ -86,3 +87,22 @@ async def test_registry_returns_validation_error() -> None:
|
|||||||
reg.register(SampleTool())
|
reg.register(SampleTool())
|
||||||
result = await reg.execute("sample", {"query": "hi"})
|
result = await reg.execute("sample", {"query": "hi"})
|
||||||
assert "Invalid parameters" in result
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user