Add Memory Gateway agent plugin
This commit is contained in:
112
README.md
112
README.md
@ -349,6 +349,21 @@ obsidian-vault/Reviews/Queue/
|
||||
integrations/hermes/memory-gateway/
|
||||
```
|
||||
|
||||
安装或更新到本机 Hermes skill 目录:
|
||||
|
||||
```bash
|
||||
mkdir -p /home/tom/.hermes/skills/memory-gateway
|
||||
rsync -a --delete \
|
||||
/home/tom/memory-gateway/integrations/hermes/memory-gateway/ \
|
||||
/home/tom/.hermes/skills/memory-gateway/
|
||||
```
|
||||
|
||||
使用方式:
|
||||
|
||||
- Hermes 对话中可以加载 `memory-gateway` skill,让 agent 按 skill 文档主动调用脚本。
|
||||
- skill 不等于自动记忆;只有 agent 根据 skill/policy 主动调用脚本时才会写入或检索记忆。
|
||||
- 适合人工可控的显式操作:创建用户、检索记忆、追加 episode、commit session、上传知识和检查 EverMemOS。
|
||||
|
||||
主要脚本:
|
||||
|
||||
```text
|
||||
@ -444,12 +459,109 @@ python /home/tom/.hermes/skills/memory-gateway/scripts/memory_search.py \
|
||||
--limit 5
|
||||
```
|
||||
|
||||
## Hermes / OpenClaw Agent Plugin
|
||||
|
||||
通用 Agent plugin 位于:
|
||||
|
||||
```text
|
||||
plugins/memory-gateway-agent/
|
||||
```
|
||||
|
||||
它是独立 adapter,不 import Gateway 内部 `services/repositories/server`,所有调用都通过现有 `/v1` HTTP API。它面向 Hermes/OpenClaw 这类 agent runtime 暴露统一工具:
|
||||
|
||||
- `memory_search`
|
||||
- `memory_append_episode`
|
||||
- `memory_commit_session`
|
||||
- `memory_upsert`
|
||||
- `memory_feedback`
|
||||
|
||||
Hermes 本机安装:
|
||||
|
||||
```bash
|
||||
mkdir -p /home/tom/.hermes/plugins
|
||||
ln -s /home/tom/memory-gateway/plugins/memory-gateway-agent \
|
||||
/home/tom/.hermes/plugins/memory-gateway-agent
|
||||
hermes plugins enable memory-gateway-agent
|
||||
hermes plugins list
|
||||
hermes tools list
|
||||
```
|
||||
|
||||
如果软链接已存在,先确认它指向当前仓库:
|
||||
|
||||
```bash
|
||||
ls -l /home/tom/.hermes/plugins/memory-gateway-agent
|
||||
```
|
||||
|
||||
运行配置:
|
||||
|
||||
```bash
|
||||
export MEMORY_GATEWAY_URL=http://127.0.0.1:1934
|
||||
export MEMORY_GATEWAY_API_KEY=
|
||||
export MEMORY_GATEWAY_DEFAULT_USER_ID=test_user_memory_gateway_plugin
|
||||
export MEMORY_GATEWAY_DEFAULT_AGENT_ID=test_hermes_memory_gateway_plugin
|
||||
export MEMORY_GATEWAY_DEFAULT_WORKSPACE_ID=test_workspace_memory_gateway_plugin
|
||||
export MEMORY_GATEWAY_AUTO_SEARCH=true
|
||||
export MEMORY_GATEWAY_AUTO_APPEND_EPISODE=true
|
||||
export MEMORY_GATEWAY_AUTO_COMMIT_SESSION=false
|
||||
```
|
||||
|
||||
Hermes plugin 已验证:
|
||||
|
||||
- `hermes plugins list` 可发现并启用 `memory-gateway-agent`。
|
||||
- `hermes tools list` 可看到 `memory_gateway` toolset。
|
||||
- `pre_llm_call` 会自动检索 Memory Gateway。
|
||||
- `post_llm_call` 会按 policy 写入摘要型 candidate episode。
|
||||
- `on_session_end` 默认不会 commit;只有 `MEMORY_GATEWAY_AUTO_COMMIT_SESSION=true` 才会 commit。
|
||||
|
||||
真实 Hermes chat 验证:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=/home/tom/memory-gateway/plugins/memory-gateway-agent \
|
||||
python /home/tom/memory-gateway/plugins/memory-gateway-agent/scripts/hermes_interactive_session_check.py
|
||||
```
|
||||
|
||||
插件 E2E 验证:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=/home/tom/memory-gateway/plugins/memory-gateway-agent \
|
||||
python /home/tom/memory-gateway/plugins/memory-gateway-agent/scripts/gateway_e2e_check.py
|
||||
|
||||
PYTHONPATH=/home/tom/memory-gateway/plugins/memory-gateway-agent \
|
||||
python /home/tom/memory-gateway/plugins/memory-gateway-agent/scripts/hermes_hook_probe.py
|
||||
```
|
||||
|
||||
清理测试数据:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=/home/tom/memory-gateway/plugins/memory-gateway-agent \
|
||||
python /home/tom/memory-gateway/plugins/memory-gateway-agent/scripts/cleanup_test_memories.py
|
||||
```
|
||||
|
||||
安全边界:
|
||||
|
||||
- plugin 不保存完整原始对话,只写摘要型 episode。
|
||||
- 默认拒绝 password、token、API key、cookie、private key、完整 transcript 和大段日志。
|
||||
- `memory_upsert` 是高风险长期记忆写入,不会自动触发。
|
||||
- 用户要求 forget/delete 时,应走 `memory_feedback` 或 delete 能力。
|
||||
- hook trace 默认关闭;需要排查时设置 `MEMORY_GATEWAY_PLUGIN_TRACE_HOOKS=true`,只会写入 hook 名称、短 session id、Gateway action 和状态到 `plugins/memory-gateway-agent/.tmp/hook_trace.log`。
|
||||
|
||||
OpenClaw manifest 目前是 best-effort 草案:
|
||||
|
||||
```text
|
||||
plugins/memory-gateway-agent/openclaw.plugin.yaml
|
||||
```
|
||||
|
||||
需要等 OpenClaw runtime 可用后再做第五阶段实测。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
cd /home/tom/memory-gateway
|
||||
source /home/tom/OpenViking/.venv/bin/activate
|
||||
PYTHONPATH=/home/tom/memory-gateway pytest -q
|
||||
|
||||
PYTHONPATH=/home/tom/memory-gateway/plugins/memory-gateway-agent \
|
||||
pytest -q plugins/memory-gateway-agent/tests
|
||||
```
|
||||
|
||||
当前测试覆盖:
|
||||
|
||||
153
plugins/memory-gateway-agent/README.md
Normal file
153
plugins/memory-gateway-agent/README.md
Normal file
@ -0,0 +1,153 @@
|
||||
# Memory Gateway Agent Plugin
|
||||
|
||||
This plugin is an adapter for the existing Memory Gateway. It is not Memory Gateway core and it does not import core service, repository, or server modules.
|
||||
|
||||
The plugin calls the existing HTTP API:
|
||||
|
||||
- `POST /v1/memory/search`
|
||||
- `POST /v1/episodes`
|
||||
- `POST /v1/sessions/{session_id}/commit`
|
||||
- `POST /v1/memory`
|
||||
- `POST /v1/memory/{memory_id}/feedback`
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
|
||||
- `MEMORY_GATEWAY_URL`, default `http://127.0.0.1:1934`
|
||||
- `MEMORY_GATEWAY_API_KEY`, optional
|
||||
- `MEMORY_GATEWAY_DEFAULT_USER_ID`, optional
|
||||
- `MEMORY_GATEWAY_DEFAULT_AGENT_ID`, optional
|
||||
- `MEMORY_GATEWAY_DEFAULT_WORKSPACE_ID`, optional
|
||||
- `MEMORY_GATEWAY_AUTO_SEARCH`, default `true`
|
||||
- `MEMORY_GATEWAY_AUTO_APPEND_EPISODE`, default `true`
|
||||
- `MEMORY_GATEWAY_AUTO_COMMIT_SESSION`, default `false`
|
||||
- `MEMORY_GATEWAY_REVIEW_MODE`, default `true`
|
||||
- `MEMORY_GATEWAY_PLUGIN_DEBUG_RAW`, default `false`
|
||||
- `MEMORY_GATEWAY_PLUGIN_TRACE_HOOKS`, default `false`
|
||||
|
||||
If an API key is configured, the plugin sends `X-API-Key`. It never logs the API key.
|
||||
|
||||
## Validation Status
|
||||
|
||||
| Check | Status |
|
||||
| --- | --- |
|
||||
| Mock unit tests | passed |
|
||||
| Hermes plugin discovery | passed |
|
||||
| Hermes tool registration | passed |
|
||||
| Hermes hook registration | passed |
|
||||
| Gateway E2E | passed |
|
||||
| PluginManager.invoke_hook probe | passed |
|
||||
| Real Hermes interactive session | passed |
|
||||
| OpenClaw runtime validation | pending |
|
||||
|
||||
## Hermes
|
||||
|
||||
Use `hermes.plugin.yaml` as the Hermes-facing manifest. The entrypoint is:
|
||||
|
||||
```text
|
||||
__init__:register
|
||||
```
|
||||
|
||||
The plugin attempts to register tools and best-effort hooks. If the Hermes runtime does not expose hook registration, it still works in tools-only mode.
|
||||
|
||||
Install locally:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.hermes/plugins
|
||||
ln -s /home/tom/memory-gateway/plugins/memory-gateway-agent ~/.hermes/plugins/memory-gateway-agent
|
||||
hermes plugins enable memory-gateway-agent
|
||||
hermes plugins list
|
||||
hermes tools list
|
||||
```
|
||||
|
||||
Example runtime configuration:
|
||||
|
||||
```bash
|
||||
export MEMORY_GATEWAY_URL=http://127.0.0.1:1934
|
||||
export MEMORY_GATEWAY_API_KEY=
|
||||
export MEMORY_GATEWAY_AUTO_SEARCH=true
|
||||
export MEMORY_GATEWAY_AUTO_APPEND_EPISODE=true
|
||||
export MEMORY_GATEWAY_AUTO_COMMIT_SESSION=false
|
||||
```
|
||||
|
||||
## OpenClaw
|
||||
|
||||
`openclaw.plugin.yaml` is a best-effort draft manifest. Adjust field names to the actual OpenClaw runtime schema before production use.
|
||||
|
||||
## Tools-Only Mode
|
||||
|
||||
Agent runtimes can call:
|
||||
|
||||
- `memory_search`
|
||||
- `memory_append_episode`
|
||||
- `memory_commit_session`
|
||||
- `memory_upsert`
|
||||
- `memory_feedback`
|
||||
|
||||
Tools-only mode does not automatically remember anything. The agent policy must decide when to call tools.
|
||||
|
||||
## Lifecycle Hooks
|
||||
|
||||
Best-effort hooks:
|
||||
|
||||
- `on_session_start`: initializes session memory context without writing long-term memory.
|
||||
- `pre_llm_call`: searches memory and returns compact memory context.
|
||||
- `post_llm_call`: appends a safe candidate episode when policy allows it.
|
||||
- `on_session_end`: commits session only when `MEMORY_GATEWAY_AUTO_COMMIT_SESSION=true`.
|
||||
|
||||
Plugin support for hooks depends on the agent runtime context API. This plugin should be described as tools-only plus best-effort hooks unless the target runtime has been verified.
|
||||
|
||||
Verified boundaries:
|
||||
|
||||
- Tools-only is usable through the registered `memory_gateway` toolset.
|
||||
- `pre_llm_call` automatic search has passed hook-probe and real `hermes chat -Q -q` validation.
|
||||
- `post_llm_call` automatic candidate episode append has passed hook-probe and real `hermes chat -Q -q` validation.
|
||||
- `on_session_end` auto commit is off by default, stays off when `MEMORY_GATEWAY_AUTO_COMMIT_SESSION=false`, and commits when `MEMORY_GATEWAY_AUTO_COMMIT_SESSION=true`.
|
||||
|
||||
## Defaults
|
||||
|
||||
- Automatic search can be enabled.
|
||||
- Automatic append episode can be enabled.
|
||||
- Automatic commit is disabled by default.
|
||||
- Automatic direct long-term upsert is disabled by default.
|
||||
|
||||
## Safety And Privacy
|
||||
|
||||
The plugin rejects memory writes containing passwords, API keys, bearer tokens, cookies, private keys, SSH keys, one-time verification codes, large logs, full raw transcripts, and chain-of-thought.
|
||||
|
||||
The plugin writes summarized candidate episodes. It does not store full raw conversations. Long-term memory should normally be produced by `memory_commit_session`, allowing Memory Gateway and EverMemOS to deduplicate, detect conflicts, and route review drafts.
|
||||
|
||||
Direct long-term `memory_upsert` is high risk and is not called automatically. If a user asks to forget or delete a memory, the agent should call `memory_feedback` or a delete-capable tool instead of silently keeping the memory.
|
||||
|
||||
Script output is redacted by default: no API key, headers, cookies, tokens, or raw result payloads are printed. Set `MEMORY_GATEWAY_PLUGIN_DEBUG_RAW=true` only for local debugging with non-sensitive test data.
|
||||
|
||||
Hook trace is disabled by default. Set `MEMORY_GATEWAY_PLUGIN_TRACE_HOOKS=true` to write minimal hook events to `plugins/memory-gateway-agent/.tmp/hook_trace.log`. Trace entries contain hook name, timestamp, shortened session id, Gateway action, and status only; they do not include user or assistant message bodies.
|
||||
|
||||
## Cleanup Test Data
|
||||
|
||||
Integration tests use:
|
||||
|
||||
- `user_id=test_user_memory_gateway_plugin`
|
||||
- tags such as `integration_test`, `plugin`, and `safe_to_delete`
|
||||
|
||||
Run cleanup:
|
||||
|
||||
```bash
|
||||
cd /home/tom/memory-gateway
|
||||
PYTHONPATH=plugins/memory-gateway-agent python plugins/memory-gateway-agent/scripts/cleanup_test_memories.py
|
||||
```
|
||||
|
||||
The cleanup script refuses non-`test_user_` users. It first tries `DELETE /v1/memory/{memory_id}` for local test memories. If deletion fails, it falls back to `memory_feedback` with `incorrect`. Current cleanup is limited by the search API: it can only clean local `MemoryRecord` rows returned by search, not arbitrary OpenViking context rows.
|
||||
|
||||
## Local Smoke Test
|
||||
|
||||
```bash
|
||||
cd /home/tom/memory-gateway
|
||||
PYTHONPATH=plugins/memory-gateway-agent python plugins/memory-gateway-agent/scripts/health.py
|
||||
PYTHONPATH=plugins/memory-gateway-agent python plugins/memory-gateway-agent/scripts/smoke_test.py
|
||||
PYTHONPATH=plugins/memory-gateway-agent python plugins/memory-gateway-agent/scripts/hermes_smoke_check.py
|
||||
PYTHONPATH=plugins/memory-gateway-agent python plugins/memory-gateway-agent/scripts/gateway_e2e_check.py
|
||||
PYTHONPATH=plugins/memory-gateway-agent python plugins/memory-gateway-agent/scripts/hermes_hook_probe.py
|
||||
PYTHONPATH=plugins/memory-gateway-agent python plugins/memory-gateway-agent/scripts/hermes_interactive_session_check.py
|
||||
```
|
||||
120
plugins/memory-gateway-agent/__init__.py
Normal file
120
plugins/memory-gateway-agent/__init__.py
Normal file
@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import schemas, tools
|
||||
from .memory_gateway_plugin.config import load_config
|
||||
from .memory_gateway_plugin import lifecycle
|
||||
from .memory_gateway_plugin.trace import trace_hook
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_PLUGIN_ROOT = Path(__file__).resolve().parent
|
||||
if str(_PLUGIN_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_PLUGIN_ROOT))
|
||||
|
||||
import schemas # type: ignore[no-redef]
|
||||
import tools # type: ignore[no-redef]
|
||||
from memory_gateway_plugin.config import load_config # type: ignore[no-redef]
|
||||
from memory_gateway_plugin import lifecycle # type: ignore[no-redef]
|
||||
from memory_gateway_plugin.trace import trace_hook # type: ignore[no-redef]
|
||||
|
||||
TOOLSET = "memory_gateway"
|
||||
_LAST_USER_MESSAGES: dict[str, str] = {}
|
||||
|
||||
|
||||
def _context_from_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
cfg = load_config()
|
||||
return {
|
||||
"user_id": kwargs.get("user_id") or kwargs.get("user") or cfg.default_user_id,
|
||||
"agent_id": kwargs.get("agent_id") or kwargs.get("agent") or cfg.default_agent_id or "hermes_agent",
|
||||
"workspace_id": kwargs.get("workspace_id") or kwargs.get("workspace") or cfg.default_workspace_id,
|
||||
"session_id": kwargs.get("session_id") or kwargs.get("task_id") or "",
|
||||
"user_message": kwargs.get("user_message") or kwargs.get("prompt") or kwargs.get("message") or "",
|
||||
"assistant_response": kwargs.get("assistant_response") or kwargs.get("response") or "",
|
||||
"conversation_history": kwargs.get("conversation_history") or [],
|
||||
"model": kwargs.get("model") or "",
|
||||
"platform": kwargs.get("platform") or "",
|
||||
}
|
||||
|
||||
|
||||
def on_session_start(**kwargs: Any) -> dict[str, Any]:
|
||||
session_id = kwargs.get("session_id") or kwargs.get("task_id") or ""
|
||||
trace_hook("on_session_start", session_id=str(session_id), gateway_action="", gateway_called=False, ok=True)
|
||||
return {
|
||||
"status": "ok",
|
||||
"memory_gateway": "session_initialized",
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
|
||||
def pre_llm_call(**kwargs: Any) -> dict[str, str]:
|
||||
context = _context_from_kwargs(kwargs)
|
||||
if context.get("session_id") and context.get("user_message"):
|
||||
_LAST_USER_MESSAGES[context["session_id"]] = context["user_message"]
|
||||
result = lifecycle.on_conversation_start(context)
|
||||
trace_hook(
|
||||
"pre_llm_call",
|
||||
session_id=context.get("session_id", ""),
|
||||
gateway_action="memory_search",
|
||||
gateway_called=bool(result.get("raw")),
|
||||
ok=bool(result.get("ok")),
|
||||
reason=str(result.get("error") or result.get("reason") or ""),
|
||||
)
|
||||
if not result.get("ok") or not result.get("memory_context"):
|
||||
return {}
|
||||
return {"context": "Relevant Memory Gateway context:\n" + result["memory_context"]}
|
||||
|
||||
|
||||
def post_llm_call(**kwargs: Any) -> dict[str, Any] | None:
|
||||
context = _context_from_kwargs(kwargs)
|
||||
if not context.get("user_message") and context.get("session_id"):
|
||||
context["user_message"] = _LAST_USER_MESSAGES.get(context["session_id"], "")
|
||||
result = lifecycle.after_user_message(context)
|
||||
trace_hook(
|
||||
"post_llm_call",
|
||||
session_id=context.get("session_id", ""),
|
||||
gateway_action="append_episode",
|
||||
gateway_called=bool(result.get("raw")),
|
||||
ok=bool(result.get("ok")),
|
||||
reason=str(result.get("error") or result.get("reason") or ""),
|
||||
)
|
||||
if result.get("ok"):
|
||||
return None
|
||||
return {"memory_gateway_error": result.get("error") or result.get("reason") or "append_failed"}
|
||||
|
||||
|
||||
def on_session_end(**kwargs: Any) -> dict[str, Any] | None:
|
||||
context = _context_from_kwargs(kwargs)
|
||||
result = lifecycle.on_session_end(context)
|
||||
trace_hook(
|
||||
"on_session_end",
|
||||
session_id=context.get("session_id", ""),
|
||||
gateway_action="commit_session",
|
||||
gateway_called=bool(result.get("raw")),
|
||||
ok=bool(result.get("ok")),
|
||||
reason=str(result.get("error") or result.get("reason") or ""),
|
||||
)
|
||||
if context.get("session_id"):
|
||||
_LAST_USER_MESSAGES.pop(context["session_id"], None)
|
||||
if result.get("ok"):
|
||||
return None
|
||||
return {"memory_gateway_error": result.get("error") or "commit_failed"}
|
||||
|
||||
|
||||
def register(ctx: Any) -> None:
|
||||
for name, schema in schemas.TOOL_SCHEMAS.items():
|
||||
ctx.register_tool(
|
||||
name=name,
|
||||
toolset=TOOLSET,
|
||||
schema=schema,
|
||||
handler=tools.HANDLERS[name],
|
||||
)
|
||||
|
||||
if hasattr(ctx, "register_hook"):
|
||||
ctx.register_hook("on_session_start", on_session_start)
|
||||
ctx.register_hook("pre_llm_call", pre_llm_call)
|
||||
ctx.register_hook("post_llm_call", post_llm_call)
|
||||
ctx.register_hook("on_session_end", on_session_end)
|
||||
41
plugins/memory-gateway-agent/hermes.plugin.yaml
Normal file
41
plugins/memory-gateway-agent/hermes.plugin.yaml
Normal file
@ -0,0 +1,41 @@
|
||||
name: memory-gateway-agent
|
||||
runtime: hermes
|
||||
version: 0.1.0
|
||||
description: Hermes plugin adapter for Memory Gateway v1. Provides tools-only mode plus best-effort lifecycle hooks.
|
||||
entrypoint: register
|
||||
provides_tools:
|
||||
- memory_search
|
||||
- memory_append_episode
|
||||
- memory_commit_session
|
||||
- memory_upsert
|
||||
- memory_feedback
|
||||
provides_hooks:
|
||||
- on_session_start
|
||||
- pre_llm_call
|
||||
- post_llm_call
|
||||
- on_session_end
|
||||
env:
|
||||
MEMORY_GATEWAY_URL: http://127.0.0.1:1934
|
||||
MEMORY_GATEWAY_AUTO_SEARCH: "true"
|
||||
MEMORY_GATEWAY_AUTO_APPEND_EPISODE: "true"
|
||||
MEMORY_GATEWAY_AUTO_COMMIT_SESSION: "false"
|
||||
tools:
|
||||
memory_search:
|
||||
description: Search Memory Gateway with user/agent/workspace/session ACL.
|
||||
memory_append_episode:
|
||||
description: Append a safe summarized candidate episode.
|
||||
memory_commit_session:
|
||||
description: Ask Gateway/EverMemOS to consolidate session episodes.
|
||||
memory_upsert:
|
||||
description: Upsert a stable memory through Gateway.
|
||||
memory_feedback:
|
||||
description: Send feedback for a memory record.
|
||||
hooks:
|
||||
on_session_start: __init__:on_session_start
|
||||
pre_llm_call: __init__:pre_llm_call
|
||||
post_llm_call: __init__:post_llm_call
|
||||
on_session_end: __init__:on_session_end
|
||||
notes:
|
||||
- Hooks are best-effort and depend on the Hermes runtime context API.
|
||||
- Without hook support, the plugin remains usable as tools-only.
|
||||
- This plugin does not store full raw conversations.
|
||||
@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
|
||||
from . import lifecycle
|
||||
from .tools import memory_append_episode, memory_commit_session, memory_feedback, memory_search, memory_upsert
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"register",
|
||||
"memory_search",
|
||||
"memory_append_episode",
|
||||
"memory_commit_session",
|
||||
"memory_upsert",
|
||||
"memory_feedback",
|
||||
]
|
||||
|
||||
|
||||
TOOLS: dict[str, Callable[..., dict[str, Any]]] = {
|
||||
"memory_search": memory_search,
|
||||
"memory_append_episode": memory_append_episode,
|
||||
"memory_commit_session": memory_commit_session,
|
||||
"memory_upsert": memory_upsert,
|
||||
"memory_feedback": memory_feedback,
|
||||
}
|
||||
|
||||
|
||||
def _try_call(target: Any, method_names: list[str], *args: Any, **kwargs: Any) -> bool:
|
||||
for name in method_names:
|
||||
method = getattr(target, name, None)
|
||||
if callable(method):
|
||||
try:
|
||||
method(*args, **kwargs)
|
||||
return True
|
||||
except TypeError:
|
||||
try:
|
||||
method(args[0], args[1])
|
||||
return True
|
||||
except Exception as exc:
|
||||
_logger.debug("[_try_call] %s(%s, %s) failed: %s", name, args, kwargs, exc)
|
||||
return False
|
||||
except Exception as exc:
|
||||
_logger.debug("[_try_call] %s(%s, %s) failed: %s", name, args, kwargs, exc)
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def register(ctx: Any) -> dict[str, Any]:
|
||||
registered_tools: list[str] = []
|
||||
registered_hooks: list[str] = []
|
||||
|
||||
for name, func in TOOLS.items():
|
||||
if _try_call(ctx, ["register_tool", "add_tool", "tool"], name, func):
|
||||
registered_tools.append(name)
|
||||
|
||||
hook_map = {
|
||||
"pre_llm_call": lifecycle.on_conversation_start,
|
||||
"post_llm_call": lifecycle.after_user_message,
|
||||
"session_end": lifecycle.on_session_end,
|
||||
"after_task_complete": lifecycle.after_task_complete,
|
||||
}
|
||||
for name, func in hook_map.items():
|
||||
if _try_call(ctx, ["register_hook", "add_hook", "hook"], name, func):
|
||||
registered_hooks.append(name)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": "tools-and-hooks" if registered_hooks else "tools-only" if registered_tools else "manual",
|
||||
"registered_tools": registered_tools,
|
||||
"registered_hooks": registered_hooks,
|
||||
}
|
||||
|
||||
101
plugins/memory-gateway-agent/memory_gateway_plugin/client.py
Normal file
101
plugins/memory-gateway-agent/memory_gateway_plugin/client.py
Normal file
@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from .config import PluginConfig, load_config
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _short_error(value: Any, max_chars: int = 500) -> str:
|
||||
text = str(value).replace("\n", " ").strip()
|
||||
return text[:max_chars]
|
||||
|
||||
|
||||
class MemoryGatewayClient:
|
||||
def __init__(self, config: PluginConfig | None = None) -> None:
|
||||
self.config = config or load_config()
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.config.api_key:
|
||||
headers["X-API-Key"] = self.config.api_key
|
||||
return headers
|
||||
|
||||
def _post(self, endpoint: str, payload: dict[str, Any], retries: int = 3, backoff: float = 1.0) -> dict[str, Any]:
|
||||
url = self.config.gateway_url.rstrip("/") + endpoint
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(retries):
|
||||
request = urllib.request.Request(url, data=body, headers=self._headers(), method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.config.timeout) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
data = json.loads(raw) if raw else {}
|
||||
return {
|
||||
"ok": True,
|
||||
"status_code": getattr(response, "status", 200),
|
||||
"endpoint": endpoint,
|
||||
"data": data,
|
||||
}
|
||||
except urllib.error.HTTPError as exc:
|
||||
# Typically, client errors (4xx) shouldn't be retried unless specifically handled.
|
||||
# Since HTTPError is a subclass of URLError, we catch it first.
|
||||
if exc.code < 500 and exc.code != 429:
|
||||
try:
|
||||
body_text = exc.read().decode("utf-8")
|
||||
except Exception:
|
||||
body_text = exc.reason
|
||||
_logger.error(f"HTTPError in _post to {endpoint}: {exc.code} {body_text}")
|
||||
return {
|
||||
"ok": False,
|
||||
"status_code": exc.code,
|
||||
"endpoint": endpoint,
|
||||
"error": _short_error(body_text),
|
||||
}
|
||||
last_error = exc
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
last_error = exc
|
||||
except Exception as exc:
|
||||
_logger.error("Unexpected error in _post to %s: %s", endpoint, exc, exc_info=True)
|
||||
return {
|
||||
"ok": False,
|
||||
"status_code": None,
|
||||
"endpoint": endpoint,
|
||||
"error": _short_error(exc),
|
||||
}
|
||||
|
||||
if attempt < retries - 1:
|
||||
time.sleep(backoff * (2 ** attempt))
|
||||
|
||||
# Exhausted retries
|
||||
error_msg = str(last_error) if last_error else "Max retries exceeded"
|
||||
_logger.error("Failed _post to %s after %d attempts. Last error: %s", endpoint, retries, last_error)
|
||||
return {
|
||||
"ok": False,
|
||||
"status_code": None,
|
||||
"endpoint": endpoint,
|
||||
"error": error_msg,
|
||||
}
|
||||
|
||||
def search_memory(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._post("/v1/memory/search", payload)
|
||||
|
||||
def append_episode(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._post("/v1/episodes", payload)
|
||||
|
||||
def commit_session(self, session_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._post(f"/v1/sessions/{session_id}/commit", payload)
|
||||
|
||||
def upsert_memory(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._post("/v1/memory", payload)
|
||||
|
||||
def send_feedback(self, memory_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._post(f"/v1/memory/{memory_id}/feedback", payload)
|
||||
|
||||
50
plugins/memory-gateway-agent/memory_gateway_plugin/config.py
Normal file
50
plugins/memory-gateway-agent/memory_gateway_plugin/config.py
Normal file
@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginConfig:
|
||||
gateway_url: str = "http://127.0.0.1:1934"
|
||||
api_key: str = ""
|
||||
default_user_id: str = ""
|
||||
default_agent_id: str = ""
|
||||
default_workspace_id: str = ""
|
||||
auto_search: bool = True
|
||||
auto_append_episode: bool = True
|
||||
auto_commit_session: bool = False
|
||||
review_mode: bool = True
|
||||
timeout: int = 30
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "PluginConfig":
|
||||
try:
|
||||
timeout_val = int(os.environ.get("MEMORY_GATEWAY_TIMEOUT", "30"))
|
||||
except ValueError:
|
||||
timeout_val = 30
|
||||
|
||||
return cls(
|
||||
gateway_url=os.environ.get("MEMORY_GATEWAY_URL", cls.gateway_url).rstrip("/"),
|
||||
api_key=os.environ.get("MEMORY_GATEWAY_API_KEY", ""),
|
||||
default_user_id=os.environ.get("MEMORY_GATEWAY_DEFAULT_USER_ID", ""),
|
||||
default_agent_id=os.environ.get("MEMORY_GATEWAY_DEFAULT_AGENT_ID", ""),
|
||||
default_workspace_id=os.environ.get("MEMORY_GATEWAY_DEFAULT_WORKSPACE_ID", ""),
|
||||
auto_search=_env_bool("MEMORY_GATEWAY_AUTO_SEARCH", True),
|
||||
auto_append_episode=_env_bool("MEMORY_GATEWAY_AUTO_APPEND_EPISODE", True),
|
||||
auto_commit_session=_env_bool("MEMORY_GATEWAY_AUTO_COMMIT_SESSION", False),
|
||||
review_mode=_env_bool("MEMORY_GATEWAY_REVIEW_MODE", True),
|
||||
timeout=timeout_val,
|
||||
)
|
||||
|
||||
|
||||
def load_config() -> PluginConfig:
|
||||
return PluginConfig.from_env()
|
||||
|
||||
109
plugins/memory-gateway-agent/memory_gateway_plugin/lifecycle.py
Normal file
109
plugins/memory-gateway-agent/memory_gateway_plugin/lifecycle.py
Normal file
@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .client import MemoryGatewayClient
|
||||
from .config import PluginConfig, load_config
|
||||
from .policy import build_episode_summary, should_append_episode, should_commit_session, should_search_memory
|
||||
from .tools import memory_append_episode, memory_commit_session, memory_search
|
||||
|
||||
|
||||
def _get(context: dict[str, Any], key: str, default: str = "") -> str:
|
||||
value = context.get(key, default)
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def compact_memory_context(search_result: dict[str, Any], limit: int = 5) -> str:
|
||||
if not search_result.get("ok"):
|
||||
return ""
|
||||
data = search_result.get("data", {})
|
||||
rows = []
|
||||
for item in data.get("results", [])[:limit]:
|
||||
memory = item.get("memory") or item.get("openviking") or {}
|
||||
summary = memory.get("summary") or memory.get("abstract") or memory.get("content") or ""
|
||||
namespace = memory.get("namespace", "")
|
||||
memory_id = memory.get("id") or memory.get("uri") or ""
|
||||
if summary:
|
||||
rows.append(f"- {memory_id} [{namespace}]: {summary[:240]}")
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def on_conversation_start(context: dict[str, Any], client: MemoryGatewayClient | None = None, config: PluginConfig | None = None) -> dict[str, Any]:
|
||||
cfg = config or load_config()
|
||||
user_message = _get(context, "user_message") or _get(context, "query")
|
||||
if not should_search_memory(user_message, context, cfg):
|
||||
return {"ok": True, "memory_context": ""}
|
||||
user_id = _get(context, "user_id", cfg.default_user_id)
|
||||
if not user_id:
|
||||
return {"ok": False, "error": "user_id_required"}
|
||||
try:
|
||||
limit_val = int(context.get("limit", 5))
|
||||
except (ValueError, TypeError):
|
||||
limit_val = 5
|
||||
|
||||
result = memory_search(
|
||||
query=user_message,
|
||||
user_id=user_id,
|
||||
agent_id=_get(context, "agent_id", cfg.default_agent_id),
|
||||
workspace_id=_get(context, "workspace_id", cfg.default_workspace_id),
|
||||
session_id=_get(context, "session_id"),
|
||||
limit=limit_val,
|
||||
client=client,
|
||||
)
|
||||
return {"ok": result.get("ok", False), "memory_context": compact_memory_context(result), "raw": result}
|
||||
|
||||
|
||||
def after_user_message(context: dict[str, Any], client: MemoryGatewayClient | None = None, config: PluginConfig | None = None) -> dict[str, Any]:
|
||||
cfg = config or load_config()
|
||||
user_message = _get(context, "user_message")
|
||||
assistant_response = _get(context, "assistant_response")
|
||||
if not should_append_episode(user_message, assistant_response, context, cfg):
|
||||
return {"ok": True, "appended": False, "reason": "policy_skip"}
|
||||
user_id = _get(context, "user_id", cfg.default_user_id)
|
||||
session_id = _get(context, "session_id")
|
||||
if not user_id or not session_id:
|
||||
return {"ok": False, "error": "user_id_and_session_id_required"}
|
||||
summary = build_episode_summary(user_message, assistant_response, context)
|
||||
result = memory_append_episode(
|
||||
user_id=user_id,
|
||||
agent_id=_get(context, "agent_id", cfg.default_agent_id),
|
||||
workspace_id=_get(context, "workspace_id", cfg.default_workspace_id),
|
||||
session_id=session_id,
|
||||
episode_summary=summary,
|
||||
tags=["plugin-candidate"],
|
||||
client=client,
|
||||
)
|
||||
return {"ok": result.get("ok", False), "appended": result.get("ok", False), "raw": result}
|
||||
|
||||
|
||||
def after_task_complete(context: dict[str, Any], client: MemoryGatewayClient | None = None, config: PluginConfig | None = None) -> dict[str, Any]:
|
||||
return _maybe_commit(context, client, config)
|
||||
|
||||
|
||||
def on_session_end(context: dict[str, Any], client: MemoryGatewayClient | None = None, config: PluginConfig | None = None) -> dict[str, Any]:
|
||||
return _maybe_commit(context, client, config)
|
||||
|
||||
|
||||
def _maybe_commit(context: dict[str, Any], client: MemoryGatewayClient | None, config: PluginConfig | None) -> dict[str, Any]:
|
||||
cfg = config or load_config()
|
||||
if not should_commit_session(context, cfg):
|
||||
return {"ok": True, "committed": False, "reason": "auto_commit_disabled"}
|
||||
user_id = _get(context, "user_id", cfg.default_user_id)
|
||||
session_id = _get(context, "session_id")
|
||||
if not user_id or not session_id:
|
||||
return {"ok": False, "error": "user_id_and_session_id_required"}
|
||||
try:
|
||||
min_importance_val = float(context.get("min_importance", 0.6))
|
||||
except (ValueError, TypeError):
|
||||
min_importance_val = 0.6
|
||||
|
||||
result = memory_commit_session(
|
||||
user_id=user_id,
|
||||
agent_id=_get(context, "agent_id", cfg.default_agent_id),
|
||||
workspace_id=_get(context, "workspace_id", cfg.default_workspace_id),
|
||||
session_id=session_id,
|
||||
min_importance=min_importance_val,
|
||||
client=client,
|
||||
)
|
||||
return {"ok": result.get("ok", False), "committed": result.get("ok", False), "raw": result}
|
||||
|
||||
86
plugins/memory-gateway-agent/memory_gateway_plugin/output.py
Normal file
86
plugins/memory-gateway-agent/memory_gateway_plugin/output.py
Normal file
@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
SENSITIVE_KEYS = ("api_key", "apikey", "authorization", "token", "cookie", "secret", "password", "x-api-key")
|
||||
|
||||
|
||||
def debug_raw_enabled() -> bool:
|
||||
return os.environ.get("MEMORY_GATEWAY_PLUGIN_DEBUG_RAW", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def short_id(value: Any, prefix: int = 8, suffix: int = 4) -> str:
|
||||
text = "" if value is None else str(value)
|
||||
if len(text) <= prefix + suffix + 3:
|
||||
return text
|
||||
return f"{text[:prefix]}...{text[-suffix:]}"
|
||||
|
||||
|
||||
import re
|
||||
|
||||
def redact(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: ("<redacted>" if key.lower() in SENSITIVE_KEYS else redact(item))
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [redact(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
lowered = value.lower()
|
||||
sensitive_markers = ("api_key=", "password=", "token=", "bearer ", "cookie:", "private key")
|
||||
if any(marker in lowered for marker in sensitive_markers):
|
||||
return "<redacted>"
|
||||
return value
|
||||
|
||||
|
||||
def summarize_data(data: Any) -> Any:
|
||||
if debug_raw_enabled():
|
||||
return redact(data)
|
||||
if isinstance(data, list):
|
||||
return {"count": len(data)}
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
if "results" in data:
|
||||
return {
|
||||
"count": len(data.get("results") or []),
|
||||
"total": data.get("total"),
|
||||
"local_total": data.get("local_total"),
|
||||
"openviking_total": data.get("openviking_total"),
|
||||
"searched_namespaces": data.get("searched_namespaces", []),
|
||||
}
|
||||
if "id" in data:
|
||||
return {
|
||||
"id": short_id(data.get("id")),
|
||||
"namespace": data.get("namespace"),
|
||||
"memory_type": data.get("memory_type"),
|
||||
"source": data.get("source"),
|
||||
}
|
||||
if "memory_id" in data:
|
||||
return {"status": data.get("status"), "memory_id": short_id(data.get("memory_id")), "feedback": data.get("feedback")}
|
||||
if "promoted" in data or "consolidation" in data:
|
||||
return {
|
||||
"status": data.get("status"),
|
||||
"promoted_count": len(data.get("promoted") or []),
|
||||
"archived_count": len(data.get("archived_episode_ids") or []),
|
||||
"consolidation_status": (data.get("consolidation") or {}).get("status") if isinstance(data.get("consolidation"), dict) else None,
|
||||
}
|
||||
allowed = {"ok", "status", "gateway", "service", "version", "healthy", "endpoint", "status_code", "error", "count"}
|
||||
return {key: redact(value) for key, value in data.items() if key in allowed}
|
||||
|
||||
|
||||
def summarize_result(result: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": bool(result.get("ok")),
|
||||
"endpoint": result.get("endpoint"),
|
||||
"status_code": result.get("status_code"),
|
||||
"error": redact(result.get("error", "")),
|
||||
"data": summarize_data(result.get("data")),
|
||||
}
|
||||
|
||||
|
||||
def dumps_safe(payload: Any, *, indent: int = 2) -> str:
|
||||
return json.dumps(redact(payload), ensure_ascii=False, indent=indent, default=str)
|
||||
59
plugins/memory-gateway-agent/memory_gateway_plugin/policy.py
Normal file
59
plugins/memory-gateway-agent/memory_gateway_plugin/policy.py
Normal file
@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .config import PluginConfig, load_config
|
||||
from .safety import validate_memory_write
|
||||
|
||||
|
||||
REMEMBER_RE = re.compile(r"记住|请保存|remember this|save this|keep in memory", re.I)
|
||||
STABLE_SIGNAL_RE = re.compile(
|
||||
r"偏好|长期|约束|架构决策|决策|结论|workflow|工作流|preference|constraint|decision|always|以后都|project fact",
|
||||
re.I,
|
||||
)
|
||||
SMALL_TALK_RE = re.compile(r"^\s*(你好|hi|hello|谢谢|thanks|ok|好的|收到|再见)[。.!!\s\w]*$", re.I)
|
||||
|
||||
|
||||
def should_search_memory(user_message: str, context: dict[str, Any] | None = None, config: PluginConfig | None = None) -> bool:
|
||||
cfg = config or load_config()
|
||||
if not cfg.auto_search:
|
||||
return False
|
||||
return bool(user_message and user_message.strip())
|
||||
|
||||
|
||||
def should_append_episode(
|
||||
user_message: str,
|
||||
assistant_response: str = "",
|
||||
context: dict[str, Any] | None = None,
|
||||
config: PluginConfig | None = None,
|
||||
) -> bool:
|
||||
cfg = config or load_config()
|
||||
if not cfg.auto_append_episode:
|
||||
return False
|
||||
combined = "\n".join(part for part in [user_message, assistant_response] if part)
|
||||
if not combined.strip() or SMALL_TALK_RE.match(combined.strip()):
|
||||
return False
|
||||
if not validate_memory_write(combined)["allowed"]:
|
||||
return False
|
||||
return bool(REMEMBER_RE.search(combined) or STABLE_SIGNAL_RE.search(combined))
|
||||
|
||||
|
||||
def build_episode_summary(user_message: str, assistant_response: str = "", context: dict[str, Any] | None = None) -> str:
|
||||
parts = []
|
||||
if REMEMBER_RE.search(user_message or ""):
|
||||
parts.append(f"用户明确要求记住:{user_message.strip()}")
|
||||
elif user_message:
|
||||
parts.append(f"用户输入中的可复用信息:{user_message.strip()}")
|
||||
if assistant_response and STABLE_SIGNAL_RE.search(assistant_response):
|
||||
parts.append(f"助手结论:{assistant_response.strip()}")
|
||||
summary = " ".join(parts).strip()
|
||||
return summary[:1000]
|
||||
|
||||
|
||||
def should_commit_session(context: dict[str, Any] | None = None, config: PluginConfig | None = None) -> bool:
|
||||
cfg = config or load_config()
|
||||
if cfg.auto_commit_session:
|
||||
return True
|
||||
return bool((context or {}).get("force_commit"))
|
||||
|
||||
97
plugins/memory-gateway-agent/memory_gateway_plugin/safety.py
Normal file
97
plugins/memory-gateway-agent/memory_gateway_plugin/safety.py
Normal file
@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
SECRET_PATTERNS = [
|
||||
r"\bpassword\s*[:=]",
|
||||
r"\bapi[_-]?key\s*[:=]",
|
||||
r"\btoken\s*[:=]",
|
||||
r"\bsecret\s*[:=]",
|
||||
r"\bbearer\s+[a-z0-9._\-]{12,}",
|
||||
r"\bcookie\s*[:=]",
|
||||
r"\bsession[_ -]?id\s*[:=]",
|
||||
r"-----BEGIN [A-Z ]*PRIVATE KEY-----",
|
||||
r"\bssh-rsa\s+[a-z0-9+/=]{40,}",
|
||||
r"\bone[- ]?time (?:password|code)\b",
|
||||
r"\botp\s*[:=]?\s*\d{4,8}\b",
|
||||
r"\b验证码\s*[::]?\s*\d{4,8}\b",
|
||||
]
|
||||
|
||||
CHAT_LINE_RE = re.compile(r"^\s*(user|assistant|system|用户|助手|模型|human|ai)\s*[::]", re.I)
|
||||
LOG_LINE_RE = re.compile(r"\b(ERROR|WARN|INFO|DEBUG|TRACE)\b|^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}")
|
||||
CHAIN_OF_THOUGHT_RE = re.compile(r"chain[- ]of[- ]thought|逐步推理|隐藏推理|internal reasoning", re.I)
|
||||
|
||||
|
||||
def detect_secret(content: str) -> tuple[bool, str]:
|
||||
for pattern in SECRET_PATTERNS:
|
||||
if re.search(pattern, content, re.I):
|
||||
return True, "secret_like_content"
|
||||
return False, ""
|
||||
|
||||
|
||||
def detect_raw_transcript(content: str) -> tuple[bool, str]:
|
||||
lines = [line for line in content.splitlines() if line.strip()]
|
||||
chat_lines = sum(1 for line in lines if CHAT_LINE_RE.search(line))
|
||||
if chat_lines >= 4:
|
||||
return True, "raw_chat_transcript"
|
||||
if "完整原始对话" in content or "full transcript" in content.lower():
|
||||
return True, "raw_chat_transcript"
|
||||
return False, ""
|
||||
|
||||
|
||||
def detect_large_log(content: str) -> tuple[bool, str]:
|
||||
lines = [line for line in content.splitlines() if line.strip()]
|
||||
log_lines = sum(1 for line in lines if LOG_LINE_RE.search(line))
|
||||
if len(content) > 4000 or len(lines) > 40 or log_lines >= 8:
|
||||
return True, "large_or_raw_log"
|
||||
return False, ""
|
||||
|
||||
|
||||
def detect_low_value_memory(content: str) -> tuple[bool, str]:
|
||||
normalized = re.sub(r"\s+", " ", content).strip().lower()
|
||||
stable_signal = re.search(r"记住|偏好|长期|决策|结论|约束|preference|remember|decision|constraint", normalized, re.I)
|
||||
if stable_signal:
|
||||
return False, ""
|
||||
if len(normalized) < 12:
|
||||
return True, "too_short"
|
||||
small_talk = {
|
||||
"hi",
|
||||
"hello",
|
||||
"thanks",
|
||||
"thank you",
|
||||
"ok",
|
||||
"好的",
|
||||
"谢谢",
|
||||
"你好",
|
||||
"收到",
|
||||
"再见",
|
||||
}
|
||||
if normalized in small_talk:
|
||||
return True, "small_talk"
|
||||
return False, ""
|
||||
|
||||
|
||||
def sanitize_memory_content(content: str) -> str:
|
||||
sanitized = content.strip()
|
||||
sanitized = re.sub(r"\b(password|api[_-]?key|token|secret)\s*[:=]\s*\S+", r"\1=<redacted>", sanitized, flags=re.I)
|
||||
sanitized = re.sub(r"\bbearer\s+[a-z0-9._\-]{12,}", "Bearer <redacted>", sanitized, flags=re.I)
|
||||
sanitized = re.sub(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", "<redacted-private-key>", sanitized, flags=re.I | re.S)
|
||||
return sanitized
|
||||
|
||||
|
||||
def validate_memory_write(content: str, *, allow_low_value: bool = False) -> dict[str, Any]:
|
||||
if not content or not content.strip():
|
||||
return {"allowed": False, "reason": "empty_content", "sanitized_content": ""}
|
||||
checks = [detect_secret, detect_raw_transcript, detect_large_log]
|
||||
for check in checks:
|
||||
blocked, reason = check(content)
|
||||
if blocked:
|
||||
return {"allowed": False, "reason": reason, "sanitized_content": ""}
|
||||
if CHAIN_OF_THOUGHT_RE.search(content):
|
||||
return {"allowed": False, "reason": "chain_of_thought", "sanitized_content": ""}
|
||||
low_value, reason = detect_low_value_memory(content)
|
||||
if low_value and not allow_low_value:
|
||||
return {"allowed": False, "reason": reason, "sanitized_content": ""}
|
||||
return {"allowed": True, "reason": "ok", "sanitized_content": sanitize_memory_content(content)}
|
||||
@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentContext:
|
||||
user_id: str
|
||||
agent_id: str = ""
|
||||
workspace_id: str = ""
|
||||
session_id: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
163
plugins/memory-gateway-agent/memory_gateway_plugin/tools.py
Normal file
163
plugins/memory-gateway-agent/memory_gateway_plugin/tools.py
Normal file
@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .client import MemoryGatewayClient
|
||||
from .config import PluginConfig, load_config
|
||||
from .safety import validate_memory_write
|
||||
|
||||
|
||||
FEEDBACK_MAP = {
|
||||
"confirm": "useful",
|
||||
"correct": "useful",
|
||||
"useful": "useful",
|
||||
"delete": "incorrect",
|
||||
"reject": "incorrect",
|
||||
"incorrect": "incorrect",
|
||||
"duplicate": "duplicate",
|
||||
"outdated": "outdated",
|
||||
"not_useful": "not_useful",
|
||||
}
|
||||
|
||||
|
||||
def _client(client: MemoryGatewayClient | None = None) -> MemoryGatewayClient:
|
||||
return client or MemoryGatewayClient()
|
||||
|
||||
|
||||
def _context_payload(user_id: str, agent_id: str = "", workspace_id: str = "", session_id: str = "") -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"user_id": user_id}
|
||||
if agent_id:
|
||||
payload["agent_id"] = agent_id
|
||||
if workspace_id:
|
||||
payload["workspace_id"] = workspace_id
|
||||
if session_id:
|
||||
payload["session_id"] = session_id
|
||||
return payload
|
||||
|
||||
|
||||
def memory_search(
|
||||
query: str,
|
||||
user_id: str,
|
||||
agent_id: str = "",
|
||||
workspace_id: str = "",
|
||||
session_id: str = "",
|
||||
namespaces: list[str] | None = None,
|
||||
memory_types: list[str] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
limit: int = 5,
|
||||
client: MemoryGatewayClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not query or not query.strip():
|
||||
return {"ok": False, "error": "query_required"}
|
||||
payload = _context_payload(user_id, agent_id, workspace_id, session_id)
|
||||
payload.update(
|
||||
{
|
||||
"query": query.strip(),
|
||||
"namespaces": namespaces or [],
|
||||
"memory_types": memory_types or [],
|
||||
"tags": tags or [],
|
||||
"limit": limit,
|
||||
}
|
||||
)
|
||||
return _client(client).search_memory(payload)
|
||||
|
||||
|
||||
def memory_append_episode(
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
content: str = "",
|
||||
episode_summary: str = "",
|
||||
workspace_id: str = "",
|
||||
source: str = "conversation",
|
||||
tags: list[str] | None = None,
|
||||
importance: float | None = None,
|
||||
confidence: float | None = None,
|
||||
client: MemoryGatewayClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
candidate = (episode_summary or content or "").strip()
|
||||
validation = validate_memory_write(candidate)
|
||||
if not validation["allowed"]:
|
||||
return {"ok": False, "error": "memory_write_rejected", "reason": validation["reason"]}
|
||||
payload = _context_payload(user_id, agent_id, workspace_id, session_id)
|
||||
payload.update({"content": validation["sanitized_content"], "tags": tags or [], "source": source})
|
||||
if importance is not None:
|
||||
payload["events"] = [{"type": "importance_hint", "value": importance}]
|
||||
if confidence is not None:
|
||||
payload.setdefault("events", []).append({"type": "confidence_hint", "value": confidence})
|
||||
return _client(client).append_episode(payload)
|
||||
|
||||
|
||||
def memory_commit_session(
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
workspace_id: str = "",
|
||||
promote: bool = True,
|
||||
min_importance: float = 0.6,
|
||||
client: MemoryGatewayClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = _context_payload(user_id, agent_id, workspace_id, session_id)
|
||||
payload.update({"promote": promote, "min_importance": min_importance})
|
||||
return _client(client).commit_session(session_id, payload)
|
||||
|
||||
|
||||
def memory_upsert(
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
content: str,
|
||||
workspace_id: str = "",
|
||||
namespace: str = "",
|
||||
memory_type: str = "fact",
|
||||
summary: str = "",
|
||||
tags: list[str] | None = None,
|
||||
importance: float = 0.5,
|
||||
confidence: float = 0.8,
|
||||
visibility: str = "private",
|
||||
source: str = "agent",
|
||||
client: MemoryGatewayClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
validation = validate_memory_write(content)
|
||||
if not validation["allowed"]:
|
||||
return {"ok": False, "error": "memory_write_rejected", "reason": validation["reason"]}
|
||||
payload = _context_payload(user_id, agent_id, workspace_id)
|
||||
payload.update(
|
||||
{
|
||||
"namespace": namespace or None,
|
||||
"memory_type": memory_type,
|
||||
"content": validation["sanitized_content"],
|
||||
"summary": summary or None,
|
||||
"tags": tags or [],
|
||||
"importance": importance,
|
||||
"confidence": confidence,
|
||||
"visibility": visibility,
|
||||
"source": source,
|
||||
}
|
||||
)
|
||||
return _client(client).upsert_memory(payload)
|
||||
|
||||
|
||||
def memory_feedback(
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
memory_id: str,
|
||||
feedback: str,
|
||||
workspace_id: str = "",
|
||||
session_id: str = "",
|
||||
comment: str = "",
|
||||
client: MemoryGatewayClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
mapped_feedback = FEEDBACK_MAP.get(feedback, feedback)
|
||||
payload = _context_payload(user_id, agent_id, workspace_id, session_id)
|
||||
payload.update({"feedback": mapped_feedback, "comment": comment or None})
|
||||
return _client(client).send_feedback(memory_id, payload)
|
||||
|
||||
|
||||
def default_context(config: PluginConfig | None = None) -> dict[str, str]:
|
||||
cfg = config or load_config()
|
||||
return {
|
||||
"user_id": cfg.default_user_id,
|
||||
"agent_id": cfg.default_agent_id,
|
||||
"workspace_id": cfg.default_workspace_id,
|
||||
}
|
||||
|
||||
45
plugins/memory-gateway-agent/memory_gateway_plugin/trace.py
Normal file
45
plugins/memory-gateway-agent/memory_gateway_plugin/trace.py
Normal file
@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .output import redact, short_id
|
||||
|
||||
|
||||
def trace_enabled() -> bool:
|
||||
return os.environ.get("MEMORY_GATEWAY_PLUGIN_TRACE_HOOKS", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def trace_path() -> Path:
|
||||
return Path(__file__).resolve().parents[1] / ".tmp" / "hook_trace.log"
|
||||
|
||||
|
||||
def trace_hook(
|
||||
hook_name: str,
|
||||
*,
|
||||
session_id: str = "",
|
||||
gateway_action: str = "",
|
||||
gateway_called: bool = False,
|
||||
ok: bool | None = None,
|
||||
audit_delta: int | None = None,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
if not trace_enabled():
|
||||
return
|
||||
path = trace_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload: dict[str, Any] = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"hook": hook_name,
|
||||
"session_id": short_id(session_id),
|
||||
"gateway_action": gateway_action,
|
||||
"gateway_called": gateway_called,
|
||||
"ok": ok,
|
||||
"audit_delta": audit_delta,
|
||||
"reason": reason[:160] if reason else "",
|
||||
}
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(redact(payload), ensure_ascii=False, default=str) + "\n")
|
||||
26
plugins/memory-gateway-agent/openclaw.plugin.yaml
Normal file
26
plugins/memory-gateway-agent/openclaw.plugin.yaml
Normal file
@ -0,0 +1,26 @@
|
||||
name: memory-gateway-agent
|
||||
runtime: openclaw
|
||||
version: 0.1.0
|
||||
description: Draft OpenClaw plugin manifest for Memory Gateway v1. Adjust field names to the actual OpenClaw runtime schema before production use.
|
||||
entrypoint: memory_gateway_plugin:register
|
||||
config:
|
||||
gateway_url: ${MEMORY_GATEWAY_URL:-http://127.0.0.1:1934}
|
||||
api_key_env: MEMORY_GATEWAY_API_KEY
|
||||
tools:
|
||||
- name: memory_search
|
||||
- name: memory_append_episode
|
||||
- name: memory_commit_session
|
||||
- name: memory_upsert
|
||||
- name: memory_feedback
|
||||
hooks:
|
||||
- name: pre_llm_call
|
||||
handler: memory_gateway_plugin.lifecycle:on_conversation_start
|
||||
- name: post_llm_call
|
||||
handler: memory_gateway_plugin.lifecycle:after_user_message
|
||||
- name: session_end
|
||||
handler: memory_gateway_plugin.lifecycle:on_session_end
|
||||
safety:
|
||||
stores_full_raw_conversation: false
|
||||
rejects_secrets: true
|
||||
long_term_commit_via_evermemos: true
|
||||
|
||||
55
plugins/memory-gateway-agent/plugin.yaml
Normal file
55
plugins/memory-gateway-agent/plugin.yaml
Normal file
@ -0,0 +1,55 @@
|
||||
name: memory-gateway-agent
|
||||
version: 0.1.0
|
||||
description: Generic AI Agent plugin adapter for the existing Memory Gateway v1 HTTP API.
|
||||
type: agent-plugin
|
||||
provides_tools:
|
||||
- memory_search
|
||||
- memory_append_episode
|
||||
- memory_commit_session
|
||||
- memory_upsert
|
||||
- memory_feedback
|
||||
provides_hooks:
|
||||
- pre_llm_call
|
||||
- post_llm_call
|
||||
- session_end
|
||||
- after_task_complete
|
||||
requires_env: []
|
||||
entrypoint: register
|
||||
transport:
|
||||
type: http
|
||||
target: ${MEMORY_GATEWAY_URL:-http://127.0.0.1:1934}
|
||||
privacy:
|
||||
stores_full_raw_conversation: false
|
||||
writes_long_term_directly_by_default: false
|
||||
auto_commit_session_default: false
|
||||
configuration:
|
||||
MEMORY_GATEWAY_URL:
|
||||
default: http://127.0.0.1:1934
|
||||
MEMORY_GATEWAY_API_KEY:
|
||||
secret: true
|
||||
required: false
|
||||
MEMORY_GATEWAY_DEFAULT_USER_ID:
|
||||
required: false
|
||||
MEMORY_GATEWAY_DEFAULT_AGENT_ID:
|
||||
required: false
|
||||
MEMORY_GATEWAY_DEFAULT_WORKSPACE_ID:
|
||||
required: false
|
||||
MEMORY_GATEWAY_AUTO_SEARCH:
|
||||
default: "true"
|
||||
MEMORY_GATEWAY_AUTO_APPEND_EPISODE:
|
||||
default: "true"
|
||||
MEMORY_GATEWAY_AUTO_COMMIT_SESSION:
|
||||
default: "false"
|
||||
MEMORY_GATEWAY_REVIEW_MODE:
|
||||
default: "true"
|
||||
tools:
|
||||
- memory_search
|
||||
- memory_append_episode
|
||||
- memory_commit_session
|
||||
- memory_upsert
|
||||
- memory_feedback
|
||||
hooks:
|
||||
- pre_llm_call
|
||||
- post_llm_call
|
||||
- session_end
|
||||
- after_task_complete
|
||||
34
plugins/memory-gateway-agent/policies/memory_policy.md
Normal file
34
plugins/memory-gateway-agent/policies/memory_policy.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Memory Gateway Agent Policy
|
||||
|
||||
Use Memory Gateway as a shared memory adapter. It is not a transcript store.
|
||||
|
||||
At conversation start:
|
||||
|
||||
- Search memory when previous context may matter.
|
||||
- Use `memory_search` with the current `user_id`, `agent_id`, `workspace_id`, and `session_id`.
|
||||
- Inject only compact relevant memory summaries into the working context.
|
||||
|
||||
During a task:
|
||||
|
||||
- Write only candidate episode summaries with `memory_append_episode`.
|
||||
- Save stable preferences, long-term project facts, architecture decisions, durable constraints, reusable workflows, and completed task conclusions.
|
||||
- Do not save complete raw conversations, chain-of-thought, large logs, one-time values, or secrets.
|
||||
|
||||
At task or session completion:
|
||||
|
||||
- Use `memory_commit_session` to let Memory Gateway and EverMemOS decide what can be promoted.
|
||||
- Do not promote all episodes directly to long-term memory.
|
||||
- Conflicting or high-value memories should enter review rather than overwrite existing memory.
|
||||
|
||||
When the user says to forget or reject memory:
|
||||
|
||||
- Use `memory_feedback` with `incorrect`, `outdated`, or `not_useful`.
|
||||
- Use delete-capable tools only when the runtime exposes them and access control allows it.
|
||||
|
||||
Default automation:
|
||||
|
||||
- Auto search may be enabled.
|
||||
- Auto append episode may be enabled for safe summaries.
|
||||
- Auto commit is disabled by default.
|
||||
- Auto direct long-term upsert is disabled by default.
|
||||
|
||||
24
plugins/memory-gateway-agent/policies/safety_filter.md
Normal file
24
plugins/memory-gateway-agent/policies/safety_filter.md
Normal file
@ -0,0 +1,24 @@
|
||||
# Memory Gateway Safety Filter
|
||||
|
||||
The plugin must reject memory writes that contain:
|
||||
|
||||
- passwords
|
||||
- API keys
|
||||
- tokens
|
||||
- secrets
|
||||
- bearer tokens
|
||||
- cookies
|
||||
- session IDs
|
||||
- private keys
|
||||
- SSH keys
|
||||
- one-time passwords or verification codes
|
||||
- large raw logs
|
||||
- full chat transcripts
|
||||
- chain-of-thought or hidden reasoning
|
||||
- unconfirmed sensitive personal attributes
|
||||
- low-value temporary chatter
|
||||
|
||||
The plugin stores summaries rather than raw messages. If a message is useful but contains sensitive detail, redact the sensitive detail before writing. If redaction would remove the meaning, reject the write.
|
||||
|
||||
Long-term memory should normally be created by session commit and EverMemOS consolidation, not by direct upsert.
|
||||
|
||||
107
plugins/memory-gateway-agent/schemas.py
Normal file
107
plugins/memory-gateway-agent/schemas.py
Normal file
@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
MEMORY_SEARCH = {
|
||||
"name": "memory_search",
|
||||
"description": "Search accessible Memory Gateway records for the current user/agent context.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query. Must not be empty."},
|
||||
"user_id": {"type": "string", "description": "Memory Gateway user id."},
|
||||
"agent_id": {"type": "string", "description": "Calling agent id, for ACL and namespace routing."},
|
||||
"workspace_id": {"type": "string", "description": "Optional workspace/project id."},
|
||||
"session_id": {"type": "string", "description": "Optional session id for session-scoped context."},
|
||||
"namespaces": {"type": "array", "items": {"type": "string"}, "description": "Optional namespace filters."},
|
||||
"memory_types": {"type": "array", "items": {"type": "string"}, "description": "Optional memory type filters."},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Optional tag filters."},
|
||||
"limit": {"type": "integer", "description": "Maximum result count.", "default": 5},
|
||||
},
|
||||
"required": ["query", "user_id", "agent_id"],
|
||||
},
|
||||
}
|
||||
|
||||
MEMORY_APPEND_EPISODE = {
|
||||
"name": "memory_append_episode",
|
||||
"description": "Append a safe summarized candidate episode. Does not save full raw conversation or directly promote long-term memory.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "Safe summarized episode content. Do not pass raw transcripts."},
|
||||
"episode_summary": {"type": "string", "description": "Optional prebuilt summary. Used instead of content when provided."},
|
||||
"user_id": {"type": "string", "description": "Memory Gateway user id."},
|
||||
"agent_id": {"type": "string", "description": "Calling agent id."},
|
||||
"workspace_id": {"type": "string", "description": "Optional workspace/project id."},
|
||||
"session_id": {"type": "string", "description": "Current session id."},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Optional tags."},
|
||||
"source": {"type": "string", "description": "Source label.", "default": "conversation"},
|
||||
},
|
||||
"required": ["content", "user_id", "agent_id", "session_id"],
|
||||
},
|
||||
}
|
||||
|
||||
MEMORY_COMMIT_SESSION = {
|
||||
"name": "memory_commit_session",
|
||||
"description": "Commit a session through Memory Gateway and EverMemOS. Promotes only what consolidation accepts.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "Memory Gateway user id."},
|
||||
"agent_id": {"type": "string", "description": "Calling agent id."},
|
||||
"workspace_id": {"type": "string", "description": "Optional workspace/project id."},
|
||||
"session_id": {"type": "string", "description": "Session id to commit."},
|
||||
"promote": {"type": "boolean", "description": "Whether promotion is allowed.", "default": True},
|
||||
"min_importance": {"type": "number", "description": "Minimum importance threshold.", "default": 0.6},
|
||||
},
|
||||
"required": ["user_id", "agent_id", "session_id"],
|
||||
},
|
||||
}
|
||||
|
||||
MEMORY_UPSERT = {
|
||||
"name": "memory_upsert",
|
||||
"description": "High-risk direct memory write. Use only for stable, concise, user-approved long-term memory; do not call automatically.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "Memory Gateway user id."},
|
||||
"agent_id": {"type": "string", "description": "Calling agent id."},
|
||||
"workspace_id": {"type": "string", "description": "Optional workspace/project id."},
|
||||
"namespace": {"type": "string", "description": "Optional explicit namespace, e.g. user/{user_id}/long_term."},
|
||||
"memory_type": {"type": "string", "description": "Memory type, e.g. preference, decision, fact, procedure."},
|
||||
"content": {"type": "string", "description": "Stable memory content. Do not pass full raw conversation."},
|
||||
"summary": {"type": "string", "description": "Optional concise summary."},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Optional tags."},
|
||||
"importance": {"type": "number", "description": "Importance score 0..1.", "default": 0.5},
|
||||
"confidence": {"type": "number", "description": "Confidence score 0..1.", "default": 0.8},
|
||||
"visibility": {"type": "string", "description": "Memory visibility.", "default": "private"},
|
||||
},
|
||||
"required": ["user_id", "agent_id", "content", "memory_type"],
|
||||
},
|
||||
}
|
||||
|
||||
MEMORY_FEEDBACK = {
|
||||
"name": "memory_feedback",
|
||||
"description": "Send quality feedback for an existing memory record.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_id": {"type": "string", "description": "Memory id to mark."},
|
||||
"user_id": {"type": "string", "description": "Memory Gateway user id."},
|
||||
"agent_id": {"type": "string", "description": "Calling agent id."},
|
||||
"workspace_id": {"type": "string", "description": "Optional workspace/project id."},
|
||||
"session_id": {"type": "string", "description": "Optional session id."},
|
||||
"feedback": {"type": "string", "description": "Feedback, e.g. confirm, correct, delete, reject, incorrect, duplicate, outdated."},
|
||||
"comment": {"type": "string", "description": "Optional feedback comment."},
|
||||
},
|
||||
"required": ["memory_id", "user_id", "agent_id", "feedback"],
|
||||
},
|
||||
}
|
||||
|
||||
TOOL_SCHEMAS = {
|
||||
"memory_search": MEMORY_SEARCH,
|
||||
"memory_append_episode": MEMORY_APPEND_EPISODE,
|
||||
"memory_commit_session": MEMORY_COMMIT_SESSION,
|
||||
"memory_upsert": MEMORY_UPSERT,
|
||||
"memory_feedback": MEMORY_FEEDBACK,
|
||||
}
|
||||
|
||||
187
plugins/memory-gateway-agent/scripts/cleanup_test_memories.py
Normal file
187
plugins/memory-gateway-agent/scripts/cleanup_test_memories.py
Normal file
@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PLUGIN_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PLUGIN_ROOT))
|
||||
|
||||
from memory_gateway_plugin.output import dumps_safe, short_id, summarize_data
|
||||
|
||||
|
||||
USER_ID = "test_user_memory_gateway_plugin"
|
||||
AGENT_ID = "test_hermes_memory_gateway_plugin"
|
||||
WORKSPACE_ID = "test_workspace_memory_gateway_plugin"
|
||||
SESSION_ID = "test_session_memory_gateway_plugin_001"
|
||||
|
||||
|
||||
def _assert_test_user(user_id: str) -> None:
|
||||
if not user_id.startswith("test_user_"):
|
||||
raise ValueError("cleanup_refuses_non_test_user")
|
||||
|
||||
|
||||
def _gateway_url() -> str:
|
||||
return os.environ.get("MEMORY_GATEWAY_URL", "http://127.0.0.1:1934").rstrip("/")
|
||||
|
||||
|
||||
def _api_key() -> str:
|
||||
return os.environ.get("MEMORY_GATEWAY_API_KEY", "")
|
||||
|
||||
|
||||
def _request(method: str, endpoint: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if _api_key():
|
||||
headers["X-API-Key"] = _api_key()
|
||||
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(_gateway_url() + endpoint, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
return {"ok": True, "status_code": getattr(response, "status", 200), "data": json.loads(raw) if raw else {}}
|
||||
except urllib.error.HTTPError as exc:
|
||||
return {"ok": False, "status_code": exc.code, "error": str(exc.reason)[:300]}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "status_code": None, "error": str(exc)[:300]}
|
||||
|
||||
|
||||
def _search_candidates() -> dict[str, Any]:
|
||||
return _request(
|
||||
"POST",
|
||||
"/v1/memory/search",
|
||||
{
|
||||
"query": "integration_test plugin safe_to_delete Memory Gateway plugin",
|
||||
"user_id": USER_ID,
|
||||
"agent_id": AGENT_ID,
|
||||
"workspace_id": WORKSPACE_ID,
|
||||
"session_id": SESSION_ID,
|
||||
"tags": ["integration_test"],
|
||||
"limit": 100,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _audit_candidate_ids() -> list[str]:
|
||||
result = _request("GET", "/v1/audit?limit=1000")
|
||||
if not result.get("ok"):
|
||||
return []
|
||||
ids: list[str] = []
|
||||
for row in result.get("data") or []:
|
||||
if row.get("actor_user_id") != USER_ID:
|
||||
continue
|
||||
if row.get("actor_agent_id") not in {AGENT_ID, None, ""}:
|
||||
continue
|
||||
if row.get("target_type") == "memory" and row.get("action") in {"upsert_memory", "feedback:incorrect", "feedback:duplicate", "feedback:outdated"}:
|
||||
target_id = row.get("target_id")
|
||||
if target_id and target_id not in ids:
|
||||
ids.append(target_id)
|
||||
return ids
|
||||
|
||||
|
||||
def _memory_from_result(item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
memory = item.get("memory")
|
||||
if isinstance(memory, dict) and memory.get("id"):
|
||||
return memory
|
||||
return None
|
||||
|
||||
|
||||
def _is_cleanup_candidate(memory: dict[str, Any]) -> bool:
|
||||
if memory.get("user_id") != USER_ID:
|
||||
return False
|
||||
tags = set(memory.get("tags") or [])
|
||||
return bool(tags.intersection({"integration_test", "safe_to_delete", "plugin"}))
|
||||
|
||||
|
||||
def _delete_memory(memory_id: str) -> dict[str, Any]:
|
||||
query = urllib.parse.urlencode({"user_id": USER_ID, "agent_id": AGENT_ID, "workspace_id": WORKSPACE_ID, "session_id": SESSION_ID})
|
||||
return _request("DELETE", f"/v1/memory/{urllib.parse.quote(memory_id)}?{query}")
|
||||
|
||||
|
||||
def _feedback_memory(memory_id: str) -> dict[str, Any]:
|
||||
return _request(
|
||||
"POST",
|
||||
f"/v1/memory/{urllib.parse.quote(memory_id)}/feedback",
|
||||
{
|
||||
"user_id": USER_ID,
|
||||
"agent_id": AGENT_ID,
|
||||
"workspace_id": WORKSPACE_ID,
|
||||
"session_id": SESSION_ID,
|
||||
"feedback": "incorrect",
|
||||
"comment": "cleanup marker for integration test memory",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def run(user_id: str = USER_ID) -> dict[str, Any]:
|
||||
_assert_test_user(user_id)
|
||||
if user_id != USER_ID:
|
||||
return {"ok": False, "error": "script_is_scoped_to_fixed_test_user", "user_id": user_id}
|
||||
|
||||
search = _search_candidates()
|
||||
if not search.get("ok"):
|
||||
return {"ok": False, "search": {"ok": False, "status_code": search.get("status_code"), "error": search.get("error")}, "deleted": 0, "feedback_marked": 0, "skipped": 0}
|
||||
|
||||
rows = (search.get("data") or {}).get("results") or []
|
||||
memory_ids = _audit_candidate_ids()
|
||||
deleted = 0
|
||||
feedback_marked = 0
|
||||
skipped = 0
|
||||
unable: list[dict[str, Any]] = []
|
||||
touched: list[str] = []
|
||||
|
||||
for item in rows:
|
||||
memory = _memory_from_result(item)
|
||||
if not memory or not _is_cleanup_candidate(memory):
|
||||
skipped += 1
|
||||
continue
|
||||
memory_id = memory["id"]
|
||||
if memory_id not in memory_ids:
|
||||
memory_ids.append(memory_id)
|
||||
|
||||
for memory_id in memory_ids:
|
||||
deletion = _delete_memory(memory_id)
|
||||
if deletion.get("ok"):
|
||||
deleted += 1
|
||||
touched.append(short_id(memory_id))
|
||||
continue
|
||||
if deletion.get("status_code") == 404:
|
||||
skipped += 1
|
||||
continue
|
||||
feedback = _feedback_memory(memory_id)
|
||||
if feedback.get("ok"):
|
||||
feedback_marked += 1
|
||||
touched.append(short_id(memory_id))
|
||||
else:
|
||||
unable.append({"memory_id": short_id(memory_id), "delete_status": deletion.get("status_code"), "feedback_status": feedback.get("status_code"), "reason": feedback.get("error") or deletion.get("error")})
|
||||
|
||||
return {
|
||||
"ok": not unable,
|
||||
"search": {"ok": True, "status_code": search.get("status_code"), "data": summarize_data(search.get("data"))},
|
||||
"deleted": deleted,
|
||||
"feedback_marked": feedback_marked,
|
||||
"skipped": skipped,
|
||||
"unable_count": len(unable),
|
||||
"unable": unable,
|
||||
"touched_memory_ids": touched,
|
||||
"limitation": "search API returns local MemoryRecord rows plus OpenViking context; cleanup only deletes local MemoryRecord rows for the fixed test user.",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
result = run(os.environ.get("MEMORY_GATEWAY_CLEANUP_USER_ID", USER_ID))
|
||||
except ValueError as exc:
|
||||
result = {"ok": False, "error": str(exc), "deleted": 0, "feedback_marked": 0, "skipped": 0}
|
||||
print(dumps_safe(result))
|
||||
return 0 if result.get("ok") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
218
plugins/memory-gateway-agent/scripts/gateway_e2e_check.py
Normal file
218
plugins/memory-gateway-agent/scripts/gateway_e2e_check.py
Normal file
@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PLUGIN_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PLUGIN_ROOT))
|
||||
|
||||
from memory_gateway_plugin.client import MemoryGatewayClient
|
||||
from memory_gateway_plugin.config import PluginConfig
|
||||
from memory_gateway_plugin.output import debug_raw_enabled, dumps_safe, redact, short_id, summarize_data, summarize_result
|
||||
from memory_gateway_plugin.tools import (
|
||||
memory_append_episode,
|
||||
memory_commit_session,
|
||||
memory_feedback,
|
||||
memory_search,
|
||||
memory_upsert,
|
||||
)
|
||||
|
||||
|
||||
USER_ID = "test_user_memory_gateway_plugin"
|
||||
AGENT_ID = "test_hermes_memory_gateway_plugin"
|
||||
WORKSPACE_ID = "test_workspace_memory_gateway_plugin"
|
||||
SESSION_ID = "test_session_memory_gateway_plugin_001"
|
||||
|
||||
|
||||
def _short(value: Any, max_chars: int = 700) -> str:
|
||||
text = json.dumps(redact(value), ensure_ascii=False, default=str)
|
||||
return text[:max_chars]
|
||||
|
||||
|
||||
def _summary_data(data: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return summarize_data(data) if isinstance(summarize_data(data), dict) else {}
|
||||
|
||||
|
||||
def _result_detail(result: dict[str, Any]) -> str:
|
||||
return _short(summarize_result(result))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Step:
|
||||
name: str
|
||||
ok: bool
|
||||
endpoint: str = ""
|
||||
status_code: int | None = None
|
||||
detail: str = ""
|
||||
data: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"ok": self.ok,
|
||||
"endpoint": self.endpoint,
|
||||
"status_code": self.status_code,
|
||||
"detail": self.detail,
|
||||
"data": _summary_data(self.data),
|
||||
}
|
||||
|
||||
|
||||
def _request(method: str, url: str, payload: dict[str, Any] | None = None, api_key: str = "") -> dict[str, Any]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
return {"ok": True, "status_code": getattr(response, "status", 200), "data": json.loads(raw) if raw else {}}
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body_text = exc.read().decode("utf-8")
|
||||
except Exception:
|
||||
body_text = str(exc.reason)
|
||||
return {"ok": False, "status_code": exc.code, "error": body_text[:700]}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "status_code": None, "error": str(exc)[:700]}
|
||||
|
||||
|
||||
def _health(config: PluginConfig) -> Step:
|
||||
endpoint = "/health"
|
||||
result = _request("GET", config.gateway_url.rstrip("/") + endpoint, api_key=config.api_key)
|
||||
return Step("health", bool(result.get("ok")), endpoint, result.get("status_code"), _result_detail(result), result.get("data"))
|
||||
|
||||
|
||||
def _ensure_user(config: PluginConfig) -> Step:
|
||||
endpoint = "/v1/users"
|
||||
result = _request(
|
||||
"POST",
|
||||
config.gateway_url.rstrip("/") + endpoint,
|
||||
{"user_id": USER_ID, "display_name": "Memory Gateway Plugin Integration Test", "preferences": {"purpose": "integration_test"}},
|
||||
api_key=config.api_key,
|
||||
)
|
||||
ok = bool(result.get("ok")) or result.get("status_code") in {200, 201, 409}
|
||||
return Step("ensure_user", ok, endpoint, result.get("status_code"), _result_detail(result), result.get("data"))
|
||||
|
||||
|
||||
def _client(config: PluginConfig) -> MemoryGatewayClient:
|
||||
return MemoryGatewayClient(config)
|
||||
|
||||
|
||||
def run() -> dict[str, Any]:
|
||||
config = PluginConfig.from_env()
|
||||
client = _client(config)
|
||||
steps: list[Step] = []
|
||||
|
||||
steps.append(_health(config))
|
||||
if not steps[-1].ok:
|
||||
return {"ok": False, "steps": [s.to_dict() for s in steps]}
|
||||
|
||||
steps.append(_ensure_user(config))
|
||||
|
||||
search_1 = memory_search(
|
||||
query="Memory Gateway plugin integration test",
|
||||
user_id=USER_ID,
|
||||
agent_id=AGENT_ID,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
session_id=SESSION_ID,
|
||||
limit=5,
|
||||
client=client,
|
||||
)
|
||||
steps.append(Step("memory_search_initial", bool(search_1.get("ok")), "/v1/memory/search", search_1.get("status_code"), _result_detail(search_1), search_1.get("data")))
|
||||
|
||||
episode = memory_append_episode(
|
||||
user_id=USER_ID,
|
||||
agent_id=AGENT_ID,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
session_id=SESSION_ID,
|
||||
content="Integration test: user prefers Memory Gateway plugin to store only summarized episodes, not raw transcripts.",
|
||||
tags=["integration_test", "plugin"],
|
||||
source="agent",
|
||||
importance=0.2,
|
||||
confidence=0.5,
|
||||
client=client,
|
||||
)
|
||||
steps.append(Step("memory_append_episode", bool(episode.get("ok")), "/v1/episodes", episode.get("status_code"), _result_detail(episode), episode.get("data")))
|
||||
|
||||
commit = memory_commit_session(
|
||||
user_id=USER_ID,
|
||||
agent_id=AGENT_ID,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
session_id=SESSION_ID,
|
||||
promote=True,
|
||||
min_importance=0.1,
|
||||
client=client,
|
||||
)
|
||||
commit_detail = _result_detail(commit)
|
||||
if commit.get("ok") and not (commit.get("data") or {}).get("promoted"):
|
||||
commit_detail += " | promotion may be empty while commit endpoint succeeded"
|
||||
steps.append(Step("memory_commit_session", bool(commit.get("ok")), f"/v1/sessions/{SESSION_ID}/commit", commit.get("status_code"), commit_detail, commit.get("data")))
|
||||
|
||||
upsert = memory_upsert(
|
||||
user_id=USER_ID,
|
||||
agent_id=AGENT_ID,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
namespace=f"user/{USER_ID}/long_term",
|
||||
memory_type="preference",
|
||||
content="Integration test memory: this should be removable or clearly tagged as test data.",
|
||||
tags=["integration_test", "plugin", "safe_to_delete"],
|
||||
importance=0.1,
|
||||
confidence=0.5,
|
||||
source="agent",
|
||||
client=client,
|
||||
)
|
||||
steps.append(Step("memory_upsert", bool(upsert.get("ok")), "/v1/memory", upsert.get("status_code"), _result_detail(upsert), upsert.get("data")))
|
||||
|
||||
search_2 = memory_search(
|
||||
query="Integration test memory summarized episodes raw transcripts",
|
||||
user_id=USER_ID,
|
||||
agent_id=AGENT_ID,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
session_id=SESSION_ID,
|
||||
limit=10,
|
||||
client=client,
|
||||
)
|
||||
result_count = len((search_2.get("data") or {}).get("results", []))
|
||||
detail = _result_detail(search_2)
|
||||
if search_2.get("ok") and result_count == 0:
|
||||
detail += " | search succeeded but returned no results; indexing or OpenViking sync may be asynchronous"
|
||||
steps.append(Step("memory_search_after_write", bool(search_2.get("ok")), "/v1/memory/search", search_2.get("status_code"), detail, search_2.get("data")))
|
||||
|
||||
memory_id = ((upsert.get("data") or {}).get("memory") or upsert.get("data") or {}).get("id")
|
||||
if memory_id:
|
||||
feedback = memory_feedback(
|
||||
user_id=USER_ID,
|
||||
agent_id=AGENT_ID,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
session_id=SESSION_ID,
|
||||
memory_id=memory_id,
|
||||
feedback="reject",
|
||||
comment="Integration test cleanup marker; safe to ignore/delete.",
|
||||
client=client,
|
||||
)
|
||||
steps.append(Step("memory_feedback", bool(feedback.get("ok")), f"/v1/memory/{memory_id}/feedback", feedback.get("status_code"), _result_detail(feedback), feedback.get("data")))
|
||||
else:
|
||||
steps.append(Step("memory_feedback", False, "/v1/memory/{memory_id}/feedback", None, "skipped because memory_upsert did not return memory id"))
|
||||
|
||||
required = {"health", "memory_search_initial", "memory_append_episode", "memory_commit_session", "memory_upsert", "memory_search_after_write", "memory_feedback"}
|
||||
ok = all(step.ok for step in steps if step.name in required)
|
||||
return {"ok": ok, "gateway_url": config.gateway_url, "debug_raw": debug_raw_enabled(), "test_identity": {"user_id": USER_ID, "agent_id": AGENT_ID, "workspace_id": WORKSPACE_ID, "session_id": SESSION_ID}, "steps": [s.to_dict() for s in steps]}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
result = run()
|
||||
print(dumps_safe(result))
|
||||
return 0 if result.get("ok") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
32
plugins/memory-gateway-agent/scripts/health.py
Normal file
32
plugins/memory-gateway-agent/scripts/health.py
Normal file
@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PLUGIN_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PLUGIN_ROOT))
|
||||
|
||||
from memory_gateway_plugin.config import load_config
|
||||
from memory_gateway_plugin.output import dumps_safe, summarize_data
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = load_config()
|
||||
request = urllib.request.Request(config.gateway_url.rstrip("/") + "/health", method="GET")
|
||||
if config.api_key:
|
||||
request.add_header("X-API-Key", config.api_key)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=config.timeout) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
print(dumps_safe({"ok": True, "endpoint": "/health", "status_code": getattr(response, "status", 200), "data": summarize_data(payload)}))
|
||||
except urllib.error.URLError as exc:
|
||||
print(dumps_safe({"ok": False, "endpoint": "/health", "status_code": None, "error": str(exc)[:300]}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
174
plugins/memory-gateway-agent/scripts/hermes_hook_probe.py
Normal file
174
plugins/memory-gateway-agent/scripts/hermes_hook_probe.py
Normal file
@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
USER_ID = "test_user_memory_gateway_plugin"
|
||||
AGENT_ID = "test_hermes_memory_gateway_plugin"
|
||||
WORKSPACE_ID = "test_workspace_memory_gateway_plugin"
|
||||
SESSION_ID = "test_session_memory_gateway_plugin_001"
|
||||
|
||||
|
||||
def _ensure_paths() -> None:
|
||||
plugin_root = Path(__file__).resolve().parents[1]
|
||||
hermes_repo = Path(os.environ.get("HERMES_REPO", "/home/tom/.hermes/hermes-agent"))
|
||||
hermes_cli = hermes_repo / "hermes_cli"
|
||||
for path in [plugin_root, hermes_repo, hermes_cli]:
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
|
||||
_ensure_paths()
|
||||
|
||||
from memory_gateway_plugin.output import dumps_safe, summarize_data
|
||||
|
||||
|
||||
def _request(method: str, url: str, payload: dict[str, Any] | None = None, api_key: str = "") -> dict[str, Any]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
return {"ok": True, "status_code": getattr(response, "status", 200), "data": json.loads(raw) if raw else {}}
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body_text = exc.read().decode("utf-8")
|
||||
except Exception:
|
||||
body_text = str(exc.reason)
|
||||
return {"ok": False, "status_code": exc.code, "error": body_text[:500]}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "status_code": None, "error": str(exc)[:500]}
|
||||
|
||||
|
||||
def _ensure_user() -> dict[str, Any]:
|
||||
gateway_url = os.environ.get("MEMORY_GATEWAY_URL", "http://127.0.0.1:1934").rstrip("/")
|
||||
api_key = os.environ.get("MEMORY_GATEWAY_API_KEY", "")
|
||||
return _request(
|
||||
"POST",
|
||||
gateway_url + "/v1/users",
|
||||
{"user_id": USER_ID, "display_name": "Memory Gateway Hook Probe", "preferences": {"purpose": "hook_probe"}},
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
|
||||
def _audit_count(action: str) -> int:
|
||||
gateway_url = os.environ.get("MEMORY_GATEWAY_URL", "http://127.0.0.1:1934").rstrip("/")
|
||||
api_key = os.environ.get("MEMORY_GATEWAY_API_KEY", "")
|
||||
result = _request("GET", gateway_url + "/v1/audit?limit=1000", api_key=api_key)
|
||||
if not result.get("ok"):
|
||||
return -1
|
||||
rows = result.get("data") or []
|
||||
return sum(
|
||||
1
|
||||
for row in rows
|
||||
if row.get("action") == action
|
||||
and row.get("actor_user_id") == USER_ID
|
||||
and row.get("actor_agent_id") == AGENT_ID
|
||||
)
|
||||
|
||||
|
||||
def _hook_report(manager: Any, hook_name: str, payload: dict[str, Any], audit_action: str = "") -> dict[str, Any]:
|
||||
registered = hook_name in getattr(manager, "_hooks", {}) and bool(manager._hooks[hook_name])
|
||||
before = _audit_count(audit_action) if audit_action else -1
|
||||
try:
|
||||
result = manager.invoke_hook(hook_name, **payload)
|
||||
after = _audit_count(audit_action) if audit_action else -1
|
||||
return {
|
||||
"registered": registered,
|
||||
"invoked": True,
|
||||
"result_type": type(result).__name__,
|
||||
"result": summarize_data(result),
|
||||
"audit_action": audit_action,
|
||||
"audit_delta": (after - before) if before >= 0 and after >= 0 else None,
|
||||
"error": "",
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"registered": registered,
|
||||
"invoked": False,
|
||||
"result_type": "",
|
||||
"result": None,
|
||||
"audit_action": audit_action,
|
||||
"audit_delta": None,
|
||||
"error": str(exc)[:500],
|
||||
}
|
||||
|
||||
|
||||
def run(auto_commit: bool = False) -> dict[str, Any]:
|
||||
os.environ.setdefault("MEMORY_GATEWAY_URL", "http://127.0.0.1:1934")
|
||||
os.environ["MEMORY_GATEWAY_DEFAULT_USER_ID"] = USER_ID
|
||||
os.environ["MEMORY_GATEWAY_DEFAULT_AGENT_ID"] = AGENT_ID
|
||||
os.environ["MEMORY_GATEWAY_DEFAULT_WORKSPACE_ID"] = WORKSPACE_ID
|
||||
os.environ["MEMORY_GATEWAY_AUTO_COMMIT_SESSION"] = "true" if auto_commit else "false"
|
||||
|
||||
from plugins import PluginManager
|
||||
|
||||
ensure_user = _ensure_user()
|
||||
manager = PluginManager()
|
||||
manager.discover_and_load()
|
||||
|
||||
base = {
|
||||
"user_id": USER_ID,
|
||||
"agent_id": AGENT_ID,
|
||||
"workspace_id": WORKSPACE_ID,
|
||||
"session_id": SESSION_ID,
|
||||
"task_id": SESSION_ID,
|
||||
"model": "hook-probe",
|
||||
"platform": "cli",
|
||||
}
|
||||
hooks = {
|
||||
"on_session_start": dict(base),
|
||||
"pre_llm_call": {
|
||||
**base,
|
||||
"user_message": "Memory Gateway plugin integration test memory preference",
|
||||
"conversation_history": [],
|
||||
"is_first_turn": True,
|
||||
},
|
||||
"post_llm_call": {
|
||||
**base,
|
||||
"user_message": "请记住:Memory Gateway plugin hook probe 偏好保存简短摘要型 episode。",
|
||||
"assistant_response": "已记录为候选摘要,后续由 session commit 判断是否提升为长期记忆。",
|
||||
},
|
||||
"on_session_end": dict(base),
|
||||
}
|
||||
|
||||
audit_actions = {
|
||||
"pre_llm_call": "memory_search",
|
||||
"post_llm_call": "append_episode",
|
||||
"on_session_end": "commit_session",
|
||||
}
|
||||
reports = {name: _hook_report(manager, name, payload, audit_actions.get(name, "")) for name, payload in hooks.items()}
|
||||
plugin = manager._plugins.get("memory-gateway-agent")
|
||||
return {
|
||||
"ok": all(item["registered"] and item["invoked"] for item in reports.values()),
|
||||
"auto_commit": auto_commit,
|
||||
"ensure_user": {"ok": ensure_user.get("ok"), "status_code": ensure_user.get("status_code"), "data": summarize_data(ensure_user.get("data"))},
|
||||
"plugin": {
|
||||
"enabled": bool(plugin and plugin.enabled),
|
||||
"tools_registered": sorted(getattr(plugin, "tools_registered", []) if plugin else []),
|
||||
"hooks_registered": sorted(getattr(plugin, "hooks_registered", []) if plugin else []),
|
||||
"error": getattr(plugin, "error", None) if plugin else "plugin_not_found",
|
||||
},
|
||||
"hooks": reports,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
auto_commit = os.environ.get("MEMORY_GATEWAY_AUTO_COMMIT_SESSION", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
result = run(auto_commit=auto_commit)
|
||||
print(dumps_safe(result))
|
||||
return 0 if result.get("ok") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PLUGIN_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PLUGIN_ROOT))
|
||||
|
||||
from memory_gateway_plugin.output import dumps_safe, short_id, summarize_data
|
||||
|
||||
|
||||
USER_ID = "test_user_memory_gateway_plugin"
|
||||
AGENT_ID = "test_hermes_memory_gateway_plugin"
|
||||
WORKSPACE_ID = "test_workspace_memory_gateway_plugin"
|
||||
SESSION_ID = "test_session_memory_gateway_plugin_interactive_002"
|
||||
PROMPT = "Please remember this integration test preference: Memory Gateway plugin should store summarized episodes, not raw transcripts."
|
||||
|
||||
|
||||
def _request(method: str, url: str, payload: dict[str, Any] | None = None, api_key: str = "") -> dict[str, Any]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
return {"ok": True, "status_code": getattr(response, "status", 200), "data": json.loads(raw) if raw else {}}
|
||||
except urllib.error.HTTPError as exc:
|
||||
return {"ok": False, "status_code": exc.code, "error": str(exc.reason)[:300]}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "status_code": None, "error": str(exc)[:300]}
|
||||
|
||||
|
||||
def _gateway_url() -> str:
|
||||
return os.environ.get("MEMORY_GATEWAY_URL", "http://127.0.0.1:1934").rstrip("/")
|
||||
|
||||
|
||||
def _api_key() -> str:
|
||||
return os.environ.get("MEMORY_GATEWAY_API_KEY", "")
|
||||
|
||||
|
||||
def _audit_counts() -> dict[str, int]:
|
||||
result = _request("GET", _gateway_url() + "/v1/audit?limit=1000", api_key=_api_key())
|
||||
rows = result.get("data") or []
|
||||
actions = {"memory_search": 0, "append_episode": 0, "commit_session": 0}
|
||||
for row in rows:
|
||||
if row.get("actor_user_id") != USER_ID or row.get("actor_agent_id") != AGENT_ID:
|
||||
continue
|
||||
action = row.get("action")
|
||||
if action in actions:
|
||||
actions[action] += 1
|
||||
return actions
|
||||
|
||||
|
||||
def _run_cmd(args: list[str], timeout: int = 20, env: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
completed = subprocess.run(args, capture_output=True, text=True, timeout=timeout, env=env, check=False)
|
||||
return {"ok": completed.returncode == 0, "returncode": completed.returncode, "stdout_chars": len(completed.stdout), "stderr_chars": len(completed.stderr)}
|
||||
except FileNotFoundError:
|
||||
return {"ok": False, "returncode": None, "error": "command_not_found"}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "returncode": None, "error": "timeout"}
|
||||
|
||||
|
||||
def _manual_instructions(reason: str) -> dict[str, Any]:
|
||||
return {
|
||||
"mode": "manual",
|
||||
"reason": reason,
|
||||
"commands": [
|
||||
"hermes plugins list",
|
||||
"hermes tools list",
|
||||
"MEMORY_GATEWAY_URL=http://127.0.0.1:1934 MEMORY_GATEWAY_DEFAULT_USER_ID=test_user_memory_gateway_plugin MEMORY_GATEWAY_DEFAULT_AGENT_ID=test_hermes_memory_gateway_plugin MEMORY_GATEWAY_DEFAULT_WORKSPACE_ID=test_workspace_memory_gateway_plugin MEMORY_GATEWAY_AUTO_COMMIT_SESSION=false hermes chat -Q -q 'Please remember this integration test preference: Memory Gateway plugin should store summarized episodes, not raw transcripts.' --source memory-gateway-plugin-test --toolsets memory_gateway",
|
||||
"python plugins/memory-gateway-agent/scripts/hermes_interactive_session_check.py",
|
||||
],
|
||||
"expected": [
|
||||
"Gateway audit memory_search count increases for the test user/agent.",
|
||||
"Gateway audit append_episode count increases for the test user/agent.",
|
||||
"commit_session count does not increase while MEMORY_GATEWAY_AUTO_COMMIT_SESSION=false.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def run() -> dict[str, Any]:
|
||||
hermes = shutil.which("hermes") or "/home/tom/.local/bin/hermes"
|
||||
plugin_list = _run_cmd([hermes, "plugins", "list"], timeout=10)
|
||||
tools_list = _run_cmd([hermes, "tools", "list"], timeout=10)
|
||||
health = _request("GET", _gateway_url() + "/health", api_key=_api_key())
|
||||
if not health.get("ok"):
|
||||
return {"ok": False, "mode": "blocked", "plugin_list": plugin_list, "tools_list": tools_list, "gateway_health": {"ok": False, "status_code": health.get("status_code"), "error": health.get("error")}, "manual": _manual_instructions("gateway_unhealthy")}
|
||||
|
||||
before = _audit_counts()
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"MEMORY_GATEWAY_URL": _gateway_url(),
|
||||
"MEMORY_GATEWAY_DEFAULT_USER_ID": USER_ID,
|
||||
"MEMORY_GATEWAY_DEFAULT_AGENT_ID": AGENT_ID,
|
||||
"MEMORY_GATEWAY_DEFAULT_WORKSPACE_ID": WORKSPACE_ID,
|
||||
"MEMORY_GATEWAY_AUTO_SEARCH": "true",
|
||||
"MEMORY_GATEWAY_AUTO_APPEND_EPISODE": "true",
|
||||
"MEMORY_GATEWAY_AUTO_COMMIT_SESSION": os.environ.get("MEMORY_GATEWAY_AUTO_COMMIT_SESSION", "false"),
|
||||
}
|
||||
)
|
||||
chat = _run_cmd(
|
||||
[hermes, "chat", "-Q", "-q", PROMPT, "--source", "memory-gateway-plugin-test", "--toolsets", "memory_gateway"],
|
||||
timeout=int(os.environ.get("MEMORY_GATEWAY_PLUGIN_CHAT_TIMEOUT", "180")),
|
||||
env=env,
|
||||
)
|
||||
after = _audit_counts()
|
||||
delta = {key: after.get(key, 0) - before.get(key, 0) for key in before}
|
||||
auto_commit = env["MEMORY_GATEWAY_AUTO_COMMIT_SESSION"].strip().lower() in {"1", "true", "yes", "on"}
|
||||
expected_commit = delta.get("commit_session", 0) > 0 if auto_commit else delta.get("commit_session", 0) == 0
|
||||
passed = chat.get("ok") and delta.get("memory_search", 0) > 0 and delta.get("append_episode", 0) > 0 and expected_commit
|
||||
return {
|
||||
"ok": bool(passed),
|
||||
"mode": "auto" if chat.get("ok") else "manual",
|
||||
"plugin_list": plugin_list,
|
||||
"tools_list": tools_list,
|
||||
"gateway_health": {"ok": True, "status_code": health.get("status_code"), "data": summarize_data(health.get("data"))},
|
||||
"chat": chat,
|
||||
"auto_commit": auto_commit,
|
||||
"audit_before": before,
|
||||
"audit_after": after,
|
||||
"audit_delta": delta,
|
||||
"test_identity": {"user_id": USER_ID, "agent_id": AGENT_ID, "workspace_id": WORKSPACE_ID, "session_id": short_id(SESSION_ID)},
|
||||
"manual": None if chat.get("ok") else _manual_instructions(chat.get("error", "chat_command_failed")),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
result = run()
|
||||
print(dumps_safe(result))
|
||||
return 0 if result.get("ok") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
73
plugins/memory-gateway-agent/scripts/hermes_smoke_check.py
Normal file
73
plugins/memory-gateway-agent/scripts/hermes_smoke_check.py
Normal file
@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FakeHermesContext:
|
||||
def __init__(self) -> None:
|
||||
self.registered_tools = []
|
||||
self.registered_hooks = []
|
||||
|
||||
def register_tool(self, name, toolset, schema, handler, **kwargs):
|
||||
self.registered_tools.append(
|
||||
{
|
||||
"name": name,
|
||||
"toolset": toolset,
|
||||
"schema": schema,
|
||||
"handler_callable": callable(handler),
|
||||
"kwargs": kwargs,
|
||||
}
|
||||
)
|
||||
|
||||
def register_hook(self, hook_name, callback):
|
||||
self.registered_hooks.append({"name": hook_name, "handler_callable": callable(callback)})
|
||||
|
||||
|
||||
def load_plugin_module():
|
||||
plugin_dir = Path(__file__).resolve().parents[1]
|
||||
init_file = plugin_dir / "__init__.py"
|
||||
module_name = "hermes_plugins.memory_gateway_agent_smoke"
|
||||
if "hermes_plugins" not in sys.modules:
|
||||
parent = types.ModuleType("hermes_plugins")
|
||||
parent.__path__ = []
|
||||
sys.modules["hermes_plugins"] = parent
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
init_file,
|
||||
submodule_search_locations=[str(plugin_dir)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("Cannot create plugin module spec")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
module.__package__ = module_name
|
||||
module.__path__ = [str(plugin_dir)]
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def main() -> None:
|
||||
module = load_plugin_module()
|
||||
ctx = FakeHermesContext()
|
||||
module.register(ctx)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"has_register": callable(getattr(module, "register", None)),
|
||||
"registered_tools": ctx.registered_tools,
|
||||
"registered_hooks": ctx.registered_hooks,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
34
plugins/memory-gateway-agent/scripts/smoke_test.py
Normal file
34
plugins/memory-gateway-agent/scripts/smoke_test.py
Normal file
@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PLUGIN_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PLUGIN_ROOT))
|
||||
|
||||
from memory_gateway_plugin.output import dumps_safe, summarize_result
|
||||
from memory_gateway_plugin.tools import memory_append_episode, memory_commit_session, memory_search
|
||||
|
||||
|
||||
def main() -> None:
|
||||
user_id = "plugin_smoke_user"
|
||||
agent_id = "plugin_smoke_agent"
|
||||
session_id = f"plugin_smoke_{uuid.uuid4().hex[:8]}"
|
||||
episode = memory_append_episode(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
episode_summary="结论:Memory Gateway Agent Plugin smoke test 写入短期 episode。",
|
||||
tags=["smoke-test"],
|
||||
)
|
||||
commit = memory_commit_session(user_id=user_id, agent_id=agent_id, session_id=session_id)
|
||||
search = memory_search(query="Memory Gateway Agent Plugin smoke test", user_id=user_id, agent_id=agent_id, session_id=session_id)
|
||||
print(dumps_safe({"episode": summarize_result(episode), "commit": summarize_result(commit), "search": summarize_result(search)}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_cleanup():
|
||||
path = Path(__file__).resolve().parents[1] / "scripts" / "cleanup_test_memories.py"
|
||||
spec = importlib.util.spec_from_file_location("cleanup_test_memories_guard_test", path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_cleanup_requires_test_user():
|
||||
module = _load_cleanup()
|
||||
|
||||
try:
|
||||
module.run("real_user")
|
||||
except ValueError as exc:
|
||||
assert "cleanup_refuses_non_test_user" in str(exc)
|
||||
else:
|
||||
raise AssertionError("cleanup accepted a non-test user")
|
||||
@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_cleanup_test_memories_imports():
|
||||
path = Path(__file__).resolve().parents[1] / "scripts" / "cleanup_test_memories.py"
|
||||
spec = importlib.util.spec_from_file_location("cleanup_test_memories", path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
assert callable(module.run)
|
||||
assert module.USER_ID.startswith("test_user_")
|
||||
105
plugins/memory-gateway-agent/tests/test_client.py
Normal file
105
plugins/memory-gateway-agent/tests/test_client.py
Normal file
@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import urllib.error
|
||||
|
||||
from memory_gateway_plugin.client import MemoryGatewayClient
|
||||
from memory_gateway_plugin.config import PluginConfig
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps(self.payload).encode("utf-8")
|
||||
|
||||
|
||||
def test_client_search_success(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
seen["url"] = request.full_url
|
||||
seen["timeout"] = timeout
|
||||
return FakeResponse({"results": [], "total": 0})
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
client = MemoryGatewayClient(PluginConfig(gateway_url="http://gateway", timeout=7))
|
||||
result = client.search_memory({"user_id": "u", "query": "demo"})
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["endpoint"] == "/v1/memory/search"
|
||||
assert seen["url"] == "http://gateway/v1/memory/search"
|
||||
assert seen["timeout"] == 7
|
||||
|
||||
|
||||
def test_client_network_error(monkeypatch):
|
||||
def fake_urlopen(request, timeout):
|
||||
raise urllib.error.URLError("connection refused")
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
client = MemoryGatewayClient(PluginConfig(gateway_url="http://gateway"))
|
||||
result = client.search_memory({"user_id": "u", "query": "demo"})
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["status_code"] is None
|
||||
assert "connection refused" in result["error"]
|
||||
|
||||
|
||||
def test_commit_session_calls_correct_endpoint(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
seen["url"] = request.full_url
|
||||
return FakeResponse({"session_id": "sess_1"})
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
client = MemoryGatewayClient(PluginConfig(gateway_url="http://gateway"))
|
||||
result = client.commit_session("sess_1", {"user_id": "u", "session_id": "sess_1"})
|
||||
|
||||
assert result["ok"] is True
|
||||
assert seen["url"] == "http://gateway/v1/sessions/sess_1/commit"
|
||||
|
||||
|
||||
def test_feedback_calls_correct_endpoint(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
seen["url"] = request.full_url
|
||||
return FakeResponse({"status": "ok"})
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
client = MemoryGatewayClient(PluginConfig(gateway_url="http://gateway"))
|
||||
result = client.send_feedback("mem_1", {"user_id": "u", "feedback": "incorrect"})
|
||||
|
||||
assert result["ok"] is True
|
||||
assert seen["url"] == "http://gateway/v1/memory/mem_1/feedback"
|
||||
|
||||
|
||||
def test_client_http_error(monkeypatch):
|
||||
def fake_urlopen(request, timeout):
|
||||
raise urllib.error.HTTPError(
|
||||
url=request.full_url,
|
||||
code=401,
|
||||
msg="unauthorized",
|
||||
hdrs=None,
|
||||
fp=io.BytesIO(b'{"detail":"Invalid or missing API key"}'),
|
||||
)
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
client = MemoryGatewayClient(PluginConfig(gateway_url="http://gateway"))
|
||||
result = client.search_memory({"user_id": "u", "query": "demo"})
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["status_code"] == 401
|
||||
assert result["endpoint"] == "/v1/memory/search"
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from memory_gateway_plugin.output import debug_raw_enabled, summarize_data
|
||||
|
||||
|
||||
def test_debug_raw_disabled_by_default(monkeypatch):
|
||||
monkeypatch.delenv("MEMORY_GATEWAY_PLUGIN_DEBUG_RAW", raising=False)
|
||||
|
||||
assert debug_raw_enabled() is False
|
||||
assert summarize_data({"results": [{"memory": {"content": "raw"}}], "total": 1}) == {
|
||||
"count": 1,
|
||||
"total": 1,
|
||||
"local_total": None,
|
||||
"openviking_total": None,
|
||||
"searched_namespaces": [],
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_gateway_e2e_script_imports():
|
||||
path = Path(__file__).resolve().parents[1] / "scripts" / "gateway_e2e_check.py"
|
||||
spec = importlib.util.spec_from_file_location("gateway_e2e_check", path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
assert callable(module.run)
|
||||
assert module.USER_ID == "test_user_memory_gateway_plugin"
|
||||
@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_hermes_hook_probe_script_imports():
|
||||
path = Path(__file__).resolve().parents[1] / "scripts" / "hermes_hook_probe.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_hook_probe", path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
assert callable(module.run)
|
||||
assert module.SESSION_ID == "test_session_memory_gateway_plugin_001"
|
||||
@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_register_tools import FakeHermesContext, load_plugin_module
|
||||
|
||||
|
||||
def test_register_registers_expected_hooks():
|
||||
module = load_plugin_module()
|
||||
ctx = FakeHermesContext()
|
||||
|
||||
module.register(ctx)
|
||||
|
||||
assert [item[0] for item in ctx.registered_hooks] == [
|
||||
"on_session_start",
|
||||
"pre_llm_call",
|
||||
"post_llm_call",
|
||||
"on_session_end",
|
||||
]
|
||||
assert all(callable(item[1]) for item in ctx.registered_hooks)
|
||||
|
||||
|
||||
def test_hook_callbacks_accept_kwargs():
|
||||
module = load_plugin_module()
|
||||
|
||||
assert isinstance(module.on_session_start(session_id="s", extra="x"), dict)
|
||||
assert isinstance(module.pre_llm_call(user_message="", session_id="s", extra="x"), dict)
|
||||
assert module.post_llm_call(user_message="hi", assistant_response="hello", extra="x") is None
|
||||
assert module.on_session_end(session_id="s", extra="x") is None
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FakeHermesContext:
|
||||
def __init__(self) -> None:
|
||||
self.registered_tools = []
|
||||
self.registered_hooks = []
|
||||
|
||||
def register_tool(self, name, toolset, schema, handler, **kwargs):
|
||||
self.registered_tools.append((name, toolset, schema, handler, kwargs))
|
||||
|
||||
def register_hook(self, hook_name, callback):
|
||||
self.registered_hooks.append((hook_name, callback))
|
||||
|
||||
|
||||
def load_plugin_module():
|
||||
plugin_dir = Path(__file__).resolve().parents[1]
|
||||
if "hermes_plugins" not in sys.modules:
|
||||
parent = types.ModuleType("hermes_plugins")
|
||||
parent.__path__ = []
|
||||
sys.modules["hermes_plugins"] = parent
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"hermes_plugins.memory_gateway_agent_test",
|
||||
plugin_dir / "__init__.py",
|
||||
submodule_search_locations=[str(plugin_dir)],
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
module.__package__ = "hermes_plugins.memory_gateway_agent_test"
|
||||
module.__path__ = [str(plugin_dir)]
|
||||
sys.modules["hermes_plugins.memory_gateway_agent_test"] = module
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_register_registers_five_tools():
|
||||
module = load_plugin_module()
|
||||
ctx = FakeHermesContext()
|
||||
|
||||
module.register(ctx)
|
||||
|
||||
assert [item[0] for item in ctx.registered_tools] == [
|
||||
"memory_search",
|
||||
"memory_append_episode",
|
||||
"memory_commit_session",
|
||||
"memory_upsert",
|
||||
"memory_feedback",
|
||||
]
|
||||
assert all(item[1] == "memory_gateway" for item in ctx.registered_tools)
|
||||
|
||||
|
||||
def test_registered_handlers_are_callable():
|
||||
module = load_plugin_module()
|
||||
ctx = FakeHermesContext()
|
||||
|
||||
module.register(ctx)
|
||||
|
||||
assert all(callable(item[3]) for item in ctx.registered_tools)
|
||||
36
plugins/memory-gateway-agent/tests/test_hermes_schemas.py
Normal file
36
plugins/memory-gateway-agent/tests/test_hermes_schemas.py
Normal file
@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_register_tools import load_plugin_module
|
||||
|
||||
|
||||
def test_tool_schemas_exist_for_all_tools():
|
||||
module = load_plugin_module()
|
||||
schemas = module.schemas.TOOL_SCHEMAS
|
||||
|
||||
assert set(schemas) == {
|
||||
"memory_search",
|
||||
"memory_append_episode",
|
||||
"memory_commit_session",
|
||||
"memory_upsert",
|
||||
"memory_feedback",
|
||||
}
|
||||
|
||||
|
||||
def test_tool_schemas_have_required_fields():
|
||||
module = load_plugin_module()
|
||||
schemas = module.schemas.TOOL_SCHEMAS
|
||||
|
||||
assert schemas["memory_search"]["parameters"]["required"] == ["query", "user_id", "agent_id"]
|
||||
assert schemas["memory_append_episode"]["parameters"]["required"] == ["content", "user_id", "agent_id", "session_id"]
|
||||
assert schemas["memory_commit_session"]["parameters"]["required"] == ["user_id", "agent_id", "session_id"]
|
||||
assert schemas["memory_upsert"]["parameters"]["required"] == ["user_id", "agent_id", "content", "memory_type"]
|
||||
assert schemas["memory_feedback"]["parameters"]["required"] == ["memory_id", "user_id", "agent_id", "feedback"]
|
||||
|
||||
|
||||
def test_upsert_schema_warns_high_risk():
|
||||
module = load_plugin_module()
|
||||
|
||||
description = module.schemas.TOOL_SCHEMAS["memory_upsert"]["description"].lower()
|
||||
assert "high-risk" in description
|
||||
assert "do not call automatically" in description
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from memory_gateway_plugin.config import PluginConfig
|
||||
from memory_gateway_plugin.lifecycle import on_session_end
|
||||
|
||||
|
||||
class CountingClient:
|
||||
def __init__(self) -> None:
|
||||
self.commit_calls = 0
|
||||
|
||||
def commit_session(self, session_id, payload):
|
||||
self.commit_calls += 1
|
||||
return {"ok": True, "data": {"session_id": session_id, "payload": payload}}
|
||||
|
||||
|
||||
def test_hook_auto_commit_disabled_by_default():
|
||||
client = CountingClient()
|
||||
|
||||
result = on_session_end(
|
||||
{"user_id": "u", "agent_id": "a", "session_id": "s"},
|
||||
client=client,
|
||||
config=PluginConfig(auto_commit_session=False),
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["committed"] is False
|
||||
assert result["reason"] == "auto_commit_disabled"
|
||||
assert client.commit_calls == 0
|
||||
@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from memory_gateway_plugin.config import PluginConfig
|
||||
from memory_gateway_plugin.lifecycle import after_user_message
|
||||
|
||||
|
||||
class RecordingClient:
|
||||
def __init__(self) -> None:
|
||||
self.payloads = []
|
||||
|
||||
def append_episode(self, payload):
|
||||
self.payloads.append(payload)
|
||||
return {"ok": True, "data": payload}
|
||||
|
||||
|
||||
def test_hook_post_llm_does_not_save_raw_transcript():
|
||||
client = RecordingClient()
|
||||
raw_transcript = "user: a\nassistant: b\nuser: c\nassistant: d"
|
||||
|
||||
result = after_user_message(
|
||||
{
|
||||
"user_id": "u",
|
||||
"agent_id": "a",
|
||||
"session_id": "s",
|
||||
"user_message": raw_transcript,
|
||||
"assistant_response": "请记住这个完整原始对话。",
|
||||
},
|
||||
client=client,
|
||||
config=PluginConfig(auto_append_episode=True),
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["appended"] is False
|
||||
assert result["reason"] == "policy_skip"
|
||||
assert client.payloads == []
|
||||
@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_root_plugin():
|
||||
plugin_dir = Path(__file__).resolve().parents[1]
|
||||
if "hermes_plugins" not in sys.modules:
|
||||
parent = types.ModuleType("hermes_plugins")
|
||||
parent.__path__ = [] # type: ignore[attr-defined]
|
||||
sys.modules["hermes_plugins"] = parent
|
||||
module_name = "hermes_plugins.memory_gateway_agent_pre_llm_test"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
plugin_dir / "__init__.py",
|
||||
submodule_search_locations=[str(plugin_dir)],
|
||||
)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
module.__package__ = module_name
|
||||
module.__path__ = [str(plugin_dir)] # type: ignore[attr-defined]
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_hook_pre_llm_search_failure_non_blocking(monkeypatch):
|
||||
plugin = _load_root_plugin()
|
||||
|
||||
class FailingLifecycle:
|
||||
@staticmethod
|
||||
def on_conversation_start(context):
|
||||
return {"ok": False, "error": "network_error"}
|
||||
|
||||
monkeypatch.setattr(plugin, "lifecycle", FailingLifecycle)
|
||||
|
||||
assert plugin.pre_llm_call(user_id="u", agent_id="a", session_id="s", user_message="search") == {}
|
||||
@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from memory_gateway_plugin.trace import trace_enabled
|
||||
|
||||
|
||||
def test_hook_trace_disabled_by_default(monkeypatch):
|
||||
monkeypatch.delenv("MEMORY_GATEWAY_PLUGIN_TRACE_HOOKS", raising=False)
|
||||
|
||||
assert trace_enabled() is False
|
||||
@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from memory_gateway_plugin.trace import trace_hook
|
||||
|
||||
|
||||
def test_hook_trace_does_not_log_api_key(monkeypatch, tmp_path):
|
||||
import memory_gateway_plugin.trace as trace_mod
|
||||
|
||||
path = tmp_path / "hook_trace.log"
|
||||
monkeypatch.setenv("MEMORY_GATEWAY_PLUGIN_TRACE_HOOKS", "true")
|
||||
monkeypatch.setenv("MEMORY_GATEWAY_API_KEY", "sk-should-not-appear")
|
||||
monkeypatch.setattr(trace_mod, "trace_path", lambda: path)
|
||||
|
||||
trace_hook("post_llm_call", session_id="s", gateway_action="append_episode", gateway_called=True, ok=True)
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert "sk-should-not-appear" not in text
|
||||
assert "api_key" not in text.lower()
|
||||
@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from memory_gateway_plugin.trace import trace_hook
|
||||
|
||||
|
||||
def test_hook_trace_redacts_content(monkeypatch, tmp_path):
|
||||
import memory_gateway_plugin.trace as trace_mod
|
||||
|
||||
path = tmp_path / "hook_trace.log"
|
||||
monkeypatch.setenv("MEMORY_GATEWAY_PLUGIN_TRACE_HOOKS", "true")
|
||||
monkeypatch.setattr(trace_mod, "trace_path", lambda: path)
|
||||
|
||||
trace_hook("pre_llm_call", session_id="test_session_1234567890", gateway_action="memory_search", gateway_called=True, ok=True, reason="password=abc")
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert "password=abc" not in text
|
||||
assert "pre_llm_call" in text
|
||||
assert "test_ses" in text
|
||||
@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_interactive_session_check_imports():
|
||||
path = Path(__file__).resolve().parents[1] / "scripts" / "hermes_interactive_session_check.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_interactive_session_check", path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
assert callable(module.run)
|
||||
assert module.SESSION_ID == "test_session_memory_gateway_plugin_interactive_002"
|
||||
70
plugins/memory-gateway-agent/tests/test_lifecycle.py
Normal file
70
plugins/memory-gateway-agent/tests/test_lifecycle.py
Normal file
@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from memory_gateway_plugin import register
|
||||
from memory_gateway_plugin.config import PluginConfig
|
||||
from memory_gateway_plugin.lifecycle import after_user_message, on_conversation_start, on_session_end
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def search_memory(self, payload):
|
||||
return {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"results": [
|
||||
{
|
||||
"memory": {
|
||||
"id": "mem_1",
|
||||
"namespace": "user/u/long_term",
|
||||
"summary": "用户偏好中文输出。",
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
def append_episode(self, payload):
|
||||
return {"ok": True, "data": payload}
|
||||
|
||||
def commit_session(self, session_id, payload):
|
||||
return {"ok": True, "data": {"session_id": session_id}}
|
||||
|
||||
|
||||
def test_lifecycle_hooks_do_not_crash_when_ctx_missing_features():
|
||||
result = register(object())
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["mode"] == "manual"
|
||||
|
||||
|
||||
def test_lifecycle_search_returns_compact_context():
|
||||
result = on_conversation_start(
|
||||
{"user_id": "u", "agent_id": "a", "session_id": "s", "user_message": "之前偏好是什么?"},
|
||||
client=FakeClient(),
|
||||
config=PluginConfig(auto_search=True),
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert "用户偏好中文输出" in result["memory_context"]
|
||||
|
||||
|
||||
def test_lifecycle_append_policy_accepts_stable_preference():
|
||||
result = after_user_message(
|
||||
{"user_id": "u", "agent_id": "a", "session_id": "s", "user_message": "请记住:我偏好中文。"},
|
||||
client=FakeClient(),
|
||||
config=PluginConfig(auto_append_episode=True),
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["appended"] is True
|
||||
|
||||
|
||||
def test_lifecycle_session_end_auto_commit_disabled():
|
||||
result = on_session_end(
|
||||
{"user_id": "u", "agent_id": "a", "session_id": "s"},
|
||||
client=FakeClient(),
|
||||
config=PluginConfig(auto_commit_session=False),
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["committed"] is False
|
||||
|
||||
24
plugins/memory-gateway-agent/tests/test_output_redaction.py
Normal file
24
plugins/memory-gateway-agent/tests/test_output_redaction.py
Normal file
@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from memory_gateway_plugin.output import dumps_safe, redact, short_id
|
||||
|
||||
|
||||
def test_output_redaction_hides_secret_fields():
|
||||
payload = {
|
||||
"api_key": "sk-test",
|
||||
"headers": {"Authorization": "Bearer abc"},
|
||||
"nested": {"cookie": "sid=abc"},
|
||||
"safe": "value",
|
||||
}
|
||||
|
||||
text = dumps_safe(payload)
|
||||
|
||||
assert "sk-test" not in text
|
||||
assert "Bearer abc" not in text
|
||||
assert "sid=abc" not in text
|
||||
assert "value" in text
|
||||
assert redact("password=abc") == "<redacted>"
|
||||
|
||||
|
||||
def test_output_redaction_shortens_memory_ids():
|
||||
assert short_id("mem_1234567890abcdef") == "mem_1234...cdef"
|
||||
22
plugins/memory-gateway-agent/tests/test_policy.py
Normal file
22
plugins/memory-gateway-agent/tests/test_policy.py
Normal file
@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from memory_gateway_plugin.config import PluginConfig
|
||||
from memory_gateway_plugin.policy import should_append_episode, should_commit_session, should_search_memory
|
||||
|
||||
|
||||
def test_policy_should_append_for_explicit_remember():
|
||||
assert should_append_episode("请记住:我偏好中文技术说明。", "", {}, PluginConfig())
|
||||
|
||||
|
||||
def test_policy_should_not_append_for_small_talk():
|
||||
assert not should_append_episode("你好", "", {}, PluginConfig())
|
||||
|
||||
|
||||
def test_policy_should_search_when_enabled():
|
||||
assert should_search_memory("这个项目之前有什么约束?", {}, PluginConfig(auto_search=True))
|
||||
|
||||
|
||||
def test_policy_should_commit_only_when_enabled_or_forced():
|
||||
assert not should_commit_session({}, PluginConfig(auto_commit_session=False))
|
||||
assert should_commit_session({"force_commit": True}, PluginConfig(auto_commit_session=False))
|
||||
|
||||
24
plugins/memory-gateway-agent/tests/test_safety.py
Normal file
24
plugins/memory-gateway-agent/tests/test_safety.py
Normal file
@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from memory_gateway_plugin.safety import detect_large_log, sanitize_memory_content, validate_memory_write
|
||||
|
||||
|
||||
def test_safety_rejects_private_key():
|
||||
content = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----"
|
||||
result = validate_memory_write(content)
|
||||
|
||||
assert result["allowed"] is False
|
||||
assert result["reason"] == "secret_like_content"
|
||||
|
||||
|
||||
def test_safety_rejects_large_log():
|
||||
content = "\n".join(f"2026-05-06 10:00:{i:02d} ERROR failure" for i in range(10))
|
||||
blocked, reason = detect_large_log(content)
|
||||
|
||||
assert blocked is True
|
||||
assert reason == "large_or_raw_log"
|
||||
|
||||
|
||||
def test_safety_sanitizes_secret_when_called_directly():
|
||||
assert "sk-test" not in sanitize_memory_content("api_key=sk-test")
|
||||
|
||||
106
plugins/memory-gateway-agent/tests/test_tools.py
Normal file
106
plugins/memory-gateway-agent/tests/test_tools.py
Normal file
@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from memory_gateway_plugin.tools import memory_append_episode, memory_feedback, memory_search, memory_upsert
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def search_memory(self, payload):
|
||||
self.calls.append(("search", payload))
|
||||
return {"ok": True, "data": {"results": []}}
|
||||
|
||||
def append_episode(self, payload):
|
||||
self.calls.append(("append", payload))
|
||||
return {"ok": True, "data": payload}
|
||||
|
||||
def upsert_memory(self, payload):
|
||||
self.calls.append(("upsert", payload))
|
||||
return {"ok": True, "data": payload}
|
||||
|
||||
def send_feedback(self, memory_id, payload):
|
||||
self.calls.append(("feedback", memory_id, payload))
|
||||
return {"ok": True, "data": payload}
|
||||
|
||||
|
||||
def test_memory_search_empty_query_rejected():
|
||||
client = FakeClient()
|
||||
result = memory_search(query="", user_id="u", agent_id="a", client=client)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert client.calls == []
|
||||
|
||||
|
||||
def test_append_episode_rejects_api_key():
|
||||
result = memory_append_episode(
|
||||
user_id="u",
|
||||
agent_id="a",
|
||||
session_id="s",
|
||||
episode_summary="api_key=sk-secret",
|
||||
client=FakeClient(),
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["reason"] == "secret_like_content"
|
||||
|
||||
|
||||
def test_append_episode_rejects_password():
|
||||
result = memory_append_episode(
|
||||
user_id="u",
|
||||
agent_id="a",
|
||||
session_id="s",
|
||||
episode_summary="password=hunter2",
|
||||
client=FakeClient(),
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["reason"] == "secret_like_content"
|
||||
|
||||
|
||||
def test_append_episode_rejects_raw_transcript():
|
||||
content = "\n".join(["User: hi", "Assistant: hello", "User: remember this", "Assistant: ok"])
|
||||
result = memory_append_episode(user_id="u", agent_id="a", session_id="s", episode_summary=content, client=FakeClient())
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["reason"] == "raw_chat_transcript"
|
||||
|
||||
|
||||
def test_append_episode_accepts_stable_preference():
|
||||
client = FakeClient()
|
||||
result = memory_append_episode(
|
||||
user_id="u",
|
||||
agent_id="a",
|
||||
session_id="s",
|
||||
episode_summary="用户稳定偏好:以后所有技术方案都使用中文输出。",
|
||||
tags=["preference"],
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert client.calls[0][0] == "append"
|
||||
|
||||
|
||||
def test_upsert_uses_long_term_namespace_when_provided():
|
||||
client = FakeClient()
|
||||
namespace = "user/u/long_term"
|
||||
result = memory_upsert(
|
||||
user_id="u",
|
||||
agent_id="a",
|
||||
namespace=namespace,
|
||||
memory_type="preference",
|
||||
content="用户稳定偏好:使用中文输出。",
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert client.calls[0][1]["namespace"] == namespace
|
||||
|
||||
|
||||
def test_feedback_calls_correct_endpoint():
|
||||
client = FakeClient()
|
||||
result = memory_feedback(user_id="u", agent_id="a", memory_id="mem_1", feedback="reject", client=client)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert client.calls[0] == ("feedback", "mem_1", {"user_id": "u", "agent_id": "a", "feedback": "incorrect", "comment": None})
|
||||
|
||||
42
plugins/memory-gateway-agent/tools.py
Normal file
42
plugins/memory-gateway-agent/tools.py
Normal file
@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from .memory_gateway_plugin import tools as impl
|
||||
except ImportError:
|
||||
from memory_gateway_plugin import tools as impl
|
||||
|
||||
|
||||
def _json_result(payload: dict[str, Any]) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def memory_search(args: dict[str, Any], **_: Any) -> str:
|
||||
return _json_result(impl.memory_search(**args))
|
||||
|
||||
|
||||
def memory_append_episode(args: dict[str, Any], **_: Any) -> str:
|
||||
return _json_result(impl.memory_append_episode(**args))
|
||||
|
||||
|
||||
def memory_commit_session(args: dict[str, Any], **_: Any) -> str:
|
||||
return _json_result(impl.memory_commit_session(**args))
|
||||
|
||||
|
||||
def memory_upsert(args: dict[str, Any], **_: Any) -> str:
|
||||
return _json_result(impl.memory_upsert(**args))
|
||||
|
||||
|
||||
def memory_feedback(args: dict[str, Any], **_: Any) -> str:
|
||||
return _json_result(impl.memory_feedback(**args))
|
||||
|
||||
|
||||
HANDLERS = {
|
||||
"memory_search": memory_search,
|
||||
"memory_append_episode": memory_append_episode,
|
||||
"memory_commit_session": memory_commit_session,
|
||||
"memory_upsert": memory_upsert,
|
||||
"memory_feedback": memory_feedback,
|
||||
}
|
||||
Reference in New Issue
Block a user