chore: initial commit (sources + docs, ignore build artifacts and secrets)
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
from mulit_agent_app.application.agent_service import AgentService
|
||||
from mulit_agent_app.config import AgentConfig
|
||||
from mulit_agent_app.infrastructure.database import AgentDatabase
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self) -> None:
|
||||
self.acks: list[tuple[str, int, str, str]] = []
|
||||
|
||||
def send_mcp_ack(self, tool_id: str, version: int, status: str, error: str = "") -> None:
|
||||
self.acks.append((tool_id, version, status, error))
|
||||
|
||||
|
||||
class _Executor:
|
||||
def __init__(self) -> None:
|
||||
self.desktop_enabled = False
|
||||
|
||||
def set_desktop_filesystem_enabled(self, enabled: bool) -> None:
|
||||
self.desktop_enabled = enabled
|
||||
|
||||
|
||||
def test_mcp_snapshot_persists_version_and_reacknowledges_after_reconnect(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.session = _Session()
|
||||
service.task_executor = _Executor()
|
||||
snapshot = {
|
||||
"tools": [
|
||||
{
|
||||
"id": "desktop-filesystem",
|
||||
"deployment_version": 3,
|
||||
"enabled": True,
|
||||
"scope": "desktop-directories",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
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"
|
||||
assert service.task_executor.desktop_enabled is True
|
||||
assert service.session.acks == [
|
||||
("desktop-filesystem", 3, "applied", ""),
|
||||
("desktop-filesystem", 3, "applied", ""),
|
||||
]
|
||||
|
||||
|
||||
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()
|
||||
|
||||
service.apply_mcp_snapshot(
|
||||
{"tools": [{"id": "desktop-filesystem", "deployment_version": 3, "enabled": True, "scope": "desktop-directories"}]}
|
||||
)
|
||||
|
||||
assert service._database.get_meta("mcp.desktop-filesystem.version") == "4"
|
||||
assert service.task_executor.desktop_enabled is False
|
||||
assert service.session.acks == [("desktop-filesystem", 3, "failed", "拒绝低版本桌面文件工具部署。")]
|
||||
@@ -0,0 +1,56 @@
|
||||
import hashlib
|
||||
|
||||
from mulit_agent_app.application.agent_service import AgentService
|
||||
from mulit_agent_app.config import AgentConfig
|
||||
from mulit_agent_app.infrastructure.database import AgentDatabase
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self) -> None:
|
||||
self.acks: list[tuple[str, str, str]] = []
|
||||
|
||||
def send_skill_ack(self, skill_id: str, status: str, error: str = "") -> None:
|
||||
self.acks.append((skill_id, status, error))
|
||||
|
||||
|
||||
class _Executor:
|
||||
def __init__(self) -> None:
|
||||
self.prefix = ""
|
||||
|
||||
def set_instruction_prefix(self, value: str) -> None:
|
||||
self.prefix = value
|
||||
|
||||
|
||||
def test_chinese_skill_name_installs_and_empty_snapshot_removes_it(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.session = _Session()
|
||||
service.task_executor = _Executor()
|
||||
content = "# 图生图工具\n\n仅用于测试。"
|
||||
skill_id = "a" * 32
|
||||
|
||||
service.apply_skill_snapshot(
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"id": skill_id,
|
||||
"name": "图生图工具",
|
||||
"version": "0.3.2",
|
||||
"content": content,
|
||||
"checksum": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert (config.data_dir / "skills" / skill_id / "SKILL.md").read_text(encoding="utf-8") == content
|
||||
assert service._database.get_meta(f"skill.{skill_id}.name") == "图生图工具"
|
||||
assert service.session.acks == [(skill_id, "applied", "")]
|
||||
|
||||
service.apply_skill_snapshot({"skills": []})
|
||||
|
||||
assert not (config.data_dir / "skills" / skill_id).exists()
|
||||
assert service._database.get_meta(f"skill.{skill_id}.name") == ""
|
||||
@@ -0,0 +1,67 @@
|
||||
import json
|
||||
|
||||
from mulit_agent_app.config import AgentConfig
|
||||
|
||||
|
||||
def test_controller_url_requires_secure_scheme(tmp_path):
|
||||
config = AgentConfig(tmp_path, tmp_path / "settings.json", tmp_path / "agent.sqlite3", tmp_path / "agent.log")
|
||||
|
||||
try:
|
||||
config.save_controller_url("http://127.0.0.1:8443")
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("An insecure controller URL must be rejected.")
|
||||
|
||||
|
||||
def test_portable_config_is_loaded_and_updated(monkeypatch, tmp_path):
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
config_path = config_dir / "agent-config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"controller_url": "wss://10.0.0.8:8443", "agent_name": "build-agent"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("MULTI_AGENT_CONFIG_DIR", str(config_dir))
|
||||
|
||||
config = AgentConfig.load()
|
||||
config.save_controller_url("wss://10.0.0.9:8443")
|
||||
|
||||
assert config.controller_url == "wss://10.0.0.8:8443"
|
||||
assert json.loads(config_path.read_text(encoding="utf-8"))["controller_url"] == "wss://10.0.0.9:8443"
|
||||
|
||||
|
||||
def test_agent_name_is_saved_in_portable_config(tmp_path):
|
||||
config_path = tmp_path / "agent-config.json"
|
||||
config = AgentConfig(tmp_path, config_path, tmp_path / "agent.sqlite3", tmp_path / "agent.log")
|
||||
|
||||
assert config.save_agent_name(" 设计部 Agent ") == "设计部 Agent"
|
||||
assert json.loads(config_path.read_text(encoding="utf-8"))["agent_name"] == "设计部 Agent"
|
||||
|
||||
|
||||
def test_persistent_settings_are_mirrored_to_the_portable_config(tmp_path):
|
||||
persistent_path = tmp_path / "app-data" / "agent-settings.json"
|
||||
portable_path = tmp_path / "release" / "config" / "agent-config.json"
|
||||
config = AgentConfig(
|
||||
tmp_path / "app-data",
|
||||
persistent_path,
|
||||
tmp_path / "app-data" / "agent.sqlite3",
|
||||
tmp_path / "app-data" / "agent.log",
|
||||
mirror_settings_path=portable_path,
|
||||
)
|
||||
|
||||
config.save_agent_name("长期保存的 Agent")
|
||||
reloaded = AgentConfig(
|
||||
tmp_path / "app-data",
|
||||
persistent_path,
|
||||
tmp_path / "app-data" / "agent.sqlite3",
|
||||
tmp_path / "app-data" / "agent.log",
|
||||
agent_name="长期保存的 Agent",
|
||||
mirror_settings_path=portable_path,
|
||||
)
|
||||
reloaded.save_controller_url("wss://10.0.0.9:8443")
|
||||
|
||||
expected = {"agent_name": "长期保存的 Agent", "controller_url": "wss://10.0.0.9:8443"}
|
||||
for path in (persistent_path, portable_path):
|
||||
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert {key: saved[key] for key in expected} == expected
|
||||
@@ -0,0 +1,46 @@
|
||||
import pytest
|
||||
|
||||
from mulit_agent_app.config import AgentConfig
|
||||
from mulit_agent_app.infrastructure.controller_session import ControllerSession
|
||||
|
||||
|
||||
def test_https_address_is_converted_to_wss():
|
||||
assert ControllerSession._websocket_url("https://192.168.1.10:8443") == "wss://192.168.1.10:8443"
|
||||
|
||||
|
||||
def test_insecure_address_is_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
ControllerSession._websocket_url("http://192.168.1.10:8443")
|
||||
|
||||
|
||||
def test_current_socket_close_schedules_a_reconnect(tmp_path):
|
||||
config = AgentConfig(
|
||||
tmp_path,
|
||||
tmp_path / "settings.json",
|
||||
tmp_path / "agent.sqlite3",
|
||||
tmp_path / "agent.log",
|
||||
controller_url="wss://10.0.0.9:8443",
|
||||
agent_id="agent-id",
|
||||
certificate_fingerprint="fingerprint",
|
||||
)
|
||||
session = ControllerSession(config, object()) # type: ignore[arg-type]
|
||||
socket = object()
|
||||
scheduled: list[bool] = []
|
||||
session._socket = socket # type: ignore[assignment]
|
||||
session._schedule_reconnect = lambda: scheduled.append(True) # type: ignore[method-assign]
|
||||
|
||||
session._on_close(socket, None, None) # type: ignore[arg-type]
|
||||
|
||||
assert scheduled == [True]
|
||||
|
||||
|
||||
def test_replaced_socket_close_does_not_schedule_an_extra_reconnect(tmp_path):
|
||||
config = AgentConfig(tmp_path, tmp_path / "settings.json", tmp_path / "agent.sqlite3", tmp_path / "agent.log")
|
||||
session = ControllerSession(config, object()) # type: ignore[arg-type]
|
||||
scheduled: list[bool] = []
|
||||
session._socket = object() # type: ignore[assignment]
|
||||
session._schedule_reconnect = lambda: scheduled.append(True) # type: ignore[method-assign]
|
||||
|
||||
session._on_close(object(), None, None) # type: ignore[arg-type]
|
||||
|
||||
assert scheduled == []
|
||||
@@ -0,0 +1,64 @@
|
||||
import pytest
|
||||
|
||||
from mulit_agent_app.domain.attachments import TaskAttachment
|
||||
from mulit_agent_app.infrastructure.desktop_filesystem_tool import DesktopFilesystemTool
|
||||
|
||||
|
||||
def test_creates_one_new_direct_child_of_desktop(tmp_path):
|
||||
tool = DesktopFilesystemTool(enabled=True, desktop_path=tmp_path)
|
||||
|
||||
result = tool.create_directory_from_instruction("在电脑桌面新建 test 文件夹")
|
||||
|
||||
assert result == "已在桌面创建文件夹:test"
|
||||
assert (tmp_path / "test").is_dir()
|
||||
|
||||
|
||||
def test_existing_directory_is_never_overwritten(tmp_path):
|
||||
(tmp_path / "test").mkdir()
|
||||
tool = DesktopFilesystemTool(enabled=True, desktop_path=tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="不会覆盖"):
|
||||
tool.create_directory_from_instruction("在电脑桌面新建 test 文件夹")
|
||||
|
||||
|
||||
def test_disabled_tool_rejects_a_recognized_request_instead_of_returning_none(tmp_path):
|
||||
tool = DesktopFilesystemTool(enabled=False, desktop_path=tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="未部署或尚未应用"):
|
||||
tool.create_directory_from_instruction("在电脑桌面新建 unavailable-test 文件夹")
|
||||
|
||||
|
||||
def test_path_traversal_request_is_rejected_by_policy(tmp_path):
|
||||
tool = DesktopFilesystemTool(enabled=True, desktop_path=tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="请求被策略拒绝"):
|
||||
tool.create_directory_from_instruction("在电脑桌面新建 ../outside 文件夹")
|
||||
|
||||
|
||||
def test_creates_folder_and_saves_controller_attachment_without_a_model(tmp_path):
|
||||
tool = DesktopFilesystemTool(enabled=True, desktop_path=tmp_path)
|
||||
result = tool.create_directory_from_instruction(
|
||||
"帮我在桌面创建一个plimg的文件夹,然后把图片保存进去",
|
||||
(TaskAttachment("桌面原图.png", "image/png", "data:image/png;base64,aW1hZ2U="),),
|
||||
)
|
||||
assert result == "已在桌面创建文件夹并保存 1 张图片:plimg"
|
||||
assert (tmp_path / "plimg" / "桌面原图.png").read_bytes() == b"image"
|
||||
|
||||
|
||||
def test_creates_explicitly_missing_desktop_destination_and_saves_attachment(tmp_path):
|
||||
tool = DesktopFilesystemTool(enabled=True, desktop_path=tmp_path)
|
||||
|
||||
result = tool.create_directory_from_instruction(
|
||||
"这个图片帮我复制到桌面的img文件夹中,如果没有这个文件夹,就创建,然后复制进去",
|
||||
(TaskAttachment("原图.png", "image/png", "data:image/png;base64,aW1hZ2U="),),
|
||||
)
|
||||
|
||||
assert result == "已在桌面创建文件夹并保存 1 张图片:img"
|
||||
assert (tmp_path / "img" / "原图.png").read_bytes() == b"image"
|
||||
|
||||
|
||||
def test_destination_name_without_explicit_creation_permission_is_rejected(tmp_path):
|
||||
tool = DesktopFilesystemTool(enabled=True, desktop_path=tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="无法识别安全"):
|
||||
tool.create_directory_from_instruction("把图片复制到桌面的img文件夹中", ())
|
||||
@@ -0,0 +1,34 @@
|
||||
from mulit_agent_app.infrastructure.full_access_tool import FullAccessTool
|
||||
|
||||
|
||||
def test_full_access_tool_runs_a_local_powershell_command():
|
||||
tool = FullAccessTool()
|
||||
|
||||
result = tool.execute("task-1", "Write-Output full-access-ok")
|
||||
|
||||
assert result == "full-access-ok"
|
||||
|
||||
|
||||
def test_full_access_tool_redacts_secrets_from_command_output():
|
||||
tool = FullAccessTool()
|
||||
|
||||
result = tool.execute("task-2", "Write-Output 'api_key=must-not-leave-agent'")
|
||||
|
||||
assert "must-not-leave-agent" not in result
|
||||
assert "[已脱敏]" in result
|
||||
|
||||
|
||||
def test_full_access_tool_stages_and_removes_task_attachments():
|
||||
from mulit_agent_app.domain.attachments import TaskAttachment
|
||||
|
||||
tool = FullAccessTool()
|
||||
paths = tool.stage_attachments(
|
||||
"test-staged-attachment",
|
||||
(TaskAttachment("input.png", "image/png", "data:image/png;base64,aW1hZ2U="),),
|
||||
)
|
||||
|
||||
assert len(paths) == 1
|
||||
assert paths[0].name == "input.png"
|
||||
assert paths[0].read_bytes() == b"image"
|
||||
tool.cleanup_task_attachments("test-staged-attachment")
|
||||
assert not paths[0].exists()
|
||||
@@ -0,0 +1,94 @@
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from mulit_agent_app.domain.attachments import TaskAttachment
|
||||
from mulit_agent_app.infrastructure.controller_session import ControllerSession
|
||||
from mulit_agent_app.infrastructure.openai_compatible_provider import OpenAICompatibleProvider
|
||||
from mulit_agent_app.infrastructure.provider_factory import AnthropicProvider, GeminiProvider
|
||||
|
||||
|
||||
def image(media_type="image/png"):
|
||||
return {"name": "sample.png", "media_type": media_type, "data_url": f"data:{media_type};base64,{base64.b64encode(b'image').decode()}"}
|
||||
|
||||
|
||||
def test_session_rejects_malformed_excessive_and_oversized_attachments():
|
||||
with pytest.raises((TypeError, ValueError)):
|
||||
ControllerSession._validated_attachments([{"name": "x"}])
|
||||
with pytest.raises((TypeError, ValueError)):
|
||||
ControllerSession._validated_attachments([image()] * 11)
|
||||
huge = base64.b64encode(b"x" * (12 * 1024 * 1024 + 1)).decode()
|
||||
with pytest.raises((TypeError, ValueError)):
|
||||
ControllerSession._validated_attachments([{**image(), "data_url": f"data:image/png;base64,{huge}"}])
|
||||
|
||||
|
||||
class Credentials:
|
||||
def load_llm_api_key(self):
|
||||
return "test-key"
|
||||
|
||||
|
||||
class Response:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_): return False
|
||||
def read(self): return json.dumps({"choices": [{"message": {"content": "ok"}}], "content": [{"text": "ok"}], "candidates": [{"content": {"parts": [{"text": "ok"}]}}]}).encode()
|
||||
|
||||
|
||||
def test_provider_image_payloads(monkeypatch):
|
||||
captured = []
|
||||
def fake_urlopen(request, timeout):
|
||||
captured.append(json.loads(request.data.decode()))
|
||||
return Response()
|
||||
monkeypatch.setattr("mulit_agent_app.infrastructure.openai_compatible_provider.urlopen", fake_urlopen)
|
||||
monkeypatch.setattr("mulit_agent_app.infrastructure.provider_factory.urlopen", fake_urlopen)
|
||||
attachment = TaskAttachment("sample.png", "image/png", image()["data_url"])
|
||||
assert OpenAICompatibleProvider("https://example/v1", "model", Credentials()).generate("look", (attachment,)) == "ok"
|
||||
assert AnthropicProvider("https://example", "model", Credentials()).generate("look", (attachment,)) == "ok"
|
||||
assert GeminiProvider("https://example", "model", Credentials()).generate("look", (attachment,)) == "ok"
|
||||
assert captured[0]["messages"][0]["content"][1]["type"] == "image_url"
|
||||
assert captured[1]["messages"][0]["content"][1]["source"]["type"] == "base64"
|
||||
assert captured[2]["contents"][0]["parts"][1]["inline_data"]["mime_type"] == "image/png"
|
||||
|
||||
|
||||
def test_anthropic_and_gemini_complete_local_tool_calls(monkeypatch):
|
||||
responses = {
|
||||
"anthropic": [
|
||||
{"content": [{"type": "tool_use", "id": "anthropic-call", "input": {"command": "Write-Output anth"}}]},
|
||||
{"content": [{"type": "text", "text": "Anthropic 已执行。"}]},
|
||||
],
|
||||
"gemini": [
|
||||
{"candidates": [{"content": {"parts": [{"functionCall": {"name": "run_powershell", "args": {"command": "Write-Output gem"}}}]}}]},
|
||||
{"candidates": [{"content": {"parts": [{"text": "Gemini 已执行。"}]}}]},
|
||||
],
|
||||
}
|
||||
captured: list[dict[str, object]] = []
|
||||
|
||||
class ToolResponse:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps(self.payload).encode()
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
body = json.loads(request.data.decode())
|
||||
captured.append(body)
|
||||
provider = "anthropic" if request.full_url.endswith("/messages") else "gemini"
|
||||
return ToolResponse(responses[provider].pop(0))
|
||||
|
||||
monkeypatch.setattr("mulit_agent_app.infrastructure.provider_factory.urlopen", fake_urlopen)
|
||||
invoked: list[str] = []
|
||||
anthropic = AnthropicProvider("https://example", "model", Credentials())
|
||||
gemini = GeminiProvider("https://example", "model", Credentials())
|
||||
|
||||
assert anthropic.generate("执行", tool_executor=lambda command: invoked.append(command) or "已执行") == "Anthropic 已执行。"
|
||||
assert gemini.generate("执行", tool_executor=lambda command: invoked.append(command) or "已执行") == "Gemini 已执行。"
|
||||
assert invoked == ["Write-Output anth", "Write-Output gem"]
|
||||
assert captured[0]["tools"]
|
||||
assert captured[2]["tools"]
|
||||
@@ -0,0 +1,107 @@
|
||||
import json
|
||||
|
||||
from mulit_agent_app.infrastructure.openai_compatible_provider import OpenAICompatibleProvider
|
||||
|
||||
|
||||
class _Credentials:
|
||||
def load_llm_api_key(self):
|
||||
return "test-key"
|
||||
|
||||
|
||||
class _Response:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps({"choices": [{"message": {"content": " 模型结果 "}}]}).encode("utf-8")
|
||||
|
||||
|
||||
def test_provider_posts_a_chat_completion(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
captured["url"] = request.full_url
|
||||
captured["body"] = json.loads(request.data.decode("utf-8"))
|
||||
captured["authorization"] = request.get_header("Authorization")
|
||||
captured["timeout"] = timeout
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr("mulit_agent_app.infrastructure.openai_compatible_provider.urlopen", fake_urlopen)
|
||||
|
||||
output = OpenAICompatibleProvider("https://api.example.test/v1", "example-model", _Credentials()).generate("请总结")
|
||||
|
||||
assert output == "模型结果"
|
||||
assert captured == {
|
||||
"url": "https://api.example.test/v1/chat/completions",
|
||||
"body": {"model": "example-model", "messages": [{"role": "user", "content": "请总结"}], "stream": False},
|
||||
"authorization": "Bearer test-key",
|
||||
"timeout": 90,
|
||||
}
|
||||
|
||||
|
||||
def test_provider_removes_reasoning_tags_before_returning_result(monkeypatch):
|
||||
class ThinkingResponse(_Response):
|
||||
def read(self):
|
||||
return json.dumps({"choices": [{"message": {"content": "<think>内部推理</think>最终答案"}}]}).encode("utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mulit_agent_app.infrastructure.openai_compatible_provider.urlopen", lambda request, timeout: ThinkingResponse()
|
||||
)
|
||||
|
||||
output = OpenAICompatibleProvider("https://api.example.test/v1", "example-model", _Credentials()).generate("请总结")
|
||||
|
||||
assert output == "最终答案"
|
||||
|
||||
|
||||
def test_provider_executes_confirmed_local_tool_call_before_returning_answer(monkeypatch):
|
||||
responses = [
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {"name": "run_powershell", "arguments": '{"command":"Get-Date"}'},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{"choices": [{"message": {"content": "已实际完成本机操作。"}}]},
|
||||
]
|
||||
captured: list[dict[str, object]] = []
|
||||
|
||||
class Response:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps(self.payload).encode("utf-8")
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
captured.append(json.loads(request.data.decode("utf-8")))
|
||||
return Response(responses.pop(0))
|
||||
|
||||
monkeypatch.setattr("mulit_agent_app.infrastructure.openai_compatible_provider.urlopen", fake_urlopen)
|
||||
commands: list[str] = []
|
||||
output = OpenAICompatibleProvider("https://api.example.test/v1", "example-model", _Credentials()).generate(
|
||||
"在桌面创建文件夹", tool_executor=lambda command: commands.append(command) or "已创建。"
|
||||
)
|
||||
|
||||
assert output == "已实际完成本机操作。"
|
||||
assert commands == ["Get-Date"]
|
||||
assert captured[0]["tools"]
|
||||
assert captured[1]["messages"][-1] == {"role": "tool", "tool_call_id": "call-1", "content": "已创建。"}
|
||||
@@ -0,0 +1,229 @@
|
||||
from threading import Event, Lock, Timer
|
||||
|
||||
from mulit_agent_app.application.task_executor import TaskExecutor
|
||||
from mulit_agent_app.domain.attachments import TaskAttachment
|
||||
from mulit_agent_app.infrastructure.database import AgentDatabase
|
||||
|
||||
|
||||
def test_executor_reports_a_single_completed_lifecycle(tmp_path):
|
||||
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
||||
executor = TaskExecutor(database)
|
||||
events: list[dict[str, object]] = []
|
||||
completed = Event()
|
||||
|
||||
def capture(event: object) -> None:
|
||||
assert isinstance(event, dict)
|
||||
events.append(event)
|
||||
if event.get("status") == "completed":
|
||||
completed.set()
|
||||
|
||||
executor.task_event.connect(capture)
|
||||
executor.submit("task-1", "确认 Agent 回传结果")
|
||||
|
||||
assert completed.wait(3)
|
||||
assert [event["status"] for event in events] == ["accepted", "running", "completed"]
|
||||
assert database.task_status("task-1") == "completed"
|
||||
|
||||
|
||||
def test_executor_cancels_and_times_out_active_tasks(tmp_path):
|
||||
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
||||
executor = TaskExecutor(database)
|
||||
events: list[dict[str, object]] = []
|
||||
terminal_events = Event()
|
||||
|
||||
def capture(event: object) -> None:
|
||||
assert isinstance(event, dict)
|
||||
events.append(event)
|
||||
if len([item for item in events if item.get("status") in {"cancelled", "timed_out"}]) == 2:
|
||||
terminal_events.set()
|
||||
|
||||
executor.task_event.connect(capture)
|
||||
executor.submit("cancel-task", "应被取消")
|
||||
Timer(0.1, lambda: executor.cancel("cancel-task", "测试取消")).start()
|
||||
executor.submit("timeout-task", "应超时", "2000-01-01T00:00:00+00:00")
|
||||
|
||||
assert terminal_events.wait(1)
|
||||
assert database.task_status("cancel-task") == "cancelled"
|
||||
assert database.task_status("timeout-task") == "timed_out"
|
||||
assert [event["status"] for event in events if event["task_id"] == "cancel-task"][-1] == "cancelled"
|
||||
assert [event["status"] for event in events if event["task_id"] == "timeout-task"][-1] == "timed_out"
|
||||
|
||||
|
||||
def test_executor_uses_the_configured_model_runner(tmp_path):
|
||||
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
||||
executor = TaskExecutor(database, lambda instruction: f"真实模型输出:{instruction}")
|
||||
completed = Event()
|
||||
events: list[dict[str, object]] = []
|
||||
|
||||
def capture(event: object) -> None:
|
||||
assert isinstance(event, dict)
|
||||
events.append(event)
|
||||
if event.get("status") == "completed":
|
||||
completed.set()
|
||||
|
||||
executor.task_event.connect(capture)
|
||||
executor.submit("model-task", "给出一句总结")
|
||||
|
||||
assert completed.wait(2)
|
||||
assert events[-1]["output"] == "真实模型输出:给出一句总结"
|
||||
assert events[-1]["execution"] == "llm"
|
||||
|
||||
|
||||
def test_executor_keeps_each_controller_task_independent(tmp_path):
|
||||
received: list[str] = []
|
||||
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
||||
executor = TaskExecutor(database, lambda instruction: received.append(instruction) or "任务结果")
|
||||
completed = Event()
|
||||
executor.task_event.connect(
|
||||
lambda event: completed.set()
|
||||
if isinstance(event, dict) and event.get("status") == "completed"
|
||||
else None
|
||||
)
|
||||
|
||||
executor.submit("design-direct-task", "整理蓝色方案")
|
||||
|
||||
assert completed.wait(2)
|
||||
assert received == ["整理蓝色方案"]
|
||||
assert database.task_status("design-direct-task") == "completed"
|
||||
|
||||
|
||||
def test_executor_runs_independent_tasks_concurrently(tmp_path):
|
||||
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
||||
both_started = Event()
|
||||
release = Event()
|
||||
completed = Event()
|
||||
lock = Lock()
|
||||
started = 0
|
||||
terminal = 0
|
||||
|
||||
def runner(instruction: str) -> str:
|
||||
nonlocal started
|
||||
with lock:
|
||||
started += 1
|
||||
if started == 2:
|
||||
both_started.set()
|
||||
assert release.wait(2)
|
||||
return f"完成:{instruction}"
|
||||
|
||||
executor = TaskExecutor(database, runner)
|
||||
|
||||
def capture(event: object) -> None:
|
||||
nonlocal terminal
|
||||
if isinstance(event, dict) and event.get("status") == "completed":
|
||||
with lock:
|
||||
terminal += 1
|
||||
if terminal == 2:
|
||||
completed.set()
|
||||
|
||||
executor.task_event.connect(capture)
|
||||
executor.submit("parallel-one", "第一条")
|
||||
executor.submit("parallel-two", "第二条")
|
||||
|
||||
assert both_started.wait(1), "两个独立任务应同时进入模型执行"
|
||||
release.set()
|
||||
assert completed.wait(2)
|
||||
assert database.task_status("parallel-one") == "completed"
|
||||
assert database.task_status("parallel-two") == "completed"
|
||||
|
||||
|
||||
def test_executor_marks_desktop_filesystem_execution(tmp_path):
|
||||
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
||||
executor = TaskExecutor(database)
|
||||
executor.set_full_access_enabled(False)
|
||||
executor.set_desktop_filesystem_enabled(True)
|
||||
executor._desktop_tool._desktop_path = tmp_path / "desktop"
|
||||
completed = Event()
|
||||
events: list[dict[str, object]] = []
|
||||
|
||||
def capture(event: object) -> None:
|
||||
assert isinstance(event, dict)
|
||||
events.append(event)
|
||||
if event.get("status") == "completed":
|
||||
completed.set()
|
||||
|
||||
executor.task_event.connect(capture)
|
||||
executor.submit("tool-task", "在电脑桌面新建 test 文件夹")
|
||||
|
||||
assert completed.wait(2)
|
||||
assert events[-1]["execution"] == "desktop-filesystem"
|
||||
|
||||
|
||||
def test_executor_defaults_to_full_access_tool_runner(tmp_path):
|
||||
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
||||
calls: list[str] = []
|
||||
|
||||
def runner(_instruction, _attachments, run_local):
|
||||
calls.append(run_local("Write-Output full-access-task"))
|
||||
return "已根据真实本机命令完成。"
|
||||
|
||||
executor = TaskExecutor(database, runner)
|
||||
completed = Event()
|
||||
events: list[dict[str, object]] = []
|
||||
|
||||
def capture(event: object) -> None:
|
||||
assert isinstance(event, dict)
|
||||
events.append(event)
|
||||
if event.get("status") == "completed":
|
||||
completed.set()
|
||||
|
||||
executor.task_event.connect(capture)
|
||||
executor.submit("full-access-task", "在本机执行一项操作")
|
||||
|
||||
assert completed.wait(5)
|
||||
assert calls == ["full-access-task"]
|
||||
assert events[-1]["execution"] == "full-access"
|
||||
|
||||
|
||||
def test_executor_rejects_prose_only_answer_for_a_local_action(tmp_path):
|
||||
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
||||
executor = TaskExecutor(database, lambda _instruction: "我会在桌面创建文件夹。")
|
||||
failed = Event()
|
||||
events: list[dict[str, object]] = []
|
||||
|
||||
def capture(event: object) -> None:
|
||||
assert isinstance(event, dict)
|
||||
events.append(event)
|
||||
if event.get("status") == "failed":
|
||||
failed.set()
|
||||
|
||||
executor.task_event.connect(capture)
|
||||
executor.submit("no-tool-action", "在桌面创建一个 test 文件夹")
|
||||
|
||||
assert failed.wait(3)
|
||||
assert "未执行任何操作" in str(events[-1]["error"])
|
||||
|
||||
|
||||
def test_executor_does_not_fall_back_to_llm_when_desktop_tool_is_not_applied(tmp_path):
|
||||
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
||||
calls: list[str] = []
|
||||
executor = TaskExecutor(database, lambda instruction: calls.append(instruction) or "不应调用模型")
|
||||
executor.set_full_access_enabled(False)
|
||||
completed = Event()
|
||||
events: list[dict[str, object]] = []
|
||||
|
||||
def capture(event: object) -> None:
|
||||
assert isinstance(event, dict)
|
||||
events.append(event)
|
||||
if event.get("status") == "failed":
|
||||
completed.set()
|
||||
|
||||
executor.task_event.connect(capture)
|
||||
executor.submit("missing-tool", "在电脑桌面新建 missing-tool-test 文件夹")
|
||||
|
||||
assert completed.wait(2)
|
||||
assert calls == []
|
||||
assert events[-1]["execution"] == "desktop-filesystem"
|
||||
assert "未部署或尚未应用" in str(events[-1]["error"])
|
||||
|
||||
|
||||
def test_image_task_never_falls_back_to_text_only_model(tmp_path):
|
||||
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
||||
calls: list[str] = []
|
||||
executor = TaskExecutor(database, lambda instruction: calls.append(instruction) or "不应调用")
|
||||
finished = Event()
|
||||
events: list[dict[str, object]] = []
|
||||
executor.task_event.connect(lambda event: (events.append(event), finished.set()) if isinstance(event, dict) and event.get("status") == "failed" else None)
|
||||
executor.submit("image-without-vision", "分析图片", attachments=(TaskAttachment("x.png", "image/png", "data:image/png;base64,aQ=="),))
|
||||
assert finished.wait(2)
|
||||
assert calls == []
|
||||
assert "未被降级" in str(events[-1]["error"])
|
||||
Reference in New Issue
Block a user