adk-perseus-vault-memory 0.3.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.
- adk_perseus_vault_memory-0.3.0/.github/workflows/publish.yml +46 -0
- adk_perseus_vault_memory-0.3.0/.github/workflows/test.yml +28 -0
- adk_perseus_vault_memory-0.3.0/.gitignore +7 -0
- adk_perseus_vault_memory-0.3.0/LICENSE +21 -0
- adk_perseus_vault_memory-0.3.0/PKG-INFO +162 -0
- adk_perseus_vault_memory-0.3.0/README.md +132 -0
- adk_perseus_vault_memory-0.3.0/adk_perseus_vault_memory/__init__.py +16 -0
- adk_perseus_vault_memory-0.3.0/adk_perseus_vault_memory/perseus_context.py +97 -0
- adk_perseus_vault_memory-0.3.0/adk_perseus_vault_memory/perseus_vault_memory_service.py +533 -0
- adk_perseus_vault_memory-0.3.0/adk_perseus_vault_memory/py.typed +0 -0
- adk_perseus_vault_memory-0.3.0/pyproject.toml +49 -0
- adk_perseus_vault_memory-0.3.0/tests/test_perseus_vault_memory_service.py +226 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: '3.12'
|
|
18
|
+
|
|
19
|
+
- name: Install build
|
|
20
|
+
run: pip install build==1.2.2.post1
|
|
21
|
+
|
|
22
|
+
- name: Build
|
|
23
|
+
run: python -m build
|
|
24
|
+
|
|
25
|
+
- uses: actions/upload-artifact@v4
|
|
26
|
+
with:
|
|
27
|
+
name: dist
|
|
28
|
+
path: dist/
|
|
29
|
+
|
|
30
|
+
publish:
|
|
31
|
+
needs: build
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
environment:
|
|
34
|
+
name: pypi
|
|
35
|
+
url: https://pypi.org/p/adk-perseus-vault-memory
|
|
36
|
+
permissions:
|
|
37
|
+
id-token: write
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/download-artifact@v4
|
|
40
|
+
with:
|
|
41
|
+
name: dist
|
|
42
|
+
path: dist/
|
|
43
|
+
|
|
44
|
+
- name: Publish to PyPI
|
|
45
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
46
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ${{ matrix.os }}
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
16
|
+
python-version: ['3.10', '3.12']
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
|
|
24
|
+
- name: Install
|
|
25
|
+
run: pip install -e ".[test]"
|
|
26
|
+
|
|
27
|
+
- name: Run tests
|
|
28
|
+
run: python -m pytest tests/ -q
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Perseus Computing
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: adk-perseus-vault-memory
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Persistent, local, encrypted cross-session memory for Google ADK agents — backed by Perseus Vault.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Perseus-Computing-LLC/adk-mimir-memory
|
|
6
|
+
Project-URL: Repository, https://github.com/Perseus-Computing-LLC/adk-mimir-memory
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/Perseus-Computing-LLC/adk-mimir-memory/issues
|
|
8
|
+
Project-URL: Perseus Vault, https://github.com/Perseus-Computing-LLC/perseus-vault
|
|
9
|
+
Author-email: Thomas Connally <51974392+tcconnally@users.noreply.github.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: adk,agent-memory,google-adk,mcp,memory,perseus-vault
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: google-adk>=1.0.0
|
|
24
|
+
Requires-Dist: typing-extensions>=4.4.0
|
|
25
|
+
Provides-Extra: perseus
|
|
26
|
+
Requires-Dist: perseus-ctx>=1.0.10; extra == 'perseus'
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# ADK Perseus Vault Memory
|
|
32
|
+
|
|
33
|
+
Persistent, local, encrypted cross-session memory for [Google ADK](https://github.com/google/adk-python) agents — backed by [Perseus Vault](https://github.com/Perseus-Computing-LLC/perseus-vault) (formerly "Mimir"/"Mneme").
|
|
34
|
+
|
|
35
|
+
## Why Perseus Vault?
|
|
36
|
+
|
|
37
|
+
| Backend | Dependencies | Encryption | Hybrid Search | Local |
|
|
38
|
+
|---|---|---|---|---|
|
|
39
|
+
| **InMemoryMemoryService** | None | ❌ | ❌ | ✅ |
|
|
40
|
+
| **VertexAiMemoryBankService** | GCP + Gemini | ❌ | Gemini-driven | ❌ |
|
|
41
|
+
| **VertexAiRagMemoryService** | GCP + RAG | ❌ | GCP vector | ❌ |
|
|
42
|
+
| **PerseusVaultMemoryService** | **Single binary** | **✅ AES-256** | **✅ BM25+FTS5+Dense** | **✅** |
|
|
43
|
+
|
|
44
|
+
- **Zero cloud dependencies** — a single Rust binary, SQLite database, fully local
|
|
45
|
+
- **AES-256-GCM encryption** at rest — your memory data stays private
|
|
46
|
+
- **Hybrid search** — BM25 keyword + FTS5 + dense vector search
|
|
47
|
+
- **30+ MCP tools** — remember, recall, synthesize, benchmark, federate, and more
|
|
48
|
+
- **Ebbinghaus confidence decay** — memories fade naturally, important ones persist
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install adk-perseus-vault-memory
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This package requires the `perseus-vault` binary. Download it from:
|
|
57
|
+
https://github.com/Perseus-Computing-LLC/perseus-vault/releases
|
|
58
|
+
|
|
59
|
+
Or build from source:
|
|
60
|
+
```bash
|
|
61
|
+
cargo install perseus-vault
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from google.adk.agents import Agent
|
|
68
|
+
from google.adk.runners import Runner
|
|
69
|
+
from google.adk.sessions import InMemorySessionService
|
|
70
|
+
from adk_perseus_vault_memory import PerseusVaultMemoryService
|
|
71
|
+
|
|
72
|
+
agent = Agent(
|
|
73
|
+
name="my_agent",
|
|
74
|
+
model="gemini-2.5-flash",
|
|
75
|
+
instruction="You are a helpful assistant with persistent memory.",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# The memory service is configured on the Runner, not on the Agent.
|
|
79
|
+
runner = Runner(
|
|
80
|
+
agent=agent,
|
|
81
|
+
app_name="my_app",
|
|
82
|
+
session_service=InMemorySessionService(),
|
|
83
|
+
memory_service=PerseusVaultMemoryService(db_path="~/.adk/vault.db"),
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
That's it. Sessions, events, and explicit memories are now persisted across restarts.
|
|
88
|
+
|
|
89
|
+
### Configuration
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
# Custom database location
|
|
93
|
+
PerseusVaultMemoryService(db_path="/data/agent_memory.db")
|
|
94
|
+
|
|
95
|
+
# Custom perseus-vault binary path (if not on $PATH)
|
|
96
|
+
PerseusVaultMemoryService(vault_binary="/usr/local/bin/perseus-vault")
|
|
97
|
+
|
|
98
|
+
# Both
|
|
99
|
+
PerseusVaultMemoryService(
|
|
100
|
+
db_path="/data/agent_memory.db",
|
|
101
|
+
vault_binary="/usr/local/bin/perseus-vault",
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Perseus Live Context (Optional)
|
|
106
|
+
|
|
107
|
+
This package also includes a drop-in agent with live workspace awareness via [Perseus](https://github.com/Perseus-Computing-LLC/perseus):
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from google.adk.runners import Runner
|
|
111
|
+
from google.adk.sessions import InMemorySessionService
|
|
112
|
+
from adk_perseus_vault_memory.perseus_context import perseus_context_agent
|
|
113
|
+
|
|
114
|
+
# The agent resolves @file, @search, @memory directives at inference time.
|
|
115
|
+
# Bind it on the Runner; run_async takes no `agent` argument.
|
|
116
|
+
runner = Runner(
|
|
117
|
+
agent=perseus_context_agent,
|
|
118
|
+
app_name="my_app",
|
|
119
|
+
session_service=InMemorySessionService(),
|
|
120
|
+
)
|
|
121
|
+
runner.run_async(
|
|
122
|
+
user_id="user",
|
|
123
|
+
session_id="session",
|
|
124
|
+
new_message=types.Content(role="user", parts=[types.Part.from_text(
|
|
125
|
+
text="What does the README say about deployment?"
|
|
126
|
+
)]),
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
pip install adk-perseus-vault-memory[perseus] # installs perseus-ctx
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Set directives via session state:
|
|
135
|
+
```python
|
|
136
|
+
session = await runner.session_service.create_session(
|
|
137
|
+
app_name="my_app",
|
|
138
|
+
user_id="user",
|
|
139
|
+
state={
|
|
140
|
+
"_perseus_directives": "@file AGENTS.md @file README.md @memory deployment",
|
|
141
|
+
"_perseus_workspace": "/path/to/project",
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## How It Works
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
┌─────────────┐ JSON-RPC (MCP stdio) ┌────────────────┐
|
|
150
|
+
│ ADK Agent │ ──────────────────────────▶ │ Perseus Vault │
|
|
151
|
+
│ (Python) │ ◀────────────────────────── │ (Rust) │
|
|
152
|
+
└─────────────┘ └───────┬────────┘
|
|
153
|
+
│
|
|
154
|
+
SQLite + FTS5
|
|
155
|
+
(AES-256-GCM)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The `PerseusVaultMemoryService` spawns a `perseus-vault` subprocess and communicates via JSON-RPC over stdin/stdout (MCP stdio transport). Each `add_session_to_memory`, `add_memory`, and `search_memory` call translates to a Perseus Vault MCP tool invocation.
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT — see [Perseus Vault](https://github.com/Perseus-Computing-LLC/perseus-vault) and [Perseus](https://github.com/Perseus-Computing-LLC/perseus) for the backing services.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# ADK Perseus Vault Memory
|
|
2
|
+
|
|
3
|
+
Persistent, local, encrypted cross-session memory for [Google ADK](https://github.com/google/adk-python) agents — backed by [Perseus Vault](https://github.com/Perseus-Computing-LLC/perseus-vault) (formerly "Mimir"/"Mneme").
|
|
4
|
+
|
|
5
|
+
## Why Perseus Vault?
|
|
6
|
+
|
|
7
|
+
| Backend | Dependencies | Encryption | Hybrid Search | Local |
|
|
8
|
+
|---|---|---|---|---|
|
|
9
|
+
| **InMemoryMemoryService** | None | ❌ | ❌ | ✅ |
|
|
10
|
+
| **VertexAiMemoryBankService** | GCP + Gemini | ❌ | Gemini-driven | ❌ |
|
|
11
|
+
| **VertexAiRagMemoryService** | GCP + RAG | ❌ | GCP vector | ❌ |
|
|
12
|
+
| **PerseusVaultMemoryService** | **Single binary** | **✅ AES-256** | **✅ BM25+FTS5+Dense** | **✅** |
|
|
13
|
+
|
|
14
|
+
- **Zero cloud dependencies** — a single Rust binary, SQLite database, fully local
|
|
15
|
+
- **AES-256-GCM encryption** at rest — your memory data stays private
|
|
16
|
+
- **Hybrid search** — BM25 keyword + FTS5 + dense vector search
|
|
17
|
+
- **30+ MCP tools** — remember, recall, synthesize, benchmark, federate, and more
|
|
18
|
+
- **Ebbinghaus confidence decay** — memories fade naturally, important ones persist
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install adk-perseus-vault-memory
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This package requires the `perseus-vault` binary. Download it from:
|
|
27
|
+
https://github.com/Perseus-Computing-LLC/perseus-vault/releases
|
|
28
|
+
|
|
29
|
+
Or build from source:
|
|
30
|
+
```bash
|
|
31
|
+
cargo install perseus-vault
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from google.adk.agents import Agent
|
|
38
|
+
from google.adk.runners import Runner
|
|
39
|
+
from google.adk.sessions import InMemorySessionService
|
|
40
|
+
from adk_perseus_vault_memory import PerseusVaultMemoryService
|
|
41
|
+
|
|
42
|
+
agent = Agent(
|
|
43
|
+
name="my_agent",
|
|
44
|
+
model="gemini-2.5-flash",
|
|
45
|
+
instruction="You are a helpful assistant with persistent memory.",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# The memory service is configured on the Runner, not on the Agent.
|
|
49
|
+
runner = Runner(
|
|
50
|
+
agent=agent,
|
|
51
|
+
app_name="my_app",
|
|
52
|
+
session_service=InMemorySessionService(),
|
|
53
|
+
memory_service=PerseusVaultMemoryService(db_path="~/.adk/vault.db"),
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That's it. Sessions, events, and explicit memories are now persisted across restarts.
|
|
58
|
+
|
|
59
|
+
### Configuration
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
# Custom database location
|
|
63
|
+
PerseusVaultMemoryService(db_path="/data/agent_memory.db")
|
|
64
|
+
|
|
65
|
+
# Custom perseus-vault binary path (if not on $PATH)
|
|
66
|
+
PerseusVaultMemoryService(vault_binary="/usr/local/bin/perseus-vault")
|
|
67
|
+
|
|
68
|
+
# Both
|
|
69
|
+
PerseusVaultMemoryService(
|
|
70
|
+
db_path="/data/agent_memory.db",
|
|
71
|
+
vault_binary="/usr/local/bin/perseus-vault",
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Perseus Live Context (Optional)
|
|
76
|
+
|
|
77
|
+
This package also includes a drop-in agent with live workspace awareness via [Perseus](https://github.com/Perseus-Computing-LLC/perseus):
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from google.adk.runners import Runner
|
|
81
|
+
from google.adk.sessions import InMemorySessionService
|
|
82
|
+
from adk_perseus_vault_memory.perseus_context import perseus_context_agent
|
|
83
|
+
|
|
84
|
+
# The agent resolves @file, @search, @memory directives at inference time.
|
|
85
|
+
# Bind it on the Runner; run_async takes no `agent` argument.
|
|
86
|
+
runner = Runner(
|
|
87
|
+
agent=perseus_context_agent,
|
|
88
|
+
app_name="my_app",
|
|
89
|
+
session_service=InMemorySessionService(),
|
|
90
|
+
)
|
|
91
|
+
runner.run_async(
|
|
92
|
+
user_id="user",
|
|
93
|
+
session_id="session",
|
|
94
|
+
new_message=types.Content(role="user", parts=[types.Part.from_text(
|
|
95
|
+
text="What does the README say about deployment?"
|
|
96
|
+
)]),
|
|
97
|
+
)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install adk-perseus-vault-memory[perseus] # installs perseus-ctx
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Set directives via session state:
|
|
105
|
+
```python
|
|
106
|
+
session = await runner.session_service.create_session(
|
|
107
|
+
app_name="my_app",
|
|
108
|
+
user_id="user",
|
|
109
|
+
state={
|
|
110
|
+
"_perseus_directives": "@file AGENTS.md @file README.md @memory deployment",
|
|
111
|
+
"_perseus_workspace": "/path/to/project",
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## How It Works
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
┌─────────────┐ JSON-RPC (MCP stdio) ┌────────────────┐
|
|
120
|
+
│ ADK Agent │ ──────────────────────────▶ │ Perseus Vault │
|
|
121
|
+
│ (Python) │ ◀────────────────────────── │ (Rust) │
|
|
122
|
+
└─────────────┘ └───────┬────────┘
|
|
123
|
+
│
|
|
124
|
+
SQLite + FTS5
|
|
125
|
+
(AES-256-GCM)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The `PerseusVaultMemoryService` spawns a `perseus-vault` subprocess and communicates via JSON-RPC over stdin/stdout (MCP stdio transport). Each `add_session_to_memory`, `add_memory`, and `search_memory` call translates to a Perseus Vault MCP tool invocation.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT — see [Perseus Vault](https://github.com/Perseus-Computing-LLC/perseus-vault) and [Perseus](https://github.com/Perseus-Computing-LLC/perseus) for the backing services.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""ADK Perseus Vault Memory Service — persistent, local, encrypted cross-session memory.
|
|
2
|
+
|
|
3
|
+
Perseus Vault (github.com/Perseus-Computing-LLC/perseus-vault) is an
|
|
4
|
+
open-source (MIT) persistent memory engine with 30+ MCP tools, FTS5 + dense
|
|
5
|
+
hybrid search, and optional AES-256-GCM encryption. This service talks to the
|
|
6
|
+
Perseus Vault binary via JSON-RPC over stdin/stdout (MCP stdio transport).
|
|
7
|
+
|
|
8
|
+
Requirements:
|
|
9
|
+
A ``perseus-vault`` binary must be on ``$PATH`` or passed explicitly via
|
|
10
|
+
``vault_binary``. Download from:
|
|
11
|
+
https://github.com/Perseus-Computing-LLC/perseus-vault/releases
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .perseus_vault_memory_service import PerseusVaultMemoryService
|
|
15
|
+
|
|
16
|
+
__all__ = ["PerseusVaultMemoryService"]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Agent that uses Perseus for live workspace context resolution.
|
|
2
|
+
|
|
3
|
+
Perseus (github.com/Perseus-Computing-LLC/perseus) is an open-source (MIT)
|
|
4
|
+
live context engine that resolves workspace state at inference time. Instead
|
|
5
|
+
of baking static instructions into prompts, agents use Perseus directives
|
|
6
|
+
(``@file``, ``@search``, ``@memory``, etc.) to pull in exactly what they
|
|
7
|
+
need.
|
|
8
|
+
|
|
9
|
+
This module provides a drop-in agent demonstrating the pattern:
|
|
10
|
+
1. A ``before_agent_callback`` resolves Perseus context before each run.
|
|
11
|
+
2. The resolved context is injected into the agent's instruction template.
|
|
12
|
+
3. The agent knows about workspace state without it being hardcoded.
|
|
13
|
+
|
|
14
|
+
Usage::
|
|
15
|
+
|
|
16
|
+
from adk_perseus_vault_memory.perseus_context import perseus_context_agent
|
|
17
|
+
|
|
18
|
+
# Use as a standalone agent
|
|
19
|
+
runner.run_async(
|
|
20
|
+
user_id="user",
|
|
21
|
+
session_id="session",
|
|
22
|
+
new_message=types.Content(...),
|
|
23
|
+
agent=perseus_context_agent,
|
|
24
|
+
)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import subprocess
|
|
31
|
+
|
|
32
|
+
from google.adk.agents import Agent
|
|
33
|
+
from google.adk.agents.callback_context import CallbackContext
|
|
34
|
+
|
|
35
|
+
_PERSEUS_BINARY = os.environ.get("PERSEUS_BINARY", "perseus")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_perseus_context(callback_context: CallbackContext) -> None:
|
|
39
|
+
"""Resolves Perseus directives and stores the result in agent state.
|
|
40
|
+
|
|
41
|
+
Called before each agent run. Reads directives from a state key
|
|
42
|
+
(defaulting to a sensible set) and resolves them via the Perseus CLI.
|
|
43
|
+
"""
|
|
44
|
+
directives = callback_context.state.get(
|
|
45
|
+
"_perseus_directives",
|
|
46
|
+
"@file AGENTS.md @file README.md",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
workspace = callback_context.state.get("_perseus_workspace", os.getcwd())
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
[_PERSEUS_BINARY, "resolve", directives],
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
timeout=15,
|
|
57
|
+
cwd=workspace,
|
|
58
|
+
)
|
|
59
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
60
|
+
callback_context.state["_perseus_context"] = result.stdout.strip()
|
|
61
|
+
else:
|
|
62
|
+
callback_context.state["_perseus_context"] = (
|
|
63
|
+
f"(Perseus: no context resolved for directives: {directives})"
|
|
64
|
+
)
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
callback_context.state["_perseus_context"] = (
|
|
67
|
+
"(Perseus CLI not installed. Install with: pip install perseus-ctx)"
|
|
68
|
+
)
|
|
69
|
+
except subprocess.TimeoutExpired:
|
|
70
|
+
callback_context.state["_perseus_context"] = (
|
|
71
|
+
"(Perseus resolution timed out)"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
perseus_context_agent = Agent(
|
|
76
|
+
name="perseus_context_agent",
|
|
77
|
+
description=(
|
|
78
|
+
"Agent with live workspace context via Perseus. Knows about "
|
|
79
|
+
"project files, git state, and workspace structure without "
|
|
80
|
+
"hardcoded instructions."
|
|
81
|
+
),
|
|
82
|
+
before_agent_callback=resolve_perseus_context,
|
|
83
|
+
instruction="""\
|
|
84
|
+
You are a helpful assistant with live context about the current workspace.
|
|
85
|
+
|
|
86
|
+
The following context was resolved by Perseus from the workspace files
|
|
87
|
+
and state. Use it to answer questions accurately.
|
|
88
|
+
|
|
89
|
+
--- BEGIN PERSEUS CONTEXT ---
|
|
90
|
+
{_perseus_context}
|
|
91
|
+
--- END PERSEUS CONTEXT ---
|
|
92
|
+
|
|
93
|
+
Use this context to give grounded, file-aware answers. If the context
|
|
94
|
+
is empty or unavailable, let the user know and fall back to general
|
|
95
|
+
knowledge.
|
|
96
|
+
""",
|
|
97
|
+
)
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""Perseus Vault persistent memory service for ADK.
|
|
2
|
+
|
|
3
|
+
Perseus Vault (github.com/Perseus-Computing-LLC/perseus-vault) is an
|
|
4
|
+
open-source (MIT) persistent memory engine with 30+ MCP tools, FTS5 + dense
|
|
5
|
+
hybrid search, and optional AES-256-GCM encryption. This service talks to the
|
|
6
|
+
Perseus Vault binary via JSON-RPC over stdin/stdout (MCP stdio transport).
|
|
7
|
+
|
|
8
|
+
Requirements:
|
|
9
|
+
A ``perseus-vault`` binary must be on ``$PATH`` or passed explicitly via
|
|
10
|
+
``vault_binary``. Build from source or download a pre-built binary from
|
|
11
|
+
the Perseus Vault releases page.
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
from adk_perseus_vault_memory import PerseusVaultMemoryService
|
|
16
|
+
from google.adk.runners import Runner
|
|
17
|
+
|
|
18
|
+
# The memory service is configured on the Runner (not on the Agent).
|
|
19
|
+
runner = Runner(
|
|
20
|
+
agent=my_agent,
|
|
21
|
+
app_name="my_app",
|
|
22
|
+
session_service=my_session_service,
|
|
23
|
+
memory_service=PerseusVaultMemoryService(db_path="~/.adk/vault.db"),
|
|
24
|
+
)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import atexit
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import queue
|
|
35
|
+
import shutil
|
|
36
|
+
import subprocess
|
|
37
|
+
import threading
|
|
38
|
+
import time
|
|
39
|
+
from collections.abc import Mapping
|
|
40
|
+
from collections.abc import Sequence
|
|
41
|
+
from datetime import datetime, timezone
|
|
42
|
+
from typing import TYPE_CHECKING
|
|
43
|
+
|
|
44
|
+
from typing_extensions import override
|
|
45
|
+
|
|
46
|
+
from google.adk.memory.base_memory_service import BaseMemoryService
|
|
47
|
+
from google.adk.memory.base_memory_service import SearchMemoryResponse
|
|
48
|
+
from google.adk.memory.memory_entry import MemoryEntry
|
|
49
|
+
from google.genai import types
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from google.adk.events.event import Event
|
|
53
|
+
from google.adk.sessions.session import Session
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
_VAULT_CATEGORY = "adk-memory"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _format_timestamp(timestamp: float) -> str:
|
|
61
|
+
"""Formats a unix timestamp as a UTC ISO 8601 string."""
|
|
62
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PerseusVaultMemoryService(BaseMemoryService):
|
|
66
|
+
"""Persistent memory service backed by Perseus Vault.
|
|
67
|
+
|
|
68
|
+
Talks to a local ``perseus-vault`` binary via JSON-RPC (MCP stdio). Stores
|
|
69
|
+
session events as structured entities and supports keyword (FTS5) search
|
|
70
|
+
across sessions.
|
|
71
|
+
|
|
72
|
+
This class is thread-safe.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
db_path: Filesystem path to the Perseus Vault SQLite database.
|
|
76
|
+
vault_binary: Path or name of the ``perseus-vault`` executable.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
db_path: str = "~/.adk/vault.db",
|
|
82
|
+
vault_binary: str = "perseus-vault",
|
|
83
|
+
timeout_s: float = 30.0,
|
|
84
|
+
):
|
|
85
|
+
"""Initializes the Perseus Vault memory service.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
db_path: Path to the Perseus Vault database file. Defaults to
|
|
89
|
+
``~/.adk/vault.db``.
|
|
90
|
+
vault_binary: Name or absolute path of the ``perseus-vault``
|
|
91
|
+
executable. Defaults to ``perseus-vault`` (resolved from
|
|
92
|
+
``$PATH``).
|
|
93
|
+
timeout_s: Maximum time to wait for any single Perseus Vault RPC
|
|
94
|
+
response. Guards against a hung subprocess blocking the agent
|
|
95
|
+
forever.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
RuntimeError: If the ``perseus-vault`` binary cannot be found or the
|
|
99
|
+
subprocess fails to start.
|
|
100
|
+
"""
|
|
101
|
+
self.db_path = os.path.expanduser(db_path)
|
|
102
|
+
self._timeout_s = timeout_s
|
|
103
|
+
|
|
104
|
+
# Resolve the perseus-vault binary.
|
|
105
|
+
if os.path.isabs(vault_binary):
|
|
106
|
+
self._vault_binary = vault_binary
|
|
107
|
+
else:
|
|
108
|
+
resolved = shutil.which(vault_binary)
|
|
109
|
+
if resolved is None:
|
|
110
|
+
raise RuntimeError(
|
|
111
|
+
f"perseus-vault binary not found on $PATH (looked for "
|
|
112
|
+
f"'{vault_binary}'). Install Perseus Vault from "
|
|
113
|
+
"https://github.com/Perseus-Computing-LLC/perseus-vault "
|
|
114
|
+
"or pass the absolute path via vault_binary=."
|
|
115
|
+
)
|
|
116
|
+
self._vault_binary = resolved
|
|
117
|
+
|
|
118
|
+
# Ensure the database directory exists.
|
|
119
|
+
os.makedirs(os.path.dirname(self.db_path) or ".", exist_ok=True)
|
|
120
|
+
|
|
121
|
+
# Start the Perseus Vault MCP stdio subprocess. stderr is discarded:
|
|
122
|
+
# nothing drains it, so a chatty server filling the OS pipe buffer would
|
|
123
|
+
# block on its stderr write while we wait on stdout (a two-pipe
|
|
124
|
+
# deadlock).
|
|
125
|
+
self._proc = subprocess.Popen(
|
|
126
|
+
[self._vault_binary, "--db", self.db_path],
|
|
127
|
+
stdin=subprocess.PIPE,
|
|
128
|
+
stdout=subprocess.PIPE,
|
|
129
|
+
stderr=subprocess.DEVNULL,
|
|
130
|
+
text=True,
|
|
131
|
+
)
|
|
132
|
+
self._lock = threading.Lock()
|
|
133
|
+
self._request_id = 0
|
|
134
|
+
|
|
135
|
+
# Background reader: pump stdout lines into a queue so _rpc can wait with a
|
|
136
|
+
# timeout and correlate responses by id, rather than blocking forever on a
|
|
137
|
+
# bare readline().
|
|
138
|
+
self._recv: queue.Queue = queue.Queue()
|
|
139
|
+
proc_stdout = self._proc.stdout
|
|
140
|
+
|
|
141
|
+
def _pump() -> None:
|
|
142
|
+
try:
|
|
143
|
+
for line in proc_stdout:
|
|
144
|
+
self._recv.put(line)
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
finally:
|
|
148
|
+
self._recv.put(None) # EOF sentinel
|
|
149
|
+
|
|
150
|
+
self._reader = threading.Thread(target=_pump, daemon=True)
|
|
151
|
+
self._reader.start()
|
|
152
|
+
|
|
153
|
+
# Initialize the MCP session, then send the required initialized
|
|
154
|
+
# notification (per the MCP spec) before any tools/call.
|
|
155
|
+
self._rpc(
|
|
156
|
+
"initialize",
|
|
157
|
+
{
|
|
158
|
+
"protocolVersion": "2024-11-05",
|
|
159
|
+
"capabilities": {},
|
|
160
|
+
"clientInfo": {
|
|
161
|
+
"name": "adk-perseus-vault-memory-service",
|
|
162
|
+
"version": "1.0",
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
self._notify("notifications/initialized", {})
|
|
167
|
+
|
|
168
|
+
# Clean up the subprocess on exit.
|
|
169
|
+
atexit.register(self._close)
|
|
170
|
+
|
|
171
|
+
def _close(self) -> None:
|
|
172
|
+
"""Terminates the Perseus Vault subprocess."""
|
|
173
|
+
try:
|
|
174
|
+
self._proc.terminate()
|
|
175
|
+
self._proc.wait(timeout=5)
|
|
176
|
+
except Exception:
|
|
177
|
+
try:
|
|
178
|
+
self._proc.kill()
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
def _next_id(self) -> int:
|
|
183
|
+
self._request_id += 1
|
|
184
|
+
return self._request_id
|
|
185
|
+
|
|
186
|
+
def _rpc(self, method: str, params: object) -> dict:
|
|
187
|
+
"""Sends a JSON-RPC request to Perseus Vault and returns the result dict.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
method: The MCP method name (e.g. ``tools/call``).
|
|
191
|
+
params: The method parameters.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
The ``result`` field of the JSON-RPC response.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
RuntimeError: If the RPC returns an error.
|
|
198
|
+
"""
|
|
199
|
+
with self._lock:
|
|
200
|
+
req_id = self._next_id()
|
|
201
|
+
req = {
|
|
202
|
+
"jsonrpc": "2.0",
|
|
203
|
+
"id": req_id,
|
|
204
|
+
"method": method,
|
|
205
|
+
"params": params,
|
|
206
|
+
}
|
|
207
|
+
payload = json.dumps(req, default=str)
|
|
208
|
+
try:
|
|
209
|
+
self._proc.stdin.write(payload + "\n")
|
|
210
|
+
self._proc.stdin.flush()
|
|
211
|
+
except (BrokenPipeError, OSError) as e:
|
|
212
|
+
raise RuntimeError(
|
|
213
|
+
f"Perseus Vault subprocess communication failed: {e}. "
|
|
214
|
+
"The perseus-vault process may have crashed."
|
|
215
|
+
) from e
|
|
216
|
+
|
|
217
|
+
# Wait for the reply with this id, honoring a deadline. Skip
|
|
218
|
+
# notifications (no id) and replies to other ids. The lock is held
|
|
219
|
+
# for the whole exchange so request/response pairs never interleave.
|
|
220
|
+
deadline = time.monotonic() + self._timeout_s
|
|
221
|
+
while True:
|
|
222
|
+
remaining = deadline - time.monotonic()
|
|
223
|
+
if remaining <= 0:
|
|
224
|
+
raise RuntimeError(
|
|
225
|
+
f"Perseus Vault RPC '{method}' timed out after "
|
|
226
|
+
f"{self._timeout_s}s."
|
|
227
|
+
)
|
|
228
|
+
try:
|
|
229
|
+
raw = self._recv.get(timeout=remaining)
|
|
230
|
+
except queue.Empty:
|
|
231
|
+
raise RuntimeError(
|
|
232
|
+
f"Perseus Vault RPC '{method}' timed out after "
|
|
233
|
+
f"{self._timeout_s}s."
|
|
234
|
+
)
|
|
235
|
+
if raw is None:
|
|
236
|
+
raise RuntimeError(
|
|
237
|
+
"Perseus Vault subprocess closed its output "
|
|
238
|
+
"(it may have crashed)."
|
|
239
|
+
)
|
|
240
|
+
raw = raw.strip()
|
|
241
|
+
if not raw:
|
|
242
|
+
continue
|
|
243
|
+
try:
|
|
244
|
+
resp = json.loads(raw)
|
|
245
|
+
except json.JSONDecodeError:
|
|
246
|
+
continue # non-JSON noise on stdout
|
|
247
|
+
if resp.get("id") != req_id:
|
|
248
|
+
continue # notification or a stale/other reply
|
|
249
|
+
|
|
250
|
+
if "error" in resp:
|
|
251
|
+
err = resp["error"]
|
|
252
|
+
raise RuntimeError(
|
|
253
|
+
f"Perseus Vault RPC error [{err.get('code')}]: "
|
|
254
|
+
f"{err.get('message')}"
|
|
255
|
+
)
|
|
256
|
+
return resp.get("result", {})
|
|
257
|
+
|
|
258
|
+
def _notify(self, method: str, params: object) -> None:
|
|
259
|
+
"""Sends a JSON-RPC notification (no id, no response expected)."""
|
|
260
|
+
payload = json.dumps({"jsonrpc": "2.0", "method": method, "params": params})
|
|
261
|
+
with self._lock:
|
|
262
|
+
try:
|
|
263
|
+
self._proc.stdin.write(payload + "\n")
|
|
264
|
+
self._proc.stdin.flush()
|
|
265
|
+
except (BrokenPipeError, OSError):
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
def _call_tool(self, name: str, arguments: dict) -> dict:
|
|
269
|
+
"""Calls a Perseus Vault MCP tool and returns the ``structuredContent``."""
|
|
270
|
+
result = self._rpc(
|
|
271
|
+
"tools/call",
|
|
272
|
+
{"name": name, "arguments": arguments},
|
|
273
|
+
)
|
|
274
|
+
# MCP result is {content: [{type: "text", text: "..."}], structuredContent: {...}}
|
|
275
|
+
sc = result.get("structuredContent")
|
|
276
|
+
if sc is not None:
|
|
277
|
+
return sc
|
|
278
|
+
# Fallback: parse the text content
|
|
279
|
+
content = result.get("content", [])
|
|
280
|
+
if content:
|
|
281
|
+
try:
|
|
282
|
+
return json.loads(content[0].get("text", "{}"))
|
|
283
|
+
except (json.JSONDecodeError, IndexError, KeyError):
|
|
284
|
+
pass
|
|
285
|
+
return {}
|
|
286
|
+
|
|
287
|
+
@override
|
|
288
|
+
async def add_session_to_memory(self, session: Session) -> None:
|
|
289
|
+
"""Stores all events from a session in Perseus Vault.
|
|
290
|
+
|
|
291
|
+
Each session is stored as a single Perseus Vault entity keyed by session
|
|
292
|
+
ID. Subsequent calls for the same session will update the stored events.
|
|
293
|
+
"""
|
|
294
|
+
if not session.events:
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
events_data = []
|
|
298
|
+
for event in session.events:
|
|
299
|
+
if not event.content or not event.content.parts:
|
|
300
|
+
continue
|
|
301
|
+
parts = []
|
|
302
|
+
for part in event.content.parts:
|
|
303
|
+
if part.text:
|
|
304
|
+
parts.append({"text": part.text})
|
|
305
|
+
elif hasattr(part, "function_call") and part.function_call:
|
|
306
|
+
parts.append({
|
|
307
|
+
"function_call": {
|
|
308
|
+
"name": part.function_call.name,
|
|
309
|
+
"args": part.function_call.args,
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
elif hasattr(part, "function_response") and part.function_response:
|
|
313
|
+
parts.append({
|
|
314
|
+
"function_response": {
|
|
315
|
+
"name": part.function_response.name,
|
|
316
|
+
"response": str(part.function_response.response)[:2000],
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
if parts:
|
|
320
|
+
events_data.append({
|
|
321
|
+
"author": event.author,
|
|
322
|
+
"timestamp": event.timestamp,
|
|
323
|
+
"parts": parts,
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
if not events_data:
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
await asyncio.to_thread(
|
|
330
|
+
self._call_tool,
|
|
331
|
+
"perseus_vault_remember",
|
|
332
|
+
{
|
|
333
|
+
"category": _VAULT_CATEGORY,
|
|
334
|
+
"key": f"session:{session.app_name}:{session.user_id}:{session.id}",
|
|
335
|
+
"body_json": json.dumps({
|
|
336
|
+
"session_id": session.id,
|
|
337
|
+
"app_name": session.app_name,
|
|
338
|
+
"user_id": session.user_id,
|
|
339
|
+
"events": events_data,
|
|
340
|
+
"event_count": len(events_data),
|
|
341
|
+
}),
|
|
342
|
+
"tags": ["adk", "session", session.app_name],
|
|
343
|
+
},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
@override
|
|
347
|
+
async def add_events_to_memory(
|
|
348
|
+
self,
|
|
349
|
+
*,
|
|
350
|
+
app_name: str,
|
|
351
|
+
user_id: str,
|
|
352
|
+
events: Sequence[Event],
|
|
353
|
+
session_id: str | None = None,
|
|
354
|
+
custom_metadata: Mapping[str, object] | None = None,
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Adds a delta of events to Perseus Vault.
|
|
357
|
+
|
|
358
|
+
Events are appended to an existing session entity if one exists, or a
|
|
359
|
+
new entity is created. This is the recommended method for incremental
|
|
360
|
+
memory updates during long-running sessions.
|
|
361
|
+
"""
|
|
362
|
+
_ = custom_metadata
|
|
363
|
+
events_data = []
|
|
364
|
+
for event in events:
|
|
365
|
+
if not event.content or not event.content.parts:
|
|
366
|
+
continue
|
|
367
|
+
parts = []
|
|
368
|
+
for part in event.content.parts:
|
|
369
|
+
if part.text:
|
|
370
|
+
parts.append({"text": part.text})
|
|
371
|
+
elif hasattr(part, "function_call") and part.function_call:
|
|
372
|
+
parts.append({
|
|
373
|
+
"function_call": {
|
|
374
|
+
"name": part.function_call.name,
|
|
375
|
+
"args": part.function_call.args,
|
|
376
|
+
}
|
|
377
|
+
})
|
|
378
|
+
if parts:
|
|
379
|
+
events_data.append({
|
|
380
|
+
"author": event.author,
|
|
381
|
+
"timestamp": event.timestamp,
|
|
382
|
+
"parts": parts,
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
if not events_data:
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
sid = session_id or "__unknown__"
|
|
389
|
+
delta_key = f"delta:{app_name}:{user_id}:{sid}:{int(time.time() * 1000)}"
|
|
390
|
+
|
|
391
|
+
await asyncio.to_thread(
|
|
392
|
+
self._call_tool,
|
|
393
|
+
"perseus_vault_remember",
|
|
394
|
+
{
|
|
395
|
+
"category": _VAULT_CATEGORY,
|
|
396
|
+
"key": delta_key,
|
|
397
|
+
"body_json": json.dumps({
|
|
398
|
+
"session_id": sid,
|
|
399
|
+
"app_name": app_name,
|
|
400
|
+
"user_id": user_id,
|
|
401
|
+
"events": events_data,
|
|
402
|
+
"event_count": len(events_data),
|
|
403
|
+
}),
|
|
404
|
+
"tags": ["adk", "delta", app_name],
|
|
405
|
+
},
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
@override
|
|
409
|
+
async def add_memory(
|
|
410
|
+
self,
|
|
411
|
+
*,
|
|
412
|
+
app_name: str,
|
|
413
|
+
user_id: str,
|
|
414
|
+
memories: Sequence[MemoryEntry],
|
|
415
|
+
custom_metadata: Mapping[str, object] | None = None,
|
|
416
|
+
) -> None:
|
|
417
|
+
"""Adds explicit memory entries directly to Perseus Vault.
|
|
418
|
+
|
|
419
|
+
Each MemoryEntry is stored as a separate entity tagged for the given
|
|
420
|
+
application and user.
|
|
421
|
+
"""
|
|
422
|
+
_ = custom_metadata
|
|
423
|
+
for i, entry in enumerate(memories):
|
|
424
|
+
content_text = ""
|
|
425
|
+
if entry.content and entry.content.parts:
|
|
426
|
+
content_text = " ".join(
|
|
427
|
+
p.text for p in entry.content.parts if p.text
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if not content_text:
|
|
431
|
+
continue
|
|
432
|
+
|
|
433
|
+
await asyncio.to_thread(
|
|
434
|
+
self._call_tool,
|
|
435
|
+
"perseus_vault_remember",
|
|
436
|
+
{
|
|
437
|
+
"category": _VAULT_CATEGORY,
|
|
438
|
+
"key": f"memory:{app_name}:{user_id}:{entry.id or i}",
|
|
439
|
+
"body_json": json.dumps({
|
|
440
|
+
"content": content_text,
|
|
441
|
+
"app_name": app_name,
|
|
442
|
+
"user_id": user_id,
|
|
443
|
+
"author": entry.author,
|
|
444
|
+
"timestamp": entry.timestamp,
|
|
445
|
+
"metadata": entry.custom_metadata,
|
|
446
|
+
}),
|
|
447
|
+
"tags": ["adk", "explicit", app_name],
|
|
448
|
+
},
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
@override
|
|
452
|
+
async def search_memory(
|
|
453
|
+
self,
|
|
454
|
+
*,
|
|
455
|
+
app_name: str,
|
|
456
|
+
user_id: str,
|
|
457
|
+
query: str,
|
|
458
|
+
) -> SearchMemoryResponse:
|
|
459
|
+
"""Searches Perseus Vault for memories matching the query.
|
|
460
|
+
|
|
461
|
+
Uses Perseus Vault's FTS5 keyword search, then enforces per-(app, user)
|
|
462
|
+
isolation in-process: Perseus Vault's recall OR's query terms together,
|
|
463
|
+
so scoping cannot be expressed by stuffing the app/user into the query
|
|
464
|
+
string (that both leaks other tenants' memories and dilutes relevance).
|
|
465
|
+
Instead the clean query is sent and every returned item is filtered to
|
|
466
|
+
the requesting ``app_name`` and ``user_id`` recorded in its body.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
app_name: The application name for memory scope.
|
|
470
|
+
user_id: The user ID for memory scope.
|
|
471
|
+
query: The natural-language query to search for.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
A SearchMemoryResponse containing matching MemoryEntry objects.
|
|
475
|
+
"""
|
|
476
|
+
# Over-fetch a little since results are post-filtered by tenant.
|
|
477
|
+
result = await asyncio.to_thread(
|
|
478
|
+
self._call_tool,
|
|
479
|
+
"perseus_vault_recall",
|
|
480
|
+
{
|
|
481
|
+
"query": query,
|
|
482
|
+
"limit": 50,
|
|
483
|
+
"category": _VAULT_CATEGORY,
|
|
484
|
+
},
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
response = SearchMemoryResponse()
|
|
488
|
+
items = result.get("items", [])
|
|
489
|
+
for item in items:
|
|
490
|
+
body = item.get("body_json", "{}")
|
|
491
|
+
try:
|
|
492
|
+
body_data = json.loads(body) if isinstance(body, str) else body
|
|
493
|
+
except json.JSONDecodeError:
|
|
494
|
+
body_data = {}
|
|
495
|
+
if not isinstance(body_data, dict):
|
|
496
|
+
continue
|
|
497
|
+
|
|
498
|
+
# Tenant isolation: never surface another app's or user's memory.
|
|
499
|
+
if (
|
|
500
|
+
body_data.get("app_name") != app_name
|
|
501
|
+
or body_data.get("user_id") != user_id
|
|
502
|
+
):
|
|
503
|
+
continue
|
|
504
|
+
|
|
505
|
+
# Determine the best text content to surface.
|
|
506
|
+
content_text = body_data.get("content", "")
|
|
507
|
+
if not content_text:
|
|
508
|
+
events = body_data.get("events", [])
|
|
509
|
+
texts = []
|
|
510
|
+
for ev in events:
|
|
511
|
+
for part in ev.get("parts", []):
|
|
512
|
+
if part.get("text"):
|
|
513
|
+
texts.append(part["text"])
|
|
514
|
+
content_text = " | ".join(texts[:5]) if texts else ""
|
|
515
|
+
|
|
516
|
+
if not content_text:
|
|
517
|
+
continue
|
|
518
|
+
|
|
519
|
+
response.memories.append(
|
|
520
|
+
MemoryEntry(
|
|
521
|
+
content=types.Content(
|
|
522
|
+
role="model",
|
|
523
|
+
parts=[types.Part.from_text(text=content_text)],
|
|
524
|
+
),
|
|
525
|
+
author=body_data.get("author") or "perseus-vault",
|
|
526
|
+
timestamp=body_data.get("timestamp")
|
|
527
|
+
or _format_timestamp(
|
|
528
|
+
item.get("created_at_unix_ms", 0) / 1000.0
|
|
529
|
+
),
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
return response
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "adk-perseus-vault-memory"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Thomas Connally", email = "51974392+tcconnally@users.noreply.github.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "Persistent, local, encrypted cross-session memory for Google ADK agents — backed by Perseus Vault."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = { text = "MIT" }
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
]
|
|
26
|
+
keywords = [
|
|
27
|
+
"google-adk",
|
|
28
|
+
"adk",
|
|
29
|
+
"memory",
|
|
30
|
+
"perseus-vault",
|
|
31
|
+
"agent-memory",
|
|
32
|
+
"mcp",
|
|
33
|
+
]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"google-adk>=1.0.0",
|
|
36
|
+
# override is used for the BaseMemoryService overrides; stdlib typing.override
|
|
37
|
+
# only exists on 3.12+, so the backport is required on 3.10/3.11.
|
|
38
|
+
"typing-extensions>=4.4.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
perseus = ["perseus-ctx>=1.0.10"]
|
|
43
|
+
test = ["pytest>=7.0"]
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://github.com/Perseus-Computing-LLC/adk-mimir-memory"
|
|
47
|
+
Repository = "https://github.com/Perseus-Computing-LLC/adk-mimir-memory"
|
|
48
|
+
"Bug Tracker" = "https://github.com/Perseus-Computing-LLC/adk-mimir-memory/issues"
|
|
49
|
+
"Perseus Vault" = "https://github.com/Perseus-Computing-LLC/perseus-vault"
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Tests for PerseusVaultMemoryService against a fake Perseus Vault MCP stdio server.
|
|
2
|
+
|
|
3
|
+
No real ``perseus-vault`` binary is required: ``subprocess.Popen`` is
|
|
4
|
+
monkeypatched to return an in-process fake that speaks JSON-RPC 2.0 over fake
|
|
5
|
+
stdin/stdout pipes and models Perseus Vault's recall OR-semantics, so these
|
|
6
|
+
exercise the real RPC, async, and tenant-isolation code paths.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import queue
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from google.genai import types
|
|
18
|
+
from google.adk.memory.memory_entry import MemoryEntry
|
|
19
|
+
|
|
20
|
+
import adk_perseus_vault_memory.perseus_vault_memory_service as svc_mod
|
|
21
|
+
from adk_perseus_vault_memory import PerseusVaultMemoryService
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── Fake Perseus Vault MCP stdio server ─────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _FakeStdin:
|
|
28
|
+
def __init__(self, on_line):
|
|
29
|
+
self._on_line = on_line
|
|
30
|
+
|
|
31
|
+
def write(self, s):
|
|
32
|
+
for line in s.splitlines():
|
|
33
|
+
if line.strip():
|
|
34
|
+
self._on_line(line)
|
|
35
|
+
|
|
36
|
+
def flush(self):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def close(self):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _FakeStdout:
|
|
44
|
+
"""Blocking, iterable line source fed by the fake server."""
|
|
45
|
+
|
|
46
|
+
def __init__(self):
|
|
47
|
+
self._q = queue.Queue()
|
|
48
|
+
|
|
49
|
+
def put(self, line):
|
|
50
|
+
self._q.put(line)
|
|
51
|
+
|
|
52
|
+
def __iter__(self):
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def __next__(self):
|
|
56
|
+
item = self._q.get()
|
|
57
|
+
if item is None:
|
|
58
|
+
raise StopIteration
|
|
59
|
+
return item
|
|
60
|
+
|
|
61
|
+
def close(self):
|
|
62
|
+
self._q.put(None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FakeVault:
|
|
66
|
+
"""Minimal Popen-compatible fake of the Perseus Vault MCP stdio server.
|
|
67
|
+
|
|
68
|
+
Options:
|
|
69
|
+
answer_tools: if False, tools/call requests get no response (to test
|
|
70
|
+
the RPC timeout).
|
|
71
|
+
emit_notification_before_reply: if True, a JSON-RPC notification line is
|
|
72
|
+
emitted before every tools/call reply (to test id correlation).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, *, answer_tools=True, emit_notification_before_reply=False):
|
|
76
|
+
self.store = {} # (category, key) -> arguments dict
|
|
77
|
+
self.stdout = _FakeStdout()
|
|
78
|
+
self.stdin = _FakeStdin(self._handle)
|
|
79
|
+
self._alive = True
|
|
80
|
+
self._answer_tools = answer_tools
|
|
81
|
+
self._emit_notif = emit_notification_before_reply
|
|
82
|
+
|
|
83
|
+
# --- JSON-RPC handling ---
|
|
84
|
+
def _handle(self, line):
|
|
85
|
+
req = json.loads(line)
|
|
86
|
+
rid = req.get("id")
|
|
87
|
+
method = req.get("method")
|
|
88
|
+
if rid is None:
|
|
89
|
+
return # client notification, no response
|
|
90
|
+
if method == "initialize":
|
|
91
|
+
self._respond(rid, {"protocolVersion": "2024-11-05", "capabilities": {}})
|
|
92
|
+
return
|
|
93
|
+
if method == "tools/call":
|
|
94
|
+
if not self._answer_tools:
|
|
95
|
+
return # simulate a hung server
|
|
96
|
+
if self._emit_notif:
|
|
97
|
+
self.stdout.put(
|
|
98
|
+
json.dumps(
|
|
99
|
+
{"jsonrpc": "2.0", "method": "notifications/progress", "params": {}}
|
|
100
|
+
)
|
|
101
|
+
+ "\n"
|
|
102
|
+
)
|
|
103
|
+
params = req["params"]
|
|
104
|
+
name = params["name"]
|
|
105
|
+
args = params["arguments"]
|
|
106
|
+
if name == "perseus_vault_remember":
|
|
107
|
+
self.store[(args["category"], args["key"])] = args
|
|
108
|
+
self._respond(rid, {"structuredContent": {"stored": True}})
|
|
109
|
+
elif name == "perseus_vault_recall":
|
|
110
|
+
q = args.get("query", "").lower().split()
|
|
111
|
+
cat = args.get("category")
|
|
112
|
+
items = []
|
|
113
|
+
for (c, _k), rec in self.store.items():
|
|
114
|
+
if cat and c != cat:
|
|
115
|
+
continue
|
|
116
|
+
body = rec["body_json"]
|
|
117
|
+
# Model Perseus Vault's OR semantics: match if ANY query word appears.
|
|
118
|
+
if any(w in body.lower() for w in q):
|
|
119
|
+
items.append({"body_json": body, "created_at_unix_ms": 0})
|
|
120
|
+
self._respond(rid, {"structuredContent": {"items": items}})
|
|
121
|
+
else:
|
|
122
|
+
self._respond(rid, {"structuredContent": {}})
|
|
123
|
+
return
|
|
124
|
+
self._respond(rid, {})
|
|
125
|
+
|
|
126
|
+
def _respond(self, rid, result):
|
|
127
|
+
self.stdout.put(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}) + "\n")
|
|
128
|
+
|
|
129
|
+
# --- Popen surface ---
|
|
130
|
+
def poll(self):
|
|
131
|
+
return None if self._alive else 0
|
|
132
|
+
|
|
133
|
+
def terminate(self):
|
|
134
|
+
self._alive = False
|
|
135
|
+
self.stdout.close()
|
|
136
|
+
|
|
137
|
+
def kill(self):
|
|
138
|
+
self._alive = False
|
|
139
|
+
self.stdout.close()
|
|
140
|
+
|
|
141
|
+
def wait(self, timeout=None):
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _make_service(monkeypatch, tmp_path, **fake_kwargs):
|
|
146
|
+
fake = FakeVault(**fake_kwargs)
|
|
147
|
+
monkeypatch.setattr(svc_mod.subprocess, "Popen", lambda *a, **k: fake)
|
|
148
|
+
db = tmp_path / "vault.db"
|
|
149
|
+
# Use a platform-absolute path so __init__ skips the $PATH lookup.
|
|
150
|
+
fake_bin = str(tmp_path / "fake-perseus-vault")
|
|
151
|
+
service = PerseusVaultMemoryService(
|
|
152
|
+
db_path=str(db), vault_binary=fake_bin, timeout_s=1.0
|
|
153
|
+
)
|
|
154
|
+
return service, fake
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _entry(text):
|
|
158
|
+
return MemoryEntry(
|
|
159
|
+
content=types.Content(role="user", parts=[types.Part.from_text(text=text)]),
|
|
160
|
+
author="user",
|
|
161
|
+
timestamp="2026-01-01T00:00:00+00:00",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── Tests ──────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_init_completes_handshake(monkeypatch, tmp_path):
|
|
169
|
+
service, _fake = _make_service(monkeypatch, tmp_path)
|
|
170
|
+
assert service._request_id >= 1 # initialize consumed an id
|
|
171
|
+
service._close()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_tenant_isolation_search(monkeypatch, tmp_path):
|
|
175
|
+
"""A search must never return another app's or user's memories, even though
|
|
176
|
+
Perseus Vault's recall OR-matches the shared query term."""
|
|
177
|
+
service, _fake = _make_service(monkeypatch, tmp_path)
|
|
178
|
+
|
|
179
|
+
asyncio.run(
|
|
180
|
+
service.add_memory(
|
|
181
|
+
app_name="app1", user_id="alice", memories=[_entry("alice likes turtles")]
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
asyncio.run(
|
|
185
|
+
service.add_memory(
|
|
186
|
+
app_name="app1", user_id="bob", memories=[_entry("bob likes turtles")]
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
asyncio.run(
|
|
190
|
+
service.add_memory(
|
|
191
|
+
app_name="app2", user_id="alice", memories=[_entry("alice in app2 turtles")]
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
resp = asyncio.run(
|
|
196
|
+
service.search_memory(app_name="app1", user_id="alice", query="turtles")
|
|
197
|
+
)
|
|
198
|
+
texts = [p.text for m in resp.memories for p in m.content.parts]
|
|
199
|
+
assert texts == ["alice likes turtles"], texts # only app1/alice
|
|
200
|
+
service._close()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_rpc_timeout_when_server_hangs(monkeypatch, tmp_path):
|
|
204
|
+
service, _fake = _make_service(monkeypatch, tmp_path, answer_tools=False)
|
|
205
|
+
with pytest.raises(RuntimeError, match="timed out"):
|
|
206
|
+
asyncio.run(
|
|
207
|
+
service.search_memory(app_name="a", user_id="u", query="anything")
|
|
208
|
+
)
|
|
209
|
+
service._close()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_id_correlation_skips_notifications(monkeypatch, tmp_path):
|
|
213
|
+
service, _fake = _make_service(
|
|
214
|
+
monkeypatch, tmp_path, emit_notification_before_reply=True
|
|
215
|
+
)
|
|
216
|
+
asyncio.run(
|
|
217
|
+
service.add_memory(
|
|
218
|
+
app_name="app", user_id="u", memories=[_entry("hello world")]
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
resp = asyncio.run(
|
|
222
|
+
service.search_memory(app_name="app", user_id="u", query="hello")
|
|
223
|
+
)
|
|
224
|
+
texts = [p.text for m in resp.memories for p in m.content.parts]
|
|
225
|
+
assert texts == ["hello world"], texts
|
|
226
|
+
service._close()
|