From 827e3434b3edd25239d7e701509646adfc8484cb Mon Sep 17 00:00:00 2001 From: tomtan Date: Mon, 15 Jun 2026 11:19:57 +0800 Subject: [PATCH] docs(memory): document and harden hybrid gateway setup --- app-instance/backend/README.md | 35 +++++++++++++++++++ app-instance/backend/beaver/engine/loader.py | 2 +- .../beaver/foundation/config/loader.py | 7 +++- .../backend/tests/unit/test_config_loader.py | 23 ++++++++++++ .../tests/unit/test_memory_gateway_loader.py | 10 +++++- 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/app-instance/backend/README.md b/app-instance/backend/README.md index c115a1d..e58cc64 100644 --- a/app-instance/backend/README.md +++ b/app-instance/backend/README.md @@ -27,3 +27,38 @@ ## 说明 后端已切到 Beaver 主线,不再保留旧实现、vendored 第三方 runtime 或迁移期旧命名兼容入口。所有 agent 运行都复用 `beaver.engine`,多 agent 协调通过 Beaver 自有 coordinator 和 `ExecutionGraph` 表达。 + +## Memory Gateway + +Curated memory 始终启用:每轮仍会冻结并注入 `MEMORY.md` / `USER.md`,原有 +`memory` 工具也保持可用。`hybrid` 模式会额外启用独立的 Memory Gateway 层, +每轮先调用 `/memories/search`,正常完成后调用一次 `/memories/add`,成功后再调用 +一次 `/memories/flush`。两套存储不会互相同步、覆盖或去重。 + +完整配置示例: + +```json +{ + "memory": { + "mode": "hybrid", + "gateway": { + "baseUrl": "http://127.0.0.1:8010", + "userId": "gateway_test_user", + "userKey": "uk_xxx", + "appId": "default", + "projectId": "default", + "scope": ["current_chat", "resources"], + "topK": 8, + "timeoutSeconds": 10 + } + } +} +``` + +- `memory` 整段缺失时,默认采用隐式 `hybrid`;Gateway 凭证不完整会告警并只运行 curated memory。 +- 显式配置 `"mode": "hybrid"` 时,`baseUrl`、`userId` 和 `userKey` 缺失会导致启动失败。 +- 配置 `"mode": "curated"` 可关闭 Gateway,curated memory 行为不变。 +- `userKey` 是密钥,不应写入日志、状态响应或提交到版本库。 +- 容器访问宿主机 Gateway 时不能使用容器内的 `127.0.0.1`。应让 Gateway 监听 + `0.0.0.0`,并把 `baseUrl` 配成该 Docker 网络的宿主机网关地址。 +- 修改 memory 配置后需要重启 runtime,因为 Gateway 服务在 `EngineLoader` 启动时创建。 diff --git a/app-instance/backend/beaver/engine/loader.py b/app-instance/backend/beaver/engine/loader.py index d68ef54..ad8d1d0 100644 --- a/app-instance/backend/beaver/engine/loader.py +++ b/app-instance/backend/beaver/engine/loader.py @@ -209,13 +209,13 @@ class EngineLoader: """装配当前主链需要的最小 runtime 对象。""" workspace = self.workspace + memory_gateway_service = self._resolve_memory_gateway_service() session_manager = self._session_manager or SessionManager(workspace) curated_root = workspace / "memory" / "curated" curated_memory_store = self._curated_memory_store or MemoryStore(curated_root) memory_service = self._memory_service or MemoryService(curated_root, store=curated_memory_store) memory_service.initialize() - memory_gateway_service = self._resolve_memory_gateway_service() run_memory_store = self._run_memory_store or RunMemoryStore(workspace / "memory" / "runs") skill_learning_store = self._skill_learning_store or SkillLearningStore(workspace / "memory" / "skills") diff --git a/app-instance/backend/beaver/foundation/config/loader.py b/app-instance/backend/beaver/foundation/config/loader.py index bc43272..7d04389 100644 --- a/app-instance/backend/beaver/foundation/config/loader.py +++ b/app-instance/backend/beaver/foundation/config/loader.py @@ -266,13 +266,18 @@ def _parse_memory(data: dict[str, Any]) -> MemoryConfig: parsed_timeout = _float( _first_config_value(gateway_raw.get("timeoutSeconds"), gateway_raw.get("timeout_seconds")) ) + scope = ( + _string_list(gateway_raw.get("scope")) + if "scope" in gateway_raw + else ["current_chat", "resources"] + ) 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"], + scope=scope, 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, ) diff --git a/app-instance/backend/tests/unit/test_config_loader.py b/app-instance/backend/tests/unit/test_config_loader.py index 63162e6..a175e9b 100644 --- a/app-instance/backend/tests/unit/test_config_loader.py +++ b/app-instance/backend/tests/unit/test_config_loader.py @@ -579,6 +579,29 @@ def test_hybrid_memory_rejects_unknown_scope(tmp_path) -> None: load_config(config_path=config_path) +def test_hybrid_memory_rejects_empty_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": [], + }, + } + } + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="scope"): + load_config(config_path=config_path) + + @pytest.mark.parametrize( ("gateway_override", "expected_error"), [ diff --git a/app-instance/backend/tests/unit/test_memory_gateway_loader.py b/app-instance/backend/tests/unit/test_memory_gateway_loader.py index e922172..e3be31b 100644 --- a/app-instance/backend/tests/unit/test_memory_gateway_loader.py +++ b/app-instance/backend/tests/unit/test_memory_gateway_loader.py @@ -68,7 +68,10 @@ def test_loader_implicit_hybrid_without_credentials_warns_and_degrades( loaded.close() -def test_loader_explicit_hybrid_without_credentials_fails_without_secret(tmp_path) -> None: +def test_loader_explicit_hybrid_without_credentials_fails_before_opening_session_store( + tmp_path, + monkeypatch, +) -> None: config = BeaverConfig( memory=MemoryConfig( mode="hybrid", @@ -77,6 +80,11 @@ def test_loader_explicit_hybrid_without_credentials_fails_without_secret(tmp_pat ) ) + monkeypatch.setattr( + "beaver.engine.loader.SessionManager", + lambda workspace: pytest.fail("session store opened before memory config validation"), + ) + with pytest.raises(ValueError) as exc_info: EngineLoader(workspace=tmp_path, config=config).load()