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: 添加技能学习和工具执行的单元测试 增加测试用例验证并发草案合成、重复外部写入抑制和无变化修订检测等功能。 ```
230 lines
7.2 KiB
Python
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
|