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:
@ -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"
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user