upstash-hermes-memory 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- upstash_hermes_memory-0.1.0/.gitignore +18 -0
- upstash_hermes_memory-0.1.0/CHANGELOG.md +7 -0
- upstash_hermes_memory-0.1.0/PKG-INFO +76 -0
- upstash_hermes_memory-0.1.0/README.md +65 -0
- upstash_hermes_memory-0.1.0/e2e/hermes_e2e.py +296 -0
- upstash_hermes_memory-0.1.0/package.json +6 -0
- upstash_hermes_memory-0.1.0/pyproject.toml +21 -0
- upstash_hermes_memory-0.1.0/src/upstash_hermes_memory/__init__.py +27 -0
- upstash_hermes_memory-0.1.0/src/upstash_hermes_memory/_search.py +91 -0
- upstash_hermes_memory-0.1.0/src/upstash_hermes_memory/_telemetry.py +27 -0
- upstash_hermes_memory-0.1.0/src/upstash_hermes_memory/_version.py +2 -0
- upstash_hermes_memory-0.1.0/src/upstash_hermes_memory/chat_history.py +170 -0
- upstash_hermes_memory-0.1.0/src/upstash_hermes_memory/memory.py +139 -0
- upstash_hermes_memory-0.1.0/src/upstash_hermes_memory/provider.py +456 -0
- upstash_hermes_memory-0.1.0/tests/conftest.py +60 -0
- upstash_hermes_memory-0.1.0/tests/test_layout.py +103 -0
- upstash_hermes_memory-0.1.0/tests/test_package.py +65 -0
- upstash_hermes_memory-0.1.0/tests/test_provider.py +267 -0
- upstash_hermes_memory-0.1.0/tests/test_redis.py +140 -0
- upstash_hermes_memory-0.1.0/tests/test_version.py +40 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# upstash-hermes-memory
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 6269e3b: First release: an Upstash Redis memory provider for Hermes Agent. It recalls related memories into each turn, saves every session as a searchable transcript, mirrors the built-in `memory` tool, and gives the model `upstash_memory_save` / `_search` / `_forget` and `upstash_chat_search`. It reads and writes the same keys and Redis Search indexes as `AgentMemory` and `ChatHistory` in `@upstash/agentkit-sdk`, so memories are shared with AgentKit apps on the same database.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: upstash-hermes-memory
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Upstash Redis memory provider for Hermes Agent: long-term memory, searchable chat history and recall on Redis Search, with no separate vector database.
|
|
5
|
+
Project-URL: Homepage, https://github.com/upstash/agentkit
|
|
6
|
+
Project-URL: Repository, https://github.com/upstash/agentkit
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: upstash-redis>=1.8.0
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# upstash-hermes-memory
|
|
13
|
+
|
|
14
|
+
An Upstash Redis memory provider for [Hermes Agent](https://hermes-agent.nousresearch.com), built on
|
|
15
|
+
the same Redis Search layout as `@upstash/agentkit-sdk`'s `AgentMemory` and `ChatHistory`. A memory
|
|
16
|
+
your AI SDK app saves is recalled by your Hermes agent, and the other way round.
|
|
17
|
+
|
|
18
|
+
## What it does in Hermes
|
|
19
|
+
|
|
20
|
+
- **Recall:** before each turn, the memories closest to the user's message are added to the prompt.
|
|
21
|
+
- **Chat history:** after each turn, the session's transcript is saved as a searchable document.
|
|
22
|
+
- **Mirroring:** facts the built-in `memory` tool adds are also saved to Redis.
|
|
23
|
+
- **Tools:** `upstash_memory_save`, `upstash_memory_search`, `upstash_memory_forget` and
|
|
24
|
+
`upstash_chat_search` (search past conversations).
|
|
25
|
+
|
|
26
|
+
Subagents, cron jobs and context flushes can read memory but never write it.
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# into the Python environment Hermes runs in
|
|
32
|
+
pip install upstash-hermes-memory
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then select it in Hermes's `config.yaml`:
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
memory:
|
|
39
|
+
provider: upstash
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
And give it a database, in your environment or `~/.hermes/.env` (`hermes memory setup` asks for
|
|
43
|
+
these):
|
|
44
|
+
|
|
45
|
+
| Variable | |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` | Required. From the [Upstash console](https://console.upstash.com/redis). |
|
|
48
|
+
| `UPSTASH_HERMES_USER_ID` | Whose memories these are. Defaults to the gateway user, then the Hermes profile name, then `default`. |
|
|
49
|
+
| `UPSTASH_HERMES_MEMORY_PREFIX`, `UPSTASH_HERMES_CHAT_PREFIX` | Key prefixes. Default `agentkit:memory` and `agentkit:chat`, the TS SDK's defaults, so the two share memories. |
|
|
50
|
+
|
|
51
|
+
## Redis layout
|
|
52
|
+
|
|
53
|
+
| | Key | Index |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| Memory | `agentkit:memory:<user>:<id>` → JSON `{text, userId, createdAt, ...metadata}` | `agentkit_memory` (`text`, `userId`) |
|
|
56
|
+
| Chat | `agentkit:chat:<user>:<session>` → JSON `{userId, sessionId, userMessages, modelMessages, messages, createdAt, updatedAt}` | `agentkit_chat` |
|
|
57
|
+
|
|
58
|
+
User and session ids are used as they are, so the same id reads the same keys from Hermes and the
|
|
59
|
+
TS SDK. Like the TS SDK, the provider refuses a user id with `:` (the key separator), and keeps no
|
|
60
|
+
transcript for a session id with one. Indexes are created on first use. `AgentMemory` and `ChatHistory` are importable on their own for
|
|
61
|
+
use outside Hermes.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv sync --all-packages && pnpm py:lint && pnpm py:test # Redis tests need UPSTASH_REDIS_REST_* set
|
|
67
|
+
uv run python python/hermes-memory/e2e/hermes_e2e.py # a real Hermes session, needs OPENAI_API_KEY
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The e2e script installs the package into a temporary directory and runs Hermes with a temporary
|
|
71
|
+
`HERMES_HOME`, so your own Hermes install is not touched. Hermes's built-in memory is turned off
|
|
72
|
+
there. One session saves two facts; a fresh session started right after must name them without
|
|
73
|
+
calling any tool (checked in Hermes's `state.db`), so they can only come from the automatic recall;
|
|
74
|
+
and a control session as another user must not know them. The script then checks the keys and
|
|
75
|
+
transcripts in Redis (and, if `packages/sdk` is built, that the TS `AgentMemory` recalls them too)
|
|
76
|
+
before cleaning up.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# upstash-hermes-memory
|
|
2
|
+
|
|
3
|
+
An Upstash Redis memory provider for [Hermes Agent](https://hermes-agent.nousresearch.com), built on
|
|
4
|
+
the same Redis Search layout as `@upstash/agentkit-sdk`'s `AgentMemory` and `ChatHistory`. A memory
|
|
5
|
+
your AI SDK app saves is recalled by your Hermes agent, and the other way round.
|
|
6
|
+
|
|
7
|
+
## What it does in Hermes
|
|
8
|
+
|
|
9
|
+
- **Recall:** before each turn, the memories closest to the user's message are added to the prompt.
|
|
10
|
+
- **Chat history:** after each turn, the session's transcript is saved as a searchable document.
|
|
11
|
+
- **Mirroring:** facts the built-in `memory` tool adds are also saved to Redis.
|
|
12
|
+
- **Tools:** `upstash_memory_save`, `upstash_memory_search`, `upstash_memory_forget` and
|
|
13
|
+
`upstash_chat_search` (search past conversations).
|
|
14
|
+
|
|
15
|
+
Subagents, cron jobs and context flushes can read memory but never write it.
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# into the Python environment Hermes runs in
|
|
21
|
+
pip install upstash-hermes-memory
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then select it in Hermes's `config.yaml`:
|
|
25
|
+
|
|
26
|
+
```yaml
|
|
27
|
+
memory:
|
|
28
|
+
provider: upstash
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
And give it a database, in your environment or `~/.hermes/.env` (`hermes memory setup` asks for
|
|
32
|
+
these):
|
|
33
|
+
|
|
34
|
+
| Variable | |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` | Required. From the [Upstash console](https://console.upstash.com/redis). |
|
|
37
|
+
| `UPSTASH_HERMES_USER_ID` | Whose memories these are. Defaults to the gateway user, then the Hermes profile name, then `default`. |
|
|
38
|
+
| `UPSTASH_HERMES_MEMORY_PREFIX`, `UPSTASH_HERMES_CHAT_PREFIX` | Key prefixes. Default `agentkit:memory` and `agentkit:chat`, the TS SDK's defaults, so the two share memories. |
|
|
39
|
+
|
|
40
|
+
## Redis layout
|
|
41
|
+
|
|
42
|
+
| | Key | Index |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| Memory | `agentkit:memory:<user>:<id>` → JSON `{text, userId, createdAt, ...metadata}` | `agentkit_memory` (`text`, `userId`) |
|
|
45
|
+
| Chat | `agentkit:chat:<user>:<session>` → JSON `{userId, sessionId, userMessages, modelMessages, messages, createdAt, updatedAt}` | `agentkit_chat` |
|
|
46
|
+
|
|
47
|
+
User and session ids are used as they are, so the same id reads the same keys from Hermes and the
|
|
48
|
+
TS SDK. Like the TS SDK, the provider refuses a user id with `:` (the key separator), and keeps no
|
|
49
|
+
transcript for a session id with one. Indexes are created on first use. `AgentMemory` and `ChatHistory` are importable on their own for
|
|
50
|
+
use outside Hermes.
|
|
51
|
+
|
|
52
|
+
## Development
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uv sync --all-packages && pnpm py:lint && pnpm py:test # Redis tests need UPSTASH_REDIS_REST_* set
|
|
56
|
+
uv run python python/hermes-memory/e2e/hermes_e2e.py # a real Hermes session, needs OPENAI_API_KEY
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The e2e script installs the package into a temporary directory and runs Hermes with a temporary
|
|
60
|
+
`HERMES_HOME`, so your own Hermes install is not touched. Hermes's built-in memory is turned off
|
|
61
|
+
there. One session saves two facts; a fresh session started right after must name them without
|
|
62
|
+
calling any tool (checked in Hermes's `state.db`), so they can only come from the automatic recall;
|
|
63
|
+
and a control session as another user must not know them. The script then checks the keys and
|
|
64
|
+
transcripts in Redis (and, if `packages/sdk` is built, that the TS `AgentMemory` recalls them too)
|
|
65
|
+
before cleaning up.
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""End-to-end check of the provider inside a real Hermes Agent, against a real Upstash Redis.
|
|
2
|
+
|
|
3
|
+
uv run python python/hermes-memory/e2e/hermes_e2e.py [--keep] [--model gpt-5-mini]
|
|
4
|
+
|
|
5
|
+
What it does:
|
|
6
|
+
1. Builds the wheel and installs it (plus upstash-redis) into a temporary directory that is put on
|
|
7
|
+
Hermes's PYTHONPATH. Hermes's own install and ~/.hermes are not touched: Hermes runs with a
|
|
8
|
+
temporary HERMES_HOME whose config.yaml selects `memory.provider: upstash`.
|
|
9
|
+
2. Session 1 asks the agent to remember two facts. Session 2, a fresh session started right away,
|
|
10
|
+
asks for them back. Hermes's built-in memory is off, and session 2 must answer without calling
|
|
11
|
+
a tool (read from Hermes's state.db), so the facts can only come from the provider's automatic
|
|
12
|
+
recall. A control session as another user must not know them.
|
|
13
|
+
3. Checks Redis: the memories and both transcripts landed under a unique prefix, in the documents
|
|
14
|
+
and indexes the TS SDK uses. If packages/sdk is built, the TS AgentMemory recalls the same
|
|
15
|
+
memory too.
|
|
16
|
+
4. Drops the e2e indexes and deletes the keys (unless --keep).
|
|
17
|
+
|
|
18
|
+
Needs: Hermes installed (`hermes` on PATH), UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN and
|
|
19
|
+
OPENAI_API_KEY in the environment or the repo's .env.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import shutil
|
|
26
|
+
import sqlite3
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
import time
|
|
31
|
+
import uuid
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
REPO = Path(__file__).resolve().parents[3]
|
|
35
|
+
PACKAGE = REPO / "python" / "hermes-memory"
|
|
36
|
+
USER_ID = "e2e-user"
|
|
37
|
+
FACT_PROMPT = (
|
|
38
|
+
"Please remember this for future conversations: my cat is called Pamuk and my favourite "
|
|
39
|
+
"programming language is Elixir. Save it to your long-term memory."
|
|
40
|
+
)
|
|
41
|
+
RECALL_PROMPT = (
|
|
42
|
+
"What is my cat called, and what is my favourite programming language? "
|
|
43
|
+
"Answer from what you remember about me, in one sentence."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_dotenv(path: Path) -> None:
|
|
48
|
+
if not path.exists():
|
|
49
|
+
return
|
|
50
|
+
for line in path.read_text().splitlines():
|
|
51
|
+
if "=" in line and not line.lstrip().startswith("#"):
|
|
52
|
+
key, value = line.split("=", 1)
|
|
53
|
+
os.environ.setdefault(key.strip(), value.strip().strip('"'))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def hermes_python() -> list[str]:
|
|
57
|
+
"""Hermes's own interpreter and entry script. Its launcher unsets PYTHONPATH, so skip it."""
|
|
58
|
+
launcher = shutil.which("hermes")
|
|
59
|
+
if not launcher:
|
|
60
|
+
sys.exit("hermes is not on PATH: install Hermes Agent first")
|
|
61
|
+
home = Path(os.environ.get("HERMES_INSTALL_DIR", Path.home() / ".hermes" / "hermes-agent"))
|
|
62
|
+
python, script = home / "venv" / "bin" / "python", home / "hermes"
|
|
63
|
+
if not (python.exists() and script.exists()):
|
|
64
|
+
sys.exit(f"cannot find Hermes's venv under {home} (set HERMES_INSTALL_DIR)")
|
|
65
|
+
return [str(python), str(script)]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def step(title: str) -> None:
|
|
69
|
+
print(f"\n== {title} ==", flush=True)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main() -> None:
|
|
73
|
+
parser = argparse.ArgumentParser()
|
|
74
|
+
parser.add_argument("--model", default="gpt-5-mini")
|
|
75
|
+
parser.add_argument("--keep", action="store_true", help="leave the keys and indexes in Redis")
|
|
76
|
+
args = parser.parse_args()
|
|
77
|
+
|
|
78
|
+
load_dotenv(REPO / ".env")
|
|
79
|
+
for name in ("UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN", "OPENAI_API_KEY"):
|
|
80
|
+
if not os.environ.get(name):
|
|
81
|
+
sys.exit(f"{name} is not set")
|
|
82
|
+
|
|
83
|
+
from upstash_hermes_memory import AgentMemory, ChatHistory
|
|
84
|
+
from upstash_redis import Redis
|
|
85
|
+
|
|
86
|
+
run = uuid.uuid4().hex[:8]
|
|
87
|
+
memory_prefix, chat_prefix = f"e2e:hermes:{run}:memory", f"e2e:hermes:{run}:chat"
|
|
88
|
+
redis = Redis.from_env()
|
|
89
|
+
memory = AgentMemory(redis, prefix=memory_prefix, enable_telemetry=False)
|
|
90
|
+
chats = ChatHistory(redis, prefix=chat_prefix, enable_telemetry=False)
|
|
91
|
+
work = Path(tempfile.mkdtemp(prefix="hermes-e2e-"))
|
|
92
|
+
failures: list[str] = []
|
|
93
|
+
|
|
94
|
+
def check(ok: bool, what: str) -> None:
|
|
95
|
+
print(f" {'ok ' if ok else 'FAIL'} {what}")
|
|
96
|
+
if not ok:
|
|
97
|
+
failures.append(what)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
step("install the provider next to Hermes (temporary)")
|
|
101
|
+
dist, site, home = work / "dist", work / "site", work / "home"
|
|
102
|
+
subprocess.run(
|
|
103
|
+
["uv", "build", "--wheel", "--package", "upstash-hermes-memory", "--out-dir", dist],
|
|
104
|
+
cwd=REPO,
|
|
105
|
+
check=True,
|
|
106
|
+
capture_output=True,
|
|
107
|
+
)
|
|
108
|
+
(wheel,) = dist.glob("*.whl")
|
|
109
|
+
# --no-deps: httpx and friends come from Hermes's venv, only upstash-redis is missing there.
|
|
110
|
+
subprocess.run(
|
|
111
|
+
[
|
|
112
|
+
"uv",
|
|
113
|
+
"pip",
|
|
114
|
+
"install",
|
|
115
|
+
"--quiet",
|
|
116
|
+
"--no-deps",
|
|
117
|
+
"--target",
|
|
118
|
+
site,
|
|
119
|
+
wheel,
|
|
120
|
+
"upstash-redis==1.8.0",
|
|
121
|
+
],
|
|
122
|
+
check=True,
|
|
123
|
+
)
|
|
124
|
+
home.mkdir()
|
|
125
|
+
# Hermes's own stores (MEMORY.md / USER.md) default to on. Off, they cannot carry the
|
|
126
|
+
# facts from session 1 to session 2: the provider is the only way across.
|
|
127
|
+
(home / "config.yaml").write_text(
|
|
128
|
+
f"model:\n provider: openai-api\n default: {args.model}\n"
|
|
129
|
+
"memory:\n provider: upstash\n memory_enabled: false\n user_profile_enabled: false\n"
|
|
130
|
+
)
|
|
131
|
+
env = {
|
|
132
|
+
**os.environ,
|
|
133
|
+
"HERMES_HOME": str(home),
|
|
134
|
+
"PYTHONPATH": str(site),
|
|
135
|
+
"UPSTASH_HERMES_MEMORY_PREFIX": memory_prefix,
|
|
136
|
+
"UPSTASH_HERMES_CHAT_PREFIX": chat_prefix,
|
|
137
|
+
}
|
|
138
|
+
print(f" HERMES_HOME={home}\n prefixes {memory_prefix} / {chat_prefix}")
|
|
139
|
+
|
|
140
|
+
def hermes(
|
|
141
|
+
prompt: str, user: str = USER_ID, hermes_home: Path = home
|
|
142
|
+
) -> tuple[str, list[str]]:
|
|
143
|
+
"""Run one fresh one-shot session; return its answer and the tools it called."""
|
|
144
|
+
cmd = [*hermes_python(), "-z", prompt, "--provider", "openai-api", "-m", args.model]
|
|
145
|
+
done = subprocess.run(
|
|
146
|
+
cmd,
|
|
147
|
+
check=False,
|
|
148
|
+
env={**env, "HERMES_HOME": str(hermes_home), "UPSTASH_HERMES_USER_ID": user},
|
|
149
|
+
capture_output=True,
|
|
150
|
+
text=True,
|
|
151
|
+
timeout=300,
|
|
152
|
+
)
|
|
153
|
+
if done.returncode != 0:
|
|
154
|
+
print(done.stdout, done.stderr, sep="\n")
|
|
155
|
+
raise SystemExit(f"hermes exited with {done.returncode}")
|
|
156
|
+
# Hermes records every session in $HERMES_HOME/state.db, tool results included.
|
|
157
|
+
with sqlite3.connect(hermes_home / "state.db") as db:
|
|
158
|
+
(session,) = db.execute(
|
|
159
|
+
"SELECT session_id FROM messages WHERE role = 'user' AND content = ?"
|
|
160
|
+
" ORDER BY id DESC LIMIT 1",
|
|
161
|
+
(prompt,),
|
|
162
|
+
).fetchone()
|
|
163
|
+
tools = [
|
|
164
|
+
row[0]
|
|
165
|
+
for row in db.execute(
|
|
166
|
+
"SELECT tool_name FROM messages WHERE session_id = ? AND role = 'tool'"
|
|
167
|
+
" ORDER BY id",
|
|
168
|
+
(session,),
|
|
169
|
+
)
|
|
170
|
+
]
|
|
171
|
+
answer = done.stdout.strip()
|
|
172
|
+
print(" < " + answer.replace("\n", "\n "))
|
|
173
|
+
print(f" tools called: {tools or 'none'}")
|
|
174
|
+
return answer, tools
|
|
175
|
+
|
|
176
|
+
# The sessions run back to back, with no waiting in between: a fact the agent saved must
|
|
177
|
+
# be recallable as soon as the save tool returns.
|
|
178
|
+
step("session 1: tell the agent two facts")
|
|
179
|
+
print(" > " + FACT_PROMPT)
|
|
180
|
+
_, tools_1 = hermes(FACT_PROMPT)
|
|
181
|
+
check(
|
|
182
|
+
tools_1.count("upstash_memory_save") >= 1, "session 1 saved through upstash_memory_save"
|
|
183
|
+
)
|
|
184
|
+
check("memory" not in tools_1, "session 1 did not use Hermes's built-in memory tool")
|
|
185
|
+
|
|
186
|
+
step("session 2: a fresh session asks for them back")
|
|
187
|
+
print(" > " + RECALL_PROMPT)
|
|
188
|
+
answer, tools_2 = hermes(RECALL_PROMPT)
|
|
189
|
+
check("Pamuk" in answer, "the answer names the cat (Pamuk)")
|
|
190
|
+
check("Elixir" in answer, "the answer names the language (Elixir)")
|
|
191
|
+
# With no tool calls, the facts can only have come from the automatic recall (prefetch).
|
|
192
|
+
check(tools_2 == [], "session 2 called no tools: the facts came from automatic recall")
|
|
193
|
+
|
|
194
|
+
step("control: the same question as a user with no memories")
|
|
195
|
+
print(" > " + RECALL_PROMPT)
|
|
196
|
+
# Its own HERMES_HOME: Hermes's built-in session_search reads the sessions in state.db,
|
|
197
|
+
# whoever they belonged to, so a shared home would let it find sessions 1 and 2. This
|
|
198
|
+
# checks the Redis side only, which is what the provider controls.
|
|
199
|
+
control_home = work / "control-home"
|
|
200
|
+
control_home.mkdir()
|
|
201
|
+
shutil.copy(home / "config.yaml", control_home / "config.yaml")
|
|
202
|
+
control, _ = hermes(RECALL_PROMPT, user=f"{USER_ID}-control", hermes_home=control_home)
|
|
203
|
+
check("Pamuk" not in control, "another user's session does not know the cat's name")
|
|
204
|
+
|
|
205
|
+
step("Hermes's built-in stores stayed empty")
|
|
206
|
+
builtin = [
|
|
207
|
+
p for p in (home / "memories").glob("*") if p.is_file() and p.read_text().strip()
|
|
208
|
+
]
|
|
209
|
+
print(f" {home / 'memories'}: {[p.name for p in builtin] or 'empty'}")
|
|
210
|
+
check(not builtin, "MEMORY.md / USER.md hold nothing")
|
|
211
|
+
|
|
212
|
+
step("Redis: memories")
|
|
213
|
+
memory.index.wait_indexing()
|
|
214
|
+
saved = memory.list(user_id=USER_ID)
|
|
215
|
+
for record in saved:
|
|
216
|
+
print(
|
|
217
|
+
f" memory {memory_prefix}:{USER_ID}:{record.id} -> {record.text!r} {record.metadata}"
|
|
218
|
+
)
|
|
219
|
+
text = " ".join(r.text for r in saved)
|
|
220
|
+
check("Pamuk" in text, "a memory mentioning Pamuk was saved")
|
|
221
|
+
check("Elixir" in text, "a memory mentioning Elixir was saved")
|
|
222
|
+
|
|
223
|
+
step("Redis: chat history")
|
|
224
|
+
chats.index.wait_indexing()
|
|
225
|
+
sessions = chats.list_chats(user_id=USER_ID)
|
|
226
|
+
for doc in sessions:
|
|
227
|
+
roles = [m["role"] for m in doc["messages"]]
|
|
228
|
+
print(f" chat {chat_prefix}:{USER_ID}:{doc['sessionId']} messages={roles}")
|
|
229
|
+
check(len(sessions) == 2, "both sessions were saved as transcripts")
|
|
230
|
+
check(
|
|
231
|
+
any("Pamuk" in d.get("userMessages", "") for d in sessions),
|
|
232
|
+
"session 1's transcript holds the user's message",
|
|
233
|
+
)
|
|
234
|
+
hits = chats.search_chats("cat Pamuk", user_id=USER_ID)
|
|
235
|
+
check(bool(hits), "the transcripts are searchable (search_chats 'cat Pamuk')")
|
|
236
|
+
|
|
237
|
+
step("Redis: layout shared with the TS SDK")
|
|
238
|
+
key = f"{memory_prefix}:{USER_ID}:{saved[0].id}" if saved else ""
|
|
239
|
+
doc = redis.execute(["JSON.GET", key]) if key else None
|
|
240
|
+
print(f" JSON.GET {key} -> {json.dumps(doc)}")
|
|
241
|
+
check(
|
|
242
|
+
isinstance(doc, dict) and {"text", "userId", "createdAt"} <= set(doc),
|
|
243
|
+
"memory documents have the AgentMemory shape",
|
|
244
|
+
)
|
|
245
|
+
ts_dist = REPO / "packages" / "sdk" / "dist" / "index.js"
|
|
246
|
+
if ts_dist.exists() and shutil.which("node"):
|
|
247
|
+
script = (
|
|
248
|
+
"const { AgentMemory } = await import(process.argv[1]);"
|
|
249
|
+
"const { Redis } = await import('@upstash/redis');"
|
|
250
|
+
"const m = new AgentMemory({ redis: Redis.fromEnv(), prefix: process.argv[2],"
|
|
251
|
+
" enableTelemetry: false });"
|
|
252
|
+
"const hits = await m.recall({ query: 'cat Pamuk', userId: process.argv[3] });"
|
|
253
|
+
"console.log(JSON.stringify(hits.map((h) => h.text)));"
|
|
254
|
+
)
|
|
255
|
+
out = subprocess.run(
|
|
256
|
+
["node", "--input-type=module", "-e", script, str(ts_dist), memory_prefix, USER_ID],
|
|
257
|
+
check=False,
|
|
258
|
+
cwd=REPO / "packages" / "sdk",
|
|
259
|
+
capture_output=True,
|
|
260
|
+
text=True,
|
|
261
|
+
)
|
|
262
|
+
print(f" TS AgentMemory.recall -> {out.stdout.strip() or out.stderr.strip()[:300]}")
|
|
263
|
+
check("Pamuk" in out.stdout, "the TS SDK recalls the memory Hermes saved")
|
|
264
|
+
else:
|
|
265
|
+
print(" (skipped: build packages/sdk to cross-check with the TS SDK)")
|
|
266
|
+
finally:
|
|
267
|
+
if args.keep:
|
|
268
|
+
print(
|
|
269
|
+
f"\n--keep: left {memory_prefix}* and {chat_prefix}* in Redis, HERMES_HOME {work}"
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
for index in (memory.index, chats.index):
|
|
273
|
+
try:
|
|
274
|
+
index.drop()
|
|
275
|
+
except Exception as error: # noqa: BLE001 - cleanup is best effort
|
|
276
|
+
print(f" could not drop {index.name}: {error}")
|
|
277
|
+
for prefix in (memory_prefix, chat_prefix):
|
|
278
|
+
cursor = 0
|
|
279
|
+
while True:
|
|
280
|
+
cursor, keys = redis.scan(cursor, match=f"{prefix}*", count=200)
|
|
281
|
+
if keys:
|
|
282
|
+
redis.delete(*keys)
|
|
283
|
+
if int(cursor) == 0:
|
|
284
|
+
break
|
|
285
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
286
|
+
|
|
287
|
+
print(f"\n{'E2E PASSED' if not failures else 'E2E FAILED: ' + '; '.join(failures)}")
|
|
288
|
+
sys.exit(1 if failures else 0)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if __name__ == "__main__":
|
|
292
|
+
start = time.monotonic()
|
|
293
|
+
try:
|
|
294
|
+
main()
|
|
295
|
+
finally:
|
|
296
|
+
print(f"({time.monotonic() - start:.0f}s)")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "upstash-hermes-memory"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Upstash Redis memory provider for Hermes Agent: long-term memory, searchable chat history and recall on Redis Search, with no separate vector database."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
dependencies = ["upstash-redis>=1.8.0"]
|
|
9
|
+
|
|
10
|
+
# Hermes discovers pip-installed memory providers through this group; the entry-point name is the
|
|
11
|
+
# value of `memory.provider` in Hermes's config.yaml.
|
|
12
|
+
[project.entry-points."hermes_agent.memory_providers"]
|
|
13
|
+
upstash = "upstash_hermes_memory:register"
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/upstash/agentkit"
|
|
17
|
+
Repository = "https://github.com/upstash/agentkit"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Upstash Redis memory provider for Hermes Agent.
|
|
2
|
+
|
|
3
|
+
Hermes stopped taking new memory providers in its own repository: they ship as standalone plugins
|
|
4
|
+
and are listed in its plugin catalog. This package is that plugin, and it lives in the AgentKit
|
|
5
|
+
monorepo rather than in a repository of its own so the Python port reads and writes exactly the
|
|
6
|
+
keys and Redis Search indexes the TypeScript SDK does. A memory saved from an AI SDK app is then
|
|
7
|
+
recalled by a Hermes agent on the same database.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._version import VERSION
|
|
11
|
+
from .chat_history import ChatHistory
|
|
12
|
+
from .memory import AgentMemory, MemoryRecord
|
|
13
|
+
from .provider import UpstashMemoryProvider
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"VERSION",
|
|
17
|
+
"AgentMemory",
|
|
18
|
+
"ChatHistory",
|
|
19
|
+
"MemoryRecord",
|
|
20
|
+
"UpstashMemoryProvider",
|
|
21
|
+
"register",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register(ctx) -> None:
|
|
26
|
+
"""Hermes plugin entry point (`hermes_agent.memory_providers`)."""
|
|
27
|
+
ctx.register_memory_provider(UpstashMemoryProvider())
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""A Redis Search index that is created on first use, shared by AgentMemory and ChatHistory.
|
|
2
|
+
|
|
3
|
+
The commands are the ones `ReactiveSearchIndex` sends from the TS SDK, argument for argument, so
|
|
4
|
+
an index created from either language is the same index.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
# (field, type, no_tokenize)
|
|
13
|
+
Field = tuple[str, str, bool]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def index_name_for(prefix: str) -> str:
|
|
17
|
+
"""The TS default: every character outside [a-zA-Z0-9_] becomes `_`."""
|
|
18
|
+
return "".join(c if c.isascii() and (c.isalnum() or c == "_") else "_" for c in prefix)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def dumps(value: Any) -> str:
|
|
22
|
+
"""JSON the way JSON.stringify writes it, so stored documents match the TS SDK's."""
|
|
23
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SearchIndex:
|
|
27
|
+
def __init__(self, redis: Any, *, name: str, prefix: str, schema: Sequence[Field]):
|
|
28
|
+
self.redis = redis
|
|
29
|
+
self.name = name
|
|
30
|
+
self.prefix = prefix
|
|
31
|
+
self.schema = list(schema)
|
|
32
|
+
self._ready = False
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
|
|
35
|
+
def create_command(self) -> list[str]:
|
|
36
|
+
cmd = ["SEARCH.CREATE", self.name, "EXISTSOK", "ON", "JSON", "PREFIX", "1", self.prefix]
|
|
37
|
+
cmd.append("SCHEMA")
|
|
38
|
+
for field, type_, no_tokenize in self.schema:
|
|
39
|
+
cmd += [field, type_] + (["NOTOKENIZE"] if no_tokenize else [])
|
|
40
|
+
return cmd
|
|
41
|
+
|
|
42
|
+
def ensure(self) -> None:
|
|
43
|
+
"""Create the index if it does not exist yet. A no-op after the first call."""
|
|
44
|
+
if self._ready:
|
|
45
|
+
return
|
|
46
|
+
with self._lock:
|
|
47
|
+
if self._ready:
|
|
48
|
+
return
|
|
49
|
+
self.redis.execute(self.create_command())
|
|
50
|
+
self.redis.execute(["SEARCH.WAITINDEXING", self.name])
|
|
51
|
+
self._ready = True
|
|
52
|
+
|
|
53
|
+
def query(self, filter: dict[str, Any], limit: int | None = None) -> list[Any]:
|
|
54
|
+
self.ensure()
|
|
55
|
+
rows = self._query(filter, limit)
|
|
56
|
+
if not rows and self._raw_count(filter) < 0:
|
|
57
|
+
# The Python client turns a missing index into [], so an empty result is ambiguous.
|
|
58
|
+
# COUNT says -1 when the index is gone (another client dropped it): recreate, retry once.
|
|
59
|
+
self._ready = False
|
|
60
|
+
self.ensure()
|
|
61
|
+
rows = self._query(filter, limit)
|
|
62
|
+
return rows
|
|
63
|
+
|
|
64
|
+
def count(self, filter: dict[str, Any]) -> int:
|
|
65
|
+
self.ensure()
|
|
66
|
+
count = self._raw_count(filter)
|
|
67
|
+
if count < 0:
|
|
68
|
+
# -1 means the index is gone (dropped after we created it): recreate it and retry once.
|
|
69
|
+
self._ready = False
|
|
70
|
+
self.ensure()
|
|
71
|
+
count = self._raw_count(filter)
|
|
72
|
+
return max(count, 0)
|
|
73
|
+
|
|
74
|
+
def _query(self, filter: dict[str, Any], limit: int | None) -> list[Any]:
|
|
75
|
+
cmd = ["SEARCH.QUERY", self.name, dumps(filter)]
|
|
76
|
+
if limit is not None:
|
|
77
|
+
cmd += ["LIMIT", str(limit)]
|
|
78
|
+
return list(self.redis.execute(cmd) or [])
|
|
79
|
+
|
|
80
|
+
def _raw_count(self, filter: dict[str, Any]) -> int:
|
|
81
|
+
result = self.redis.execute(["SEARCH.COUNT", self.name, dumps(filter)])
|
|
82
|
+
return int(getattr(result, "count", result))
|
|
83
|
+
|
|
84
|
+
def wait_indexing(self) -> None:
|
|
85
|
+
"""Block until every write so far is searchable (and every delete has left the index)."""
|
|
86
|
+
self.ensure()
|
|
87
|
+
self.redis.execute(["SEARCH.WAITINDEXING", self.name])
|
|
88
|
+
|
|
89
|
+
def drop(self) -> None:
|
|
90
|
+
self.redis.execute(["SEARCH.DROP", self.name])
|
|
91
|
+
self._ready = False
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Tags the redis client's telemetry header, the Python counterpart of the TS SDK's addTelemetry."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ._version import VERSION
|
|
7
|
+
|
|
8
|
+
SDK_TELEMETRY = f"upstash-hermes-memory@{VERSION}"
|
|
9
|
+
|
|
10
|
+
_HEADER = "Upstash-Telemetry-Sdk"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def add_telemetry(redis: Any, *, enabled: bool = True) -> None:
|
|
14
|
+
"""Append this package's tag to the `Upstash-Telemetry-Sdk` header, once per client.
|
|
15
|
+
|
|
16
|
+
upstash-redis only sends the header when the client allows telemetry, so a client created
|
|
17
|
+
with `allow_telemetry=False` stays untagged, as does any process with
|
|
18
|
+
`UPSTASH_DISABLE_TELEMETRY` set.
|
|
19
|
+
"""
|
|
20
|
+
if not enabled or os.environ.get("UPSTASH_DISABLE_TELEMETRY"):
|
|
21
|
+
return
|
|
22
|
+
headers = getattr(redis, "_headers", None)
|
|
23
|
+
if not isinstance(headers, dict) or _HEADER not in headers:
|
|
24
|
+
return
|
|
25
|
+
tags = headers[_HEADER].split(",")
|
|
26
|
+
if SDK_TELEMETRY not in tags:
|
|
27
|
+
headers[_HEADER] = ",".join([*tags, SDK_TELEMETRY])
|