Files
mulit-agent/mulit_agent/tests/test_builtin_tools.py
T
toot 9e01cbbec0 feat(agent): 预设本机工具、stdio MCP 传输与多轮上下文
- 预设 12 个本机工具并默认启用,装好即是可操作电脑的 Agent、无需总控配置或安装:
  文件与文件夹的查看/读取/写入/建目录/移动/删除(默认不覆盖、删目录需 recursive、
  拒绝删除系统目录与用户根目录)、用默认程序打开网址或文件、时间与时区换算
  (tzdata 已随包内置)、查看进程、屏幕截图、读写剪贴板。
- 工具下发改为通用:任意 HTTP MCP 服务与 stdio 本机命令(Agent 拉起子进程,
  容忍 stdout 噪声,命令缺失或进程崩溃会明确回报),支持多工具并存、
  定义未变更时复用连接、替换/删除/退出时回收子进程。
- 多轮上下文:task_dispatch.history 作为此前的 user/assistant 文本拼接到本次请求前,
  OpenAI 兼容、Anthropic、Gemini 分别适配;Agent 侧独立校验与限幅。
- 工具级实时进度以 progress 事件回传;使用预设工具的任务标记为 builtin 执行来源。
- Skill 与历史内容上限与总控对齐,多个已批准 Skill 不再被静默截断。
- 修复 build.ps1 缺 UTF-8 BOM 导致 build.bat 在 PowerShell 5.1 下语法报错。
2026-09-16 15:31:00 +08:00

150 lines
6.4 KiB
Python

"""The preset computer tools must work on a freshly installed Agent."""
from datetime import UTC, datetime, timedelta, timezone
from threading import Event
from zoneinfo import ZoneInfo
import pytest
from mulit_agent_app.application.task_executor import TaskExecutor
from mulit_agent_app.infrastructure import builtin_tools
from mulit_agent_app.infrastructure.builtin_tools import builtin_tools as specs
from mulit_agent_app.infrastructure.database import AgentDatabase
from mulit_agent_app.infrastructure.tool_registry import Toolbox
_HANDLERS = {spec.name: spec.handler for spec in specs()}
def test_preset_tool_set_covers_file_open_time_process_screenshot_and_clipboard():
names = set(_HANDLERS)
assert {
"computer_list_directory",
"computer_read_text_file",
"computer_write_text_file",
"computer_create_directory",
"computer_move_path",
"computer_delete_path",
"computer_open",
"computer_time",
"computer_list_processes",
"computer_screenshot",
"computer_read_clipboard",
"computer_write_clipboard",
} <= names
for spec in specs():
assert spec.description and spec.parameters["type"] == "object"
def test_file_tools_round_trip_inside_a_folder(tmp_path):
folder = tmp_path / "work"
assert "已创建文件夹" in _HANDLERS["computer_create_directory"]({"path": str(folder)})
assert "已写入" in _HANDLERS["computer_write_text_file"]({"path": str(folder / "note.txt"), "content": "第一行\n第二行"})
assert "第一行" in _HANDLERS["computer_read_text_file"]({"path": str(folder / "note.txt")})
listing = _HANDLERS["computer_list_directory"]({"path": str(folder)})
assert "note.txt" in listing and "[文件]" in listing
moved = folder / "moved.txt"
assert "已移动" in _HANDLERS["computer_move_path"]({"source": str(folder / "note.txt"), "destination": str(moved)})
assert _HANDLERS["computer_delete_path"]({"path": str(moved)}).startswith("已删除")
def test_file_tools_refuse_dangerous_or_accidental_overwrites(tmp_path):
existing = tmp_path / "keep.txt"
existing.write_text("原内容", encoding="utf-8")
with pytest.raises(ValueError, match="overwrite=true"):
_HANDLERS["computer_write_text_file"]({"path": str(existing), "content": "新内容"})
assert existing.read_text(encoding="utf-8") == "原内容"
_HANDLERS["computer_write_text_file"]({"path": str(existing), "content": "新内容", "overwrite": True})
assert existing.read_text(encoding="utf-8") == "新内容"
with pytest.raises(ValueError, match="不是可读取的文件"):
_HANDLERS["computer_read_text_file"]({"path": str(tmp_path / "missing.txt")})
folder = tmp_path / "nested"
(folder / "child").mkdir(parents=True)
(folder / "child" / "file.txt").write_text("x", encoding="utf-8")
with pytest.raises(ValueError, match="recursive=true"):
_HANDLERS["computer_delete_path"]({"path": str(folder)})
assert _HANDLERS["computer_delete_path"]({"path": str(folder), "recursive": True}).startswith("已删除")
with pytest.raises(ValueError, match="拒绝删除"):
_HANDLERS["computer_delete_path"]({"path": "C:\\Windows", "recursive": True})
def test_time_tool_accepts_local_offsets_and_iana_zones():
assert "星期" in _HANDLERS["computer_time"]({})
for requested in ("Asia/Tokyo", "UTC", "UTC+9"):
before = datetime.now(_zone(requested)).strftime("%Y-%m-%d %H")
reported = _HANDLERS["computer_time"]({"timezone": requested})
after = datetime.now(_zone(requested)).strftime("%Y-%m-%d %H")
assert before <= reported[:13] <= after, f"{requested} 返回了错误时间:{reported}"
with pytest.raises(ValueError, match="无法识别时区"):
_HANDLERS["computer_time"]({"timezone": "Mars/Olympus"})
def _zone(requested: str):
if requested == "UTC":
return UTC
if requested == "UTC+9":
return timezone(timedelta(hours=9))
return ZoneInfo(requested)
def test_open_tool_opens_urls_and_refuses_unknown_paths(monkeypatch):
opened: list[str] = []
monkeypatch.setattr(builtin_tools.webbrowser, "open", lambda target: opened.append(target) or True)
assert "已用系统默认程序打开网址" in _HANDLERS["computer_open"]({"target": "https://example.com"})
assert opened == ["https://example.com"]
with pytest.raises(ValueError, match="只支持 http/https"):
_HANDLERS["computer_open"]({"target": "ftp://example.com"})
def test_process_screenshot_and_clipboard_use_the_local_windows_session(tmp_path):
processes = _HANDLERS["computer_list_processes"]({"limit": 5})
assert "Name" in processes or "名称" in processes
shot = _HANDLERS["computer_screenshot"]({"path": str(tmp_path / "shot.png")})
assert "已保存屏幕截图" in shot
assert (tmp_path / "shot.png").read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
assert "已写入剪贴板" in _HANDLERS["computer_write_clipboard"]({"text": "MultiClaw 剪贴板测试"})
assert "MultiClaw 剪贴板测试" in _HANDLERS["computer_read_clipboard"]({})
def test_toolbox_exposes_preset_tools_and_reports_builtin_execution(tmp_path):
toolbox = Toolbox("task-1", None)
names = {spec.name for spec in toolbox.specs()}
assert "run_powershell" in names and "computer_time" in names
assert "星期" in toolbox.call("computer_time", {"timezone": "UTC"})
assert toolbox.used_builtin is True and toolbox.used_any_tool is True
assert toolbox.call("computer_unknown", {}).startswith("工具调用被拒绝")
assert toolbox.call("computer_read_text_file", {"path": "C:\\definitely\\missing.txt"}).startswith("工具调用失败")
def test_task_executor_marks_preset_tool_usage(tmp_path):
database = AgentDatabase(tmp_path / "agent.sqlite3")
def runner(_instruction, _attachments, toolbox):
return f"本机时间:{toolbox.call('computer_time', {'timezone': 'UTC'})}"
executor = TaskExecutor(database, runner)
completed = Event()
events: list[dict[str, object]] = []
executor.task_event.connect(
lambda event: (events.append(event), completed.set())
if isinstance(event, dict) and event.get("status") == "completed"
else None
)
executor.submit("preset-task", "看看现在几点")
assert completed.wait(10)
assert events[-1]["execution"] == "builtin"
assert "本机时间:" in str(events[-1]["output"])