[codex] feat(memory-gateway): merge memory mode with main #2
@ -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",
|
||||
|
||||
@ -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 {}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user