From f4bdfc07176f2dd0c8e4d67c0522134712397ee8 Mon Sep 17 00:00:00 2001 From: tomtan Date: Mon, 15 Jun 2026 11:05:23 +0800 Subject: [PATCH] feat(memory): add hybrid gateway configuration --- .../beaver/foundation/config/__init__.py | 4 + .../beaver/foundation/config/loader.py | 47 +++++++ .../beaver/foundation/config/schema.py | 28 ++++ .../backend/tests/unit/test_config_loader.py | 128 ++++++++++++++++++ 4 files changed, 207 insertions(+) diff --git a/app-instance/backend/beaver/foundation/config/__init__.py b/app-instance/backend/beaver/foundation/config/__init__.py index c3c1aa1..57b183c 100644 --- a/app-instance/backend/beaver/foundation/config/__init__.py +++ b/app-instance/backend/beaver/foundation/config/__init__.py @@ -7,6 +7,8 @@ from .schema import ( BackendIdentityConfig, BeaverConfig, EmbeddingConfig, + MemoryConfig, + MemoryGatewayConfig, MCPServerConfig, ProviderConfig, ToolsConfig, @@ -18,6 +20,8 @@ __all__ = [ "BackendIdentityConfig", "BeaverConfig", "EmbeddingConfig", + "MemoryConfig", + "MemoryGatewayConfig", "MCPServerConfig", "ProviderConfig", "ToolsConfig", diff --git a/app-instance/backend/beaver/foundation/config/loader.py b/app-instance/backend/beaver/foundation/config/loader.py index 3e71302..bc43272 100644 --- a/app-instance/backend/beaver/foundation/config/loader.py +++ b/app-instance/backend/beaver/foundation/config/loader.py @@ -15,6 +15,8 @@ from .schema import ( BeaverConfig, ChannelConfig, EmbeddingConfig, + MemoryConfig, + MemoryGatewayConfig, MCPServerConfig, ProviderConfig, ToolsConfig, @@ -76,6 +78,7 @@ def load_config( authz=_parse_authz(data.get("authz")), channels=_parse_channels(data.get("channels")), backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")), + memory=_parse_memory(data), 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]: return value if isinstance(value, dict) else {} diff --git a/app-instance/backend/beaver/foundation/config/schema.py b/app-instance/backend/beaver/foundation/config/schema.py index 2c89f57..0c39a4f 100644 --- a/app-instance/backend/beaver/foundation/config/schema.py +++ b/app-instance/backend/beaver/foundation/config/schema.py @@ -115,6 +115,33 @@ class BackendIdentityConfig: 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) class BeaverConfig: """Config loaded once per backend sandbox instance.""" @@ -126,6 +153,7 @@ class BeaverConfig: authz: AuthzConfig = field(default_factory=AuthzConfig) channels: dict[str, ChannelConfig] = field(default_factory=dict) backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig) + memory: MemoryConfig = field(default_factory=MemoryConfig) config_path: Path | None = None @property diff --git a/app-instance/backend/tests/unit/test_config_loader.py b/app-instance/backend/tests/unit/test_config_loader.py index 1f61cef..63162e6 100644 --- a/app-instance/backend/tests/unit/test_config_loader.py +++ b/app-instance/backend/tests/unit/test_config_loader.py @@ -1,6 +1,7 @@ import json import asyncio +import pytest from fastapi.testclient import TestClient 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.display_name == "个人智能体文件系统工具" 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)