feat(engine): 添加技能查看工具并优化异步任务管理 - 添加SkillViewTool到引擎加载器中,增强技能管理功能 - 在AgentLoop中引入_active_direct_task来跟踪活跃任务 - 实现直接任务执行时的同步处理逻辑 - 更新工具实例化方式以支持依赖注入 feat(config): 增加智能体运行时参数配置支持 - 扩展AgentDefaultsConfig添加max_tokens和temperature字段 - 实现配置解析函数_first_config_value处理多个配置源 - 支持通过Web API动态更新智能体运行时参数 - 添加前端页面配置表单和验证逻辑 refactor(provider): 统一最大令牌数参数类型为可选整型 - 将所有LLM提供者的max_tokens参数改为int | None类型 - 为AnthropicProvider实现模型特定的最大令牌数默认值 - 调整参数传递逻辑,优先级:调用参数 > 配置文件 > 模型默认值 - 移除硬编码的默认值,改用条件判断 feat(process): 增强事件投影功能 - 添加工具调用开始/结束事件的映射逻辑 - 实现技能激活事件的识别和展示 - 添加辅助函数处理工具调用名称和参数提取 - 优化运行记录关联逻辑,提升事件匹配准确性 fix(web): 更新网络请求客户端信任环境设置 - 将WebFetchTool和WebSearchTool的trust_env参数设为True - 确保HTTP客户端能够正确使用系统代理配置 - 修复可能的网络连接问题 test: 添加配置加载和事件投影相关测试 - 新增智能体默认参数配置测试用例 - 实现API配置持久化和重载测试 - 添加技能卡片和工具事件的投影测试 ```
261 lines
10 KiB
Python
261 lines
10 KiB
Python
"""Config loader for per-sandbox Beaver runtime settings."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .schema import (
|
|
AgentDefaultsConfig,
|
|
AuthzConfig,
|
|
BackendIdentityConfig,
|
|
BeaverConfig,
|
|
EmbeddingConfig,
|
|
MCPServerConfig,
|
|
ProviderConfig,
|
|
ToolsConfig,
|
|
)
|
|
|
|
LOCAL_MCP_CATEGORIES: dict[str, dict[str, str]] = {
|
|
"local_filesystem_mcp": {"category": "filesystem", "display_name": "本地文件工具"},
|
|
"local_runtime_mcp": {"category": "runtime", "display_name": "本地运行工具"},
|
|
"local_memory_mcp": {"category": "memory", "display_name": "本地记忆工具"},
|
|
"local_skills_mcp": {"category": "skills", "display_name": "本地技能工具"},
|
|
"local_coordination_mcp": {"category": "coordination", "display_name": "本地协作工具"},
|
|
"local_scheduler_mcp": {"category": "scheduler", "display_name": "本地定时工具"},
|
|
"local_web_mcp": {"category": "web", "display_name": "本地联网工具"},
|
|
}
|
|
|
|
|
|
def default_config_path(*, workspace: str | Path | None = None) -> Path:
|
|
"""Resolve the default config path for a single-user sandbox instance.
|
|
|
|
Priority:
|
|
1. `BEAVER_CONFIG_PATH`
|
|
2. `BEAVER_HOME/config.json`
|
|
3. `<workspace>/.beaver/config.json`
|
|
4. `./.beaver/config.json`
|
|
"""
|
|
|
|
explicit = os.getenv("BEAVER_CONFIG_PATH")
|
|
if explicit:
|
|
return Path(explicit).expanduser()
|
|
|
|
beaver_home = os.getenv("BEAVER_HOME")
|
|
if beaver_home:
|
|
return Path(beaver_home).expanduser() / "config.json"
|
|
|
|
root = Path(workspace).expanduser() if workspace is not None else Path.cwd()
|
|
return root / ".beaver" / "config.json"
|
|
|
|
|
|
def load_config(
|
|
*,
|
|
workspace: str | Path | None = None,
|
|
config_path: str | Path | None = None,
|
|
) -> BeaverConfig:
|
|
"""Load backend config; missing config is treated as an empty config."""
|
|
|
|
path = Path(config_path).expanduser() if config_path is not None else default_config_path(workspace=workspace)
|
|
if not path.exists():
|
|
return BeaverConfig(config_path=path)
|
|
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"Beaver config must be a JSON object: {path}")
|
|
|
|
return BeaverConfig(
|
|
agents_defaults=_parse_agent_defaults(data),
|
|
providers=_parse_providers(data.get("providers")),
|
|
embedding=_parse_embedding(data),
|
|
tools=_parse_tools(data.get("tools")),
|
|
authz=_parse_authz(data.get("authz")),
|
|
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
|
|
config_path=path,
|
|
)
|
|
|
|
|
|
def _parse_agent_defaults(data: dict[str, Any]) -> AgentDefaultsConfig:
|
|
agents = _as_dict(data.get("agents"))
|
|
defaults = _as_dict(agents.get("defaults"))
|
|
return AgentDefaultsConfig(
|
|
workspace=_string(defaults.get("workspace") or data.get("workspace")),
|
|
model=_string(defaults.get("model") or data.get("model")),
|
|
provider=_string(defaults.get("provider") or data.get("provider")),
|
|
embedding_model=_string(defaults.get("embeddingModel") or defaults.get("embedding_model") or data.get("embeddingModel")),
|
|
max_tokens=_int(_first_config_value(
|
|
defaults.get("maxTokens"),
|
|
defaults.get("max_tokens"),
|
|
data.get("maxTokens"),
|
|
data.get("max_tokens"),
|
|
)),
|
|
temperature=_float(_first_config_value(defaults.get("temperature"), data.get("temperature"))),
|
|
max_context_messages=_int(
|
|
defaults.get("maxContextMessages")
|
|
or defaults.get("max_context_messages")
|
|
or data.get("maxContextMessages")
|
|
or data.get("max_context_messages")
|
|
),
|
|
max_tool_iterations=_int(_first_config_value(
|
|
defaults.get("maxToolIterations"),
|
|
defaults.get("max_tool_iterations"),
|
|
data.get("maxToolIterations"),
|
|
data.get("max_tool_iterations"),
|
|
)),
|
|
)
|
|
|
|
|
|
def _parse_providers(raw: Any) -> dict[str, ProviderConfig]:
|
|
providers: dict[str, ProviderConfig] = {}
|
|
for name, payload in _as_dict(raw).items():
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
providers[str(name)] = ProviderConfig(
|
|
api_key=_string(payload.get("apiKey") or payload.get("api_key")),
|
|
api_base=_string(payload.get("apiBase") or payload.get("api_base") or payload.get("baseUrl") or payload.get("base_url")),
|
|
extra_headers=_string_dict(payload.get("extraHeaders") or payload.get("extra_headers") or payload.get("headers")),
|
|
request_timeout_seconds=_float(
|
|
payload.get("requestTimeoutSeconds")
|
|
or payload.get("request_timeout_seconds")
|
|
or payload.get("timeout")
|
|
),
|
|
)
|
|
return providers
|
|
|
|
|
|
def _parse_embedding(data: dict[str, Any]) -> EmbeddingConfig:
|
|
raw = _as_dict(data.get("embedding") or data.get("embeddings"))
|
|
return EmbeddingConfig(
|
|
provider=_string(raw.get("provider") or raw.get("provider_name")),
|
|
model=_string(raw.get("model") or data.get("embeddingModel") or data.get("embedding_model")),
|
|
api_key=_string(raw.get("apiKey") or raw.get("api_key")),
|
|
api_base=_string(raw.get("apiBase") or raw.get("api_base") or raw.get("baseUrl") or raw.get("base_url")),
|
|
extra_headers=_string_dict(raw.get("extraHeaders") or raw.get("extra_headers") or raw.get("headers")),
|
|
request_timeout_seconds=_float(
|
|
raw.get("requestTimeoutSeconds") or raw.get("request_timeout_seconds") or raw.get("timeout")
|
|
),
|
|
)
|
|
|
|
|
|
def _parse_tools(raw: Any) -> ToolsConfig:
|
|
data = _as_dict(raw)
|
|
mcp_servers: dict[str, MCPServerConfig] = {}
|
|
for server_id, payload in _as_dict(data.get("mcpServers") or data.get("mcp_servers")).items():
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
mcp_servers[str(server_id)] = MCPServerConfig(
|
|
command=_string(payload.get("command")) or "",
|
|
args=_string_list(payload.get("args")),
|
|
env=_string_dict(payload.get("env")),
|
|
url=_string(payload.get("url")) or "",
|
|
headers=_string_dict(payload.get("headers")),
|
|
auth_mode=(_string(payload.get("authMode") or payload.get("auth_mode")) or "none").lower(),
|
|
auth_audience=_string(payload.get("authAudience") or payload.get("auth_audience")) or "",
|
|
auth_scopes=_string_list(payload.get("authScopes") or payload.get("auth_scopes")),
|
|
tool_timeout=int(_float(payload.get("toolTimeout") or payload.get("tool_timeout")) or 30),
|
|
sensitive=_bool(payload.get("sensitive"), default=False),
|
|
kind=(_string(payload.get("kind")) or ("local" if payload.get("command") else "online")).lower(),
|
|
category=_string(payload.get("category")) or ("local" if payload.get("command") else "online"),
|
|
managed=_bool(payload.get("managed"), default=False),
|
|
display_name=_string(payload.get("displayName") or payload.get("display_name")) or "",
|
|
source=_string(payload.get("source")) or "config",
|
|
)
|
|
for server_id, meta in LOCAL_MCP_CATEGORIES.items():
|
|
if server_id in mcp_servers:
|
|
continue
|
|
mcp_servers[server_id] = MCPServerConfig(
|
|
command=sys.executable or "python",
|
|
args=["-m", "beaver.interfaces.mcp.tools_server", "--category", meta["category"]],
|
|
env={},
|
|
kind="local",
|
|
category=meta["category"],
|
|
managed=True,
|
|
display_name=meta["display_name"],
|
|
source="beaver-default",
|
|
tool_timeout=60,
|
|
)
|
|
return ToolsConfig(
|
|
restrict_to_workspace=_bool(
|
|
data.get("restrictToWorkspace") if "restrictToWorkspace" in data else data.get("restrict_to_workspace"),
|
|
default=True,
|
|
),
|
|
mcp_servers=mcp_servers,
|
|
)
|
|
|
|
|
|
def _parse_authz(raw: Any) -> AuthzConfig:
|
|
data = _as_dict(raw)
|
|
return AuthzConfig(
|
|
enabled=_bool(data.get("enabled"), default=False),
|
|
base_url=_string(data.get("baseUrl") or data.get("base_url")) or "",
|
|
request_timeout_seconds=int(_float(data.get("requestTimeoutSeconds") or data.get("request_timeout_seconds")) or 10),
|
|
outlook_mcp_url=_string(data.get("outlookMcpUrl") or data.get("outlook_mcp_url")) or "",
|
|
)
|
|
|
|
|
|
def _parse_backend_identity(raw: Any) -> BackendIdentityConfig:
|
|
data = _as_dict(raw)
|
|
return BackendIdentityConfig(
|
|
backend_id=_string(data.get("backendId") or data.get("backend_id")) or "",
|
|
client_id=_string(data.get("clientId") or data.get("client_id")) or "",
|
|
client_secret=_string(data.get("clientSecret") or data.get("client_secret")) or "",
|
|
name=_string(data.get("name")) or "",
|
|
public_base_url=_string(data.get("publicBaseUrl") or data.get("public_base_url")) or "",
|
|
)
|
|
|
|
|
|
def _as_dict(value: Any) -> dict[str, Any]:
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def _first_config_value(*values: Any) -> Any:
|
|
for value in values:
|
|
if value not in (None, ""):
|
|
return value
|
|
return None
|
|
|
|
|
|
def _string(value: Any) -> str | None:
|
|
if value is None:
|
|
return None
|
|
value = str(value).strip()
|
|
return value or None
|
|
|
|
|
|
def _string_dict(value: Any) -> dict[str, str]:
|
|
if not isinstance(value, dict):
|
|
return {}
|
|
return {str(key): str(item) for key, item in value.items() if item is not None}
|
|
|
|
|
|
def _string_list(value: Any) -> list[str]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
return [str(item) for item in value if str(item).strip()]
|
|
|
|
|
|
def _float(value: Any) -> float | None:
|
|
if value in (None, ""):
|
|
return None
|
|
return float(value)
|
|
|
|
|
|
def _int(value: Any) -> int | None:
|
|
parsed = _float(value)
|
|
if parsed is None:
|
|
return None
|
|
return int(parsed)
|
|
|
|
|
|
def _bool(value: Any, *, default: bool) -> bool:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if value in (None, ""):
|
|
return default
|
|
if isinstance(value, str):
|
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
return bool(value)
|