From 19a5efa89eaefb9c800f5c888a8a195c1d9fa548 Mon Sep 17 00:00:00 2001 From: Elliot Lee Date: Wed, 25 Feb 2026 07:47:52 -0800 Subject: [PATCH 01/78] fix: update heartbeat tests to match two-phase tool-call architecture HeartbeatService was refactored from free-text HEARTBEAT_OK token matching to a structured two-phase design (LLM tool call for skip/run decision, then execution). The tests still used the old on_heartbeat callback constructor and HEARTBEAT_OK_TOKEN import. - Remove obsolete test_heartbeat_ok_detection test - Update test_start_is_idempotent to use new provider+model constructor - Add tests for _decide() skip path, trigger_now() run/skip paths Co-Authored-By: Claude Opus 4.6 --- tests/test_heartbeat_service.py | 109 ++++++++++++++++++++++++++------ 1 file changed, 91 insertions(+), 18 deletions(-) diff --git a/tests/test_heartbeat_service.py b/tests/test_heartbeat_service.py index ec91c6b..c5478af 100644 --- a/tests/test_heartbeat_service.py +++ b/tests/test_heartbeat_service.py @@ -2,34 +2,28 @@ import asyncio import pytest -from nanobot.heartbeat.service import ( - HEARTBEAT_OK_TOKEN, - HeartbeatService, -) +from nanobot.heartbeat.service import HeartbeatService +from nanobot.providers.base import LLMResponse, ToolCallRequest -def test_heartbeat_ok_detection() -> None: - def is_ok(response: str) -> bool: - return HEARTBEAT_OK_TOKEN in response.upper() +class DummyProvider: + def __init__(self, responses: list[LLMResponse]): + self._responses = list(responses) - assert is_ok("HEARTBEAT_OK") - assert is_ok("`HEARTBEAT_OK`") - assert is_ok("**HEARTBEAT_OK**") - assert is_ok("heartbeat_ok") - assert is_ok("HEARTBEAT_OK.") - - assert not is_ok("HEARTBEAT_NOT_OK") - assert not is_ok("all good") + async def chat(self, *args, **kwargs) -> LLMResponse: + if self._responses: + return self._responses.pop(0) + return LLMResponse(content="", tool_calls=[]) @pytest.mark.asyncio async def test_start_is_idempotent(tmp_path) -> None: - async def _on_heartbeat(_: str) -> str: - return "HEARTBEAT_OK" + provider = DummyProvider([]) service = HeartbeatService( workspace=tmp_path, - on_heartbeat=_on_heartbeat, + provider=provider, + model="openai/gpt-4o-mini", interval_s=9999, enabled=True, ) @@ -42,3 +36,82 @@ async def test_start_is_idempotent(tmp_path) -> None: service.stop() await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_decide_returns_skip_when_no_tool_call(tmp_path) -> None: + provider = DummyProvider([LLMResponse(content="no tool call", tool_calls=[])]) + service = HeartbeatService( + workspace=tmp_path, + provider=provider, + model="openai/gpt-4o-mini", + ) + + action, tasks = await service._decide("heartbeat content") + assert action == "skip" + assert tasks == "" + + +@pytest.mark.asyncio +async def test_trigger_now_executes_when_decision_is_run(tmp_path) -> None: + (tmp_path / "HEARTBEAT.md").write_text("- [ ] do thing", encoding="utf-8") + + provider = DummyProvider([ + LLMResponse( + content="", + tool_calls=[ + ToolCallRequest( + id="hb_1", + name="heartbeat", + arguments={"action": "run", "tasks": "check open tasks"}, + ) + ], + ) + ]) + + called_with: list[str] = [] + + async def _on_execute(tasks: str) -> str: + called_with.append(tasks) + return "done" + + service = HeartbeatService( + workspace=tmp_path, + provider=provider, + model="openai/gpt-4o-mini", + on_execute=_on_execute, + ) + + result = await service.trigger_now() + assert result == "done" + assert called_with == ["check open tasks"] + + +@pytest.mark.asyncio +async def test_trigger_now_returns_none_when_decision_is_skip(tmp_path) -> None: + (tmp_path / "HEARTBEAT.md").write_text("- [ ] do thing", encoding="utf-8") + + provider = DummyProvider([ + LLMResponse( + content="", + tool_calls=[ + ToolCallRequest( + id="hb_1", + name="heartbeat", + arguments={"action": "skip"}, + ) + ], + ) + ]) + + async def _on_execute(tasks: str) -> str: + return tasks + + service = HeartbeatService( + workspace=tmp_path, + provider=provider, + model="openai/gpt-4o-mini", + on_execute=_on_execute, + ) + + assert await service.trigger_now() is None From fafd8d4eb86c856c72d3dcabab59a013ed5a741a Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Thu, 26 Feb 2026 00:23:58 +0800 Subject: [PATCH 02/78] fix(agent): only suppress final reply when message tool sends to same target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refactoring in commit 132807a introduced a regression where the final response was silently discarded whenever the message tool was used, regardless of the target. This restored the original logic from PR #832 that only suppresses the final reply when the message tool sends to the same (channel, chat_id) as the original message. Changes: - message.py: Replace _sent_in_turn: bool with _turn_sends: list[tuple] to track actual send targets, add get_turn_sends() method - loop.py: Check if (msg.channel, msg.chat_id) is in sent_targets before suppressing final reply. Also move the "Response to" log after the suppress check to avoid misleading logs. - Add unit tests for the suppress logic This ensures: - Email sent via message tool → Feishu still gets confirmation - Message tool sends to same Feishu chat → No duplicate (suppressed) --- nanobot/agent/loop.py | 19 ++- nanobot/agent/tools/message.py | 10 +- tests/test_message_tool_suppress.py | 200 ++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 tests/test_message_tool_suppress.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 8be8e51..2a998d4 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -407,16 +407,25 @@ class AgentLoop: if final_content is None: final_content = "I've completed processing but have no response to give." - preview = final_content[:120] + "..." if len(final_content) > 120 else final_content - logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) - self._save_turn(session, all_msgs, 1 + len(history)) self.sessions.save(session) + suppress_final_reply = False if message_tool := self.tools.get("message"): - if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: - return None + if isinstance(message_tool, MessageTool): + sent_targets = set(message_tool.get_turn_sends()) + suppress_final_reply = (msg.channel, msg.chat_id) in sent_targets + if suppress_final_reply: + logger.info( + "Skipping final auto-reply because message tool already sent to {}:{} in this turn", + msg.channel, + msg.chat_id, + ) + return None + + preview = final_content[:120] + "..." if len(final_content) > 120 else final_content + logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=final_content, metadata=msg.metadata or {}, diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 40e76e3..be359f3 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -20,7 +20,7 @@ class MessageTool(Tool): self._default_channel = default_channel self._default_chat_id = default_chat_id self._default_message_id = default_message_id - self._sent_in_turn: bool = False + self._turn_sends: list[tuple[str, str]] = [] def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Set the current message context.""" @@ -34,7 +34,11 @@ class MessageTool(Tool): def start_turn(self) -> None: """Reset per-turn send tracking.""" - self._sent_in_turn = False + self._turn_sends.clear() + + def get_turn_sends(self) -> list[tuple[str, str]]: + """Get (channel, chat_id) targets sent in the current turn.""" + return list(self._turn_sends) @property def name(self) -> str: @@ -101,7 +105,7 @@ class MessageTool(Tool): try: await self._send_callback(msg) - self._sent_in_turn = True + self._turn_sends.append((channel, chat_id)) media_info = f" with {len(media)} attachments" if media else "" return f"Message sent to {channel}:{chat_id}{media_info}" except Exception as e: diff --git a/tests/test_message_tool_suppress.py b/tests/test_message_tool_suppress.py new file mode 100644 index 0000000..77436a0 --- /dev/null +++ b/tests/test_message_tool_suppress.py @@ -0,0 +1,200 @@ +"""Test message tool suppress logic for final replies.""" + +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nanobot.agent.loop import AgentLoop +from nanobot.agent.tools.message import MessageTool +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.providers.base import LLMResponse, ToolCallRequest + + +class TestMessageToolSuppressLogic: + """Test that final reply is only suppressed when message tool sends to same target.""" + + @pytest.mark.asyncio + async def test_final_reply_suppressed_when_message_tool_sends_to_same_target( + self, tmp_path: Path + ) -> None: + """If message tool sends to the same (channel, chat_id), final reply is suppressed.""" + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 + ) + + # First call returns tool call, second call returns final response + tool_call = ToolCallRequest( + id="call1", + name="message", + arguments={"content": "Hello from tool", "channel": "feishu", "chat_id": "chat123"} + ) + + call_count = 0 + + def mock_chat(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return LLMResponse(content="", tool_calls=[tool_call]) + else: + return LLMResponse(content="Done", tool_calls=[]) + + loop.provider.chat = AsyncMock(side_effect=mock_chat) + loop.tools.get_definitions = MagicMock(return_value=[]) + + # Track outbound messages + sent_messages: list[OutboundMessage] = [] + + async def _capture_outbound(msg: OutboundMessage) -> None: + sent_messages.append(msg) + + # Set up message tool with callback + message_tool = loop.tools.get("message") + if isinstance(message_tool, MessageTool): + message_tool.set_send_callback(_capture_outbound) + + msg = InboundMessage( + channel="feishu", sender_id="user1", chat_id="chat123", content="Send a message" + ) + result = await loop._process_message(msg) + + # Message tool should have sent to the same target + assert len(sent_messages) == 1 + assert sent_messages[0].channel == "feishu" + assert sent_messages[0].chat_id == "chat123" + + # Final reply should be None (suppressed) + assert result is None + + @pytest.mark.asyncio + async def test_final_reply_sent_when_message_tool_sends_to_different_target( + self, tmp_path: Path + ) -> None: + """If message tool sends to a different target, final reply is still sent.""" + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 + ) + + # First call returns tool call to email, second call returns final response + tool_call = ToolCallRequest( + id="call1", + name="message", + arguments={"content": "Email content", "channel": "email", "chat_id": "user@example.com"} + ) + + call_count = 0 + + def mock_chat(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return LLMResponse(content="", tool_calls=[tool_call]) + else: + return LLMResponse(content="I've sent the email.", tool_calls=[]) + + loop.provider.chat = AsyncMock(side_effect=mock_chat) + loop.tools.get_definitions = MagicMock(return_value=[]) + + # Track outbound messages + sent_messages: list[OutboundMessage] = [] + + async def _capture_outbound(msg: OutboundMessage) -> None: + sent_messages.append(msg) + + # Set up message tool with callback + message_tool = loop.tools.get("message") + if isinstance(message_tool, MessageTool): + message_tool.set_send_callback(_capture_outbound) + + msg = InboundMessage( + channel="feishu", sender_id="user1", chat_id="chat123", content="Send an email" + ) + result = await loop._process_message(msg) + + # Message tool should have sent to email + assert len(sent_messages) == 1 + assert sent_messages[0].channel == "email" + assert sent_messages[0].chat_id == "user@example.com" + + # Final reply should be sent to Feishu (not suppressed) + assert result is not None + assert result.channel == "feishu" + assert result.chat_id == "chat123" + assert "email" in result.content.lower() or "sent" in result.content.lower() + + @pytest.mark.asyncio + async def test_final_reply_sent_when_no_message_tool_used(self, tmp_path: Path) -> None: + """If no message tool is used, final reply is always sent.""" + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 + ) + + # Mock provider to return a simple response without tool calls + loop.provider.chat = AsyncMock(return_value=LLMResponse( + content="Hello! How can I help you?", + tool_calls=[] + )) + loop.tools.get_definitions = MagicMock(return_value=[]) + + msg = InboundMessage( + channel="feishu", sender_id="user1", chat_id="chat123", content="Hi" + ) + result = await loop._process_message(msg) + + # Final reply should be sent + assert result is not None + assert result.channel == "feishu" + assert result.chat_id == "chat123" + assert "Hello" in result.content + + +class TestMessageToolTurnTracking: + """Test MessageTool's turn tracking functionality.""" + + def test_turn_sends_tracking(self) -> None: + """MessageTool correctly tracks sends per turn.""" + tool = MessageTool() + + # Initially empty + assert tool.get_turn_sends() == [] + + # Simulate sends + tool._turn_sends.append(("feishu", "chat1")) + tool._turn_sends.append(("email", "user@example.com")) + + sends = tool.get_turn_sends() + assert len(sends) == 2 + assert ("feishu", "chat1") in sends + assert ("email", "user@example.com") in sends + + def test_start_turn_clears_tracking(self) -> None: + """start_turn() clears the turn sends list.""" + tool = MessageTool() + tool._turn_sends.append(("feishu", "chat1")) + assert len(tool.get_turn_sends()) == 1 + + tool.start_turn() + assert tool.get_turn_sends() == [] + + def test_get_turn_sends_returns_copy(self) -> None: + """get_turn_sends() returns a copy, not the original list.""" + tool = MessageTool() + tool._turn_sends.append(("feishu", "chat1")) + + sends = tool.get_turn_sends() + sends.append(("email", "user@example.com")) # Modify the copy + + # Original should be unchanged + assert len(tool.get_turn_sends()) == 1 From 45ae410f05177eb41ef996bd71d0eaa657c5f2ee Mon Sep 17 00:00:00 2001 From: Kim <150593189+KimGLee@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:12:37 +0800 Subject: [PATCH 03/78] fix(agent): do not persist runtime context metadata in session history --- nanobot/agent/loop.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 3e513cb..eb34b31 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -467,6 +467,14 @@ class AgentLoop: content = entry["content"] if len(content) > self._TOOL_RESULT_MAX_CHARS: entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)" + if ( + entry.get("role") == "user" + and isinstance(entry.get("content"), str) + and entry["content"].startswith(ContextBuilder._RUNTIME_CONTEXT_TAG) + ): + # Runtime metadata is injected per-turn for model context only; do not persist it + # into long-lived session history to avoid semantic drift and repetitive replies. + continue if entry.get("role") == "user" and isinstance(entry.get("content"), list): entry["content"] = [ {"type": "text", "text": "[image]"} if ( From 286e67ddef6cafca737a3edfbe9373a77da0f9ab Mon Sep 17 00:00:00 2001 From: Kim <150593189+KimGLee@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:21:44 +0800 Subject: [PATCH 04/78] style(agent): remove inline comment in runtime-context history filter --- nanobot/agent/loop.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index eb34b31..7ae2634 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -472,8 +472,6 @@ class AgentLoop: and isinstance(entry.get("content"), str) and entry["content"].startswith(ContextBuilder._RUNTIME_CONTEXT_TAG) ): - # Runtime metadata is injected per-turn for model context only; do not persist it - # into long-lived session history to avoid semantic drift and repetitive replies. continue if entry.get("role") == "user" and isinstance(entry.get("content"), list): entry["content"] = [ From 7a3788fee93b581a1b2872ad5836b7d7348dbc63 Mon Sep 17 00:00:00 2001 From: Yongfeng Huang <1040488613@qq.com> Date: Thu, 26 Feb 2026 15:43:04 +0800 Subject: [PATCH 05/78] fix(web): use self.api_key instead of undefined api_key Made-with: Cursor --- nanobot/agent/tools/web.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 56956c3..7860f12 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -80,7 +80,7 @@ class WebSearchTool(Tool): r = await client.get( "https://api.search.brave.com/res/v1/web/search", params={"q": query, "count": n}, - headers={"Accept": "application/json", "X-Subscription-Token": api_key}, + headers={"Accept": "application/json", "X-Subscription-Token": self.api_key}, timeout=10.0 ) r.raise_for_status() From 29e6709e261632b0494760f053711bf677ab6b22 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 27 Feb 2026 02:27:18 +0000 Subject: [PATCH 06/78] =?UTF-8?q?refactor:=20simplify=20message=20tool=20s?= =?UTF-8?q?uppress=20=E2=80=94=20bool=20check=20instead=20of=20target=20tr?= =?UTF-8?q?acking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nanobot/agent/loop.py | 13 +- nanobot/agent/tools/message.py | 11 +- tests/test_message_tool_suppress.py | 203 ++++++++-------------------- 3 files changed, 58 insertions(+), 169 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index c6e565b..6155f99 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -444,18 +444,7 @@ class AgentLoop: self._save_turn(session, all_msgs, 1 + len(history)) self.sessions.save(session) - suppress_final_reply = False - if message_tool := self.tools.get("message"): - if isinstance(message_tool, MessageTool): - sent_targets = set(message_tool.get_turn_sends()) - suppress_final_reply = (msg.channel, msg.chat_id) in sent_targets - - if suppress_final_reply: - logger.info( - "Skipping final auto-reply because message tool already sent to {}:{} in this turn", - msg.channel, - msg.chat_id, - ) + if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn: return None preview = final_content[:120] + "..." if len(final_content) > 120 else final_content diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index be359f3..35e519a 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -20,7 +20,7 @@ class MessageTool(Tool): self._default_channel = default_channel self._default_chat_id = default_chat_id self._default_message_id = default_message_id - self._turn_sends: list[tuple[str, str]] = [] + self._sent_in_turn: bool = False def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Set the current message context.""" @@ -34,11 +34,7 @@ class MessageTool(Tool): def start_turn(self) -> None: """Reset per-turn send tracking.""" - self._turn_sends.clear() - - def get_turn_sends(self) -> list[tuple[str, str]]: - """Get (channel, chat_id) targets sent in the current turn.""" - return list(self._turn_sends) + self._sent_in_turn = False @property def name(self) -> str: @@ -105,7 +101,8 @@ class MessageTool(Tool): try: await self._send_callback(msg) - self._turn_sends.append((channel, chat_id)) + if channel == self._default_channel and chat_id == self._default_chat_id: + self._sent_in_turn = True media_info = f" with {len(media)} attachments" if media else "" return f"Message sent to {channel}:{chat_id}{media_info}" except Exception as e: diff --git a/tests/test_message_tool_suppress.py b/tests/test_message_tool_suppress.py index 77436a0..26b8a16 100644 --- a/tests/test_message_tool_suppress.py +++ b/tests/test_message_tool_suppress.py @@ -1,6 +1,5 @@ """Test message tool suppress logic for final replies.""" -import asyncio from pathlib import Path from unittest.mock import AsyncMock, MagicMock @@ -13,188 +12,92 @@ from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMResponse, ToolCallRequest +def _make_loop(tmp_path: Path) -> AgentLoop: + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + return AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10) + + class TestMessageToolSuppressLogic: - """Test that final reply is only suppressed when message tool sends to same target.""" + """Final reply suppressed only when message tool sends to the same target.""" @pytest.mark.asyncio - async def test_final_reply_suppressed_when_message_tool_sends_to_same_target( - self, tmp_path: Path - ) -> None: - """If message tool sends to the same (channel, chat_id), final reply is suppressed.""" - bus = MessageBus() - provider = MagicMock() - provider.get_default_model.return_value = "test-model" - loop = AgentLoop( - bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 - ) - - # First call returns tool call, second call returns final response + async def test_suppress_when_sent_to_same_target(self, tmp_path: Path) -> None: + loop = _make_loop(tmp_path) tool_call = ToolCallRequest( - id="call1", - name="message", - arguments={"content": "Hello from tool", "channel": "feishu", "chat_id": "chat123"} + id="call1", name="message", + arguments={"content": "Hello", "channel": "feishu", "chat_id": "chat123"}, ) - - call_count = 0 - - def mock_chat(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - return LLMResponse(content="", tool_calls=[tool_call]) - else: - return LLMResponse(content="Done", tool_calls=[]) - - loop.provider.chat = AsyncMock(side_effect=mock_chat) + calls = iter([ + LLMResponse(content="", tool_calls=[tool_call]), + LLMResponse(content="Done", tool_calls=[]), + ]) + loop.provider.chat = AsyncMock(side_effect=lambda *a, **kw: next(calls)) loop.tools.get_definitions = MagicMock(return_value=[]) - # Track outbound messages - sent_messages: list[OutboundMessage] = [] + sent: list[OutboundMessage] = [] + mt = loop.tools.get("message") + if isinstance(mt, MessageTool): + mt.set_send_callback(AsyncMock(side_effect=lambda m: sent.append(m))) - async def _capture_outbound(msg: OutboundMessage) -> None: - sent_messages.append(msg) - - # Set up message tool with callback - message_tool = loop.tools.get("message") - if isinstance(message_tool, MessageTool): - message_tool.set_send_callback(_capture_outbound) - - msg = InboundMessage( - channel="feishu", sender_id="user1", chat_id="chat123", content="Send a message" - ) + msg = InboundMessage(channel="feishu", sender_id="user1", chat_id="chat123", content="Send") result = await loop._process_message(msg) - # Message tool should have sent to the same target - assert len(sent_messages) == 1 - assert sent_messages[0].channel == "feishu" - assert sent_messages[0].chat_id == "chat123" - - # Final reply should be None (suppressed) - assert result is None + assert len(sent) == 1 + assert result is None # suppressed @pytest.mark.asyncio - async def test_final_reply_sent_when_message_tool_sends_to_different_target( - self, tmp_path: Path - ) -> None: - """If message tool sends to a different target, final reply is still sent.""" - bus = MessageBus() - provider = MagicMock() - provider.get_default_model.return_value = "test-model" - loop = AgentLoop( - bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 - ) - - # First call returns tool call to email, second call returns final response + async def test_not_suppress_when_sent_to_different_target(self, tmp_path: Path) -> None: + loop = _make_loop(tmp_path) tool_call = ToolCallRequest( - id="call1", - name="message", - arguments={"content": "Email content", "channel": "email", "chat_id": "user@example.com"} + id="call1", name="message", + arguments={"content": "Email content", "channel": "email", "chat_id": "user@example.com"}, ) - - call_count = 0 - - def mock_chat(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - return LLMResponse(content="", tool_calls=[tool_call]) - else: - return LLMResponse(content="I've sent the email.", tool_calls=[]) - - loop.provider.chat = AsyncMock(side_effect=mock_chat) + calls = iter([ + LLMResponse(content="", tool_calls=[tool_call]), + LLMResponse(content="I've sent the email.", tool_calls=[]), + ]) + loop.provider.chat = AsyncMock(side_effect=lambda *a, **kw: next(calls)) loop.tools.get_definitions = MagicMock(return_value=[]) - # Track outbound messages - sent_messages: list[OutboundMessage] = [] + sent: list[OutboundMessage] = [] + mt = loop.tools.get("message") + if isinstance(mt, MessageTool): + mt.set_send_callback(AsyncMock(side_effect=lambda m: sent.append(m))) - async def _capture_outbound(msg: OutboundMessage) -> None: - sent_messages.append(msg) - - # Set up message tool with callback - message_tool = loop.tools.get("message") - if isinstance(message_tool, MessageTool): - message_tool.set_send_callback(_capture_outbound) - - msg = InboundMessage( - channel="feishu", sender_id="user1", chat_id="chat123", content="Send an email" - ) + msg = InboundMessage(channel="feishu", sender_id="user1", chat_id="chat123", content="Send email") result = await loop._process_message(msg) - # Message tool should have sent to email - assert len(sent_messages) == 1 - assert sent_messages[0].channel == "email" - assert sent_messages[0].chat_id == "user@example.com" - - # Final reply should be sent to Feishu (not suppressed) - assert result is not None + assert len(sent) == 1 + assert sent[0].channel == "email" + assert result is not None # not suppressed assert result.channel == "feishu" - assert result.chat_id == "chat123" - assert "email" in result.content.lower() or "sent" in result.content.lower() @pytest.mark.asyncio - async def test_final_reply_sent_when_no_message_tool_used(self, tmp_path: Path) -> None: - """If no message tool is used, final reply is always sent.""" - bus = MessageBus() - provider = MagicMock() - provider.get_default_model.return_value = "test-model" - loop = AgentLoop( - bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 - ) - - # Mock provider to return a simple response without tool calls - loop.provider.chat = AsyncMock(return_value=LLMResponse( - content="Hello! How can I help you?", - tool_calls=[] - )) + async def test_not_suppress_when_no_message_tool_used(self, tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + loop.provider.chat = AsyncMock(return_value=LLMResponse(content="Hello!", tool_calls=[])) loop.tools.get_definitions = MagicMock(return_value=[]) - msg = InboundMessage( - channel="feishu", sender_id="user1", chat_id="chat123", content="Hi" - ) + msg = InboundMessage(channel="feishu", sender_id="user1", chat_id="chat123", content="Hi") result = await loop._process_message(msg) - # Final reply should be sent assert result is not None - assert result.channel == "feishu" - assert result.chat_id == "chat123" assert "Hello" in result.content class TestMessageToolTurnTracking: - """Test MessageTool's turn tracking functionality.""" - def test_turn_sends_tracking(self) -> None: - """MessageTool correctly tracks sends per turn.""" + def test_sent_in_turn_tracks_same_target(self) -> None: tool = MessageTool() + tool.set_context("feishu", "chat1") + assert not tool._sent_in_turn + tool._sent_in_turn = True + assert tool._sent_in_turn - # Initially empty - assert tool.get_turn_sends() == [] - - # Simulate sends - tool._turn_sends.append(("feishu", "chat1")) - tool._turn_sends.append(("email", "user@example.com")) - - sends = tool.get_turn_sends() - assert len(sends) == 2 - assert ("feishu", "chat1") in sends - assert ("email", "user@example.com") in sends - - def test_start_turn_clears_tracking(self) -> None: - """start_turn() clears the turn sends list.""" + def test_start_turn_resets(self) -> None: tool = MessageTool() - tool._turn_sends.append(("feishu", "chat1")) - assert len(tool.get_turn_sends()) == 1 - + tool._sent_in_turn = True tool.start_turn() - assert tool.get_turn_sends() == [] - - def test_get_turn_sends_returns_copy(self) -> None: - """get_turn_sends() returns a copy, not the original list.""" - tool = MessageTool() - tool._turn_sends.append(("feishu", "chat1")) - - sends = tool.get_turn_sends() - sends.append(("email", "user@example.com")) # Modify the copy - - # Original should be unchanged - assert len(tool.get_turn_sends()) == 1 + assert not tool._sent_in_turn From cb999ae82600915015a7cbe2c858d7f9b9a6cc0f Mon Sep 17 00:00:00 2001 From: Hon Jia Xuan Date: Fri, 27 Feb 2026 10:39:05 +0800 Subject: [PATCH 07/78] feat: implement automatic workspace template synchronization --- nanobot/cli/commands.py | 39 +++++------------------- nanobot/utils/helpers.py | 65 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 32 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 1c20b50..9dee105 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -186,7 +186,8 @@ def onboard(): console.print(f"[green]✓[/green] Created workspace at {workspace}") # Create default bootstrap files - _create_workspace_templates(workspace) + from nanobot.utils.helpers import sync_workspace_templates + sync_workspace_templates(workspace) console.print(f"\n{__logo__} nanobot is ready!") console.print("\nNext steps:") @@ -198,36 +199,6 @@ def onboard(): -def _create_workspace_templates(workspace: Path): - """Create default workspace template files from bundled templates.""" - from importlib.resources import files as pkg_files - - templates_dir = pkg_files("nanobot") / "templates" - - for item in templates_dir.iterdir(): - if not item.name.endswith(".md"): - continue - dest = workspace / item.name - if not dest.exists(): - dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8") - console.print(f" [dim]Created {item.name}[/dim]") - - memory_dir = workspace / "memory" - memory_dir.mkdir(exist_ok=True) - - memory_template = templates_dir / "memory" / "MEMORY.md" - memory_file = memory_dir / "MEMORY.md" - if not memory_file.exists(): - memory_file.write_text(memory_template.read_text(encoding="utf-8"), encoding="utf-8") - console.print(" [dim]Created memory/MEMORY.md[/dim]") - - history_file = memory_dir / "HISTORY.md" - if not history_file.exists(): - history_file.write_text("", encoding="utf-8") - console.print(" [dim]Created memory/HISTORY.md[/dim]") - - (workspace / "skills").mkdir(exist_ok=True) - def _make_provider(config: Config): """Create the appropriate LLM provider from config.""" @@ -294,6 +265,8 @@ def gateway( console.print(f"{__logo__} Starting nanobot gateway on port {port}...") config = load_config() + from nanobot.utils.helpers import sync_workspace_templates + sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) @@ -447,6 +420,8 @@ def agent( from loguru import logger config = load_config() + from nanobot.utils.helpers import sync_workspace_templates + sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) @@ -1008,6 +983,8 @@ def status(): config_path = get_config_path() config = load_config() workspace = config.workspace_path + from nanobot.utils.helpers import sync_workspace_templates + sync_workspace_templates(workspace) console.print(f"{__logo__} nanobot Status\n") diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 06d8fd5..83653ac 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -76,4 +76,67 @@ def parse_session_key(key: str) -> tuple[str, str]: parts = key.split(":", 1) if len(parts) != 2: raise ValueError(f"Invalid session key: {key}") - return parts[0], parts[1] \ No newline at end of file + return parts[0], parts[1] + +def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]: + """ + Synchronize default workspace template files from bundled templates. + Only creates files that do not exist. Returns list of added file names. + """ + from importlib.resources import files as pkg_files + from rich.console import Console + console = Console() + added = [] + + try: + templates_dir = pkg_files("nanobot") / "templates" + except Exception: + # Fallback for some environments where pkg_files might fail + return [] + + if not templates_dir.is_dir(): + return [] + + # 1. Sync root templates + for item in templates_dir.iterdir(): + if not item.name.endswith(".md"): + continue + dest = workspace / item.name + if not dest.exists(): + try: + dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8") + added.append(item.name) + except Exception: + pass + + # 2. Sync memory templates + memory_dir = workspace / "memory" + memory_dir.mkdir(exist_ok=True) + + memory_src = templates_dir / "memory" / "MEMORY.md" + memory_dest = memory_dir / "MEMORY.md" + if memory_src.is_file() and not memory_dest.exists(): + try: + memory_dest.write_text(memory_src.read_text(encoding="utf-8"), encoding="utf-8") + added.append("memory/MEMORY.md") + except Exception: + pass + + # 3. History file (always ensure it exists) + history_file = memory_dir / "HISTORY.md" + if not history_file.exists(): + try: + history_file.write_text("", encoding="utf-8") + added.append("memory/HISTORY.md") + except Exception: + pass + + # 4. Ensure skills dir exists + (workspace / "skills").mkdir(exist_ok=True) + + # Print notices if files were added + if added and not silent: + for name in added: + console.print(f" [dim]Created {name}[/dim]") + + return added \ No newline at end of file From ec8dee802c3727e6293e1d0bba9c6d0bb171b718 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 27 Feb 2026 02:39:38 +0000 Subject: [PATCH 08/78] refactor: simplify message tool suppress and inline consolidation locks --- README.md | 2 +- nanobot/agent/loop.py | 41 ++++++++++---------------------- tests/test_consolidate_offset.py | 2 +- 3 files changed, 14 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index be360dc..71922fb 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,966 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,932 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6155f99..e3a9d67 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -43,6 +43,8 @@ class AgentLoop: 5. Sends responses back """ + _TOOL_RESULT_MAX_CHARS = 500 + def __init__( self, bus: MessageBus, @@ -145,17 +147,10 @@ class AgentLoop: def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Update context for all tools that need routing info.""" - if message_tool := self.tools.get("message"): - if isinstance(message_tool, MessageTool): - message_tool.set_context(channel, chat_id, message_id) - - if spawn_tool := self.tools.get("spawn"): - if isinstance(spawn_tool, SpawnTool): - spawn_tool.set_context(channel, chat_id) - - if cron_tool := self.tools.get("cron"): - if isinstance(cron_tool, CronTool): - cron_tool.set_context(channel, chat_id) + for name in ("message", "spawn", "cron"): + if tool := self.tools.get(name): + if hasattr(tool, "set_context"): + tool.set_context(channel, chat_id, *([message_id] if name == "message" else [])) @staticmethod def _strip_think(text: str | None) -> str | None: @@ -315,18 +310,6 @@ class AgentLoop: self._running = False logger.info("Agent loop stopping") - def _get_consolidation_lock(self, session_key: str) -> asyncio.Lock: - lock = self._consolidation_locks.get(session_key) - if lock is None: - lock = asyncio.Lock() - self._consolidation_locks[session_key] = lock - return lock - - def _prune_consolidation_lock(self, session_key: str, lock: asyncio.Lock) -> None: - """Drop lock entry if no longer in use.""" - if not lock.locked(): - self._consolidation_locks.pop(session_key, None) - async def _process_message( self, msg: InboundMessage, @@ -362,7 +345,7 @@ class AgentLoop: # Slash commands cmd = msg.content.strip().lower() if cmd == "/new": - lock = self._get_consolidation_lock(session.key) + lock = self._consolidation_locks.setdefault(session.key, asyncio.Lock()) self._consolidating.add(session.key) try: async with lock: @@ -383,7 +366,8 @@ class AgentLoop: ) finally: self._consolidating.discard(session.key) - self._prune_consolidation_lock(session.key, lock) + if not lock.locked(): + self._consolidation_locks.pop(session.key, None) session.clear() self.sessions.save(session) @@ -397,7 +381,7 @@ class AgentLoop: unconsolidated = len(session.messages) - session.last_consolidated if (unconsolidated >= self.memory_window and session.key not in self._consolidating): self._consolidating.add(session.key) - lock = self._get_consolidation_lock(session.key) + lock = self._consolidation_locks.setdefault(session.key, asyncio.Lock()) async def _consolidate_and_unlock(): try: @@ -405,7 +389,8 @@ class AgentLoop: await self._consolidate_memory(session) finally: self._consolidating.discard(session.key) - self._prune_consolidation_lock(session.key, lock) + 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) @@ -454,8 +439,6 @@ class AgentLoop: metadata=msg.metadata or {}, ) - _TOOL_RESULT_MAX_CHARS = 500 - def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None: """Save new-turn messages into session, truncating large tool results.""" from datetime import datetime diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py index 323519e..6755124 100644 --- a/tests/test_consolidate_offset.py +++ b/tests/test_consolidate_offset.py @@ -812,7 +812,7 @@ class TestConsolidationDeduplicationGuard: loop.sessions.save(session) # Ensure lock exists before /new. - _ = loop._get_consolidation_lock(session.key) + 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: From 6641bad337668d23344b21a724e3e5f61e561158 Mon Sep 17 00:00:00 2001 From: kimkitsuragi26 Date: Fri, 27 Feb 2026 11:45:44 +0800 Subject: [PATCH 09/78] feat(feishu): make reaction emoji configurable Replace hardcoded THUMBSUP with configurable react_emoji field in FeishuConfig, consistent with SlackConfig.react_emoji pattern. Default remains THUMBSUP for backward compatibility. --- nanobot/channels/feishu.py | 2 +- nanobot/config/schema.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 480bf7b..4a6312e 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -692,7 +692,7 @@ class FeishuChannel(BaseChannel): msg_type = message.message_type # Add reaction - await self._add_reaction(message_id, "THUMBSUP") + await self._add_reaction(message_id, self.config.react_emoji) # Parse content content_parts = [] diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 61aee96..d83967c 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -42,6 +42,7 @@ class FeishuConfig(Base): encrypt_key: str = "" # Encrypt Key for event subscription (optional) verification_token: str = "" # Verification Token for event subscription (optional) allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids + react_emoji: str = "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE) class DingTalkConfig(Base): From aa774733ea2a78798ed582c7bc1f72bb59af5487 Mon Sep 17 00:00:00 2001 From: Kim <150593189+KimGLee@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:08:48 +0800 Subject: [PATCH 10/78] fix(telegram): aggregate media-group images into a single inbound turn --- nanobot/channels/telegram.py | 218 ++++++++++++++++++++++++----------- 1 file changed, 152 insertions(+), 66 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 808f50c..bf2da73 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -4,9 +4,10 @@ from __future__ import annotations import asyncio import re + from loguru import logger -from telegram import BotCommand, Update, ReplyParameters -from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes +from telegram import BotCommand, ReplyParameters, Update +from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage @@ -21,60 +22,60 @@ def _markdown_to_telegram_html(text: str) -> str: """ if not text: return "" - + # 1. Extract and protect code blocks (preserve content from other processing) code_blocks: list[str] = [] def save_code_block(m: re.Match) -> str: code_blocks.append(m.group(1)) return f"\x00CB{len(code_blocks) - 1}\x00" - + text = re.sub(r'```[\w]*\n?([\s\S]*?)```', save_code_block, text) - + # 2. Extract and protect inline code inline_codes: list[str] = [] def save_inline_code(m: re.Match) -> str: inline_codes.append(m.group(1)) return f"\x00IC{len(inline_codes) - 1}\x00" - + text = re.sub(r'`([^`]+)`', save_inline_code, text) - + # 3. Headers # Title -> just the title text text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE) - + # 4. Blockquotes > text -> just the text (before HTML escaping) text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE) - + # 5. Escape HTML special characters text = text.replace("&", "&").replace("<", "<").replace(">", ">") - + # 6. Links [text](url) - must be before bold/italic to handle nested cases text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', text) - + # 7. Bold **text** or __text__ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) text = re.sub(r'__(.+?)__', r'\1', text) - + # 8. Italic _text_ (avoid matching inside words like some_var_name) text = re.sub(r'(?\1', text) - + # 9. Strikethrough ~~text~~ text = re.sub(r'~~(.+?)~~', r'\1', text) - + # 10. Bullet lists - item -> • item text = re.sub(r'^[-*]\s+', '• ', text, flags=re.MULTILINE) - + # 11. Restore inline code with HTML tags for i, code in enumerate(inline_codes): # Escape HTML in code content escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace(f"\x00IC{i}\x00", f"{escaped}") - + # 12. Restore code blocks with HTML tags for i, code in enumerate(code_blocks): # Escape HTML in code content escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace(f"\x00CB{i}\x00", f"
{escaped}
") - + return text @@ -101,12 +102,12 @@ def _split_message(content: str, max_len: int = 4000) -> list[str]: class TelegramChannel(BaseChannel): """ Telegram channel using long polling. - + Simple and reliable - no webhook/public IP needed. """ - + name = "telegram" - + # Commands registered with Telegram's command menu BOT_COMMANDS = [ BotCommand("start", "Start the bot"), @@ -114,7 +115,7 @@ class TelegramChannel(BaseChannel): BotCommand("stop", "Stop the current task"), BotCommand("help", "Show available commands"), ] - + def __init__( self, config: TelegramConfig, @@ -127,15 +128,17 @@ class TelegramChannel(BaseChannel): self._app: Application | None = None self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task - + self._media_group_buffers: dict[str, dict[str, object]] = {} + self._media_group_tasks: dict[str, asyncio.Task] = {} + async def start(self) -> None: """Start the Telegram bot with long polling.""" if not self.config.token: logger.error("Telegram bot token not configured") return - + self._running = True - + # Build the application with larger connection pool to avoid pool-timeout on long runs req = HTTPXRequest(connection_pool_size=16, pool_timeout=5.0, connect_timeout=30.0, read_timeout=30.0) builder = Application.builder().token(self.config.token).request(req).get_updates_request(req) @@ -143,62 +146,69 @@ class TelegramChannel(BaseChannel): builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) self._app = builder.build() self._app.add_error_handler(self._on_error) - + # Add command handlers self._app.add_handler(CommandHandler("start", self._on_start)) self._app.add_handler(CommandHandler("new", self._forward_command)) self._app.add_handler(CommandHandler("help", self._on_help)) - + # Add message handler for text, photos, voice, documents self._app.add_handler( MessageHandler( - (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL) - & ~filters.COMMAND, + (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL) + & ~filters.COMMAND, self._on_message ) ) - + logger.info("Starting Telegram bot (polling mode)...") - + # Initialize and start polling await self._app.initialize() await self._app.start() - + # Get bot info and register command menu bot_info = await self._app.bot.get_me() logger.info("Telegram bot @{} connected", bot_info.username) - + try: await self._app.bot.set_my_commands(self.BOT_COMMANDS) logger.debug("Telegram bot commands registered") except Exception as e: logger.warning("Failed to register bot commands: {}", e) - + # Start polling (this runs until stopped) await self._app.updater.start_polling( allowed_updates=["message"], drop_pending_updates=True # Ignore old messages on startup ) - + # Keep running until stopped while self._running: await asyncio.sleep(1) - + async def stop(self) -> None: """Stop the Telegram bot.""" self._running = False - + # Cancel all typing indicators for chat_id in list(self._typing_tasks): self._stop_typing(chat_id) - + + # Cancel buffered media-group flush tasks + for key, task in list(self._media_group_tasks.items()): + if task and not task.done(): + task.cancel() + self._media_group_tasks.pop(key, None) + self._media_group_buffers.clear() + if self._app: logger.info("Stopping Telegram bot...") await self._app.updater.stop() await self._app.stop() await self._app.shutdown() self._app = None - + @staticmethod def _get_media_type(path: str) -> str: """Guess media type from file extension.""" @@ -246,7 +256,7 @@ class TelegramChannel(BaseChannel): param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document" with open(media_path, 'rb') as f: await sender( - chat_id=chat_id, + chat_id=chat_id, **{param: f}, reply_parameters=reply_params ) @@ -265,8 +275,8 @@ class TelegramChannel(BaseChannel): try: html = _markdown_to_telegram_html(chunk) await self._app.bot.send_message( - chat_id=chat_id, - text=html, + chat_id=chat_id, + text=html, parse_mode="HTML", reply_parameters=reply_params ) @@ -274,13 +284,13 @@ class TelegramChannel(BaseChannel): logger.warning("HTML parse failed, falling back to plain text: {}", e) try: await self._app.bot.send_message( - chat_id=chat_id, + chat_id=chat_id, text=chunk, reply_parameters=reply_params ) except Exception as e2: logger.error("Error sending Telegram message: {}", e2) - + async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start command.""" if not update.message or not update.effective_user: @@ -319,34 +329,34 @@ class TelegramChannel(BaseChannel): chat_id=str(update.message.chat_id), content=update.message.text, ) - + async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming messages (text, photos, voice, documents).""" if not update.message or not update.effective_user: return - + message = update.message user = update.effective_user chat_id = message.chat_id sender_id = self._sender_id(user) - + # Store chat_id for replies self._chat_ids[sender_id] = chat_id - + # Build content from text and/or media content_parts = [] media_paths = [] - + # Text content if message.text: content_parts.append(message.text) if message.caption: content_parts.append(message.caption) - + # Handle media files media_file = None media_type = None - + if message.photo: media_file = message.photo[-1] # Largest photo media_type = "image" @@ -359,23 +369,23 @@ class TelegramChannel(BaseChannel): elif message.document: media_file = message.document media_type = "file" - + # Download media if present if media_file and self._app: try: file = await self._app.bot.get_file(media_file.file_id) ext = self._get_extension(media_type, getattr(media_file, 'mime_type', None)) - + # Save to workspace/media/ from pathlib import Path media_dir = Path.home() / ".nanobot" / "media" media_dir.mkdir(parents=True, exist_ok=True) - + file_path = media_dir / f"{media_file.file_id[:16]}{ext}" await file.download_to_drive(str(file_path)) - + media_paths.append(str(file_path)) - + # Handle voice transcription if media_type == "voice" or media_type == "audio": from nanobot.providers.transcription import GroqTranscriptionProvider @@ -388,21 +398,60 @@ class TelegramChannel(BaseChannel): content_parts.append(f"[{media_type}: {file_path}]") else: content_parts.append(f"[{media_type}: {file_path}]") - + logger.debug("Downloaded {} to {}", media_type, file_path) except Exception as e: logger.error("Failed to download media: {}", e) content_parts.append(f"[{media_type}: download failed]") - + content = "\n".join(content_parts) if content_parts else "[empty message]" - + logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) - + str_chat_id = str(chat_id) - + + # Telegram media groups arrive as multiple messages sharing media_group_id. + # Buffer briefly and forward as one aggregated turn. + media_group_id = getattr(message, "media_group_id", None) + if media_group_id: + group_key = f"{str_chat_id}:{media_group_id}" + buffer = self._media_group_buffers.get(group_key) + if not buffer: + buffer = { + "sender_id": sender_id, + "chat_id": str_chat_id, + "contents": [], + "media": [], + "metadata": { + "message_id": message.message_id, + "user_id": user.id, + "username": user.username, + "first_name": user.first_name, + "is_group": message.chat.type != "private", + "media_group_id": media_group_id, + }, + } + self._media_group_buffers[group_key] = buffer + self._start_typing(str_chat_id) + + if content and content != "[empty message]": + cast_contents = buffer["contents"] + if isinstance(cast_contents, list): + cast_contents.append(content) + cast_media = buffer["media"] + if isinstance(cast_media, list): + cast_media.extend(media_paths) + + # Start one delayed flush task per media group. + if group_key not in self._media_group_tasks: + self._media_group_tasks[group_key] = asyncio.create_task( + self._flush_media_group(group_key) + ) + return + # Start typing indicator before processing self._start_typing(str_chat_id) - + # Forward to the message bus await self._handle_message( sender_id=sender_id, @@ -417,19 +466,56 @@ class TelegramChannel(BaseChannel): "is_group": message.chat.type != "private" } ) - + + async def _flush_media_group(self, group_key: str, delay_s: float = 0.6) -> None: + """Flush buffered Telegram media-group messages as one aggregated turn.""" + try: + await asyncio.sleep(delay_s) + buffer = self._media_group_buffers.pop(group_key, None) + if not buffer: + return + + sender_id = str(buffer.get("sender_id", "")) + chat_id = str(buffer.get("chat_id", "")) + contents = buffer.get("contents") + media = buffer.get("media") + metadata = buffer.get("metadata") + + content_parts = [c for c in (contents if isinstance(contents, list) else []) if isinstance(c, str) and c] + media_paths = [m for m in (media if isinstance(media, list) else []) if isinstance(m, str) and m] + + # De-duplicate while preserving order + seen = set() + unique_media: list[str] = [] + for m in media_paths: + if m in seen: + continue + seen.add(m) + unique_media.append(m) + + content = "\n".join(content_parts) if content_parts else "[empty message]" + await self._handle_message( + sender_id=sender_id, + chat_id=chat_id, + content=content, + media=unique_media, + metadata=metadata if isinstance(metadata, dict) else {}, + ) + finally: + self._media_group_tasks.pop(group_key, None) + def _start_typing(self, chat_id: str) -> None: """Start sending 'typing...' indicator for a chat.""" # Cancel any existing typing task for this chat self._stop_typing(chat_id) self._typing_tasks[chat_id] = asyncio.create_task(self._typing_loop(chat_id)) - + def _stop_typing(self, chat_id: str) -> None: """Stop the typing indicator for a chat.""" task = self._typing_tasks.pop(chat_id, None) if task and not task.done(): task.cancel() - + async def _typing_loop(self, chat_id: str) -> None: """Repeatedly send 'typing' action until cancelled.""" try: @@ -440,7 +526,7 @@ class TelegramChannel(BaseChannel): pass except Exception as e: logger.debug("Typing indicator stopped for {}: {}", chat_id, e) - + async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """Log polling / handler errors instead of silently swallowing them.""" logger.error("Telegram error: {}", context.error) @@ -454,6 +540,6 @@ class TelegramChannel(BaseChannel): } if mime_type in ext_map: return ext_map[mime_type] - + type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "file": ""} return type_map.get(media_type, "") From a3e0543eae66e566b9d5cb1c0e398bfc33b6e7d9 Mon Sep 17 00:00:00 2001 From: Kim <150593189+KimGLee@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:16:51 +0800 Subject: [PATCH 11/78] chore(telegram): keep media-group fix without unrelated formatting changes --- nanobot/channels/telegram.py | 133 +++++++++++++++++------------------ 1 file changed, 66 insertions(+), 67 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index bf2da73..ed77963 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -4,10 +4,9 @@ from __future__ import annotations import asyncio import re - from loguru import logger -from telegram import BotCommand, ReplyParameters, Update -from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters +from telegram import BotCommand, Update, ReplyParameters +from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage @@ -22,60 +21,60 @@ def _markdown_to_telegram_html(text: str) -> str: """ if not text: return "" - + # 1. Extract and protect code blocks (preserve content from other processing) code_blocks: list[str] = [] def save_code_block(m: re.Match) -> str: code_blocks.append(m.group(1)) return f"\x00CB{len(code_blocks) - 1}\x00" - + text = re.sub(r'```[\w]*\n?([\s\S]*?)```', save_code_block, text) - + # 2. Extract and protect inline code inline_codes: list[str] = [] def save_inline_code(m: re.Match) -> str: inline_codes.append(m.group(1)) return f"\x00IC{len(inline_codes) - 1}\x00" - + text = re.sub(r'`([^`]+)`', save_inline_code, text) - + # 3. Headers # Title -> just the title text text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE) - + # 4. Blockquotes > text -> just the text (before HTML escaping) text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE) - + # 5. Escape HTML special characters text = text.replace("&", "&").replace("<", "<").replace(">", ">") - + # 6. Links [text](url) - must be before bold/italic to handle nested cases text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', text) - + # 7. Bold **text** or __text__ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) text = re.sub(r'__(.+?)__', r'\1', text) - + # 8. Italic _text_ (avoid matching inside words like some_var_name) text = re.sub(r'(?\1', text) - + # 9. Strikethrough ~~text~~ text = re.sub(r'~~(.+?)~~', r'\1', text) - + # 10. Bullet lists - item -> • item text = re.sub(r'^[-*]\s+', '• ', text, flags=re.MULTILINE) - + # 11. Restore inline code with HTML tags for i, code in enumerate(inline_codes): # Escape HTML in code content escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace(f"\x00IC{i}\x00", f"{escaped}") - + # 12. Restore code blocks with HTML tags for i, code in enumerate(code_blocks): # Escape HTML in code content escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace(f"\x00CB{i}\x00", f"
{escaped}
") - + return text @@ -102,12 +101,12 @@ def _split_message(content: str, max_len: int = 4000) -> list[str]: class TelegramChannel(BaseChannel): """ Telegram channel using long polling. - + Simple and reliable - no webhook/public IP needed. """ - + name = "telegram" - + # Commands registered with Telegram's command menu BOT_COMMANDS = [ BotCommand("start", "Start the bot"), @@ -115,7 +114,7 @@ class TelegramChannel(BaseChannel): BotCommand("stop", "Stop the current task"), BotCommand("help", "Show available commands"), ] - + def __init__( self, config: TelegramConfig, @@ -130,15 +129,15 @@ class TelegramChannel(BaseChannel): self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task self._media_group_buffers: dict[str, dict[str, object]] = {} self._media_group_tasks: dict[str, asyncio.Task] = {} - + async def start(self) -> None: """Start the Telegram bot with long polling.""" if not self.config.token: logger.error("Telegram bot token not configured") return - + self._running = True - + # Build the application with larger connection pool to avoid pool-timeout on long runs req = HTTPXRequest(connection_pool_size=16, pool_timeout=5.0, connect_timeout=30.0, read_timeout=30.0) builder = Application.builder().token(self.config.token).request(req).get_updates_request(req) @@ -146,51 +145,51 @@ class TelegramChannel(BaseChannel): builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) self._app = builder.build() self._app.add_error_handler(self._on_error) - + # Add command handlers self._app.add_handler(CommandHandler("start", self._on_start)) self._app.add_handler(CommandHandler("new", self._forward_command)) self._app.add_handler(CommandHandler("help", self._on_help)) - + # Add message handler for text, photos, voice, documents self._app.add_handler( MessageHandler( - (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL) - & ~filters.COMMAND, + (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL) + & ~filters.COMMAND, self._on_message ) ) - + logger.info("Starting Telegram bot (polling mode)...") - + # Initialize and start polling await self._app.initialize() await self._app.start() - + # Get bot info and register command menu bot_info = await self._app.bot.get_me() logger.info("Telegram bot @{} connected", bot_info.username) - + try: await self._app.bot.set_my_commands(self.BOT_COMMANDS) logger.debug("Telegram bot commands registered") except Exception as e: logger.warning("Failed to register bot commands: {}", e) - + # Start polling (this runs until stopped) await self._app.updater.start_polling( allowed_updates=["message"], drop_pending_updates=True # Ignore old messages on startup ) - + # Keep running until stopped while self._running: await asyncio.sleep(1) - + async def stop(self) -> None: """Stop the Telegram bot.""" self._running = False - + # Cancel all typing indicators for chat_id in list(self._typing_tasks): self._stop_typing(chat_id) @@ -201,14 +200,14 @@ class TelegramChannel(BaseChannel): task.cancel() self._media_group_tasks.pop(key, None) self._media_group_buffers.clear() - + if self._app: logger.info("Stopping Telegram bot...") await self._app.updater.stop() await self._app.stop() await self._app.shutdown() self._app = None - + @staticmethod def _get_media_type(path: str) -> str: """Guess media type from file extension.""" @@ -256,7 +255,7 @@ class TelegramChannel(BaseChannel): param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document" with open(media_path, 'rb') as f: await sender( - chat_id=chat_id, + chat_id=chat_id, **{param: f}, reply_parameters=reply_params ) @@ -275,8 +274,8 @@ class TelegramChannel(BaseChannel): try: html = _markdown_to_telegram_html(chunk) await self._app.bot.send_message( - chat_id=chat_id, - text=html, + chat_id=chat_id, + text=html, parse_mode="HTML", reply_parameters=reply_params ) @@ -284,13 +283,13 @@ class TelegramChannel(BaseChannel): logger.warning("HTML parse failed, falling back to plain text: {}", e) try: await self._app.bot.send_message( - chat_id=chat_id, + chat_id=chat_id, text=chunk, reply_parameters=reply_params ) except Exception as e2: logger.error("Error sending Telegram message: {}", e2) - + async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start command.""" if not update.message or not update.effective_user: @@ -329,34 +328,34 @@ class TelegramChannel(BaseChannel): chat_id=str(update.message.chat_id), content=update.message.text, ) - + async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming messages (text, photos, voice, documents).""" if not update.message or not update.effective_user: return - + message = update.message user = update.effective_user chat_id = message.chat_id sender_id = self._sender_id(user) - + # Store chat_id for replies self._chat_ids[sender_id] = chat_id - + # Build content from text and/or media content_parts = [] media_paths = [] - + # Text content if message.text: content_parts.append(message.text) if message.caption: content_parts.append(message.caption) - + # Handle media files media_file = None media_type = None - + if message.photo: media_file = message.photo[-1] # Largest photo media_type = "image" @@ -369,23 +368,23 @@ class TelegramChannel(BaseChannel): elif message.document: media_file = message.document media_type = "file" - + # Download media if present if media_file and self._app: try: file = await self._app.bot.get_file(media_file.file_id) ext = self._get_extension(media_type, getattr(media_file, 'mime_type', None)) - + # Save to workspace/media/ from pathlib import Path media_dir = Path.home() / ".nanobot" / "media" media_dir.mkdir(parents=True, exist_ok=True) - + file_path = media_dir / f"{media_file.file_id[:16]}{ext}" await file.download_to_drive(str(file_path)) - + media_paths.append(str(file_path)) - + # Handle voice transcription if media_type == "voice" or media_type == "audio": from nanobot.providers.transcription import GroqTranscriptionProvider @@ -398,16 +397,16 @@ class TelegramChannel(BaseChannel): content_parts.append(f"[{media_type}: {file_path}]") else: content_parts.append(f"[{media_type}: {file_path}]") - + logger.debug("Downloaded {} to {}", media_type, file_path) except Exception as e: logger.error("Failed to download media: {}", e) content_parts.append(f"[{media_type}: download failed]") - + content = "\n".join(content_parts) if content_parts else "[empty message]" - + logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) - + str_chat_id = str(chat_id) # Telegram media groups arrive as multiple messages sharing media_group_id. @@ -448,10 +447,10 @@ class TelegramChannel(BaseChannel): self._flush_media_group(group_key) ) return - + # Start typing indicator before processing self._start_typing(str_chat_id) - + # Forward to the message bus await self._handle_message( sender_id=sender_id, @@ -466,7 +465,7 @@ class TelegramChannel(BaseChannel): "is_group": message.chat.type != "private" } ) - + async def _flush_media_group(self, group_key: str, delay_s: float = 0.6) -> None: """Flush buffered Telegram media-group messages as one aggregated turn.""" try: @@ -509,13 +508,13 @@ class TelegramChannel(BaseChannel): # Cancel any existing typing task for this chat self._stop_typing(chat_id) self._typing_tasks[chat_id] = asyncio.create_task(self._typing_loop(chat_id)) - + def _stop_typing(self, chat_id: str) -> None: """Stop the typing indicator for a chat.""" task = self._typing_tasks.pop(chat_id, None) if task and not task.done(): task.cancel() - + async def _typing_loop(self, chat_id: str) -> None: """Repeatedly send 'typing' action until cancelled.""" try: @@ -526,7 +525,7 @@ class TelegramChannel(BaseChannel): pass except Exception as e: logger.debug("Typing indicator stopped for {}: {}", chat_id, e) - + async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """Log polling / handler errors instead of silently swallowing them.""" logger.error("Telegram error: {}", context.error) @@ -540,6 +539,6 @@ class TelegramChannel(BaseChannel): } if mime_type in ext_map: return ext_map[mime_type] - + type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "file": ""} return type_map.get(media_type, "") From 568a54ae3e8909003c37f41b96424300d73c8c2e Mon Sep 17 00:00:00 2001 From: Tanish Rajput Date: Thu, 26 Feb 2026 19:49:17 +0530 Subject: [PATCH 12/78] Initialize Matrix channel in ChannelManager when enabled in config --- nanobot/channels/manager.py | 12 ++++++++++++ nanobot/config/schema.py | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 77b7294..c8df6b2 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -136,6 +136,18 @@ class ChannelManager: logger.info("QQ channel enabled") except ImportError as e: logger.warning("QQ channel not available: {}", e) + + # Matrix channel + if self.config.channels.matrix.enabled: + try: + from nanobot.channels.matrix import MatrixChannel + self.channels["matrix"] = MatrixChannel( + self.config.channels.matrix, + self.bus, + ) + logger.info("Matrix channel enabled") + except ImportError as e: + logger.warning("Matrix channel not available: {}", e) async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 61aee96..cdc3b41 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -6,6 +6,7 @@ from typing import Literal from pydantic import BaseModel, Field, ConfigDict from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings +from typing import Literal class Base(BaseModel): @@ -183,6 +184,24 @@ class QQConfig(Base): secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) +class MatrixConfig(Base): + """Matrix (Element) channel configuration.""" + + enabled: bool = False + homeserver: str = "https://matrix.org" + access_token: str = "" + user_id: str = "" # @bot:matrix.org + device_id: str = "" + # Enable Matrix E2EE support (encryption + encrypted room handling). + e2ee_enabled: bool = True + # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. + sync_stop_grace_seconds: int = 2 + # Max attachment size accepted for Matrix media handling (inbound + outbound). + max_media_bytes: int = 20 * 1024 * 1024 + allow_from: list[str] = Field(default_factory=list) + group_policy: Literal["open", "mention", "allowlist"] = "open" + group_allow_from: list[str] = Field(default_factory=list) + allow_room_mentions: bool = False class ChannelsConfig(Base): """Configuration for chat channels.""" From aa2987be3eed79aa31cb4de9e49d7a751262d440 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 27 Feb 2026 09:30:01 +0000 Subject: [PATCH 13/78] refactor: streamline Telegram media-group buffering --- nanobot/channels/telegram.py | 95 ++++++++++-------------------------- 1 file changed, 27 insertions(+), 68 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index ed77963..969d853 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -127,7 +127,7 @@ class TelegramChannel(BaseChannel): self._app: Application | None = None self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task - self._media_group_buffers: dict[str, dict[str, object]] = {} + self._media_group_buffers: dict[str, dict] = {} self._media_group_tasks: dict[str, asyncio.Task] = {} async def start(self) -> None: @@ -194,11 +194,9 @@ class TelegramChannel(BaseChannel): for chat_id in list(self._typing_tasks): self._stop_typing(chat_id) - # Cancel buffered media-group flush tasks - for key, task in list(self._media_group_tasks.items()): - if task and not task.done(): - task.cancel() - self._media_group_tasks.pop(key, None) + for task in self._media_group_tasks.values(): + task.cancel() + self._media_group_tasks.clear() self._media_group_buffers.clear() if self._app: @@ -409,43 +407,26 @@ class TelegramChannel(BaseChannel): str_chat_id = str(chat_id) - # Telegram media groups arrive as multiple messages sharing media_group_id. - # Buffer briefly and forward as one aggregated turn. - media_group_id = getattr(message, "media_group_id", None) - if media_group_id: - group_key = f"{str_chat_id}:{media_group_id}" - buffer = self._media_group_buffers.get(group_key) - if not buffer: - buffer = { - "sender_id": sender_id, - "chat_id": str_chat_id, - "contents": [], - "media": [], + # Telegram media groups: buffer briefly, forward as one aggregated turn. + if media_group_id := getattr(message, "media_group_id", None): + key = f"{str_chat_id}:{media_group_id}" + if key not in self._media_group_buffers: + self._media_group_buffers[key] = { + "sender_id": sender_id, "chat_id": str_chat_id, + "contents": [], "media": [], "metadata": { - "message_id": message.message_id, - "user_id": user.id, - "username": user.username, - "first_name": user.first_name, + "message_id": message.message_id, "user_id": user.id, + "username": user.username, "first_name": user.first_name, "is_group": message.chat.type != "private", - "media_group_id": media_group_id, }, } - self._media_group_buffers[group_key] = buffer self._start_typing(str_chat_id) - + buf = self._media_group_buffers[key] if content and content != "[empty message]": - cast_contents = buffer["contents"] - if isinstance(cast_contents, list): - cast_contents.append(content) - cast_media = buffer["media"] - if isinstance(cast_media, list): - cast_media.extend(media_paths) - - # Start one delayed flush task per media group. - if group_key not in self._media_group_tasks: - self._media_group_tasks[group_key] = asyncio.create_task( - self._flush_media_group(group_key) - ) + buf["contents"].append(content) + buf["media"].extend(media_paths) + if key not in self._media_group_tasks: + self._media_group_tasks[key] = asyncio.create_task(self._flush_media_group(key)) return # Start typing indicator before processing @@ -466,42 +447,20 @@ class TelegramChannel(BaseChannel): } ) - async def _flush_media_group(self, group_key: str, delay_s: float = 0.6) -> None: - """Flush buffered Telegram media-group messages as one aggregated turn.""" + async def _flush_media_group(self, key: str) -> None: + """Wait briefly, then forward buffered media-group as one turn.""" try: - await asyncio.sleep(delay_s) - buffer = self._media_group_buffers.pop(group_key, None) - if not buffer: + await asyncio.sleep(0.6) + if not (buf := self._media_group_buffers.pop(key, None)): return - - sender_id = str(buffer.get("sender_id", "")) - chat_id = str(buffer.get("chat_id", "")) - contents = buffer.get("contents") - media = buffer.get("media") - metadata = buffer.get("metadata") - - content_parts = [c for c in (contents if isinstance(contents, list) else []) if isinstance(c, str) and c] - media_paths = [m for m in (media if isinstance(media, list) else []) if isinstance(m, str) and m] - - # De-duplicate while preserving order - seen = set() - unique_media: list[str] = [] - for m in media_paths: - if m in seen: - continue - seen.add(m) - unique_media.append(m) - - content = "\n".join(content_parts) if content_parts else "[empty message]" + content = "\n".join(buf["contents"]) or "[empty message]" await self._handle_message( - sender_id=sender_id, - chat_id=chat_id, - content=content, - media=unique_media, - metadata=metadata if isinstance(metadata, dict) else {}, + sender_id=buf["sender_id"], chat_id=buf["chat_id"], + content=content, media=list(dict.fromkeys(buf["media"])), + metadata=buf["metadata"], ) finally: - self._media_group_tasks.pop(group_key, None) + self._media_group_tasks.pop(key, None) def _start_typing(self, chat_id: str) -> None: """Start sending 'typing...' indicator for a chat.""" From d5808bf586c09a16ea6ba5ba5d48674d7466a38c Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 27 Feb 2026 09:46:57 +0000 Subject: [PATCH 14/78] refactor: streamline workspace template sync --- nanobot/cli/commands.py | 7 +--- nanobot/utils/helpers.py | 70 ++++++++++++---------------------------- 2 files changed, 21 insertions(+), 56 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 9dee105..fc4c261 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -20,6 +20,7 @@ from prompt_toolkit.patch_stdout import patch_stdout from nanobot import __version__, __logo__ from nanobot.config.schema import Config +from nanobot.utils.helpers import sync_workspace_templates app = typer.Typer( name="nanobot", @@ -185,8 +186,6 @@ def onboard(): workspace.mkdir(parents=True, exist_ok=True) console.print(f"[green]✓[/green] Created workspace at {workspace}") - # Create default bootstrap files - from nanobot.utils.helpers import sync_workspace_templates sync_workspace_templates(workspace) console.print(f"\n{__logo__} nanobot is ready!") @@ -265,7 +264,6 @@ def gateway( console.print(f"{__logo__} Starting nanobot gateway on port {port}...") config = load_config() - from nanobot.utils.helpers import sync_workspace_templates sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) @@ -420,7 +418,6 @@ def agent( from loguru import logger config = load_config() - from nanobot.utils.helpers import sync_workspace_templates sync_workspace_templates(config.workspace_path) bus = MessageBus() @@ -983,8 +980,6 @@ def status(): config_path = get_config_path() config = load_config() workspace = config.workspace_path - from nanobot.utils.helpers import sync_workspace_templates - sync_workspace_templates(workspace) console.print(f"{__logo__} nanobot Status\n") diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 83653ac..8963138 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -79,64 +79,34 @@ def parse_session_key(key: str) -> tuple[str, str]: return parts[0], parts[1] def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]: - """ - Synchronize default workspace template files from bundled templates. - Only creates files that do not exist. Returns list of added file names. - """ + """Sync bundled templates to workspace. Only creates missing files.""" from importlib.resources import files as pkg_files - from rich.console import Console - console = Console() - added = [] - try: - templates_dir = pkg_files("nanobot") / "templates" + tpl = pkg_files("nanobot") / "templates" except Exception: - # Fallback for some environments where pkg_files might fail + return [] + if not tpl.is_dir(): return [] - if not templates_dir.is_dir(): - return [] + added: list[str] = [] - # 1. Sync root templates - for item in templates_dir.iterdir(): - if not item.name.endswith(".md"): - continue - dest = workspace / item.name - if not dest.exists(): - try: - dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8") - added.append(item.name) - except Exception: - pass + def _write(src, dest: Path): + """Write src content (or empty string if None) to dest if missing.""" + if dest.exists(): + return + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(src.read_text(encoding="utf-8") if src else "", encoding="utf-8") + added.append(str(dest.relative_to(workspace))) - # 2. Sync memory templates - memory_dir = workspace / "memory" - memory_dir.mkdir(exist_ok=True) - - memory_src = templates_dir / "memory" / "MEMORY.md" - memory_dest = memory_dir / "MEMORY.md" - if memory_src.is_file() and not memory_dest.exists(): - try: - memory_dest.write_text(memory_src.read_text(encoding="utf-8"), encoding="utf-8") - added.append("memory/MEMORY.md") - except Exception: - pass - - # 3. History file (always ensure it exists) - history_file = memory_dir / "HISTORY.md" - if not history_file.exists(): - try: - history_file.write_text("", encoding="utf-8") - added.append("memory/HISTORY.md") - except Exception: - pass - - # 4. Ensure skills dir exists + for item in tpl.iterdir(): + if item.name.endswith(".md"): + _write(item, workspace / item.name) + _write(tpl / "memory" / "MEMORY.md", workspace / "memory" / "MEMORY.md") + _write(None, workspace / "memory" / "HISTORY.md") (workspace / "skills").mkdir(exist_ok=True) - # Print notices if files were added if added and not silent: + from rich.console import Console for name in added: - console.print(f" [dim]Created {name}[/dim]") - - return added \ No newline at end of file + Console().print(f" [dim]Created {name}[/dim]") + return added From 858a62dd9bda42696ad07a7e6453608ca9ece34d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 27 Feb 2026 09:50:12 +0000 Subject: [PATCH 15/78] =?UTF-8?q?refactor:=20slim=20down=20helpers.py=20?= =?UTF-8?q?=E2=80=94=20remove=20dead=20code,=20compress=20docstrings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- nanobot/utils/helpers.py | 65 +++++++--------------------------------- 2 files changed, 11 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 71922fb..251181b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,932 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,922 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 8963138..8322bc8 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -1,83 +1,39 @@ """Utility functions for nanobot.""" +import re from pathlib import Path from datetime import datetime + def ensure_dir(path: Path) -> Path: - """Ensure a directory exists, creating it if necessary.""" + """Ensure directory exists, return it.""" path.mkdir(parents=True, exist_ok=True) return path def get_data_path() -> Path: - """Get the nanobot data directory (~/.nanobot).""" + """~/.nanobot data directory.""" return ensure_dir(Path.home() / ".nanobot") def get_workspace_path(workspace: str | None = None) -> Path: - """ - Get the workspace path. - - Args: - workspace: Optional workspace path. Defaults to ~/.nanobot/workspace. - - Returns: - Expanded and ensured workspace path. - """ - if workspace: - path = Path(workspace).expanduser() - else: - path = Path.home() / ".nanobot" / "workspace" + """Resolve and ensure workspace path. Defaults to ~/.nanobot/workspace.""" + path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace" return ensure_dir(path) -def get_sessions_path() -> Path: - """Get the sessions storage directory.""" - return ensure_dir(get_data_path() / "sessions") - - -def get_skills_path(workspace: Path | None = None) -> Path: - """Get the skills directory within the workspace.""" - ws = workspace or get_workspace_path() - return ensure_dir(ws / "skills") - - def timestamp() -> str: - """Get current timestamp in ISO format.""" + """Current ISO timestamp.""" return datetime.now().isoformat() -def truncate_string(s: str, max_len: int = 100, suffix: str = "...") -> str: - """Truncate a string to max length, adding suffix if truncated.""" - if len(s) <= max_len: - return s - return s[: max_len - len(suffix)] + suffix - +_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]') def safe_filename(name: str) -> str: - """Convert a string to a safe filename.""" - # Replace unsafe characters - unsafe = '<>:"/\\|?*' - for char in unsafe: - name = name.replace(char, "_") - return name.strip() + """Replace unsafe path characters with underscores.""" + return _UNSAFE_CHARS.sub("_", name).strip() -def parse_session_key(key: str) -> tuple[str, str]: - """ - Parse a session key into channel and chat_id. - - Args: - key: Session key in format "channel:chat_id" - - Returns: - Tuple of (channel, chat_id) - """ - parts = key.split(":", 1) - if len(parts) != 2: - raise ValueError(f"Invalid session key: {key}") - return parts[0], parts[1] - def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]: """Sync bundled templates to workspace. Only creates missing files.""" from importlib.resources import files as pkg_files @@ -91,7 +47,6 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str] added: list[str] = [] def _write(src, dest: Path): - """Write src content (or empty string if None) to dest if missing.""" if dest.exists(): return dest.parent.mkdir(parents=True, exist_ok=True) From 12f3365103c4aa33d5acaea01dfd30e66a6866e2 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 27 Feb 2026 09:53:31 +0000 Subject: [PATCH 16/78] fix: remove duplicate import, tidy MatrixConfig comments --- nanobot/config/schema.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 4889783..1ff9782 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -6,7 +6,6 @@ from typing import Literal from pydantic import BaseModel, Field, ConfigDict from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings -from typing import Literal class Base(BaseModel): @@ -187,18 +186,14 @@ class QQConfig(Base): class MatrixConfig(Base): """Matrix (Element) channel configuration.""" - enabled: bool = False homeserver: str = "https://matrix.org" access_token: str = "" - user_id: str = "" # @bot:matrix.org + user_id: str = "" # e.g. @bot:matrix.org device_id: str = "" - # Enable Matrix E2EE support (encryption + encrypted room handling). - e2ee_enabled: bool = True - # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. - sync_stop_grace_seconds: int = 2 - # Max attachment size accepted for Matrix media handling (inbound + outbound). - max_media_bytes: int = 20 * 1024 * 1024 + e2ee_enabled: bool = True # end-to-end encryption support + sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout + max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) From bc558d0592c144b38a0f8b18d8c8270d2addca60 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 27 Feb 2026 10:07:22 +0000 Subject: [PATCH 17/78] refactor: merge user-role branches in _save_turn --- nanobot/agent/loop.py | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 69c2916..6fe37e9 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -444,24 +444,19 @@ class AgentLoop: from datetime import datetime for m in messages[skip:]: entry = {k: v for k, v in m.items() if k != "reasoning_content"} - if entry.get("role") == "tool" and isinstance(entry.get("content"), str): - content = entry["content"] - if len(content) > self._TOOL_RESULT_MAX_CHARS: - entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)" - if ( - entry.get("role") == "user" - and isinstance(entry.get("content"), str) - and entry["content"].startswith(ContextBuilder._RUNTIME_CONTEXT_TAG) - ): - continue - if entry.get("role") == "user" and isinstance(entry.get("content"), list): - entry["content"] = [ - {"type": "text", "text": "[image]"} if ( - c.get("type") == "image_url" - and c.get("image_url", {}).get("url", "").startswith("data:image/") - ) else c - for c in entry["content"] - ] + role, content = entry.get("role"), entry.get("content") + 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": + if isinstance(content, str) and content.startswith(ContextBuilder._RUNTIME_CONTEXT_TAG): + continue + if isinstance(content, list): + entry["content"] = [ + {"type": "text", "text": "[image]"} if ( + c.get("type") == "image_url" + and c.get("image_url", {}).get("url", "").startswith("data:image/") + ) else c for c in content + ] entry.setdefault("timestamp", datetime.now().isoformat()) session.messages.append(entry) session.updated_at = datetime.now() From db4185c8b7f8a80084cf1e8cfb397b60ce409ed9 Mon Sep 17 00:00:00 2001 From: aiguozhi123456 Date: Fri, 27 Feb 2026 11:11:42 +0000 Subject: [PATCH 18/78] 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 19/78] 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 20/78] 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 21/78] 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 22/78] 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 23/78] 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 24/78] 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 25/78] 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 26/78] 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 27/78] 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 28/78] 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 29/78] 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 c0ad98650414aa22ad9d3364ded069e94e5f1479 Mon Sep 17 00:00:00 2001 From: spartan077 Date: Sat, 28 Feb 2026 13:44:22 +0530 Subject: [PATCH 30/78] fix: add message deduplication to WhatsApp channel Prevent infinite loops by tracking processed message IDs in WhatsApp channel. The bridge may send duplicate messages which caused the bot to respond repeatedly with the same generic message. Changes: - Add _processed_message_ids deque (max 2000) to track seen messages - Skip processing if message_id was already processed - Align WhatsApp dedup with other channels (Feishu, Email, Mochat, QQ) This fixes the issue where WhatsApp gets stuck in a loop sending identical responses repeatedly. Co-Authored-By: Claude Sonnet 4.6 --- nanobot/channels/whatsapp.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index f5fb521..b171b6c 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -2,6 +2,7 @@ import asyncio import json +from collections import deque from typing import Any from loguru import logger @@ -15,18 +16,20 @@ from nanobot.config.schema import WhatsAppConfig class WhatsAppChannel(BaseChannel): """ WhatsApp channel that connects to a Node.js bridge. - + The bridge uses @whiskeysockets/baileys to handle the WhatsApp Web protocol. Communication between Python and Node.js is via WebSocket. """ - + name = "whatsapp" - + MAX_PROCESSED_MESSAGE_IDS = 2000 + def __init__(self, config: WhatsAppConfig, bus: MessageBus): super().__init__(config, bus) self.config: WhatsAppConfig = config self._ws = None self._connected = False + self._processed_message_ids: deque[str] = deque(maxlen=self.MAX_PROCESSED_MESSAGE_IDS) async def start(self) -> None: """Start the WhatsApp channel by connecting to the bridge.""" @@ -105,26 +108,35 @@ class WhatsAppChannel(BaseChannel): # Incoming message from WhatsApp # Deprecated by whatsapp: old phone number style typically: @s.whatspp.net pn = data.get("pn", "") - # New LID sytle typically: + # New LID sytle typically: sender = data.get("sender", "") content = data.get("content", "") - + message_id = data.get("id", "") + + # Dedup by message ID to prevent loops + if message_id and message_id in self._processed_message_ids: + logger.debug("Duplicate message {}, skipping", message_id) + return + + if message_id: + self._processed_message_ids.append(message_id) + # Extract just the phone number or lid as chat_id user_id = pn if pn else sender sender_id = user_id.split("@")[0] if "@" in user_id else user_id logger.info("Sender {}", sender) - + # Handle voice transcription if it's a voice message if content == "[Voice Message]": logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id) content = "[Voice Message: Transcription not available for WhatsApp yet]" - + await self._handle_message( sender_id=sender_id, chat_id=sender, # Use full LID for replies content=content, metadata={ - "message_id": data.get("id"), + "message_id": message_id, "timestamp": data.get("timestamp"), "is_group": data.get("isGroup", False) } From 8410f859f734372f3a97cac413f847dc297b588d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 08:26:55 +0000 Subject: [PATCH 31/78] =?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 == [] From 95ffe47e343b6411aa7a20b32cbfaf8aa369f749 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 08:38:29 +0000 Subject: [PATCH 32/78] refactor: use OrderedDict for WhatsApp dedup, consistent with Feishu --- nanobot/channels/whatsapp.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index b171b6c..49d2390 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -2,7 +2,7 @@ import asyncio import json -from collections import deque +from collections import OrderedDict from typing import Any from loguru import logger @@ -22,14 +22,13 @@ class WhatsAppChannel(BaseChannel): """ name = "whatsapp" - MAX_PROCESSED_MESSAGE_IDS = 2000 def __init__(self, config: WhatsAppConfig, bus: MessageBus): super().__init__(config, bus) self.config: WhatsAppConfig = config self._ws = None self._connected = False - self._processed_message_ids: deque[str] = deque(maxlen=self.MAX_PROCESSED_MESSAGE_IDS) + self._processed_message_ids: OrderedDict[str, None] = OrderedDict() async def start(self) -> None: """Start the WhatsApp channel by connecting to the bridge.""" @@ -113,13 +112,12 @@ class WhatsAppChannel(BaseChannel): content = data.get("content", "") message_id = data.get("id", "") - # Dedup by message ID to prevent loops - if message_id and message_id in self._processed_message_ids: - logger.debug("Duplicate message {}, skipping", message_id) - return - if message_id: - self._processed_message_ids.append(message_id) + if message_id in self._processed_message_ids: + return + self._processed_message_ids[message_id] = None + while len(self._processed_message_ids) > 1000: + self._processed_message_ids.popitem(last=False) # Extract just the phone number or lid as chat_id user_id = pn if pn else sender From 52222a9f8475b64879d50f8925587206a3ffc774 Mon Sep 17 00:00:00 2001 From: fengxiaohu <975326527@qq.com> Date: Sat, 28 Feb 2026 18:46:15 +0800 Subject: [PATCH 33/78] fix(providers): allow reasoning_content in message history for thinking models --- nanobot/providers/litellm_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 7402a2b..03a6c4d 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -13,7 +13,7 @@ from nanobot.providers.registry import find_by_model, find_gateway # Standard OpenAI chat-completion message keys; extras (e.g. reasoning_content) are stripped for strict providers. -_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"}) +_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"}) class LiteLLMProvider(LLMProvider): From cfc55d626afa86a9bdf4c120d1ad8882a063244c Mon Sep 17 00:00:00 2001 From: "siyuan.qsy" Date: Sat, 28 Feb 2026 19:00:22 +0800 Subject: [PATCH 34/78] feat(dingtalk): send images as image messages, keep files as attachments --- nanobot/channels/dingtalk.py | 290 +++++++++++++++++++++++++++++++---- 1 file changed, 263 insertions(+), 27 deletions(-) diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 09c7714..53a9bb8 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -2,8 +2,12 @@ import asyncio import json +import mimetypes +import os import time +from pathlib import Path from typing import Any +from urllib.parse import unquote, urlparse from loguru import logger import httpx @@ -96,6 +100,9 @@ class DingTalkChannel(BaseChannel): """ name = "dingtalk" + _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"} + _AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac"} + _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"} def __init__(self, config: DingTalkConfig, bus: MessageBus): super().__init__(config, bus) @@ -191,40 +198,269 @@ class DingTalkChannel(BaseChannel): logger.error("Failed to get DingTalk access token: {}", e) return None + @staticmethod + def _is_http_url(value: str) -> bool: + low = value.lower() + return low.startswith("http://") or low.startswith("https://") + + def _guess_upload_type(self, media_ref: str) -> str: + parsed = urlparse(media_ref) + path = parsed.path if parsed.scheme else media_ref + ext = Path(path).suffix.lower() + if ext in self._IMAGE_EXTS: + return "image" + if ext in self._AUDIO_EXTS: + return "voice" + if ext in self._VIDEO_EXTS: + return "video" + return "file" + + def _guess_filename(self, media_ref: str, upload_type: str) -> str: + parsed = urlparse(media_ref) + path = parsed.path if parsed.scheme else media_ref + name = os.path.basename(path) + if name: + return name + fallback = { + "image": "image.jpg", + "voice": "audio.amr", + "video": "video.mp4", + "file": "file.bin", + } + return fallback.get(upload_type, "file.bin") + + async def _read_media_bytes( + self, + media_ref: str, + ) -> tuple[bytes | None, str | None, str | None]: + if not media_ref: + return None, None, None + + if self._is_http_url(media_ref): + if not self._http: + return None, None, None + try: + resp = await self._http.get(media_ref, follow_redirects=True) + if resp.status_code >= 400: + logger.warning( + "DingTalk media download failed status={} ref={}", + resp.status_code, + media_ref, + ) + return None, None, None + content_type = (resp.headers.get("content-type") or "").split(";")[0].strip() + filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref)) + return resp.content, filename, content_type or None + except Exception as e: + logger.error("DingTalk media download error ref={} err={}", media_ref, e) + return None, None, None + + try: + if media_ref.startswith("file://"): + parsed = urlparse(media_ref) + local_path = Path(unquote(parsed.path)) + else: + local_path = Path(os.path.expanduser(media_ref)) + if not local_path.is_file(): + logger.warning("DingTalk media file not found: {}", local_path) + return None, None, None + data = await asyncio.to_thread(local_path.read_bytes) + content_type = mimetypes.guess_type(local_path.name)[0] + return data, local_path.name, content_type + except Exception as e: + logger.error("DingTalk media read error ref={} err={}", media_ref, e) + return None, None, None + + async def _upload_media( + self, + token: str, + data: bytes, + media_type: str, + filename: str, + content_type: str | None, + ) -> str | None: + if not self._http: + return None + url = f"https://oapi.dingtalk.com/media/upload?access_token={token}&type={media_type}" + mime = content_type or mimetypes.guess_type(filename)[0] or "application/octet-stream" + files = {"media": (filename, data, mime)} + + try: + resp = await self._http.post(url, files=files) + text = resp.text + try: + result = resp.json() + except Exception: + result = {} + if resp.status_code >= 400: + logger.error( + "DingTalk media upload failed status={} type={} body={}", + resp.status_code, + media_type, + text[:500], + ) + return None + errcode = result.get("errcode", 0) + if errcode != 0: + logger.error( + "DingTalk media upload api error type={} errcode={} body={}", + media_type, + errcode, + text[:500], + ) + return None + media_id = ( + result.get("media_id") + or result.get("mediaId") + or (result.get("result") or {}).get("media_id") + or (result.get("result") or {}).get("mediaId") + ) + if not media_id: + logger.error("DingTalk media upload missing media_id body={}", text[:500]) + return None + return str(media_id) + except Exception as e: + logger.error("DingTalk media upload error type={} err={}", media_type, e) + return None + + async def _send_batch_message( + self, + token: str, + chat_id: str, + msg_key: str, + msg_param: dict[str, Any], + ) -> bool: + if not self._http: + logger.warning("DingTalk HTTP client not initialized, cannot send") + return False + + url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" + headers = {"x-acs-dingtalk-access-token": token} + payload = { + "robotCode": self.config.client_id, + "userIds": [chat_id], + "msgKey": msg_key, + "msgParam": json.dumps(msg_param, ensure_ascii=False), + } + + try: + resp = await self._http.post(url, json=payload, headers=headers) + body = resp.text + if resp.status_code != 200: + logger.error( + "DingTalk send failed msgKey={} status={} body={}", + msg_key, + resp.status_code, + body[:500], + ) + return False + try: + result = resp.json() + except Exception: + result = {} + errcode = result.get("errcode") + if errcode not in (None, 0): + logger.error( + "DingTalk send api error msgKey={} errcode={} body={}", + msg_key, + errcode, + body[:500], + ) + return False + logger.debug("DingTalk message sent to {} with msgKey={}", chat_id, msg_key) + return True + except Exception as e: + logger.error("Error sending DingTalk message msgKey={} err={}", msg_key, e) + return False + + async def _send_markdown_text(self, token: str, chat_id: str, content: str) -> bool: + return await self._send_batch_message( + token, + chat_id, + "sampleMarkdown", + {"text": content, "title": "Nanobot Reply"}, + ) + + async def _send_media_ref(self, token: str, chat_id: str, media_ref: str) -> bool: + media_ref = (media_ref or "").strip() + if not media_ref: + return True + + upload_type = self._guess_upload_type(media_ref) + if upload_type == "image" and self._is_http_url(media_ref): + ok = await self._send_batch_message( + token, + chat_id, + "sampleImageMsg", + {"photoURL": media_ref}, + ) + if ok: + return True + logger.warning("DingTalk image url send failed, trying upload fallback: {}", media_ref) + + data, filename, content_type = await self._read_media_bytes(media_ref) + if not data: + logger.error("DingTalk media read failed: {}", media_ref) + return False + + filename = filename or self._guess_filename(media_ref, upload_type) + file_type = Path(filename).suffix.lower().lstrip(".") + if not file_type: + guessed = mimetypes.guess_extension(content_type or "") + file_type = (guessed or ".bin").lstrip(".") + if file_type == "jpeg": + file_type = "jpg" + + media_id = await self._upload_media( + token=token, + data=data, + media_type=upload_type, + filename=filename, + content_type=content_type, + ) + if not media_id: + return False + + if upload_type == "image": + # Verified in production: sampleImageMsg accepts media_id in photoURL. + ok = await self._send_batch_message( + token, + chat_id, + "sampleImageMsg", + {"photoURL": media_id}, + ) + if ok: + return True + logger.warning("DingTalk image media_id send failed, falling back to file: {}", media_ref) + + return await self._send_batch_message( + token, + chat_id, + "sampleFile", + {"mediaId": media_id, "fileName": filename, "fileType": file_type}, + ) + async def send(self, msg: OutboundMessage) -> None: """Send a message through DingTalk.""" token = await self._get_access_token() if not token: return - # oToMessages/batchSend: sends to individual users (private chat) - # https://open.dingtalk.com/document/orgapp/robot-batch-send-messages - url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" + if msg.content and msg.content.strip(): + await self._send_markdown_text(token, msg.chat_id, msg.content.strip()) - headers = {"x-acs-dingtalk-access-token": token} - - data = { - "robotCode": self.config.client_id, - "userIds": [msg.chat_id], # chat_id is the user's staffId - "msgKey": "sampleMarkdown", - "msgParam": json.dumps({ - "text": msg.content, - "title": "Nanobot Reply", - }, ensure_ascii=False), - } - - if not self._http: - logger.warning("DingTalk HTTP client not initialized, cannot send") - return - - try: - resp = await self._http.post(url, json=data, headers=headers) - if resp.status_code != 200: - logger.error("DingTalk send failed: {}", resp.text) - else: - logger.debug("DingTalk message sent to {}", msg.chat_id) - except Exception as e: - logger.error("Error sending DingTalk message: {}", e) + for media_ref in msg.media or []: + ok = await self._send_media_ref(token, msg.chat_id, media_ref) + if ok: + continue + logger.error("DingTalk media send failed for {}", media_ref) + # Send visible fallback so failures are observable by the user. + filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref)) + await self._send_markdown_text( + token, + msg.chat_id, + f"[Attachment send failed: {filename}]", + ) async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None: """Handle incoming message (called by NanobotDingTalkHandler). From 977ca725f261bfb665ecc5ec11493865c3c795ed Mon Sep 17 00:00:00 2001 From: JK_Lu <6513279+jk_lu@user.noreply.gitee.com> Date: Sat, 28 Feb 2026 20:55:43 +0800 Subject: [PATCH 35/78] style: unify code formatting and import order - Remove trailing whitespace and normalize blank lines - Unify string quotes and line breaks for long lines - Sort imports alphabetically across modules --- nanobot/agent/__init__.py | 2 +- nanobot/agent/context.py | 26 ++-- nanobot/agent/skills.py | 78 +++++------ nanobot/agent/subagent.py | 51 +++---- nanobot/agent/tools/base.py | 28 ++-- nanobot/agent/tools/cron.py | 48 +++---- nanobot/agent/tools/filesystem.py | 93 +++++-------- nanobot/agent/tools/registry.py | 22 +-- nanobot/agent/tools/shell.py | 8 +- nanobot/agent/tools/spawn.py | 14 +- nanobot/agent/tools/web.py | 26 ++-- nanobot/bus/events.py | 6 +- nanobot/channels/base.py | 40 +++--- nanobot/channels/dingtalk.py | 8 +- nanobot/channels/discord.py | 1 - nanobot/channels/feishu.py | 85 ++++++------ nanobot/channels/manager.py | 46 +++--- nanobot/channels/matrix.py | 20 ++- nanobot/channels/slack.py | 3 +- nanobot/channels/telegram.py | 133 +++++++++--------- nanobot/channels/whatsapp.py | 39 +++--- nanobot/cli/commands.py | 154 +++++++++++---------- nanobot/config/__init__.py | 2 +- nanobot/config/schema.py | 2 +- nanobot/cron/service.py | 89 ++++++------ nanobot/providers/base.py | 8 +- nanobot/providers/litellm_provider.py | 60 ++++---- nanobot/providers/openai_codex_provider.py | 2 +- nanobot/providers/transcription.py | 21 ++- nanobot/session/__init__.py | 2 +- nanobot/session/manager.py | 34 ++--- nanobot/utils/__init__.py | 2 +- nanobot/utils/helpers.py | 2 +- 33 files changed, 574 insertions(+), 581 deletions(-) diff --git a/nanobot/agent/__init__.py b/nanobot/agent/__init__.py index c3fc97b..f9ba8b8 100644 --- a/nanobot/agent/__init__.py +++ b/nanobot/agent/__init__.py @@ -1,7 +1,7 @@ """Agent core module.""" -from nanobot.agent.loop import AgentLoop from nanobot.agent.context import ContextBuilder +from nanobot.agent.loop import AgentLoop from nanobot.agent.memory import MemoryStore from nanobot.agent.skills import SkillsLoader diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index be0ec59..a39ee75 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -14,15 +14,15 @@ from nanobot.agent.skills import SkillsLoader class ContextBuilder: """Builds the context (system prompt + messages) for the agent.""" - + BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"] _RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]" - + def __init__(self, workspace: Path): self.workspace = workspace self.memory = MemoryStore(workspace) self.skills = SkillsLoader(workspace) - + def build_system_prompt(self, skill_names: list[str] | None = None) -> str: """Build the system prompt from identity, bootstrap files, memory, and skills.""" parts = [self._get_identity()] @@ -51,13 +51,13 @@ Skills with available="false" need dependencies installed first - you can try in {skills_summary}""") return "\n\n---\n\n".join(parts) - + def _get_identity(self) -> str: """Get the core identity section.""" workspace_path = str(self.workspace.expanduser().resolve()) system = platform.system() runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" - + return f"""# nanobot 🐈 You are nanobot, a helpful AI assistant. @@ -89,19 +89,19 @@ Reply directly with text for conversations. Only use the 'message' tool to send if channel and chat_id: lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"] return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) - + def _load_bootstrap_files(self) -> str: """Load all bootstrap files from workspace.""" parts = [] - + for filename in self.BOOTSTRAP_FILES: file_path = self.workspace / filename if file_path.exists(): content = file_path.read_text(encoding="utf-8") parts.append(f"## {filename}\n\n{content}") - + return "\n\n".join(parts) if parts else "" - + def build_messages( self, history: list[dict[str, Any]], @@ -123,7 +123,7 @@ Reply directly with text for conversations. Only use the 'message' tool to send """Build user message content with optional base64-encoded images.""" if not media: return text - + images = [] for path in media: p = Path(path) @@ -132,11 +132,11 @@ Reply directly with text for conversations. Only use the 'message' tool to send continue b64 = base64.b64encode(p.read_bytes()).decode() images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}) - + if not images: return text return images + [{"type": "text", "text": text}] - + def add_tool_result( self, messages: list[dict[str, Any]], tool_call_id: str, tool_name: str, result: str, @@ -144,7 +144,7 @@ Reply directly with text for conversations. Only use the 'message' tool to send """Add a tool result to the message list.""" messages.append({"role": "tool", "tool_call_id": tool_call_id, "name": tool_name, "content": result}) return messages - + def add_assistant_message( self, messages: list[dict[str, Any]], content: str | None, diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index 5b841f3..9afee82 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -13,28 +13,28 @@ BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills" class SkillsLoader: """ Loader for agent skills. - + Skills are markdown files (SKILL.md) that teach the agent how to use specific tools or perform certain tasks. """ - + def __init__(self, workspace: Path, builtin_skills_dir: Path | None = None): self.workspace = workspace self.workspace_skills = workspace / "skills" self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR - + def list_skills(self, filter_unavailable: bool = True) -> list[dict[str, str]]: """ List all available skills. - + Args: filter_unavailable: If True, filter out skills with unmet requirements. - + Returns: List of skill info dicts with 'name', 'path', 'source'. """ skills = [] - + # Workspace skills (highest priority) if self.workspace_skills.exists(): for skill_dir in self.workspace_skills.iterdir(): @@ -42,7 +42,7 @@ class SkillsLoader: skill_file = skill_dir / "SKILL.md" if skill_file.exists(): skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "workspace"}) - + # Built-in skills if self.builtin_skills and self.builtin_skills.exists(): for skill_dir in self.builtin_skills.iterdir(): @@ -50,19 +50,19 @@ class SkillsLoader: skill_file = skill_dir / "SKILL.md" if skill_file.exists() and not any(s["name"] == skill_dir.name for s in skills): skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "builtin"}) - + # Filter by requirements if filter_unavailable: return [s for s in skills if self._check_requirements(self._get_skill_meta(s["name"]))] return skills - + def load_skill(self, name: str) -> str | None: """ Load a skill by name. - + Args: name: Skill name (directory name). - + Returns: Skill content or None if not found. """ @@ -70,22 +70,22 @@ class SkillsLoader: workspace_skill = self.workspace_skills / name / "SKILL.md" if workspace_skill.exists(): return workspace_skill.read_text(encoding="utf-8") - + # Check built-in if self.builtin_skills: builtin_skill = self.builtin_skills / name / "SKILL.md" if builtin_skill.exists(): return builtin_skill.read_text(encoding="utf-8") - + return None - + def load_skills_for_context(self, skill_names: list[str]) -> str: """ Load specific skills for inclusion in agent context. - + Args: skill_names: List of skill names to load. - + Returns: Formatted skills content. """ @@ -95,26 +95,26 @@ class SkillsLoader: if content: content = self._strip_frontmatter(content) parts.append(f"### Skill: {name}\n\n{content}") - + return "\n\n---\n\n".join(parts) if parts else "" - + def build_skills_summary(self) -> str: """ Build a summary of all skills (name, description, path, availability). - + This is used for progressive loading - the agent can read the full skill content using read_file when needed. - + Returns: XML-formatted skills summary. """ all_skills = self.list_skills(filter_unavailable=False) if not all_skills: return "" - + def escape_xml(s: str) -> str: return s.replace("&", "&").replace("<", "<").replace(">", ">") - + lines = [""] for s in all_skills: name = escape_xml(s["name"]) @@ -122,23 +122,23 @@ class SkillsLoader: desc = escape_xml(self._get_skill_description(s["name"])) skill_meta = self._get_skill_meta(s["name"]) available = self._check_requirements(skill_meta) - + lines.append(f" ") lines.append(f" {name}") lines.append(f" {desc}") lines.append(f" {path}") - + # Show missing requirements for unavailable skills if not available: missing = self._get_missing_requirements(skill_meta) if missing: lines.append(f" {escape_xml(missing)}") - - lines.append(f" ") + + lines.append(" ") lines.append("") - + return "\n".join(lines) - + def _get_missing_requirements(self, skill_meta: dict) -> str: """Get a description of missing requirements.""" missing = [] @@ -150,14 +150,14 @@ class SkillsLoader: if not os.environ.get(env): missing.append(f"ENV: {env}") return ", ".join(missing) - + def _get_skill_description(self, name: str) -> str: """Get the description of a skill from its frontmatter.""" meta = self.get_skill_metadata(name) if meta and meta.get("description"): return meta["description"] return name # Fallback to skill name - + def _strip_frontmatter(self, content: str) -> str: """Remove YAML frontmatter from markdown content.""" if content.startswith("---"): @@ -165,7 +165,7 @@ class SkillsLoader: if match: return content[match.end():].strip() return content - + def _parse_nanobot_metadata(self, raw: str) -> dict: """Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys).""" try: @@ -173,7 +173,7 @@ class SkillsLoader: return data.get("nanobot", data.get("openclaw", {})) if isinstance(data, dict) else {} except (json.JSONDecodeError, TypeError): return {} - + def _check_requirements(self, skill_meta: dict) -> bool: """Check if skill requirements are met (bins, env vars).""" requires = skill_meta.get("requires", {}) @@ -184,12 +184,12 @@ class SkillsLoader: if not os.environ.get(env): return False return True - + def _get_skill_meta(self, name: str) -> dict: """Get nanobot metadata for a skill (cached in frontmatter).""" meta = self.get_skill_metadata(name) or {} return self._parse_nanobot_metadata(meta.get("metadata", "")) - + def get_always_skills(self) -> list[str]: """Get skills marked as always=true that meet requirements.""" result = [] @@ -199,21 +199,21 @@ class SkillsLoader: if skill_meta.get("always") or meta.get("always"): result.append(s["name"]) return result - + def get_skill_metadata(self, name: str) -> dict | None: """ Get metadata from a skill's frontmatter. - + Args: name: Skill name. - + Returns: Metadata dict or None. """ content = self.load_skill(name) if not content: return None - + if content.startswith("---"): match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) if match: @@ -224,5 +224,5 @@ class SkillsLoader: key, value = line.split(":", 1) metadata[key.strip()] = value.strip().strip('"\'') return metadata - + return None diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 337796c..5aff25c 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -8,18 +8,19 @@ from typing import Any from loguru import logger +from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.agent.tools.shell import ExecTool +from nanobot.agent.tools.web import WebFetchTool, WebSearchTool from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus +from nanobot.config.schema import ExecToolConfig from nanobot.providers.base import LLMProvider -from nanobot.agent.tools.registry import ToolRegistry -from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool -from nanobot.agent.tools.shell import ExecTool -from nanobot.agent.tools.web import WebSearchTool, WebFetchTool class SubagentManager: """Manages background subagent execution.""" - + def __init__( self, provider: LLMProvider, @@ -44,7 +45,7 @@ class SubagentManager: self.restrict_to_workspace = restrict_to_workspace self._running_tasks: dict[str, asyncio.Task[None]] = {} self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...} - + async def spawn( self, task: str, @@ -73,10 +74,10 @@ class SubagentManager: del self._session_tasks[session_key] bg_task.add_done_callback(_cleanup) - + logger.info("Spawned subagent [{}]: {}", task_id, display_label) return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes." - + async def _run_subagent( self, task_id: str, @@ -86,7 +87,7 @@ class SubagentManager: ) -> None: """Execute the subagent task and announce the result.""" logger.info("Subagent [{}] starting task: {}", task_id, label) - + try: # Build subagent tools (no message tool, no spawn tool) tools = ToolRegistry() @@ -103,22 +104,22 @@ class SubagentManager: )) tools.register(WebSearchTool(api_key=self.brave_api_key)) tools.register(WebFetchTool()) - + # Build messages with subagent-specific prompt system_prompt = self._build_subagent_prompt(task) messages: list[dict[str, Any]] = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": task}, ] - + # Run agent loop (limited iterations) max_iterations = 15 iteration = 0 final_result: str | None = None - + while iteration < max_iterations: iteration += 1 - + response = await self.provider.chat( messages=messages, tools=tools.get_definitions(), @@ -126,7 +127,7 @@ class SubagentManager: temperature=self.temperature, max_tokens=self.max_tokens, ) - + if response.has_tool_calls: # Add assistant message with tool calls tool_call_dicts = [ @@ -145,7 +146,7 @@ class SubagentManager: "content": response.content or "", "tool_calls": tool_call_dicts, }) - + # Execute tools for tool_call in response.tool_calls: args_str = json.dumps(tool_call.arguments, ensure_ascii=False) @@ -160,18 +161,18 @@ class SubagentManager: else: final_result = response.content break - + if final_result is None: final_result = "Task completed but no final response was generated." - + logger.info("Subagent [{}] completed successfully", task_id) await self._announce_result(task_id, label, task, final_result, origin, "ok") - + except Exception as e: error_msg = f"Error: {str(e)}" logger.error("Subagent [{}] failed: {}", task_id, e) await self._announce_result(task_id, label, task, error_msg, origin, "error") - + async def _announce_result( self, task_id: str, @@ -183,7 +184,7 @@ class SubagentManager: ) -> None: """Announce the subagent result to the main agent via the message bus.""" status_text = "completed successfully" if status == "ok" else "failed" - + announce_content = f"""[Subagent '{label}' {status_text}] Task: {task} @@ -192,7 +193,7 @@ Result: {result} Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs.""" - + # Inject as system message to trigger main agent msg = InboundMessage( channel="system", @@ -200,14 +201,14 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men chat_id=f"{origin['channel']}:{origin['chat_id']}", content=announce_content, ) - + await self.bus.publish_inbound(msg) logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id']) - + def _build_subagent_prompt(self, task: str) -> str: """Build a focused system prompt for the subagent.""" - from datetime import datetime import time as _time + from datetime import datetime now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") tz = _time.strftime("%Z") or "UTC" @@ -240,7 +241,7 @@ Your workspace is at: {self.workspace} Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed) When you have completed the task, provide a clear summary of your findings or actions.""" - + async def cancel_by_session(self, session_key: str) -> int: """Cancel all subagents for the given session. Returns count cancelled.""" tasks = [self._running_tasks[tid] for tid in self._session_tasks.get(session_key, []) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index ca9bcc2..8dd82c7 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -7,11 +7,11 @@ from typing import Any class Tool(ABC): """ Abstract base class for agent tools. - + Tools are capabilities that the agent can use to interact with the environment, such as reading files, executing commands, etc. """ - + _TYPE_MAP = { "string": str, "integer": int, @@ -20,33 +20,33 @@ class Tool(ABC): "array": list, "object": dict, } - + @property @abstractmethod def name(self) -> str: """Tool name used in function calls.""" pass - + @property @abstractmethod def description(self) -> str: """Description of what the tool does.""" pass - + @property @abstractmethod def parameters(self) -> dict[str, Any]: """JSON Schema for tool parameters.""" pass - + @abstractmethod async def execute(self, **kwargs: Any) -> str: """ Execute the tool with given parameters. - + Args: **kwargs: Tool-specific parameters. - + Returns: String result of the tool execution. """ @@ -63,7 +63,7 @@ class Tool(ABC): t, label = schema.get("type"), path or "parameter" if t in self._TYPE_MAP and not isinstance(val, self._TYPE_MAP[t]): return [f"{label} should be {t}"] - + errors = [] if "enum" in schema and val not in schema["enum"]: errors.append(f"{label} must be one of {schema['enum']}") @@ -84,12 +84,14 @@ class Tool(ABC): errors.append(f"missing required {path + '.' + k if path else k}") for k, v in val.items(): if k in props: - errors.extend(self._validate(v, props[k], path + '.' + k if path else k)) + errors.extend(self._validate(v, props[k], path + "." + k if path else k)) if t == "array" and "items" in schema: for i, item in enumerate(val): - errors.extend(self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]")) + errors.extend( + self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]") + ) return errors - + def to_schema(self) -> dict[str, Any]: """Convert tool to OpenAI function schema format.""" return { @@ -98,5 +100,5 @@ class Tool(ABC): "name": self.name, "description": self.description, "parameters": self.parameters, - } + }, } diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index b10e34b..fe1dce6 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -9,25 +9,25 @@ from nanobot.cron.types import CronSchedule class CronTool(Tool): """Tool to schedule reminders and recurring tasks.""" - + def __init__(self, cron_service: CronService): self._cron = cron_service self._channel = "" self._chat_id = "" - + def set_context(self, channel: str, chat_id: str) -> None: """Set the current session context for delivery.""" self._channel = channel self._chat_id = chat_id - + @property def name(self) -> str: return "cron" - + @property def description(self) -> str: return "Schedule reminders and recurring tasks. Actions: add, list, remove." - + @property def parameters(self) -> dict[str, Any]: return { @@ -36,36 +36,30 @@ class CronTool(Tool): "action": { "type": "string", "enum": ["add", "list", "remove"], - "description": "Action to perform" - }, - "message": { - "type": "string", - "description": "Reminder message (for add)" + "description": "Action to perform", }, + "message": {"type": "string", "description": "Reminder message (for add)"}, "every_seconds": { "type": "integer", - "description": "Interval in seconds (for recurring tasks)" + "description": "Interval in seconds (for recurring tasks)", }, "cron_expr": { "type": "string", - "description": "Cron expression like '0 9 * * *' (for scheduled tasks)" + "description": "Cron expression like '0 9 * * *' (for scheduled tasks)", }, "tz": { "type": "string", - "description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')" + "description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')", }, "at": { "type": "string", - "description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')" + "description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')", }, - "job_id": { - "type": "string", - "description": "Job ID (for remove)" - } + "job_id": {"type": "string", "description": "Job ID (for remove)"}, }, - "required": ["action"] + "required": ["action"], } - + async def execute( self, action: str, @@ -75,7 +69,7 @@ class CronTool(Tool): tz: str | None = None, at: str | None = None, job_id: str | None = None, - **kwargs: Any + **kwargs: Any, ) -> str: if action == "add": return self._add_job(message, every_seconds, cron_expr, tz, at) @@ -84,7 +78,7 @@ class CronTool(Tool): elif action == "remove": return self._remove_job(job_id) return f"Unknown action: {action}" - + def _add_job( self, message: str, @@ -101,11 +95,12 @@ class CronTool(Tool): return "Error: tz can only be used with cron_expr" if tz: from zoneinfo import ZoneInfo + try: ZoneInfo(tz) except (KeyError, Exception): return f"Error: unknown timezone '{tz}'" - + # Build schedule delete_after = False if every_seconds: @@ -114,13 +109,14 @@ class CronTool(Tool): schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz) elif at: from datetime import datetime + dt = datetime.fromisoformat(at) at_ms = int(dt.timestamp() * 1000) schedule = CronSchedule(kind="at", at_ms=at_ms) delete_after = True else: return "Error: either every_seconds, cron_expr, or at is required" - + job = self._cron.add_job( name=message[:30], schedule=schedule, @@ -131,14 +127,14 @@ class CronTool(Tool): delete_after_run=delete_after, ) return f"Created job '{job.name}' (id: {job.id})" - + def _list_jobs(self) -> str: jobs = self._cron.list_jobs() if not jobs: return "No scheduled jobs." lines = [f"- {j.name} (id: {j.id}, {j.schedule.kind})" for j in jobs] return "Scheduled jobs:\n" + "\n".join(lines) - + def _remove_job(self, job_id: str | None) -> str: if not job_id: return "Error: job_id is required for remove" diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index b87da11..bbdd49c 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -7,7 +7,9 @@ from typing import Any from nanobot.agent.tools.base import Tool -def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path | None = None) -> Path: +def _resolve_path( + path: str, workspace: Path | None = None, allowed_dir: Path | None = None +) -> Path: """Resolve path against workspace (if relative) and enforce directory restriction.""" p = Path(path).expanduser() if not p.is_absolute() and workspace: @@ -31,24 +33,19 @@ class ReadFileTool(Tool): @property def name(self) -> str: return "read_file" - + @property def description(self) -> str: return "Read the contents of a file at the given path." - + @property def parameters(self) -> dict[str, Any]: return { "type": "object", - "properties": { - "path": { - "type": "string", - "description": "The file path to read" - } - }, - "required": ["path"] + "properties": {"path": {"type": "string", "description": "The file path to read"}}, + "required": ["path"], } - + async def execute(self, path: str, **kwargs: Any) -> str: try: file_path = _resolve_path(path, self._workspace, self._allowed_dir) @@ -75,28 +72,22 @@ class WriteFileTool(Tool): @property def name(self) -> str: return "write_file" - + @property def description(self) -> str: return "Write content to a file at the given path. Creates parent directories if needed." - + @property def parameters(self) -> dict[str, Any]: return { "type": "object", "properties": { - "path": { - "type": "string", - "description": "The file path to write to" - }, - "content": { - "type": "string", - "description": "The content to write" - } + "path": {"type": "string", "description": "The file path to write to"}, + "content": {"type": "string", "description": "The content to write"}, }, - "required": ["path", "content"] + "required": ["path", "content"], } - + async def execute(self, path: str, content: str, **kwargs: Any) -> str: try: file_path = _resolve_path(path, self._workspace, self._allowed_dir) @@ -119,32 +110,23 @@ class EditFileTool(Tool): @property def name(self) -> str: return "edit_file" - + @property def description(self) -> str: return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file." - + @property def parameters(self) -> dict[str, Any]: return { "type": "object", "properties": { - "path": { - "type": "string", - "description": "The file path to edit" - }, - "old_text": { - "type": "string", - "description": "The exact text to find and replace" - }, - "new_text": { - "type": "string", - "description": "The text to replace with" - } + "path": {"type": "string", "description": "The file path to edit"}, + "old_text": {"type": "string", "description": "The exact text to find and replace"}, + "new_text": {"type": "string", "description": "The text to replace with"}, }, - "required": ["path", "old_text", "new_text"] + "required": ["path", "old_text", "new_text"], } - + async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str: try: file_path = _resolve_path(path, self._workspace, self._allowed_dir) @@ -184,13 +166,19 @@ class EditFileTool(Tool): best_ratio, best_start = ratio, i if best_ratio > 0.5: - diff = "\n".join(difflib.unified_diff( - old_lines, lines[best_start : best_start + window], - fromfile="old_text (provided)", tofile=f"{path} (actual, line {best_start + 1})", - lineterm="", - )) + diff = "\n".join( + difflib.unified_diff( + old_lines, + lines[best_start : best_start + window], + fromfile="old_text (provided)", + tofile=f"{path} (actual, line {best_start + 1})", + lineterm="", + ) + ) return f"Error: old_text not found in {path}.\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}" - return f"Error: old_text not found in {path}. No similar text found. Verify the file content." + return ( + f"Error: old_text not found in {path}. No similar text found. Verify the file content." + ) class ListDirTool(Tool): @@ -203,24 +191,19 @@ class ListDirTool(Tool): @property def name(self) -> str: return "list_dir" - + @property def description(self) -> str: return "List the contents of a directory." - + @property def parameters(self) -> dict[str, Any]: return { "type": "object", - "properties": { - "path": { - "type": "string", - "description": "The directory path to list" - } - }, - "required": ["path"] + "properties": {"path": {"type": "string", "description": "The directory path to list"}}, + "required": ["path"], } - + async def execute(self, path: str, **kwargs: Any) -> str: try: dir_path = _resolve_path(path, self._workspace, self._allowed_dir) diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index 3af4aef..5d36e52 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -8,33 +8,33 @@ from nanobot.agent.tools.base import Tool class ToolRegistry: """ Registry for agent tools. - + Allows dynamic registration and execution of tools. """ - + def __init__(self): self._tools: dict[str, Tool] = {} - + def register(self, tool: Tool) -> None: """Register a tool.""" self._tools[tool.name] = tool - + def unregister(self, name: str) -> None: """Unregister a tool by name.""" self._tools.pop(name, None) - + def get(self, name: str) -> Tool | None: """Get a tool by name.""" return self._tools.get(name) - + def has(self, name: str) -> bool: """Check if a tool is registered.""" return name in self._tools - + def get_definitions(self) -> list[dict[str, Any]]: """Get all tool definitions in OpenAI format.""" return [tool.to_schema() for tool in self._tools.values()] - + async def execute(self, name: str, params: dict[str, Any]) -> str: """Execute a tool by name with given parameters.""" _HINT = "\n\n[Analyze the error above and try a different approach.]" @@ -53,14 +53,14 @@ class ToolRegistry: return result except Exception as e: return f"Error executing {name}: {str(e)}" + _HINT - + @property def tool_names(self) -> list[str]: """Get list of registered tool names.""" return list(self._tools.keys()) - + def __len__(self) -> int: return len(self._tools) - + def __contains__(self, name: str) -> bool: return name in self._tools diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 6b57874..ce19920 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -11,7 +11,7 @@ from nanobot.agent.tools.base import Tool class ExecTool(Tool): """Tool to execute shell commands.""" - + def __init__( self, timeout: int = 60, @@ -37,15 +37,15 @@ class ExecTool(Tool): self.allow_patterns = allow_patterns or [] self.restrict_to_workspace = restrict_to_workspace self.path_append = path_append - + @property def name(self) -> str: return "exec" - + @property def description(self) -> str: return "Execute a shell command and return its output. Use with caution." - + @property def parameters(self) -> dict[str, Any]: return { diff --git a/nanobot/agent/tools/spawn.py b/nanobot/agent/tools/spawn.py index fb816ca..fc62bf8 100644 --- a/nanobot/agent/tools/spawn.py +++ b/nanobot/agent/tools/spawn.py @@ -1,6 +1,6 @@ """Spawn tool for creating background subagents.""" -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from nanobot.agent.tools.base import Tool @@ -10,23 +10,23 @@ if TYPE_CHECKING: class SpawnTool(Tool): """Tool to spawn a subagent for background task execution.""" - + def __init__(self, manager: "SubagentManager"): self._manager = manager self._origin_channel = "cli" self._origin_chat_id = "direct" self._session_key = "cli:direct" - + def set_context(self, channel: str, chat_id: str) -> None: """Set the origin context for subagent announcements.""" self._origin_channel = channel self._origin_chat_id = chat_id self._session_key = f"{channel}:{chat_id}" - + @property def name(self) -> str: return "spawn" - + @property def description(self) -> str: return ( @@ -34,7 +34,7 @@ class SpawnTool(Tool): "Use this for complex or time-consuming tasks that can run independently. " "The subagent will complete the task and report back when done." ) - + @property def parameters(self) -> dict[str, Any]: return { @@ -51,7 +51,7 @@ class SpawnTool(Tool): }, "required": ["task"], } - + async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str: """Spawn a subagent to execute the given task.""" return await self._manager.spawn( diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 7860f12..e817a4c 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -45,7 +45,7 @@ def _validate_url(url: str) -> tuple[bool, str]: class WebSearchTool(Tool): """Search the web using Brave Search API.""" - + name = "web_search" description = "Search the web. Returns titles, URLs, and snippets." parameters = { @@ -56,7 +56,7 @@ class WebSearchTool(Tool): }, "required": ["query"] } - + def __init__(self, api_key: str | None = None, max_results: int = 5): self._init_api_key = api_key self.max_results = max_results @@ -73,7 +73,7 @@ class WebSearchTool(Tool): "Set it in ~/.nanobot/config.json under tools.web.search.apiKey " "(or export BRAVE_API_KEY), then restart the gateway." ) - + try: n = min(max(count or self.max_results, 1), 10) async with httpx.AsyncClient() as client: @@ -84,11 +84,11 @@ class WebSearchTool(Tool): timeout=10.0 ) r.raise_for_status() - + results = r.json().get("web", {}).get("results", []) if not results: return f"No results for: {query}" - + lines = [f"Results for: {query}\n"] for i, item in enumerate(results[:n], 1): lines.append(f"{i}. {item.get('title', '')}\n {item.get('url', '')}") @@ -101,7 +101,7 @@ class WebSearchTool(Tool): class WebFetchTool(Tool): """Fetch and extract content from a URL using Readability.""" - + name = "web_fetch" description = "Fetch URL and extract readable content (HTML → markdown/text)." parameters = { @@ -113,10 +113,10 @@ class WebFetchTool(Tool): }, "required": ["url"] } - + def __init__(self, max_chars: int = 50000): self.max_chars = max_chars - + async def execute(self, url: str, extractMode: str = "markdown", maxChars: int | None = None, **kwargs: Any) -> str: from readability import Document @@ -135,9 +135,9 @@ class WebFetchTool(Tool): ) as client: r = await client.get(url, headers={"User-Agent": USER_AGENT}) r.raise_for_status() - + ctype = r.headers.get("content-type", "") - + # JSON if "application/json" in ctype: text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json" @@ -149,16 +149,16 @@ class WebFetchTool(Tool): extractor = "readability" else: text, extractor = r.text, "raw" - + truncated = len(text) > max_chars if truncated: text = text[:max_chars] - + return json.dumps({"url": url, "finalUrl": str(r.url), "status": r.status_code, "extractor": extractor, "truncated": truncated, "length": len(text), "text": text}, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e), "url": url}, ensure_ascii=False) - + def _to_markdown(self, html: str) -> str: """Convert HTML to markdown.""" # Convert links, headings, lists before stripping tags diff --git a/nanobot/bus/events.py b/nanobot/bus/events.py index a48660d..018c25b 100644 --- a/nanobot/bus/events.py +++ b/nanobot/bus/events.py @@ -8,7 +8,7 @@ from typing import Any @dataclass class InboundMessage: """Message received from a chat channel.""" - + channel: str # telegram, discord, slack, whatsapp sender_id: str # User identifier chat_id: str # Chat/channel identifier @@ -17,7 +17,7 @@ class InboundMessage: media: list[str] = field(default_factory=list) # Media URLs metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data session_key_override: str | None = None # Optional override for thread-scoped sessions - + @property def session_key(self) -> str: """Unique key for session identification.""" @@ -27,7 +27,7 @@ class InboundMessage: @dataclass class OutboundMessage: """Message to send to a chat channel.""" - + channel: str chat_id: str content: str diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 3010373..f795931 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -12,17 +12,17 @@ from nanobot.bus.queue import MessageBus class BaseChannel(ABC): """ Abstract base class for chat channel implementations. - + Each channel (Telegram, Discord, etc.) should implement this interface to integrate with the nanobot message bus. """ - + name: str = "base" - + def __init__(self, config: Any, bus: MessageBus): """ Initialize the channel. - + Args: config: Channel-specific configuration. bus: The message bus for communication. @@ -30,50 +30,50 @@ class BaseChannel(ABC): self.config = config self.bus = bus self._running = False - + @abstractmethod async def start(self) -> None: """ Start the channel and begin listening for messages. - + This should be a long-running async task that: 1. Connects to the chat platform 2. Listens for incoming messages 3. Forwards messages to the bus via _handle_message() """ pass - + @abstractmethod async def stop(self) -> None: """Stop the channel and clean up resources.""" pass - + @abstractmethod async def send(self, msg: OutboundMessage) -> None: """ Send a message through this channel. - + Args: msg: The message to send. """ pass - + def is_allowed(self, sender_id: str) -> bool: """ Check if a sender is allowed to use this bot. - + Args: sender_id: The sender's identifier. - + Returns: True if allowed, False otherwise. """ allow_list = getattr(self.config, "allow_from", []) - + # If no allow list, allow everyone if not allow_list: return True - + sender_str = str(sender_id) if sender_str in allow_list: return True @@ -82,7 +82,7 @@ class BaseChannel(ABC): if part and part in allow_list: return True return False - + async def _handle_message( self, sender_id: str, @@ -94,9 +94,9 @@ class BaseChannel(ABC): ) -> None: """ Handle an incoming message from the chat platform. - + This method checks permissions and forwards to the bus. - + Args: sender_id: The sender's identifier. chat_id: The chat/channel identifier. @@ -112,7 +112,7 @@ class BaseChannel(ABC): sender_id, self.name, ) return - + msg = InboundMessage( channel=self.name, sender_id=str(sender_id), @@ -122,9 +122,9 @@ class BaseChannel(ABC): metadata=metadata or {}, session_key_override=session_key, ) - + await self.bus.publish_inbound(msg) - + @property def is_running(self) -> bool: """Check if the channel is running.""" diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 09c7714..371c45b 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -5,8 +5,8 @@ import json import time from typing import Any -from loguru import logger import httpx +from loguru import logger from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus @@ -15,11 +15,11 @@ from nanobot.config.schema import DingTalkConfig try: from dingtalk_stream import ( - DingTalkStreamClient, - Credential, + AckMessage, CallbackHandler, CallbackMessage, - AckMessage, + Credential, + DingTalkStreamClient, ) from dingtalk_stream.chatbot import ChatbotMessage diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index b9227fb..57e5922 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -14,7 +14,6 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import DiscordConfig - DISCORD_API_BASE = "https://discord.com/api/v10" MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB MAX_MESSAGE_LEN = 2000 # Discord message character limit diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 6703f21..9911d08 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -23,12 +23,11 @@ try: CreateFileRequestBody, CreateImageRequest, CreateImageRequestBody, - CreateMessageRequest, - CreateMessageRequestBody, CreateMessageReactionRequest, CreateMessageReactionRequestBody, + CreateMessageRequest, + CreateMessageRequestBody, Emoji, - GetFileRequest, GetMessageResourceRequest, P2ImMessageReceiveV1, ) @@ -70,7 +69,7 @@ def _extract_share_card_content(content_json: dict, msg_type: str) -> str: def _extract_interactive_content(content: dict) -> list[str]: """Recursively extract text and links from interactive card content.""" parts = [] - + if isinstance(content, str): try: content = json.loads(content) @@ -104,19 +103,19 @@ def _extract_interactive_content(content: dict) -> list[str]: header_text = header_title.get("content", "") or header_title.get("text", "") if header_text: parts.append(f"title: {header_text}") - + return parts def _extract_element_content(element: dict) -> list[str]: """Extract content from a single card element.""" parts = [] - + if not isinstance(element, dict): return parts - + tag = element.get("tag", "") - + if tag in ("markdown", "lark_md"): content = element.get("content", "") if content: @@ -177,17 +176,17 @@ def _extract_element_content(element: dict) -> list[str]: else: for ne in element.get("elements", []): parts.extend(_extract_element_content(ne)) - + return parts def _extract_post_content(content_json: dict) -> tuple[str, list[str]]: """Extract text and image keys from Feishu post (rich text) message content. - + Supports two formats: 1. Direct format: {"title": "...", "content": [...]} 2. Localized format: {"zh_cn": {"title": "...", "content": [...]}} - + Returns: (text, image_keys) - extracted text and list of image keys """ @@ -220,26 +219,26 @@ def _extract_post_content(content_json: dict) -> tuple[str, list[str]]: image_keys.append(img_key) text = " ".join(text_parts).strip() if text_parts else None return text, image_keys - + # Try direct format first if "content" in content_json: text, images = extract_from_lang(content_json) if text or images: return text or "", images - + # Try localized format for lang_key in ("zh_cn", "en_us", "ja_jp"): lang_content = content_json.get(lang_key) text, images = extract_from_lang(lang_content) if text or images: return text or "", images - + return "", [] def _extract_post_text(content_json: dict) -> str: """Extract plain text from Feishu post (rich text) message content. - + Legacy wrapper for _extract_post_content, returns only text. """ text, _ = _extract_post_content(content_json) @@ -249,17 +248,17 @@ def _extract_post_text(content_json: dict) -> str: class FeishuChannel(BaseChannel): """ Feishu/Lark channel using WebSocket long connection. - + Uses WebSocket to receive events - no public IP or webhook required. - + Requires: - App ID and App Secret from Feishu Open Platform - Bot capability enabled - Event subscription enabled (im.message.receive_v1) """ - + name = "feishu" - + def __init__(self, config: FeishuConfig, bus: MessageBus): super().__init__(config, bus) self.config: FeishuConfig = config @@ -268,27 +267,27 @@ class FeishuChannel(BaseChannel): self._ws_thread: threading.Thread | None = None self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache self._loop: asyncio.AbstractEventLoop | None = None - + async def start(self) -> None: """Start the Feishu bot with WebSocket long connection.""" if not FEISHU_AVAILABLE: logger.error("Feishu SDK not installed. Run: pip install lark-oapi") return - + if not self.config.app_id or not self.config.app_secret: logger.error("Feishu app_id and app_secret not configured") return - + self._running = True self._loop = asyncio.get_running_loop() - + # Create Lark client for sending messages self._client = lark.Client.builder() \ .app_id(self.config.app_id) \ .app_secret(self.config.app_secret) \ .log_level(lark.LogLevel.INFO) \ .build() - + # Create event handler (only register message receive, ignore other events) event_handler = lark.EventDispatcherHandler.builder( self.config.encrypt_key or "", @@ -296,7 +295,7 @@ class FeishuChannel(BaseChannel): ).register_p2_im_message_receive_v1( self._on_message_sync ).build() - + # Create WebSocket client for long connection self._ws_client = lark.ws.Client( self.config.app_id, @@ -304,7 +303,7 @@ class FeishuChannel(BaseChannel): event_handler=event_handler, log_level=lark.LogLevel.INFO ) - + # Start WebSocket client in a separate thread with reconnect loop def run_ws(): while self._running: @@ -313,18 +312,19 @@ class FeishuChannel(BaseChannel): except Exception as e: logger.warning("Feishu WebSocket error: {}", e) if self._running: - import time; time.sleep(5) - + import time + time.sleep(5) + self._ws_thread = threading.Thread(target=run_ws, daemon=True) self._ws_thread.start() - + logger.info("Feishu bot started with WebSocket long connection") logger.info("No public IP required - using WebSocket to receive events") - + # Keep running until stopped while self._running: await asyncio.sleep(1) - + async def stop(self) -> None: """Stop the Feishu bot.""" self._running = False @@ -334,7 +334,7 @@ class FeishuChannel(BaseChannel): except Exception as e: logger.warning("Error stopping WebSocket client: {}", e) logger.info("Feishu bot stopped") - + def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: """Sync helper for adding reaction (runs in thread pool).""" try: @@ -345,9 +345,9 @@ class FeishuChannel(BaseChannel): .reaction_type(Emoji.builder().emoji_type(emoji_type).build()) .build() ).build() - + response = self._client.im.v1.message_reaction.create(request) - + if not response.success(): logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg) else: @@ -358,15 +358,15 @@ class FeishuChannel(BaseChannel): async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None: """ Add a reaction emoji to a message (non-blocking). - + Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART """ if not self._client or not Emoji: return - + loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._add_reaction_sync, message_id, emoji_type) - + # Regex to match markdown tables (header + separator + data rows) _TABLE_RE = re.compile( r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)", @@ -380,12 +380,13 @@ class FeishuChannel(BaseChannel): @staticmethod def _parse_md_table(table_text: str) -> dict | None: """Parse a markdown table into a Feishu table element.""" - lines = [l.strip() for l in table_text.strip().split("\n") if l.strip()] + lines = [_line.strip() for _line in table_text.strip().split("\n") if _line.strip()] if len(lines) < 3: return None - split = lambda l: [c.strip() for c in l.strip("|").split("|")] + def split(_line: str) -> list[str]: + return [c.strip() for c in _line.strip("|").split("|")] headers = split(lines[0]) - rows = [split(l) for l in lines[2:]] + rows = [split(_line) for _line in lines[2:]] columns = [{"tag": "column", "name": f"c{i}", "display_name": h, "width": "auto"} for i, h in enumerate(headers)] return { @@ -657,7 +658,7 @@ class FeishuChannel(BaseChannel): except Exception as e: logger.error("Error sending Feishu message: {}", e) - + def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None: """ Sync handler for incoming messages (called from WebSocket thread). @@ -665,7 +666,7 @@ class FeishuChannel(BaseChannel): """ if self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe(self._on_message(data), self._loop) - + async def _on_message(self, data: "P2ImMessageReceiveV1") -> None: """Handle incoming message from Feishu.""" try: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index c8df6b2..4b40d0e 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -16,24 +16,24 @@ from nanobot.config.schema import Config class ChannelManager: """ Manages chat channels and coordinates message routing. - + Responsibilities: - Initialize enabled channels (Telegram, WhatsApp, etc.) - Start/stop channels - Route outbound messages """ - + def __init__(self, config: Config, bus: MessageBus): self.config = config self.bus = bus self.channels: dict[str, BaseChannel] = {} self._dispatch_task: asyncio.Task | None = None - + self._init_channels() - + def _init_channels(self) -> None: """Initialize channels based on config.""" - + # Telegram channel if self.config.channels.telegram.enabled: try: @@ -46,7 +46,7 @@ class ChannelManager: logger.info("Telegram channel enabled") except ImportError as e: logger.warning("Telegram channel not available: {}", e) - + # WhatsApp channel if self.config.channels.whatsapp.enabled: try: @@ -68,7 +68,7 @@ class ChannelManager: logger.info("Discord channel enabled") except ImportError as e: logger.warning("Discord channel not available: {}", e) - + # Feishu channel if self.config.channels.feishu.enabled: try: @@ -136,7 +136,7 @@ class ChannelManager: logger.info("QQ channel enabled") except ImportError as e: logger.warning("QQ channel not available: {}", e) - + # Matrix channel if self.config.channels.matrix.enabled: try: @@ -148,7 +148,7 @@ class ChannelManager: logger.info("Matrix channel enabled") except ImportError as e: logger.warning("Matrix channel not available: {}", e) - + async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" try: @@ -161,23 +161,23 @@ class ChannelManager: if not self.channels: logger.warning("No channels enabled") return - + # Start outbound dispatcher self._dispatch_task = asyncio.create_task(self._dispatch_outbound()) - + # Start channels tasks = [] for name, channel in self.channels.items(): logger.info("Starting {} channel...", name) tasks.append(asyncio.create_task(self._start_channel(name, channel))) - + # Wait for all to complete (they should run forever) await asyncio.gather(*tasks, return_exceptions=True) - + async def stop_all(self) -> None: """Stop all channels and the dispatcher.""" logger.info("Stopping all channels...") - + # Stop dispatcher if self._dispatch_task: self._dispatch_task.cancel() @@ -185,7 +185,7 @@ class ChannelManager: await self._dispatch_task except asyncio.CancelledError: pass - + # Stop all channels for name, channel in self.channels.items(): try: @@ -193,24 +193,24 @@ class ChannelManager: logger.info("Stopped {} channel", name) except Exception as e: logger.error("Error stopping {}: {}", name, e) - + async def _dispatch_outbound(self) -> None: """Dispatch outbound messages to the appropriate channel.""" logger.info("Outbound dispatcher started") - + while True: try: msg = await asyncio.wait_for( self.bus.consume_outbound(), timeout=1.0 ) - + if msg.metadata.get("_progress"): if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints: continue if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress: continue - + channel = self.channels.get(msg.channel) if channel: try: @@ -219,16 +219,16 @@ class ChannelManager: logger.error("Error sending to {}: {}", msg.channel, e) else: logger.warning("Unknown channel: {}", msg.channel) - + except asyncio.TimeoutError: continue except asyncio.CancelledError: break - + def get_channel(self, name: str) -> BaseChannel | None: """Get a channel by name.""" return self.channels.get(name) - + def get_status(self) -> dict[str, Any]: """Get status of all channels.""" return { @@ -238,7 +238,7 @@ class ChannelManager: } for name, channel in self.channels.items() } - + @property def enabled_channels(self) -> list[str]: """Get list of enabled channel names.""" diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 21192e9..43fc573 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -12,10 +12,22 @@ try: import nh3 from mistune import create_markdown from nio import ( - AsyncClient, AsyncClientConfig, ContentRepositoryConfigError, - DownloadError, InviteEvent, JoinError, MatrixRoom, MemoryDownloadResponse, - RoomEncryptedMedia, RoomMessage, RoomMessageMedia, RoomMessageText, - RoomSendError, RoomTypingError, SyncError, UploadError, + AsyncClient, + AsyncClientConfig, + ContentRepositoryConfigError, + DownloadError, + InviteEvent, + JoinError, + MatrixRoom, + MemoryDownloadResponse, + RoomEncryptedMedia, + RoomMessage, + RoomMessageMedia, + RoomMessageText, + RoomSendError, + RoomTypingError, + SyncError, + UploadError, ) from nio.crypto.attachments import decrypt_attachment from nio.exceptions import EncryptionError diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 57bfbcb..afd1d2d 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -5,11 +5,10 @@ import re from typing import Any from loguru import logger -from slack_sdk.socket_mode.websockets import SocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.response import SocketModeResponse +from slack_sdk.socket_mode.websockets import SocketModeClient from slack_sdk.web.async_client import AsyncWebClient - from slackify_markdown import slackify_markdown from nanobot.bus.events import OutboundMessage diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 969d853..c290535 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -4,9 +4,10 @@ from __future__ import annotations import asyncio import re + from loguru import logger -from telegram import BotCommand, Update, ReplyParameters -from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes +from telegram import BotCommand, ReplyParameters, Update +from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage @@ -21,60 +22,60 @@ def _markdown_to_telegram_html(text: str) -> str: """ if not text: return "" - + # 1. Extract and protect code blocks (preserve content from other processing) code_blocks: list[str] = [] def save_code_block(m: re.Match) -> str: code_blocks.append(m.group(1)) return f"\x00CB{len(code_blocks) - 1}\x00" - + text = re.sub(r'```[\w]*\n?([\s\S]*?)```', save_code_block, text) - + # 2. Extract and protect inline code inline_codes: list[str] = [] def save_inline_code(m: re.Match) -> str: inline_codes.append(m.group(1)) return f"\x00IC{len(inline_codes) - 1}\x00" - + text = re.sub(r'`([^`]+)`', save_inline_code, text) - + # 3. Headers # Title -> just the title text text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE) - + # 4. Blockquotes > text -> just the text (before HTML escaping) text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE) - + # 5. Escape HTML special characters text = text.replace("&", "&").replace("<", "<").replace(">", ">") - + # 6. Links [text](url) - must be before bold/italic to handle nested cases text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', text) - + # 7. Bold **text** or __text__ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) text = re.sub(r'__(.+?)__', r'\1', text) - + # 8. Italic _text_ (avoid matching inside words like some_var_name) text = re.sub(r'(?\1', text) - + # 9. Strikethrough ~~text~~ text = re.sub(r'~~(.+?)~~', r'\1', text) - + # 10. Bullet lists - item -> • item text = re.sub(r'^[-*]\s+', '• ', text, flags=re.MULTILINE) - + # 11. Restore inline code with HTML tags for i, code in enumerate(inline_codes): # Escape HTML in code content escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace(f"\x00IC{i}\x00", f"{escaped}") - + # 12. Restore code blocks with HTML tags for i, code in enumerate(code_blocks): # Escape HTML in code content escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace(f"\x00CB{i}\x00", f"
{escaped}
") - + return text @@ -101,12 +102,12 @@ def _split_message(content: str, max_len: int = 4000) -> list[str]: class TelegramChannel(BaseChannel): """ Telegram channel using long polling. - + Simple and reliable - no webhook/public IP needed. """ - + name = "telegram" - + # Commands registered with Telegram's command menu BOT_COMMANDS = [ BotCommand("start", "Start the bot"), @@ -114,7 +115,7 @@ class TelegramChannel(BaseChannel): BotCommand("stop", "Stop the current task"), BotCommand("help", "Show available commands"), ] - + def __init__( self, config: TelegramConfig, @@ -129,15 +130,15 @@ class TelegramChannel(BaseChannel): self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task self._media_group_buffers: dict[str, dict] = {} self._media_group_tasks: dict[str, asyncio.Task] = {} - + async def start(self) -> None: """Start the Telegram bot with long polling.""" if not self.config.token: logger.error("Telegram bot token not configured") return - + self._running = True - + # Build the application with larger connection pool to avoid pool-timeout on long runs req = HTTPXRequest(connection_pool_size=16, pool_timeout=5.0, connect_timeout=30.0, read_timeout=30.0) builder = Application.builder().token(self.config.token).request(req).get_updates_request(req) @@ -145,51 +146,51 @@ class TelegramChannel(BaseChannel): builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) self._app = builder.build() self._app.add_error_handler(self._on_error) - + # Add command handlers self._app.add_handler(CommandHandler("start", self._on_start)) self._app.add_handler(CommandHandler("new", self._forward_command)) self._app.add_handler(CommandHandler("help", self._on_help)) - + # Add message handler for text, photos, voice, documents self._app.add_handler( MessageHandler( - (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL) - & ~filters.COMMAND, + (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL) + & ~filters.COMMAND, self._on_message ) ) - + logger.info("Starting Telegram bot (polling mode)...") - + # Initialize and start polling await self._app.initialize() await self._app.start() - + # Get bot info and register command menu bot_info = await self._app.bot.get_me() logger.info("Telegram bot @{} connected", bot_info.username) - + try: await self._app.bot.set_my_commands(self.BOT_COMMANDS) logger.debug("Telegram bot commands registered") except Exception as e: logger.warning("Failed to register bot commands: {}", e) - + # Start polling (this runs until stopped) await self._app.updater.start_polling( allowed_updates=["message"], drop_pending_updates=True # Ignore old messages on startup ) - + # Keep running until stopped while self._running: await asyncio.sleep(1) - + async def stop(self) -> None: """Stop the Telegram bot.""" self._running = False - + # Cancel all typing indicators for chat_id in list(self._typing_tasks): self._stop_typing(chat_id) @@ -198,14 +199,14 @@ class TelegramChannel(BaseChannel): task.cancel() self._media_group_tasks.clear() self._media_group_buffers.clear() - + if self._app: logger.info("Stopping Telegram bot...") await self._app.updater.stop() await self._app.stop() await self._app.shutdown() self._app = None - + @staticmethod def _get_media_type(path: str) -> str: """Guess media type from file extension.""" @@ -253,7 +254,7 @@ class TelegramChannel(BaseChannel): param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document" with open(media_path, 'rb') as f: await sender( - chat_id=chat_id, + chat_id=chat_id, **{param: f}, reply_parameters=reply_params ) @@ -272,8 +273,8 @@ class TelegramChannel(BaseChannel): try: html = _markdown_to_telegram_html(chunk) await self._app.bot.send_message( - chat_id=chat_id, - text=html, + chat_id=chat_id, + text=html, parse_mode="HTML", reply_parameters=reply_params ) @@ -281,13 +282,13 @@ class TelegramChannel(BaseChannel): logger.warning("HTML parse failed, falling back to plain text: {}", e) try: await self._app.bot.send_message( - chat_id=chat_id, + chat_id=chat_id, text=chunk, reply_parameters=reply_params ) except Exception as e2: logger.error("Error sending Telegram message: {}", e2) - + async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start command.""" if not update.message or not update.effective_user: @@ -326,34 +327,34 @@ class TelegramChannel(BaseChannel): chat_id=str(update.message.chat_id), content=update.message.text, ) - + async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming messages (text, photos, voice, documents).""" if not update.message or not update.effective_user: return - + message = update.message user = update.effective_user chat_id = message.chat_id sender_id = self._sender_id(user) - + # Store chat_id for replies self._chat_ids[sender_id] = chat_id - + # Build content from text and/or media content_parts = [] media_paths = [] - + # Text content if message.text: content_parts.append(message.text) if message.caption: content_parts.append(message.caption) - + # Handle media files media_file = None media_type = None - + if message.photo: media_file = message.photo[-1] # Largest photo media_type = "image" @@ -366,23 +367,23 @@ class TelegramChannel(BaseChannel): elif message.document: media_file = message.document media_type = "file" - + # Download media if present if media_file and self._app: try: file = await self._app.bot.get_file(media_file.file_id) ext = self._get_extension(media_type, getattr(media_file, 'mime_type', None)) - + # Save to workspace/media/ from pathlib import Path media_dir = Path.home() / ".nanobot" / "media" media_dir.mkdir(parents=True, exist_ok=True) - + file_path = media_dir / f"{media_file.file_id[:16]}{ext}" await file.download_to_drive(str(file_path)) - + media_paths.append(str(file_path)) - + # Handle voice transcription if media_type == "voice" or media_type == "audio": from nanobot.providers.transcription import GroqTranscriptionProvider @@ -395,16 +396,16 @@ class TelegramChannel(BaseChannel): content_parts.append(f"[{media_type}: {file_path}]") else: content_parts.append(f"[{media_type}: {file_path}]") - + logger.debug("Downloaded {} to {}", media_type, file_path) except Exception as e: logger.error("Failed to download media: {}", e) content_parts.append(f"[{media_type}: download failed]") - + content = "\n".join(content_parts) if content_parts else "[empty message]" - + logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) - + str_chat_id = str(chat_id) # Telegram media groups: buffer briefly, forward as one aggregated turn. @@ -428,10 +429,10 @@ class TelegramChannel(BaseChannel): if key not in self._media_group_tasks: self._media_group_tasks[key] = asyncio.create_task(self._flush_media_group(key)) return - + # Start typing indicator before processing self._start_typing(str_chat_id) - + # Forward to the message bus await self._handle_message( sender_id=sender_id, @@ -446,7 +447,7 @@ class TelegramChannel(BaseChannel): "is_group": message.chat.type != "private" } ) - + async def _flush_media_group(self, key: str) -> None: """Wait briefly, then forward buffered media-group as one turn.""" try: @@ -467,13 +468,13 @@ class TelegramChannel(BaseChannel): # Cancel any existing typing task for this chat self._stop_typing(chat_id) self._typing_tasks[chat_id] = asyncio.create_task(self._typing_loop(chat_id)) - + def _stop_typing(self, chat_id: str) -> None: """Stop the typing indicator for a chat.""" task = self._typing_tasks.pop(chat_id, None) if task and not task.done(): task.cancel() - + async def _typing_loop(self, chat_id: str) -> None: """Repeatedly send 'typing' action until cancelled.""" try: @@ -484,7 +485,7 @@ class TelegramChannel(BaseChannel): pass except Exception as e: logger.debug("Typing indicator stopped for {}: {}", chat_id, e) - + async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """Log polling / handler errors instead of silently swallowing them.""" logger.error("Telegram error: {}", context.error) @@ -498,6 +499,6 @@ class TelegramChannel(BaseChannel): } if mime_type in ext_map: return ext_map[mime_type] - + type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "file": ""} return type_map.get(media_type, "") diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 49d2390..0d1ec7e 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -3,7 +3,6 @@ import asyncio import json from collections import OrderedDict -from typing import Any from loguru import logger @@ -29,17 +28,17 @@ class WhatsAppChannel(BaseChannel): self._ws = None self._connected = False self._processed_message_ids: OrderedDict[str, None] = OrderedDict() - + async def start(self) -> None: """Start the WhatsApp channel by connecting to the bridge.""" import websockets - + bridge_url = self.config.bridge_url - + logger.info("Connecting to WhatsApp bridge at {}...", bridge_url) - + self._running = True - + while self._running: try: async with websockets.connect(bridge_url) as ws: @@ -49,40 +48,40 @@ class WhatsAppChannel(BaseChannel): await ws.send(json.dumps({"type": "auth", "token": self.config.bridge_token})) self._connected = True logger.info("Connected to WhatsApp bridge") - + # Listen for messages async for message in ws: try: await self._handle_bridge_message(message) except Exception as e: logger.error("Error handling bridge message: {}", e) - + except asyncio.CancelledError: break except Exception as e: self._connected = False self._ws = None logger.warning("WhatsApp bridge connection error: {}", e) - + if self._running: logger.info("Reconnecting in 5 seconds...") await asyncio.sleep(5) - + async def stop(self) -> None: """Stop the WhatsApp channel.""" self._running = False self._connected = False - + if self._ws: await self._ws.close() self._ws = None - + async def send(self, msg: OutboundMessage) -> None: """Send a message through WhatsApp.""" if not self._ws or not self._connected: logger.warning("WhatsApp bridge not connected") return - + try: payload = { "type": "send", @@ -92,7 +91,7 @@ class WhatsAppChannel(BaseChannel): await self._ws.send(json.dumps(payload, ensure_ascii=False)) except Exception as e: logger.error("Error sending WhatsApp message: {}", e) - + async def _handle_bridge_message(self, raw: str) -> None: """Handle a message from the bridge.""" try: @@ -100,9 +99,9 @@ class WhatsAppChannel(BaseChannel): except json.JSONDecodeError: logger.warning("Invalid JSON from bridge: {}", raw[:100]) return - + msg_type = data.get("type") - + if msg_type == "message": # Incoming message from WhatsApp # Deprecated by whatsapp: old phone number style typically: @s.whatspp.net @@ -139,20 +138,20 @@ class WhatsAppChannel(BaseChannel): "is_group": data.get("isGroup", False) } ) - + elif msg_type == "status": # Connection status update status = data.get("status") logger.info("WhatsApp status: {}", status) - + if status == "connected": self._connected = True elif status == "disconnected": self._connected = False - + elif msg_type == "qr": # QR code for authentication logger.info("Scan QR code in the bridge terminal to connect WhatsApp") - + elif msg_type == "error": logger.error("WhatsApp bridge error: {}", data.get('error')) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index fc4c261..15bee4c 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -2,23 +2,22 @@ import asyncio import os -import signal -from pathlib import Path import select +import signal import sys +from pathlib import Path import typer +from prompt_toolkit import PromptSession +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.history import FileHistory +from prompt_toolkit.patch_stdout import patch_stdout from rich.console import Console from rich.markdown import Markdown from rich.table import Table from rich.text import Text -from prompt_toolkit import PromptSession -from prompt_toolkit.formatted_text import HTML -from prompt_toolkit.history import FileHistory -from prompt_toolkit.patch_stdout import patch_stdout - -from nanobot import __version__, __logo__ +from nanobot import __logo__, __version__ from nanobot.config.schema import Config from nanobot.utils.helpers import sync_workspace_templates @@ -160,9 +159,9 @@ def onboard(): from nanobot.config.loader import get_config_path, load_config, save_config from nanobot.config.schema import Config from nanobot.utils.helpers import get_workspace_path - + config_path = get_config_path() - + if config_path.exists(): console.print(f"[yellow]Config already exists at {config_path}[/yellow]") console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)") @@ -178,16 +177,16 @@ def onboard(): else: save_config(Config()) console.print(f"[green]✓[/green] Created config at {config_path}") - + # Create workspace workspace = get_workspace_path() - + if not workspace.exists(): workspace.mkdir(parents=True, exist_ok=True) console.print(f"[green]✓[/green] Created workspace at {workspace}") - + sync_workspace_templates(workspace) - + console.print(f"\n{__logo__} nanobot is ready!") console.print("\nNext steps:") console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]") @@ -201,9 +200,9 @@ def onboard(): def _make_provider(config: Config): """Create the appropriate LLM provider from config.""" + from nanobot.providers.custom_provider import CustomProvider from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from nanobot.providers.custom_provider import CustomProvider model = config.agents.defaults.model provider_name = config.get_provider_name(model) @@ -248,31 +247,31 @@ def gateway( verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), ): """Start the nanobot gateway.""" - from nanobot.config.loader import load_config, get_data_dir - from nanobot.bus.queue import MessageBus from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager - from nanobot.session.manager import SessionManager + from nanobot.config.loader import get_data_dir, load_config from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService - + from nanobot.session.manager import SessionManager + if verbose: import logging logging.basicConfig(level=logging.DEBUG) - + console.print(f"{__logo__} Starting nanobot gateway on port {port}...") - + config = load_config() sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) - + # Create cron service first (callback set after agent creation) cron_store_path = get_data_dir() / "cron" / "jobs.json" cron = CronService(cron_store_path) - + # Create agent with cron service agent = AgentLoop( bus=bus, @@ -291,7 +290,7 @@ def gateway( mcp_servers=config.tools.mcp_servers, channels_config=config.channels, ) - + # Set cron callback (needs agent) async def on_cron_job(job: CronJob) -> str | None: """Execute a cron job through the agent.""" @@ -310,7 +309,7 @@ def gateway( )) return response cron.on_job = on_cron_job - + # Create channel manager channels = ChannelManager(config, bus) @@ -364,18 +363,18 @@ def gateway( interval_s=hb_cfg.interval_s, enabled=hb_cfg.enabled, ) - + if channels.enabled_channels: console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}") else: console.print("[yellow]Warning: No channels enabled[/yellow]") - + cron_status = cron.status() if cron_status["jobs"] > 0: console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs") - + console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s") - + async def run(): try: await cron.start() @@ -392,7 +391,7 @@ def gateway( cron.stop() agent.stop() await channels.stop_all() - + asyncio.run(run()) @@ -411,15 +410,16 @@ def agent( logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), ): """Interact with the agent directly.""" - from nanobot.config.loader import load_config, get_data_dir - from nanobot.bus.queue import MessageBus - from nanobot.agent.loop import AgentLoop - from nanobot.cron.service import CronService from loguru import logger - + + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + from nanobot.config.loader import get_data_dir, load_config + from nanobot.cron.service import CronService + config = load_config() sync_workspace_templates(config.workspace_path) - + bus = MessageBus() provider = _make_provider(config) @@ -431,7 +431,7 @@ def agent( logger.enable("nanobot") else: logger.disable("nanobot") - + agent_loop = AgentLoop( bus=bus, provider=provider, @@ -448,7 +448,7 @@ def agent( mcp_servers=config.tools.mcp_servers, channels_config=config.channels, ) - + # Show spinner when logs are off (no output to miss); skip when logs are on def _thinking_ctx(): if logs: @@ -624,7 +624,7 @@ def channels_status(): "✓" if mc.enabled else "✗", mc_base ) - + # Telegram tg = config.channels.telegram tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]" @@ -677,57 +677,57 @@ def _get_bridge_dir() -> Path: """Get the bridge directory, setting it up if needed.""" import shutil import subprocess - + # User's bridge location user_bridge = Path.home() / ".nanobot" / "bridge" - + # Check if already built if (user_bridge / "dist" / "index.js").exists(): return user_bridge - + # Check for npm if not shutil.which("npm"): console.print("[red]npm not found. Please install Node.js >= 18.[/red]") raise typer.Exit(1) - + # Find source bridge: first check package data, then source dir pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev) - + source = None if (pkg_bridge / "package.json").exists(): source = pkg_bridge elif (src_bridge / "package.json").exists(): source = src_bridge - + if not source: console.print("[red]Bridge source not found.[/red]") console.print("Try reinstalling: pip install --force-reinstall nanobot") raise typer.Exit(1) - + console.print(f"{__logo__} Setting up bridge...") - + # Copy to user directory user_bridge.parent.mkdir(parents=True, exist_ok=True) if user_bridge.exists(): shutil.rmtree(user_bridge) shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist")) - + # Install and build try: console.print(" Installing dependencies...") subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True) - + console.print(" Building...") subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True) - + console.print("[green]✓[/green] Bridge ready\n") except subprocess.CalledProcessError as e: console.print(f"[red]Build failed: {e}[/red]") if e.stderr: console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]") raise typer.Exit(1) - + return user_bridge @@ -735,18 +735,19 @@ def _get_bridge_dir() -> Path: def channels_login(): """Link device via QR code.""" import subprocess + from nanobot.config.loader import load_config - + config = load_config() bridge_dir = _get_bridge_dir() - + console.print(f"{__logo__} Starting bridge...") console.print("Scan the QR code to connect.\n") - + env = {**os.environ} if config.channels.whatsapp.bridge_token: env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token - + try: subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env) except subprocess.CalledProcessError as e: @@ -770,23 +771,23 @@ def cron_list( """List scheduled jobs.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + jobs = service.list_jobs(include_disabled=all) - + if not jobs: console.print("No scheduled jobs.") return - + table = Table(title="Scheduled Jobs") table.add_column("ID", style="cyan") table.add_column("Name") table.add_column("Schedule") table.add_column("Status") table.add_column("Next Run") - + import time from datetime import datetime as _dt from zoneinfo import ZoneInfo @@ -798,7 +799,7 @@ def cron_list( sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "") else: sched = "one-time" - + # Format next run next_run = "" if job.state.next_run_at_ms: @@ -808,11 +809,11 @@ def cron_list( next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M") except Exception: next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) - + status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" - + table.add_row(job.id, job.name, sched, status, next_run) - + console.print(table) @@ -832,7 +833,7 @@ def cron_add( from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService from nanobot.cron.types import CronSchedule - + if tz and not cron_expr: console.print("[red]Error: --tz can only be used with --cron[/red]") raise typer.Exit(1) @@ -849,10 +850,10 @@ def cron_add( else: console.print("[red]Error: Must specify --every, --cron, or --at[/red]") raise typer.Exit(1) - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + try: job = service.add_job( name=name, @@ -876,10 +877,10 @@ def cron_remove( """Remove a scheduled job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + if service.remove_job(job_id): console.print(f"[green]✓[/green] Removed job {job_id}") else: @@ -894,10 +895,10 @@ def cron_enable( """Enable or disable a job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + job = service.enable_job(job_id, enabled=not disable) if job: status = "disabled" if disable else "enabled" @@ -913,11 +914,12 @@ def cron_run( ): """Manually run a job.""" from loguru import logger - from nanobot.config.loader import load_config, get_data_dir + + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + from nanobot.config.loader import get_data_dir, load_config from nanobot.cron.service import CronService from nanobot.cron.types import CronJob - from nanobot.bus.queue import MessageBus - from nanobot.agent.loop import AgentLoop logger.disable("nanobot") config = load_config() @@ -975,7 +977,7 @@ def cron_run( @app.command() def status(): """Show nanobot status.""" - from nanobot.config.loader import load_config, get_config_path + from nanobot.config.loader import get_config_path, load_config config_path = get_config_path() config = load_config() @@ -990,7 +992,7 @@ def status(): from nanobot.providers.registry import PROVIDERS console.print(f"Model: {config.agents.defaults.model}") - + # Check API keys from registry for spec in PROVIDERS: p = getattr(config.providers, spec.name, None) diff --git a/nanobot/config/__init__.py b/nanobot/config/__init__.py index 88e8e9b..6c59668 100644 --- a/nanobot/config/__init__.py +++ b/nanobot/config/__init__.py @@ -1,6 +1,6 @@ """Configuration module for nanobot.""" -from nanobot.config.loader import load_config, get_config_path +from nanobot.config.loader import get_config_path, load_config from nanobot.config.schema import Config __all__ = ["Config", "load_config", "get_config_path"] diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 1ff9782..a908f3d 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Literal -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 6889a10..cc3b7b2 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -21,17 +21,18 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None: """Compute next run time in ms.""" if schedule.kind == "at": return schedule.at_ms if schedule.at_ms and schedule.at_ms > now_ms else None - + if schedule.kind == "every": if not schedule.every_ms or schedule.every_ms <= 0: return None # Next interval from now return now_ms + schedule.every_ms - + if schedule.kind == "cron" and schedule.expr: try: - from croniter import croniter from zoneinfo import ZoneInfo + + from croniter import croniter # Use caller-provided reference time for deterministic scheduling base_time = now_ms / 1000 tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo @@ -41,7 +42,7 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None: return int(next_dt.timestamp() * 1000) except Exception: return None - + return None @@ -61,7 +62,7 @@ def _validate_schedule_for_add(schedule: CronSchedule) -> None: class CronService: """Service for managing and executing scheduled jobs.""" - + def __init__( self, store_path: Path, @@ -72,12 +73,12 @@ class CronService: self._store: CronStore | None = None self._timer_task: asyncio.Task | None = None self._running = False - + def _load_store(self) -> CronStore: """Load jobs from disk.""" if self._store: return self._store - + if self.store_path.exists(): try: data = json.loads(self.store_path.read_text(encoding="utf-8")) @@ -117,16 +118,16 @@ class CronService: self._store = CronStore() else: self._store = CronStore() - + return self._store - + def _save_store(self) -> None: """Save jobs to disk.""" if not self._store: return - + self.store_path.parent.mkdir(parents=True, exist_ok=True) - + data = { "version": self._store.version, "jobs": [ @@ -161,9 +162,9 @@ class CronService: for j in self._store.jobs ] } - + self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - + async def start(self) -> None: """Start the cron service.""" self._running = True @@ -172,14 +173,14 @@ class CronService: self._save_store() self._arm_timer() logger.info("Cron service started with {} jobs", len(self._store.jobs if self._store else [])) - + def stop(self) -> None: """Stop the cron service.""" self._running = False if self._timer_task: self._timer_task.cancel() self._timer_task = None - + def _recompute_next_runs(self) -> None: """Recompute next run times for all enabled jobs.""" if not self._store: @@ -188,73 +189,73 @@ class CronService: for job in self._store.jobs: if job.enabled: job.state.next_run_at_ms = _compute_next_run(job.schedule, now) - + def _get_next_wake_ms(self) -> int | None: """Get the earliest next run time across all jobs.""" if not self._store: return None - times = [j.state.next_run_at_ms for j in self._store.jobs + times = [j.state.next_run_at_ms for j in self._store.jobs if j.enabled and j.state.next_run_at_ms] return min(times) if times else None - + def _arm_timer(self) -> None: """Schedule the next timer tick.""" if self._timer_task: self._timer_task.cancel() - + next_wake = self._get_next_wake_ms() if not next_wake or not self._running: return - + delay_ms = max(0, next_wake - _now_ms()) delay_s = delay_ms / 1000 - + async def tick(): await asyncio.sleep(delay_s) if self._running: await self._on_timer() - + self._timer_task = asyncio.create_task(tick()) - + async def _on_timer(self) -> None: """Handle timer tick - run due jobs.""" if not self._store: return - + now = _now_ms() due_jobs = [ j for j in self._store.jobs if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms ] - + for job in due_jobs: await self._execute_job(job) - + self._save_store() self._arm_timer() - + async def _execute_job(self, job: CronJob) -> None: """Execute a single job.""" start_ms = _now_ms() logger.info("Cron: executing job '{}' ({})", job.name, job.id) - + try: response = None if self.on_job: response = await self.on_job(job) - + job.state.last_status = "ok" job.state.last_error = None logger.info("Cron: job '{}' completed", job.name) - + except Exception as e: job.state.last_status = "error" job.state.last_error = str(e) logger.error("Cron: job '{}' failed: {}", job.name, e) - + job.state.last_run_at_ms = start_ms job.updated_at_ms = _now_ms() - + # Handle one-shot jobs if job.schedule.kind == "at": if job.delete_after_run: @@ -265,15 +266,15 @@ class CronService: else: # Compute next run job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms()) - + # ========== Public API ========== - + def list_jobs(self, include_disabled: bool = False) -> list[CronJob]: """List all jobs.""" store = self._load_store() jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled] return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf')) - + def add_job( self, name: str, @@ -288,7 +289,7 @@ class CronService: store = self._load_store() _validate_schedule_for_add(schedule) now = _now_ms() - + job = CronJob( id=str(uuid.uuid4())[:8], name=name, @@ -306,28 +307,28 @@ class CronService: updated_at_ms=now, delete_after_run=delete_after_run, ) - + store.jobs.append(job) self._save_store() self._arm_timer() - + logger.info("Cron: added job '{}' ({})", name, job.id) return job - + def remove_job(self, job_id: str) -> bool: """Remove a job by ID.""" store = self._load_store() before = len(store.jobs) store.jobs = [j for j in store.jobs if j.id != job_id] removed = len(store.jobs) < before - + if removed: self._save_store() self._arm_timer() logger.info("Cron: removed job {}", job_id) - + return removed - + def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None: """Enable or disable a job.""" store = self._load_store() @@ -343,7 +344,7 @@ class CronService: self._arm_timer() return job return None - + async def run_job(self, job_id: str, force: bool = False) -> bool: """Manually run a job.""" store = self._load_store() @@ -356,7 +357,7 @@ class CronService: self._arm_timer() return True return False - + def status(self) -> dict: """Get service status.""" store = self._load_store() diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index eb1599a..a46e68f 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -21,7 +21,7 @@ class LLMResponse: finish_reason: str = "stop" usage: dict[str, int] = field(default_factory=dict) reasoning_content: str | None = None # Kimi, DeepSeek-R1 etc. - + @property def has_tool_calls(self) -> bool: """Check if response contains tool calls.""" @@ -35,7 +35,7 @@ class LLMProvider(ABC): Implementations should handle the specifics of each provider's API while maintaining a consistent interface. """ - + def __init__(self, api_key: str | None = None, api_base: str | None = None): self.api_key = api_key self.api_base = api_base @@ -79,7 +79,7 @@ class LLMProvider(ABC): result.append(msg) return result - + @abstractmethod async def chat( self, @@ -103,7 +103,7 @@ class LLMProvider(ABC): LLMResponse with content and/or tool calls. """ pass - + @abstractmethod def get_default_model(self) -> str: """Get the default model for this provider.""" diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 5427d97..931e038 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -1,19 +1,17 @@ """LiteLLM provider implementation for multi-provider support.""" -import json -import json_repair import os import secrets import string from typing import Any +import json_repair import litellm from litellm import acompletion from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest 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"}) @@ -32,10 +30,10 @@ class LiteLLMProvider(LLMProvider): a unified interface. Provider-specific logic is driven by the registry (see providers/registry.py) — no if-elif chains needed here. """ - + def __init__( - self, - api_key: str | None = None, + self, + api_key: str | None = None, api_base: str | None = None, default_model: str = "anthropic/claude-opus-4-5", extra_headers: dict[str, str] | None = None, @@ -44,24 +42,24 @@ class LiteLLMProvider(LLMProvider): super().__init__(api_key, api_base) self.default_model = default_model self.extra_headers = extra_headers or {} - + # Detect gateway / local deployment. # provider_name (from config key) is the primary signal; # api_key / api_base are fallback for auto-detection. self._gateway = find_gateway(provider_name, api_key, api_base) - + # Configure environment variables if api_key: self._setup_env(api_key, api_base, default_model) - + if api_base: litellm.api_base = api_base - + # Disable LiteLLM logging noise litellm.suppress_debug_info = True # Drop unsupported parameters for providers (e.g., gpt-5 rejects some params) litellm.drop_params = True - + def _setup_env(self, api_key: str, api_base: str | None, model: str) -> None: """Set environment variables based on detected provider.""" spec = self._gateway or find_by_model(model) @@ -85,7 +83,7 @@ class LiteLLMProvider(LLMProvider): resolved = env_val.replace("{api_key}", api_key) resolved = resolved.replace("{api_base}", effective_base) os.environ.setdefault(env_name, resolved) - + def _resolve_model(self, model: str) -> str: """Resolve model name by applying provider/gateway prefixes.""" if self._gateway: @@ -96,7 +94,7 @@ class LiteLLMProvider(LLMProvider): if prefix and not model.startswith(f"{prefix}/"): model = f"{prefix}/{model}" return model - + # Standard mode: auto-prefix for known providers spec = find_by_model(model) if spec and spec.litellm_prefix: @@ -115,7 +113,7 @@ class LiteLLMProvider(LLMProvider): if prefix.lower().replace("-", "_") != spec_name: return model return f"{canonical_prefix}/{remainder}" - + def _supports_cache_control(self, model: str) -> bool: """Return True when the provider supports cache_control on content blocks.""" if self._gateway is not None: @@ -158,7 +156,7 @@ class LiteLLMProvider(LLMProvider): if pattern in model_lower: kwargs.update(overrides) return - + @staticmethod def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: """Strip non-standard keys and ensure assistant messages have a content key.""" @@ -181,14 +179,14 @@ class LiteLLMProvider(LLMProvider): ) -> LLMResponse: """ Send a chat completion request via LiteLLM. - + Args: messages: List of message dicts with 'role' and 'content'. tools: Optional list of tool definitions in OpenAI format. model: Model identifier (e.g., 'anthropic/claude-sonnet-4-5'). max_tokens: Maximum tokens in response. temperature: Sampling temperature. - + Returns: LLMResponse with content and/or tool calls. """ @@ -201,33 +199,33 @@ class LiteLLMProvider(LLMProvider): # Clamp max_tokens to at least 1 — negative or zero values cause # LiteLLM to reject the request with "max_tokens must be at least 1". max_tokens = max(1, max_tokens) - + kwargs: dict[str, Any] = { "model": model, "messages": self._sanitize_messages(self._sanitize_empty_content(messages)), "max_tokens": max_tokens, "temperature": temperature, } - + # Apply model-specific overrides (e.g. kimi-k2.5 temperature) self._apply_model_overrides(model, kwargs) - + # Pass api_key directly — more reliable than env vars alone if self.api_key: kwargs["api_key"] = self.api_key - + # Pass api_base for custom endpoints if self.api_base: kwargs["api_base"] = self.api_base - + # Pass extra headers (e.g. APP-Code for AiHubMix) if self.extra_headers: kwargs["extra_headers"] = self.extra_headers - + if tools: kwargs["tools"] = tools kwargs["tool_choice"] = "auto" - + try: response = await acompletion(**kwargs) return self._parse_response(response) @@ -237,12 +235,12 @@ class LiteLLMProvider(LLMProvider): content=f"Error calling LLM: {str(e)}", finish_reason="error", ) - + def _parse_response(self, response: Any) -> LLMResponse: """Parse LiteLLM response into our standard format.""" choice = response.choices[0] message = choice.message - + tool_calls = [] if hasattr(message, "tool_calls") and message.tool_calls: for tc in message.tool_calls: @@ -250,13 +248,13 @@ class LiteLLMProvider(LLMProvider): args = tc.function.arguments if isinstance(args, str): args = json_repair.loads(args) - + tool_calls.append(ToolCallRequest( id=_short_tool_id(), name=tc.function.name, arguments=args, )) - + usage = {} if hasattr(response, "usage") and response.usage: usage = { @@ -264,9 +262,9 @@ class LiteLLMProvider(LLMProvider): "completion_tokens": response.usage.completion_tokens, "total_tokens": response.usage.total_tokens, } - + reasoning_content = getattr(message, "reasoning_content", None) or None - + return LLMResponse( content=message.content, tool_calls=tool_calls, @@ -274,7 +272,7 @@ class LiteLLMProvider(LLMProvider): usage=usage, reasoning_content=reasoning_content, ) - + def get_default_model(self) -> str: """Get the default model.""" return self.default_model diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index fa28593..1e4dd8a 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -9,8 +9,8 @@ from typing import Any, AsyncGenerator import httpx from loguru import logger - from oauth_cli_kit import get_token as get_codex_token + from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses" diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index 7a3c628..1c8cb6a 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -2,7 +2,6 @@ import os from pathlib import Path -from typing import Any import httpx from loguru import logger @@ -11,33 +10,33 @@ from loguru import logger class GroqTranscriptionProvider: """ Voice transcription provider using Groq's Whisper API. - + Groq offers extremely fast transcription with a generous free tier. """ - + def __init__(self, api_key: str | None = None): self.api_key = api_key or os.environ.get("GROQ_API_KEY") self.api_url = "https://api.groq.com/openai/v1/audio/transcriptions" - + async def transcribe(self, file_path: str | Path) -> str: """ Transcribe an audio file using Groq. - + Args: file_path: Path to the audio file. - + Returns: Transcribed text. """ if not self.api_key: logger.warning("Groq API key not configured for transcription") return "" - + path = Path(file_path) if not path.exists(): logger.error("Audio file not found: {}", file_path) return "" - + try: async with httpx.AsyncClient() as client: with open(path, "rb") as f: @@ -48,18 +47,18 @@ class GroqTranscriptionProvider: headers = { "Authorization": f"Bearer {self.api_key}", } - + response = await client.post( self.api_url, headers=headers, files=files, timeout=60.0 ) - + response.raise_for_status() data = response.json() return data.get("text", "") - + except Exception as e: logger.error("Groq transcription error: {}", e) return "" diff --git a/nanobot/session/__init__.py b/nanobot/session/__init__.py index 3faf424..931f7c6 100644 --- a/nanobot/session/__init__.py +++ b/nanobot/session/__init__.py @@ -1,5 +1,5 @@ """Session management module.""" -from nanobot.session.manager import SessionManager, Session +from nanobot.session.manager import Session, SessionManager __all__ = ["SessionManager", "Session"] diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index d59b7c9..dce4b2e 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -2,9 +2,9 @@ import json import shutil -from pathlib import Path from dataclasses import dataclass, field from datetime import datetime +from pathlib import Path from typing import Any from loguru import logger @@ -30,7 +30,7 @@ class Session: updated_at: datetime = field(default_factory=datetime.now) metadata: dict[str, Any] = field(default_factory=dict) last_consolidated: int = 0 # Number of messages already consolidated to files - + def add_message(self, role: str, content: str, **kwargs: Any) -> None: """Add a message to the session.""" msg = { @@ -41,7 +41,7 @@ class Session: } self.messages.append(msg) self.updated_at = datetime.now() - + def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]: """Return unconsolidated messages for LLM input, aligned to a user turn.""" unconsolidated = self.messages[self.last_consolidated:] @@ -61,7 +61,7 @@ class Session: entry[k] = m[k] out.append(entry) return out - + def clear(self) -> None: """Clear all messages and reset session to initial state.""" self.messages = [] @@ -81,7 +81,7 @@ class SessionManager: self.sessions_dir = ensure_dir(self.workspace / "sessions") self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions" self._cache: dict[str, Session] = {} - + def _get_session_path(self, key: str) -> Path: """Get the file path for a session.""" safe_key = safe_filename(key.replace(":", "_")) @@ -91,27 +91,27 @@ class SessionManager: """Legacy global session path (~/.nanobot/sessions/).""" safe_key = safe_filename(key.replace(":", "_")) return self.legacy_sessions_dir / f"{safe_key}.jsonl" - + def get_or_create(self, key: str) -> Session: """ Get an existing session or create a new one. - + Args: key: Session key (usually channel:chat_id). - + Returns: The session. """ if key in self._cache: return self._cache[key] - + session = self._load(key) if session is None: session = Session(key=key) - + self._cache[key] = session return session - + def _load(self, key: str) -> Session | None: """Load a session from disk.""" path = self._get_session_path(key) @@ -158,7 +158,7 @@ class SessionManager: except Exception as e: logger.warning("Failed to load session {}: {}", key, e) return None - + def save(self, session: Session) -> None: """Save a session to disk.""" path = self._get_session_path(session.key) @@ -177,20 +177,20 @@ class SessionManager: f.write(json.dumps(msg, ensure_ascii=False) + "\n") self._cache[session.key] = session - + def invalidate(self, key: str) -> None: """Remove a session from the in-memory cache.""" self._cache.pop(key, None) - + def list_sessions(self) -> list[dict[str, Any]]: """ List all sessions. - + Returns: List of session info dicts. """ sessions = [] - + for path in self.sessions_dir.glob("*.jsonl"): try: # Read just the metadata line @@ -208,5 +208,5 @@ class SessionManager: }) except Exception: continue - + return sorted(sessions, key=lambda x: x.get("updated_at", ""), reverse=True) diff --git a/nanobot/utils/__init__.py b/nanobot/utils/__init__.py index 7444987..9163e38 100644 --- a/nanobot/utils/__init__.py +++ b/nanobot/utils/__init__.py @@ -1,5 +1,5 @@ """Utility functions for nanobot.""" -from nanobot.utils.helpers import ensure_dir, get_workspace_path, get_data_path +from nanobot.utils.helpers import ensure_dir, get_data_path, get_workspace_path __all__ = ["ensure_dir", "get_workspace_path", "get_data_path"] diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 8322bc8..3a8c802 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -1,8 +1,8 @@ """Utility functions for nanobot.""" import re -from pathlib import Path from datetime import datetime +from pathlib import Path def ensure_dir(path: Path) -> Path: From b3af59fc8e09fd6acc0af8f0bddcadec64ce7d42 Mon Sep 17 00:00:00 2001 From: "zhangxiaoyu.york" Date: Sun, 1 Mar 2026 00:20:32 +0800 Subject: [PATCH 36/78] bugfix: remove client.stop --- nanobot/channels/feishu.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 4a6312e..4abac85 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -311,8 +311,8 @@ class FeishuChannel(BaseChannel): self._ws_client.start() except Exception as e: logger.warning("Feishu WebSocket error: {}", e) - if self._running: - import time; time.sleep(5) + if self._running: + import time; time.sleep(5) self._ws_thread = threading.Thread(target=run_ws, daemon=True) self._ws_thread.start() @@ -327,11 +327,6 @@ class FeishuChannel(BaseChannel): async def stop(self) -> None: """Stop the Feishu bot.""" self._running = False - if self._ws_client: - try: - self._ws_client.stop() - except Exception as e: - logger.warning("Error stopping WebSocket client: {}", e) logger.info("Feishu bot stopped") def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: From 73a708770e3a2e7331ae61100778cd4d78ced5c4 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 16:23:43 +0000 Subject: [PATCH 37/78] refactor: compress DingTalk helpers --- nanobot/channels/dingtalk.py | 77 ++++++++---------------------------- 1 file changed, 16 insertions(+), 61 deletions(-) diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 53a9bb8..2797029 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -200,34 +200,18 @@ class DingTalkChannel(BaseChannel): @staticmethod def _is_http_url(value: str) -> bool: - low = value.lower() - return low.startswith("http://") or low.startswith("https://") + return urlparse(value).scheme in ("http", "https") def _guess_upload_type(self, media_ref: str) -> str: - parsed = urlparse(media_ref) - path = parsed.path if parsed.scheme else media_ref - ext = Path(path).suffix.lower() - if ext in self._IMAGE_EXTS: - return "image" - if ext in self._AUDIO_EXTS: - return "voice" - if ext in self._VIDEO_EXTS: - return "video" + ext = Path(urlparse(media_ref).path).suffix.lower() + if ext in self._IMAGE_EXTS: return "image" + if ext in self._AUDIO_EXTS: return "voice" + if ext in self._VIDEO_EXTS: return "video" return "file" def _guess_filename(self, media_ref: str, upload_type: str) -> str: - parsed = urlparse(media_ref) - path = parsed.path if parsed.scheme else media_ref - name = os.path.basename(path) - if name: - return name - fallback = { - "image": "image.jpg", - "voice": "audio.amr", - "video": "video.mp4", - "file": "file.bin", - } - return fallback.get(upload_type, "file.bin") + name = os.path.basename(urlparse(media_ref).path) + return name or {"image": "image.jpg", "voice": "audio.amr", "video": "video.mp4"}.get(upload_type, "file.bin") async def _read_media_bytes( self, @@ -288,33 +272,16 @@ class DingTalkChannel(BaseChannel): try: resp = await self._http.post(url, files=files) text = resp.text - try: - result = resp.json() - except Exception: - result = {} + result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} if resp.status_code >= 400: - logger.error( - "DingTalk media upload failed status={} type={} body={}", - resp.status_code, - media_type, - text[:500], - ) + logger.error("DingTalk media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500]) return None errcode = result.get("errcode", 0) if errcode != 0: - logger.error( - "DingTalk media upload api error type={} errcode={} body={}", - media_type, - errcode, - text[:500], - ) + logger.error("DingTalk media upload api error type={} errcode={} body={}", media_type, errcode, text[:500]) return None - media_id = ( - result.get("media_id") - or result.get("mediaId") - or (result.get("result") or {}).get("media_id") - or (result.get("result") or {}).get("mediaId") - ) + sub = result.get("result") or {} + media_id = result.get("media_id") or result.get("mediaId") or sub.get("media_id") or sub.get("mediaId") if not media_id: logger.error("DingTalk media upload missing media_id body={}", text[:500]) return None @@ -347,25 +314,13 @@ class DingTalkChannel(BaseChannel): resp = await self._http.post(url, json=payload, headers=headers) body = resp.text if resp.status_code != 200: - logger.error( - "DingTalk send failed msgKey={} status={} body={}", - msg_key, - resp.status_code, - body[:500], - ) + logger.error("DingTalk send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500]) return False - try: - result = resp.json() - except Exception: - result = {} + try: result = resp.json() + except Exception: result = {} errcode = result.get("errcode") if errcode not in (None, 0): - logger.error( - "DingTalk send api error msgKey={} errcode={} body={}", - msg_key, - errcode, - body[:500], - ) + logger.error("DingTalk send api error msgKey={} errcode={} body={}", msg_key, errcode, body[:500]) return False logger.debug("DingTalk message sent to {} with msgKey={}", chat_id, msg_key) return True From 5d829ca575464214b18b8af27d326a2db967e922 Mon Sep 17 00:00:00 2001 From: "zhangxiaoyu.york" Date: Sun, 1 Mar 2026 00:30:03 +0800 Subject: [PATCH 38/78] bugfix: remove client.stop --- nanobot/channels/feishu.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 4abac85..161d31e 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -325,7 +325,13 @@ class FeishuChannel(BaseChannel): await asyncio.sleep(1) async def stop(self) -> None: - """Stop the Feishu bot.""" + """ + Stop the Feishu bot. + + Notice: lark.ws.Client does not expose stop method, simply exiting the program will close the client. + + Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86 + """ self._running = False logger.info("Feishu bot stopped") From 8545d5790ebf0979eec3a96b850f12b7967688c3 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 16:32:50 +0000 Subject: [PATCH 39/78] refactor: streamline subagent prompt by reusing ContextBuilder and SkillsLoader --- README.md | 2 +- nanobot/agent/subagent.py | 44 +++++++++++++-------------------------- 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index d788e5e..66da385 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,922 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,927 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 337796c..5606303 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -104,8 +104,7 @@ class SubagentManager: tools.register(WebSearchTool(api_key=self.brave_api_key)) tools.register(WebFetchTool()) - # Build messages with subagent-specific prompt - system_prompt = self._build_subagent_prompt(task) + system_prompt = self._build_subagent_prompt() messages: list[dict[str, Any]] = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": task}, @@ -204,42 +203,27 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men await self.bus.publish_inbound(msg) logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id']) - def _build_subagent_prompt(self, task: str) -> str: + def _build_subagent_prompt(self) -> str: """Build a focused system prompt for the subagent.""" - from datetime import datetime - import time as _time - now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") - tz = _time.strftime("%Z") or "UTC" + from nanobot.agent.context import ContextBuilder + from nanobot.agent.skills import SkillsLoader - return f"""# Subagent + time_ctx = ContextBuilder._build_runtime_context(None, None) + parts = [f"""# Subagent -## Current Time -{now} ({tz}) +{time_ctx} You are a subagent spawned by the main agent to complete a specific task. - -## Rules -1. Stay focused - complete only the assigned task, nothing else -2. Your final response will be reported back to the main agent -3. Do not initiate conversations or take on side tasks -4. Be concise but informative in your findings - -## What You Can Do -- Read and write files in the workspace -- Execute shell commands -- Search the web and fetch web pages -- Complete the task thoroughly - -## What You Cannot Do -- Send messages directly to users (no message tool available) -- Spawn other subagents -- Access the main agent's conversation history +Stay focused on the assigned task. Your final response will be reported back to the main agent. ## Workspace -Your workspace is at: {self.workspace} -Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed) +{self.workspace}"""] -When you have completed the task, provide a clear summary of your findings or actions.""" + skills_summary = SkillsLoader(self.workspace).build_skills_summary() + if skills_summary: + parts.append(f"## Skills\n\nRead SKILL.md with read_file to use a skill.\n\n{skills_summary}") + + return "\n\n".join(parts) async def cancel_by_session(self, session_key: str) -> int: """Cancel all subagents for the given session. Returns count cancelled.""" From cfe33ff7cd321813b03d1bc88a18bffc811dbeb9 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Sat, 28 Feb 2026 17:35:07 +0100 Subject: [PATCH 40/78] fix(qq): disable botpy file log to fix read-only filesystem error When nanobot is run as a systemd service with ProtectSystem=strict, the process cwd defaults to the read-only root filesystem (/). botpy's default Client configuration includes a TimedRotatingFileHandler that writes 'botpy.log' to os.getcwd(), which raises [Errno 30] Read-only file system. Pass ext_handlers=False when constructing the botpy Client subclass to suppress the file handler. nanobot already routes all log output through loguru, so botpy's file handler is redundant. Fixes #1343 Co-Authored-By: Claude --- nanobot/channels/qq.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 50dbbde..41e6ad3 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -31,7 +31,13 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": class _Bot(botpy.Client): def __init__(self): - super().__init__(intents=intents) + # Disable botpy's default file handler (TimedRotatingFileHandler). + # By default botpy writes "botpy.log" to the process cwd, which + # fails under systemd with ProtectSystem=strict (read-only root fs). + # nanobot already handles logging via loguru, so the file handler is + # redundant. ext_handlers=False keeps console output but suppresses + # the file log. See: https://github.com/HKUDS/nanobot/issues/1343 + super().__init__(intents=intents, ext_handlers=False) async def on_ready(self): logger.info("QQ bot ready: {}", self.robot.name) From c34e1053f05ec8f96f68904dcd26fbf86e654afd Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 16:45:06 +0000 Subject: [PATCH 41/78] fix(qq): disable botpy file log to fix read-only filesystem error --- nanobot/channels/qq.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 41e6ad3..7b171bc 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -31,12 +31,7 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": class _Bot(botpy.Client): def __init__(self): - # Disable botpy's default file handler (TimedRotatingFileHandler). - # By default botpy writes "botpy.log" to the process cwd, which - # fails under systemd with ProtectSystem=strict (read-only root fs). - # nanobot already handles logging via loguru, so the file handler is - # redundant. ext_handlers=False keeps console output but suppresses - # the file log. See: https://github.com/HKUDS/nanobot/issues/1343 + # Disable botpy's file log — nanobot uses loguru; default "botpy.log" fails on read-only fs super().__init__(intents=intents, ext_handlers=False) async def on_ready(self): From 9e2f69bd5a069c8e7b7a2288fa7e004a4409cec5 Mon Sep 17 00:00:00 2001 From: "zhangxiaoyu.york" Date: Sun, 1 Mar 2026 00:51:17 +0800 Subject: [PATCH 42/78] tidy up --- nanobot/channels/feishu.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 161d31e..16c6a07 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -311,8 +311,8 @@ class FeishuChannel(BaseChannel): self._ws_client.start() except Exception as e: logger.warning("Feishu WebSocket error: {}", e) - if self._running: - import time; time.sleep(5) + if self._running: + import time; time.sleep(5) self._ws_thread = threading.Thread(target=run_ws, daemon=True) self._ws_thread.start() From f9d72e2e74cb4177ed892b66fdf4dd639690793c Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 17:18:05 +0000 Subject: [PATCH 43/78] feat: add reasoning_effort config to enable LLM thinking mode --- README.md | 2 +- nanobot/agent/loop.py | 4 ++++ nanobot/agent/subagent.py | 3 +++ nanobot/cli/commands.py | 3 +++ nanobot/config/schema.py | 1 + nanobot/providers/base.py | 1 + nanobot/providers/custom_provider.py | 5 ++++- nanobot/providers/litellm_provider.py | 5 +++++ nanobot/providers/openai_codex_provider.py | 1 + 9 files changed, 23 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 66da385..0d46b7f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,927 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,935 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index d8e5cad..b42c3ba 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -56,6 +56,7 @@ class AgentLoop: temperature: float = 0.1, max_tokens: int = 4096, memory_window: int = 100, + reasoning_effort: str | None = None, brave_api_key: str | None = None, exec_config: ExecToolConfig | None = None, cron_service: CronService | None = None, @@ -74,6 +75,7 @@ class AgentLoop: self.temperature = temperature self.max_tokens = max_tokens self.memory_window = memory_window + self.reasoning_effort = reasoning_effort self.brave_api_key = brave_api_key self.exec_config = exec_config or ExecToolConfig() self.cron_service = cron_service @@ -89,6 +91,7 @@ class AgentLoop: model=self.model, temperature=self.temperature, max_tokens=self.max_tokens, + reasoning_effort=reasoning_effort, brave_api_key=brave_api_key, exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, @@ -191,6 +194,7 @@ class AgentLoop: model=self.model, temperature=self.temperature, max_tokens=self.max_tokens, + reasoning_effort=self.reasoning_effort, ) if response.has_tool_calls: diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 5606303..a99ba4d 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -28,6 +28,7 @@ class SubagentManager: model: str | None = None, temperature: float = 0.7, max_tokens: int = 4096, + reasoning_effort: str | None = None, brave_api_key: str | None = None, exec_config: "ExecToolConfig | None" = None, restrict_to_workspace: bool = False, @@ -39,6 +40,7 @@ class SubagentManager: self.model = model or provider.get_default_model() self.temperature = temperature self.max_tokens = max_tokens + self.reasoning_effort = reasoning_effort self.brave_api_key = brave_api_key self.exec_config = exec_config or ExecToolConfig() self.restrict_to_workspace = restrict_to_workspace @@ -124,6 +126,7 @@ class SubagentManager: model=self.model, temperature=self.temperature, max_tokens=self.max_tokens, + reasoning_effort=self.reasoning_effort, ) if response.has_tool_calls: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index fc4c261..2e417d6 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -283,6 +283,7 @@ def gateway( max_tokens=config.agents.defaults.max_tokens, max_iterations=config.agents.defaults.max_tool_iterations, memory_window=config.agents.defaults.memory_window, + reasoning_effort=config.agents.defaults.reasoning_effort, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, cron_service=cron, @@ -441,6 +442,7 @@ def agent( max_tokens=config.agents.defaults.max_tokens, max_iterations=config.agents.defaults.max_tool_iterations, memory_window=config.agents.defaults.memory_window, + reasoning_effort=config.agents.defaults.reasoning_effort, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, cron_service=cron, @@ -932,6 +934,7 @@ def cron_run( max_tokens=config.agents.defaults.max_tokens, max_iterations=config.agents.defaults.max_tool_iterations, memory_window=config.agents.defaults.memory_window, + reasoning_effort=config.agents.defaults.reasoning_effort, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, restrict_to_workspace=config.tools.restrict_to_workspace, diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 1ff9782..4f06ebe 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -226,6 +226,7 @@ class AgentDefaults(Base): temperature: float = 0.1 max_tool_iterations: int = 40 memory_window: int = 100 + reasoning_effort: str | None = None # low / medium / high — enables LLM thinking mode class AgentsConfig(Base): diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index eb1599a..36e9938 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -88,6 +88,7 @@ class LLMProvider(ABC): model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + reasoning_effort: str | None = None, ) -> LLMResponse: """ Send a chat completion request. diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index a578d14..56e6270 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -18,13 +18,16 @@ class CustomProvider(LLMProvider): self._client = AsyncOpenAI(api_key=api_key, base_url=api_base) async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, - model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse: + model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + reasoning_effort: str | None = None) -> LLMResponse: kwargs: dict[str, Any] = { "model": model or self.default_model, "messages": self._sanitize_empty_content(messages), "max_tokens": max(1, max_tokens), "temperature": temperature, } + if reasoning_effort: + kwargs["reasoning_effort"] = reasoning_effort if tools: kwargs.update(tools=tools, tool_choice="auto") try: diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 5427d97..0067ae8 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -178,6 +178,7 @@ class LiteLLMProvider(LLMProvider): model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + reasoning_effort: str | None = None, ) -> LLMResponse: """ Send a chat completion request via LiteLLM. @@ -224,6 +225,10 @@ class LiteLLMProvider(LLMProvider): if self.extra_headers: kwargs["extra_headers"] = self.extra_headers + if reasoning_effort: + kwargs["reasoning_effort"] = reasoning_effort + kwargs["drop_params"] = True + if tools: kwargs["tools"] = tools kwargs["tool_choice"] = "auto" diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index fa28593..9039202 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -31,6 +31,7 @@ class OpenAICodexProvider(LLMProvider): model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + reasoning_effort: str | None = None, ) -> LLMResponse: model = model or self.default_model system_prompt, input_items = _convert_messages(messages) From 5ca386ebf52f36441b44dacd85072d79aea0dd98 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 17:37:12 +0000 Subject: [PATCH 44/78] fix: preserve reasoning_content and thinking_blocks in session history --- nanobot/agent/context.py | 3 +++ nanobot/agent/loop.py | 4 +++- nanobot/providers/base.py | 1 + nanobot/providers/litellm_provider.py | 4 +++- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index be0ec59..a469bc8 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -150,6 +150,7 @@ Reply directly with text for conversations. Only use the 'message' tool to send content: str | None, tool_calls: list[dict[str, Any]] | None = None, reasoning_content: str | None = None, + thinking_blocks: list[dict] | None = None, ) -> list[dict[str, Any]]: """Add an assistant message to the message list.""" msg: dict[str, Any] = {"role": "assistant", "content": content} @@ -157,5 +158,7 @@ Reply directly with text for conversations. Only use the 'message' tool to send msg["tool_calls"] = tool_calls if reasoning_content is not None: msg["reasoning_content"] = reasoning_content + if thinking_blocks: + msg["thinking_blocks"] = thinking_blocks messages.append(msg) return messages diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b42c3ba..8da9fcb 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -218,6 +218,7 @@ class AgentLoop: messages = self.context.add_assistant_message( messages, response.content, tool_call_dicts, reasoning_content=response.reasoning_content, + thinking_blocks=response.thinking_blocks, ) for tool_call in response.tool_calls: @@ -238,6 +239,7 @@ class AgentLoop: break messages = self.context.add_assistant_message( messages, clean, reasoning_content=response.reasoning_content, + thinking_blocks=response.thinking_blocks, ) final_content = clean break @@ -451,7 +453,7 @@ class AgentLoop: """Save new-turn messages into session, truncating large tool results.""" from datetime import datetime for m in messages[skip:]: - entry = {k: v for k, v in m.items() if k != "reasoning_content"} + entry = dict(m) 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 diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 36e9938..25932a3 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -21,6 +21,7 @@ class LLMResponse: finish_reason: str = "stop" usage: dict[str, int] = field(default_factory=dict) reasoning_content: str | None = None # Kimi, DeepSeek-R1 etc. + thinking_blocks: list[dict] | None = None # Anthropic extended thinking @property def has_tool_calls(self) -> bool: diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 0067ae8..aff2ac7 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -16,7 +16,7 @@ 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"}) +_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content", "thinking_blocks"}) _ALNUM = string.ascii_letters + string.digits def _short_tool_id() -> str: @@ -271,6 +271,7 @@ class LiteLLMProvider(LLMProvider): } reasoning_content = getattr(message, "reasoning_content", None) or None + thinking_blocks = getattr(message, "thinking_blocks", None) or None return LLMResponse( content=message.content, @@ -278,6 +279,7 @@ class LiteLLMProvider(LLMProvider): finish_reason=choice.finish_reason or "stop", usage=usage, reasoning_content=reasoning_content, + thinking_blocks=thinking_blocks, ) def get_default_model(self) -> str: From 4f0530dd6147b057ef44af278ee577cf21ecebd5 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 17:55:18 +0000 Subject: [PATCH 45/78] release: v0.1.4.post3 --- nanobot/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/__init__.py b/nanobot/__init__.py index bb9bfb6..4dba5f4 100644 --- a/nanobot/__init__.py +++ b/nanobot/__init__.py @@ -2,5 +2,5 @@ nanobot - A lightweight AI agent framework """ -__version__ = "0.1.4.post2" +__version__ = "0.1.4.post3" __logo__ = "🐈" diff --git a/pyproject.toml b/pyproject.toml index 20dcb1e..a22053c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.4.post2" +version = "0.1.4.post3" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} From ee9bd6a96c736295b54878f65a9489260a222c7d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 18:04:12 +0000 Subject: [PATCH 46/78] docs: update v0.1.4.post3 release news --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 0d46b7f..4ae9aa2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ ## 📢 News +- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details. +- **2026-02-27** 🧠 Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes. +- **2026-02-26** 🛡️ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility. +- **2026-02-25** 🧹 New Matrix channel, cleaner session context, auto workspace template sync. - **2026-02-24** 🚀 Released **v0.1.4.post2** — a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details. - **2026-02-23** 🔧 Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes. - **2026-02-22** 🛡️ Slack thread isolation, Discord typing fix, agent reliability improvements. From f172c9f381980a870ac47283a58136f09314b184 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 18:06:56 +0000 Subject: [PATCH 47/78] docs: reformat release news with v0.1.4.post3 release --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4ae9aa2..45779e7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## 📢 News -- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details. +- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details. - **2026-02-27** 🧠 Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes. - **2026-02-26** 🛡️ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility. - **2026-02-25** 🧹 New Matrix channel, cleaner session context, auto workspace template sync. @@ -30,6 +30,10 @@ - **2026-02-21** 🎉 Released **v0.1.4.post1** — new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details. - **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood. - **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode. + +
+Earlier news + - **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching. - **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details. - **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills. @@ -38,10 +42,6 @@ - **2026-02-13** 🎉 Released **v0.1.3.post7** — includes security hardening and multiple improvements. **Please upgrade to the latest version to address security issues**. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. - **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it! - **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support! - -
-Earlier news - - **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms! - **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). From 2fc16596d0c0a0d255417507f544d4405365be4f Mon Sep 17 00:00:00 2001 From: yzchen Date: Sun, 1 Mar 2026 02:17:10 +0800 Subject: [PATCH 48/78] fix(feishu): parse post wrapper payload for rich text messages --- nanobot/channels/feishu.py | 20 +++++++++++++--- tests/test_feishu_post_content.py | 40 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 tests/test_feishu_post_content.py diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index c632fb7..f6ba74a 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -221,18 +221,32 @@ def _extract_post_content(content_json: dict) -> tuple[str, list[str]]: text = " ".join(text_parts).strip() if text_parts else None return text, image_keys + # Compatible with both shapes: + # 1) {"post": {"zh_cn": {...}}} + # 2) {"zh_cn": {...}} or {"title": "...", "content": [...]} + post_root = content_json.get("post") if isinstance(content_json, dict) else None + if not isinstance(post_root, dict): + post_root = content_json if isinstance(content_json, dict) else {} + # Try direct format first - if "content" in content_json: - text, images = extract_from_lang(content_json) + if "content" in post_root: + text, images = extract_from_lang(post_root) if text or images: return text or "", images # Try localized format for lang_key in ("zh_cn", "en_us", "ja_jp"): - lang_content = content_json.get(lang_key) + lang_content = post_root.get(lang_key) text, images = extract_from_lang(lang_content) if text or images: return text or "", images + + # Fallback: first dict-shaped child + for value in post_root.values(): + if isinstance(value, dict): + text, images = extract_from_lang(value) + if text or images: + return text or "", images return "", [] diff --git a/tests/test_feishu_post_content.py b/tests/test_feishu_post_content.py new file mode 100644 index 0000000..bf1ea82 --- /dev/null +++ b/tests/test_feishu_post_content.py @@ -0,0 +1,40 @@ +from nanobot.channels.feishu import _extract_post_content + + +def test_extract_post_content_supports_post_wrapper_shape() -> None: + payload = { + "post": { + "zh_cn": { + "title": "日报", + "content": [ + [ + {"tag": "text", "text": "完成"}, + {"tag": "img", "image_key": "img_1"}, + ] + ], + } + } + } + + text, image_keys = _extract_post_content(payload) + + assert text == "日报 完成" + assert image_keys == ["img_1"] + + +def test_extract_post_content_keeps_direct_shape_behavior() -> None: + payload = { + "title": "Daily", + "content": [ + [ + {"tag": "text", "text": "report"}, + {"tag": "img", "image_key": "img_a"}, + {"tag": "img", "image_key": "img_b"}, + ] + ], + } + + text, image_keys = _extract_post_content(payload) + + assert text == "Daily report" + assert image_keys == ["img_a", "img_b"] From 89e5a28097bf70c4f160c384df600e121cd9caea Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 1 Mar 2026 06:01:47 +0000 Subject: [PATCH 49/78] fix(cron): auto-reload jobs.json when modified externally --- nanobot/cron/service.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 6889a10..7c7b3e5 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -68,13 +68,19 @@ class CronService: on_job: Callable[[CronJob], Coroutine[Any, Any, str | None]] | None = None ): self.store_path = store_path - self.on_job = on_job # Callback to execute job, returns response text + self.on_job = on_job self._store: CronStore | None = None + self._last_mtime: float = 0.0 self._timer_task: asyncio.Task | None = None self._running = False def _load_store(self) -> CronStore: - """Load jobs from disk.""" + """Load jobs from disk. Reloads automatically if file was modified externally.""" + if self._store and self.store_path.exists(): + mtime = self.store_path.stat().st_mtime + if mtime != self._last_mtime: + logger.info("Cron: jobs.json modified externally, reloading") + self._store = None if self._store: return self._store @@ -163,6 +169,7 @@ class CronService: } self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + self._last_mtime = self.store_path.stat().st_mtime async def start(self) -> None: """Start the cron service.""" From 4752e95a24ca52edfcd09e07433d26db81e5645f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 1 Mar 2026 06:36:29 +0000 Subject: [PATCH 50/78] merge origin/main into pr-1361 --- nanobot/channels/feishu.py | 104 ++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 58 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 6bc0ebd..0a0a5e4 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -181,71 +181,59 @@ def _extract_element_content(element: dict) -> list[str]: def _extract_post_content(content_json: dict) -> tuple[str, list[str]]: - """Extract text and image keys from Feishu post (rich text) message content. + """Extract text and image keys from Feishu post (rich text) message. - Supports two formats: - 1. Direct format: {"title": "...", "content": [...]} - 2. Localized format: {"zh_cn": {"title": "...", "content": [...]}} - - Returns: - (text, image_keys) - extracted text and list of image keys + Handles three payload shapes: + - Direct: {"title": "...", "content": [[...]]} + - Localized: {"zh_cn": {"title": "...", "content": [...]}} + - Wrapped: {"post": {"zh_cn": {"title": "...", "content": [...]}}} """ - def extract_from_lang(lang_content: dict) -> tuple[str | None, list[str]]: - if not isinstance(lang_content, dict): + + def _parse_block(block: dict) -> tuple[str | None, list[str]]: + if not isinstance(block, dict) or not isinstance(block.get("content"), list): return None, [] - title = lang_content.get("title", "") - content_blocks = lang_content.get("content", []) - if not isinstance(content_blocks, list): - return None, [] - text_parts = [] - image_keys = [] - if title: - text_parts.append(title) - for block in content_blocks: - if not isinstance(block, list): + texts, images = [], [] + if title := block.get("title"): + texts.append(title) + for row in block["content"]: + if not isinstance(row, list): continue - for element in block: - if isinstance(element, dict): - tag = element.get("tag") - if tag == "text": - text_parts.append(element.get("text", "")) - elif tag == "a": - text_parts.append(element.get("text", "")) - elif tag == "at": - text_parts.append(f"@{element.get('user_name', 'user')}") - elif tag == "img": - img_key = element.get("image_key") - if img_key: - image_keys.append(img_key) - text = " ".join(text_parts).strip() if text_parts else None - return text, image_keys + for el in row: + if not isinstance(el, dict): + continue + tag = el.get("tag") + if tag in ("text", "a"): + texts.append(el.get("text", "")) + elif tag == "at": + texts.append(f"@{el.get('user_name', 'user')}") + elif tag == "img" and (key := el.get("image_key")): + images.append(key) + return (" ".join(texts).strip() or None), images - # Compatible with both shapes: - # 1) {"post": {"zh_cn": {...}}} - # 2) {"zh_cn": {...}} or {"title": "...", "content": [...]} - post_root = content_json.get("post") if isinstance(content_json, dict) else None - if not isinstance(post_root, dict): - post_root = content_json if isinstance(content_json, dict) else {} + # Unwrap optional {"post": ...} envelope + root = content_json + if isinstance(root, dict) and isinstance(root.get("post"), dict): + root = root["post"] + if not isinstance(root, dict): + return "", [] - # Try direct format first - if "content" in post_root: - text, images = extract_from_lang(post_root) - if text or images: - return text or "", images + # Direct format + if "content" in root: + text, imgs = _parse_block(root) + if text or imgs: + return text or "", imgs - # Try localized format - for lang_key in ("zh_cn", "en_us", "ja_jp"): - lang_content = post_root.get(lang_key) - text, images = extract_from_lang(lang_content) - if text or images: - return text or "", images - - # Fallback: first dict-shaped child - for value in post_root.values(): - if isinstance(value, dict): - text, images = extract_from_lang(value) - if text or images: - return text or "", images + # Localized: prefer known locales, then fall back to any dict child + for key in ("zh_cn", "en_us", "ja_jp"): + if key in root: + text, imgs = _parse_block(root[key]) + if text or imgs: + return text or "", imgs + for val in root.values(): + if isinstance(val, dict): + text, imgs = _parse_block(val) + if text or imgs: + return text or "", imgs return "", [] From 82be2ae1a5cbf0d2579c4fc346c2562464f85dd8 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sun, 1 Mar 2026 13:27:46 +0800 Subject: [PATCH 51/78] feat(tool): add web search proxy --- nanobot/agent/loop.py | 7 +++++-- nanobot/agent/subagent.py | 6 ++++-- nanobot/agent/tools/web.py | 30 +++++++++++++++++++++++++----- nanobot/cli/commands.py | 3 +++ nanobot/config/schema.py | 1 + 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 8da9fcb..488615d 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -58,6 +58,7 @@ class AgentLoop: memory_window: int = 100, reasoning_effort: str | None = None, brave_api_key: str | None = None, + web_proxy: str | None = None, exec_config: ExecToolConfig | None = None, cron_service: CronService | None = None, restrict_to_workspace: bool = False, @@ -77,6 +78,7 @@ class AgentLoop: self.memory_window = memory_window self.reasoning_effort = reasoning_effort self.brave_api_key = brave_api_key + self.web_proxy = web_proxy self.exec_config = exec_config or ExecToolConfig() self.cron_service = cron_service self.restrict_to_workspace = restrict_to_workspace @@ -93,6 +95,7 @@ class AgentLoop: max_tokens=self.max_tokens, reasoning_effort=reasoning_effort, brave_api_key=brave_api_key, + web_proxy=web_proxy, exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, ) @@ -120,8 +123,8 @@ class AgentLoop: restrict_to_workspace=self.restrict_to_workspace, path_append=self.exec_config.path_append, )) - self.tools.register(WebSearchTool(api_key=self.brave_api_key)) - self.tools.register(WebFetchTool()) + self.tools.register(WebSearchTool(api_key=self.brave_api_key, proxy=self.web_proxy)) + self.tools.register(WebFetchTool(proxy=self.web_proxy)) self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) self.tools.register(SpawnTool(manager=self.subagents)) if self.cron_service: diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 9b543dc..f2d6ee5 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -31,6 +31,7 @@ class SubagentManager: max_tokens: int = 4096, reasoning_effort: str | None = None, brave_api_key: str | None = None, + web_proxy: str | None = None, exec_config: "ExecToolConfig | None" = None, restrict_to_workspace: bool = False, ): @@ -43,6 +44,7 @@ class SubagentManager: self.max_tokens = max_tokens self.reasoning_effort = reasoning_effort self.brave_api_key = brave_api_key + self.web_proxy = web_proxy self.exec_config = exec_config or ExecToolConfig() self.restrict_to_workspace = restrict_to_workspace self._running_tasks: dict[str, asyncio.Task[None]] = {} @@ -104,8 +106,8 @@ class SubagentManager: restrict_to_workspace=self.restrict_to_workspace, path_append=self.exec_config.path_append, )) - tools.register(WebSearchTool(api_key=self.brave_api_key)) - tools.register(WebFetchTool()) + tools.register(WebSearchTool(api_key=self.brave_api_key, proxy=self.web_proxy)) + tools.register(WebFetchTool(proxy=self.web_proxy)) system_prompt = self._build_subagent_prompt() messages: list[dict[str, Any]] = [ diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index e817a4c..0d2135d 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -8,6 +8,7 @@ from typing import Any from urllib.parse import urlparse import httpx +from loguru import logger from nanobot.agent.tools.base import Tool @@ -57,9 +58,10 @@ class WebSearchTool(Tool): "required": ["query"] } - def __init__(self, api_key: str | None = None, max_results: int = 5): + def __init__(self, api_key: str | None = None, max_results: int = 5, proxy: str | None = None): self._init_api_key = api_key self.max_results = max_results + self.proxy = proxy @property def api_key(self) -> str: @@ -71,12 +73,16 @@ class WebSearchTool(Tool): return ( "Error: Brave Search API key not configured. " "Set it in ~/.nanobot/config.json under tools.web.search.apiKey " - "(or export BRAVE_API_KEY), then restart the gateway." + "(or export BRAIVE_API_KEY), then restart the gateway." ) try: n = min(max(count or self.max_results, 1), 10) - async with httpx.AsyncClient() as client: + if self.proxy: + logger.info("WebSearch: using proxy {} for query: {}", self.proxy, query[:50]) + else: + logger.debug("WebSearch: direct connection for query: {}", query[:50]) + async with httpx.AsyncClient(proxy=self.proxy) as client: r = await client.get( "https://api.search.brave.com/res/v1/web/search", params={"q": query, "count": n}, @@ -95,7 +101,11 @@ class WebSearchTool(Tool): if desc := item.get("description"): lines.append(f" {desc}") return "\n".join(lines) + except httpx.ProxyError as e: + logger.error("WebSearch proxy error: {}", e) + return f"Proxy error: {e}" except Exception as e: + logger.error("WebSearch error: {}", e) return f"Error: {e}" @@ -114,8 +124,9 @@ class WebFetchTool(Tool): "required": ["url"] } - def __init__(self, max_chars: int = 50000): + def __init__(self, max_chars: int = 50000, proxy: str | None = None): self.max_chars = max_chars + self.proxy = proxy async def execute(self, url: str, extractMode: str = "markdown", maxChars: int | None = None, **kwargs: Any) -> str: from readability import Document @@ -128,10 +139,15 @@ class WebFetchTool(Tool): return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False) try: + if self.proxy: + logger.info("WebFetch: using proxy {} for {}", self.proxy, url) + else: + logger.debug("WebFetch: direct connection for {}", url) async with httpx.AsyncClient( follow_redirects=True, max_redirects=MAX_REDIRECTS, - timeout=30.0 + timeout=30.0, + proxy=self.proxy, ) as client: r = await client.get(url, headers={"User-Agent": USER_AGENT}) r.raise_for_status() @@ -156,7 +172,11 @@ class WebFetchTool(Tool): return json.dumps({"url": url, "finalUrl": str(r.url), "status": r.status_code, "extractor": extractor, "truncated": truncated, "length": len(text), "text": text}, ensure_ascii=False) + except httpx.ProxyError as e: + logger.error("WebFetch proxy error for {}: {}", url, e) + return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False) except Exception as e: + logger.error("WebFetch error for {}: {}", url, e) return json.dumps({"error": str(e), "url": url}, ensure_ascii=False) def _to_markdown(self, html: str) -> str: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 4987c84..25fa8e1 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -284,6 +284,7 @@ def gateway( memory_window=config.agents.defaults.memory_window, reasoning_effort=config.agents.defaults.reasoning_effort, brave_api_key=config.tools.web.search.api_key or None, + web_proxy=config.tools.web.proxy or None, exec_config=config.tools.exec, cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, @@ -444,6 +445,7 @@ def agent( memory_window=config.agents.defaults.memory_window, reasoning_effort=config.agents.defaults.reasoning_effort, brave_api_key=config.tools.web.search.api_key or None, + web_proxy=config.tools.web.proxy or None, exec_config=config.tools.exec, cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, @@ -938,6 +940,7 @@ def cron_run( memory_window=config.agents.defaults.memory_window, reasoning_effort=config.agents.defaults.reasoning_effort, brave_api_key=config.tools.web.search.api_key or None, + web_proxy=config.tools.web.proxy or None, exec_config=config.tools.exec, restrict_to_workspace=config.tools.restrict_to_workspace, mcp_servers=config.tools.mcp_servers, diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 091a210..6b80c81 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -290,6 +290,7 @@ class WebSearchConfig(Base): class WebToolsConfig(Base): """Web tools configuration.""" + proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" search: WebSearchConfig = Field(default_factory=WebSearchConfig) From 468dfc406bfdd96eb6049852f679336f8d13bbf2 Mon Sep 17 00:00:00 2001 From: VITOHJL Date: Sun, 1 Mar 2026 17:05:04 +0800 Subject: [PATCH 52/78] feat(cron): improve cron job context handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve cron job execution context to ensure proper message delivery and session history recording. Changes: - Add [绯荤粺瀹氭椂浠诲姟] prefix to cron reminder messages to clearly mark them as system-driven, not user queries - Use user role for cron reminder messages (required by some LLM APIs) - Properly handle MessageTool to avoid duplicate message delivery - Correctly save turn history with proper skip count - Ensure Runtime Context is included in the message list This ensures that: 1. Cron jobs execute with proper context 2. Messages are correctly delivered to users 3. Session history accurately records cron job interactions 4. The LLM understands these are system-driven reminders, not user queries --- nanobot/cli/commands.py | 51 ++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 4987c84..4b70f32 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -295,20 +295,55 @@ def gateway( # Set cron callback (needs agent) async def on_cron_job(job: CronJob) -> str | None: """Execute a cron job through the agent.""" - response = await agent.process_direct( - job.payload.message, - session_key=f"cron:{job.id}", - channel=job.payload.channel or "cli", - chat_id=job.payload.to or "direct", + from nanobot.agent.tools.message import MessageTool + + cron_session_key = f"cron:{job.id}" + cron_session = agent.sessions.get_or_create(cron_session_key) + + reminder_note = ( + f"[系统定时任务] ⏰ 计时已结束\n\n" + f"定时任务 '{job.name}' 已触发。定时内容:{job.payload.message}\n\n" ) - if job.payload.deliver and job.payload.to: + + cron_session.add_message(role="user", content=reminder_note) + agent.sessions.save(cron_session) + + agent._set_tool_context( + job.payload.channel or "cli", + job.payload.to or "direct", + None + ) + + message_tool = agent.tools.get("message") + if isinstance(message_tool, MessageTool): + message_tool.start_turn() + + history = cron_session.get_history(max_messages=agent.memory_window) + + messages = [ + {"role": "system", "content": agent.context.build_system_prompt()}, + *history, + {"role": "user", "content": agent.context._build_runtime_context( + job.payload.channel or "cli", + job.payload.to or "direct" + )}, + ] + + final_content, _, all_msgs = await agent._run_agent_loop(messages) + agent._save_turn(cron_session, all_msgs, 1 + len(history) + 1) + agent.sessions.save(cron_session) + + if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: + return final_content + + if job.payload.deliver and job.payload.to and final_content: from nanobot.bus.events import OutboundMessage await bus.publish_outbound(OutboundMessage( channel=job.payload.channel or "cli", chat_id=job.payload.to, - content=response or "" + content=final_content )) - return response + return final_content cron.on_job = on_cron_job # Create channel manager From a7d24192d94c413c035284e7f1905666c63bed80 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 1 Mar 2026 12:45:53 +0000 Subject: [PATCH 53/78] fix(cron): route scheduled jobs through process_direct with english reminder prefix --- nanobot/cli/commands.py | 53 ++++++++++++----------------------------- 1 file changed, 15 insertions(+), 38 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 4b70f32..fbc8e20 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -296,54 +296,31 @@ def gateway( async def on_cron_job(job: CronJob) -> str | None: """Execute a cron job through the agent.""" from nanobot.agent.tools.message import MessageTool - - cron_session_key = f"cron:{job.id}" - cron_session = agent.sessions.get_or_create(cron_session_key) - reminder_note = ( - f"[系统定时任务] ⏰ 计时已结束\n\n" - f"定时任务 '{job.name}' 已触发。定时内容:{job.payload.message}\n\n" + "[Scheduled Task] Timer finished.\n\n" + f"Task '{job.name}' has been triggered.\n" + f"Scheduled instruction: {job.payload.message}" ) - - cron_session.add_message(role="user", content=reminder_note) - agent.sessions.save(cron_session) - - agent._set_tool_context( - job.payload.channel or "cli", - job.payload.to or "direct", - None + + response = await agent.process_direct( + reminder_note, + session_key=f"cron:{job.id}", + channel=job.payload.channel or "cli", + chat_id=job.payload.to or "direct", ) - + message_tool = agent.tools.get("message") - if isinstance(message_tool, MessageTool): - message_tool.start_turn() - - history = cron_session.get_history(max_messages=agent.memory_window) - - messages = [ - {"role": "system", "content": agent.context.build_system_prompt()}, - *history, - {"role": "user", "content": agent.context._build_runtime_context( - job.payload.channel or "cli", - job.payload.to or "direct" - )}, - ] - - final_content, _, all_msgs = await agent._run_agent_loop(messages) - agent._save_turn(cron_session, all_msgs, 1 + len(history) + 1) - agent.sessions.save(cron_session) - if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: - return final_content - - if job.payload.deliver and job.payload.to and final_content: + return response + + if job.payload.deliver and job.payload.to and response: from nanobot.bus.events import OutboundMessage await bus.publish_outbound(OutboundMessage( channel=job.payload.channel or "cli", chat_id=job.payload.to, - content=final_content + content=response )) - return final_content + return response cron.on_job = on_cron_job # Create channel manager From 15529c668e51c623ab860509b12346e3dfe956d6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 1 Mar 2026 12:53:18 +0000 Subject: [PATCH 54/78] fix(web): sanitize proxy logs and polish search key hint --- nanobot/agent/tools/web.py | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 0d2135d..0d8f4d1 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -71,17 +71,14 @@ class WebSearchTool(Tool): async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str: if not self.api_key: return ( - "Error: Brave Search API key not configured. " - "Set it in ~/.nanobot/config.json under tools.web.search.apiKey " - "(or export BRAIVE_API_KEY), then restart the gateway." + "Error: Brave Search API key not configured. Set it in " + "~/.nanobot/config.json under tools.web.search.apiKey " + "(or export BRAVE_API_KEY), then restart the gateway." ) try: n = min(max(count or self.max_results, 1), 10) - if self.proxy: - logger.info("WebSearch: using proxy {} for query: {}", self.proxy, query[:50]) - else: - logger.debug("WebSearch: direct connection for query: {}", query[:50]) + logger.debug("WebSearch: {}", "proxy enabled" if self.proxy else "direct connection") async with httpx.AsyncClient(proxy=self.proxy) as client: r = await client.get( "https://api.search.brave.com/res/v1/web/search", @@ -91,12 +88,12 @@ class WebSearchTool(Tool): ) r.raise_for_status() - results = r.json().get("web", {}).get("results", []) + results = r.json().get("web", {}).get("results", [])[:n] if not results: return f"No results for: {query}" lines = [f"Results for: {query}\n"] - for i, item in enumerate(results[:n], 1): + for i, item in enumerate(results, 1): lines.append(f"{i}. {item.get('title', '')}\n {item.get('url', '')}") if desc := item.get("description"): lines.append(f" {desc}") @@ -132,17 +129,12 @@ class WebFetchTool(Tool): from readability import Document max_chars = maxChars or self.max_chars - - # Validate URL before fetching is_valid, error_msg = _validate_url(url) if not is_valid: return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False) try: - if self.proxy: - logger.info("WebFetch: using proxy {} for {}", self.proxy, url) - else: - logger.debug("WebFetch: direct connection for {}", url) + logger.debug("WebFetch: {}", "proxy enabled" if self.proxy else "direct connection") async with httpx.AsyncClient( follow_redirects=True, max_redirects=MAX_REDIRECTS, @@ -154,10 +146,8 @@ class WebFetchTool(Tool): ctype = r.headers.get("content-type", "") - # JSON if "application/json" in ctype: text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json" - # HTML elif "text/html" in ctype or r.text[:256].lower().startswith((" max_chars - if truncated: - text = text[:max_chars] + if truncated: text = text[:max_chars] return json.dumps({"url": url, "finalUrl": str(r.url), "status": r.status_code, "extractor": extractor, "truncated": truncated, "length": len(text), "text": text}, ensure_ascii=False) From dba93ae83afe0a91a7fd6a79f40eb81ab30a5e14 Mon Sep 17 00:00:00 2001 From: yzchen Date: Mon, 2 Mar 2026 11:19:45 +0800 Subject: [PATCH 55/78] cron: reload jobs store on each timer tick --- nanobot/cron/service.py | 2 ++ tests/test_cron_service.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index c3864ae..811dc3b 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -226,6 +226,8 @@ class CronService: async def _on_timer(self) -> None: """Handle timer tick - run due jobs.""" + # Pick up external CLI/file changes before deciding due jobs. + self._load_store() if not self._store: return diff --git a/tests/test_cron_service.py b/tests/test_cron_service.py index 07e990a..2a36f4c 100644 --- a/tests/test_cron_service.py +++ b/tests/test_cron_service.py @@ -1,3 +1,5 @@ +import asyncio + import pytest from nanobot.cron.service import CronService @@ -28,3 +30,30 @@ def test_add_job_accepts_valid_timezone(tmp_path) -> None: assert job.schedule.tz == "America/Vancouver" assert job.state.next_run_at_ms is not None + + +@pytest.mark.asyncio +async def test_running_service_honors_external_disable(tmp_path) -> None: + store_path = tmp_path / "cron" / "jobs.json" + called: list[str] = [] + + async def on_job(job) -> None: + called.append(job.id) + + service = CronService(store_path, on_job=on_job) + job = service.add_job( + name="external-disable", + schedule=CronSchedule(kind="every", every_ms=200), + message="hello", + ) + await service.start() + try: + external = CronService(store_path) + updated = external.enable_job(job.id, enabled=False) + assert updated is not None + assert updated.enabled is False + + await asyncio.sleep(0.35) + assert called == [] + finally: + service.stop() From d447be5ca22c335945c51d15d119562af82b27e8 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 2 Mar 2026 13:17:39 +0800 Subject: [PATCH 56/78] security: deny by default in is_allowed for all channels When allow_from is not configured, block all access by default instead of allowing everyone. This prevents unauthorized access when channels are enabled without explicit allow lists. --- nanobot/channels/base.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index f795931..d73d34c 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -70,9 +70,16 @@ class BaseChannel(ABC): """ allow_list = getattr(self.config, "allow_from", []) - # If no allow list, allow everyone + # Security fix: If no allow list, deny everyone by default + # This prevents unauthorized access when allow_from is not configured if not allow_list: - return True + logger.warning( + "Channel {} has no allow_from configured - " + "blocking all access by default for security. " + "Add allowed senders to config to enable access.", + self.name, + ) + return False sender_str = str(sender_id) if sender_str in allow_list: From 2c63946519eb7ba63d4c6613510dd1d50bda9353 Mon Sep 17 00:00:00 2001 From: Wenjie Lei Date: Sun, 1 Mar 2026 21:56:08 -0800 Subject: [PATCH 57/78] fix(matrix): normalize media metadata and keyword-call attachment upload --- nanobot/channels/matrix.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 43fc573..c6b1f91 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -362,7 +362,11 @@ class MatrixChannel(BaseChannel): limit_bytes = await self._effective_media_limit_bytes() for path in candidates: if fail := await self._upload_and_send_attachment( - msg.chat_id, path, limit_bytes, relates_to): + room_id=msg.chat_id, + path=path, + limit_bytes=limit_bytes, + relates_to=relates_to, + ): failures.append(fail) if failures: text = f"{text.rstrip()}\n{chr(10).join(failures)}" if text.strip() else "\n".join(failures) @@ -676,11 +680,13 @@ class MatrixChannel(BaseChannel): parts: list[str] = [] if isinstance(body := getattr(event, "body", None), str) and body.strip(): parts.append(body.strip()) - parts.append(marker) + if marker: + parts.append(marker) await self._start_typing_keepalive(room.room_id) try: meta = self._base_metadata(room, event) + meta["attachments"] = [] if attachment: meta["attachments"] = [attachment] await self._handle_message( From bbfc1b40c1251814e70a55cc947b48375c3bbc71 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 2 Mar 2026 06:13:37 +0000 Subject: [PATCH 58/78] security: deny-by-default allowFrom with wildcard support and startup validation --- README.md | 18 ++++++++++-------- SECURITY.md | 5 ++--- nanobot/channels/base.py | 33 +++++++-------------------------- nanobot/channels/manager.py | 10 ++++++++++ nanobot/channels/matrix.py | 3 +-- nanobot/config/schema.py | 1 + 6 files changed, 31 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 45779e7..01da228 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,7 @@ pip install nanobot-ai[matrix] "accessToken": "syt_xxx", "deviceId": "NANOBOT01", "e2eeEnabled": true, - "allowFrom": [], + "allowFrom": ["@your_user:matrix.org"], "groupPolicy": "open", "groupAllowFrom": [], "allowRoomMentions": false, @@ -441,14 +441,14 @@ Uses **WebSocket** long connection — no public IP required. "appSecret": "xxx", "encryptKey": "", "verificationToken": "", - "allowFrom": [] + "allowFrom": ["ou_YOUR_OPEN_ID"] } } } ``` > `encryptKey` and `verificationToken` are optional for Long Connection mode. -> `allowFrom`: Leave empty to allow all users, or add `["ou_xxx"]` to restrict access. +> `allowFrom`: Add your open_id (find it in nanobot logs when you message the bot). Use `["*"]` to allow all users. **3. Run** @@ -478,7 +478,7 @@ Uses **botpy SDK** with WebSocket — no public IP required. Currently supports **3. Configure** -> - `allowFrom`: Leave empty for public access, or add user openids to restrict. You can find openids in the nanobot logs when a user messages the bot. +> - `allowFrom`: Add your openid (find it in nanobot logs when you message the bot). Use `["*"]` for public access. > - For production: submit a review in the bot console and publish. See [QQ Bot Docs](https://bot.q.qq.com/wiki/) for the full publishing flow. ```json @@ -488,7 +488,7 @@ Uses **botpy SDK** with WebSocket — no public IP required. Currently supports "enabled": true, "appId": "YOUR_APP_ID", "secret": "YOUR_APP_SECRET", - "allowFrom": [] + "allowFrom": ["YOUR_OPENID"] } } } @@ -527,13 +527,13 @@ Uses **Stream Mode** — no public IP required. "enabled": true, "clientId": "YOUR_APP_KEY", "clientSecret": "YOUR_APP_SECRET", - "allowFrom": [] + "allowFrom": ["YOUR_STAFF_ID"] } } } ``` -> `allowFrom`: Leave empty to allow all users, or add `["staffId"]` to restrict access. +> `allowFrom`: Add your staff ID. Use `["*"]` to allow all users. **3. Run** @@ -568,6 +568,7 @@ Uses **Socket Mode** — no public URL required. "enabled": true, "botToken": "xoxb-...", "appToken": "xapp-...", + "allowFrom": ["YOUR_SLACK_USER_ID"], "groupPolicy": "mention" } } @@ -601,7 +602,7 @@ Give nanobot its own email account. It polls **IMAP** for incoming mail and repl **2. Configure** > - `consentGranted` must be `true` to allow mailbox access. This is a safety gate — set `false` to fully disable. -> - `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific senders. +> - `allowFrom`: Add your email address. Use `["*"]` to accept emails from anyone. > - `smtpUseTls` and `smtpUseSsl` default to `true` / `false` respectively, which is correct for Gmail (port 587 + STARTTLS). No need to set them explicitly. > - Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies. @@ -874,6 +875,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us > [!TIP] > For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent. +> **Change in source / post-`v0.1.4.post3`:** In `v0.1.4.post3` and earlier, an empty `allowFrom` means "allow all senders". In newer versions (including building from source), **empty `allowFrom` denies all access by default**. To allow all senders, set `"allowFrom": ["*"]`. | Option | Default | Description | |--------|---------|-------------| diff --git a/SECURITY.md b/SECURITY.md index 405ce52..af4da71 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,7 +55,7 @@ chmod 600 ~/.nanobot/config.json ``` **Security Notes:** -- Empty `allowFrom` list will **ALLOW ALL** users (open by default for personal use) +- In `v0.1.4.post3` and earlier, an empty `allowFrom` allows all users. In newer versions (including source builds), **empty `allowFrom` denies all access** — set `["*"]` to explicitly allow everyone. - Get your Telegram user ID from `@userinfobot` - Use full phone numbers with country code for WhatsApp - Review access logs regularly for unauthorized access attempts @@ -212,9 +212,8 @@ If you suspect a security breach: - Input length limits on HTTP requests ✅ **Authentication** -- Allow-list based access control +- Allow-list based access control — in `v0.1.4.post3` and earlier empty means allow all; in newer versions empty means deny all (`["*"]` to explicitly allow all) - Failed authentication attempt logging -- Open by default (configure allowFrom for production use) ✅ **Resource Protection** - Command execution timeouts (60s default) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index d73d34c..b38fcaf 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -59,36 +59,17 @@ class BaseChannel(ABC): pass def is_allowed(self, sender_id: str) -> bool: - """ - Check if a sender is allowed to use this bot. - - Args: - sender_id: The sender's identifier. - - Returns: - True if allowed, False otherwise. - """ + """Check if *sender_id* is permitted. Empty list → deny all; ``"*"`` → allow all.""" allow_list = getattr(self.config, "allow_from", []) - - # Security fix: If no allow list, deny everyone by default - # This prevents unauthorized access when allow_from is not configured if not allow_list: - logger.warning( - "Channel {} has no allow_from configured - " - "blocking all access by default for security. " - "Add allowed senders to config to enable access.", - self.name, - ) + logger.warning("{}: allow_from is empty — all access denied", self.name) return False - - sender_str = str(sender_id) - if sender_str in allow_list: + if "*" in allow_list: return True - if "|" in sender_str: - for part in sender_str.split("|"): - if part and part in allow_list: - return True - return False + sender_str = str(sender_id) + return sender_str in allow_list or any( + p in allow_list for p in sender_str.split("|") if p + ) async def _handle_message( self, diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 4b40d0e..7d7d110 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -149,6 +149,16 @@ class ChannelManager: except ImportError as e: logger.warning("Matrix channel not available: {}", e) + self._validate_allow_from() + + def _validate_allow_from(self) -> None: + for name, ch in self.channels.items(): + if getattr(ch.config, "allow_from", None) == []: + raise SystemExit( + f'Error: "{name}" has empty allowFrom (denies all). ' + f'Set ["*"] to allow everyone, or add specific user IDs.' + ) + async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" try: diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 43fc573..b19975c 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -450,8 +450,7 @@ class MatrixChannel(BaseChannel): await asyncio.sleep(2) async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None: - allow_from = self.config.allow_from or [] - if not allow_from or event.sender in allow_from: + if self.is_allowed(event.sender): await self.client.join(room.room_id) def _is_direct_room(self, room: MatrixRoom) -> bool: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 6b80c81..61a7bd2 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -171,6 +171,7 @@ class SlackConfig(Base): user_token_read_only: bool = True reply_in_thread: bool = True react_emoji: str = "eyes" + allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs (sender-level) group_policy: str = "mention" # "mention", "open", "allowlist" group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist dm: SlackDMConfig = Field(default_factory=SlackDMConfig) From 9877195de57817101485f3effe7780f81c86f2d7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 2 Mar 2026 06:37:57 +0000 Subject: [PATCH 59/78] chore(cron): remove redundant timer comment --- nanobot/cron/service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 811dc3b..1ed71f0 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -226,7 +226,6 @@ class CronService: async def _on_timer(self) -> None: """Handle timer tick - run due jobs.""" - # Pick up external CLI/file changes before deciding due jobs. self._load_store() if not self._store: return From 3c79404194ad95702bc49a908ce984b6a26c26c9 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 2 Mar 2026 06:58:10 +0000 Subject: [PATCH 60/78] fix(providers): sanitize thinking_blocks by provider and harden content normalization --- nanobot/providers/base.py | 6 ++++++ nanobot/providers/litellm_provider.py | 22 ++++++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index af23a4c..55bd805 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -78,6 +78,12 @@ class LLMProvider(ABC): result.append(clean) continue + if isinstance(content, dict): + clean = dict(msg) + clean["content"] = [content] + result.append(clean) + continue + result.append(msg) return result diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 79277bc..d8d8ace 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -12,9 +12,9 @@ from litellm import acompletion from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest 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", "thinking_blocks"}) +# Standard chat-completion message keys. +_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"}) +_ANTHROPIC_EXTRA_KEYS = frozenset({"thinking_blocks"}) _ALNUM = string.ascii_letters + string.digits def _short_tool_id() -> str: @@ -158,11 +158,20 @@ class LiteLLMProvider(LLMProvider): return @staticmethod - def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + def _extra_msg_keys(original_model: str, resolved_model: str) -> frozenset[str]: + """Return provider-specific extra keys to preserve in request messages.""" + spec = find_by_model(original_model) or find_by_model(resolved_model) + if (spec and spec.name == "anthropic") or "claude" in original_model.lower() or resolved_model.startswith("anthropic/"): + return _ANTHROPIC_EXTRA_KEYS + return frozenset() + + @staticmethod + def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]: """Strip non-standard keys and ensure assistant messages have a content key.""" + allowed = _ALLOWED_MSG_KEYS | extra_keys sanitized = [] for msg in messages: - clean = {k: v for k, v in msg.items() if k in _ALLOWED_MSG_KEYS} + clean = {k: v for k, v in msg.items() if k in allowed} # Strict providers require "content" even when assistant only has tool_calls if clean.get("role") == "assistant" and "content" not in clean: clean["content"] = None @@ -193,6 +202,7 @@ class LiteLLMProvider(LLMProvider): """ original_model = model or self.default_model model = self._resolve_model(original_model) + extra_msg_keys = self._extra_msg_keys(original_model, model) if self._supports_cache_control(original_model): messages, tools = self._apply_cache_control(messages, tools) @@ -203,7 +213,7 @@ class LiteLLMProvider(LLMProvider): kwargs: dict[str, Any] = { "model": model, - "messages": self._sanitize_messages(self._sanitize_empty_content(messages)), + "messages": self._sanitize_messages(self._sanitize_empty_content(messages), extra_keys=extra_msg_keys), "max_tokens": max_tokens, "temperature": temperature, } From ad99d5aaa060655347ec593ee613837667045c77 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Tue, 3 Mar 2026 00:59:58 -0300 Subject: [PATCH 61/78] fix: merge consecutive user messages into single message Some LLM providers (Minimax, Dashscope) strictly reject consecutive messages with the same role. build_messages() was emitting two separate user messages back-to-back: the runtime context and the actual user content. Merge them into a single user message, handling both plain text and multimodal (image) content. Update _save_turn() to strip the runtime context prefix from the merged message when persisting to session history. Fixes #1414 Fixes #1344 --- nanobot/agent/context.py | 13 +++++++++++-- nanobot/agent/loop.py | 23 ++++++++++++++++------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 010b126..df4825f 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -112,11 +112,20 @@ Reply directly with text for conversations. Only use the 'message' tool to send chat_id: str | None = None, ) -> list[dict[str, Any]]: """Build the complete message list for an LLM call.""" + runtime_ctx = self._build_runtime_context(channel, chat_id) + user_content = self._build_user_content(current_message, media) + + # Merge runtime context and user content into a single user message + # to avoid consecutive same-role messages that some providers reject. + if isinstance(user_content, str): + merged = f"{runtime_ctx}\n\n{user_content}" + else: + merged = [{"type": "text", "text": runtime_ctx}] + user_content + return [ {"role": "system", "content": self.build_system_prompt(skill_names)}, *history, - {"role": "user", "content": self._build_runtime_context(channel, chat_id)}, - {"role": "user", "content": self._build_user_content(current_message, media)}, + {"role": "user", "content": merged}, ] def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]: diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 488615d..825b11a 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -464,14 +464,23 @@ class AgentLoop: entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)" elif role == "user": if isinstance(content, str) and content.startswith(ContextBuilder._RUNTIME_CONTEXT_TAG): - continue + # Strip the runtime-context prefix, keep only the user text. + parts = content.split("\n\n", 1) + if len(parts) > 1 and parts[1].strip(): + entry["content"] = parts[1] + else: + continue if isinstance(content, list): - entry["content"] = [ - {"type": "text", "text": "[image]"} if ( - c.get("type") == "image_url" - and c.get("image_url", {}).get("url", "").startswith("data:image/") - ) else c for c in content - ] + filtered = [] + for c in content: + if c.get("type") == "text" and isinstance(c.get("text"), str) and c["text"].startswith(ContextBuilder._RUNTIME_CONTEXT_TAG): + continue # Strip runtime context from multimodal messages + if (c.get("type") == "image_url" + and c.get("image_url", {}).get("url", "").startswith("data:image/")): + filtered.append({"type": "text", "text": "[image]"}) + else: + filtered.append(c) + entry["content"] = filtered entry.setdefault("timestamp", datetime.now().isoformat()) session.messages.append(entry) session.updated_at = datetime.now() From da8a4fc68c6964e5ee7917b56769ccd39e1a86b6 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Tue, 3 Mar 2026 01:02:33 -0300 Subject: [PATCH 62/78] fix: prevent cron job execution from scheduling new jobs When a cron job fires, the agent processes the scheduled message and has access to the cron tool. If the original message resembles a scheduling instruction (e.g. "remind me in 10 seconds"), the agent would call cron.add again, creating an infinite feedback loop. Add a cron-context flag to CronTool that blocks add operations during cron job execution. The flag is set before process_direct() and cleared in a finally block to ensure cleanup even on errors. Fixes #1441 --- nanobot/agent/tools/cron.py | 7 +++++++ nanobot/cli/commands.py | 21 +++++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index fe1dce6..d360b14 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -14,12 +14,17 @@ class CronTool(Tool): self._cron = cron_service self._channel = "" self._chat_id = "" + self._in_cron_context = False def set_context(self, channel: str, chat_id: str) -> None: """Set the current session context for delivery.""" self._channel = channel self._chat_id = chat_id + def set_cron_context(self, active: bool) -> None: + """Mark whether the tool is executing inside a cron job callback.""" + self._in_cron_context = active + @property def name(self) -> str: return "cron" @@ -72,6 +77,8 @@ class CronTool(Tool): **kwargs: Any, ) -> str: if action == "add": + if self._in_cron_context: + return "Error: cannot schedule new jobs from within a cron job execution" return self._add_job(message, every_seconds, cron_expr, tz, at) elif action == "list": return self._list_jobs() diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2662e9f..42c0c2d 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -296,6 +296,7 @@ def gateway( # Set cron callback (needs agent) async def on_cron_job(job: CronJob) -> str | None: """Execute a cron job through the agent.""" + from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.message import MessageTool reminder_note = ( "[Scheduled Task] Timer finished.\n\n" @@ -303,12 +304,20 @@ def gateway( f"Scheduled instruction: {job.payload.message}" ) - response = await agent.process_direct( - reminder_note, - session_key=f"cron:{job.id}", - channel=job.payload.channel or "cli", - chat_id=job.payload.to or "direct", - ) + # Prevent the agent from scheduling new cron jobs during execution + cron_tool = agent.tools.get("cron") + if isinstance(cron_tool, CronTool): + cron_tool.set_cron_context(True) + try: + response = await agent.process_direct( + reminder_note, + session_key=f"cron:{job.id}", + channel=job.payload.channel or "cli", + chat_id=job.payload.to or "direct", + ) + finally: + if isinstance(cron_tool, CronTool): + cron_tool.set_cron_context(False) message_tool = agent.tools.get("message") if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: From 03b83fb79ee91833accd47ef9cf81d68eedcde62 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 3 Mar 2026 05:13:17 +0000 Subject: [PATCH 63/78] fix(agent): skip empty multimodal user entries after runtime-context strip --- nanobot/agent/loop.py | 2 ++ tests/test_loop_save_turn.py | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 tests/test_loop_save_turn.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 825b11a..65a62e5 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -480,6 +480,8 @@ class AgentLoop: filtered.append({"type": "text", "text": "[image]"}) else: filtered.append(c) + if not filtered: + continue entry["content"] = filtered entry.setdefault("timestamp", datetime.now().isoformat()) session.messages.append(entry) diff --git a/tests/test_loop_save_turn.py b/tests/test_loop_save_turn.py new file mode 100644 index 0000000..aec6d1a --- /dev/null +++ b/tests/test_loop_save_turn.py @@ -0,0 +1,41 @@ +from nanobot.agent.context import ContextBuilder +from nanobot.agent.loop import AgentLoop +from nanobot.session.manager import Session + + +def _mk_loop() -> AgentLoop: + loop = AgentLoop.__new__(AgentLoop) + loop._TOOL_RESULT_MAX_CHARS = 500 + return loop + + +def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None: + loop = _mk_loop() + session = Session(key="test:runtime-only") + runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)" + + loop._save_turn( + session, + [{"role": "user", "content": [{"type": "text", "text": runtime}]}], + skip=0, + ) + assert session.messages == [] + + +def test_save_turn_keeps_image_placeholder_after_runtime_strip() -> None: + loop = _mk_loop() + session = Session(key="test:image") + runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)" + + loop._save_turn( + session, + [{ + "role": "user", + "content": [ + {"type": "text", "text": runtime}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, + ], + }], + skip=0, + ) + assert session.messages[0]["content"] == [{"type": "text", "text": "[image]"}] From 30803afec0b704651666d9df3debd2225c64e1ae Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 3 Mar 2026 05:36:48 +0000 Subject: [PATCH 64/78] fix(cron): isolate cron-execution guard with contextvars --- nanobot/agent/tools/cron.py | 13 +++++++++---- nanobot/cli/commands.py | 7 ++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index d360b14..13b1e12 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -1,5 +1,6 @@ """Cron tool for scheduling reminders and tasks.""" +from contextvars import ContextVar from typing import Any from nanobot.agent.tools.base import Tool @@ -14,16 +15,20 @@ class CronTool(Tool): self._cron = cron_service self._channel = "" self._chat_id = "" - self._in_cron_context = False + self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False) def set_context(self, channel: str, chat_id: str) -> None: """Set the current session context for delivery.""" self._channel = channel self._chat_id = chat_id - def set_cron_context(self, active: bool) -> None: + def set_cron_context(self, active: bool): """Mark whether the tool is executing inside a cron job callback.""" - self._in_cron_context = active + return self._in_cron_context.set(active) + + def reset_cron_context(self, token) -> None: + """Restore previous cron context.""" + self._in_cron_context.reset(token) @property def name(self) -> str: @@ -77,7 +82,7 @@ class CronTool(Tool): **kwargs: Any, ) -> str: if action == "add": - if self._in_cron_context: + if self._in_cron_context.get(): return "Error: cannot schedule new jobs from within a cron job execution" return self._add_job(message, every_seconds, cron_expr, tz, at) elif action == "list": diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 42c0c2d..f9fe347 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -306,8 +306,9 @@ def gateway( # Prevent the agent from scheduling new cron jobs during execution cron_tool = agent.tools.get("cron") + cron_token = None if isinstance(cron_tool, CronTool): - cron_tool.set_cron_context(True) + cron_token = cron_tool.set_cron_context(True) try: response = await agent.process_direct( reminder_note, @@ -316,8 +317,8 @@ def gateway( chat_id=job.payload.to or "direct", ) finally: - if isinstance(cron_tool, CronTool): - cron_tool.set_cron_context(False) + if isinstance(cron_tool, CronTool) and cron_token is not None: + cron_tool.reset_cron_context(cron_token) message_tool = agent.tools.get("message") if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: From c05cb2ef64ce8eaa0257e1ae677a64ea7309f243 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 3 Mar 2026 05:51:24 +0000 Subject: [PATCH 65/78] refactor(cron): remove CLI cron commands and unify scheduling via cron tool --- README.md | 17 --- nanobot/cli/commands.py | 215 ------------------------------------ nanobot/templates/AGENTS.md | 8 +- 3 files changed, 3 insertions(+), 237 deletions(-) diff --git a/README.md b/README.md index 01da228..33cdeee 100644 --- a/README.md +++ b/README.md @@ -901,23 +901,6 @@ MCP tools are automatically discovered and registered on startup. The LLM can us Interactive mode exits: `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`. -
-Scheduled Tasks (Cron) - -```bash -# Add a job -nanobot cron add --name "daily" --message "Good morning!" --cron "0 9 * * *" -nanobot cron add --name "hourly" --message "Check status" --every 3600 - -# List jobs -nanobot cron list - -# Remove a job -nanobot cron remove -``` - -
-
Heartbeat (Periodic Tasks) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index f9fe347..b75a2bc 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -782,221 +782,6 @@ def channels_login(): console.print("[red]npm not found. Please install Node.js.[/red]") -# ============================================================================ -# Cron Commands -# ============================================================================ - -cron_app = typer.Typer(help="Manage scheduled tasks") -app.add_typer(cron_app, name="cron") - - -@cron_app.command("list") -def cron_list( - all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"), -): - """List scheduled jobs.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - jobs = service.list_jobs(include_disabled=all) - - if not jobs: - console.print("No scheduled jobs.") - return - - table = Table(title="Scheduled Jobs") - table.add_column("ID", style="cyan") - table.add_column("Name") - table.add_column("Schedule") - table.add_column("Status") - table.add_column("Next Run") - - import time - from datetime import datetime as _dt - from zoneinfo import ZoneInfo - for job in jobs: - # Format schedule - if job.schedule.kind == "every": - sched = f"every {(job.schedule.every_ms or 0) // 1000}s" - elif job.schedule.kind == "cron": - sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "") - else: - sched = "one-time" - - # Format next run - next_run = "" - if job.state.next_run_at_ms: - ts = job.state.next_run_at_ms / 1000 - try: - tz = ZoneInfo(job.schedule.tz) if job.schedule.tz else None - next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M") - except Exception: - next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) - - status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" - - table.add_row(job.id, job.name, sched, status, next_run) - - console.print(table) - - -@cron_app.command("add") -def cron_add( - name: str = typer.Option(..., "--name", "-n", help="Job name"), - message: str = typer.Option(..., "--message", "-m", help="Message for agent"), - every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"), - cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"), - tz: str | None = typer.Option(None, "--tz", help="IANA timezone for cron (e.g. 'America/Vancouver')"), - at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"), - deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"), - to: str = typer.Option(None, "--to", help="Recipient for delivery"), - channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"), -): - """Add a scheduled job.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - from nanobot.cron.types import CronSchedule - - if tz and not cron_expr: - console.print("[red]Error: --tz can only be used with --cron[/red]") - raise typer.Exit(1) - - # Determine schedule type - if every: - schedule = CronSchedule(kind="every", every_ms=every * 1000) - elif cron_expr: - schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz) - elif at: - import datetime - dt = datetime.datetime.fromisoformat(at) - schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000)) - else: - console.print("[red]Error: Must specify --every, --cron, or --at[/red]") - raise typer.Exit(1) - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - try: - job = service.add_job( - name=name, - schedule=schedule, - message=message, - deliver=deliver, - to=to, - channel=channel, - ) - except ValueError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e - - console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})") - - -@cron_app.command("remove") -def cron_remove( - job_id: str = typer.Argument(..., help="Job ID to remove"), -): - """Remove a scheduled job.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - if service.remove_job(job_id): - console.print(f"[green]✓[/green] Removed job {job_id}") - else: - console.print(f"[red]Job {job_id} not found[/red]") - - -@cron_app.command("enable") -def cron_enable( - job_id: str = typer.Argument(..., help="Job ID"), - disable: bool = typer.Option(False, "--disable", help="Disable instead of enable"), -): - """Enable or disable a job.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - job = service.enable_job(job_id, enabled=not disable) - if job: - status = "disabled" if disable else "enabled" - console.print(f"[green]✓[/green] Job '{job.name}' {status}") - else: - console.print(f"[red]Job {job_id} not found[/red]") - - -@cron_app.command("run") -def cron_run( - job_id: str = typer.Argument(..., help="Job ID to run"), - force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"), -): - """Manually run a job.""" - from loguru import logger - - from nanobot.agent.loop import AgentLoop - from nanobot.bus.queue import MessageBus - from nanobot.config.loader import get_data_dir, load_config - from nanobot.cron.service import CronService - from nanobot.cron.types import CronJob - logger.disable("nanobot") - - config = load_config() - provider = _make_provider(config) - bus = MessageBus() - agent_loop = AgentLoop( - bus=bus, - provider=provider, - workspace=config.workspace_path, - model=config.agents.defaults.model, - temperature=config.agents.defaults.temperature, - max_tokens=config.agents.defaults.max_tokens, - max_iterations=config.agents.defaults.max_tool_iterations, - memory_window=config.agents.defaults.memory_window, - reasoning_effort=config.agents.defaults.reasoning_effort, - brave_api_key=config.tools.web.search.api_key or None, - web_proxy=config.tools.web.proxy or None, - exec_config=config.tools.exec, - restrict_to_workspace=config.tools.restrict_to_workspace, - mcp_servers=config.tools.mcp_servers, - channels_config=config.channels, - ) - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - result_holder = [] - - async def on_job(job: CronJob) -> str | None: - response = await agent_loop.process_direct( - job.payload.message, - session_key=f"cron:{job.id}", - channel=job.payload.channel or "cli", - chat_id=job.payload.to or "direct", - ) - result_holder.append(response) - return response - - service.on_job = on_job - - async def run(): - return await service.run_job(job_id, force=force) - - if asyncio.run(run()): - console.print("[green]✓[/green] Job executed") - if result_holder: - _print_agent_response(result_holder[0], render_markdown=True) - else: - console.print(f"[red]Failed to run job {job_id}[/red]") - - # ============================================================================ # Status Commands # ============================================================================ diff --git a/nanobot/templates/AGENTS.md b/nanobot/templates/AGENTS.md index 4c3e5b1..a24604b 100644 --- a/nanobot/templates/AGENTS.md +++ b/nanobot/templates/AGENTS.md @@ -4,17 +4,15 @@ You are a helpful AI assistant. Be concise, accurate, and friendly. ## Scheduled Reminders -When user asks for a reminder at a specific time, use `exec` to run: -``` -nanobot cron add --name "reminder" --message "Your message" --at "YYYY-MM-DDTHH:MM:SS" --deliver --to "USER_ID" --channel "CHANNEL" -``` +Before scheduling reminders, check available skills and follow skill guidance first. +Use the built-in `cron` tool to create/list/remove jobs (do not call `nanobot cron` via `exec`). Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`). **Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications. ## Heartbeat Tasks -`HEARTBEAT.md` is checked every 30 minutes. Use file tools to manage periodic tasks: +`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks: - **Add**: `edit_file` to append new tasks - **Remove**: `edit_file` to delete completed tasks From 5f7fb9c75ad1d3d442d4236607c827ad97a132fd Mon Sep 17 00:00:00 2001 From: cocolato Date: Tue, 3 Mar 2026 23:40:56 +0800 Subject: [PATCH 66/78] add missed dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index a22053c..4199af1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "prompt-toolkit>=3.0.50,<4.0.0", "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", + "openai>=2.8.0", ] [project.optional-dependencies] From d0a48ed23c7eb578702f9dd5e7d4dc009d022efa Mon Sep 17 00:00:00 2001 From: Liwx Date: Wed, 4 Mar 2026 14:00:40 +0800 Subject: [PATCH 67/78] Update qq.py --- nanobot/channels/qq.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 7b171bc..99a712b 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -56,6 +56,7 @@ class QQChannel(BaseChannel): self.config: QQConfig = config self._client: "botpy.Client | None" = None self._processed_ids: deque = deque(maxlen=1000) + self._msg_seq: int = 1 # Message sequence to avoid QQ API deduplication async def start(self) -> None: """Start the QQ bot.""" From 20bec3bc266ef84399d3170cef6b4b5de8627f67 Mon Sep 17 00:00:00 2001 From: Liwx Date: Wed, 4 Mar 2026 14:06:19 +0800 Subject: [PATCH 68/78] Update qq.py --- nanobot/channels/qq.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 99a712b..6c58049 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -56,7 +56,7 @@ class QQChannel(BaseChannel): self.config: QQConfig = config self._client: "botpy.Client | None" = None self._processed_ids: deque = deque(maxlen=1000) - self._msg_seq: int = 1 # Message sequence to avoid QQ API deduplication + self._msg_seq: int = 1 # 消息序列号,避免被 QQ API 去重 async def start(self) -> None: """Start the QQ bot.""" @@ -103,11 +103,13 @@ class QQChannel(BaseChannel): return try: msg_id = msg.metadata.get("message_id") + self._msg_seq += 1 # 递增序列号 await self._client.api.post_c2c_message( openid=msg.chat_id, msg_type=0, content=msg.content, msg_id=msg_id, + msg_seq=self._msg_seq, # 添加序列号避免去重 ) except Exception as e: logger.error("Error sending QQ message: {}", e) @@ -134,3 +136,4 @@ class QQChannel(BaseChannel): ) except Exception: logger.exception("Error handling QQ message") + From df8d09f2b6c0eb23298e41acbe139fad9d38f325 Mon Sep 17 00:00:00 2001 From: Kiplangatkorir Date: Wed, 4 Mar 2026 10:53:30 +0300 Subject: [PATCH 69/78] fix: guard validate_params against non-dict input When the LLM returns malformed tool arguments (e.g. a list or string instead of a dict), validate_params would crash with AttributeError in _validate() when calling val.items(). Now returns a clear validation error instead of crashing. --- nanobot/agent/tools/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 8dd82c7..051fc9a 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -54,6 +54,8 @@ class Tool(ABC): def validate_params(self, params: dict[str, Any]) -> list[str]: """Validate tool parameters against JSON schema. Returns error list (empty if valid).""" + if not isinstance(params, dict): + return [f"parameters must be an object, got {type(params).__name__}"] schema = self.parameters or {} if schema.get("type", "object") != "object": raise ValueError(f"Schema must be object type, got {schema.get('type')!r}") From edaf7a244a0d65395cab954fc768dc8031489b29 Mon Sep 17 00:00:00 2001 From: Kiplangatkorir Date: Wed, 4 Mar 2026 10:55:17 +0300 Subject: [PATCH 70/78] fix: handle invalid ISO datetime in CronTool gracefully datetime.fromisoformat(at) raises ValueError for malformed strings, which propagated uncaught and crashed the tool execution. Now catches ValueError and returns a user-friendly error message instead. --- nanobot/agent/tools/cron.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 13b1e12..f8e737b 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -122,7 +122,10 @@ class CronTool(Tool): elif at: from datetime import datetime - dt = datetime.fromisoformat(at) + try: + dt = datetime.fromisoformat(at) + except ValueError: + return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS" at_ms = int(dt.timestamp() * 1000) schedule = CronSchedule(kind="at", at_ms=at_ms) delete_after = True From ce65f8c11be13b51f242890cabdf15f4e0d1b12a Mon Sep 17 00:00:00 2001 From: Kiplangatkorir Date: Wed, 4 Mar 2026 11:15:45 +0300 Subject: [PATCH 71/78] fix: add size limit to ReadFileTool to prevent OOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReadFileTool had no file size check — reading a multi-GB file would load everything into memory and crash the process. Now: - Rejects files over ~512KB at the byte level (fast stat check) - Truncates at 128K chars with a notice if content is too long - Guides the agent to use exec with head/tail/grep for large files This matches the protection already in ExecTool (10KB) and WebFetchTool (50KB). --- nanobot/agent/tools/filesystem.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index bbdd49c..7b0b867 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -26,6 +26,8 @@ def _resolve_path( class ReadFileTool(Tool): """Tool to read file contents.""" + _MAX_CHARS = 128_000 # ~128 KB — prevents OOM from reading huge files into LLM context + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): self._workspace = workspace self._allowed_dir = allowed_dir @@ -54,7 +56,16 @@ class ReadFileTool(Tool): if not file_path.is_file(): return f"Error: Not a file: {path}" + size = file_path.stat().st_size + if size > self._MAX_CHARS * 4: # rough upper bound (UTF-8 chars ≤ 4 bytes) + return ( + f"Error: File too large ({size:,} bytes). " + f"Use exec tool with head/tail/grep to read portions." + ) + content = file_path.read_text(encoding="utf-8") + if len(content) > self._MAX_CHARS: + return content[: self._MAX_CHARS] + f"\n\n... (truncated — file is {len(content):,} chars, limit {self._MAX_CHARS:,})" return content except PermissionError as e: return f"Error: {e}" From bb8512ca842fc3b14c6dee01c5aaf9e241f8344e Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 4 Mar 2026 20:42:49 +0800 Subject: [PATCH 72/78] test: fix test failures from refactored cron and context builder - test_context_prompt_cache: Update test to reflect merged runtime context and user message (commit ad99d5a merged them into one) - Remove test_cron_commands.py: cron add CLI command was removed in commit c05cb2e (unified scheduling via cron tool) --- tests/test_context_prompt_cache.py | 19 +++++++++---------- tests/test_cron_commands.py | 29 ----------------------------- 2 files changed, 9 insertions(+), 39 deletions(-) delete mode 100644 tests/test_cron_commands.py diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 9afcc7d..ce796e2 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -40,7 +40,7 @@ def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: - """Runtime metadata should be a separate user message before the actual user message.""" + """Runtime metadata should be merged with the user message.""" workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) @@ -54,13 +54,12 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert messages[0]["role"] == "system" assert "## Current Session" not in messages[0]["content"] - assert messages[-2]["role"] == "user" - runtime_content = messages[-2]["content"] - assert isinstance(runtime_content, str) - assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content - assert "Current Time:" in runtime_content - assert "Channel: cli" in runtime_content - assert "Chat ID: direct" in runtime_content - + # Runtime context is now merged with user message into a single message assert messages[-1]["role"] == "user" - assert messages[-1]["content"] == "Return exactly: OK" + user_content = messages[-1]["content"] + assert isinstance(user_content, str) + assert ContextBuilder._RUNTIME_CONTEXT_TAG in user_content + assert "Current Time:" in user_content + assert "Channel: cli" in user_content + assert "Chat ID: direct" in user_content + assert "Return exactly: OK" in user_content diff --git a/tests/test_cron_commands.py b/tests/test_cron_commands.py deleted file mode 100644 index bce1ef5..0000000 --- a/tests/test_cron_commands.py +++ /dev/null @@ -1,29 +0,0 @@ -from typer.testing import CliRunner - -from nanobot.cli.commands import app - -runner = CliRunner() - - -def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None: - monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path) - - result = runner.invoke( - app, - [ - "cron", - "add", - "--name", - "demo", - "--message", - "hello", - "--cron", - "0 9 * * *", - "--tz", - "America/Vancovuer", - ], - ) - - assert result.exit_code == 1 - assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout - assert not (tmp_path / "cron" / "jobs.json").exists() From ecdf30940459a27311855a97cfdb7599cb3f89a2 Mon Sep 17 00:00:00 2001 From: Daniel Emden Date: Wed, 4 Mar 2026 15:31:56 +0100 Subject: [PATCH 73/78] fix(codex): pass reasoning_effort to Codex API The OpenAI Codex provider accepts reasoning_effort but silently discards it. Wire it through as {"reasoning": {"effort": ...}} in the request body so the config option actually takes effect. --- nanobot/providers/openai_codex_provider.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index b6afa65..d04e210 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -52,6 +52,9 @@ class OpenAICodexProvider(LLMProvider): "parallel_tool_calls": True, } + if reasoning_effort: + body["reasoning"] = {"effort": reasoning_effort} + if tools: body["tools"] = _convert_tools(tools) From c64fe0afd8cfcbfe0c26569140db33b473f87854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 16:53:07 +0100 Subject: [PATCH 74/78] fix(tests): resolve failing tests on main branch - Unskip matrix logic by adding missing deps (matrix-nio, nh3, mistune) - Update matrix tests for 'allow_from' default deny security change - Fix asyncio typing keepalive leak in matrix tests - Update context prompt cache assert after runtime message merge - Fix flaky cron service test with mtime sleep - Remove obsolete test_cron_commands.py testing deleted CLI commands --- pyproject.toml | 3 +++ tests/test_context_prompt_cache.py | 9 ++++----- tests/test_cron_commands.py | 29 ----------------------------- tests/test_cron_service.py | 2 ++ tests/test_matrix_channel.py | 20 ++++++++++++++++++-- 5 files changed, 27 insertions(+), 36 deletions(-) delete mode 100644 tests/test_cron_commands.py diff --git a/pyproject.toml b/pyproject.toml index a22053c..0546523 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,9 @@ dev = [ "pytest>=9.0.0,<10.0.0", "pytest-asyncio>=1.3.0,<2.0.0", "ruff>=0.1.0", + "matrix-nio[e2e]>=0.25.2", + "mistune>=3.0.0,<4.0.0", + "nh3>=0.2.17,<1.0.0", ] [project.scripts] diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 9afcc7d..38b8d35 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -54,13 +54,12 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert messages[0]["role"] == "system" assert "## Current Session" not in messages[0]["content"] - assert messages[-2]["role"] == "user" - runtime_content = messages[-2]["content"] + assert len(messages) == 2 + assert messages[-1]["role"] == "user" + runtime_content = messages[-1]["content"] assert isinstance(runtime_content, str) assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content assert "Current Time:" in runtime_content assert "Channel: cli" in runtime_content assert "Chat ID: direct" in runtime_content - - assert messages[-1]["role"] == "user" - assert messages[-1]["content"] == "Return exactly: OK" + assert "Return exactly: OK" in runtime_content diff --git a/tests/test_cron_commands.py b/tests/test_cron_commands.py deleted file mode 100644 index bce1ef5..0000000 --- a/tests/test_cron_commands.py +++ /dev/null @@ -1,29 +0,0 @@ -from typer.testing import CliRunner - -from nanobot.cli.commands import app - -runner = CliRunner() - - -def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None: - monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path) - - result = runner.invoke( - app, - [ - "cron", - "add", - "--name", - "demo", - "--message", - "hello", - "--cron", - "0 9 * * *", - "--tz", - "America/Vancovuer", - ], - ) - - assert result.exit_code == 1 - assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout - assert not (tmp_path / "cron" / "jobs.json").exists() diff --git a/tests/test_cron_service.py b/tests/test_cron_service.py index 2a36f4c..9631da5 100644 --- a/tests/test_cron_service.py +++ b/tests/test_cron_service.py @@ -48,6 +48,8 @@ async def test_running_service_honors_external_disable(tmp_path) -> None: ) await service.start() try: + # Wait slightly to ensure file mtime is definitively different + await asyncio.sleep(0.05) external = CronService(store_path) updated = external.enable_job(job.id, enabled=False) assert updated is not None diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index c6714c2..c25b95a 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -159,6 +159,7 @@ class _FakeAsyncClient: def _make_config(**kwargs) -> MatrixConfig: + kwargs.setdefault("allow_from", ["*"]) return MatrixConfig( enabled=True, homeserver="https://matrix.org", @@ -274,7 +275,7 @@ async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None: @pytest.mark.asyncio -async def test_room_invite_joins_when_allow_list_is_empty() -> None: +async def test_room_invite_ignores_when_allow_list_is_empty() -> None: channel = MatrixChannel(_make_config(allow_from=[]), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client @@ -284,9 +285,22 @@ async def test_room_invite_joins_when_allow_list_is_empty() -> None: await channel._on_room_invite(room, event) - assert client.join_calls == ["!room:matrix.org"] + assert client.join_calls == [] +@pytest.mark.asyncio +async def test_room_invite_joins_when_sender_allowed() -> None: + channel = MatrixChannel(_make_config(allow_from=["@alice:matrix.org"]), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + room = SimpleNamespace(room_id="!room:matrix.org") + event = SimpleNamespace(sender="@alice:matrix.org") + + await channel._on_room_invite(room, event) + + assert client.join_calls == ["!room:matrix.org"] + @pytest.mark.asyncio async def test_room_invite_respects_allow_list_when_configured() -> None: channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus()) @@ -1163,6 +1177,8 @@ async def test_send_progress_keeps_typing_keepalive_running() -> None: assert "!room:matrix.org" in channel._typing_tasks assert client.typing_calls[-1] == ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS) + await channel.stop() + @pytest.mark.asyncio async def test_send_clears_typing_when_send_fails() -> None: From 88d7642c1ec570e07eef473f47d1d637b38b9b07 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 4 Mar 2026 20:42:49 +0800 Subject: [PATCH 75/78] test: fix test failures from refactored cron and context builder - test_context_prompt_cache: Update test to reflect merged runtime context and user message (commit ad99d5a merged them into one) - Remove test_cron_commands.py: cron add CLI command was removed in commit c05cb2e (unified scheduling via cron tool) --- tests/test_context_prompt_cache.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 38b8d35..fa7f02d 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -40,7 +40,7 @@ def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: - """Runtime metadata should be a separate user message before the actual user message.""" + """Runtime metadata should be merged with the user message.""" workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) @@ -55,11 +55,12 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert "## Current Session" not in messages[0]["content"] assert len(messages) == 2 + # Runtime context is now merged with user message into a single message assert messages[-1]["role"] == "user" - runtime_content = messages[-1]["content"] - assert isinstance(runtime_content, str) - assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content - assert "Current Time:" in runtime_content - assert "Channel: cli" in runtime_content - assert "Chat ID: direct" in runtime_content - assert "Return exactly: OK" in runtime_content + user_content = messages[-1]["content"] + assert isinstance(user_content, str) + assert ContextBuilder._RUNTIME_CONTEXT_TAG in user_content + assert "Current Time:" in user_content + assert "Channel: cli" in user_content + assert "Chat ID: direct" in user_content + assert "Return exactly: OK" in user_content From bdfe7d6449dab772f681b857ad76796c92b63d05 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 5 Mar 2026 00:16:31 +0800 Subject: [PATCH 76/78] fix(feishu): convert audio type to file for API compatibility Feishu's GetMessageResource API only accepts 'image' or 'file' as the type parameter. When downloading voice messages, nanobot was passing 'audio' which caused the API to reject the request with an error. This fix converts 'audio' to 'file' in _download_file_sync method before making the API call, allowing voice messages to be downloaded and transcribed successfully. Fixes voice message download failure in Feishu channel. --- nanobot/channels/feishu.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0a0a5e4..a9a32b2 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -530,6 +530,10 @@ class FeishuChannel(BaseChannel): self, message_id: str, file_key: str, resource_type: str = "file" ) -> tuple[bytes | None, str | None]: """Download a file/audio/media from a Feishu message by message_id and file_key.""" + # Feishu API only accepts 'image' or 'file' as type parameter + # Convert 'audio' to 'file' for API compatibility + if resource_type == "audio": + resource_type = "file" try: request = ( GetMessageResourceRequest.builder() From 0209ad57d9655d8fea5f5e551a4bb89bd0f1691c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 19:31:39 +0100 Subject: [PATCH 77/78] fix(tests): resolve RequestsDependencyWarning and lark-oapi asyncio/websockets DeprecationWarnings --- nanobot/channels/feishu.py | 32 +++++++++++--------------------- pyproject.toml | 1 + 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0a0a5e4..7d26fa8 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -16,26 +16,9 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import FeishuConfig -try: - import lark_oapi as lark - from lark_oapi.api.im.v1 import ( - CreateFileRequest, - CreateFileRequestBody, - CreateImageRequest, - CreateImageRequestBody, - CreateMessageReactionRequest, - CreateMessageReactionRequestBody, - CreateMessageRequest, - CreateMessageRequestBody, - Emoji, - GetMessageResourceRequest, - P2ImMessageReceiveV1, - ) - FEISHU_AVAILABLE = True -except ImportError: - FEISHU_AVAILABLE = False - lark = None - Emoji = None +import importlib.util + +FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None # Message type display mapping MSG_TYPE_MAP = { @@ -280,6 +263,7 @@ class FeishuChannel(BaseChannel): logger.error("Feishu app_id and app_secret not configured") return + import lark_oapi as lark self._running = True self._loop = asyncio.get_running_loop() @@ -340,6 +324,7 @@ class FeishuChannel(BaseChannel): def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: """Sync helper for adding reaction (runs in thread pool).""" + from lark_oapi.api.im.v1 import CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji try: request = CreateMessageReactionRequest.builder() \ .message_id(message_id) \ @@ -364,7 +349,7 @@ class FeishuChannel(BaseChannel): Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART """ - if not self._client or not Emoji: + if not self._client: return loop = asyncio.get_running_loop() @@ -456,6 +441,7 @@ class FeishuChannel(BaseChannel): def _upload_image_sync(self, file_path: str) -> str | None: """Upload an image to Feishu and return the image_key.""" + from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody try: with open(file_path, "rb") as f: request = CreateImageRequest.builder() \ @@ -479,6 +465,7 @@ class FeishuChannel(BaseChannel): def _upload_file_sync(self, file_path: str) -> str | None: """Upload a file to Feishu and return the file_key.""" + from lark_oapi.api.im.v1 import CreateFileRequest, CreateFileRequestBody ext = os.path.splitext(file_path)[1].lower() file_type = self._FILE_TYPE_MAP.get(ext, "stream") file_name = os.path.basename(file_path) @@ -506,6 +493,7 @@ class FeishuChannel(BaseChannel): def _download_image_sync(self, message_id: str, image_key: str) -> tuple[bytes | None, str | None]: """Download an image from Feishu message by message_id and image_key.""" + from lark_oapi.api.im.v1 import GetMessageResourceRequest try: request = GetMessageResourceRequest.builder() \ .message_id(message_id) \ @@ -530,6 +518,7 @@ class FeishuChannel(BaseChannel): self, message_id: str, file_key: str, resource_type: str = "file" ) -> tuple[bytes | None, str | None]: """Download a file/audio/media from a Feishu message by message_id and file_key.""" + from lark_oapi.api.im.v1 import GetMessageResourceRequest try: request = ( GetMessageResourceRequest.builder() @@ -598,6 +587,7 @@ class FeishuChannel(BaseChannel): def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool: """Send a single message (text/image/file/interactive) synchronously.""" + from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody try: request = CreateMessageRequest.builder() \ .receive_id_type(receive_id_type) \ diff --git a/pyproject.toml b/pyproject.toml index 0546523..d384f3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "prompt-toolkit>=3.0.50,<4.0.0", "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", + "chardet>=3.0.2,<6.0.0", ] [project.optional-dependencies] From e032faaeff81d7e4fa39659badbacc7b4004dc05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 20:04:00 +0100 Subject: [PATCH 78/78] Merge branch 'main' of upstream/main into fix/test-failures --- .gitignore | 2 +- nanobot/agent/tools/base.py | 2 ++ nanobot/agent/tools/cron.py | 5 ++++- nanobot/agent/tools/filesystem.py | 11 +++++++++++ nanobot/channels/feishu.py | 6 ++++++ nanobot/providers/openai_codex_provider.py | 3 +++ pyproject.toml | 2 ++ tests/test_context_prompt_cache.py | 1 + 8 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index d7b930d..742d593 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ __pycache__/ poetry.lock .pytest_cache/ botpy.log -tests/ + diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 8dd82c7..051fc9a 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -54,6 +54,8 @@ class Tool(ABC): def validate_params(self, params: dict[str, Any]) -> list[str]: """Validate tool parameters against JSON schema. Returns error list (empty if valid).""" + if not isinstance(params, dict): + return [f"parameters must be an object, got {type(params).__name__}"] schema = self.parameters or {} if schema.get("type", "object") != "object": raise ValueError(f"Schema must be object type, got {schema.get('type')!r}") diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 13b1e12..f8e737b 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -122,7 +122,10 @@ class CronTool(Tool): elif at: from datetime import datetime - dt = datetime.fromisoformat(at) + try: + dt = datetime.fromisoformat(at) + except ValueError: + return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS" at_ms = int(dt.timestamp() * 1000) schedule = CronSchedule(kind="at", at_ms=at_ms) delete_after = True diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index bbdd49c..7b0b867 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -26,6 +26,8 @@ def _resolve_path( class ReadFileTool(Tool): """Tool to read file contents.""" + _MAX_CHARS = 128_000 # ~128 KB — prevents OOM from reading huge files into LLM context + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): self._workspace = workspace self._allowed_dir = allowed_dir @@ -54,7 +56,16 @@ class ReadFileTool(Tool): if not file_path.is_file(): return f"Error: Not a file: {path}" + size = file_path.stat().st_size + if size > self._MAX_CHARS * 4: # rough upper bound (UTF-8 chars ≤ 4 bytes) + return ( + f"Error: File too large ({size:,} bytes). " + f"Use exec tool with head/tail/grep to read portions." + ) + content = file_path.read_text(encoding="utf-8") + if len(content) > self._MAX_CHARS: + return content[: self._MAX_CHARS] + f"\n\n... (truncated — file is {len(content):,} chars, limit {self._MAX_CHARS:,})" return content except PermissionError as e: return f"Error: {e}" diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 7d26fa8..0cd84c3 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -519,6 +519,12 @@ class FeishuChannel(BaseChannel): ) -> tuple[bytes | None, str | None]: """Download a file/audio/media from a Feishu message by message_id and file_key.""" from lark_oapi.api.im.v1 import GetMessageResourceRequest + + # Feishu API only accepts 'image' or 'file' as type parameter + # Convert 'audio' to 'file' for API compatibility + if resource_type == "audio": + resource_type = "file" + try: request = ( GetMessageResourceRequest.builder() diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index b6afa65..d04e210 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -52,6 +52,9 @@ class OpenAICodexProvider(LLMProvider): "parallel_tool_calls": True, } + if reasoning_effort: + body["reasoning"] = {"effort": reasoning_effort} + if tools: body["tools"] = _convert_tools(tools) diff --git a/pyproject.toml b/pyproject.toml index d384f3f..e5214bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,8 @@ dependencies = [ "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", "chardet>=3.0.2,<6.0.0", + "openai>=2.8.0", + ] [project.optional-dependencies] diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index fa7f02d..d347e53 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -55,6 +55,7 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert "## Current Session" not in messages[0]["content"] assert len(messages) == 2 + # Runtime context is now merged with user message into a single message assert messages[-1]["role"] == "user" user_content = messages[-1]["content"]