feat(app-instance): 集成Beaver后端并更新配置管理
集成新的Beaver后端服务到应用实例中,替换原有的nanobot实现。 主要变更包括: - 在Dockerfile和环境配置中添加Beaver相关路径和配置变量 - 更新工作目录结构从.nanobot到.beaver - 实现Beaver引擎加载器,支持配置文件加载和工具组装 - 添加内置工具如ListDirectoryTool、ReadFileTool、SearchFilesTool - 更新消息处理流程,支持通道适配器和网关模式 - 重构技能系统,支持显式工具提示和嵌入式检索 - 改进错误处理和生命周期管理 此变更使应用实例能够使用统一的Beaver后端进行AI代理运行时管理。
This commit is contained in:
@ -14,6 +14,12 @@ NANO_MODEL=openai/gpt-5
|
||||
NANO_API_KEY=sk-xxxxxxxx
|
||||
NANO_API_BASE=
|
||||
|
||||
# Per-instance Beaver backend config. In Docker app-instance this should point
|
||||
# to the mounted single-user sandbox config, not to frontend env.
|
||||
BEAVER_HOME=/root/.beaver
|
||||
BEAVER_CONFIG_PATH=/root/.beaver/config.json
|
||||
BEAVER_WORKSPACE=/root/.beaver/workspace
|
||||
|
||||
# Must be reachable from app-instance containers.
|
||||
NANO_AUTHZ_URL=http://nano-authz-service:19090
|
||||
NANO_OUTLOOK_MCP_URL=
|
||||
|
||||
@ -36,7 +36,10 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
||||
APP_PUBLIC_PORT=8080 \
|
||||
APP_FRONTEND_PORT=3000 \
|
||||
APP_BACKEND_PORT=18080 \
|
||||
NANOBOT_AUTH_FILE=/root/.nanobot/web_auth_users.json \
|
||||
BEAVER_HOME=/root/.beaver \
|
||||
BEAVER_CONFIG_PATH=/root/.beaver/config.json \
|
||||
BEAVER_WORKSPACE=/root/.beaver/workspace \
|
||||
NANOBOT_AUTH_FILE=/root/.beaver/web_auth_users.json \
|
||||
PORT=3000 \
|
||||
HOSTNAME=127.0.0.1
|
||||
|
||||
@ -58,25 +61,10 @@ RUN apt-get update && \
|
||||
|
||||
WORKDIR /opt/app/backend
|
||||
|
||||
COPY backend/pyproject.toml backend/README.md backend/LICENSE ./
|
||||
RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
|
||||
uv pip install --system --no-cache .
|
||||
|
||||
COPY backend/nanobot/ ./nanobot/
|
||||
COPY backend/bridge/ ./bridge/
|
||||
COPY backend/third_party/swarms/ ./third_party/swarms/
|
||||
COPY backend/pyproject.toml backend/README.md ./
|
||||
COPY backend/beaver/ ./beaver/
|
||||
RUN uv pip install --system --no-cache .
|
||||
|
||||
WORKDIR /opt/app/backend/bridge
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" && \
|
||||
git config --global url."https://github.com/".insteadOf "git@github.com:" && \
|
||||
npm config set registry "https://registry.npmjs.org" && \
|
||||
npm config set fetch-retries "${NPM_FETCH_RETRIES}" && \
|
||||
npm config set fetch-retry-mintimeout "${NPM_FETCH_RETRY_MIN_TIMEOUT}" && \
|
||||
npm config set fetch-retry-maxtimeout "${NPM_FETCH_RETRY_MAX_TIMEOUT}" && \
|
||||
npm install && npm run build
|
||||
|
||||
WORKDIR /opt/app/frontend
|
||||
COPY --from=frontend-builder /build/frontend/next.config.js ./
|
||||
COPY --from=frontend-builder /build/frontend/public ./public
|
||||
@ -89,7 +77,7 @@ COPY nginx.conf /opt/app/nginx.conf
|
||||
COPY entrypoint.sh /opt/app/entrypoint.sh
|
||||
|
||||
RUN chmod +x /opt/app/entrypoint.sh && \
|
||||
mkdir -p /var/lib/nginx/body /root/.nanobot/workspace
|
||||
mkdir -p /var/lib/nginx/body /root/.beaver/workspace
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@ -106,17 +106,33 @@ runtime/instances/<instance-slug>/
|
||||
|
||||
```text
|
||||
runtime/instances/<instance-slug>/
|
||||
└── nanobot-home
|
||||
└── beaver-home
|
||||
├── config.json
|
||||
├── web_auth_users.json
|
||||
└── workspace/
|
||||
```
|
||||
|
||||
这个目录是单用户 sandbox 的配置与数据边界。容器内会把它挂到:
|
||||
|
||||
```text
|
||||
/root/.beaver/
|
||||
```
|
||||
|
||||
并设置:
|
||||
|
||||
```text
|
||||
BEAVER_CONFIG_PATH=/root/.beaver/config.json
|
||||
BEAVER_WORKSPACE=/root/.beaver/workspace
|
||||
```
|
||||
|
||||
所以模型 `provider/api_key/api_base/model` 配一次即可,Web / channel 请求不需要、也不应该携带 API Key。
|
||||
|
||||
## 当前状态
|
||||
|
||||
这层已经支持:
|
||||
|
||||
- 统一镜像构建
|
||||
- 镜像内安装并启动新的 `beaver` 后端
|
||||
- 实例创建
|
||||
- 实例删除
|
||||
- 实例列表
|
||||
|
||||
@ -2,17 +2,27 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from beaver.engine.context import ContextBuilder
|
||||
from beaver.engine.session import SessionManager
|
||||
from beaver.foundation.config import BeaverConfig, load_config
|
||||
from beaver.memory.curated.store import MemoryStore
|
||||
from beaver.services.memory_service import MemoryService
|
||||
from beaver.skills import SkillAssembler, SkillsLoader
|
||||
from beaver.tools import ObjectBackedTool, ToolExecutor, ToolRegistry
|
||||
from beaver.tools.builtins import EchoTool, MemoryTool, SessionSearchTool, SkillViewTool
|
||||
from beaver.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry
|
||||
from beaver.tools.builtins import (
|
||||
EchoTool,
|
||||
ListDirectoryTool,
|
||||
MemoryTool,
|
||||
ReadFileTool,
|
||||
SearchFilesTool,
|
||||
SessionSearchTool,
|
||||
SkillViewTool,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@ -27,6 +37,7 @@ class EngineLoadResult:
|
||||
"""
|
||||
|
||||
workspace: Path
|
||||
config: BeaverConfig = field(default_factory=BeaverConfig)
|
||||
tools: list[str] = field(default_factory=list)
|
||||
skills: list[str] = field(default_factory=list)
|
||||
memory_stores: list[str] = field(default_factory=list)
|
||||
@ -35,6 +46,7 @@ class EngineLoadResult:
|
||||
curated_memory_store: MemoryStore | None = None
|
||||
memory_service: MemoryService | None = None
|
||||
tool_registry: ToolRegistry | None = None
|
||||
tool_assembler: ToolAssembler | None = None
|
||||
tool_executor: ToolExecutor | None = None
|
||||
context_builder: ContextBuilder | None = None
|
||||
skills_loader: SkillsLoader | None = None
|
||||
@ -89,19 +101,26 @@ class EngineLoader:
|
||||
self,
|
||||
*,
|
||||
workspace: str | Path | None = None,
|
||||
config_path: str | Path | None = None,
|
||||
config: BeaverConfig | None = None,
|
||||
session_manager: SessionManager | None = None,
|
||||
curated_memory_store: MemoryStore | None = None,
|
||||
memory_service: MemoryService | None = None,
|
||||
tool_registry: ToolRegistry | None = None,
|
||||
tool_assembler: ToolAssembler | None = None,
|
||||
context_builder: ContextBuilder | None = None,
|
||||
skills_loader: SkillsLoader | None = None,
|
||||
skill_assembler: SkillAssembler | None = None,
|
||||
) -> None:
|
||||
self.workspace = Path(workspace or Path.cwd())
|
||||
self.config = config or load_config(workspace=workspace, config_path=config_path)
|
||||
configured_workspace = self.config.agents_defaults.workspace
|
||||
env_workspace = os.getenv("BEAVER_WORKSPACE")
|
||||
self.workspace = Path(workspace or configured_workspace or env_workspace or Path.cwd())
|
||||
self._session_manager = session_manager
|
||||
self._curated_memory_store = curated_memory_store
|
||||
self._memory_service = memory_service
|
||||
self._tool_registry = tool_registry
|
||||
self._tool_assembler = tool_assembler
|
||||
self._context_builder = context_builder
|
||||
self._skills_loader = skills_loader
|
||||
self._skill_assembler = skill_assembler
|
||||
@ -127,15 +146,20 @@ class EngineLoader:
|
||||
ObjectBackedTool(MemoryTool(store=memory_service.get_store())),
|
||||
ObjectBackedTool(SkillViewTool(loader=skills_loader)),
|
||||
ObjectBackedTool(SessionSearchTool(db=session_manager)),
|
||||
ObjectBackedTool(ListDirectoryTool()),
|
||||
ObjectBackedTool(ReadFileTool()),
|
||||
ObjectBackedTool(SearchFilesTool()),
|
||||
]
|
||||
)
|
||||
|
||||
context_builder = self._context_builder or ContextBuilder()
|
||||
tool_assembler = self._tool_assembler or ToolAssembler()
|
||||
tool_executor = ToolExecutor(tool_registry)
|
||||
skill_assembler = self._skill_assembler or SkillAssembler(skills_loader)
|
||||
|
||||
result = EngineLoadResult(
|
||||
workspace=workspace,
|
||||
config=self.config,
|
||||
tools=[spec.name for spec in tool_registry.list_specs()],
|
||||
skills=[record.name for record in skills_loader.list_skills(filter_unavailable=False)],
|
||||
memory_stores=["curated"],
|
||||
@ -144,6 +168,7 @@ class EngineLoader:
|
||||
curated_memory_store=memory_service.get_store(),
|
||||
memory_service=memory_service,
|
||||
tool_registry=tool_registry,
|
||||
tool_assembler=tool_assembler,
|
||||
tool_executor=tool_executor,
|
||||
context_builder=context_builder,
|
||||
skills_loader=skills_loader,
|
||||
|
||||
@ -272,12 +272,24 @@ class AgentLoop:
|
||||
memory_service = self._require_loaded("memory_service")
|
||||
context_builder = self._require_loaded("context_builder")
|
||||
tool_registry = self._require_loaded("tool_registry")
|
||||
tool_assembler = self._require_loaded("tool_assembler")
|
||||
tool_executor = self._require_loaded("tool_executor")
|
||||
skills_loader = self._require_loaded("skills_loader")
|
||||
skill_assembler = self._require_loaded("skill_assembler")
|
||||
|
||||
config = loaded.config
|
||||
configured_provider = config.resolve_provider_target(model=model, provider_name=provider_name)
|
||||
|
||||
resolved_session_id = session_id or uuid4().hex
|
||||
resolved_run_id = uuid4().hex
|
||||
resolved_model = model or self.profile.default_model
|
||||
resolved_model = configured_provider.get("model") or self.profile.default_model
|
||||
resolved_provider_name = configured_provider.get("provider_name") or provider_name
|
||||
resolved_api_key = api_key or configured_provider.get("api_key")
|
||||
resolved_api_base = api_base or configured_provider.get("api_base")
|
||||
resolved_extra_headers = extra_headers or configured_provider.get("extra_headers")
|
||||
resolved_request_timeout_seconds = configured_provider.get("request_timeout_seconds")
|
||||
resolved_embedding_model = embedding_model or config.default_embedding_model
|
||||
resolved_embedding_target = embedding_target or config.resolve_embedding_target()
|
||||
resolved_max_tokens = max_tokens or self.profile.max_tokens
|
||||
resolved_temperature = self.profile.temperature if temperature is None else temperature
|
||||
resolved_max_tool_iterations = (
|
||||
@ -316,20 +328,21 @@ class AgentLoop:
|
||||
user_message_recorded = False
|
||||
iterations = 0
|
||||
final_usage: dict[str, Any] = {}
|
||||
final_provider_name: str | None = provider_name
|
||||
final_provider_name: str | None = resolved_provider_name
|
||||
final_model: str | None = resolved_model
|
||||
try:
|
||||
bundle = provider_bundle or make_provider_bundle(
|
||||
model=resolved_model,
|
||||
provider_name=provider_name,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
extra_headers=extra_headers,
|
||||
provider_name=resolved_provider_name,
|
||||
api_key=resolved_api_key,
|
||||
api_base=resolved_api_base,
|
||||
request_timeout_seconds=resolved_request_timeout_seconds,
|
||||
extra_headers=resolved_extra_headers,
|
||||
routing=routing,
|
||||
fallback_target=fallback_target,
|
||||
auxiliary_target=auxiliary_target,
|
||||
embedding_target=embedding_target,
|
||||
embedding_model=embedding_model or "text-embedding-v4",
|
||||
embedding_target=resolved_embedding_target,
|
||||
embedding_model=resolved_embedding_model,
|
||||
)
|
||||
skill_selector_provider = bundle.auxiliary_provider or bundle.main_provider
|
||||
skill_selector_model = (
|
||||
@ -364,6 +377,32 @@ class AgentLoop:
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
selected_tool_specs = await tool_assembler.assemble(
|
||||
task_description=task,
|
||||
registry=tool_registry,
|
||||
skills_loader=skills_loader,
|
||||
activated_skills=assembled_skills.activated_skills,
|
||||
embedding_runtime=bundle.embedding_runtime,
|
||||
top_k=10,
|
||||
)
|
||||
tool_schemas = tool_registry.export_selected_provider_schemas(selected_tool_specs)
|
||||
session_manager.append_message(
|
||||
resolved_session_id,
|
||||
run_id=resolved_run_id,
|
||||
role="system",
|
||||
event_type="tool_selection_snapshotted",
|
||||
event_payload={
|
||||
"tools": [spec.to_mcp_descriptor() for spec in selected_tool_specs],
|
||||
"tool_names": [spec.name for spec in selected_tool_specs],
|
||||
},
|
||||
content=", ".join(spec.name for spec in selected_tool_specs) or None,
|
||||
context_visible=False,
|
||||
source=source,
|
||||
title=title,
|
||||
model=resolved_model,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
build_input = ContextBuildInput(
|
||||
base_system_prompt=self.profile.system_prompt,
|
||||
history=session_manager.get_history(resolved_session_id),
|
||||
@ -412,7 +451,6 @@ class AgentLoop:
|
||||
|
||||
provider = bundle.main_provider
|
||||
messages = list(context_result.messages)
|
||||
tool_schemas = tool_registry.export_provider_schemas()
|
||||
tool_context = ToolContext(
|
||||
workspace=str(loaded.workspace),
|
||||
session_id=resolved_session_id,
|
||||
|
||||
@ -8,7 +8,7 @@ import os
|
||||
from typing import Any
|
||||
|
||||
from .base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from .registry import find_by_model, find_gateway
|
||||
from .registry import find_by_model, find_by_name, find_gateway
|
||||
from .runtime import ProviderRoutingConfig
|
||||
|
||||
try: # pragma: no cover - optional dependency
|
||||
@ -58,7 +58,11 @@ class LiteLLMProvider(LLMProvider):
|
||||
|
||||
if not api_key:
|
||||
return {}
|
||||
spec = self._gateway or find_by_model(model)
|
||||
spec = self._gateway
|
||||
if spec is None and self.provider_name:
|
||||
spec = find_by_name(self.provider_name)
|
||||
if spec is None:
|
||||
spec = find_by_model(model)
|
||||
if spec is None or not spec.env_key:
|
||||
return {}
|
||||
overrides: dict[str, str] = {spec.env_key: api_key}
|
||||
@ -97,6 +101,15 @@ class LiteLLMProvider(LLMProvider):
|
||||
if prefix and not resolved.startswith(f"{prefix}/"):
|
||||
resolved = f"{prefix}/{resolved}"
|
||||
return resolved
|
||||
if self.provider_name:
|
||||
spec = find_by_name(self.provider_name)
|
||||
if spec is not None and not spec.is_gateway and not spec.is_local:
|
||||
resolved = model
|
||||
if spec.litellm_prefix and not any(resolved.startswith(prefix) for prefix in spec.skip_prefixes):
|
||||
resolved = f"{spec.litellm_prefix}/{resolved}"
|
||||
elif spec.name == "openai" and "/" not in resolved:
|
||||
resolved = f"openai/{resolved}"
|
||||
return resolved
|
||||
spec = find_by_model(model)
|
||||
if spec and spec.litellm_prefix:
|
||||
if not any(model.startswith(prefix) for prefix in spec.skip_prefixes):
|
||||
|
||||
@ -1,2 +1,13 @@
|
||||
"""Configuration models and loaders."""
|
||||
|
||||
from .loader import default_config_path, load_config
|
||||
from .schema import AgentDefaultsConfig, BeaverConfig, EmbeddingConfig, ProviderConfig
|
||||
|
||||
__all__ = [
|
||||
"AgentDefaultsConfig",
|
||||
"BeaverConfig",
|
||||
"EmbeddingConfig",
|
||||
"ProviderConfig",
|
||||
"default_config_path",
|
||||
"load_config",
|
||||
]
|
||||
|
||||
127
app-instance/backend/beaver/foundation/config/loader.py
Normal file
127
app-instance/backend/beaver/foundation/config/loader.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""Config loader for per-sandbox Beaver runtime settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .schema import AgentDefaultsConfig, BeaverConfig, EmbeddingConfig, ProviderConfig
|
||||
|
||||
|
||||
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. `NANOBOT_CONFIG_PATH` for compatibility during migration
|
||||
3. `BEAVER_HOME/config.json`
|
||||
4. `NANOBOT_HOME/config.json` for migration compatibility
|
||||
5. `<workspace>/.beaver/config.json`
|
||||
6. `./.beaver/config.json`
|
||||
"""
|
||||
|
||||
explicit = os.getenv("BEAVER_CONFIG_PATH") or os.getenv("NANOBOT_CONFIG_PATH")
|
||||
if explicit:
|
||||
return Path(explicit).expanduser()
|
||||
|
||||
beaver_home = os.getenv("BEAVER_HOME")
|
||||
if beaver_home:
|
||||
return Path(beaver_home).expanduser() / "config.json"
|
||||
|
||||
nanobot_home = os.getenv("NANOBOT_HOME")
|
||||
if nanobot_home:
|
||||
return Path(nanobot_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),
|
||||
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")),
|
||||
)
|
||||
|
||||
|
||||
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 _as_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
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 _float(value: Any) -> float | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return float(value)
|
||||
136
app-instance/backend/beaver/foundation/config/schema.py
Normal file
136
app-instance/backend/beaver/foundation/config/schema.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""Runtime configuration schema for Beaver sandbox instances."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ProviderConfig:
|
||||
"""One configured LLM provider profile."""
|
||||
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
extra_headers: dict[str, str] = field(default_factory=dict)
|
||||
request_timeout_seconds: float | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AgentDefaultsConfig:
|
||||
"""Default agent settings for this sandbox instance."""
|
||||
|
||||
workspace: str | None = None
|
||||
model: str | None = None
|
||||
provider: str | None = None
|
||||
embedding_model: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EmbeddingConfig:
|
||||
"""Optional dedicated embedding model settings."""
|
||||
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
extra_headers: dict[str, str] = field(default_factory=dict)
|
||||
request_timeout_seconds: float | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BeaverConfig:
|
||||
"""Config loaded once per backend sandbox instance."""
|
||||
|
||||
agents_defaults: AgentDefaultsConfig = field(default_factory=AgentDefaultsConfig)
|
||||
providers: dict[str, ProviderConfig] = field(default_factory=dict)
|
||||
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
||||
config_path: Path | None = None
|
||||
|
||||
@property
|
||||
def default_model(self) -> str | None:
|
||||
return _clean(self.agents_defaults.model)
|
||||
|
||||
@property
|
||||
def default_embedding_model(self) -> str:
|
||||
return _clean(self.embedding.model) or _clean(self.agents_defaults.embedding_model) or "text-embedding-v4"
|
||||
|
||||
def resolve_provider_target(
|
||||
self,
|
||||
*,
|
||||
model: str | None = None,
|
||||
provider_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve model/provider credentials from instance config.
|
||||
|
||||
Request-level model/provider overrides are allowed, but credentials are still
|
||||
read from backend config, not from Web/channel payloads.
|
||||
"""
|
||||
|
||||
resolved_model = _clean(model) or self.default_model
|
||||
resolved_provider = _clean(provider_name) or self._infer_provider(resolved_model)
|
||||
provider_cfg = self.providers.get(resolved_provider or "") if resolved_provider else None
|
||||
payload: dict[str, Any] = {
|
||||
"model": resolved_model,
|
||||
"provider_name": resolved_provider,
|
||||
}
|
||||
if provider_cfg is not None:
|
||||
payload.update(
|
||||
{
|
||||
"api_key": provider_cfg.api_key,
|
||||
"api_base": provider_cfg.api_base,
|
||||
"extra_headers": dict(provider_cfg.extra_headers),
|
||||
"request_timeout_seconds": provider_cfg.request_timeout_seconds,
|
||||
}
|
||||
)
|
||||
return {key: value for key, value in payload.items() if value not in (None, "", {})}
|
||||
|
||||
def resolve_embedding_target(self) -> dict[str, Any] | None:
|
||||
"""Return an explicit embedding target when configured."""
|
||||
|
||||
has_explicit_embedding = any(
|
||||
[
|
||||
_clean(self.embedding.provider),
|
||||
_clean(self.embedding.api_key),
|
||||
_clean(self.embedding.api_base),
|
||||
self.embedding.extra_headers,
|
||||
self.embedding.request_timeout_seconds is not None,
|
||||
]
|
||||
)
|
||||
if not has_explicit_embedding:
|
||||
return None
|
||||
|
||||
provider_cfg = self.providers.get(_clean(self.embedding.provider) or "")
|
||||
payload: dict[str, Any] = {
|
||||
"provider": _clean(self.embedding.provider),
|
||||
"model": self.default_embedding_model,
|
||||
"api_key": _clean(self.embedding.api_key) or (provider_cfg.api_key if provider_cfg else None),
|
||||
"api_base": _clean(self.embedding.api_base) or (provider_cfg.api_base if provider_cfg else None),
|
||||
"extra_headers": self.embedding.extra_headers or (dict(provider_cfg.extra_headers) if provider_cfg else {}),
|
||||
"request_timeout_seconds": self.embedding.request_timeout_seconds
|
||||
or (provider_cfg.request_timeout_seconds if provider_cfg else None),
|
||||
}
|
||||
return {key: value for key, value in payload.items() if value not in (None, "", {})}
|
||||
|
||||
def _infer_provider(self, model: str | None) -> str | None:
|
||||
configured_provider = _clean(self.agents_defaults.provider)
|
||||
if configured_provider:
|
||||
return configured_provider
|
||||
|
||||
if model and "/" in model:
|
||||
prefix = model.split("/", 1)[0]
|
||||
if prefix in self.providers:
|
||||
return prefix
|
||||
|
||||
if len(self.providers) == 1:
|
||||
return next(iter(self.providers))
|
||||
return None
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
return value or None
|
||||
|
||||
205
app-instance/backend/beaver/foundation/embedding.py
Normal file
205
app-instance/backend/beaver/foundation/embedding.py
Normal file
@ -0,0 +1,205 @@
|
||||
"""Shared embedding-based semantic retrieval utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib import request
|
||||
|
||||
|
||||
class EmbeddingRetriever:
|
||||
"""Use an OpenAI-compatible embeddings API to rank lightweight candidates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key_env: str = "OPENAI_API_KEY",
|
||||
api_base_env: str = "OPENAI_API_BASE",
|
||||
model: str = "text-embedding-v4",
|
||||
timeout_seconds: float = 20.0,
|
||||
) -> None:
|
||||
self.api_key_env = api_key_env
|
||||
self.api_base_env = api_base_env
|
||||
self.model = model
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
async def retrieve(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
candidates: list[dict[str, str]],
|
||||
top_k: int,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
model: str | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
fallback_top_k: int | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Return candidates ordered by embedding similarity.
|
||||
|
||||
If embedding config is missing or the request fails, return the original
|
||||
candidate order. This keeps retrieval non-blocking for the main run.
|
||||
"""
|
||||
|
||||
if not candidates or top_k <= 0:
|
||||
return []
|
||||
|
||||
fallback = self._fallback_candidates(candidates, fallback_top_k=fallback_top_k)
|
||||
resolved_api_key = api_key or os.getenv(self.api_key_env)
|
||||
resolved_api_base = api_base or os.getenv(self.api_base_env)
|
||||
if not resolved_api_key or not resolved_api_base:
|
||||
return fallback
|
||||
|
||||
try:
|
||||
query_embedding = await self._embed_texts(
|
||||
api_key=resolved_api_key,
|
||||
api_base=resolved_api_base,
|
||||
texts=[query],
|
||||
model=model or self.model,
|
||||
extra_headers=extra_headers,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
candidate_embeddings = await self._embed_texts(
|
||||
api_key=resolved_api_key,
|
||||
api_base=resolved_api_base,
|
||||
texts=[self._candidate_text(item) for item in candidates],
|
||||
model=model or self.model,
|
||||
extra_headers=extra_headers,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
if not query_embedding or not query_embedding[0] or len(candidate_embeddings) != len(candidates):
|
||||
return fallback
|
||||
|
||||
query_vector = query_embedding[0]
|
||||
scored: list[tuple[float, dict[str, str]]] = []
|
||||
for candidate, vector in zip(candidates, candidate_embeddings, strict=False):
|
||||
if vector:
|
||||
scored.append((self._cosine_similarity(query_vector, vector), candidate))
|
||||
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
return [item[1] for item in scored[:top_k]]
|
||||
|
||||
async def _embed_texts(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
api_base: str,
|
||||
texts: list[str],
|
||||
model: str,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> list[list[float]]:
|
||||
all_vectors: list[list[float]] = []
|
||||
endpoint = self._normalize_embeddings_endpoint(api_base)
|
||||
for start in range(0, len(texts), 10):
|
||||
batch = texts[start:start + 10]
|
||||
payload = await self._post_embeddings(
|
||||
endpoint=endpoint,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
texts=batch,
|
||||
extra_headers=extra_headers,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
embeddings = payload.get("data") or []
|
||||
embeddings = sorted(embeddings, key=lambda item: item.get("index", 0))
|
||||
all_vectors.extend([list(item.get("embedding") or []) for item in embeddings])
|
||||
return all_vectors
|
||||
|
||||
async def _post_embeddings(
|
||||
self,
|
||||
*,
|
||||
endpoint: str,
|
||||
api_key: str,
|
||||
model: str,
|
||||
texts: list[str],
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await asyncio.to_thread(
|
||||
self._post_embeddings_sync,
|
||||
endpoint=endpoint,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
texts=texts,
|
||||
extra_headers=extra_headers,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
|
||||
def _post_embeddings_sync(
|
||||
self,
|
||||
*,
|
||||
endpoint: str,
|
||||
api_key: str,
|
||||
model: str,
|
||||
texts: list[str],
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
body = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"input": texts if len(texts) > 1 else texts[0],
|
||||
"encoding_format": "float",
|
||||
}
|
||||
).encode("utf-8")
|
||||
req = request.Request(
|
||||
endpoint,
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
**(extra_headers or {}),
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with request.urlopen(req, timeout=timeout_seconds or self.timeout_seconds) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
@staticmethod
|
||||
def _fallback_candidates(
|
||||
candidates: list[dict[str, str]],
|
||||
*,
|
||||
fallback_top_k: int | None,
|
||||
) -> list[dict[str, str]]:
|
||||
if fallback_top_k is None:
|
||||
return list(candidates)
|
||||
if fallback_top_k <= 0:
|
||||
return []
|
||||
return candidates[:fallback_top_k]
|
||||
|
||||
@staticmethod
|
||||
def _candidate_text(candidate: dict[str, str]) -> str:
|
||||
parts = [
|
||||
(candidate.get("name") or "").strip(),
|
||||
(candidate.get("description") or "").strip(),
|
||||
(candidate.get("input_schema") or "").strip(),
|
||||
]
|
||||
return "\n".join(part for part in parts if part)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_embeddings_endpoint(api_base: str) -> str:
|
||||
base = api_base.rstrip("/")
|
||||
if base.endswith("/embeddings"):
|
||||
return base
|
||||
if base.endswith("/v1"):
|
||||
return f"{base}/embeddings"
|
||||
return f"{base}/v1/embeddings"
|
||||
|
||||
@staticmethod
|
||||
def _cosine_similarity(left: list[float], right: list[float]) -> float:
|
||||
if not left or not right or len(left) != len(right):
|
||||
return -1.0
|
||||
dot = sum(a * b for a, b in zip(left, right, strict=False))
|
||||
left_norm = math.sqrt(sum(a * a for a in left))
|
||||
right_norm = math.sqrt(sum(b * b for b in right))
|
||||
if left_norm == 0 or right_norm == 0:
|
||||
return -1.0
|
||||
return dot / (left_norm * right_norm)
|
||||
@ -1,2 +1,7 @@
|
||||
"""Channel interfaces."""
|
||||
|
||||
from .base import ChannelAdapter
|
||||
from .manager import ChannelManager
|
||||
from .memory import MemoryChannelAdapter
|
||||
|
||||
__all__ = ["ChannelAdapter", "ChannelManager", "MemoryChannelAdapter"]
|
||||
|
||||
24
app-instance/backend/beaver/interfaces/channels/base.py
Normal file
24
app-instance/backend/beaver/interfaces/channels/base.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Channel adapter contracts for gateway-facing integrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from beaver.foundation.events import MessageBus, OutboundMessage
|
||||
|
||||
|
||||
class ChannelAdapter(Protocol):
|
||||
"""Minimal contract every gateway channel must implement."""
|
||||
|
||||
name: str
|
||||
bus: MessageBus
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Prepare the channel before messages are routed."""
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop accepting/routing channel messages."""
|
||||
|
||||
async def send(self, message: OutboundMessage) -> None:
|
||||
"""Deliver an outbound message to the concrete channel."""
|
||||
|
||||
76
app-instance/backend/beaver/interfaces/channels/manager.py
Normal file
76
app-instance/backend/beaver/interfaces/channels/manager.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""Channel manager for routing gateway outbound messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
|
||||
from beaver.foundation.events import MessageBus, OutboundMessage
|
||||
|
||||
from .base import ChannelAdapter
|
||||
|
||||
|
||||
class ChannelManager:
|
||||
"""Start/stop channel adapters and dispatch outbound messages to them."""
|
||||
|
||||
def __init__(self, bus: MessageBus) -> None:
|
||||
self.bus = bus
|
||||
self.channels: dict[str, ChannelAdapter] = {}
|
||||
self.undeliverable: list[OutboundMessage] = []
|
||||
self.started = False
|
||||
|
||||
def register(self, channel: ChannelAdapter) -> None:
|
||||
if self.started:
|
||||
raise RuntimeError("Cannot register channels after ChannelManager.start()")
|
||||
if channel.name in self.channels:
|
||||
raise ValueError(f"Channel already registered: {channel.name}")
|
||||
if channel.bus is not self.bus:
|
||||
raise ValueError("Channel must share the same MessageBus as ChannelManager")
|
||||
self.channels[channel.name] = channel
|
||||
|
||||
async def start(self) -> None:
|
||||
started: list[ChannelAdapter] = []
|
||||
try:
|
||||
for channel in self.channels.values():
|
||||
await channel.start()
|
||||
started.append(channel)
|
||||
except BaseException:
|
||||
for channel in reversed(started):
|
||||
with suppress(BaseException):
|
||||
await channel.stop()
|
||||
raise
|
||||
else:
|
||||
self.started = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
errors: list[BaseException] = []
|
||||
for channel in reversed(tuple(self.channels.values())):
|
||||
try:
|
||||
await channel.stop()
|
||||
except Exception as exc: # pragma: no cover - defensive cleanup path
|
||||
errors.append(exc)
|
||||
self.started = False
|
||||
if errors:
|
||||
raise RuntimeError(f"Failed to stop {len(errors)} channel(s)") from errors[0]
|
||||
|
||||
async def dispatch_outbound(self, stop_event: asyncio.Event) -> None:
|
||||
"""Route bus outbound messages until stopped and the queue is drained."""
|
||||
|
||||
while True:
|
||||
if stop_event.is_set() and self.bus.outbound_size == 0:
|
||||
break
|
||||
|
||||
try:
|
||||
message = await asyncio.wait_for(self.bus.consume_outbound(), timeout=0.25)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
channel = self.channels.get(message.channel)
|
||||
if channel is None:
|
||||
self.undeliverable.append(message)
|
||||
continue
|
||||
|
||||
try:
|
||||
await channel.send(message)
|
||||
except Exception: # pragma: no cover - defensive channel isolation
|
||||
self.undeliverable.append(message)
|
||||
57
app-instance/backend/beaver/interfaces/channels/memory.py
Normal file
57
app-instance/backend/beaver/interfaces/channels/memory.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""In-memory channel adapter for tests and local gateway embedding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
|
||||
|
||||
|
||||
class MemoryChannelAdapter:
|
||||
"""A local channel that stores outbound messages in memory."""
|
||||
|
||||
def __init__(self, bus: MessageBus, *, name: str = "memory") -> None:
|
||||
self.name = name
|
||||
self.bus = bus
|
||||
self.started = False
|
||||
self.sent_messages: list[OutboundMessage] = []
|
||||
|
||||
async def start(self) -> None:
|
||||
self.started = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.started = False
|
||||
|
||||
async def send(self, message: OutboundMessage) -> None:
|
||||
self.sent_messages.append(message)
|
||||
|
||||
async def publish_text(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
title: str | None = None,
|
||||
execution_context: str | None = None,
|
||||
model: str | None = None,
|
||||
provider_name: str | None = None,
|
||||
embedding_model: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> InboundMessage:
|
||||
"""Publish a text message from this channel into the shared bus."""
|
||||
|
||||
message = InboundMessage(
|
||||
channel=self.name,
|
||||
content=content,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
execution_context=execution_context,
|
||||
model=model,
|
||||
provider_name=provider_name,
|
||||
embedding_model=embedding_model,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
await self.bus.publish_inbound(message)
|
||||
return message
|
||||
|
||||
@ -35,6 +35,7 @@ app = typer.Typer(help="Beaver backend CLI") if hasattr(typer, "Typer") else typ
|
||||
def run(
|
||||
message: str | None = typer.Option(None, "--message", "-m", help="Run one direct Beaver request."),
|
||||
workspace: str | None = typer.Option(None, "--workspace", help="Workspace root for this run."),
|
||||
config: str | None = typer.Option(None, "--config", help="Backend config path for this run."),
|
||||
) -> None:
|
||||
"""Thin CLI wrapper around AgentService.
|
||||
|
||||
@ -44,7 +45,7 @@ def run(
|
||||
3. 打印结果
|
||||
"""
|
||||
|
||||
service = AgentService(workspace=workspace)
|
||||
service = AgentService(workspace=workspace, config_path=config)
|
||||
if not message:
|
||||
service.create_loop()
|
||||
typer.echo("Beaver engine booted.")
|
||||
|
||||
@ -1,41 +1,39 @@
|
||||
"""Gateway entrypoint for Beaver.
|
||||
|
||||
当前阶段先不扩 bus / channels adapter,只做最小消息桥接:
|
||||
当前阶段只做最小 gateway 宿主与 channel adapter 桥接:
|
||||
1. 启动时托管 `AgentService.start()`
|
||||
2. 常驻消费 `MessageBus.inbound`
|
||||
3. 调 `service.submit_direct(...)`
|
||||
3. 调 `service.handle_inbound_message(...)`
|
||||
4. 将结果写回 `MessageBus.outbound`
|
||||
5. 退出时走 `AgentService.shutdown()`
|
||||
5. 如果配置了 channel adapters,则由 `ChannelManager` 分发 outbound
|
||||
6. 退出时走 `AgentService.shutdown()`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
|
||||
from beaver.foundation.events import InboundMessage, MessageBus
|
||||
from beaver.interfaces.channels import ChannelAdapter, ChannelManager
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
async def _publish_bridge_error(
|
||||
bus: MessageBus,
|
||||
inbound: InboundMessage,
|
||||
async def _cleanup_owned_service(
|
||||
service: AgentService,
|
||||
*,
|
||||
detail: str,
|
||||
finish_reason: str = "error",
|
||||
timeout_seconds: float | None,
|
||||
force: bool,
|
||||
) -> None:
|
||||
"""把 bridge 处理失败转换成结构化 outbound 错误消息。"""
|
||||
"""Best-effort cleanup for service startup failures or cancellations."""
|
||||
|
||||
await bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
message_id=inbound.message_id,
|
||||
channel=inbound.channel,
|
||||
session_id=inbound.session_id,
|
||||
content=detail,
|
||||
finish_reason=finish_reason,
|
||||
metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)},
|
||||
)
|
||||
)
|
||||
with suppress(BaseException):
|
||||
if service.is_running:
|
||||
await service.shutdown(timeout_seconds=timeout_seconds, force=force)
|
||||
else:
|
||||
service.close()
|
||||
|
||||
|
||||
async def _flush_pending_inbound(bus: MessageBus, *, reason: str) -> None:
|
||||
@ -46,11 +44,17 @@ async def _flush_pending_inbound(bus: MessageBus, *, reason: str) -> None:
|
||||
pending = bus.inbound.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
await _publish_bridge_error(bus, pending, detail=reason, finish_reason="stopped")
|
||||
await bus.publish_outbound(
|
||||
AgentService.build_outbound_error(
|
||||
pending,
|
||||
detail=reason,
|
||||
finish_reason="stopped",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _await_bridge_shutdown(task: asyncio.Task[None], *, timeout_seconds: float = 1.0) -> None:
|
||||
"""等待 bridge 退出;超时则取消,避免 shutdown 被桥接层反向卡死。"""
|
||||
async def _await_task_shutdown(task: asyncio.Task[None], *, timeout_seconds: float = 1.0) -> None:
|
||||
"""等待后台任务退出;超时则取消,避免 shutdown 被反向卡死。"""
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=timeout_seconds)
|
||||
@ -85,53 +89,28 @@ async def _bridge_inbound_to_runtime(
|
||||
continue
|
||||
|
||||
try:
|
||||
result = await service.submit_direct(
|
||||
inbound.content,
|
||||
session_id=inbound.session_id,
|
||||
source=f"gateway:{inbound.channel}",
|
||||
user_id=inbound.user_id,
|
||||
title=inbound.title,
|
||||
execution_context=inbound.execution_context,
|
||||
model=inbound.model,
|
||||
provider_name=inbound.provider_name,
|
||||
embedding_model=inbound.embedding_model,
|
||||
)
|
||||
outbound = await service.handle_inbound_message(inbound)
|
||||
except asyncio.CancelledError:
|
||||
await _publish_bridge_error(
|
||||
bus,
|
||||
inbound,
|
||||
detail="Gateway stopped before completing the inbound message",
|
||||
finish_reason="cancelled",
|
||||
)
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - defensive bridge path
|
||||
await _publish_bridge_error(
|
||||
bus,
|
||||
inbound,
|
||||
detail=str(exc),
|
||||
)
|
||||
else:
|
||||
await bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
message_id=inbound.message_id,
|
||||
channel=inbound.channel,
|
||||
session_id=result.session_id,
|
||||
run_id=result.run_id,
|
||||
content=result.output_text,
|
||||
finish_reason=result.finish_reason,
|
||||
provider_name=result.provider_name,
|
||||
model=result.model,
|
||||
usage=dict(result.usage),
|
||||
metadata={"inbound_metadata": dict(inbound.metadata)},
|
||||
AgentService.build_outbound_error(
|
||||
inbound,
|
||||
detail="Gateway stopped before completing the inbound message",
|
||||
finish_reason="cancelled",
|
||||
)
|
||||
)
|
||||
raise
|
||||
else:
|
||||
await bus.publish_outbound(outbound)
|
||||
|
||||
|
||||
async def run_gateway(
|
||||
*,
|
||||
workspace: str | Path | None = None,
|
||||
config_path: str | Path | None = None,
|
||||
service: AgentService | None = None,
|
||||
bus: MessageBus | None = None,
|
||||
channels: Sequence[ChannelAdapter] | None = None,
|
||||
channel_manager: ChannelManager | None = None,
|
||||
manage_service_lifecycle: bool | None = None,
|
||||
stop_event: asyncio.Event | None = None,
|
||||
shutdown_timeout_seconds: float | None = 5.0,
|
||||
@ -142,19 +121,41 @@ async def run_gateway(
|
||||
默认 ownership 语义:
|
||||
- 未传 `service`:gateway 自己创建并接管其 lifecycle
|
||||
- 传入外部 `service`:默认只使用,不自动 start/shutdown
|
||||
- `channel_manager` 和 `channels` 二选一,避免隐式修改外部 manager
|
||||
"""
|
||||
|
||||
attached_service = service or AgentService(workspace=workspace)
|
||||
attached_bus = bus or MessageBus()
|
||||
attached_service = service or AgentService(workspace=workspace, config_path=config_path)
|
||||
if channel_manager is not None and channels is not None:
|
||||
raise ValueError("Pass either channel_manager or channels, not both")
|
||||
if bus is not None:
|
||||
attached_bus = bus
|
||||
elif channel_manager is not None:
|
||||
attached_bus = channel_manager.bus
|
||||
else:
|
||||
attached_bus = MessageBus()
|
||||
attached_channel_manager = channel_manager
|
||||
if attached_channel_manager is not None and attached_channel_manager.bus is not attached_bus:
|
||||
raise ValueError("Injected channel_manager must share the gateway MessageBus")
|
||||
if attached_channel_manager is None and channels is not None:
|
||||
attached_channel_manager = ChannelManager(attached_bus)
|
||||
if attached_channel_manager is not None and channels is not None:
|
||||
for channel in channels:
|
||||
attached_channel_manager.register(channel)
|
||||
|
||||
owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None
|
||||
owned_stop_event = stop_event or asyncio.Event()
|
||||
started = False
|
||||
channels_started = False
|
||||
if owns_service:
|
||||
try:
|
||||
await attached_service.start()
|
||||
started = True
|
||||
except Exception:
|
||||
attached_service.close()
|
||||
except BaseException:
|
||||
await _cleanup_owned_service(
|
||||
attached_service,
|
||||
timeout_seconds=shutdown_timeout_seconds,
|
||||
force=shutdown_force,
|
||||
)
|
||||
raise
|
||||
|
||||
if not attached_service.is_running:
|
||||
@ -163,7 +164,25 @@ async def run_gateway(
|
||||
"or allow the gateway to manage its lifecycle."
|
||||
)
|
||||
|
||||
if attached_channel_manager is not None:
|
||||
try:
|
||||
await attached_channel_manager.start()
|
||||
channels_started = True
|
||||
except BaseException:
|
||||
if owns_service and started:
|
||||
await _cleanup_owned_service(
|
||||
attached_service,
|
||||
timeout_seconds=shutdown_timeout_seconds,
|
||||
force=shutdown_force,
|
||||
)
|
||||
raise
|
||||
|
||||
bridge_task = asyncio.create_task(_bridge_inbound_to_runtime(attached_service, attached_bus, owned_stop_event))
|
||||
dispatch_task: asyncio.Task[None] | None = None
|
||||
dispatch_stop_event = asyncio.Event()
|
||||
if attached_channel_manager is not None:
|
||||
dispatch_task = asyncio.create_task(attached_channel_manager.dispatch_outbound(dispatch_stop_event))
|
||||
|
||||
try:
|
||||
await owned_stop_event.wait()
|
||||
finally:
|
||||
@ -175,9 +194,14 @@ async def run_gateway(
|
||||
force=shutdown_force,
|
||||
)
|
||||
finally:
|
||||
await _await_bridge_shutdown(bridge_task)
|
||||
await _await_task_shutdown(bridge_task)
|
||||
else:
|
||||
await _await_bridge_shutdown(bridge_task)
|
||||
await _await_task_shutdown(bridge_task)
|
||||
if dispatch_task is not None:
|
||||
dispatch_stop_event.set()
|
||||
await _await_task_shutdown(dispatch_task)
|
||||
if attached_channel_manager is not None and channels_started:
|
||||
await attached_channel_manager.stop()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
@ -56,6 +56,7 @@ async def _app_lifespan(
|
||||
app: FastAPI,
|
||||
*,
|
||||
workspace: str | Path | None,
|
||||
config_path: str | Path | None,
|
||||
service: AgentService | None,
|
||||
manage_service_lifecycle: bool | None,
|
||||
shutdown_timeout_seconds: float | None,
|
||||
@ -63,7 +64,7 @@ async def _app_lifespan(
|
||||
) -> AsyncIterator[None]:
|
||||
"""把 Web app 接到 AgentService lifecycle 上。"""
|
||||
|
||||
attached_service = service or AgentService(workspace=workspace)
|
||||
attached_service = service or AgentService(workspace=workspace, config_path=config_path)
|
||||
owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None
|
||||
app.state.agent_service = attached_service
|
||||
started = False
|
||||
@ -71,8 +72,15 @@ async def _app_lifespan(
|
||||
try:
|
||||
await attached_service.start()
|
||||
started = True
|
||||
except Exception:
|
||||
attached_service.close()
|
||||
except BaseException:
|
||||
with suppress(BaseException):
|
||||
if attached_service.is_running:
|
||||
await attached_service.shutdown(
|
||||
timeout_seconds=shutdown_timeout_seconds,
|
||||
force=shutdown_force,
|
||||
)
|
||||
else:
|
||||
attached_service.close()
|
||||
raise
|
||||
try:
|
||||
yield
|
||||
@ -87,6 +95,7 @@ async def _app_lifespan(
|
||||
def create_app(
|
||||
*,
|
||||
workspace: str | Path | None = None,
|
||||
config_path: str | Path | None = None,
|
||||
service: AgentService | None = None,
|
||||
manage_service_lifecycle: bool | None = None,
|
||||
shutdown_timeout_seconds: float | None = 5.0,
|
||||
@ -106,6 +115,7 @@ def create_app(
|
||||
lifespan=lambda fastapi_app: _app_lifespan(
|
||||
fastapi_app,
|
||||
workspace=workspace,
|
||||
config_path=config_path,
|
||||
service=service,
|
||||
manage_service_lifecycle=manage_service_lifecycle,
|
||||
shutdown_timeout_seconds=shutdown_timeout_seconds,
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
"""Application services for Beaver."""
|
||||
|
||||
from .agent_service import AgentService
|
||||
from .memory_service import MemoryService
|
||||
|
||||
__all__ = ["AgentService", "MemoryService"]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "AgentService":
|
||||
from .agent_service import AgentService
|
||||
|
||||
return AgentService
|
||||
if name == "MemoryService":
|
||||
from .memory_service import MemoryService
|
||||
|
||||
return MemoryService
|
||||
raise AttributeError(name)
|
||||
|
||||
@ -17,6 +17,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from beaver.engine import AgentLoop, AgentProfile, AgentRunResult, EngineLoader
|
||||
from beaver.foundation.events import InboundMessage, OutboundMessage
|
||||
|
||||
|
||||
class AgentService:
|
||||
@ -36,11 +37,12 @@ class AgentService:
|
||||
self,
|
||||
*,
|
||||
workspace: str | Path | None = None,
|
||||
config_path: str | Path | None = None,
|
||||
profile: AgentProfile | None = None,
|
||||
loader: EngineLoader | None = None,
|
||||
) -> None:
|
||||
self.profile = profile or AgentProfile()
|
||||
self.loader = loader or EngineLoader(workspace=workspace)
|
||||
self.loader = loader or EngineLoader(workspace=workspace, config_path=config_path)
|
||||
self._loop: AgentLoop | None = None
|
||||
self._run_task: asyncio.Task[None] | None = None
|
||||
|
||||
@ -189,6 +191,60 @@ class AgentService:
|
||||
loop = self.create_loop()
|
||||
return await loop.submit_direct(message, **kwargs)
|
||||
|
||||
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
|
||||
"""把 bus inbound 映射成标准 runtime 调用,并返回结构化 outbound。"""
|
||||
|
||||
try:
|
||||
result = await self.submit_direct(
|
||||
inbound.content,
|
||||
session_id=inbound.session_id,
|
||||
source=f"gateway:{inbound.channel}",
|
||||
user_id=inbound.user_id,
|
||||
title=inbound.title,
|
||||
execution_context=inbound.execution_context,
|
||||
model=inbound.model,
|
||||
provider_name=inbound.provider_name,
|
||||
embedding_model=inbound.embedding_model,
|
||||
)
|
||||
except Exception as exc:
|
||||
return self.build_outbound_error(inbound, detail=str(exc))
|
||||
return self.build_outbound_message(inbound, result)
|
||||
|
||||
@staticmethod
|
||||
def build_outbound_message(inbound: InboundMessage, result: AgentRunResult) -> OutboundMessage:
|
||||
"""把一次 runtime 正常结果转成 bus outbound。"""
|
||||
|
||||
return OutboundMessage(
|
||||
message_id=inbound.message_id,
|
||||
channel=inbound.channel,
|
||||
session_id=result.session_id,
|
||||
run_id=result.run_id,
|
||||
content=result.output_text,
|
||||
finish_reason=result.finish_reason,
|
||||
provider_name=result.provider_name,
|
||||
model=result.model,
|
||||
usage=dict(result.usage),
|
||||
metadata={"inbound_metadata": dict(inbound.metadata)},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_outbound_error(
|
||||
inbound: InboundMessage,
|
||||
*,
|
||||
detail: str,
|
||||
finish_reason: str = "error",
|
||||
) -> OutboundMessage:
|
||||
"""把 inbound 处理失败转换成结构化 outbound 错误消息。"""
|
||||
|
||||
return OutboundMessage(
|
||||
message_id=inbound.message_id,
|
||||
channel=inbound.channel,
|
||||
session_id=inbound.session_id,
|
||||
content=detail,
|
||||
finish_reason=finish_reason,
|
||||
metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)},
|
||||
)
|
||||
|
||||
def run_direct(
|
||||
self,
|
||||
message: str,
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
"""Skill system for Beaver."""
|
||||
"""Skill system for Beaver.
|
||||
|
||||
from .assembler import SkillAssembler, SkillAssemblyResult, SkillEmbeddingRetriever
|
||||
from .catalog import SkillRecord, SkillsLoader
|
||||
顶层包保持 lazy export,避免只导入 catalog/loader 时顺带拉起
|
||||
SkillAssembler -> provider -> litellm 这条重依赖链。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = [
|
||||
"SkillAssembler",
|
||||
@ -10,3 +15,22 @@ __all__ = [
|
||||
"SkillRecord",
|
||||
"SkillsLoader",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in {"SkillAssembler", "SkillAssemblyResult", "SkillEmbeddingRetriever"}:
|
||||
from .assembler import SkillAssembler, SkillAssemblyResult, SkillEmbeddingRetriever
|
||||
|
||||
return {
|
||||
"SkillAssembler": SkillAssembler,
|
||||
"SkillAssemblyResult": SkillAssemblyResult,
|
||||
"SkillEmbeddingRetriever": SkillEmbeddingRetriever,
|
||||
}[name]
|
||||
if name in {"SkillRecord", "SkillsLoader"}:
|
||||
from .catalog import SkillRecord, SkillsLoader
|
||||
|
||||
return {
|
||||
"SkillRecord": SkillRecord,
|
||||
"SkillsLoader": SkillsLoader,
|
||||
}[name]
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@ -1,188 +1,9 @@
|
||||
"""Embedding-based skill candidate retrieval.
|
||||
|
||||
当前实现使用 OpenAI-compatible `/v1/embeddings` 接口调用
|
||||
阿里云百炼 `text-embedding-v4` 做最小语义召回:
|
||||
1. 复用当前 provider 的 `api_key/api_base`
|
||||
2. 先用 embedding 相似度召回一小批候选
|
||||
3. 再交给上层 LLM selector 做最终技能选择
|
||||
"""
|
||||
"""Embedding-based skill candidate retrieval."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
import json
|
||||
from urllib import request
|
||||
from typing import Any
|
||||
from beaver.foundation.embedding import EmbeddingRetriever
|
||||
|
||||
|
||||
class SkillEmbeddingRetriever:
|
||||
class SkillEmbeddingRetriever(EmbeddingRetriever):
|
||||
"""用 OpenAI-compatible embeddings API 为 skill 选择做候选召回。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key_env: str = "OPENAI_API_KEY",
|
||||
api_base_env: str = "OPENAI_API_BASE",
|
||||
model: str = "text-embedding-v4",
|
||||
timeout_seconds: float = 20.0,
|
||||
) -> None:
|
||||
self.api_key_env = api_key_env
|
||||
self.api_base_env = api_base_env
|
||||
self.model = model
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
async def retrieve(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
candidates: list[dict[str, str]],
|
||||
top_k: int = 12,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""按 embedding 相似度召回 top-k 候选。
|
||||
|
||||
如果没有可用的 API Key / base URL,或者 embedding 调用失败,
|
||||
当前阶段先退回到“全部候选交给 LLM selector”。
|
||||
"""
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
resolved_api_key = api_key or os.getenv(self.api_key_env)
|
||||
resolved_api_base = api_base or os.getenv(self.api_base_env)
|
||||
if not resolved_api_key or not resolved_api_base:
|
||||
return candidates
|
||||
|
||||
try:
|
||||
query_embedding = await self._embed_texts(
|
||||
api_key=resolved_api_key,
|
||||
api_base=resolved_api_base,
|
||||
texts=[query],
|
||||
model=model or self.model,
|
||||
)
|
||||
candidate_texts = [self._candidate_text(item) for item in candidates]
|
||||
candidate_embeddings = await self._embed_texts(
|
||||
api_key=resolved_api_key,
|
||||
api_base=resolved_api_base,
|
||||
texts=candidate_texts,
|
||||
model=model or self.model,
|
||||
)
|
||||
except Exception:
|
||||
return candidates
|
||||
|
||||
if not query_embedding or not query_embedding[0] or len(candidate_embeddings) != len(candidates):
|
||||
return candidates
|
||||
|
||||
query_vector = query_embedding[0]
|
||||
scored: list[tuple[float, dict[str, str]]] = []
|
||||
for candidate, vector in zip(candidates, candidate_embeddings, strict=False):
|
||||
if not vector:
|
||||
continue
|
||||
scored.append((self._cosine_similarity(query_vector, vector), candidate))
|
||||
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
return [item[1] for item in scored[:top_k]]
|
||||
|
||||
async def _embed_texts(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
api_base: str,
|
||||
texts: list[str],
|
||||
model: str,
|
||||
) -> list[list[float]]:
|
||||
"""调用 OpenAI-compatible embeddings 接口。
|
||||
|
||||
当前对齐的是你们实际在用的网关配置:
|
||||
- `POST {api_base}/embeddings`
|
||||
- `model=text-embedding-v4`
|
||||
- `encoding_format=float`
|
||||
"""
|
||||
|
||||
all_vectors: list[list[float]] = []
|
||||
endpoint = self._normalize_embeddings_endpoint(api_base)
|
||||
for start in range(0, len(texts), 10):
|
||||
batch = texts[start:start + 10]
|
||||
payload = await self._post_embeddings(
|
||||
endpoint=endpoint,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
texts=batch,
|
||||
)
|
||||
embeddings = payload.get("data") or []
|
||||
embeddings = sorted(embeddings, key=lambda item: item.get("index", 0))
|
||||
all_vectors.extend([list(item.get("embedding") or []) for item in embeddings])
|
||||
return all_vectors
|
||||
|
||||
async def _post_embeddings(
|
||||
self,
|
||||
*,
|
||||
endpoint: str,
|
||||
api_key: str,
|
||||
model: str,
|
||||
texts: list[str],
|
||||
) -> dict[str, Any]:
|
||||
return await asyncio.to_thread(
|
||||
self._post_embeddings_sync,
|
||||
endpoint=endpoint,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
texts=texts,
|
||||
)
|
||||
|
||||
def _post_embeddings_sync(
|
||||
self,
|
||||
*,
|
||||
endpoint: str,
|
||||
api_key: str,
|
||||
model: str,
|
||||
texts: list[str],
|
||||
) -> dict[str, Any]:
|
||||
body = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"input": texts if len(texts) > 1 else texts[0],
|
||||
"encoding_format": "float",
|
||||
}
|
||||
).encode("utf-8")
|
||||
req = request.Request(
|
||||
endpoint,
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with request.urlopen(req, timeout=self.timeout_seconds) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
@staticmethod
|
||||
def _candidate_text(candidate: dict[str, str]) -> str:
|
||||
name = (candidate.get("name") or "").strip()
|
||||
description = (candidate.get("description") or "").strip()
|
||||
return f"{name}\n{description}".strip()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_embeddings_endpoint(api_base: str) -> str:
|
||||
base = api_base.rstrip("/")
|
||||
if base.endswith("/embeddings"):
|
||||
return base
|
||||
if base.endswith("/v1"):
|
||||
return f"{base}/embeddings"
|
||||
return f"{base}/v1/embeddings"
|
||||
|
||||
@staticmethod
|
||||
def _cosine_similarity(left: list[float], right: list[float]) -> float:
|
||||
if not left or not right or len(left) != len(right):
|
||||
return -1.0
|
||||
dot = sum(a * b for a, b in zip(left, right, strict=False))
|
||||
left_norm = math.sqrt(sum(a * a for a in left))
|
||||
right_norm = math.sqrt(sum(b * b for b in right))
|
||||
if left_norm == 0 or right_norm == 0:
|
||||
return -1.0
|
||||
return dot / (left_norm * right_norm)
|
||||
|
||||
@ -63,6 +63,11 @@ class SkillAssembler:
|
||||
api_key=embedding_runtime.api_key if embedding_runtime is not None else None,
|
||||
api_base=embedding_runtime.api_base if embedding_runtime is not None else None,
|
||||
model=embedding_runtime.model if embedding_runtime is not None else None,
|
||||
extra_headers=embedding_runtime.extra_headers if embedding_runtime is not None else None,
|
||||
timeout_seconds=(
|
||||
embedding_runtime.request_timeout_seconds if embedding_runtime is not None else None
|
||||
),
|
||||
fallback_top_k=None,
|
||||
)
|
||||
if not candidates:
|
||||
return SkillAssemblyResult()
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@ -111,6 +112,32 @@ class SkillsLoader:
|
||||
metadata, _ = parse_frontmatter(content)
|
||||
return metadata
|
||||
|
||||
def get_skill_tool_hints(self, name: str) -> list[str]:
|
||||
"""读取 skill 显式声明的推荐工具。
|
||||
|
||||
第一版只信任显式 metadata,不从正文里猜:
|
||||
- `tools: read_file, search_files`
|
||||
- `tools: ["read_file", "search_files"]`
|
||||
- YAML-like list:
|
||||
tools:
|
||||
- read_file
|
||||
- search_files
|
||||
- 兼容 metadata JSON blob 里的 `tools`
|
||||
"""
|
||||
|
||||
frontmatter = self.get_skill_metadata(name) or {}
|
||||
meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", ""))
|
||||
names = [
|
||||
*self._coerce_tool_names(frontmatter.get("tools")),
|
||||
*self._coerce_tool_names(meta_blob.get("tools")),
|
||||
*self._coerce_tool_names(meta_blob.get("required_tools")),
|
||||
]
|
||||
result: list[str] = []
|
||||
for item in names:
|
||||
if item and item not in result:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
||||
"""加载指定 skills 的正文,并整理成上下文块。"""
|
||||
|
||||
@ -253,6 +280,26 @@ class SkillsLoader:
|
||||
result.append(record.name)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _coerce_tool_names(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return []
|
||||
if raw.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
return [str(item).strip() for item in parsed if str(item).strip()]
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
return []
|
||||
|
||||
def _find_record(self, name: str) -> SkillRecord | None:
|
||||
for record in self.list_skills(filter_unavailable=False):
|
||||
if record.name == name:
|
||||
|
||||
@ -20,7 +20,7 @@ import shutil
|
||||
from typing import Any
|
||||
|
||||
|
||||
def parse_frontmatter(content: str) -> tuple[dict[str, str], str]:
|
||||
def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
|
||||
"""解析 Markdown 文件顶部的极简 frontmatter。
|
||||
|
||||
当前先只支持最常见的:
|
||||
@ -43,12 +43,36 @@ def parse_frontmatter(content: str) -> tuple[dict[str, str], str]:
|
||||
if match is None:
|
||||
return {}, content
|
||||
|
||||
metadata: dict[str, str] = {}
|
||||
for line in match.group(1).splitlines():
|
||||
metadata: dict[str, Any] = {}
|
||||
lines = match.group(1).splitlines()
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
line = lines[index]
|
||||
if ":" not in line:
|
||||
index += 1
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
metadata[key.strip()] = value.strip().strip('"\'')
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if not value:
|
||||
items: list[str] = []
|
||||
lookahead = index + 1
|
||||
while lookahead < len(lines):
|
||||
candidate = lines[lookahead]
|
||||
stripped = candidate.strip()
|
||||
if not stripped:
|
||||
lookahead += 1
|
||||
continue
|
||||
if not stripped.startswith("- "):
|
||||
break
|
||||
items.append(stripped[2:].strip().strip('"\''))
|
||||
lookahead += 1
|
||||
if items:
|
||||
metadata[key] = items
|
||||
index = lookahead
|
||||
continue
|
||||
metadata[key] = value.strip('"\'')
|
||||
index += 1
|
||||
body = content[match.end():].strip()
|
||||
return metadata, body
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""Tool system for Beaver."""
|
||||
|
||||
from .base import BaseTool, ObjectBackedTool, ToolContext, ToolResult, ToolSpec
|
||||
from .assembler import ToolAssembler
|
||||
from .registry import ToolRegistry
|
||||
from .runtime import ToolExecutor
|
||||
|
||||
@ -8,6 +9,7 @@ __all__ = [
|
||||
"BaseTool",
|
||||
"ObjectBackedTool",
|
||||
"ToolContext",
|
||||
"ToolAssembler",
|
||||
"ToolExecutor",
|
||||
"ToolRegistry",
|
||||
"ToolResult",
|
||||
|
||||
5
app-instance/backend/beaver/tools/assembler/__init__.py
Normal file
5
app-instance/backend/beaver/tools/assembler/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Tool selection for a single Beaver run."""
|
||||
|
||||
from .task_assembler import ToolAssembler
|
||||
|
||||
__all__ = ["ToolAssembler"]
|
||||
106
app-instance/backend/beaver/tools/assembler/task_assembler.py
Normal file
106
app-instance/backend/beaver/tools/assembler/task_assembler.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""Task-driven tool assembler.
|
||||
|
||||
这层和 SkillAssembler 的位置类似:它不执行工具,只决定本轮 run 应该把哪些
|
||||
tool schema 暴露给模型。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from beaver.engine.context import SkillContext
|
||||
from beaver.foundation.embedding import EmbeddingRetriever
|
||||
from beaver.tools.base import ToolSpec
|
||||
from beaver.tools.registry import ToolRegistry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beaver.engine.providers.runtime import ProviderRuntime
|
||||
from beaver.skills.catalog.loader import SkillsLoader
|
||||
|
||||
|
||||
class ToolAssembler:
|
||||
"""Use skill hints and embedding retrieval to select run-scoped tools."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
retriever: EmbeddingRetriever | None = None,
|
||||
always_tool_names: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
self.retriever = retriever or EmbeddingRetriever()
|
||||
self.always_tool_names = tuple(always_tool_names or ("memory", "session_search", "skill_view"))
|
||||
|
||||
async def assemble(
|
||||
self,
|
||||
*,
|
||||
task_description: str,
|
||||
registry: ToolRegistry,
|
||||
skills_loader: SkillsLoader | None = None,
|
||||
activated_skills: Sequence[SkillContext] | None = None,
|
||||
embedding_runtime: ProviderRuntime | None = None,
|
||||
top_k: int = 10,
|
||||
) -> list[ToolSpec]:
|
||||
"""Return selected tool specs for the current run.
|
||||
|
||||
Selection order is intentionally deterministic:
|
||||
1. always tools from config/spec
|
||||
2. tools explicitly declared by activated skills
|
||||
3. embedding top-k tools for the task
|
||||
"""
|
||||
|
||||
selected: list[ToolSpec] = []
|
||||
selected_names: set[str] = set()
|
||||
|
||||
def add_specs(specs: Sequence[ToolSpec]) -> None:
|
||||
for spec in specs:
|
||||
if spec.name in selected_names:
|
||||
continue
|
||||
selected.append(spec)
|
||||
selected_names.add(spec.name)
|
||||
|
||||
add_specs(registry.list_always_specs())
|
||||
add_specs(registry.get_specs(self.always_tool_names))
|
||||
|
||||
skill_tool_names = self._collect_skill_tool_names(
|
||||
skills_loader=skills_loader,
|
||||
activated_skills=activated_skills or (),
|
||||
)
|
||||
add_specs(registry.get_specs(skill_tool_names))
|
||||
|
||||
candidates = [
|
||||
spec.to_embedding_candidate()
|
||||
for spec in registry.list_specs()
|
||||
if spec.name not in selected_names
|
||||
]
|
||||
retrieved = await self.retriever.retrieve(
|
||||
query=task_description,
|
||||
candidates=candidates,
|
||||
top_k=top_k,
|
||||
api_key=embedding_runtime.api_key if embedding_runtime is not None else None,
|
||||
api_base=embedding_runtime.api_base if embedding_runtime is not None else None,
|
||||
model=embedding_runtime.model if embedding_runtime is not None else None,
|
||||
extra_headers=embedding_runtime.extra_headers if embedding_runtime is not None else None,
|
||||
timeout_seconds=(
|
||||
embedding_runtime.request_timeout_seconds if embedding_runtime is not None else None
|
||||
),
|
||||
fallback_top_k=top_k,
|
||||
)
|
||||
add_specs(registry.get_specs([item["name"] for item in retrieved]))
|
||||
return selected
|
||||
|
||||
@staticmethod
|
||||
def _collect_skill_tool_names(
|
||||
*,
|
||||
skills_loader: SkillsLoader | None,
|
||||
activated_skills: Sequence[SkillContext],
|
||||
) -> list[str]:
|
||||
if skills_loader is None or not activated_skills:
|
||||
return []
|
||||
|
||||
result: list[str] = []
|
||||
for skill in activated_skills:
|
||||
for name in skills_loader.get_skill_tool_hints(skill.name):
|
||||
if name not in result:
|
||||
result.append(name)
|
||||
return result
|
||||
@ -29,13 +29,30 @@ class ToolSpec:
|
||||
"""单个工具对外暴露的描述信息。
|
||||
|
||||
这份信息主要服务两个场景:
|
||||
1. 导出给 provider 的 function schema
|
||||
2. 在 registry 中做列出、查找、调试
|
||||
1. 以 MCP-style descriptor 作为统一事实来源
|
||||
2. 导出给 provider 的 function schema
|
||||
3. 在 registry 中做列出、查找、调试与 embedding 召回
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
input_schema: dict[str, Any]
|
||||
toolset: str = "core"
|
||||
always_available: bool = False
|
||||
|
||||
def to_mcp_descriptor(self) -> dict[str, Any]:
|
||||
"""导出 MCP ListTools 风格的工具描述。
|
||||
|
||||
MCP 的基础字段是 `name`、`description`、`inputSchema`。
|
||||
Beaver 内部额外的 toolset/always_available 不塞进这个对象,
|
||||
避免未来对接真实 MCP server 时出现格式偏差。
|
||||
"""
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"inputSchema": self.input_schema,
|
||||
}
|
||||
|
||||
def to_provider_schema(self) -> dict[str, Any]:
|
||||
"""导出为 OpenAI-compatible function tool schema。"""
|
||||
@ -49,6 +66,15 @@ class ToolSpec:
|
||||
},
|
||||
}
|
||||
|
||||
def to_embedding_candidate(self) -> dict[str, str]:
|
||||
"""导出给语义召回使用的轻量文本候选。"""
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"input_schema": json.dumps(self.input_schema, ensure_ascii=False, sort_keys=True),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolContext:
|
||||
@ -113,6 +139,8 @@ class ObjectBackedTool(BaseTool):
|
||||
name=str(getattr(backend, "name")),
|
||||
description=str(getattr(backend, "description", "")),
|
||||
input_schema=dict(getattr(backend, "parameters", {"type": "object", "properties": {}})),
|
||||
toolset=str(getattr(backend, "toolset", "core")),
|
||||
always_available=bool(getattr(backend, "always_available", False)),
|
||||
)
|
||||
|
||||
@property
|
||||
@ -150,6 +178,8 @@ class ObjectBackedTool(BaseTool):
|
||||
|
||||
if "current_session_id" not in arguments and hasattr(self.backend, "current_session_id"):
|
||||
arguments["current_session_id"] = context.session_id
|
||||
if "workspace" not in arguments and hasattr(self.backend, "workspace"):
|
||||
arguments["workspace"] = context.workspace
|
||||
|
||||
@staticmethod
|
||||
def _normalize_output(content: Any) -> dict[str, Any]:
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
"""Built-in Beaver tools."""
|
||||
|
||||
from .echo import EchoTool, echo_tool
|
||||
from .filesystem import ListDirectoryTool, ReadFileTool, SearchFilesTool
|
||||
from .memory import MemoryTool, memory_tool
|
||||
from .skill_view import SkillViewTool, skill_view
|
||||
from .session_search import SessionSearchTool, session_search
|
||||
|
||||
__all__ = [
|
||||
"EchoTool",
|
||||
"ListDirectoryTool",
|
||||
"MemoryTool",
|
||||
"ReadFileTool",
|
||||
"SearchFilesTool",
|
||||
"SkillViewTool",
|
||||
"SessionSearchTool",
|
||||
"echo_tool",
|
||||
|
||||
@ -34,6 +34,8 @@ class EchoTool:
|
||||
|
||||
name: str = "echo"
|
||||
description: str = ECHO_TOOL_DESCRIPTION
|
||||
toolset: str = "debug"
|
||||
always_available: bool = False
|
||||
parameters: dict[str, Any] = field(default_factory=lambda: dict(ECHO_TOOL_PARAMETERS))
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
|
||||
442
app-instance/backend/beaver/tools/builtins/filesystem.py
Normal file
442
app-instance/backend/beaver/tools/builtins/filesystem.py
Normal file
@ -0,0 +1,442 @@
|
||||
"""Workspace-scoped read-only filesystem tools.
|
||||
|
||||
这些工具是 Beaver 第一批真实本地工具,只做只读能力:
|
||||
- list_directory
|
||||
- read_file
|
||||
- search_files
|
||||
|
||||
安全边界先保持非常明确:所有用户传入路径都必须解析到当前
|
||||
`ToolContext.workspace` 内部。即使 workspace 里有指向外部的符号链接,
|
||||
读取时也会因为真实路径越界而被拒绝。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
MAX_LIST_ENTRIES = 1_000
|
||||
MAX_READ_LINES = 1_000
|
||||
MAX_READ_CHARS = 120_000
|
||||
MAX_SEARCH_RESULTS = 200
|
||||
MAX_SEARCH_FILE_BYTES = 2_000_000
|
||||
MAX_SEARCH_FILES = 5_000
|
||||
SKIP_DIR_NAMES = {
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
".venv",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
}
|
||||
|
||||
|
||||
LIST_DIRECTORY_PARAMETERS: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"default": ".",
|
||||
"description": "Directory path relative to the current workspace. Absolute paths are allowed only if they stay inside the workspace.",
|
||||
},
|
||||
"recursive": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Whether to recursively list child entries. Symlink directories are not followed.",
|
||||
},
|
||||
"max_entries": {
|
||||
"type": "integer",
|
||||
"default": 200,
|
||||
"minimum": 1,
|
||||
"maximum": MAX_LIST_ENTRIES,
|
||||
"description": "Maximum number of entries to return.",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
|
||||
READ_FILE_PARAMETERS: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path relative to the current workspace. Absolute paths are allowed only if they stay inside the workspace.",
|
||||
},
|
||||
"start_line": {
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
"description": "1-based line number to start reading from.",
|
||||
},
|
||||
"max_lines": {
|
||||
"type": "integer",
|
||||
"default": 200,
|
||||
"minimum": 1,
|
||||
"maximum": MAX_READ_LINES,
|
||||
"description": "Maximum number of lines to read.",
|
||||
},
|
||||
},
|
||||
"required": ["path"],
|
||||
}
|
||||
|
||||
SEARCH_FILES_PARAMETERS: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Plain text query to search in file paths and UTF-8 text files.",
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"default": ".",
|
||||
"description": "Directory or file path relative to the current workspace.",
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"default": 50,
|
||||
"minimum": 1,
|
||||
"maximum": MAX_SEARCH_RESULTS,
|
||||
"description": "Maximum number of matches to return.",
|
||||
},
|
||||
"case_sensitive": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Whether search should be case-sensitive.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
|
||||
class WorkspacePathError(ValueError):
|
||||
"""Raised when a requested path escapes the configured workspace."""
|
||||
|
||||
|
||||
def _json_result(success: bool, **payload: Any) -> str:
|
||||
return json.dumps({"success": success, **payload}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _clamp_int(value: Any, *, default: int, minimum: int, maximum: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
parsed = default
|
||||
return max(minimum, min(parsed, maximum))
|
||||
|
||||
|
||||
def _workspace_root(workspace: str | None) -> Path:
|
||||
if not workspace:
|
||||
raise WorkspacePathError("workspace is not configured for filesystem tools")
|
||||
root = Path(workspace).expanduser().resolve(strict=True)
|
||||
if not root.is_dir():
|
||||
raise WorkspacePathError(f"workspace is not a directory: {root}")
|
||||
return root
|
||||
|
||||
|
||||
def _resolve_existing_path(workspace: str | None, user_path: str | None) -> tuple[Path, Path]:
|
||||
"""Resolve a user path and ensure the real target stays inside workspace."""
|
||||
|
||||
root = _workspace_root(workspace)
|
||||
raw_path = Path(user_path or ".").expanduser()
|
||||
candidate = raw_path if raw_path.is_absolute() else root / raw_path
|
||||
resolved = candidate.resolve(strict=True)
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise WorkspacePathError(
|
||||
f"path escapes workspace: {user_path or '.'}"
|
||||
) from exc
|
||||
return root, resolved
|
||||
|
||||
|
||||
def _relative_path(root: Path, path: Path) -> str:
|
||||
try:
|
||||
return str(path.relative_to(root)) or "."
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
|
||||
def _entry_type(path: Path) -> str:
|
||||
if path.is_symlink():
|
||||
return "symlink"
|
||||
if path.is_dir():
|
||||
return "directory"
|
||||
if path.is_file():
|
||||
return "file"
|
||||
return "other"
|
||||
|
||||
|
||||
def _entry_payload(root: Path, path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
stat = path.lstat() if path.is_symlink() else path.stat()
|
||||
size = stat.st_size
|
||||
except OSError:
|
||||
size = None
|
||||
return {
|
||||
"name": path.name,
|
||||
"path": _relative_path(root, path),
|
||||
"type": _entry_type(path),
|
||||
"size": size,
|
||||
}
|
||||
|
||||
|
||||
def _iter_directory(root: Path, directory: Path, *, recursive: bool) -> Iterable[Path]:
|
||||
def sort_key(item: Path) -> tuple[bool, str]:
|
||||
is_real_directory = not item.is_symlink() and item.is_dir()
|
||||
return (not is_real_directory, item.name.lower())
|
||||
|
||||
entries = sorted(directory.iterdir(), key=sort_key)
|
||||
for entry in entries:
|
||||
yield entry
|
||||
if not recursive or entry.is_symlink() or not entry.is_dir():
|
||||
continue
|
||||
yield from _iter_directory(root, entry, recursive=True)
|
||||
|
||||
|
||||
def _looks_binary(path: Path) -> bool:
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
sample = handle.read(4096)
|
||||
except OSError:
|
||||
return True
|
||||
return b"\0" in sample
|
||||
|
||||
|
||||
def _read_text_file(path: Path) -> str:
|
||||
if _looks_binary(path):
|
||||
raise ValueError("binary files cannot be read by read_file/search_files")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _iter_search_files(root: Path, start: Path) -> Iterable[Path]:
|
||||
if start.is_file():
|
||||
yield start
|
||||
return
|
||||
|
||||
stack = [start]
|
||||
visited = 0
|
||||
while stack and visited < MAX_SEARCH_FILES:
|
||||
current = stack.pop()
|
||||
try:
|
||||
children = sorted(current.iterdir(), key=lambda item: item.name.lower())
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for child in children:
|
||||
if child.is_symlink():
|
||||
continue
|
||||
if child.is_dir():
|
||||
if child.name in SKIP_DIR_NAMES:
|
||||
continue
|
||||
stack.append(child)
|
||||
continue
|
||||
if child.is_file():
|
||||
visited += 1
|
||||
yield child
|
||||
if visited >= MAX_SEARCH_FILES:
|
||||
break
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ListDirectoryTool:
|
||||
"""List files and directories inside the current workspace."""
|
||||
|
||||
name: str = "list_directory"
|
||||
description: str = (
|
||||
"List files and directories inside the current workspace. "
|
||||
"Use this before reading files when you need to inspect project structure. "
|
||||
"This tool never follows paths outside the workspace."
|
||||
)
|
||||
toolset: str = "filesystem"
|
||||
always_available: bool = True
|
||||
workspace: str | None = None
|
||||
parameters: dict[str, Any] = field(default_factory=lambda: dict(LIST_DIRECTORY_PARAMETERS))
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
path: str = ".",
|
||||
recursive: bool = False,
|
||||
max_entries: int = 200,
|
||||
workspace: str | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
root, resolved = _resolve_existing_path(workspace, path)
|
||||
if not resolved.is_dir():
|
||||
return _json_result(False, error="not_a_directory", path=path)
|
||||
|
||||
limit = _clamp_int(max_entries, default=200, minimum=1, maximum=MAX_LIST_ENTRIES)
|
||||
entries: list[dict[str, Any]] = []
|
||||
truncated = False
|
||||
for entry in _iter_directory(root, resolved, recursive=bool(recursive)):
|
||||
entries.append(_entry_payload(root, entry))
|
||||
if len(entries) >= limit:
|
||||
truncated = True
|
||||
break
|
||||
|
||||
return _json_result(
|
||||
True,
|
||||
path=_relative_path(root, resolved),
|
||||
recursive=bool(recursive),
|
||||
entries=entries,
|
||||
truncated=truncated,
|
||||
)
|
||||
except (OSError, WorkspacePathError, ValueError) as exc:
|
||||
return _json_result(False, error=str(exc), path=path)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ReadFileTool:
|
||||
"""Read a UTF-8 text file inside the current workspace."""
|
||||
|
||||
name: str = "read_file"
|
||||
description: str = (
|
||||
"Read a UTF-8 text file inside the current workspace with line limits. "
|
||||
"Use this to inspect source code, docs, config, or logs. "
|
||||
"This tool rejects binary files and paths outside the workspace."
|
||||
)
|
||||
toolset: str = "filesystem"
|
||||
always_available: bool = True
|
||||
workspace: str | None = None
|
||||
parameters: dict[str, Any] = field(default_factory=lambda: dict(READ_FILE_PARAMETERS))
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
path: str,
|
||||
start_line: int = 1,
|
||||
max_lines: int = 200,
|
||||
workspace: str | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
root, resolved = _resolve_existing_path(workspace, path)
|
||||
if not resolved.is_file():
|
||||
return _json_result(False, error="not_a_file", path=path)
|
||||
|
||||
start = _clamp_int(start_line, default=1, minimum=1, maximum=10_000_000)
|
||||
limit = _clamp_int(max_lines, default=200, minimum=1, maximum=MAX_READ_LINES)
|
||||
content = _read_text_file(resolved)
|
||||
lines = content.splitlines()
|
||||
selected = lines[start - 1 : start - 1 + limit]
|
||||
selected_text = "\n".join(selected)
|
||||
char_truncated = False
|
||||
if len(selected_text) > MAX_READ_CHARS:
|
||||
selected_text = selected_text[:MAX_READ_CHARS]
|
||||
char_truncated = True
|
||||
|
||||
end_line = start + len(selected) - 1 if selected else start - 1
|
||||
return _json_result(
|
||||
True,
|
||||
path=_relative_path(root, resolved),
|
||||
start_line=start,
|
||||
end_line=end_line,
|
||||
total_lines=len(lines),
|
||||
truncated=end_line < len(lines) or char_truncated,
|
||||
content=selected_text,
|
||||
)
|
||||
except UnicodeDecodeError:
|
||||
return _json_result(False, error="file is not valid UTF-8 text", path=path)
|
||||
except (OSError, WorkspacePathError, ValueError) as exc:
|
||||
return _json_result(False, error=str(exc), path=path)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SearchFilesTool:
|
||||
"""Search filenames and UTF-8 text file contents inside the workspace."""
|
||||
|
||||
name: str = "search_files"
|
||||
description: str = (
|
||||
"Search file paths and UTF-8 text file contents inside the current workspace. "
|
||||
"Use this to find relevant source files, docs, config keys, or log lines. "
|
||||
"This tool skips large/binary files and never searches outside the workspace."
|
||||
)
|
||||
toolset: str = "filesystem"
|
||||
always_available: bool = True
|
||||
workspace: str | None = None
|
||||
parameters: dict[str, Any] = field(default_factory=lambda: dict(SEARCH_FILES_PARAMETERS))
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
path: str = ".",
|
||||
max_results: int = 50,
|
||||
case_sensitive: bool = False,
|
||||
workspace: str | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
if not isinstance(query, str) or not query.strip():
|
||||
return _json_result(False, error="query must be a non-empty string")
|
||||
root, resolved = _resolve_existing_path(workspace, path)
|
||||
if not resolved.is_dir() and not resolved.is_file():
|
||||
return _json_result(False, error="path must be a file or directory", path=path)
|
||||
|
||||
limit = _clamp_int(max_results, default=50, minimum=1, maximum=MAX_SEARCH_RESULTS)
|
||||
needle = query if case_sensitive else query.lower()
|
||||
results: list[dict[str, Any]] = []
|
||||
searched_files = 0
|
||||
skipped_files = 0
|
||||
|
||||
for file_path in _iter_search_files(root, resolved):
|
||||
relative = _relative_path(root, file_path)
|
||||
haystack_path = relative if case_sensitive else relative.lower()
|
||||
if needle in haystack_path:
|
||||
results.append(
|
||||
{
|
||||
"path": relative,
|
||||
"line": None,
|
||||
"match_type": "path",
|
||||
"preview": relative,
|
||||
}
|
||||
)
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
try:
|
||||
if file_path.stat().st_size > MAX_SEARCH_FILE_BYTES or _looks_binary(file_path):
|
||||
skipped_files += 1
|
||||
continue
|
||||
text = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
skipped_files += 1
|
||||
continue
|
||||
|
||||
searched_files += 1
|
||||
lines = text.splitlines()
|
||||
for index, line in enumerate(lines, start=1):
|
||||
haystack_line = line if case_sensitive else line.lower()
|
||||
if needle not in haystack_line:
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"path": relative,
|
||||
"line": index,
|
||||
"match_type": "content",
|
||||
"preview": line[:500],
|
||||
}
|
||||
)
|
||||
if len(results) >= limit:
|
||||
break
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return _json_result(
|
||||
True,
|
||||
query=query,
|
||||
path=_relative_path(root, resolved),
|
||||
results=results,
|
||||
truncated=len(results) >= limit,
|
||||
searched_files=searched_files,
|
||||
skipped_files=skipped_files,
|
||||
)
|
||||
except (OSError, WorkspacePathError, ValueError) as exc:
|
||||
return _json_result(False, error=str(exc), path=path)
|
||||
@ -123,6 +123,8 @@ class MemoryTool:
|
||||
store: MemoryStore
|
||||
name: str = "memory"
|
||||
description: str = MEMORY_TOOL_DESCRIPTION
|
||||
toolset: str = "memory"
|
||||
always_available: bool = True
|
||||
parameters: dict[str, Any] = field(default_factory=lambda: dict(MEMORY_TOOL_PARAMETERS))
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
|
||||
@ -406,6 +406,8 @@ class SessionSearchTool:
|
||||
summarizer: SessionSummarizer | None = None
|
||||
name: str = "session_search"
|
||||
description: str = SESSION_SEARCH_TOOL_DESCRIPTION
|
||||
toolset: str = "session"
|
||||
always_available: bool = True
|
||||
parameters: dict[str, Any] = field(default_factory=lambda: dict(SESSION_SEARCH_TOOL_PARAMETERS))
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
|
||||
@ -76,6 +76,8 @@ class SkillViewTool:
|
||||
loader: SkillsLoader
|
||||
name: str = "skill_view"
|
||||
description: str = SKILL_VIEW_TOOL_DESCRIPTION
|
||||
toolset: str = "skills"
|
||||
always_available: bool = True
|
||||
parameters: dict[str, Any] = field(default_factory=lambda: dict(SKILL_VIEW_TOOL_PARAMETERS))
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Iterable
|
||||
|
||||
from beaver.tools.base import BaseTool, ToolSpec
|
||||
@ -49,7 +50,30 @@ class ToolRegistry:
|
||||
def list_specs(self) -> list[ToolSpec]:
|
||||
return [tool.spec for tool in self._tools.values()]
|
||||
|
||||
def list_always_specs(self) -> list[ToolSpec]:
|
||||
"""列出每轮 run 都应该暴露给模型的基础工具。"""
|
||||
|
||||
return [spec for spec in self.list_specs() if spec.always_available]
|
||||
|
||||
def get_specs(self, names: Sequence[str]) -> list[ToolSpec]:
|
||||
"""按名称顺序返回已注册工具 spec,忽略未知工具。"""
|
||||
|
||||
specs: list[ToolSpec] = []
|
||||
seen: set[str] = set()
|
||||
for name in names:
|
||||
tool = self.get(name)
|
||||
if tool is None or name in seen:
|
||||
continue
|
||||
specs.append(tool.spec)
|
||||
seen.add(name)
|
||||
return specs
|
||||
|
||||
def export_provider_schemas(self) -> list[dict]:
|
||||
"""导出给 provider 的函数工具 schema 列表。"""
|
||||
|
||||
return [spec.to_provider_schema() for spec in self.list_specs()]
|
||||
|
||||
def export_selected_provider_schemas(self, specs: Sequence[ToolSpec]) -> list[dict]:
|
||||
"""导出一组已选择工具的 provider schema。"""
|
||||
|
||||
return [spec.to_provider_schema() for spec in specs]
|
||||
|
||||
@ -12,12 +12,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from beaver.engine.providers.base import ToolCallRequest
|
||||
from beaver.tools.base import ToolContext, ToolResult
|
||||
from beaver.tools.registry.tool_registry import ToolRegistry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beaver.engine.providers.base import ToolCallRequest
|
||||
|
||||
|
||||
class ToolExecutor:
|
||||
"""统一执行单个 tool call。"""
|
||||
@ -80,16 +82,17 @@ class ToolExecutor:
|
||||
|
||||
@staticmethod
|
||||
def _normalize_tool_call(tool_call: ToolCallRequest | dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
if isinstance(tool_call, ToolCallRequest):
|
||||
return tool_call.name, dict(tool_call.arguments)
|
||||
|
||||
function = tool_call.get("function")
|
||||
if isinstance(function, dict):
|
||||
name = function.get("name")
|
||||
arguments = function.get("arguments", {})
|
||||
if not isinstance(tool_call, dict):
|
||||
name = getattr(tool_call, "name", None)
|
||||
arguments = getattr(tool_call, "arguments", {})
|
||||
else:
|
||||
name = tool_call.get("name")
|
||||
arguments = tool_call.get("arguments", {})
|
||||
function = tool_call.get("function")
|
||||
if isinstance(function, dict):
|
||||
name = function.get("name")
|
||||
arguments = function.get("arguments", {})
|
||||
else:
|
||||
name = tool_call.get("name")
|
||||
arguments = tool_call.get("arguments", {})
|
||||
|
||||
if not name:
|
||||
raise ValueError("Tool call is missing a tool name")
|
||||
@ -104,8 +107,8 @@ class ToolExecutor:
|
||||
|
||||
@staticmethod
|
||||
def _extract_tool_name(tool_call: ToolCallRequest | dict[str, Any]) -> str:
|
||||
if isinstance(tool_call, ToolCallRequest):
|
||||
return str(tool_call.name or "unknown")
|
||||
if not isinstance(tool_call, dict):
|
||||
return str(getattr(tool_call, "name", None) or "unknown")
|
||||
function = tool_call.get("function")
|
||||
if isinstance(function, dict) and function.get("name"):
|
||||
return str(function["name"])
|
||||
|
||||
@ -73,27 +73,49 @@
|
||||
1. `4.1 session`
|
||||
2. `4.2 provider`
|
||||
3. `4.3 context`
|
||||
4. `4.4 tools`
|
||||
4. `4.4 tools framework + 最小内建工具`
|
||||
5. `4.5 最小主链`
|
||||
6. `5.1 memory 最小接入`
|
||||
7. `5.2 skills 最小接入`
|
||||
8. `6.1 session-first / event-source 第一阶段`
|
||||
9. `6.2 runtime lifecycle 最小骨架`
|
||||
10. `6.2.1 Web / Gateway 最小接主链`
|
||||
11. app-instance Docker 镜像切到新 `beaver` 后端
|
||||
|
||||
更准确地说,当前 Beaver 已经有:
|
||||
|
||||
1. 一个可运行的 `AgentService -> AgentLoop` 主链
|
||||
2. 一个外部化的 Session 子系统
|
||||
3. 一个可工作的 tool loop
|
||||
3. 一个可工作的 tool loop 框架
|
||||
4. Hermes 风格的 memory / skills 接入
|
||||
5. LLM-driven 的 `SkillAssembler`
|
||||
6. embedding-driven 的 `ToolAssembler`
|
||||
7. MCP-style 本地工具描述
|
||||
8. skill frontmatter `tools` 会影响本轮工具选择
|
||||
9. `start()/submit_direct()/stop()/shutdown()/close()` 最小 lifecycle
|
||||
10. FastAPI `/api/ping` + `/api/chat`
|
||||
11. Gateway `MessageBus -> AgentService -> MessageBus` 最小桥接
|
||||
12. Docker app-instance 使用 `/root/.beaver/config.json` 和 `/root/.beaver/workspace`
|
||||
|
||||
已经实测通过:
|
||||
|
||||
1. Docker image build
|
||||
2. container `/api/ping`
|
||||
3. `/api/chat` 调用 `qwen-plus`
|
||||
4. Session SQLite 事件写入
|
||||
5. 宿主机 `curl` 直连 app-instance
|
||||
|
||||
但还没有:
|
||||
|
||||
1. 更完整的 shutdown hooks
|
||||
2. Web / Gateway 的 bus / channels / realtime 全量接入
|
||||
3. delegation / swarm / team runtime
|
||||
4. 权限系统
|
||||
5. MCP 全量工具接回 runtime
|
||||
1. shell / web 等高风险或外部访问工具
|
||||
2. 完整 tool permission gates
|
||||
3. Web / Gateway 的 realtime streaming
|
||||
4. bus retry / routing / persistence
|
||||
5. delegation / swarm / team runtime
|
||||
6. MCP 全量工具接回 runtime
|
||||
7. checkpoint / rewind / fork / crash-resume
|
||||
8. skill selector 的 embedding / LLM 选择细节还没有写入 Session event stream
|
||||
9. 前端完整 auth / sessions / skills / files / ws 兼容新 Beaver API
|
||||
|
||||
---
|
||||
|
||||
@ -106,7 +128,7 @@ service = AgentService()
|
||||
await service.process_direct("你好")
|
||||
```
|
||||
|
||||
同时,第 6 阶段的最小运行循环已经有了:
|
||||
上面是 direct/debug path。宿主层进入运行模式后,正式入口是:
|
||||
|
||||
```python
|
||||
service = AgentService()
|
||||
@ -123,6 +145,36 @@ app = create_app() # FastAPI lifespan 内部托管 AgentService.start()/s
|
||||
await run_gateway() # Gateway 常驻进程托管 AgentService.start()/shutdown()
|
||||
```
|
||||
|
||||
模型与 provider 配置现在从 backend sandbox config 统一读取,而不是从前端或 channel
|
||||
请求里传密钥。Docker 单实例部署时,配置路径优先级是:
|
||||
|
||||
1. `BEAVER_CONFIG_PATH`
|
||||
2. `NANOBOT_CONFIG_PATH`
|
||||
3. `BEAVER_HOME/config.json`
|
||||
4. `NANOBOT_HOME/config.json`
|
||||
5. `<workspace>/.beaver/config.json`
|
||||
|
||||
当前 app-instance 会把每个用户实例自己的数据目录挂到 `/root/.beaver`,所以
|
||||
Beaver 会默认读取:
|
||||
|
||||
```text
|
||||
/root/.beaver/config.json
|
||||
```
|
||||
|
||||
这份配置跟随单个 sandbox 容器/数据卷,不放在前端,也不放在宿主机全局目录。
|
||||
Web / Gateway / Channel 只传 `message/session_id/user_id` 等业务输入。
|
||||
|
||||
app-instance 镜像当前也已经切到新 Beaver 后端:
|
||||
|
||||
```text
|
||||
entrypoint.sh
|
||||
├─ 启动 python -m uvicorn beaver.interfaces.web.app:create_app --factory
|
||||
├─ 使用 /root/.beaver/config.json
|
||||
└─ 使用 /root/.beaver/workspace
|
||||
```
|
||||
|
||||
旧的 `nanobot web`、`backend/nanobot`、`backend/bridge`、vendored `swarms` 不再进入新镜像。
|
||||
|
||||
这套 lifecycle 当前明确是:
|
||||
|
||||
1. `start()` 进入一个 `AgentLoop` 实例的运行模式
|
||||
@ -165,15 +217,27 @@ await run_gateway() # Gateway 常驻进程托管 AgentService.start()/shut
|
||||
- `run_gateway()` 启动时:
|
||||
- 如果 gateway 自己创建 service,则 `await service.start()`
|
||||
- 持有最小 `MessageBus`
|
||||
- 常驻消费 `bus.inbound`
|
||||
- 调 `await service.submit_direct(...)`
|
||||
- 把结果写回 `bus.outbound`
|
||||
- 可选接收 `ChannelManager` / channel adapters
|
||||
- `ChannelManager` 和 `channels` 参数二选一:
|
||||
- 传 `ChannelManager`:外部提前配置好 channel
|
||||
- 传 `channels`:gateway 内部创建 `ChannelManager` 并注册这些 channel
|
||||
- inbound 流向:
|
||||
- channel adapter 发布 `InboundMessage`
|
||||
- `MessageBus.inbound`
|
||||
- gateway bridge 常驻消费
|
||||
- `await service.handle_inbound_message(...)`
|
||||
- outbound 流向:
|
||||
- `AgentService` 内部完成 `InboundMessage -> OutboundMessage` 映射
|
||||
- gateway bridge 写回 `MessageBus.outbound`
|
||||
- 如果启用了 `ChannelManager`,则分发给对应 channel adapter
|
||||
- 未启用 `ChannelManager` 时,保留直接消费 `bus.outbound` 的最小测试能力
|
||||
- 同时等待 `stop_event`
|
||||
- 退出时:
|
||||
- 先尝试 `await service.shutdown(timeout_seconds=5.0, force=True)`
|
||||
- 再等待 bridge 协程收尾;必要时取消 bridge
|
||||
- 如果 gateway 自己接管 lifecycle 且 `start()` 失败:
|
||||
- 会立即 `close()` 做 startup cleanup
|
||||
- 再等待 outbound dispatch 协程收尾;必要时取消 dispatch
|
||||
- 如果 gateway 自己接管 lifecycle 且 `start()` 失败:
|
||||
- 会立即 `close()` 做 startup cleanup
|
||||
- 未处理完的 inbound:
|
||||
- 不再静默丢下
|
||||
- 会被冲刷成结构化 outbound error
|
||||
@ -188,6 +252,16 @@ await run_gateway() # Gateway 常驻进程托管 AgentService.start()/shut
|
||||
- `outbound`
|
||||
- 还没有 broker / topic routing / retry / persistence
|
||||
|
||||
4. `beaver/interfaces/channels/*`
|
||||
- 已有最小 channel adapter 层:
|
||||
- `ChannelAdapter`
|
||||
- `ChannelManager`
|
||||
- `MemoryChannelAdapter`
|
||||
- 当前 channel 职责很窄:
|
||||
- 把外部输入发布成 `InboundMessage`
|
||||
- 接收并投递 `OutboundMessage`
|
||||
- `MemoryChannelAdapter` 只用于本地测试和内嵌接入,不是正式消息 broker
|
||||
|
||||
所以现在已经明确:
|
||||
|
||||
1. Web / Gateway 属于宿主层
|
||||
@ -202,9 +276,11 @@ await run_gateway() # Gateway 常驻进程托管 AgentService.start()/shut
|
||||
- 外部注入的 `AgentService`:默认不自动 start/shutdown,除非显式要求接管
|
||||
5. gateway 已经从“只会常驻等待”推进到“最小消息桥接层”
|
||||
- external inbound message
|
||||
- channel adapter
|
||||
- `MessageBus.inbound`
|
||||
- `service.submit_direct(...)`
|
||||
- `service.handle_inbound_message(...)`
|
||||
- `MessageBus.outbound`
|
||||
- channel adapter outbound delivery
|
||||
|
||||
### 3.2 总体链路
|
||||
|
||||
@ -216,6 +292,7 @@ AgentService
|
||||
-> Session
|
||||
-> Memory
|
||||
-> SkillAssembler
|
||||
-> ToolAssembler
|
||||
-> ContextBuilder
|
||||
-> Provider
|
||||
-> ToolExecutor
|
||||
@ -237,6 +314,7 @@ AgentService
|
||||
│ ├─ MemoryStore
|
||||
│ ├─ MemoryService
|
||||
│ ├─ ToolRegistry
|
||||
│ ├─ ToolAssembler
|
||||
│ ├─ ToolExecutor
|
||||
│ ├─ SkillsLoader
|
||||
│ ├─ SkillAssembler
|
||||
@ -269,6 +347,18 @@ AgentService
|
||||
│ ├─ 如果 activated_skills 非空:
|
||||
│ │ └─ sessions.append_message(event_type="skill_activation_snapshotted", hidden)
|
||||
│ │
|
||||
│ ├─ tool_assembler.assemble(task_description=task, activated_skills=..., ...)
|
||||
│ │ ├─ always tools
|
||||
│ │ │ ├─ memory
|
||||
│ │ │ ├─ session_search
|
||||
│ │ │ └─ skill_view
|
||||
│ │ ├─ 读取 activated skill 的 frontmatter `tools`
|
||||
│ │ ├─ 用 `text-embedding-v4` 对工具描述做相似度召回
|
||||
│ │ ├─ 返回本轮选中的 ToolSpec
|
||||
│ │ └─ ToolSpec 同时可导出 MCP descriptor 与 provider schema
|
||||
│ │
|
||||
│ ├─ sessions.append_message(event_type="tool_selection_snapshotted", hidden)
|
||||
│ │
|
||||
│ ├─ ContextBuilder.build_messages()
|
||||
│ │ ├─ system prompt 包含:
|
||||
│ │ │ ├─ base system prompt
|
||||
@ -328,10 +418,11 @@ AgentService
|
||||
2. `MemoryStore`
|
||||
3. `MemoryService`
|
||||
4. `ToolRegistry`
|
||||
5. `ToolExecutor`
|
||||
6. `SkillsLoader`
|
||||
7. `SkillAssembler`
|
||||
8. `ContextBuilder`
|
||||
5. `ToolAssembler`
|
||||
6. `ToolExecutor`
|
||||
7. `SkillsLoader`
|
||||
8. `SkillAssembler`
|
||||
9. `ContextBuilder`
|
||||
|
||||
### 4.2 `AgentLoop`
|
||||
|
||||
@ -349,7 +440,7 @@ AgentService
|
||||
|
||||
1. 更复杂的 message bus mode
|
||||
2. 多 worker / 并发调度
|
||||
3. 更完整的 runtime lifecycle
|
||||
3. provider/client 级 async shutdown hooks
|
||||
4. multi-agent orchestration
|
||||
|
||||
### 4.3 `Session`
|
||||
@ -383,9 +474,10 @@ AgentService
|
||||
|
||||
1. `run_started`
|
||||
2. `skill_activation_snapshotted`
|
||||
3. `system_prompt_snapshotted`
|
||||
4. `run_completed`
|
||||
5. `run_failed`
|
||||
3. `tool_selection_snapshotted`
|
||||
4. `system_prompt_snapshotted`
|
||||
5. `run_completed`
|
||||
6. `run_failed`
|
||||
|
||||
### 4.4 `Memory`
|
||||
|
||||
@ -438,13 +530,57 @@ AgentService
|
||||
2. `memory`
|
||||
3. `skill_view`
|
||||
4. `session_search`
|
||||
5. `list_directory`
|
||||
6. `read_file`
|
||||
7. `search_files`
|
||||
|
||||
当前工具基础设施:
|
||||
|
||||
1. `ToolSpec`
|
||||
- 以 MCP-style descriptor 作为本地统一描述
|
||||
- 可导出 `to_mcp_descriptor()`
|
||||
- 可导出 OpenAI-compatible `to_provider_schema()`
|
||||
2. `ObjectBackedTool`
|
||||
3. `ToolRegistry`
|
||||
4. `ToolExecutor`
|
||||
5. `ToolAssembler`
|
||||
|
||||
当前工具选择语义:
|
||||
|
||||
1. 工具选择是 **run-scoped**
|
||||
2. `memory` / `session_search` / `skill_view` / 只读 filesystem tools 是 always tools
|
||||
3. activated skill 的 frontmatter 可声明:
|
||||
|
||||
```yaml
|
||||
---
|
||||
tools:
|
||||
- terminal
|
||||
- read_file
|
||||
---
|
||||
```
|
||||
|
||||
4. `ToolAssembler` 会合并:
|
||||
- always tools
|
||||
- activated skill 显式声明的 tools
|
||||
- task description embedding top10 tools
|
||||
5. 当前只信任 frontmatter / metadata 里的显式 tools,不从 skill 正文里猜工具名
|
||||
6. 如果 skill 声明了未注册工具,当前会忽略,不阻断 run
|
||||
|
||||
当前 filesystem tools 的边界:
|
||||
|
||||
1. `list_directory` 只能列当前 `ToolContext.workspace` 内的目录
|
||||
2. `read_file` 只能读 workspace 内 UTF-8 文本文件
|
||||
3. `search_files` 只能搜索 workspace 内文件名和 UTF-8 文本内容
|
||||
4. 绝对路径如果解析后不在 workspace 内,会拒绝
|
||||
5. workspace 内指向外部的符号链接,读取 / 搜索时会拒绝
|
||||
6. 二进制文件会拒绝读取,并在搜索时跳过
|
||||
|
||||
当前还没有默认注册:
|
||||
|
||||
1. shell / exec tools
|
||||
2. web search / web fetch tools
|
||||
3. MCP tools
|
||||
4. spawn / team tools
|
||||
|
||||
### 4.7 `Providers`
|
||||
|
||||
@ -454,12 +590,16 @@ AgentService
|
||||
2. runtime resolution
|
||||
3. main provider
|
||||
4. fallback provider
|
||||
5. auxiliary provider
|
||||
6. embedding runtime 配置线
|
||||
|
||||
当前状态:
|
||||
|
||||
1. fallback 已经是“每次调用都先 main,再 fallback”
|
||||
2. auxiliary provider 已经可用于 skill 选择
|
||||
3. auxiliary provider 还没有进入主对话 tool loop
|
||||
3. embedding runtime 当前用于 SkillAssembler 的候选召回
|
||||
4. embedding runtime 当前也用于 ToolAssembler 的工具召回
|
||||
5. auxiliary provider 还没有进入主对话 tool loop
|
||||
|
||||
---
|
||||
|
||||
@ -507,26 +647,81 @@ task description
|
||||
1. activated skill messages
|
||||
2. `skill_view`
|
||||
|
||||
### 5.5 `Tools` 采用 MCP-style 描述
|
||||
|
||||
当前本地工具不再只是一段 OpenAI function schema,而是先收敛成:
|
||||
|
||||
```text
|
||||
ToolSpec
|
||||
├─ name
|
||||
├─ description
|
||||
├─ input_schema
|
||||
├─ toolset
|
||||
└─ always_available
|
||||
```
|
||||
|
||||
其中 `name/description/input_schema` 可直接导出 MCP-style descriptor:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "memory",
|
||||
"description": "...",
|
||||
"inputSchema": {}
|
||||
}
|
||||
```
|
||||
|
||||
provider 需要的 OpenAI-compatible schema 由 `ToolSpec.to_provider_schema()` 转换出来。
|
||||
|
||||
---
|
||||
|
||||
## 6. 当前还没完成什么
|
||||
## 6. 对照施工指南,我们现在处于哪一步
|
||||
|
||||
这部分是接下来继续施工的重点。
|
||||
这部分严格对齐 `施工指南.md` 的第 6 阶段编号,不再自行改号。
|
||||
|
||||
### 6.1 运行时生命周期
|
||||
### 6.1 第一步:Session 升级为事件源模型
|
||||
|
||||
已做第一步:
|
||||
当前状态:**基本完成第一阶段目标,但还不是完整 event-source 系统。**
|
||||
|
||||
已经具备:
|
||||
|
||||
1. `messages` 表已经承担主事件流语义
|
||||
2. 每次 run 都有独立 `run_id`
|
||||
3. `AgentLoop.process_direct()` 已按事件阶段写回 Session
|
||||
4. 已有:
|
||||
- `get_event_records(session_id)`
|
||||
- `get_run_event_records(session_id, run_id)`
|
||||
- `list_run_ids(session_id)`
|
||||
- `get_visible_history(session_id)`
|
||||
5. `session_search` 只检索可见 transcript,不把 hidden snapshots 当搜索候选
|
||||
|
||||
当前还没做:
|
||||
|
||||
1. `checkpoint`
|
||||
2. `rewind`
|
||||
3. `fork session`
|
||||
4. `crash-resume protocol`
|
||||
|
||||
所以更准确地说:
|
||||
|
||||
1. `6.1` 的“Session-first / event-source 第一阶段”已经落地
|
||||
2. 但更完整的 event-source 能力还没有做完
|
||||
|
||||
### 6.2 第二步:runtime 生命周期协议补齐
|
||||
|
||||
当前状态:**最小 lifecycle 骨架已经完成。**
|
||||
|
||||
已完成:
|
||||
|
||||
1. `EngineLoadResult.close()`
|
||||
2. `AgentLoop.close()`
|
||||
3. `AgentService.close()`
|
||||
4. `AgentService.shutdown()`
|
||||
|
||||
已做第二步的最小版本:
|
||||
|
||||
1. `AgentLoop.run()`
|
||||
2. `AgentLoop.stop()`
|
||||
3. `AgentLoop.submit_direct()`
|
||||
5. `AgentLoop.run()`
|
||||
6. `AgentLoop.stop()`
|
||||
7. `AgentLoop.submit_direct()`
|
||||
8. `AgentService.start()`
|
||||
9. `AgentService.stop()`
|
||||
10. `AgentService.submit_direct()`
|
||||
|
||||
还没做:
|
||||
|
||||
@ -534,66 +729,160 @@ task description
|
||||
2. 更完整的 provider/client 资源释放协议
|
||||
3. 多 worker / bus / 调度策略
|
||||
|
||||
### 6.2 Web / Gateway 接主链
|
||||
### 6.2.1 Web / Gateway 现在如何接这套 lifecycle
|
||||
|
||||
现在主链已经能跑,但还没正式变成:
|
||||
当前状态:**最小宿主层接入已经完成。**
|
||||
|
||||
1. Web 真正调用 `AgentService.process_direct()`
|
||||
2. Gateway 真正调用 `AgentService.process_direct()`
|
||||
已经完成:
|
||||
|
||||
### 6.3 Session 更完整的 event-source 能力
|
||||
1. Web 通过 FastAPI lifespan 托管 `AgentService.start()/shutdown()`
|
||||
2. Web 请求只走 `AgentService.submit_direct()`
|
||||
3. Gateway 已有最小 `MessageBus -> AgentService.handle_inbound_message() -> MessageBus` 桥接
|
||||
4. Gateway 已支持可选 `ChannelManager`,把 outbound 分发回 channel adapter
|
||||
|
||||
还没做:
|
||||
当前 app-instance Docker 已完成:
|
||||
|
||||
1. checkpoint
|
||||
2. rewind
|
||||
3. fork session
|
||||
4. crash-resume protocol
|
||||
1. Dockerfile 只安装 `backend/beaver`
|
||||
2. entrypoint 启动 `beaver.interfaces.web.app:create_app`
|
||||
3. 每个实例挂载 `/root/.beaver`
|
||||
4. 配置读取 `/root/.beaver/config.json`
|
||||
5. workspace 使用 `/root/.beaver/workspace`
|
||||
6. 宿主 `curl /api/chat` 已实测通过
|
||||
|
||||
### 6.4 Multi-agent / swarms
|
||||
这一小步还没做:
|
||||
|
||||
还没正式接回主链:
|
||||
1. realtime streaming
|
||||
2. retry / broker persistence
|
||||
3. 外部真实 channel adapter 全量接入
|
||||
|
||||
1. delegation
|
||||
2. team runtime
|
||||
3. swarms orchestration backend
|
||||
### 6.3 第三步:回填 bus 模式
|
||||
|
||||
但 lifecycle 关系已经先定下来了:
|
||||
当前状态:**只完成了前置地基,还没有按施工指南真正收口。**
|
||||
|
||||
已经具备的前置件:
|
||||
|
||||
1. `MessageBus`
|
||||
2. `InboundMessage`
|
||||
3. `OutboundMessage`
|
||||
4. `AgentService.handle_inbound_message()`
|
||||
5. Gateway bridge 常驻消费 inbound 并写回 outbound
|
||||
6. `AgentLoop.run()` 已有最小运行循环
|
||||
|
||||
但严格按 `施工指南.md` 来看,`6.3` 还没有正式完成,因为现在还缺:
|
||||
|
||||
1. 把 bus mode 明确成 runtime 的正式运行形态之一
|
||||
2. 明确 `run()` 如何稳定消费 inbound message
|
||||
3. 明确 bus mode 与 direct mode / queue mode 的职责边界
|
||||
4. 明确停机、取消、冲刷 pending inbound 时的统一语义
|
||||
5. 再决定后续是否需要更复杂的 worker / retry / routing
|
||||
|
||||
也就是说:
|
||||
|
||||
1. 现在不是“还没 bus”
|
||||
2. 而是“已经把 bus 协议映射收口到 `AgentService`,但还没按施工指南把它扩成完整 bus runtime 模式”
|
||||
|
||||
### 6.4 单 agent lifecycle 如何扩展到 team
|
||||
|
||||
当前状态:**关系已经定死,但实现还没开始。**
|
||||
|
||||
当前已经明确:
|
||||
|
||||
1. team 不会共享一个大 `AgentLoop` 跑所有成员
|
||||
2. 每个 team member 都应有自己独立的 `AgentService / AgentLoop`
|
||||
3. team coordinator 在上层调度多个 member 实例
|
||||
4. 因此当前这套 `start()/submit_direct()/stop()/close()` 首先是 member-level lifecycle
|
||||
|
||||
当前还没开始的部分:
|
||||
|
||||
1. delegation
|
||||
2. team runtime
|
||||
3. swarms backend
|
||||
3. swarms orchestration backend
|
||||
4. group discussion / workflow orchestration
|
||||
|
||||
### 6.5 权限与治理
|
||||
|
||||
还没做:
|
||||
|
||||
1. permission gates
|
||||
2. tool policy
|
||||
3. MCP 工具治理
|
||||
|
||||
---
|
||||
|
||||
## 7. 下一步从哪开始最合理
|
||||
## 7. 对照 `change.md`,哪些长期目标还没开始
|
||||
|
||||
如果现在继续施工,最合理的顺序是:
|
||||
`change.md` 讲的是总蓝图,不是当前施工编号。下面这些仍然是长期目标,还没有正式进入当前阶段实现:
|
||||
|
||||
1. 先把 `flow.md` 作为当前基线固定下来
|
||||
2. 再继续第 6 阶段:
|
||||
- runtime lifecycle
|
||||
- `boot / close / run / stop`
|
||||
3. 然后再接:
|
||||
- Web / Gateway
|
||||
4. 最后才是:
|
||||
- multi-agent / swarms
|
||||
1. skills 生命周期系统
|
||||
- `SkillDraft`
|
||||
- `SkillVersion`
|
||||
- review / publish / rollback
|
||||
2. Hermes-style learning loop
|
||||
- 智能体定期整理 / 提示记忆
|
||||
- 复杂任务完成后可自主创建技能
|
||||
- 技能在使用过程中自我提升
|
||||
- FTS5 + LLM 摘要的跨会话回忆增强
|
||||
- Honcho 风格辩证用户建模
|
||||
3. swarms 作为正式 backend 接回平台
|
||||
4. delegation / subagent / team orchestration
|
||||
|
||||
当前只完成了这些基础入口:
|
||||
|
||||
1. curated memory CRUD
|
||||
2. session_search
|
||||
3. skill loader / skill_view
|
||||
4. skill assembler
|
||||
5. tool assembler
|
||||
|
||||
### 7.1 权限与治理
|
||||
|
||||
还没做:
|
||||
|
||||
1. 完整 permission gates
|
||||
2. tool policy
|
||||
3. MCP 工具治理
|
||||
|
||||
已完成的最小边界:
|
||||
|
||||
1. 只读 filesystem tools 强制限制在 `ToolContext.workspace`
|
||||
2. 路径解析使用真实路径,防止相对路径、绝对路径、符号链接逃逸
|
||||
3. 当前还没有 shell / write / network 工具,因此还没进入高风险授权阶段
|
||||
|
||||
### 7.2 前端兼容
|
||||
|
||||
当前只做了最小 chat response 兼容:
|
||||
|
||||
1. 前端 `sendMessage()` 已兼容 Beaver 的 `output_text`
|
||||
|
||||
还没做:
|
||||
|
||||
1. `/api/auth/*`
|
||||
2. `/api/sessions`
|
||||
3. `/api/status` 完整页面数据
|
||||
4. `/api/skills`
|
||||
5. `/api/files`
|
||||
6. `/ws`
|
||||
7. 浏览器端免登录或新 auth 接入策略
|
||||
|
||||
---
|
||||
|
||||
## 8. 下一步从哪开始最合理
|
||||
|
||||
如果严格按 `施工指南.md` 的施工顺序继续,下一步应是:
|
||||
|
||||
1. 完成 `6.3 回填 bus 模式`
|
||||
- 明确 bus mode 的正式运行语义
|
||||
- 让 `AgentLoop.run()` 与 `MessageBus` 的关系稳定收口
|
||||
- 把 inbound / outbound 结果结构定稳
|
||||
2. 然后再进入 `6.4`
|
||||
- 先把 team lifecycle 关系写成更可实现的 coordinator 约束
|
||||
3. 再进入第 7 阶段
|
||||
- delegation
|
||||
- local subagent
|
||||
4. 再进入第 8 阶段
|
||||
- team / swarms backend
|
||||
|
||||
如果按 `change.md` 的长期方向看,后面还要补:
|
||||
|
||||
1. skills 生命周期
|
||||
2. Hermes-style learning loop
|
||||
3. 更完整的 memory / governance / frontend
|
||||
|
||||
一句话总结:
|
||||
|
||||
**当前 Beaver 已经有一个可运行的单 agent runtime;接下来不是继续堆局部能力,而是把它升级成有完整生命周期的标准 harness。**
|
||||
**当前 Beaver 已经完成到“单 agent runtime + memory/skills + lifecycle + Web/Gateway 最小接入”,按施工指南的编号,下一步应是 `6.3 回填 bus 模式`。**
|
||||
|
||||
---
|
||||
|
||||
|
||||
107
app-instance/backend/tests/unit/test_config_loader.py
Normal file
107
app-instance/backend/tests/unit/test_config_loader.py
Normal file
@ -0,0 +1,107 @@
|
||||
import json
|
||||
|
||||
from beaver.engine import AgentLoop, EngineLoader
|
||||
from beaver.engine.providers import make_provider_bundle
|
||||
from beaver.engine.providers.litellm import LiteLLMProvider
|
||||
from beaver.foundation.config import load_config
|
||||
|
||||
|
||||
def test_load_config_reads_current_instance_shape(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": str(tmp_path / "workspace"),
|
||||
"model": "qwen-plus",
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"apiKey": "sk-test",
|
||||
"apiBase": "https://oai.example.com/v1",
|
||||
"extraHeaders": {"X-Test": "1"},
|
||||
}
|
||||
},
|
||||
"embeddingModel": "text-embedding-v4",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_config(config_path=config_path)
|
||||
target = config.resolve_provider_target()
|
||||
|
||||
assert config.default_model == "qwen-plus"
|
||||
assert config.default_embedding_model == "text-embedding-v4"
|
||||
assert target["provider_name"] == "openai"
|
||||
assert target["model"] == "qwen-plus"
|
||||
assert target["api_key"] == "sk-test"
|
||||
assert target["api_base"] == "https://oai.example.com/v1"
|
||||
assert target["extra_headers"] == {"X-Test": "1"}
|
||||
|
||||
|
||||
def test_engine_loader_uses_config_workspace(tmp_path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": str(workspace),
|
||||
"model": "qwen-plus",
|
||||
}
|
||||
},
|
||||
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://oai.example.com/v1"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loader = EngineLoader(config_path=config_path)
|
||||
assert loader.workspace == workspace
|
||||
|
||||
|
||||
def test_agent_loop_config_drives_provider_bundle(tmp_path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": str(workspace),
|
||||
"model": "qwen-plus",
|
||||
}
|
||||
},
|
||||
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://oai.example.com/v1"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loop = AgentLoop(loader=EngineLoader(config_path=config_path))
|
||||
loaded = loop.boot()
|
||||
target = loaded.config.resolve_provider_target()
|
||||
|
||||
assert target["provider_name"] == "openai"
|
||||
assert target["model"] == "qwen-plus"
|
||||
assert target["api_key"] == "sk-test"
|
||||
assert target["api_base"] == "https://oai.example.com/v1"
|
||||
loop.close()
|
||||
|
||||
|
||||
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
|
||||
bundle = make_provider_bundle(
|
||||
model="qwen-plus",
|
||||
provider_name="openai",
|
||||
api_key="sk-test",
|
||||
api_base="https://oai.example.com/v1",
|
||||
)
|
||||
|
||||
assert bundle.main_runtime.provider_name == "openai"
|
||||
assert bundle.main_runtime.api_base == "https://oai.example.com/v1"
|
||||
assert isinstance(bundle.main_provider, LiteLLMProvider)
|
||||
assert bundle.main_provider._resolve_model("qwen-plus") == "openai/qwen-plus"
|
||||
129
app-instance/backend/tests/unit/test_filesystem_tools.py
Normal file
129
app-instance/backend/tests/unit/test_filesystem_tools.py
Normal file
@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from beaver.tools import ObjectBackedTool, ToolContext
|
||||
from beaver.tools.builtins import ListDirectoryTool, ReadFileTool, SearchFilesTool
|
||||
|
||||
|
||||
def _run_tool(tool, arguments: dict, workspace: Path):
|
||||
return asyncio.run(
|
||||
ObjectBackedTool(tool).invoke(arguments, ToolContext(workspace=str(workspace)))
|
||||
)
|
||||
|
||||
|
||||
def _payload(result):
|
||||
return json.loads(result.content)
|
||||
|
||||
|
||||
def test_list_directory_is_workspace_scoped(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "README.md").write_text("# Hello\n", encoding="utf-8")
|
||||
(workspace / "src").mkdir()
|
||||
|
||||
result = _run_tool(ListDirectoryTool(), {"path": "."}, workspace)
|
||||
payload = _payload(result)
|
||||
|
||||
assert result.success is True
|
||||
assert payload["success"] is True
|
||||
assert [entry["path"] for entry in payload["entries"]] == ["src", "README.md"]
|
||||
|
||||
|
||||
def test_read_file_returns_limited_text(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "notes.txt").write_text("one\ntwo\nthree\n", encoding="utf-8")
|
||||
|
||||
result = _run_tool(ReadFileTool(), {"path": "notes.txt", "start_line": 2, "max_lines": 1}, workspace)
|
||||
payload = _payload(result)
|
||||
|
||||
assert result.success is True
|
||||
assert payload["success"] is True
|
||||
assert payload["content"] == "two"
|
||||
assert payload["start_line"] == 2
|
||||
assert payload["end_line"] == 2
|
||||
assert payload["truncated"] is True
|
||||
|
||||
|
||||
def test_search_files_finds_paths_and_content(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "Dockerfile").write_text("FROM python:3.12\n", encoding="utf-8")
|
||||
(workspace / "src").mkdir()
|
||||
(workspace / "src" / "app.py").write_text("print('docker log')\n", encoding="utf-8")
|
||||
|
||||
result = _run_tool(SearchFilesTool(), {"query": "docker", "max_results": 10}, workspace)
|
||||
payload = _payload(result)
|
||||
|
||||
assert result.success is True
|
||||
assert payload["success"] is True
|
||||
assert ("Dockerfile", "path") in {
|
||||
(item["path"], item["match_type"]) for item in payload["results"]
|
||||
}
|
||||
assert ("src/app.py", "content") in {
|
||||
(item["path"], item["match_type"]) for item in payload["results"]
|
||||
}
|
||||
|
||||
|
||||
def test_read_file_rejects_relative_path_escape(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(tmp_path / "secret.txt").write_text("secret\n", encoding="utf-8")
|
||||
|
||||
result = _run_tool(ReadFileTool(), {"path": "../secret.txt"}, workspace)
|
||||
payload = _payload(result)
|
||||
|
||||
assert result.success is False
|
||||
assert payload["success"] is False
|
||||
assert "escapes workspace" in payload["error"]
|
||||
|
||||
|
||||
def test_read_file_rejects_absolute_path_escape(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("secret\n", encoding="utf-8")
|
||||
|
||||
result = _run_tool(ReadFileTool(), {"path": str(outside)}, workspace)
|
||||
payload = _payload(result)
|
||||
|
||||
assert result.success is False
|
||||
assert payload["success"] is False
|
||||
assert "escapes workspace" in payload["error"]
|
||||
|
||||
|
||||
def test_read_file_rejects_symlink_escape(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("secret\n", encoding="utf-8")
|
||||
link = workspace / "outside-link.txt"
|
||||
try:
|
||||
os.symlink(outside, link)
|
||||
except (OSError, NotImplementedError):
|
||||
return
|
||||
|
||||
result = _run_tool(ReadFileTool(), {"path": "outside-link.txt"}, workspace)
|
||||
payload = _payload(result)
|
||||
|
||||
assert result.success is False
|
||||
assert payload["success"] is False
|
||||
assert "escapes workspace" in payload["error"]
|
||||
|
||||
|
||||
def test_read_file_rejects_binary_files(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "blob.bin").write_bytes(b"abc\x00def")
|
||||
|
||||
result = _run_tool(ReadFileTool(), {"path": "blob.bin"}, workspace)
|
||||
payload = _payload(result)
|
||||
|
||||
assert result.success is False
|
||||
assert payload["success"] is False
|
||||
assert "binary" in payload["error"]
|
||||
|
||||
201
app-instance/backend/tests/unit/test_gateway_channels.py
Normal file
201
app-instance/backend/tests/unit/test_gateway_channels.py
Normal file
@ -0,0 +1,201 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from beaver.foundation.events import InboundMessage, MessageBus
|
||||
from beaver.interfaces.channels import ChannelManager, MemoryChannelAdapter
|
||||
from beaver.interfaces.gateway.main import run_gateway
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FakeResult:
|
||||
session_id: str
|
||||
run_id: str = "run-1"
|
||||
output_text: str = ""
|
||||
finish_reason: str = "stop"
|
||||
provider_name: str | None = "fake"
|
||||
model: str | None = "fake-model"
|
||||
usage: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class FakeService:
|
||||
is_running = True
|
||||
|
||||
async def submit_direct(self, message: str, **kwargs: Any) -> FakeResult:
|
||||
return FakeResult(
|
||||
session_id=kwargs.get("session_id") or "s1",
|
||||
output_text=f"echo:{message}",
|
||||
)
|
||||
|
||||
async def handle_inbound_message(self, inbound: InboundMessage):
|
||||
result = await self.submit_direct(inbound.content, session_id=inbound.session_id)
|
||||
return AgentService.build_outbound_message(inbound, result)
|
||||
|
||||
|
||||
class SlowService:
|
||||
is_running = True
|
||||
|
||||
async def submit_direct(self, message: str, **kwargs: Any) -> FakeResult:
|
||||
await asyncio.sleep(10)
|
||||
return FakeResult(session_id=kwargs.get("session_id") or "s1")
|
||||
|
||||
async def handle_inbound_message(self, inbound: InboundMessage):
|
||||
result = await self.submit_direct(inbound.content, session_id=inbound.session_id)
|
||||
return AgentService.build_outbound_message(inbound, result)
|
||||
|
||||
|
||||
def test_gateway_routes_memory_channel_roundtrip() -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
channel = MemoryChannelAdapter(bus)
|
||||
stop_event = asyncio.Event()
|
||||
task = asyncio.create_task(
|
||||
run_gateway(
|
||||
service=FakeService(),
|
||||
manage_service_lifecycle=False,
|
||||
bus=bus,
|
||||
channels=[channel],
|
||||
stop_event=stop_event,
|
||||
)
|
||||
)
|
||||
|
||||
await channel.publish_text("hello", session_id="s1")
|
||||
for _ in range(40):
|
||||
if channel.sent_messages:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert channel.sent_messages
|
||||
message = channel.sent_messages[0]
|
||||
assert message.content == "echo:hello"
|
||||
assert message.session_id == "s1"
|
||||
assert message.finish_reason == "stop"
|
||||
|
||||
stop_event.set()
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_gateway_delivers_cancelled_outbound_to_channel() -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
channel = MemoryChannelAdapter(bus)
|
||||
stop_event = asyncio.Event()
|
||||
task = asyncio.create_task(
|
||||
run_gateway(
|
||||
service=SlowService(),
|
||||
manage_service_lifecycle=False,
|
||||
bus=bus,
|
||||
channels=[channel],
|
||||
stop_event=stop_event,
|
||||
)
|
||||
)
|
||||
|
||||
await channel.publish_text("slow", session_id="s1")
|
||||
await asyncio.sleep(0.05)
|
||||
stop_event.set()
|
||||
await asyncio.wait_for(task, timeout=3)
|
||||
|
||||
assert channel.sent_messages
|
||||
assert channel.sent_messages[0].finish_reason == "cancelled"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_gateway_rejects_channel_manager_and_channels_together() -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
try:
|
||||
await run_gateway(
|
||||
service=FakeService(),
|
||||
manage_service_lifecycle=False,
|
||||
bus=bus,
|
||||
channel_manager=ChannelManager(bus),
|
||||
channels=[MemoryChannelAdapter(bus)],
|
||||
stop_event=asyncio.Event(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "either channel_manager or channels" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_agent_service_maps_inbound_error_to_structured_outbound() -> None:
|
||||
async def run() -> None:
|
||||
service = AgentService()
|
||||
|
||||
async def failing_submit_direct(message: str, **kwargs: Any) -> FakeResult:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
service.submit_direct = failing_submit_direct # type: ignore[method-assign]
|
||||
outbound = await service.handle_inbound_message(
|
||||
InboundMessage(channel="memory", content="hello", session_id="s1", metadata={"source": "test"})
|
||||
)
|
||||
|
||||
assert outbound.finish_reason == "error"
|
||||
assert outbound.session_id == "s1"
|
||||
assert outbound.metadata["error"] == "boom"
|
||||
assert outbound.metadata["inbound_metadata"] == {"source": "test"}
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None:
|
||||
class StartedChannel:
|
||||
name = "started"
|
||||
|
||||
def __init__(self, bus: MessageBus) -> None:
|
||||
self.bus = bus
|
||||
self.stopped = False
|
||||
|
||||
async def start(self) -> None:
|
||||
pass
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.stopped = True
|
||||
|
||||
async def send(self, message: Any) -> None:
|
||||
pass
|
||||
|
||||
class BlockingChannel:
|
||||
name = "blocking"
|
||||
|
||||
def __init__(self, bus: MessageBus) -> None:
|
||||
self.bus = bus
|
||||
self.entered = asyncio.Event()
|
||||
|
||||
async def start(self) -> None:
|
||||
self.entered.set()
|
||||
await asyncio.sleep(10)
|
||||
|
||||
async def stop(self) -> None:
|
||||
pass
|
||||
|
||||
async def send(self, message: Any) -> None:
|
||||
pass
|
||||
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
started = StartedChannel(bus)
|
||||
blocking = BlockingChannel(bus)
|
||||
manager = ChannelManager(bus)
|
||||
manager.register(started)
|
||||
manager.register(blocking)
|
||||
|
||||
task = asyncio.create_task(manager.start())
|
||||
await blocking.entered.wait()
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected cancellation")
|
||||
|
||||
assert started.stopped
|
||||
|
||||
asyncio.run(run())
|
||||
@ -1,12 +1,13 @@
|
||||
from beaver.engine import AgentLoop
|
||||
from beaver.engine import AgentLoop, EngineLoader
|
||||
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
|
||||
from beaver.interfaces.channels import ChannelManager, MemoryChannelAdapter
|
||||
from beaver.interfaces.gateway.main import run_gateway
|
||||
from beaver.interfaces.web.app import create_app
|
||||
from beaver.interfaces.web.schemas import WebChatRequest, WebChatResponse
|
||||
|
||||
|
||||
def test_agent_loop_boots() -> None:
|
||||
loop = AgentLoop()
|
||||
def test_agent_loop_boots(tmp_path) -> None:
|
||||
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
|
||||
loaded = loop.boot()
|
||||
assert "echo" in loaded.tools
|
||||
assert "memory" in loaded.tools
|
||||
@ -29,6 +30,14 @@ def test_message_bus_imports() -> None:
|
||||
assert OutboundMessage(channel="test", content="ok", session_id=None, finish_reason="stop").content == "ok"
|
||||
|
||||
|
||||
def test_channel_imports() -> None:
|
||||
bus = MessageBus()
|
||||
channel = MemoryChannelAdapter(bus)
|
||||
manager = ChannelManager(bus)
|
||||
manager.register(channel)
|
||||
assert manager.channels["memory"] is channel
|
||||
|
||||
|
||||
def test_web_schema_imports() -> None:
|
||||
assert WebChatRequest(message="hello").message == "hello"
|
||||
assert WebChatResponse(
|
||||
|
||||
149
app-instance/backend/tests/unit/test_tool_assembler.py
Normal file
149
app-instance/backend/tests/unit/test_tool_assembler.py
Normal file
@ -0,0 +1,149 @@
|
||||
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,
|
||||
)
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
return self._spec
|
||||
|
||||
async def invoke(self, arguments: dict, context: ToolContext) -> ToolResult:
|
||||
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("skill_view", toolset="skills", 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", "skill_view", "terminal", "search_files"]
|
||||
|
||||
|
||||
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"}
|
||||
@ -704,6 +704,7 @@ provider 层不要再做成“一个厂商一个世界”,而是要拆成 4
|
||||
|
||||
1. `beaver/tools/base.py`
|
||||
2. `beaver/tools/registry/tool_registry.py`
|
||||
3. `beaver/tools/assembler/task_assembler.py`
|
||||
|
||||
参考旧文件:
|
||||
|
||||
@ -732,6 +733,48 @@ provider 层不要再做成“一个厂商一个世界”,而是要拆成 4
|
||||
1. registry 可以注册工具
|
||||
2. provider 返回 tool call 时可以找到并执行工具
|
||||
3. memory / session_search 已纳入统一工具集合
|
||||
4. 本地工具描述采用 MCP-style `name/description/inputSchema`
|
||||
5. `ToolAssembler` 能按 task description 用 embedding 召回本轮 top10 工具
|
||||
6. activated skill frontmatter 里的 `tools` 能参与本轮工具选择
|
||||
7. 只读 filesystem tools 已接入:
|
||||
- `list_directory`
|
||||
- `read_file`
|
||||
- `search_files`
|
||||
8. filesystem tools 强制限制在 `ToolContext.workspace`
|
||||
9. 相对路径逃逸、绝对路径逃逸、符号链接逃逸都会拒绝
|
||||
10. 二进制文件读取会拒绝,搜索会跳过二进制 / 大文件
|
||||
|
||||
当前工具选择规则已经定为:
|
||||
|
||||
1. always tools 每轮默认可用
|
||||
- `memory`
|
||||
- `session_search`
|
||||
- `skill_view`
|
||||
- `list_directory`
|
||||
- `read_file`
|
||||
- `search_files`
|
||||
2. activated skill 可以显式声明工具:
|
||||
|
||||
```yaml
|
||||
---
|
||||
tools:
|
||||
- terminal
|
||||
- read_file
|
||||
---
|
||||
```
|
||||
|
||||
3. `ToolAssembler` 合并:
|
||||
- always tools
|
||||
- skill hints
|
||||
- task embedding top10 tools
|
||||
4. 第一版只信任 frontmatter / metadata 的显式 `tools`,不从正文里猜工具名
|
||||
5. 如果 skill 声明了尚未注册的工具,先忽略,不阻断 run
|
||||
|
||||
filesystem 这一版只做只读,不做写文件 / shell:
|
||||
|
||||
1. 先让 agent 能看见 workspace 结构、读源码、搜文本
|
||||
2. 写文件和 shell 属于高风险工具,必须等 permission gates 明确后再接
|
||||
3. 当前 workspace 边界只保证路径隔离,不等价于完整权限系统
|
||||
|
||||
### 4.5 最后实现第一版 `AgentLoop`
|
||||
|
||||
@ -855,6 +898,23 @@ provider 层不要再做成“一个厂商一个世界”,而是要拆成 4
|
||||
- 先用 embedding 做语义召回
|
||||
- 输出应该激活的 skills
|
||||
6. embedding 配置通过 provider bundle 的独立 `embedding runtime` 传入;若没有显式 embedding 配置,则只有主链本身是 OpenAI-compatible 时才允许继承 `api_base/api_key`
|
||||
7. skill frontmatter 可声明本 skill 推荐工具;这些 tool hints 会交给 `ToolAssembler`
|
||||
|
||||
当前和长期目标的关系:
|
||||
|
||||
1. 已完成基础入口:
|
||||
- curated memory CRUD
|
||||
- `session_search`
|
||||
- `skill_view`
|
||||
- `SkillAssembler`
|
||||
- `ToolAssembler`
|
||||
2. 还没完成长期智能体治理:
|
||||
- 智能体定期整理 / 提示记忆
|
||||
- 复杂任务完成后自主创建技能
|
||||
- 技能在使用过程中自我提升
|
||||
- FTS5 + LLM 摘要的跨会话回忆增强
|
||||
- Honcho 风格辩证用户建模
|
||||
- agentskills.io 开放标准兼容
|
||||
|
||||
---
|
||||
|
||||
@ -884,6 +944,10 @@ provider 层不要再做成“一个厂商一个世界”,而是要拆成 4
|
||||
3. `ContextBuilder`
|
||||
- 不再直接依赖进程内状态
|
||||
- 只从 Session / curated memory / skills 中提取当前需要的上下文
|
||||
4. `ToolAssembler`
|
||||
- 不把所有工具无脑暴露给模型
|
||||
- 按 task description / activated skill hints 选择本轮工具
|
||||
- 输出 provider 可消费的 tool schema
|
||||
|
||||
这一步不是要一口气做完 fork / rewind / checkpoint 全套系统,而是先把“Session-first, Stateless Harness”这条主线立住。
|
||||
|
||||
@ -926,12 +990,13 @@ provider 层不要再做成“一个厂商一个世界”,而是要拆成 4
|
||||
|
||||
1. `session_started/ensured`
|
||||
2. `run_started`
|
||||
3. `system_prompt_snapshotted`
|
||||
4. `user_message_added`
|
||||
5. `assistant_message_added`
|
||||
6. `tool_call_requested`
|
||||
7. `tool_result_recorded`
|
||||
8. `run_failed` 或 `run_completed`
|
||||
3. `skill_activation_snapshotted`
|
||||
4. `tool_selection_snapshotted`
|
||||
5. `system_prompt_snapshotted`
|
||||
6. `user_message_added`
|
||||
7. `assistant_message_added`
|
||||
8. `tool_result_recorded`
|
||||
9. `run_failed` 或 `run_completed`
|
||||
|
||||
并且每次 run 都要带独立 `run_id`,这样同一个 session 内的多次运行才能被切开。
|
||||
|
||||
@ -1016,6 +1081,31 @@ provider 层不要再做成“一个厂商一个世界”,而是要拆成 4
|
||||
7. `stop()` / `shutdown()` 应支持 graceful timeout,必要时允许 force cancel
|
||||
8. `close()`:只有在实例已停止后才能释放 runtime 资源
|
||||
|
||||
这一阶段也明确了模型配置的归属:
|
||||
|
||||
1. 大模型 provider / api_key / api_base 属于 backend runtime config
|
||||
2. Web / Gateway / Channel 不单独保存模型密钥
|
||||
3. 前端请求不传 API Key,只传 `message/session_id/user_id` 等业务输入
|
||||
4. Docker 单实例部署时,每个用户 sandbox 读取自己的实例配置
|
||||
5. 默认使用新 Beaver 实例目录:
|
||||
- `/root/.beaver/config.json`
|
||||
- `/root/.beaver/workspace`
|
||||
6. 新 Beaver 命名优先使用:
|
||||
- `BEAVER_CONFIG_PATH`
|
||||
- `BEAVER_HOME/config.json`
|
||||
7. 兼容迁移期旧命名:
|
||||
- `NANOBOT_CONFIG_PATH`
|
||||
- `NANOBOT_HOME/config.json`
|
||||
|
||||
app-instance 镜像也已经切到新 Beaver 后端:
|
||||
|
||||
1. Dockerfile 只安装 `backend/beaver`
|
||||
2. 不再复制旧 `backend/nanobot`、`backend/bridge`、vendored `swarms`
|
||||
3. entrypoint 通过 `python -m uvicorn beaver.interfaces.web.app:create_app --factory` 启动 Web
|
||||
4. 容器内默认配置与 workspace 使用:
|
||||
- `/root/.beaver/config.json`
|
||||
- `/root/.beaver/workspace`
|
||||
|
||||
### 6.2.1 Web / Gateway 现在如何接这套 lifecycle
|
||||
|
||||
这一层现在已经开始落成真正的宿主层,而不是只停留在文档占位:
|
||||
@ -1045,13 +1135,25 @@ provider 层不要再做成“一个厂商一个世界”,而是要拆成 4
|
||||
- `run_gateway()` 启动时:
|
||||
- 如果 gateway 自己创建 service,则 `await service.start()`
|
||||
- 持有最小 `MessageBus`
|
||||
- 常驻消费 `bus.inbound`
|
||||
- 调 `await service.submit_direct(...)`
|
||||
- 将结果写回 `bus.outbound`
|
||||
- 可选接收 `ChannelManager` / channel adapters
|
||||
- `ChannelManager` 和 `channels` 参数二选一:
|
||||
- 传 `ChannelManager`:外部提前配置好 channel
|
||||
- 传 `channels`:gateway 内部创建 `ChannelManager` 并注册这些 channel
|
||||
- inbound 流向:
|
||||
- channel adapter 发布 `InboundMessage`
|
||||
- `MessageBus.inbound`
|
||||
- gateway bridge 常驻消费
|
||||
- 调 `await service.handle_inbound_message(...)`
|
||||
- outbound 流向:
|
||||
- `AgentService` 内部完成 `InboundMessage -> OutboundMessage` 映射
|
||||
- gateway bridge 将结果写回 `MessageBus.outbound`
|
||||
- 如果启用了 `ChannelManager`,则分发给对应 channel adapter
|
||||
- 未启用 `ChannelManager` 时,保留直接消费 `bus.outbound` 的最小测试能力
|
||||
- 同时等待 `stop_event`
|
||||
- 停机时:
|
||||
- 先尝试 `await service.shutdown(timeout_seconds=5.0, force=True)`
|
||||
- 再等待 bridge 协程收尾;必要时取消 bridge
|
||||
- 再等待 outbound dispatch 协程收尾;必要时取消 dispatch
|
||||
- 如果 gateway 自己接管 lifecycle 且 `start()` 失败:
|
||||
- 立即 `close()` 做 startup cleanup
|
||||
- 未处理完的 inbound:
|
||||
@ -1068,6 +1170,16 @@ provider 层不要再做成“一个厂商一个世界”,而是要拆成 4
|
||||
- `outbound`
|
||||
- 还没有 broker / topic routing / retry / persistence
|
||||
|
||||
4. `beaver/interfaces/channels/*`
|
||||
- 已经补了最小 channel adapter 层:
|
||||
- `ChannelAdapter`
|
||||
- `ChannelManager`
|
||||
- `MemoryChannelAdapter`
|
||||
- 当前 channel 职责很窄:
|
||||
- 把外部输入发布成 `InboundMessage`
|
||||
- 接收并投递 `OutboundMessage`
|
||||
- `MemoryChannelAdapter` 只用于本地测试和内嵌接入,不是正式消息 broker
|
||||
|
||||
所以现在已经明确:
|
||||
|
||||
1. Web / Gateway 属于宿主层
|
||||
@ -1082,16 +1194,18 @@ provider 层不要再做成“一个厂商一个世界”,而是要拆成 4
|
||||
- 外部注入的 `AgentService`:默认不自动 start/shutdown,除非显式要求接管
|
||||
5. gateway 已经从“只会常驻等待”推进到“最小消息桥接层”
|
||||
- external inbound message
|
||||
- channel adapter
|
||||
- `MessageBus.inbound`
|
||||
- `service.submit_direct(...)`
|
||||
- `service.handle_inbound_message(...)`
|
||||
- `MessageBus.outbound`
|
||||
- channel adapter outbound delivery
|
||||
|
||||
但这一阶段还没做:
|
||||
|
||||
1. channels adapter
|
||||
2. realtime streaming
|
||||
3. platform-level supervisor
|
||||
4. 更复杂的 bus 语义(retry / routing / persistence)
|
||||
1. realtime streaming
|
||||
2. platform-level supervisor
|
||||
3. 更复杂的 bus 语义(retry / routing / persistence)
|
||||
4. 外部真实 channel adapter
|
||||
|
||||
### 6.3 第三步:回填 bus 模式
|
||||
|
||||
@ -1107,9 +1221,16 @@ provider 层不要再做成“一个厂商一个世界”,而是要拆成 4
|
||||
需要补的函数:
|
||||
|
||||
1. 从 inbound 读取消息
|
||||
2. 调用 `_process_message`
|
||||
2. 通过 `AgentService.handle_inbound_message(...)` 映射到 runtime 调用
|
||||
3. 发布 outbound
|
||||
|
||||
当前这一步的最小收口方式已经确定为:
|
||||
|
||||
1. `MessageBus` 只负责协议和队列
|
||||
2. `gateway` 只负责宿主、常驻消费和 channel 分发
|
||||
3. `InboundMessage -> AgentRunResult -> OutboundMessage` 的映射收口在 `AgentService`
|
||||
4. `AgentLoop` 继续只关心 agent 执行内核,不直接感知 bus 协议
|
||||
|
||||
注意:
|
||||
|
||||
只有在 `process_direct()` 稳定,并且 6.1 / 6.2 已经把 Session-first + lifecycle 骨架立住后,才做 `run()` 的长循环版本。
|
||||
|
||||
@ -194,7 +194,7 @@ if outlook_mcp_url:
|
||||
data = {
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/root/.nanobot/workspace",
|
||||
"workspace": "/root/.beaver/workspace",
|
||||
"model": os.environ["MODEL"],
|
||||
}
|
||||
},
|
||||
@ -505,13 +505,13 @@ PY
|
||||
fi
|
||||
|
||||
INSTANCE_ROOT="${INSTANCES_ROOT}/${INSTANCE_SLUG}"
|
||||
NANOBOT_HOME="${INSTANCE_ROOT}/nanobot-home"
|
||||
CONFIG_PATH="${NANOBOT_HOME}/config.json"
|
||||
AUTH_USERS_PATH="${NANOBOT_HOME}/web_auth_users.json"
|
||||
RUNTIME_ENV_PATH="${NANOBOT_HOME}/runtime.env"
|
||||
WORKSPACE_PATH="${NANOBOT_HOME}/workspace"
|
||||
BEAVER_HOME="${INSTANCE_ROOT}/beaver-home"
|
||||
CONFIG_PATH="${BEAVER_HOME}/config.json"
|
||||
AUTH_USERS_PATH="${BEAVER_HOME}/web_auth_users.json"
|
||||
RUNTIME_ENV_PATH="${BEAVER_HOME}/runtime.env"
|
||||
WORKSPACE_PATH="${BEAVER_HOME}/workspace"
|
||||
|
||||
mkdir -p "$NANOBOT_HOME" "$WORKSPACE_PATH"
|
||||
mkdir -p "$BEAVER_HOME" "$WORKSPACE_PATH"
|
||||
|
||||
render_config_json "$CONFIG_PATH"
|
||||
render_auth_users_json "$AUTH_USERS_PATH"
|
||||
@ -540,8 +540,12 @@ RUN_ARGS=(
|
||||
--name "$CONTAINER_NAME"
|
||||
--restart unless-stopped
|
||||
-p "${HOST_BIND_IP}:${HOST_PORT}:8080"
|
||||
-v "${NANOBOT_HOME}:/root/.nanobot"
|
||||
-e "NANOBOT_AUTH_FILE=/root/.nanobot/web_auth_users.json"
|
||||
-v "${BEAVER_HOME}:/root/.beaver"
|
||||
-e "BEAVER_HOME=/root/.beaver"
|
||||
-e "BEAVER_CONFIG_PATH=/root/.beaver/config.json"
|
||||
-e "BEAVER_WORKSPACE=/root/.beaver/workspace"
|
||||
-e "NANOBOT_HOME=/root/.beaver"
|
||||
-e "NANOBOT_AUTH_FILE=/root/.beaver/web_auth_users.json"
|
||||
-e "NANOBOT_FRONTEND_PUBLIC_BASE_URL=${PUBLIC_URL}"
|
||||
-e "APP_PUBLIC_PORT=8080"
|
||||
-e "APP_FRONTEND_PORT=3000"
|
||||
@ -567,7 +571,7 @@ docker run "${RUN_ARGS[@]}" "$IMAGE_NAME" >/dev/null
|
||||
--host-port "$HOST_PORT" \
|
||||
--public-url "$PUBLIC_URL" \
|
||||
--instance-root "$INSTANCE_ROOT" \
|
||||
--nanobot-home "$NANOBOT_HOME" \
|
||||
--nanobot-home "$BEAVER_HOME" \
|
||||
--config-path "$CONFIG_PATH" \
|
||||
--auth-users-path "$AUTH_USERS_PATH" \
|
||||
--network-name "$NETWORK_NAME" \
|
||||
@ -589,7 +593,8 @@ image_name=${IMAGE_NAME}
|
||||
host_port=${HOST_PORT}
|
||||
public_url=${PUBLIC_URL}
|
||||
instance_root=${INSTANCE_ROOT}
|
||||
nanobot_home=${NANOBOT_HOME}
|
||||
beaver_home=${BEAVER_HOME}
|
||||
nanobot_home=${BEAVER_HOME}
|
||||
config_path=${CONFIG_PATH}
|
||||
auth_users_path=${AUTH_USERS_PATH}
|
||||
runtime_env_path=${RUNTIME_ENV_PATH}
|
||||
|
||||
@ -4,9 +4,12 @@ set -euo pipefail
|
||||
APP_PUBLIC_PORT="${APP_PUBLIC_PORT:-8080}"
|
||||
APP_FRONTEND_PORT="${APP_FRONTEND_PORT:-3000}"
|
||||
APP_BACKEND_PORT="${APP_BACKEND_PORT:-18080}"
|
||||
NANOBOT_HOME="${NANOBOT_HOME:-/root/.nanobot}"
|
||||
NANOBOT_AUTH_FILE="${NANOBOT_AUTH_FILE:-$NANOBOT_HOME/web_auth_users.json}"
|
||||
NANOBOT_RUNTIME_ENV_FILE="${NANOBOT_RUNTIME_ENV_FILE:-$NANOBOT_HOME/runtime.env}"
|
||||
BEAVER_HOME="${BEAVER_HOME:-/root/.beaver}"
|
||||
BEAVER_CONFIG_PATH="${BEAVER_CONFIG_PATH:-$BEAVER_HOME/config.json}"
|
||||
BEAVER_WORKSPACE="${BEAVER_WORKSPACE:-$BEAVER_HOME/workspace}"
|
||||
NANOBOT_HOME="${NANOBOT_HOME:-$BEAVER_HOME}"
|
||||
NANOBOT_AUTH_FILE="${NANOBOT_AUTH_FILE:-$BEAVER_HOME/web_auth_users.json}"
|
||||
NANOBOT_RUNTIME_ENV_FILE="${NANOBOT_RUNTIME_ENV_FILE:-$BEAVER_HOME/runtime.env}"
|
||||
|
||||
log() {
|
||||
printf '[app-instance] %s\n' "$*"
|
||||
@ -21,40 +24,6 @@ require_file() {
|
||||
fi
|
||||
}
|
||||
|
||||
render_swarms_env_file() {
|
||||
local config_path="$1"
|
||||
local target_path="$2"
|
||||
|
||||
CONFIG_PATH="$config_path" TARGET_PATH="$target_path" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
config_path = Path(os.environ["CONFIG_PATH"])
|
||||
target_path = Path(os.environ["TARGET_PATH"])
|
||||
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
model = str(data.get("agents", {}).get("defaults", {}).get("model") or "").strip()
|
||||
if model and "/" not in model:
|
||||
model = f"openai/{model}"
|
||||
provider_cfg = data.get("providers", {}).get("openai", {}) or {}
|
||||
api_key = str(provider_cfg.get("apiKey") or "").strip()
|
||||
api_base = str(provider_cfg.get("apiBase") or "").strip()
|
||||
|
||||
lines = [
|
||||
'# Generated from /root/.nanobot/config.json for vendored swarms runtime.',
|
||||
'WORKSPACE_DIR="/root/.nanobot/workspace"',
|
||||
'SWARMS_VERBOSE_GLOBAL="False"',
|
||||
'SWARMS_TELEMETRY_ON="false"',
|
||||
f'SWARMS_DEFAULT_MODEL="{model}"',
|
||||
f'OPENAI_API_KEY="{api_key}"',
|
||||
f'OPENAI_API_BASE="{api_base}"',
|
||||
f'OPENAI_BASE_URL="{api_base}"',
|
||||
]
|
||||
target_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
PY
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local status=$?
|
||||
|
||||
@ -74,7 +43,7 @@ cleanup() {
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
mkdir -p "$NANOBOT_HOME" "$NANOBOT_HOME/workspace"
|
||||
mkdir -p "$BEAVER_HOME" "$BEAVER_WORKSPACE"
|
||||
|
||||
if [[ -f "$NANOBOT_RUNTIME_ENV_FILE" ]]; then
|
||||
set -a
|
||||
@ -82,24 +51,21 @@ if [[ -f "$NANOBOT_RUNTIME_ENV_FILE" ]]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
require_file "$NANOBOT_HOME/config.json" "Missing Boardware Genius config"
|
||||
require_file "$NANOBOT_AUTH_FILE" "Missing web auth users file"
|
||||
|
||||
SWARMS_ENV_FILE="/opt/app/backend/third_party/swarms/.env"
|
||||
render_swarms_env_file "$NANOBOT_HOME/config.json" "$SWARMS_ENV_FILE"
|
||||
if [[ -f "$SWARMS_ENV_FILE" ]]; then
|
||||
set -a
|
||||
. "$SWARMS_ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
require_file "$BEAVER_CONFIG_PATH" "Missing Beaver config"
|
||||
|
||||
export NANOBOT_AUTH_FILE
|
||||
export NANOBOT_RUNTIME_ENV_FILE
|
||||
export BEAVER_HOME
|
||||
export BEAVER_CONFIG_PATH
|
||||
export BEAVER_WORKSPACE
|
||||
export PORT="$APP_FRONTEND_PORT"
|
||||
export HOSTNAME="127.0.0.1"
|
||||
|
||||
log "starting backend on 127.0.0.1:${APP_BACKEND_PORT}"
|
||||
nanobot web --host 127.0.0.1 --port "$APP_BACKEND_PORT" &
|
||||
log "starting Beaver backend on 127.0.0.1:${APP_BACKEND_PORT}"
|
||||
(
|
||||
cd /opt/app/backend
|
||||
python -m uvicorn "beaver.interfaces.web.app:create_app" --factory --host 127.0.0.1 --port "$APP_BACKEND_PORT"
|
||||
) &
|
||||
BACKEND_PID=$!
|
||||
|
||||
log "starting frontend on 127.0.0.1:${APP_FRONTEND_PORT}"
|
||||
|
||||
@ -251,10 +251,21 @@ export async function sendMessage(
|
||||
if (attachments && attachments.length > 0) {
|
||||
body.attachments = attachments;
|
||||
}
|
||||
return fetchJSON('/api/chat', {
|
||||
const result = await fetchJSON<{
|
||||
response?: string;
|
||||
status?: string;
|
||||
session_id: string;
|
||||
output_text?: string;
|
||||
finish_reason?: string;
|
||||
}>('/api/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return {
|
||||
response: result.response ?? result.output_text,
|
||||
status: result.status ?? result.finish_reason,
|
||||
session_id: result.session_id,
|
||||
};
|
||||
}
|
||||
|
||||
export function streamMessage(
|
||||
|
||||
@ -183,7 +183,7 @@ def wait_for_backend(record: dict[str, Any]) -> None:
|
||||
try:
|
||||
with urllib_request.urlopen(target, timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
if payload.get("message") == "pong":
|
||||
if payload.get("message") == "pong" or payload.get("status") == "ok":
|
||||
return
|
||||
last_error = f"unexpected ping response from {target}"
|
||||
except (urllib_error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
|
||||
Reference in New Issue
Block a user