Merge remote-tracking branch 'origin/main'
# Conflicts: # nanobot/agent/tools/shell.py # tests/agent/test_evaluator.py # tests/channels/test_feishu_tool_hint_code_block.py # tests/providers/test_litellm_kwargs.py # tests/tools/test_web_search_tool.py
This commit is contained in:
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -21,13 +21,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential
|
run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install all dependencies
|
||||||
run: |
|
run: uv sync --all-extras
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install .[dev]
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: python -m pytest tests/ -v
|
run: uv run pytest tests/
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import sys
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
|
|
||||||
|
|
||||||
@@ -93,31 +94,32 @@ class ExecTool(Tool):
|
|||||||
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
|
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
|
process = await asyncio.create_subprocess_shell(
|
||||||
process = subprocess.Popen(
|
command,
|
||||||
command,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stdout=stdout_file,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
stderr=stderr_file,
|
cwd=cwd,
|
||||||
cwd=cwd,
|
env=env,
|
||||||
env=env,
|
)
|
||||||
shell=True,
|
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(
|
||||||
|
process.communicate(),
|
||||||
|
timeout=effective_timeout,
|
||||||
)
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
deadline = asyncio.get_running_loop().time() + effective_timeout
|
process.kill()
|
||||||
while process.poll() is None:
|
try:
|
||||||
if asyncio.get_running_loop().time() >= deadline:
|
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||||
process.kill()
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if sys.platform != "win32":
|
||||||
try:
|
try:
|
||||||
process.wait(timeout=5.0)
|
os.waitpid(process.pid, os.WNOHANG)
|
||||||
except subprocess.TimeoutExpired:
|
except (ProcessLookupError, ChildProcessError) as e:
|
||||||
pass
|
logger.debug("Process already reaped or not found: {}", e)
|
||||||
return f"Error: Command timed out after {effective_timeout} seconds"
|
return f"Error: Command timed out after {effective_timeout} seconds"
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
|
|
||||||
stdout_file.seek(0)
|
|
||||||
stderr_file.seek(0)
|
|
||||||
stdout = stdout_file.read()
|
|
||||||
stderr = stderr_file.read()
|
|
||||||
|
|
||||||
output_parts = []
|
output_parts = []
|
||||||
|
|
||||||
|
|||||||
@@ -66,11 +66,8 @@ matrix = [
|
|||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0.0,<10.0.0",
|
"pytest>=9.0.0,<10.0.0",
|
||||||
"pytest-asyncio>=1.3.0,<2.0.0",
|
"pytest-asyncio>=1.3.0,<2.0.0",
|
||||||
|
"pytest-cov>=6.0.0,<7.0.0",
|
||||||
"ruff>=0.1.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",
|
|
||||||
"mypy>=1.19.1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
# Check optional dingtalk dependencies before running tests
|
||||||
|
try:
|
||||||
|
from nanobot.channels import dingtalk
|
||||||
|
DINGTALK_AVAILABLE = getattr(dingtalk, "DINGTALK_AVAILABLE", False)
|
||||||
|
except ImportError:
|
||||||
|
DINGTALK_AVAILABLE = False
|
||||||
|
|
||||||
|
if not DINGTALK_AVAILABLE:
|
||||||
|
pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True)
|
||||||
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
import nanobot.channels.dingtalk as dingtalk_module
|
import nanobot.channels.dingtalk as dingtalk_module
|
||||||
from nanobot.channels.dingtalk import DingTalkChannel, NanobotDingTalkHandler
|
from nanobot.channels.dingtalk import DingTalkChannel, NanobotDingTalkHandler
|
||||||
@@ -1,3 +1,14 @@
|
|||||||
|
# Check optional Feishu dependencies before running tests
|
||||||
|
try:
|
||||||
|
from nanobot.channels import feishu
|
||||||
|
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
|
||||||
|
except ImportError:
|
||||||
|
FEISHU_AVAILABLE = False
|
||||||
|
|
||||||
|
if not FEISHU_AVAILABLE:
|
||||||
|
import pytest
|
||||||
|
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
|
||||||
|
|
||||||
from nanobot.channels.feishu import FeishuChannel
|
from nanobot.channels.feishu import FeishuChannel
|
||||||
|
|
||||||
|
|
||||||
@@ -1,3 +1,14 @@
|
|||||||
|
# Check optional Feishu dependencies before running tests
|
||||||
|
try:
|
||||||
|
from nanobot.channels import feishu
|
||||||
|
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
|
||||||
|
except ImportError:
|
||||||
|
FEISHU_AVAILABLE = False
|
||||||
|
|
||||||
|
if not FEISHU_AVAILABLE:
|
||||||
|
import pytest
|
||||||
|
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
|
||||||
|
|
||||||
from nanobot.channels.feishu import FeishuChannel, _extract_post_content
|
from nanobot.channels.feishu import FeishuChannel, _extract_post_content
|
||||||
|
|
||||||
|
|
||||||
@@ -7,6 +7,16 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
# Check optional Feishu dependencies before running tests
|
||||||
|
try:
|
||||||
|
from nanobot.channels import feishu
|
||||||
|
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
|
||||||
|
except ImportError:
|
||||||
|
FEISHU_AVAILABLE = False
|
||||||
|
|
||||||
|
if not FEISHU_AVAILABLE:
|
||||||
|
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.feishu import FeishuChannel, FeishuConfig
|
from nanobot.channels.feishu import FeishuChannel, FeishuConfig
|
||||||
@@ -6,6 +6,17 @@ list of card elements into groups so that each group contains at most one
|
|||||||
table, allowing nanobot to send multiple cards instead of failing.
|
table, allowing nanobot to send multiple cards instead of failing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Check optional Feishu dependencies before running tests
|
||||||
|
try:
|
||||||
|
from nanobot.channels import feishu
|
||||||
|
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
|
||||||
|
except ImportError:
|
||||||
|
FEISHU_AVAILABLE = False
|
||||||
|
|
||||||
|
if not FEISHU_AVAILABLE:
|
||||||
|
import pytest
|
||||||
|
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
|
||||||
|
|
||||||
from nanobot.channels.feishu import FeishuChannel
|
from nanobot.channels.feishu import FeishuChannel
|
||||||
|
|
||||||
|
|
||||||
148
tests/channels/test_feishu_tool_hint_code_block.py
Normal file
148
tests/channels/test_feishu_tool_hint_code_block.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""Tests for FeishuChannel tool hint code block formatting."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytest import mark
|
||||||
|
|
||||||
|
# Check optional Feishu dependencies before running tests
|
||||||
|
try:
|
||||||
|
from nanobot.channels import feishu
|
||||||
|
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
|
||||||
|
except ImportError:
|
||||||
|
FEISHU_AVAILABLE = False
|
||||||
|
|
||||||
|
if not FEISHU_AVAILABLE:
|
||||||
|
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
|
||||||
|
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.channels.feishu import FeishuChannel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_feishu_channel():
|
||||||
|
"""Create a FeishuChannel with mocked client."""
|
||||||
|
config = MagicMock()
|
||||||
|
config.app_id = "test_app_id"
|
||||||
|
config.app_secret = "test_app_secret"
|
||||||
|
config.encrypt_key = None
|
||||||
|
config.verification_token = None
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = FeishuChannel(config, bus)
|
||||||
|
channel._client = MagicMock() # Simulate initialized client
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
async def test_tool_hint_sends_code_message(mock_feishu_channel):
|
||||||
|
"""Tool hint messages should be sent as interactive cards with code blocks."""
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="oc_123456",
|
||||||
|
content='web_search("test query")',
|
||||||
|
metadata={"_tool_hint": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
||||||
|
await mock_feishu_channel.send(msg)
|
||||||
|
|
||||||
|
# Verify interactive message with card was sent
|
||||||
|
assert mock_send.call_count == 1
|
||||||
|
call_args = mock_send.call_args[0]
|
||||||
|
receive_id_type, receive_id, msg_type, content = call_args
|
||||||
|
|
||||||
|
assert receive_id_type == "chat_id"
|
||||||
|
assert receive_id == "oc_123456"
|
||||||
|
assert msg_type == "interactive"
|
||||||
|
|
||||||
|
# Parse content to verify card structure
|
||||||
|
card = json.loads(content)
|
||||||
|
assert card["config"]["wide_screen_mode"] is True
|
||||||
|
assert len(card["elements"]) == 1
|
||||||
|
assert card["elements"][0]["tag"] == "markdown"
|
||||||
|
# Check that code block is properly formatted with language hint
|
||||||
|
expected_md = "**Tool Calls**\n\n```text\nweb_search(\"test query\")\n```"
|
||||||
|
assert card["elements"][0]["content"] == expected_md
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
async def test_tool_hint_empty_content_does_not_send(mock_feishu_channel):
|
||||||
|
"""Empty tool hint messages should not be sent."""
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="oc_123456",
|
||||||
|
content=" ", # whitespace only
|
||||||
|
metadata={"_tool_hint": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
||||||
|
await mock_feishu_channel.send(msg)
|
||||||
|
|
||||||
|
# Should not send any message
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
async def test_tool_hint_without_metadata_sends_as_normal(mock_feishu_channel):
|
||||||
|
"""Regular messages without _tool_hint should use normal formatting."""
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="oc_123456",
|
||||||
|
content="Hello, world!",
|
||||||
|
metadata={}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
||||||
|
await mock_feishu_channel.send(msg)
|
||||||
|
|
||||||
|
# Should send as text message (detected format)
|
||||||
|
assert mock_send.call_count == 1
|
||||||
|
call_args = mock_send.call_args[0]
|
||||||
|
_, _, msg_type, content = call_args
|
||||||
|
assert msg_type == "text"
|
||||||
|
assert json.loads(content) == {"text": "Hello, world!"}
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel):
|
||||||
|
"""Multiple tool calls should be displayed each on its own line in a code block."""
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="oc_123456",
|
||||||
|
content='web_search("query"), read_file("/path/to/file")',
|
||||||
|
metadata={"_tool_hint": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
||||||
|
await mock_feishu_channel.send(msg)
|
||||||
|
|
||||||
|
call_args = mock_send.call_args[0]
|
||||||
|
msg_type = call_args[2]
|
||||||
|
content = json.loads(call_args[3])
|
||||||
|
assert msg_type == "interactive"
|
||||||
|
# Each tool call should be on its own line
|
||||||
|
expected_md = "**Tool Calls**\n\n```text\nweb_search(\"query\"),\nread_file(\"/path/to/file\")\n```"
|
||||||
|
assert content["elements"][0]["content"] == expected_md
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
async def test_tool_hint_keeps_commas_inside_arguments(mock_feishu_channel):
|
||||||
|
"""Commas inside a single tool argument must not be split onto a new line."""
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="oc_123456",
|
||||||
|
content='web_search("foo, bar"), read_file("/path/to/file")',
|
||||||
|
metadata={"_tool_hint": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
||||||
|
await mock_feishu_channel.send(msg)
|
||||||
|
|
||||||
|
content = json.loads(mock_send.call_args[0][3])
|
||||||
|
expected_md = (
|
||||||
|
"**Tool Calls**\n\n```text\n"
|
||||||
|
"web_search(\"foo, bar\"),\n"
|
||||||
|
"read_file(\"/path/to/file\")\n```"
|
||||||
|
)
|
||||||
|
assert content["elements"][0]["content"] == expected_md
|
||||||
@@ -4,6 +4,12 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
# Check optional matrix dependencies before importing
|
||||||
|
try:
|
||||||
|
import nh3 # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("Matrix dependencies not installed (nh3)", allow_module_level=True)
|
||||||
|
|
||||||
import nanobot.channels.matrix as matrix_module
|
import nanobot.channels.matrix as matrix_module
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
@@ -3,6 +3,16 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
# Check optional QQ dependencies before running tests
|
||||||
|
try:
|
||||||
|
from nanobot.channels import qq
|
||||||
|
QQ_AVAILABLE = getattr(qq, "QQ_AVAILABLE", False)
|
||||||
|
except ImportError:
|
||||||
|
QQ_AVAILABLE = False
|
||||||
|
|
||||||
|
if not QQ_AVAILABLE:
|
||||||
|
pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True)
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.qq import QQChannel, _make_bot_class
|
from nanobot.channels.qq import QQChannel, _make_bot_class
|
||||||
@@ -2,6 +2,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
# Check optional Slack dependencies before running tests
|
||||||
|
try:
|
||||||
|
import slack_sdk # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("Slack dependencies not installed (slack-sdk)", allow_module_level=True)
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.slack import SlackChannel
|
from nanobot.channels.slack import SlackChannel
|
||||||
@@ -3,6 +3,12 @@ from unittest.mock import AsyncMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
# Check optional Telegram dependencies before running tests
|
||||||
|
try:
|
||||||
|
import telegram # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("Telegram dependencies not installed (python-telegram-bot)", allow_module_level=True)
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.telegram import TELEGRAM_REPLY_CONTEXT_MAX_LEN, TelegramChannel
|
from nanobot.channels.telegram import TELEGRAM_REPLY_CONTEXT_MAX_LEN, TelegramChannel
|
||||||
Reference in New Issue
Block a user