feat: 将项目从nano重命名为beaver并更新相关配置

- 将所有环境变量前缀从NANO_改为BEAVER_
- 更新README.md文档内容,包括项目介绍、组件说明和快速开始指南
- 修改.gitignore文件,添加auth-portal运行时路径排除规则
- 更新app-instance镜像标签从nano/app-instance改为beaver/app-instance
- 增强技能安全检查器,支持工具前缀白名单功能
- 添加技能草稿重新检查安全性API端点
- 扩展证据选择器,收集工具调用名称用于技能学习
- 改进技能合成器,基于实际调用的工具生成工具提示
- 优化路由超时处理机制,增加重试逻辑
- 更新后端架构文档,添加可视化入口和基础概念说明
- 实现在WebSocket消息中传递工具迭代次数信息
This commit is contained in:
2026-05-20 18:01:06 +08:00
parent 3b0af173cc
commit 9d6cde2d23
63 changed files with 4894 additions and 1596 deletions

View File

@ -158,7 +158,7 @@ def test_load_config_reads_mcp_authz_identity(tmp_path) -> None:
},
"authz": {
"enabled": True,
"baseUrl": "http://nano-authz-service:19090",
"baseUrl": "http://beaver-authz-service:19090",
},
"backend_identity": {
"backend_id": "stevenli",
@ -180,7 +180,7 @@ def test_load_config_reads_mcp_authz_identity(tmp_path) -> None:
assert server.sensitive is True
assert config.authz.enabled is True
assert config.authz.base_url == "http://nano-authz-service:19090"
assert config.authz.base_url == "http://beaver-authz-service:19090"
assert config.backend_identity.backend_id == "stevenli"
assert config.backend_identity.client_id == "stevenli"

View File

@ -38,6 +38,39 @@ class RouterProvider(LLMProvider):
return "stub-model"
class SequenceRouterProvider(LLMProvider):
def __init__(self, responses: list[str | Exception]) -> None:
super().__init__()
self.responses = list(responses)
self.calls: list[dict] = []
async def chat(
self,
messages: list[dict],
tools: list[dict] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
self.calls.append(
{
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"model": model,
"thinking_enabled": thinking_enabled,
}
)
response = self.responses.pop(0)
if isinstance(response, Exception):
raise response
return LLMResponse(content=response, finish_reason="stop", provider_name="stub", model="stub-model")
def get_default_model(self) -> str:
return "stub-model"
def _task() -> TaskRecord:
return TaskRecord(
task_id="task-1",
@ -133,3 +166,38 @@ def test_router_fallback_keeps_active_task_but_not_new_task() -> None:
assert active.is_task
assert not inactive.is_task
def test_router_retries_once_after_provider_failure() -> None:
provider = SequenceRouterProvider(
[
TimeoutError(),
'{"action":"new_task","reason":"needs search","short_title":"中美会面"}',
]
)
decision = asyncio.run(
MainAgentRouter().classify(
"帮我看看昨天的中美会面都谈了什么?",
provider=provider,
)
)
assert decision.is_task
assert decision.action == "create_task"
assert len(provider.calls) == 2
def test_router_fallback_after_two_provider_failures() -> None:
provider = SequenceRouterProvider([TimeoutError(), RuntimeError("provider down")])
decision = asyncio.run(
MainAgentRouter().classify(
"帮我看看昨天的中美会面都谈了什么?",
provider=provider,
)
)
assert not decision.is_task
assert decision.reason == "router_failed: provider down"
assert len(provider.calls) == 2

View File

@ -15,7 +15,12 @@ from beaver.skills.reviews import ReviewService
from beaver.skills.specs import SkillSpecStore
def _pipeline(tmp_path: Path, *, allowed_tools: set[str] | None = None) -> SkillLearningPipelineService:
def _pipeline(
tmp_path: Path,
*,
allowed_tools: set[str] | None = None,
allowed_prefixes: set[str] | None = None,
) -> SkillLearningPipelineService:
spec_store = SkillSpecStore(tmp_path)
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
learning_store = SkillLearningStore(tmp_path / "memory" / "skills")
@ -32,7 +37,10 @@ def _pipeline(tmp_path: Path, *, allowed_tools: set[str] | None = None) -> Skill
draft_service=drafts,
review_service=ReviewService(spec_store),
publisher=SkillPublisher(spec_store),
safety_checker=SkillDraftSafetyChecker(allowed_tool_names=allowed_tools),
safety_checker=SkillDraftSafetyChecker(
allowed_tool_names=allowed_tools,
allowed_tool_prefixes=allowed_prefixes,
),
)
@ -106,3 +114,53 @@ def test_safety_blocks_unknown_tool_hint(tmp_path: Path) -> None:
assert report.passed is False
assert "unknown tool hints" in report.blocked_reasons[0]
def test_safety_allows_configured_mcp_tool_prefix(tmp_path: Path) -> None:
pipeline = _pipeline(
tmp_path,
allowed_tools={"echo"},
allowed_prefixes={"mcp_officebench_"},
)
draft = pipeline.draft_service.create_new_skill_draft(
skill_name="officebench-excel",
proposed_content="# OfficeBench Excel\n\nUse the configured OfficeBench MCP tools.",
proposed_frontmatter={
"description": "officebench",
"tools": [
"mcp_officebench_shell_list_directory",
"mcp_officebench_excel_read_file",
"mcp_officebench_excel_set_cell",
],
},
created_by="test",
reason="test",
)
report = pipeline.check_safety(draft.skill_name, draft.draft_id)
assert report.passed is True
assert report.blocked_reasons == []
def test_safety_blocks_unconfigured_mcp_tool_prefix(tmp_path: Path) -> None:
pipeline = _pipeline(
tmp_path,
allowed_tools={"echo"},
allowed_prefixes={"mcp_outlook_mcp_"},
)
draft = pipeline.draft_service.create_new_skill_draft(
skill_name="wrong-mcp",
proposed_content="# Wrong MCP\n\nUse an unconfigured MCP namespace.",
proposed_frontmatter={
"description": "wrong mcp",
"tools": ["mcp_officebench_excel_set_cell"],
},
created_by="test",
reason="test",
)
report = pipeline.check_safety(draft.skill_name, draft.draft_id)
assert report.passed is False
assert "mcp_officebench_excel_set_cell" in report.blocked_reasons[0]

View File

@ -7,6 +7,7 @@ from types import SimpleNamespace
from beaver.engine.providers.base import LLMProvider, LLMResponse
from beaver.engine.providers.factory import ProviderBundle
from beaver.engine.session import SessionManager
from beaver.memory.runs import RunMemoryStore, RunRecord
from beaver.memory.skills import SkillLearningCandidate, SkillLearningStore
from beaver.skills.drafts import DraftService
@ -125,6 +126,78 @@ def test_worker_retries_and_marks_failed_after_limit(tmp_path: Path) -> None:
assert "provider failed" in (candidate.last_error or "")
def test_synthesizer_fills_missing_tools_from_evidence(tmp_path: Path) -> None:
pipeline = _pipeline(tmp_path)
candidate = pipeline.get_candidate("candidate-1")
provider = JsonProvider(
payload={
"frontmatter": {"description": "Generated skill"},
"content": "# Generated\n\nUse the observed workflow.",
"change_reason": "learned",
}
)
packet = EvidenceSelector(pipeline.learning_service.run_store).build_evidence_packet(
candidate.source_run_ids,
candidate.source_session_ids,
)
packet.metadata["tool_names"] = ["web_fetch", "memory"]
payload = asyncio.run(
SkillDraftSynthesizer().synthesize_new_skill(candidate, packet, provider, "stub")
)
assert payload["frontmatter"]["tools"] == ["web_fetch", "memory"]
def test_evidence_selector_records_run_tool_names(tmp_path: Path) -> None:
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
run_store.append_run_record(
RunRecord(
run_id="run-1",
session_id="session-1",
task_text="research latest docs",
started_at="start",
ended_at="end",
success=True,
finish_reason="stop",
)
)
session_manager = SessionManager(tmp_path)
session_manager.ensure_session("session-1")
session_manager.append_message(
"session-1",
run_id="run-1",
role="system",
event_type="tool_selection_snapshotted",
event_payload={"tool_names": ["memory", "web_fetch"]},
context_visible=False,
)
session_manager.append_message(
"session-1",
run_id="run-1",
role="assistant",
tool_calls=[{"id": "call-1", "function": {"name": "web_search"}}],
)
session_manager.append_message(
"session-1",
run_id="run-1",
role="tool",
tool_name="web_fetch",
content="ok",
)
try:
packet = EvidenceSelector(run_store, session_manager).build_evidence_packet(
["run-1"],
["session-1"],
)
finally:
session_manager.close()
assert packet.metadata["tool_names"] == ["web_search", "web_fetch"]
assert packet.metadata["selected_tool_names"] == ["memory", "web_fetch"]
def test_worker_supersedes_candidate_when_active_draft_exists(tmp_path: Path) -> None:
pipeline = _pipeline(tmp_path)
pipeline.learning_store.record_learning_candidate(

View File

@ -78,6 +78,7 @@ def test_websocket_message_returns_chat_metadata_and_session_updated() -> None:
"model": None,
"provider_name": None,
"embedding_model": None,
"max_tool_iterations": None,
}
]
assert message["type"] == "message"
@ -128,5 +129,6 @@ def test_websocket_runtime_error_returns_assistant_error_message() -> None:
assert message["role"] == "assistant"
assert message["session_id"] == "web:alpha"
assert message["finish_reason"] == "error"
assert message["tool_iterations"] == 0
assert "boom" in message["content"]
assert pong == {"type": "pong"}