feat(memory): add hybrid gateway configuration
This commit is contained in:
@ -7,6 +7,8 @@ from .schema import (
|
|||||||
BackendIdentityConfig,
|
BackendIdentityConfig,
|
||||||
BeaverConfig,
|
BeaverConfig,
|
||||||
EmbeddingConfig,
|
EmbeddingConfig,
|
||||||
|
MemoryConfig,
|
||||||
|
MemoryGatewayConfig,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
ToolsConfig,
|
ToolsConfig,
|
||||||
@ -18,6 +20,8 @@ __all__ = [
|
|||||||
"BackendIdentityConfig",
|
"BackendIdentityConfig",
|
||||||
"BeaverConfig",
|
"BeaverConfig",
|
||||||
"EmbeddingConfig",
|
"EmbeddingConfig",
|
||||||
|
"MemoryConfig",
|
||||||
|
"MemoryGatewayConfig",
|
||||||
"MCPServerConfig",
|
"MCPServerConfig",
|
||||||
"ProviderConfig",
|
"ProviderConfig",
|
||||||
"ToolsConfig",
|
"ToolsConfig",
|
||||||
|
|||||||
@ -15,6 +15,8 @@ from .schema import (
|
|||||||
BeaverConfig,
|
BeaverConfig,
|
||||||
ChannelConfig,
|
ChannelConfig,
|
||||||
EmbeddingConfig,
|
EmbeddingConfig,
|
||||||
|
MemoryConfig,
|
||||||
|
MemoryGatewayConfig,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
ToolsConfig,
|
ToolsConfig,
|
||||||
@ -76,6 +78,7 @@ def load_config(
|
|||||||
authz=_parse_authz(data.get("authz")),
|
authz=_parse_authz(data.get("authz")),
|
||||||
channels=_parse_channels(data.get("channels")),
|
channels=_parse_channels(data.get("channels")),
|
||||||
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
|
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
|
||||||
|
memory=_parse_memory(data),
|
||||||
config_path=path,
|
config_path=path,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -251,6 +254,50 @@ def _parse_backend_identity(raw: Any) -> BackendIdentityConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_memory(data: dict[str, Any]) -> MemoryConfig:
|
||||||
|
explicit = "memory" in data
|
||||||
|
raw = _as_dict(data.get("memory"))
|
||||||
|
mode = (_string(raw.get("mode")) or "hybrid").lower()
|
||||||
|
if mode not in {"curated", "hybrid"}:
|
||||||
|
raise ValueError("memory.mode must be 'curated' or 'hybrid'")
|
||||||
|
|
||||||
|
gateway_raw = _as_dict(raw.get("gateway"))
|
||||||
|
parsed_top_k = _int(_first_config_value(gateway_raw.get("topK"), gateway_raw.get("top_k")))
|
||||||
|
parsed_timeout = _float(
|
||||||
|
_first_config_value(gateway_raw.get("timeoutSeconds"), gateway_raw.get("timeout_seconds"))
|
||||||
|
)
|
||||||
|
gateway = MemoryGatewayConfig(
|
||||||
|
base_url=_string(gateway_raw.get("baseUrl") or gateway_raw.get("base_url")) or "",
|
||||||
|
user_id=_string(gateway_raw.get("userId") or gateway_raw.get("user_id")) or "",
|
||||||
|
user_key=_string(gateway_raw.get("userKey") or gateway_raw.get("user_key")) or "",
|
||||||
|
app_id=_string(gateway_raw.get("appId") or gateway_raw.get("app_id")) or "default",
|
||||||
|
project_id=_string(gateway_raw.get("projectId") or gateway_raw.get("project_id")) or "default",
|
||||||
|
scope=_string_list(gateway_raw.get("scope")) or ["current_chat", "resources"],
|
||||||
|
top_k=8 if parsed_top_k is None else parsed_top_k,
|
||||||
|
timeout_seconds=10.0 if parsed_timeout is None else parsed_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode == "hybrid" and explicit:
|
||||||
|
missing: list[str] = []
|
||||||
|
if not gateway.base_url:
|
||||||
|
missing.append("baseUrl")
|
||||||
|
if not gateway.user_id:
|
||||||
|
missing.append("userId")
|
||||||
|
if not gateway.user_key:
|
||||||
|
missing.append("userKey")
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Explicit hybrid memory requires gateway fields: {', '.join(missing)}")
|
||||||
|
allowed_scopes = {"current_chat", "resources", "all_user_memory"}
|
||||||
|
if not gateway.scope or any(scope not in allowed_scopes for scope in gateway.scope):
|
||||||
|
raise ValueError("memory.gateway.scope contains an unsupported value")
|
||||||
|
if gateway.top_k < 1 or gateway.top_k > 100:
|
||||||
|
raise ValueError("memory.gateway.topK must be between 1 and 100")
|
||||||
|
if gateway.timeout_seconds <= 0:
|
||||||
|
raise ValueError("memory.gateway.timeoutSeconds must be positive")
|
||||||
|
|
||||||
|
return MemoryConfig(mode=mode, explicit=explicit, gateway=gateway)
|
||||||
|
|
||||||
|
|
||||||
def _as_dict(value: Any) -> dict[str, Any]:
|
def _as_dict(value: Any) -> dict[str, Any]:
|
||||||
return value if isinstance(value, dict) else {}
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|||||||
@ -115,6 +115,33 @@ class BackendIdentityConfig:
|
|||||||
public_base_url: str = ""
|
public_base_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class MemoryGatewayConfig:
|
||||||
|
"""Fixed Memory Gateway settings for one Beaver instance."""
|
||||||
|
|
||||||
|
base_url: str = ""
|
||||||
|
user_id: str = ""
|
||||||
|
user_key: str = field(default="", repr=False)
|
||||||
|
app_id: str = "default"
|
||||||
|
project_id: str = "default"
|
||||||
|
scope: list[str] = field(default_factory=lambda: ["current_chat", "resources"])
|
||||||
|
top_k: int = 8
|
||||||
|
timeout_seconds: float = 10.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
return bool(_clean(self.base_url) and _clean(self.user_id) and _clean(self.user_key))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class MemoryConfig:
|
||||||
|
"""Curated baseline plus optional Memory Gateway layer."""
|
||||||
|
|
||||||
|
mode: str = "hybrid"
|
||||||
|
explicit: bool = False
|
||||||
|
gateway: MemoryGatewayConfig = field(default_factory=MemoryGatewayConfig)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class BeaverConfig:
|
class BeaverConfig:
|
||||||
"""Config loaded once per backend sandbox instance."""
|
"""Config loaded once per backend sandbox instance."""
|
||||||
@ -126,6 +153,7 @@ class BeaverConfig:
|
|||||||
authz: AuthzConfig = field(default_factory=AuthzConfig)
|
authz: AuthzConfig = field(default_factory=AuthzConfig)
|
||||||
channels: dict[str, ChannelConfig] = field(default_factory=dict)
|
channels: dict[str, ChannelConfig] = field(default_factory=dict)
|
||||||
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
|
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
|
||||||
|
memory: MemoryConfig = field(default_factory=MemoryConfig)
|
||||||
config_path: Path | None = None
|
config_path: Path | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from beaver.engine import AgentLoop, EngineLoader
|
from beaver.engine import AgentLoop, EngineLoader
|
||||||
@ -474,3 +475,130 @@ def test_load_config_adds_managed_local_mcp_servers(tmp_path) -> None:
|
|||||||
assert local.managed is True
|
assert local.managed is True
|
||||||
assert local.display_name == "个人智能体文件系统工具"
|
assert local.display_name == "个人智能体文件系统工具"
|
||||||
assert "beaver.interfaces.mcp.tools_server" in local.args
|
assert "beaver.interfaces.mcp.tools_server" in local.args
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_memory_config_defaults_to_implicit_hybrid(tmp_path) -> None:
|
||||||
|
config = load_config(config_path=tmp_path / "missing.json")
|
||||||
|
|
||||||
|
assert config.memory.mode == "hybrid"
|
||||||
|
assert config.memory.explicit is False
|
||||||
|
assert config.memory.gateway.scope == ["current_chat", "resources"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_reads_explicit_curated_memory_mode(tmp_path) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(json.dumps({"memory": {"mode": "curated"}}), encoding="utf-8")
|
||||||
|
|
||||||
|
config = load_config(config_path=config_path)
|
||||||
|
|
||||||
|
assert config.memory.mode == "curated"
|
||||||
|
assert config.memory.explicit is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_reads_explicit_hybrid_gateway_settings(tmp_path) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"memory": {
|
||||||
|
"mode": "hybrid",
|
||||||
|
"gateway": {
|
||||||
|
"baseUrl": "http://127.0.0.1:8010",
|
||||||
|
"userId": "gateway-user",
|
||||||
|
"userKey": "uk_secret",
|
||||||
|
"appId": "beaver",
|
||||||
|
"projectId": "sandbox",
|
||||||
|
"scope": ["current_chat", "resources"],
|
||||||
|
"topK": 5,
|
||||||
|
"timeoutSeconds": 12.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_config(config_path=config_path)
|
||||||
|
|
||||||
|
assert config.memory.mode == "hybrid"
|
||||||
|
assert config.memory.explicit is True
|
||||||
|
assert config.memory.gateway.base_url == "http://127.0.0.1:8010"
|
||||||
|
assert config.memory.gateway.user_id == "gateway-user"
|
||||||
|
assert config.memory.gateway.user_key == "uk_secret"
|
||||||
|
assert config.memory.gateway.app_id == "beaver"
|
||||||
|
assert config.memory.gateway.project_id == "sandbox"
|
||||||
|
assert config.memory.gateway.scope == ["current_chat", "resources"]
|
||||||
|
assert config.memory.gateway.top_k == 5
|
||||||
|
assert config.memory.gateway.timeout_seconds == 12.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_hybrid_requires_gateway_credentials_without_leaking_secret(tmp_path) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"memory": {
|
||||||
|
"mode": "hybrid",
|
||||||
|
"gateway": {
|
||||||
|
"baseUrl": "http://127.0.0.1:8010",
|
||||||
|
"userKey": "uk_super_secret",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
load_config(config_path=config_path)
|
||||||
|
|
||||||
|
assert "userId" in str(exc_info.value)
|
||||||
|
assert "uk_super_secret" not in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hybrid_memory_rejects_unknown_scope(tmp_path) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"memory": {
|
||||||
|
"mode": "hybrid",
|
||||||
|
"gateway": {
|
||||||
|
"baseUrl": "http://127.0.0.1:8010",
|
||||||
|
"userId": "gateway-user",
|
||||||
|
"userKey": "uk_secret",
|
||||||
|
"scope": ["current_chat", "unknown"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="scope"):
|
||||||
|
load_config(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("gateway_override", "expected_error"),
|
||||||
|
[
|
||||||
|
({"topK": 0}, "topK"),
|
||||||
|
({"topK": 101}, "topK"),
|
||||||
|
({"timeoutSeconds": 0}, "timeoutSeconds"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_hybrid_memory_rejects_invalid_limits(tmp_path, gateway_override, expected_error) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
gateway = {
|
||||||
|
"baseUrl": "http://127.0.0.1:8010",
|
||||||
|
"userId": "gateway-user",
|
||||||
|
"userKey": "uk_secret",
|
||||||
|
**gateway_override,
|
||||||
|
}
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps({"memory": {"mode": "hybrid", "gateway": gateway}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=expected_error):
|
||||||
|
load_config(config_path=config_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user