Files
beaver_project/app-instance/backend/tests/unit/test_tool_assembler.py
steven_li 83d9d8c200 ```
feat(learning): 添加技能学习候选者合成锁定机制

添加了 DraftSynthesisInProgress 和 DraftHasNoChanges 异常来处理并发场景,
确保同一技能学习候选者的合成过程不会重复执行。实现了 claim_learning_candidate_for_synthesis
方法来原子性地锁定候选者进行合成。

fix(web): 为技能草案创建端点添加适当的HTTP状态码

当草案没有变化或正在合成时,现在正确返回409状态码而不是内部错误。

feat(skills): 实现技能修订内容比较以检测无变化情况

添加了 _is_noop_revision 方法来比较基础技能和提议的修订,
如果内容没有实际变化则抛出 NoDraftChanges 异常。

refactor(process): 修复任务证据记录后根运行状态更新逻辑

将任务证据记录事件后的状态从 waiting 更改为 done,并设置 finished_at 时间戳。

feat(tools): 防止在同一运行中重复执行外部写入操作

为邮件发送、日历创建等外部写入工具添加去重机制,避免重复的外部操作。

test: 添加技能学习和工具执行的单元测试

增加测试用例验证并发草案合成、重复外部写入抑制和无变化修订检测等功能。
```
2026-06-16 15:58:42 +08:00

230 lines
7.2 KiB
Python

from __future__ import annotations
import asyncio
from pathlib import Path
import subprocess
import sys
from types import SimpleNamespace
from beaver.engine.context import SkillContext
from beaver.foundation.embedding import EmbeddingRetriever
from beaver.skills.catalog.loader import SkillsLoader
from beaver.tools import BaseTool, ToolAssembler, ToolContext, ToolExecutor, ToolRegistry, ToolResult, ToolSpec
class DummyTool(BaseTool):
def __init__(
self,
name: str,
*,
description: str | None = None,
toolset: str = "test",
always_available: bool = False,
) -> None:
self._spec = ToolSpec(
name=name,
description=description or name,
input_schema={"type": "object", "properties": {}},
toolset=toolset,
always_available=always_available,
)
self.calls: list[dict] = []
@property
def spec(self) -> ToolSpec:
return self._spec
async def invoke(self, arguments: dict, context: ToolContext) -> ToolResult:
self.calls.append(dict(arguments))
return ToolResult(success=True, content="ok", tool_name=self.spec.name)
class StaticRetriever:
async def retrieve(self, **kwargs):
candidates = kwargs["candidates"]
top_k = kwargs["top_k"]
preferred = ["search_files", "echo"]
ordered = sorted(
candidates,
key=lambda item: preferred.index(item["name"]) if item["name"] in preferred else len(preferred),
)
return ordered[:top_k]
def test_tool_spec_exports_mcp_and_provider_schema() -> None:
spec = ToolSpec(
name="read_file",
description="Read a file",
input_schema={"type": "object", "properties": {"path": {"type": "string"}}},
toolset="file",
)
assert spec.to_mcp_descriptor() == {
"name": "read_file",
"description": "Read a file",
"inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}},
}
assert spec.to_provider_schema()["function"]["parameters"] == spec.input_schema
def test_tool_assembler_merges_always_skill_hints_and_embedding(tmp_path: Path) -> None:
skill_dir = tmp_path / "skills" / "docker-debug"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"""---
name: docker-debug
description: Debug Docker issues.
tools:
- terminal
---
# Docker Debug
""",
encoding="utf-8",
)
registry = ToolRegistry()
registry.register(DummyTool("memory", toolset="memory", always_available=True))
registry.register(DummyTool("terminal", toolset="shell"))
registry.register(DummyTool("search_files", toolset="file"))
registry.register(DummyTool("echo", toolset="debug"))
assembler = ToolAssembler(retriever=StaticRetriever())
loader = SkillsLoader(tmp_path)
selected = asyncio.run(
assembler.assemble(
task_description="排查 Docker 容器日志",
registry=registry,
skills_loader=loader,
activated_skills=[SkillContext(name="docker-debug", content="")],
top_k=1,
)
)
assert [spec.name for spec in selected] == ["memory", "terminal", "search_files"]
def test_tool_assembler_uses_required_tools_section_when_frontmatter_omits_tools(tmp_path: Path) -> None:
skill_dir = tmp_path / "skills" / "docker-debug"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"""---
name: docker-debug
description: Debug Docker issues.
---
# Docker Debug
## Overview
Debug Docker issues.
## Required Tools
- `terminal`
- `search_files`
## Workflow
Inspect logs and search related files.
""",
encoding="utf-8",
)
registry = ToolRegistry()
registry.register(DummyTool("memory", toolset="memory", always_available=True))
registry.register(DummyTool("terminal", toolset="shell"))
registry.register(DummyTool("search_files", toolset="file"))
registry.register(DummyTool("echo", toolset="debug"))
assembler = ToolAssembler(retriever=StaticRetriever())
loader = SkillsLoader(tmp_path)
record = loader.get_skill_record("docker-debug")
assert record is not None
assert record.tool_hints == ["terminal", "search_files"]
selected = asyncio.run(
assembler.assemble(
task_description="排查 Docker 容器日志",
registry=registry,
skills_loader=loader,
activated_skills=[SkillContext(name="docker-debug", content="", tool_hints=record.tool_hints)],
top_k=1,
)
)
assert [spec.name for spec in selected] == ["memory", "terminal", "search_files", "echo"]
def test_embedding_fallback_can_return_all_or_top_k() -> None:
candidates = [{"name": f"tool_{index}", "description": "", "input_schema": "{}"} for index in range(3)]
retriever = EmbeddingRetriever(api_key_env="MISSING_EMBEDDING_KEY", api_base_env="MISSING_EMBEDDING_BASE")
all_candidates = asyncio.run(
retriever.retrieve(query="x", candidates=candidates, top_k=1, fallback_top_k=None)
)
top_candidate = asyncio.run(
retriever.retrieve(query="x", candidates=candidates, top_k=1, fallback_top_k=1)
)
assert [item["name"] for item in all_candidates] == ["tool_0", "tool_1", "tool_2"]
assert [item["name"] for item in top_candidate] == ["tool_0"]
def test_beaver_tools_import_does_not_load_provider_stack_with_socks_proxy() -> None:
code = (
"import beaver.tools\n"
"from beaver.skills.catalog.loader import SkillsLoader\n"
"print('ok')"
)
result = subprocess.run(
[sys.executable, "-c", code],
check=False,
capture_output=True,
text=True,
env={
"PYTHONPATH": str(Path(__file__).resolve().parents[2]),
"HTTP_PROXY": "socks://127.0.0.1:7897/",
"HTTPS_PROXY": "socks://127.0.0.1:7897/",
},
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "ok"
def test_tool_executor_parses_object_tool_call_string_arguments() -> None:
tool_call = SimpleNamespace(name="echo", arguments='{"text": "hello"}')
name, arguments = ToolExecutor._normalize_tool_call(tool_call)
assert name == "echo"
assert arguments == {"text": "hello"}
def test_tool_executor_suppresses_duplicate_external_write_in_same_run() -> None:
registry = ToolRegistry()
send_tool = DummyTool("mcp_outlook_mcp_mail_send_email", toolset="mcp")
registry.register(send_tool)
executor = ToolExecutor(registry)
context = ToolContext(
metadata={
"task_id": "task-1",
"run_id": "run-1",
}
)
arguments = {
"to_recipients": ["jay.chen@boardware.com"],
"subject": "请回复今天下午的日程安排",
"body": "Hi Jay",
}
first = asyncio.run(executor.execute("mcp_outlook_mcp_mail_send_email", arguments, context=context))
second = asyncio.run(executor.execute("mcp_outlook_mcp_mail_send_email", dict(arguments), context=context))
assert first.success is True
assert second.success is True
assert second.error == "duplicate_external_write_suppressed"
assert "Duplicate external write suppressed" in second.content
assert len(send_tool.calls) == 1