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

@ -260,7 +260,12 @@ class EngineLoader:
review_service=review_service,
publisher=skill_publisher,
safety_checker=SkillDraftSafetyChecker(
allowed_tool_names={spec.name for spec in tool_registry.list_specs()}
allowed_tool_names={spec.name for spec in tool_registry.list_specs()},
allowed_tool_prefixes={
f"mcp_{server_id}_"
for server_id in self.config.tools.mcp_servers
if str(server_id).strip()
},
),
evaluator=SkillDraftEvaluator(run_memory_store),
)

View File

@ -1437,6 +1437,15 @@ def create_app(
raise HTTPException(status_code=404, detail="Safety report not found")
return report.to_dict()
@app.post("/api/skills/{skill_name}/drafts/{draft_id}/safety")
async def recheck_skill_draft_safety(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
report = loaded.skill_learning_pipeline.check_safety(skill_name, draft_id) # type: ignore[union-attr]
except ValueError as exc:
raise _skill_draft_http_error(exc) from exc
return report.to_dict()
@app.get("/api/skills/{skill_name}/drafts/{draft_id}/eval")
async def get_skill_draft_eval(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
@ -1831,6 +1840,7 @@ def create_app(
"model": _clean_text(payload.get("model")) or None,
"provider_name": _clean_text(payload.get("provider_name")) or None,
"embedding_model": _clean_text(payload.get("embedding_model")) or None,
"max_tool_iterations": _int_or_none(payload.get("max_tool_iterations")),
}
websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled"))
if websocket_thinking_enabled is not None:
@ -1844,6 +1854,7 @@ def create_app(
"content": f"Run failed before completion: {exc}",
"session_id": session_id,
"finish_reason": "error",
"tool_iterations": 0,
"metadata": {
"error": str(exc),
"input_metadata": _websocket_input_metadata(payload),
@ -2403,6 +2414,15 @@ def _bool_or_none(value: Any) -> bool | None:
return None
def _int_or_none(value: Any) -> int | None:
if value in (None, ""):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) -> dict[str, Any]:
validation_result = getattr(result, "validation_result", None)
task_id = getattr(result, "task_id", None)
@ -2414,6 +2434,7 @@ def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) ->
"session_id": getattr(result, "session_id", None),
"run_id": getattr(result, "run_id", None),
"finish_reason": getattr(result, "finish_reason", None),
"tool_iterations": getattr(result, "tool_iterations", 0),
"provider_name": getattr(result, "provider_name", None),
"model": getattr(result, "model", None),
"usage": dict(getattr(result, "usage", {}) or {}),

View File

@ -42,6 +42,8 @@ class EvidenceSelector:
resolved_session_ids: list[str] = list(dict.fromkeys(session_ids or []))
task_summaries: list[str] = []
session_excerpts: list[str] = []
tool_names: list[str] = []
selected_tool_names: list[str] = []
for run_id in run_ids:
record = runs_by_id.get(run_id)
if record is None:
@ -56,12 +58,19 @@ class EvidenceSelector:
excerpt = self._session_excerpt(record.session_id, run_id)
if excerpt:
session_excerpts.append(excerpt)
run_tool_names, run_selected_tool_names = self._run_tool_names(record.session_id, run_id)
tool_names.extend(run_tool_names)
selected_tool_names.extend(run_selected_tool_names)
return EvidencePacket(
run_ids=resolved_run_ids,
session_ids=resolved_session_ids,
task_summaries=task_summaries[:8],
session_excerpts=session_excerpts[:6],
metadata={"bounded": True},
metadata={
"bounded": True,
"tool_names": _unique_strings(tool_names),
"selected_tool_names": _unique_strings(selected_tool_names),
},
)
def _session_excerpt(self, session_id: str, run_id: str) -> str:
@ -74,3 +83,37 @@ class EvidenceSelector:
continue
visible.append(f"{event.role}: {event.content.strip()}")
return "\n".join(visible[:12])[:2000]
def _run_tool_names(self, session_id: str, run_id: str) -> tuple[list[str], list[str]]:
if self.session_manager is None:
return [], []
names: list[str] = []
selected_names: list[str] = []
for event in self.session_manager.get_run_event_records(session_id, run_id):
if event.tool_name:
names.append(event.tool_name)
if event.tool_calls:
for call in event.tool_calls:
if not isinstance(call, dict):
continue
name = call.get("name")
function = call.get("function")
if not name and isinstance(function, dict):
name = function.get("name")
if name:
names.append(str(name))
if event.event_type == "tool_selection_snapshotted" and isinstance(event.event_payload, dict):
selected = event.event_payload.get("tool_names")
if isinstance(selected, list):
selected_names.extend(str(item) for item in selected if str(item).strip())
return _unique_strings(names), _unique_strings(selected_names)
def _unique_strings(values: list[str]) -> list[str]:
result: list[str] = []
for value in values:
cleaned = str(value).strip()
if cleaned and cleaned not in result:
result.append(cleaned)
return result

View File

@ -32,8 +32,14 @@ class SkillDraftSafetyChecker:
"credentials",
}
def __init__(self, *, allowed_tool_names: set[str] | None = None) -> None:
def __init__(
self,
*,
allowed_tool_names: set[str] | None = None,
allowed_tool_prefixes: set[str] | None = None,
) -> None:
self.allowed_tool_names = allowed_tool_names
self.allowed_tool_prefixes = allowed_tool_prefixes or set()
def check(self, draft: SkillDraft) -> SkillDraftSafetyReport:
issues: list[str] = []
@ -50,7 +56,7 @@ class SkillDraftSafetyChecker:
tool_hints = _tool_hints(frontmatter)
if self.allowed_tool_names is not None:
unknown = [name for name in tool_hints if name not in self.allowed_tool_names]
unknown = [name for name in tool_hints if not self._is_allowed_tool_hint(name)]
if unknown:
blocked.append(f"unknown tool hints: {', '.join(sorted(unknown))}")
dangerous = sorted({name for name in tool_hints if name.lower() in self._DANGEROUS_TOOL_HINTS})
@ -80,6 +86,11 @@ class SkillDraftSafetyChecker:
created_at=_utc_now(),
)
def _is_allowed_tool_hint(self, name: str) -> bool:
if self.allowed_tool_names is not None and name in self.allowed_tool_names:
return True
return any(name.startswith(prefix) and len(name) > len(prefix) for prefix in self.allowed_tool_prefixes)
def _tool_hints(frontmatter: dict) -> list[str]:
raw = frontmatter.get("tools")

View File

@ -65,19 +65,29 @@ class SkillDraftSynthesizer:
)
payload = self._parse_payload(response.content or "")
if payload:
return payload
return self._normalize_payload(payload, evidence_packet)
return self._fallback_payload(candidate, evidence_packet, action)
@staticmethod
def _build_prompt(candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, action: str) -> str:
tool_names = _coerce_string_list(evidence_packet.metadata.get("tool_names"))
tool_section = ", ".join(tool_names) if tool_names else "none observed"
selected_tool_names = _coerce_string_list(evidence_packet.metadata.get("selected_tool_names"))
selected_tool_section = ", ".join(selected_tool_names) if selected_tool_names else "none recorded"
return (
f"Action: {action}\n"
f"Candidate kind: {candidate.kind}\n"
f"Reason: {candidate.reason}\n"
f"Related skills: {candidate.related_skill_names}\n"
f"Called tool names: {tool_section}\n"
f"Run-selected tool names: {selected_tool_section}\n"
f"Task summaries:\n- " + "\n- ".join(evidence_packet.task_summaries)
+ "\n\nSession excerpts:\n" + "\n\n".join(evidence_packet.session_excerpts)
+ "\n\nReturn JSON only."
+ "\n\nReturn JSON only. The frontmatter object must include:"
+ "\n- description: a concise skill description"
+ "\n- tools: an explicit JSON array of exact tool names this skill needs. "
+ "Prefer called tool names when the workflow depends on them; use run-selected tool names only when clearly required. "
+ "Use [] only when no tool is required."
)
@staticmethod
@ -103,6 +113,19 @@ class SkillDraftSynthesizer:
"change_reason": str(payload.get("change_reason") or ""),
}
@staticmethod
def _normalize_payload(payload: dict[str, Any], evidence_packet: EvidencePacket) -> dict[str, Any]:
frontmatter = dict(payload.get("frontmatter") or {})
tool_hints = _coerce_string_list(frontmatter.get("tools"))
if not tool_hints:
tool_hints = _coerce_string_list(evidence_packet.metadata.get("tool_names"))
frontmatter["tools"] = tool_hints
return {
"frontmatter": frontmatter,
"content": str(payload.get("content") or "").strip(),
"change_reason": str(payload.get("change_reason") or ""),
}
@staticmethod
def _fallback_payload(candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, action: str) -> dict[str, Any]:
related = candidate.related_skill_names[0] if candidate.related_skill_names else "generated-skill"
@ -111,8 +134,25 @@ class SkillDraftSynthesizer:
return {
"frontmatter": {
"description": candidate.reason or f"Auto-generated {action} draft for {title}.",
"tools": [],
"tools": _coerce_string_list(evidence_packet.metadata.get("tool_names")),
},
"content": f"# {title}\n\n## Evidence\n\n{content}\n",
"change_reason": candidate.reason or f"Fallback {action} synthesis.",
}
def _coerce_string_list(value: Any) -> list[str]:
raw_items: list[Any]
if isinstance(value, list):
raw_items = value
elif isinstance(value, str):
raw_items = value.split(",")
else:
raw_items = []
result: list[str] = []
for item in raw_items:
cleaned = str(item).strip()
if cleaned and cleaned not in result:
result.append(cleaned)
return result

View File

@ -26,38 +26,42 @@ class MainAgentRouter:
) -> MainAgentDecision:
if provider is None:
return self._fallback(active_task=active_task, reason="router_provider_unavailable")
try:
chat_kwargs: dict[str, Any] = {
"messages": [
{
"role": "system",
"content": (
"You are Beaver's Intent Agent. Your only job is to route the user's "
"message to simple chat or internal Task mode. Return only compact JSON. "
"Do not answer the user. Do not explain."
),
},
{
"role": "user",
"content": self._prompt(
message=message,
active_task=active_task,
recent_messages=recent_messages or [],
intent_skill=intent_skill,
),
},
],
"tools": None,
"model": model,
"max_tokens": 256,
"temperature": 0.0,
}
if thinking_enabled is not None:
chat_kwargs["thinking_enabled"] = thinking_enabled
response = await asyncio.wait_for(provider.chat(**chat_kwargs), timeout=timeout_seconds)
return self.from_json(response.content or "", active_task=active_task)
except Exception as exc:
return self._fallback(active_task=active_task, reason=f"router_failed: {exc}")
chat_kwargs: dict[str, Any] = {
"messages": [
{
"role": "system",
"content": (
"You are Beaver's Intent Agent. Your only job is to route the user's "
"message to simple chat or internal Task mode. Return only compact JSON. "
"Do not answer the user. Do not explain."
),
},
{
"role": "user",
"content": self._prompt(
message=message,
active_task=active_task,
recent_messages=recent_messages or [],
intent_skill=intent_skill,
),
},
],
"tools": None,
"model": model,
"max_tokens": 256,
"temperature": 0.0,
}
if thinking_enabled is not None:
chat_kwargs["thinking_enabled"] = thinking_enabled
last_error: Exception | None = None
for attempt_timeout in (timeout_seconds, 12.0):
try:
response = await asyncio.wait_for(provider.chat(**chat_kwargs), timeout=attempt_timeout)
return self.from_json(response.content or "", active_task=active_task)
except Exception as exc:
last_error = exc
return self._fallback(active_task=active_task, reason=f"router_failed: {last_error}")
def from_json(self, text: str, *, active_task: TaskRecord | None = None) -> MainAgentDecision:
payload = self._parse_json_object(text)