初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,86 @@
|
||||
"""内置工具规格测试:覆盖风险分级、env 传参与必填校验。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.tool_runtime.builtins import builtin_specs
|
||||
from app.modules.safety.governor import PermissionPolicy
|
||||
|
||||
|
||||
def test_builtin_specs_cover_risk_levels() -> None:
|
||||
specs = builtin_specs()
|
||||
assert {"L0", "L1", "L2", "L3"} <= {s.risk_level for s in specs.values()}
|
||||
assert {
|
||||
"datetime",
|
||||
"read_file",
|
||||
"list_dir",
|
||||
"write_file",
|
||||
"shell",
|
||||
"fetch_url",
|
||||
} == set(specs)
|
||||
|
||||
|
||||
def test_write_file_is_ask_and_side_effect() -> None:
|
||||
spec = builtin_specs()["write_file"]
|
||||
assert spec.permission_policy is PermissionPolicy.ask
|
||||
assert spec.side_effect is True
|
||||
|
||||
|
||||
def test_read_tools_allow_and_no_side_effect() -> None:
|
||||
specs = builtin_specs()
|
||||
for name in ["datetime", "read_file", "list_dir"]:
|
||||
assert specs[name].permission_policy is PermissionPolicy.allow
|
||||
assert specs[name].side_effect is False
|
||||
|
||||
|
||||
def test_file_tools_use_workspace() -> None:
|
||||
req = builtin_specs()["read_file"].build_request({"path": "a.txt"})
|
||||
assert req.workdir == "workspace"
|
||||
assert req.env.get("PATH_ARG") == "a.txt"
|
||||
|
||||
|
||||
def test_read_file_pagination_builds_sed() -> None:
|
||||
"""read_file 传 offset/limit 时用 sed 行分页,缺省时 cat 全文。"""
|
||||
specs = builtin_specs()
|
||||
|
||||
full = specs["read_file"].build_request({"path": "a.txt"})
|
||||
assert full.argv[2] == 'cat -- "$PATH_ARG"'
|
||||
|
||||
paged = specs["read_file"].build_request({"path": "a.txt", "offset": 10, "limit": 5})
|
||||
assert paged.argv[2] == "sed -n '10,14p' \"$PATH_ARG\""
|
||||
assert paged.env.get("PATH_ARG") == "a.txt"
|
||||
assert paged.workdir == "workspace"
|
||||
|
||||
tail = specs["read_file"].build_request({"path": "a.txt", "offset": 100})
|
||||
assert tail.argv[2] == "sed -n '100,$p' \"$PATH_ARG\""
|
||||
|
||||
|
||||
def test_read_file_pagination_ignores_bad_input() -> None:
|
||||
"""offset/limit 非正整数时回退全文 cat,不误构造非法 sed 地址。"""
|
||||
spec = builtin_specs()["read_file"]
|
||||
assert (
|
||||
spec.build_request({"path": "a", "offset": "x", "limit": -3}).argv[2]
|
||||
== 'cat -- "$PATH_ARG"'
|
||||
)
|
||||
assert spec.build_request({"path": "a", "offset": 0}).argv[2] == 'cat -- "$PATH_ARG"'
|
||||
|
||||
|
||||
def test_required_inputs_validated() -> None:
|
||||
specs = builtin_specs()
|
||||
with pytest.raises(ValueError):
|
||||
specs["read_file"].build_request({})
|
||||
with pytest.raises(ValueError):
|
||||
specs["write_file"].build_request({"content": "x"})
|
||||
with pytest.raises(ValueError):
|
||||
specs["shell"].build_request({})
|
||||
|
||||
|
||||
def test_fetch_url_uses_executor_not_sandbox() -> None:
|
||||
"""fetch_url 使用 executor 直接执行,不走沙箱。"""
|
||||
specs = builtin_specs()
|
||||
spec = specs["fetch_url"]
|
||||
assert spec.executor is not None
|
||||
assert spec.permission_policy is PermissionPolicy.ask
|
||||
assert spec.side_effect is True
|
||||
assert spec.audit_required is True
|
||||
@@ -0,0 +1,74 @@
|
||||
"""MCP 工具转 ToolSpec 测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.tool_runtime import mcp as mcp_module
|
||||
from app.application.tool_runtime.mcp import make_mcp_tool_specs
|
||||
from app.modules.safety.governor import PermissionPolicy
|
||||
from app.modules.tool_runtime.definition import ToolResult
|
||||
|
||||
|
||||
class _Server:
|
||||
def __init__(self, name, endpoint_url, auth_token=None):
|
||||
self.name = name
|
||||
self.endpoint_url = endpoint_url
|
||||
self.auth_token = auth_token
|
||||
|
||||
|
||||
def test_make_mcp_tool_specs_basic() -> None:
|
||||
tools = [{"name": "weather", "description": "查天气", "risk_level": "L1"}]
|
||||
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
|
||||
assert len(specs) == 1
|
||||
s = specs[0]
|
||||
assert s.name == "mcp_srv_weather"
|
||||
assert s.description == "查天气"
|
||||
assert s.risk_level == "L1"
|
||||
assert s.permission_policy == PermissionPolicy.allow
|
||||
|
||||
|
||||
def test_make_mcp_tool_specs_default_risk_l2() -> None:
|
||||
tools = [{"name": "t"}]
|
||||
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
|
||||
assert specs[0].risk_level == "L2"
|
||||
assert specs[0].permission_policy == PermissionPolicy.ask
|
||||
|
||||
|
||||
def test_make_mcp_tool_specs_skips_unnamed() -> None:
|
||||
tools = [{"name": "t"}, {"description": "无名"}]
|
||||
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
|
||||
assert len(specs) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_executor_calls_call_tool(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_call_tool(
|
||||
_http_client, endpoint_url, tool_name, input, auth_token=None, **_kwargs
|
||||
):
|
||||
captured.update(tool=tool_name, input=input, endpoint=endpoint_url, auth=auth_token)
|
||||
return ToolResult(
|
||||
ok=True,
|
||||
exit_code=0,
|
||||
stdout="ok",
|
||||
stderr="",
|
||||
duration_ms=1,
|
||||
timeout=False,
|
||||
truncated=False,
|
||||
trace_id="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mcp_module, "call_tool", fake_call_tool)
|
||||
|
||||
tools = [{"name": "t"}]
|
||||
specs = make_mcp_tool_specs(
|
||||
_Server("srv", "http://mcp.local", auth_token="tok"), tools, http_client="HTTP"
|
||||
)
|
||||
result = await specs[0].executor({"x": 1}, None)
|
||||
assert result.ok is True
|
||||
assert captured == {
|
||||
"tool": "t",
|
||||
"input": {"x": 1},
|
||||
"endpoint": "http://mcp.local",
|
||||
"auth": "tok",
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.application.agent.tools import to_ai_provider_tools
|
||||
from app.application.skill_executor import SkillExecutor
|
||||
from app.application.tool_runtime.view import RunScopedToolView
|
||||
from app.modules.skills.manifest import SkillManifest
|
||||
from app.modules.tool_runtime.definition import ToolResult, ToolSpec
|
||||
|
||||
|
||||
def _spec(name: str) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name=name,
|
||||
description=f"{name} description",
|
||||
risk_level="L1",
|
||||
permission_policy=MagicMock(),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"value": {"type": "string"}},
|
||||
"required": ["value"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_aliases_are_isolated_and_execute_through_base_runtime() -> None:
|
||||
runtime = MagicMock()
|
||||
runtime.list_specs.return_value = [_spec("shared_tool")]
|
||||
runtime.execute = AsyncMock(
|
||||
return_value=ToolResult(True, 0, "ok", "", 1, False, False, "trace_1")
|
||||
)
|
||||
first = RunScopedToolView(runtime)
|
||||
second = RunScopedToolView(runtime)
|
||||
|
||||
assert first.bind_alias("skill:甲/shared_tool", "shared_tool", description_prefix="[甲] ")
|
||||
assert second.bind_alias("skill:乙/shared_tool", "shared_tool", description_prefix="[乙] ")
|
||||
|
||||
first_names = {spec.name for spec in first.list_specs()}
|
||||
second_names = {spec.name for spec in second.list_specs()}
|
||||
result = await first.execute(
|
||||
"skill:甲/shared_tool",
|
||||
{"value": "x"},
|
||||
run_id="run_1",
|
||||
character_id="char_1",
|
||||
)
|
||||
|
||||
assert "skill:甲/shared_tool" in first_names
|
||||
assert "skill:乙/shared_tool" not in first_names
|
||||
assert "skill:乙/shared_tool" in second_names
|
||||
assert "skill:甲/shared_tool" not in second_names
|
||||
assert result.stdout == "ok"
|
||||
runtime.execute.assert_awaited_once_with(
|
||||
"shared_tool",
|
||||
{"value": "x"},
|
||||
run_id="run_1",
|
||||
character_id="char_1",
|
||||
trace_id=None,
|
||||
confirmed=False,
|
||||
context=None,
|
||||
)
|
||||
runtime.register.assert_not_called()
|
||||
runtime.unregister.assert_not_called()
|
||||
|
||||
|
||||
def test_alias_keeps_target_input_schema() -> None:
|
||||
runtime = MagicMock()
|
||||
runtime.list_specs.return_value = [_spec("custom_tool")]
|
||||
view = RunScopedToolView(runtime)
|
||||
view.bind_alias("skill:研究/custom_tool", "custom_tool")
|
||||
|
||||
tools = to_ai_provider_tools(view.list_specs())
|
||||
alias = next(tool for tool in tools if tool["function"]["name"] == "skill:研究/custom_tool")
|
||||
|
||||
assert alias["function"]["parameters"]["required"] == ["value"]
|
||||
|
||||
|
||||
async def test_skill_executor_binds_only_current_run_aliases(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
manifest = SkillManifest(
|
||||
name="研究",
|
||||
description="",
|
||||
triggers=("研究",),
|
||||
prompt="研究说明",
|
||||
allowed_tools=("shared_tool", "missing"),
|
||||
risk_level="L1",
|
||||
)
|
||||
|
||||
async def load(*_args, **_kwargs):
|
||||
return [manifest]
|
||||
|
||||
monkeypatch.setattr("app.application.skill_executor.load_skill_manifests", load)
|
||||
runtime = MagicMock()
|
||||
runtime.list_specs.return_value = [_spec("shared_tool")]
|
||||
view = RunScopedToolView(runtime)
|
||||
messages: list[str] = []
|
||||
|
||||
await SkillExecutor(view).match_and_activate("请研究", messages, run_id="run_1")
|
||||
|
||||
assert messages == ["【研究·能力注入】\n研究说明"]
|
||||
assert "skill:研究/shared_tool" in view.specs_by_name()
|
||||
assert "skill:研究/missing" not in view.specs_by_name()
|
||||
runtime.register.assert_not_called()
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Tool Runtime 编排闭环测试:司命评估 → 沙箱执行 → 一炁事件回写。
|
||||
|
||||
沙箱用 respx 模拟,事件存储用 InMemoryEventStore。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.core.config import SandboxSettings
|
||||
from app.integrations.sandbox.client import SandboxClient
|
||||
from app.modules.runtime.events import InMemoryEventStore
|
||||
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation
|
||||
from app.modules.safety.governor import PermissionPolicy, SafetyGovernor, ToolPolicy
|
||||
|
||||
|
||||
def _settings() -> SandboxSettings:
|
||||
return SandboxSettings(
|
||||
rust_sandbox_url="http://sandbox.test",
|
||||
sandbox_auth_enabled=True,
|
||||
sandbox_auth_secret="shared-secret",
|
||||
sandbox_default_timeout_ms=30000,
|
||||
sandbox_auth_ttl_seconds=30,
|
||||
)
|
||||
|
||||
|
||||
def _ok_response() -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ok": True,
|
||||
"exit_code": 0,
|
||||
"stdout": "42\n",
|
||||
"stderr": "",
|
||||
"duration_ms": 3,
|
||||
"timeout": False,
|
||||
"truncated": False,
|
||||
"trace_id": "t1",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sandbox_client() -> AsyncIterator[SandboxClient]:
|
||||
async with httpx.AsyncClient() as http:
|
||||
yield SandboxClient(http, _settings())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_allow_executes_and_records_started_completed(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
result = await runtime.execute("datetime", {}, run_id="run_1", trace_id="t1")
|
||||
|
||||
assert result.ok
|
||||
assert result.stdout == "42\n"
|
||||
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
|
||||
assert all(e.run_id == "run_1" for e in store.all())
|
||||
|
||||
|
||||
async def test_cancelled_sandbox_execute_requests_remote_cancel() -> None:
|
||||
sandbox = MagicMock()
|
||||
started = asyncio.Event()
|
||||
|
||||
async def exec_forever(*_args, **_kwargs):
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
sandbox.exec = AsyncMock(side_effect=exec_forever)
|
||||
sandbox.cancel = AsyncMock(return_value=True)
|
||||
runtime = ToolRuntime(sandbox, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
task = asyncio.create_task(runtime.execute("datetime", {}, trace_id="trace_cancel"))
|
||||
await started.wait()
|
||||
task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
sandbox.cancel.assert_awaited_once()
|
||||
assert sandbox.cancel.await_args.args[0] == "trace_cancel"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_high_risk_requires_confirmation(sandbox_client: SandboxClient) -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
with pytest.raises(RequiresConfirmation):
|
||||
await runtime.execute("shell", {"command": "echo hi"}, run_id="run_1")
|
||||
|
||||
# 未确认不应进入执行,故无 started 事件
|
||||
assert all(e.type != "tool.started" for e in store.all())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_confirmed_high_risk_executes(sandbox_client: SandboxClient) -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
result = await runtime.execute("shell", {"command": "echo hi"}, run_id="run_1", confirmed=True)
|
||||
|
||||
assert result.ok
|
||||
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_forbidden_records_failed_event(sandbox_client: SandboxClient) -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
governor = SafetyGovernor(
|
||||
{"shell": ToolPolicy(risk_level="L3", permission_policy=PermissionPolicy.forbidden)}
|
||||
)
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, governor, store)
|
||||
|
||||
with pytest.raises(PermissionDenied):
|
||||
await runtime.execute("shell", {"command": "x"}, run_id="run_1", trace_id="t1")
|
||||
|
||||
failed = [e for e in store.all() if e.type == "tool.failed"]
|
||||
assert len(failed) == 1
|
||||
assert failed[0].payload["error_code"] == "permission_denied"
|
||||
assert failed[0].trace_id == "t1"
|
||||
|
||||
|
||||
async def test_unknown_tool_denied(sandbox_client: SandboxClient) -> None:
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
with pytest.raises(PermissionDenied):
|
||||
await runtime.execute("nope", {}, run_id="run_1")
|
||||
|
||||
assert any(e.type == "tool.failed" for e in store.all())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_file_tool_isolates_workdir_by_character(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""文件类工具的 workdir 应按角色隔离,同一角色跨 run 共享 workspace。"""
|
||||
route = respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
await runtime.execute(
|
||||
"write_file",
|
||||
{"path": "a.txt", "content": "x"},
|
||||
character_id="char_01HABC",
|
||||
run_id="run_42",
|
||||
confirmed=True,
|
||||
)
|
||||
|
||||
body = json.loads(route.calls.last.request.content)
|
||||
# workdir 按 character_id 隔离(而非 run_id),保证同一角色跨 run 文件互通
|
||||
assert body["workdir"] == "workspace/char_01HABC"
|
||||
|
||||
|
||||
async def test_isolate_workdir_sanitizes_and_falls_back(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""隔离键经 sanitize 杜绝路径逃逸;缺主体/run/trace 时逐级回退。"""
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
# 路径分隔符与 .. 被 sanitize 清除,无法逃逸沙箱根
|
||||
traversed = runtime._isolate_workdir(
|
||||
"workspace", subject_id="../../etc", run_id=None, trace_id=None
|
||||
)
|
||||
key = traversed.removeprefix("workspace/")
|
||||
assert key == "etc"
|
||||
assert "/" not in key and ".." not in key
|
||||
# 正常 subject_id(character_id)原样保留
|
||||
assert (
|
||||
runtime._isolate_workdir("workspace", subject_id="char_01HABC", run_id=None, trace_id=None)
|
||||
== "workspace/char_01HABC"
|
||||
)
|
||||
# 缺 subject_id 时回退 run_id
|
||||
assert (
|
||||
runtime._isolate_workdir("workspace", subject_id=None, run_id="run_01HABC", trace_id=None)
|
||||
== "workspace/run_01HABC"
|
||||
)
|
||||
# 缺 subject_id/run_id/trace 时回退 adhoc 前缀
|
||||
assert runtime._isolate_workdir(
|
||||
"workspace", subject_id=None, run_id=None, trace_id=None
|
||||
).startswith("workspace/adhoc_")
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_execute_stream_forwards_stdout_and_records_events(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""流式执行:转发 stdout/done,started/completed 落一炁,stdout 增量不落库。"""
|
||||
sse = (
|
||||
b"event: stdout\n"
|
||||
b"data: aGVsbG8K\n"
|
||||
b"\n"
|
||||
b"event: done\n"
|
||||
b'data: {"exit_code":0,"duration_ms":3,"timeout":false,"cancelled":false,"truncated":false}\n'
|
||||
b"\n"
|
||||
)
|
||||
respx.post("http://sandbox.test/v1/exec/stream").mock(
|
||||
return_value=httpx.Response(200, content=sse)
|
||||
)
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
events = [ev async for ev in runtime.execute_stream("datetime", {}, run_id="run_1")]
|
||||
|
||||
assert [e.kind for e in events] == ["stdout", "done"]
|
||||
assert events[-1].payload["exit_code"] == 0
|
||||
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
|
||||
assert all(e.run_id == "run_1" for e in store.all())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_execute_stream_requires_confirmation_for_high_risk(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""流式调用未确认的高风险工具:抛 RequiresConfirmation(端点据此发 requires_confirmation 事件)。"""
|
||||
respx.post("http://sandbox.test/v1/exec/stream").mock(return_value=_ok_response())
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
with pytest.raises(RequiresConfirmation):
|
||||
async for _ in runtime.execute_stream("shell", {"command": "echo hi"}):
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# executor 工具集成测试(fetch_url 走直接执行路径)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_executor_tool_runs_directly_and_records_events(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""executor 工具(fetch_url)走直接执行路径,跳过沙箱,started/completed 落库。"""
|
||||
respx.get("https://example.test").mock(
|
||||
return_value=httpx.Response(200, text="<html><body>Hello</body></html>")
|
||||
)
|
||||
async with httpx.AsyncClient() as tools_http:
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(
|
||||
sandbox_client,
|
||||
SafetyGovernor(),
|
||||
store,
|
||||
http_client=tools_http,
|
||||
)
|
||||
# fetch_url permission=ask,confirmed=True 绕过确认。
|
||||
result = await runtime.execute(
|
||||
"fetch_url",
|
||||
{"url": "https://example.test"},
|
||||
run_id="run_fu",
|
||||
trace_id="t_fu",
|
||||
confirmed=True,
|
||||
)
|
||||
|
||||
assert result.ok
|
||||
assert "Hello" in result.stdout
|
||||
assert result.trace_id == "t_fu"
|
||||
# 沙箱未被调用(直接执行路径)
|
||||
events = store.all()
|
||||
assert [e.type for e in events] == ["tool.started", "tool.completed"]
|
||||
assert all(e.run_id == "run_fu" for e in events)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_executor_tool_requires_confirmation_when_ask(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""executor 工具的 permission_policy=ask 同样触发 RequiresConfirmation。"""
|
||||
async with httpx.AsyncClient() as tools_http:
|
||||
runtime = ToolRuntime(
|
||||
sandbox_client,
|
||||
SafetyGovernor(),
|
||||
InMemoryEventStore(),
|
||||
http_client=tools_http,
|
||||
)
|
||||
with pytest.raises(RequiresConfirmation):
|
||||
await runtime.execute(
|
||||
"fetch_url",
|
||||
{"url": "https://example.test"},
|
||||
)
|
||||
|
||||
|
||||
async def test_executor_tool_without_http_client_returns_error(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""未注入 http_client 时 executor 返回 ok=False(由 executor 自行兜底)。"""
|
||||
runtime = ToolRuntime(
|
||||
sandbox_client,
|
||||
SafetyGovernor(),
|
||||
InMemoryEventStore(),
|
||||
# 故意不传 http_client
|
||||
)
|
||||
result = await runtime.execute(
|
||||
"fetch_url",
|
||||
{"url": "https://example.test"},
|
||||
confirmed=True,
|
||||
)
|
||||
|
||||
assert not result.ok
|
||||
assert "http_client" in result.stderr
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 热刷新:builtin_specs() 的变更对已存在的 ToolRuntime 实例立即生效,无需重启
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_builtin_changes_reflected_in_list_specs_without_reinit(
|
||||
sandbox_client: SandboxClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""删除/修改 builtin 工具后,同一个 ToolRuntime 实例的 list_specs 立即反映变化。"""
|
||||
from app.application.tool_runtime import builtins as builtins_module
|
||||
|
||||
original = builtins_module.builtin_specs()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
# 基线:包含全部 builtin 工具
|
||||
baseline_names = {s.name for s in runtime.list_specs()}
|
||||
assert "datetime" in baseline_names
|
||||
assert "shell" in baseline_names
|
||||
|
||||
# 模拟 builtin_specs 删除了 shell(例如配置关闭或被下线)
|
||||
pruned = {k: v for k, v in original.items() if k != "shell"}
|
||||
|
||||
def _pruned_specs() -> dict:
|
||||
return dict(pruned)
|
||||
|
||||
monkeypatch.setattr(builtins_module, "builtin_specs", _pruned_specs)
|
||||
|
||||
after_names = {s.name for s in runtime.list_specs()}
|
||||
assert "datetime" in after_names
|
||||
assert "shell" not in after_names # 删除立即生效,未重新构造 ToolRuntime
|
||||
|
||||
|
||||
def test_register_overrides_builtin_and_survives_builtin_change(
|
||||
sandbox_client: SandboxClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""register 的工具覆盖 builtin 同名;builtin 变更不影响已注册的覆盖层。"""
|
||||
from app.application.tool_runtime import builtins as builtins_module
|
||||
from app.modules.tool_runtime.definition import ToolSpec
|
||||
|
||||
custom_shell = ToolSpec(
|
||||
name="shell",
|
||||
description="自定义 shell(覆盖 builtin)",
|
||||
risk_level="L1",
|
||||
permission_policy=PermissionPolicy.allow,
|
||||
build_request=lambda _input: _ok_exec_request(),
|
||||
side_effect=False,
|
||||
)
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
runtime.register(custom_shell)
|
||||
|
||||
registered = {s.name: s for s in runtime.list_specs()}
|
||||
assert registered["shell"].description == "自定义 shell(覆盖 builtin)"
|
||||
|
||||
# builtin 变更(如整体被清空)不应影响 register 叠加的工具
|
||||
monkeypatch.setattr(builtins_module, "builtin_specs", lambda: {})
|
||||
|
||||
after = {s.name: s for s in runtime.list_specs()}
|
||||
assert after["shell"].description == "自定义 shell(覆盖 builtin)"
|
||||
|
||||
|
||||
def _ok_exec_request():
|
||||
"""供 register 测试构造 ToolSpec.build_request 用的占位 ExecRequest。"""
|
||||
from app.integrations.sandbox.models import ExecRequest
|
||||
|
||||
return ExecRequest(argv=["sh", "-c", "echo ok"])
|
||||
@@ -0,0 +1,117 @@
|
||||
"""fetch_url executor 单元测试。
|
||||
|
||||
mock 边界:
|
||||
- ``respx`` 模拟 httpx.AsyncClient 的 GET(不真实联网)。
|
||||
- executor 直接调用,不经过 ToolRuntime(runtime 集成路径由 test_runtime 覆盖)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.application.tool_runtime.web_tools import _fetch_url_executor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def http_client() -> AsyncIterator[httpx.AsyncClient]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
yield client
|
||||
|
||||
|
||||
def _ctx(http_client: httpx.AsyncClient, **extra: Any) -> dict[str, Any]:
|
||||
"""构造 executor 上下文。"""
|
||||
base: dict[str, Any] = {"http_client": http_client, "trace_id": "trace_test"}
|
||||
base.update(extra)
|
||||
return base
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_fetch_url_converts_html_to_markdown(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""fetch_url 把 HTML 转成 markdown 并截断。"""
|
||||
html = (
|
||||
"<html><head><title>T</title></head>"
|
||||
"<body><h1>标题</h1><p>这是一段正文。</p>"
|
||||
'<a href="https://example.com">链接</a></body></html>'
|
||||
)
|
||||
respx.get("https://example.com/page").mock(return_value=httpx.Response(200, text=html))
|
||||
|
||||
result = await _fetch_url_executor({"url": "https://example.com/page"}, _ctx(http_client))
|
||||
|
||||
assert result.ok
|
||||
assert "标题" in result.stdout
|
||||
assert "这是一段正文。" in result.stdout
|
||||
# html2text 默认保留链接
|
||||
assert "example.com" in result.stdout
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_fetch_url_truncates_to_max_chars(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""max_chars 截断生效。"""
|
||||
long_text = "A" * 500
|
||||
html = f"<html><body><p>{long_text}</p></body></html>"
|
||||
respx.get("https://example.com/big").mock(return_value=httpx.Response(200, text=html))
|
||||
|
||||
result = await _fetch_url_executor(
|
||||
{"url": "https://example.com/big", "max_chars": 50}, _ctx(http_client)
|
||||
)
|
||||
|
||||
assert result.ok
|
||||
assert "已截断" in result.stdout
|
||||
assert len(result.stdout) < 500
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_fetch_url_http_error_returns_error(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""HTTP 错误返回 ok=False。"""
|
||||
respx.get("https://example.com/missing").mock(
|
||||
return_value=httpx.Response(404, text="not found")
|
||||
)
|
||||
|
||||
result = await _fetch_url_executor({"url": "https://example.com/missing"}, _ctx(http_client))
|
||||
|
||||
assert not result.ok
|
||||
assert "404" in result.stderr
|
||||
|
||||
|
||||
async def test_fetch_url_missing_url_returns_error(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""缺 url 输入时返回 ok=False。"""
|
||||
result = await _fetch_url_executor({}, _ctx(http_client))
|
||||
|
||||
assert not result.ok
|
||||
assert "url" in result.stderr
|
||||
|
||||
|
||||
async def test_fetch_url_missing_http_client_returns_error() -> None:
|
||||
"""缺注入的 http_client 时返回 ok=False。"""
|
||||
result = await _fetch_url_executor({"url": "https://example.com"}, None)
|
||||
|
||||
assert not result.ok
|
||||
assert "http_client" in result.stderr
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_fetch_url_network_error_returns_error(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""网络错误(HTTPError)返回 ok=False 而不抛异常。"""
|
||||
respx.get("https://example.com/neterr").mock(
|
||||
side_effect=httpx.ConnectError("connection refused")
|
||||
)
|
||||
|
||||
result = await _fetch_url_executor({"url": "https://example.com/neterr"}, _ctx(http_client))
|
||||
|
||||
assert not result.ok
|
||||
assert "请求失败" in result.stderr
|
||||
Reference in new issue
Block a user