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 下语法报错。
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
import json
|
||||
import time
|
||||
from threading import Lock
|
||||
|
||||
from mulit_agent_app.application.agent_service import AgentService
|
||||
from mulit_agent_app.config import AgentConfig
|
||||
from mulit_agent_app.infrastructure.database import AgentDatabase
|
||||
@@ -14,12 +18,22 @@ class _Session:
|
||||
class _Executor:
|
||||
def __init__(self) -> None:
|
||||
self.desktop_enabled = False
|
||||
self.endpoints: tuple = ()
|
||||
|
||||
def set_desktop_filesystem_enabled(self, enabled: bool) -> None:
|
||||
self.desktop_enabled = enabled
|
||||
|
||||
def set_mcp_endpoints(self, endpoints: tuple) -> None:
|
||||
for endpoint in self.endpoints:
|
||||
if endpoint not in endpoints:
|
||||
endpoint.close()
|
||||
self.endpoints = tuple(endpoints)
|
||||
|
||||
def test_mcp_snapshot_persists_version_and_reacknowledges_after_reconnect(tmp_path):
|
||||
def mcp_endpoints(self) -> tuple:
|
||||
return self.endpoints
|
||||
|
||||
|
||||
def _service(tmp_path) -> AgentService:
|
||||
config = AgentConfig(tmp_path, tmp_path / "settings.json", tmp_path / "agent.sqlite3", tmp_path / "agent.log")
|
||||
service = AgentService.__new__(AgentService)
|
||||
service._config = config
|
||||
@@ -27,22 +41,36 @@ def test_mcp_snapshot_persists_version_and_reacknowledges_after_reconnect(tmp_pa
|
||||
service._database.initialize()
|
||||
service.session = _Session()
|
||||
service.task_executor = _Executor()
|
||||
snapshot = {
|
||||
service._mcp_lock = Lock()
|
||||
service._mcp_sequence = 0
|
||||
return service
|
||||
|
||||
|
||||
def _desktop_snapshot(version: int) -> dict:
|
||||
return {
|
||||
"tools": [
|
||||
{
|
||||
"id": "desktop-filesystem",
|
||||
"deployment_version": 3,
|
||||
"name": "桌面文件工具",
|
||||
"kind": "builtin",
|
||||
"version": "1.0.0",
|
||||
"deployment_version": version,
|
||||
"enabled": True,
|
||||
"scope": "desktop-directories",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_mcp_snapshot_persists_a_builtin_tool_and_reacknowledges_after_reconnect(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
snapshot = _desktop_snapshot(3)
|
||||
|
||||
service.apply_mcp_snapshot(snapshot)
|
||||
service.apply_mcp_snapshot(snapshot)
|
||||
|
||||
assert service._database.get_meta("mcp.desktop-filesystem.enabled") == "true"
|
||||
assert service._database.get_meta("mcp.desktop-filesystem.version") == "3"
|
||||
stored = json.loads(service._database.get_meta("mcp.tool.desktop-filesystem"))
|
||||
assert stored["deployment_version"] == 3 and stored["kind"] == "builtin"
|
||||
assert service.task_executor.desktop_enabled is True
|
||||
assert service.session.acks == [
|
||||
("desktop-filesystem", 3, "applied", ""),
|
||||
@@ -50,21 +78,133 @@ def test_mcp_snapshot_persists_version_and_reacknowledges_after_reconnect(tmp_pa
|
||||
]
|
||||
|
||||
|
||||
def test_mcp_snapshot_rejects_lower_version_without_changing_enabled_state(tmp_path):
|
||||
config = AgentConfig(tmp_path, tmp_path / "settings.json", tmp_path / "agent.sqlite3", tmp_path / "agent.log")
|
||||
service = AgentService.__new__(AgentService)
|
||||
service._config = config
|
||||
service._database = AgentDatabase(config.database_path)
|
||||
service._database.initialize()
|
||||
service._database.set_meta("mcp.desktop-filesystem.enabled", "true")
|
||||
service._database.set_meta("mcp.desktop-filesystem.version", "4")
|
||||
service.session = _Session()
|
||||
service.task_executor = _Executor()
|
||||
def test_mcp_snapshot_rejects_a_lower_version_without_changing_state(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
service.apply_mcp_snapshot(_desktop_snapshot(4))
|
||||
service.session.acks.clear()
|
||||
|
||||
service.apply_mcp_snapshot(_desktop_snapshot(3))
|
||||
|
||||
assert json.loads(service._database.get_meta("mcp.tool.desktop-filesystem"))["deployment_version"] == 4
|
||||
# The rejected snapshot is answered, and the already-deployed tool is re-acknowledged.
|
||||
assert service.session.acks == [
|
||||
("desktop-filesystem", 3, "failed", "拒绝低版本工具部署。"),
|
||||
("desktop-filesystem", 4, "applied", ""),
|
||||
]
|
||||
|
||||
|
||||
def test_mcp_snapshot_rejects_an_unsupported_builtin_and_removes_unselected_tools(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
service.apply_mcp_snapshot(_desktop_snapshot(1))
|
||||
service.session.acks.clear()
|
||||
|
||||
service.apply_mcp_snapshot(
|
||||
{"tools": [{"id": "desktop-filesystem", "deployment_version": 3, "enabled": True, "scope": "desktop-directories"}]}
|
||||
{
|
||||
"tools": [
|
||||
{"id": "docker", "kind": "builtin", "deployment_version": 1, "enabled": True},
|
||||
]
|
||||
}
|
||||
)
|
||||
assert service.session.acks == [("docker", 1, "failed", "Agent 不支持该内置工具。")]
|
||||
|
||||
assert service._database.get_meta("mcp.desktop-filesystem.version") == "4"
|
||||
service.session.acks.clear()
|
||||
service.apply_mcp_snapshot({"tools": []})
|
||||
assert service._database.get_meta("mcp.tool.desktop-filesystem") == ""
|
||||
assert service.task_executor.desktop_enabled is False
|
||||
assert service.session.acks == [("desktop-filesystem", 3, "failed", "拒绝低版本桌面文件工具部署。")]
|
||||
|
||||
|
||||
def test_http_mcp_tool_is_reported_failed_when_its_endpoint_is_unreachable(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
|
||||
service.apply_mcp_snapshot(
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"id": "imageforge",
|
||||
"name": "ImageForge",
|
||||
"kind": "http",
|
||||
"endpoint": "http://127.0.0.1:6/mcp",
|
||||
"version": "1.0.0",
|
||||
"deployment_version": 2,
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
deadline = time.time() + 5
|
||||
while not service.session.acks and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
assert len(service.session.acks) == 1
|
||||
tool_id, version, status, error = service.session.acks[0]
|
||||
assert (tool_id, version, status) == ("imageforge", 2, "failed") and error
|
||||
# A failed endpoint must not be published as a usable tool.
|
||||
assert service.task_executor.endpoints == ()
|
||||
|
||||
|
||||
def _stdio_snapshot(command: str, args: list[str], version: int = 1) -> dict:
|
||||
return {
|
||||
"tools": [
|
||||
{
|
||||
"id": "echo",
|
||||
"name": "回显工具",
|
||||
"kind": "stdio",
|
||||
"command": command,
|
||||
"args": args,
|
||||
"version": "1.0.0",
|
||||
"deployment_version": version,
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_stdio_tool_is_spawned_discovered_and_reused_across_reconnects(tmp_path, python_executable, mcp_server_script):
|
||||
service = _service(tmp_path)
|
||||
snapshot = _stdio_snapshot(python_executable, [mcp_server_script])
|
||||
|
||||
try:
|
||||
service.apply_mcp_snapshot(snapshot)
|
||||
_wait_for_acks(service, 1)
|
||||
assert service.session.acks == [("echo", 1, "applied", "")]
|
||||
endpoints = service.task_executor.endpoints
|
||||
assert [endpoint.tool_id for endpoint in endpoints] == ["echo"]
|
||||
process = endpoints[0]._client._transport._process
|
||||
|
||||
# A reconnect replays the same definition: it must keep the live process
|
||||
# instead of spawning a second server.
|
||||
service.session.acks.clear()
|
||||
service.apply_mcp_snapshot({**_stdio_snapshot(python_executable, [mcp_server_script], version=2)})
|
||||
_wait_for_acks(service, 1)
|
||||
|
||||
assert service.session.acks == [("echo", 2, "applied", "")]
|
||||
assert service.task_executor.endpoints[0] is endpoints[0]
|
||||
assert process.poll() is None, "未变更的定义不应重启 MCP 进程"
|
||||
finally:
|
||||
service.task_executor.set_mcp_endpoints(())
|
||||
assert process.poll() is not None, "Agent 必须回收自己拉起的 MCP 进程"
|
||||
|
||||
|
||||
def test_stdio_tool_is_reported_failed_when_its_command_is_missing(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
|
||||
service.apply_mcp_snapshot(_stdio_snapshot("definitely-not-installed-command", []))
|
||||
_wait_for_acks(service, 1)
|
||||
|
||||
tool_id, version, status, error = service.session.acks[0]
|
||||
assert (tool_id, version, status) == ("echo", 1, "failed") and "找不到要执行的命令" in error
|
||||
assert service.task_executor.endpoints == ()
|
||||
|
||||
|
||||
def test_stdio_tool_rejects_a_definition_with_invalid_arguments(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
|
||||
service.apply_mcp_snapshot(_stdio_snapshot("python", ["bad\nargument"]))
|
||||
|
||||
assert service.session.acks == [("echo", 1, "failed", "本机命令参数无效。")]
|
||||
|
||||
|
||||
def _wait_for_acks(service: AgentService, count: int, timeout: float = 10.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while len(service.session.acks) < count and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
Reference in New Issue
Block a user