@atomicmemory/hermes-plugin 0.1.10
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.
- package/README.md +157 -0
- package/__init__.py +333 -0
- package/breaker.py +57 -0
- package/client.py +161 -0
- package/config.py +236 -0
- package/package.json +29 -0
- package/plugin.yaml +12 -0
- package/python_sdk.py +392 -0
- package/tools.py +193 -0
- package/worker.py +113 -0
package/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# AtomicMemory for Hermes Agent
|
|
2
|
+
|
|
3
|
+
AtomicMemory is a native Hermes memory provider. It participates in Hermes'
|
|
4
|
+
memory lifecycle: background recall is prefetched for the next turn,
|
|
5
|
+
completed turns are synced without blocking the chat loop, and the agent
|
|
6
|
+
gets explicit tools for searching and storing durable facts.
|
|
7
|
+
|
|
8
|
+
By default, recall is **shared** across every AtomicMemory tool the user
|
|
9
|
+
has touched (Claude Code, Codex, the web extension, etc.). Set
|
|
10
|
+
`memory_scope=siloed` to restrict Hermes to Hermes-ingested memories only.
|
|
11
|
+
|
|
12
|
+
## Architecture
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Hermes Agent (Python)
|
|
16
|
+
→ plugins/memory/atomicmemory/__init__.py
|
|
17
|
+
→ AtomicMemoryClient (Python protocol)
|
|
18
|
+
→ PythonSdkAtomicMemoryClient
|
|
19
|
+
→ published atomicmemory Python SDK MemoryClient
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The Python provider owns Hermes lifecycle compatibility only — registration,
|
|
23
|
+
hooks, tool schemas. Memory semantics flow through the published Python SDK.
|
|
24
|
+
|
|
25
|
+
## Prerequisites
|
|
26
|
+
|
|
27
|
+
- Hermes Agent installed and `HERMES_HOME` set
|
|
28
|
+
- AtomicMemory core URL exported as `ATOMICMEMORY_API_URL`
|
|
29
|
+
|
|
30
|
+
## Install (dev)
|
|
31
|
+
|
|
32
|
+
The simplest dev install symlinks the plugin into Hermes' memory directory.
|
|
33
|
+
Hermes installs the published `atomicmemory` SDK from `plugin.yaml`.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cd /path/to/atomicmemory-integrations
|
|
37
|
+
mkdir -p "$HERMES_HOME/plugins/memory"
|
|
38
|
+
ln -s "$(pwd)/plugins/hermes" "$HERMES_HOME/plugins/memory/atomicmemory"
|
|
39
|
+
export ATOMICMEMORY_API_URL="http://localhost:3050"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then select and verify the provider:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
hermes memory setup
|
|
46
|
+
# select "atomicmemory"
|
|
47
|
+
hermes memory status
|
|
48
|
+
# confirm "atomicmemory" is active
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Config
|
|
52
|
+
|
|
53
|
+
Hermes' setup wizard prompts for a minimal pair (`scope_user`, `memory_scope`).
|
|
54
|
+
Advanced settings live in `$HERMES_HOME/atomicmemory.json`.
|
|
55
|
+
|
|
56
|
+
Connection details (`ATOMICMEMORY_API_URL`, `ATOMICMEMORY_API_KEY`) flow
|
|
57
|
+
through environment variables into the Python SDK. The provider does not
|
|
58
|
+
have a default API URL and fails to start if `ATOMICMEMORY_API_URL` is unset.
|
|
59
|
+
|
|
60
|
+
### Environment
|
|
61
|
+
|
|
62
|
+
| Env var | Purpose |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `ATOMICMEMORY_API_URL` | AtomicMemory core URL. Required. |
|
|
65
|
+
| `ATOMICMEMORY_API_KEY` | Bearer credential for AtomicMemory core. Optional. |
|
|
66
|
+
| `ATOMICMEMORY_PROVIDER` | SDK provider name. Defaults to `atomicmemory`. |
|
|
67
|
+
| `ATOMICMEMORY_SCOPE_USER` | Hermes user identity. Defaults to `$USER`. |
|
|
68
|
+
| `ATOMICMEMORY_MEMORY_SCOPE` | `shared` (default) or `siloed`. |
|
|
69
|
+
| `ATOMICMEMORY_MEMORY_MODE` | `hybrid` (default), `context`, or `tools`. |
|
|
70
|
+
| `ATOMICMEMORY_PREFETCH_ENABLED` | `true`/`false`. Default `true`. |
|
|
71
|
+
| `ATOMICMEMORY_PREFETCH_METHOD` | `context` (default) or `fast`. |
|
|
72
|
+
| `ATOMICMEMORY_SEARCH_LIMIT` | Default search/list limit. |
|
|
73
|
+
| `ATOMICMEMORY_TOKEN_BUDGET` | Default context-package token budget. |
|
|
74
|
+
|
|
75
|
+
### Provider-local file
|
|
76
|
+
|
|
77
|
+
`$HERMES_HOME/atomicmemory.json` accepts these keys (all optional):
|
|
78
|
+
|
|
79
|
+
| Key | Description |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `scope_user` | User identity. |
|
|
82
|
+
| `scope_agent` | Hermes prompt label (does not change scoping). |
|
|
83
|
+
| `memory_mode` | `hybrid` / `context` / `tools`. |
|
|
84
|
+
| `memory_scope` | `shared` / `siloed`. |
|
|
85
|
+
| `prefetch_enabled` | bool. |
|
|
86
|
+
| `prefetch_method` | `context` / `fast`. |
|
|
87
|
+
| `search_limit` | int. |
|
|
88
|
+
| `token_budget` | int. |
|
|
89
|
+
|
|
90
|
+
Secrets are never persisted here — `api_key` and `api_url` are deliberately
|
|
91
|
+
not in the allowed key set.
|
|
92
|
+
|
|
93
|
+
## Memory scope
|
|
94
|
+
|
|
95
|
+
| Mode | Recall | Ingest |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `shared` (default) | All AtomicMemory memories for the user | stamped `source_site=hermes` |
|
|
98
|
+
| `siloed` | Only Hermes-ingested memories | stamped `source_site=hermes` |
|
|
99
|
+
|
|
100
|
+
The `source_site` filter on recall is enforced through the Python SDK's
|
|
101
|
+
AtomicMemory namespace handle. If the SDK is configured against a
|
|
102
|
+
non-AtomicMemory provider (e.g. mem0), `siloed` mode fails loudly with
|
|
103
|
+
`PROVIDER_UNSUPPORTED` rather than silently dropping the filter.
|
|
104
|
+
|
|
105
|
+
## Memory mode
|
|
106
|
+
|
|
107
|
+
`memory_mode` selects which Hermes surfaces AtomicMemory exposes:
|
|
108
|
+
|
|
109
|
+
| Mode | Auto-recall + sync | Explicit tools |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `hybrid` (default) | yes | yes |
|
|
112
|
+
| `context` | yes | hidden |
|
|
113
|
+
| `tools` | disabled | yes |
|
|
114
|
+
|
|
115
|
+
## Tools
|
|
116
|
+
|
|
117
|
+
| Tool | Description |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `atomicmemory_search` | Search AtomicMemory by meaning. |
|
|
120
|
+
| `atomicmemory_context` | Build an injection-ready context package. |
|
|
121
|
+
| `atomicmemory_conclude` | Store one explicit durable fact verbatim. |
|
|
122
|
+
| `atomicmemory_profile` | List recent records (description text varies by `memory_scope`). |
|
|
123
|
+
|
|
124
|
+
## Lifecycle
|
|
125
|
+
|
|
126
|
+
- `queue_prefetch(query)` searches AtomicMemory in a background thread, with
|
|
127
|
+
a generation counter so a slow earlier prefetch can't overwrite a faster
|
|
128
|
+
newer one.
|
|
129
|
+
- `prefetch(query)` returns the most recent completed recall, then clears
|
|
130
|
+
the slot.
|
|
131
|
+
- `sync_turn(user, assistant)` enqueues the turn to a single-writer worker
|
|
132
|
+
thread and returns immediately. The worker calls
|
|
133
|
+
`client.ingest_messages(...)` with `provenance.source = "hermes"` and
|
|
134
|
+
`provenance.sourceUrl = "hermes://session/<session_id>"`.
|
|
135
|
+
- `on_session_end(messages)` drains the worker, then closes the SDK client.
|
|
136
|
+
|
|
137
|
+
## Reliability
|
|
138
|
+
|
|
139
|
+
A circuit breaker pauses SDK calls for two minutes after five
|
|
140
|
+
consecutive failures and resets on the next success. Hermes continues to
|
|
141
|
+
run while AtomicMemory is temporarily unavailable.
|
|
142
|
+
|
|
143
|
+
## Troubleshooting
|
|
144
|
+
|
|
145
|
+
| Symptom | Likely cause |
|
|
146
|
+
|---|---|
|
|
147
|
+
| Provider does not appear in `hermes memory setup` | Wrong install path. Memory providers must live under `$HERMES_HOME/plugins/memory/<name>/`, not `$HERMES_HOME/plugins/<name>/`. |
|
|
148
|
+
| `is_available()` returns False | `ATOMICMEMORY_API_URL` unset, or the Hermes Python environment did not install the `atomicmemory` dependency from `plugin.yaml`. |
|
|
149
|
+
| Import fails at startup | The Hermes Python environment is missing the SDK dependency from `plugin.yaml`. |
|
|
150
|
+
| Calls fail with `PROVIDER_UNSUPPORTED` while `memory_scope=siloed` | The configured SDK provider is not the AtomicMemory core (e.g. it's `mem0`). Either switch `ATOMICMEMORY_PROVIDER=atomicmemory` or move to `memory_scope=shared`. |
|
|
151
|
+
|
|
152
|
+
## Tests
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
# Python provider and SDK adapter (deterministic, no network)
|
|
156
|
+
python3 -m unittest discover plugins/hermes/tests
|
|
157
|
+
```
|
package/__init__.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""AtomicMemory native memory provider for Hermes Agent (v2).
|
|
2
|
+
|
|
3
|
+
Thin Python adapter over the AtomicMemory Python SDK. The provider owns Hermes
|
|
4
|
+
lifecycle compatibility (provider registration, hook dispatch, tool schemas,
|
|
5
|
+
worker supervision); all memory semantics flow through `AtomicMemoryClient`.
|
|
6
|
+
|
|
7
|
+
Default scope is `shared` — Hermes recalls memories from every AtomicMemory
|
|
8
|
+
tool the user has touched. Set `memory_scope=siloed` to restrict recall to
|
|
9
|
+
Hermes-ingested memories only.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
from typing import Any, Callable
|
|
19
|
+
|
|
20
|
+
from .breaker import CircuitBreaker
|
|
21
|
+
from .client import (
|
|
22
|
+
AtomicMemoryClient,
|
|
23
|
+
BridgeError,
|
|
24
|
+
Message,
|
|
25
|
+
Provenance,
|
|
26
|
+
)
|
|
27
|
+
from .config import (
|
|
28
|
+
DEFAULT_USER_ID,
|
|
29
|
+
SOURCE_SITE,
|
|
30
|
+
ProviderConfig,
|
|
31
|
+
get_config_schema,
|
|
32
|
+
ingest_scope_dict,
|
|
33
|
+
load_config,
|
|
34
|
+
read_scope_kwargs,
|
|
35
|
+
save_config,
|
|
36
|
+
)
|
|
37
|
+
from .python_sdk import (
|
|
38
|
+
PythonSdkAtomicMemoryClient,
|
|
39
|
+
PythonSdkConfig,
|
|
40
|
+
sdk_is_available,
|
|
41
|
+
)
|
|
42
|
+
from .tools import (
|
|
43
|
+
CONCLUDE_SCHEMA,
|
|
44
|
+
CONTEXT_SCHEMA,
|
|
45
|
+
SEARCH_SCHEMA,
|
|
46
|
+
TOOL_HANDLERS,
|
|
47
|
+
format_memory_bullets,
|
|
48
|
+
profile_schema,
|
|
49
|
+
)
|
|
50
|
+
from .worker import IngestJob, IngestWorker
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
from agent.memory_provider import MemoryProvider # type: ignore[import-not-found]
|
|
55
|
+
except Exception: # pragma: no cover — used only outside Hermes test runs.
|
|
56
|
+
class MemoryProvider: # type: ignore[no-redef]
|
|
57
|
+
"""Fallback base class so the provider can be unit-tested standalone."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
from tools.registry import tool_error # type: ignore[import-not-found]
|
|
62
|
+
except Exception: # pragma: no cover — used only outside Hermes test runs.
|
|
63
|
+
def tool_error(message: str) -> str:
|
|
64
|
+
return json.dumps({"error": message})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger(__name__)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AtomicMemoryMemoryProvider(MemoryProvider):
|
|
71
|
+
"""Hermes-compatible AtomicMemory memory provider."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
client_factory: Callable[[ProviderConfig], AtomicMemoryClient] | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self._client_factory = client_factory or _default_client_factory
|
|
79
|
+
self._client: AtomicMemoryClient | None = None
|
|
80
|
+
self._config: ProviderConfig = ProviderConfig()
|
|
81
|
+
self._session_id: str = ""
|
|
82
|
+
self._user_id: str = DEFAULT_USER_ID
|
|
83
|
+
|
|
84
|
+
self._breaker = CircuitBreaker()
|
|
85
|
+
self._worker: IngestWorker | None = None
|
|
86
|
+
|
|
87
|
+
self._prefetch_lock = threading.Lock()
|
|
88
|
+
self._prefetch_result: str = ""
|
|
89
|
+
self._prefetch_generation: int = 0
|
|
90
|
+
self._prefetch_thread: threading.Thread | None = None
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
# Hermes provider contract
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def name(self) -> str:
|
|
98
|
+
return "atomicmemory"
|
|
99
|
+
|
|
100
|
+
def is_available(self) -> bool:
|
|
101
|
+
if not os.environ.get("ATOMICMEMORY_API_URL"):
|
|
102
|
+
return False
|
|
103
|
+
return sdk_is_available()
|
|
104
|
+
|
|
105
|
+
def get_config_schema(self) -> list[dict[str, Any]]:
|
|
106
|
+
return get_config_schema()
|
|
107
|
+
|
|
108
|
+
def save_config(self, values: dict[str, Any], hermes_home: str) -> None:
|
|
109
|
+
save_config(values, hermes_home)
|
|
110
|
+
|
|
111
|
+
def initialize(self, session_id: str, **kwargs: Any) -> None:
|
|
112
|
+
hermes_home = kwargs.get("hermes_home")
|
|
113
|
+
self._config = load_config(hermes_home=hermes_home)
|
|
114
|
+
self._user_id = (
|
|
115
|
+
_clean_str(kwargs.get("user_id"))
|
|
116
|
+
or self._config.scope_user
|
|
117
|
+
or DEFAULT_USER_ID
|
|
118
|
+
)
|
|
119
|
+
self._session_id = session_id
|
|
120
|
+
client = self._client_factory(self._config)
|
|
121
|
+
client.initialize()
|
|
122
|
+
self._client = client
|
|
123
|
+
self._worker = IngestWorker(
|
|
124
|
+
run_job=self._run_ingest_job,
|
|
125
|
+
on_failure=self._on_ingest_failure,
|
|
126
|
+
)
|
|
127
|
+
self._worker.start()
|
|
128
|
+
|
|
129
|
+
def system_prompt_block(self) -> str:
|
|
130
|
+
action = (
|
|
131
|
+
"Use atomicmemory_search for targeted recall, atomicmemory_context for broad context, "
|
|
132
|
+
"and atomicmemory_conclude to store explicit durable facts."
|
|
133
|
+
if self._tools_enabled()
|
|
134
|
+
else "AtomicMemory recall is injected automatically; explicit AtomicMemory tools are disabled."
|
|
135
|
+
)
|
|
136
|
+
return (
|
|
137
|
+
"# AtomicMemory\n"
|
|
138
|
+
f"Active. User: {self._user_id}. Memory mode: {self._config.memory_mode}. "
|
|
139
|
+
f"Scope: {self._config.memory_scope}.\n"
|
|
140
|
+
f"{action}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
|
144
|
+
del session_id
|
|
145
|
+
if not self._context_enabled() or not self._config.prefetch_enabled:
|
|
146
|
+
return
|
|
147
|
+
if self._breaker.is_open() or not query:
|
|
148
|
+
return
|
|
149
|
+
with self._prefetch_lock:
|
|
150
|
+
self._prefetch_generation += 1
|
|
151
|
+
generation = self._prefetch_generation
|
|
152
|
+
|
|
153
|
+
def _run() -> None:
|
|
154
|
+
try:
|
|
155
|
+
result = self._prefetch_text(query)
|
|
156
|
+
if result:
|
|
157
|
+
with self._prefetch_lock:
|
|
158
|
+
if generation == self._prefetch_generation:
|
|
159
|
+
self._prefetch_result = result
|
|
160
|
+
self._breaker.record_success()
|
|
161
|
+
except Exception as exc: # noqa: BLE001
|
|
162
|
+
self._breaker.record_failure()
|
|
163
|
+
logger.debug("AtomicMemory prefetch failed: %s", exc)
|
|
164
|
+
|
|
165
|
+
self._prefetch_thread = threading.Thread(
|
|
166
|
+
target=_run, daemon=True, name="atomicmemory-prefetch",
|
|
167
|
+
)
|
|
168
|
+
self._prefetch_thread.start()
|
|
169
|
+
|
|
170
|
+
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
|
171
|
+
del query, session_id
|
|
172
|
+
if not self._context_enabled():
|
|
173
|
+
return ""
|
|
174
|
+
thread = self._prefetch_thread
|
|
175
|
+
if thread is not None and thread.is_alive():
|
|
176
|
+
thread.join(timeout=3.0)
|
|
177
|
+
with self._prefetch_lock:
|
|
178
|
+
result = self._prefetch_result
|
|
179
|
+
self._prefetch_result = ""
|
|
180
|
+
return f"## AtomicMemory\n{result}" if result else ""
|
|
181
|
+
|
|
182
|
+
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
|
183
|
+
if not self._context_enabled() or self._breaker.is_open() or self._worker is None:
|
|
184
|
+
return
|
|
185
|
+
self._worker.submit(
|
|
186
|
+
IngestJob(
|
|
187
|
+
user_content=user_content,
|
|
188
|
+
assistant_content=assistant_content,
|
|
189
|
+
session_id=session_id or self._session_id,
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def on_session_end(self, messages: list[dict[str, Any]]) -> None:
|
|
194
|
+
del messages
|
|
195
|
+
self.shutdown()
|
|
196
|
+
|
|
197
|
+
def shutdown(self) -> None:
|
|
198
|
+
if self._worker is not None:
|
|
199
|
+
self._worker.shutdown()
|
|
200
|
+
self._worker = None
|
|
201
|
+
if self._client is not None:
|
|
202
|
+
try:
|
|
203
|
+
self._client.shutdown()
|
|
204
|
+
except Exception as exc: # noqa: BLE001
|
|
205
|
+
logger.debug("AtomicMemory client shutdown failed: %s", exc)
|
|
206
|
+
self._client = None
|
|
207
|
+
|
|
208
|
+
def get_tool_schemas(self) -> list[dict[str, Any]]:
|
|
209
|
+
if not self._tools_enabled():
|
|
210
|
+
return []
|
|
211
|
+
return [
|
|
212
|
+
profile_schema(self._config.memory_scope),
|
|
213
|
+
SEARCH_SCHEMA,
|
|
214
|
+
CONTEXT_SCHEMA,
|
|
215
|
+
CONCLUDE_SCHEMA,
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
def handle_tool_call(self, tool_name: str, args: dict[str, Any], **kwargs: Any) -> str:
|
|
219
|
+
del kwargs
|
|
220
|
+
if not self._tools_enabled():
|
|
221
|
+
return tool_error("AtomicMemory tools are disabled by memory_mode.")
|
|
222
|
+
if self._breaker.is_open():
|
|
223
|
+
return json.dumps(
|
|
224
|
+
{
|
|
225
|
+
"error": (
|
|
226
|
+
"AtomicMemory client temporarily unavailable after repeated failures. "
|
|
227
|
+
"It will retry automatically."
|
|
228
|
+
),
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
handler = TOOL_HANDLERS.get(tool_name)
|
|
232
|
+
if handler is None:
|
|
233
|
+
return tool_error(f"Unknown tool: {tool_name}")
|
|
234
|
+
try:
|
|
235
|
+
result = handler(self, args)
|
|
236
|
+
self._breaker.record_success()
|
|
237
|
+
return result
|
|
238
|
+
except Exception as exc: # noqa: BLE001
|
|
239
|
+
self._breaker.record_failure()
|
|
240
|
+
return tool_error(str(exc))
|
|
241
|
+
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
# Helpers shared with tools.py
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def _context_enabled(self) -> bool:
|
|
247
|
+
return self._config.memory_mode in {"hybrid", "context"}
|
|
248
|
+
|
|
249
|
+
def _tools_enabled(self) -> bool:
|
|
250
|
+
return self._config.memory_mode in {"hybrid", "tools"}
|
|
251
|
+
|
|
252
|
+
def _require_client(self) -> AtomicMemoryClient:
|
|
253
|
+
if self._client is None:
|
|
254
|
+
raise BridgeError("AtomicMemory client not initialized", code="NOT_INITIALIZED")
|
|
255
|
+
return self._client
|
|
256
|
+
|
|
257
|
+
def _ingest_provenance(self, *, session_id: str) -> Provenance:
|
|
258
|
+
return Provenance(
|
|
259
|
+
source=SOURCE_SITE,
|
|
260
|
+
source_url=f"hermes://session/{session_id or self._session_id or 'default'}",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def _read_kwargs(self, *, limit_override: int | None = None) -> dict[str, Any]:
|
|
264
|
+
kwargs = read_scope_kwargs(memory_scope=self._config.memory_scope, user_id=self._user_id)
|
|
265
|
+
kwargs["limit"] = limit_override if limit_override is not None else self._config.search_limit
|
|
266
|
+
return kwargs
|
|
267
|
+
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
# Prefetch + ingest worker
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def _prefetch_text(self, query: str) -> str:
|
|
273
|
+
client = self._require_client()
|
|
274
|
+
kwargs = read_scope_kwargs(memory_scope=self._config.memory_scope, user_id=self._user_id)
|
|
275
|
+
if self._config.prefetch_method == "fast":
|
|
276
|
+
page = client.search(
|
|
277
|
+
query=query,
|
|
278
|
+
scope=kwargs["scope"],
|
|
279
|
+
limit=self._config.search_limit,
|
|
280
|
+
source_site=kwargs.get("source_site"),
|
|
281
|
+
)
|
|
282
|
+
return format_memory_bullets(page)
|
|
283
|
+
package = client.package(
|
|
284
|
+
query=query,
|
|
285
|
+
scope=kwargs["scope"],
|
|
286
|
+
token_budget=self._config.token_budget,
|
|
287
|
+
source_site=kwargs.get("source_site"),
|
|
288
|
+
)
|
|
289
|
+
if package.injection_text:
|
|
290
|
+
return package.injection_text
|
|
291
|
+
return format_memory_bullets(package)
|
|
292
|
+
|
|
293
|
+
def _run_ingest_job(self, job: IngestJob) -> None:
|
|
294
|
+
if self._client is None:
|
|
295
|
+
return
|
|
296
|
+
try:
|
|
297
|
+
self._client.ingest_messages(
|
|
298
|
+
messages=[
|
|
299
|
+
Message(role="user", content=job.user_content),
|
|
300
|
+
Message(role="assistant", content=job.assistant_content),
|
|
301
|
+
],
|
|
302
|
+
scope=ingest_scope_dict(user_id=self._user_id),
|
|
303
|
+
provenance=self._ingest_provenance(session_id=job.session_id),
|
|
304
|
+
metadata={"kind": "turn"},
|
|
305
|
+
)
|
|
306
|
+
self._breaker.record_success()
|
|
307
|
+
except Exception:
|
|
308
|
+
self._breaker.record_failure()
|
|
309
|
+
raise
|
|
310
|
+
|
|
311
|
+
def _on_ingest_failure(self, exc: BaseException) -> None:
|
|
312
|
+
logger.warning("AtomicMemory ingest failed: %s", exc)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _default_client_factory(config: ProviderConfig) -> AtomicMemoryClient:
|
|
316
|
+
return PythonSdkAtomicMemoryClient(
|
|
317
|
+
config=PythonSdkConfig(
|
|
318
|
+
provider=os.environ.get("ATOMICMEMORY_PROVIDER", "atomicmemory"),
|
|
319
|
+
api_url=os.environ.get("ATOMICMEMORY_API_URL"),
|
|
320
|
+
api_key=os.environ.get("ATOMICMEMORY_API_KEY"),
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _clean_str(value: Any) -> str | None:
|
|
326
|
+
if value is None:
|
|
327
|
+
return None
|
|
328
|
+
cleaned = str(value).strip()
|
|
329
|
+
return cleaned or None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def register(ctx: Any) -> None:
|
|
333
|
+
ctx.register_memory_provider(AtomicMemoryMemoryProvider())
|
package/breaker.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Thread-safe circuit breaker — restored from v1, with a real lock.
|
|
2
|
+
|
|
3
|
+
Wraps every SDK call. After `threshold` consecutive failures the breaker
|
|
4
|
+
opens for `cooldown_seconds`; the next probing call after cooldown reads as
|
|
5
|
+
closed (the counter is cleared eagerly on the read so a single success
|
|
6
|
+
proves recovery).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CircuitBreaker:
|
|
22
|
+
threshold: int = 5
|
|
23
|
+
cooldown_seconds: float = 120.0
|
|
24
|
+
_consecutive_failures: int = 0
|
|
25
|
+
_open_until_monotonic: float = 0.0
|
|
26
|
+
_lock: threading.Lock = None # type: ignore[assignment]
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if self._lock is None:
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
def is_open(self, *, now: float | None = None) -> bool:
|
|
33
|
+
clock = now if now is not None else time.monotonic()
|
|
34
|
+
with self._lock:
|
|
35
|
+
if self._consecutive_failures < self.threshold:
|
|
36
|
+
return False
|
|
37
|
+
if clock >= self._open_until_monotonic:
|
|
38
|
+
self._consecutive_failures = 0
|
|
39
|
+
return False
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
def record_success(self) -> None:
|
|
43
|
+
with self._lock:
|
|
44
|
+
self._consecutive_failures = 0
|
|
45
|
+
|
|
46
|
+
def record_failure(self, *, now: float | None = None) -> None:
|
|
47
|
+
clock = now if now is not None else time.monotonic()
|
|
48
|
+
with self._lock:
|
|
49
|
+
self._consecutive_failures += 1
|
|
50
|
+
if self._consecutive_failures >= self.threshold:
|
|
51
|
+
self._open_until_monotonic = clock + self.cooldown_seconds
|
|
52
|
+
logger.warning(
|
|
53
|
+
"AtomicMemory circuit breaker tripped after %d consecutive failures. "
|
|
54
|
+
"Pausing calls for %.0fs.",
|
|
55
|
+
self._consecutive_failures,
|
|
56
|
+
self.cooldown_seconds,
|
|
57
|
+
)
|
package/client.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""AtomicMemoryClient protocol.
|
|
2
|
+
|
|
3
|
+
Narrow seam between the Hermes Python provider and the AtomicMemory Python SDK.
|
|
4
|
+
The provider depends only on this interface so lifecycle code, tool handlers,
|
|
5
|
+
scope policy, and tests are isolated from SDK-specific response shapes.
|
|
6
|
+
|
|
7
|
+
The shapes here intentionally drop SDK fields the Hermes integration does
|
|
8
|
+
not consume (importance, tier assignments, observability blocks). Add them
|
|
9
|
+
when a call site needs them, not before.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Protocol
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
Scope = dict[str, Any]
|
|
19
|
+
"""Scope dict matching the V3 MemoryClient shape: {user, agent?, namespace?, thread?}.
|
|
20
|
+
|
|
21
|
+
Hermes uses user-only scope. Workspace scope is left available for future
|
|
22
|
+
provider modes but is not exercised in v2.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Message:
|
|
28
|
+
"""One turn message used by ingest_messages."""
|
|
29
|
+
|
|
30
|
+
role: str
|
|
31
|
+
content: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Provenance:
|
|
36
|
+
"""Ingest provenance. source is required by Hermes; everything else optional."""
|
|
37
|
+
|
|
38
|
+
source: str
|
|
39
|
+
source_url: str | None = None
|
|
40
|
+
source_id: str | None = None
|
|
41
|
+
extractor: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class MemoryItem:
|
|
46
|
+
"""Minimal memory record consumed by Hermes tools (search/list/profile)."""
|
|
47
|
+
|
|
48
|
+
id: str | None
|
|
49
|
+
content: str
|
|
50
|
+
score: float | None = None
|
|
51
|
+
source_site: str | None = None
|
|
52
|
+
created_at: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class SearchPage:
|
|
57
|
+
memories: list[MemoryItem] = field(default_factory=list)
|
|
58
|
+
count: int = 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class ContextPackage:
|
|
63
|
+
injection_text: str = ""
|
|
64
|
+
memories: list[MemoryItem] = field(default_factory=list)
|
|
65
|
+
count: int = 0
|
|
66
|
+
estimated_context_tokens: int = 0
|
|
67
|
+
citations: list[str] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class ListPage:
|
|
72
|
+
memories: list[MemoryItem] = field(default_factory=list)
|
|
73
|
+
count: int = 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class IngestResult:
|
|
78
|
+
created: list[str] = field(default_factory=list)
|
|
79
|
+
updated: list[str] = field(default_factory=list)
|
|
80
|
+
unchanged: list[str] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class BridgeError(RuntimeError):
|
|
84
|
+
"""Bridge call returned a typed error."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, message: str, code: str | None = None) -> None:
|
|
87
|
+
super().__init__(message)
|
|
88
|
+
self.code = code
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class BridgeCrashedError(BridgeError):
|
|
92
|
+
"""Client transport exited unexpectedly mid-call."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, message: str = "AtomicMemory client transport exited") -> None:
|
|
95
|
+
super().__init__(message, code="BRIDGE_CRASHED")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ProviderUnsupportedError(BridgeError):
|
|
99
|
+
"""Client returned PROVIDER_UNSUPPORTED — the active provider lacks a feature.
|
|
100
|
+
|
|
101
|
+
Today this happens when source_site is requested with a non-AtomicMemory
|
|
102
|
+
provider. The client fails loudly rather than silently dropping the filter.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, message: str) -> None:
|
|
106
|
+
super().__init__(message, code="PROVIDER_UNSUPPORTED")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AtomicMemoryClient(Protocol):
|
|
110
|
+
"""Narrow client surface used by the Hermes provider.
|
|
111
|
+
|
|
112
|
+
The production Python SDK implementation lives in python_sdk.py.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def initialize(self) -> None: ...
|
|
116
|
+
|
|
117
|
+
def shutdown(self) -> None: ...
|
|
118
|
+
|
|
119
|
+
def search(
|
|
120
|
+
self,
|
|
121
|
+
*,
|
|
122
|
+
query: str,
|
|
123
|
+
scope: Scope,
|
|
124
|
+
limit: int,
|
|
125
|
+
source_site: str | None = None,
|
|
126
|
+
) -> SearchPage: ...
|
|
127
|
+
|
|
128
|
+
def package(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
query: str,
|
|
132
|
+
scope: Scope,
|
|
133
|
+
token_budget: int,
|
|
134
|
+
source_site: str | None = None,
|
|
135
|
+
) -> ContextPackage: ...
|
|
136
|
+
|
|
137
|
+
def list_recent(
|
|
138
|
+
self,
|
|
139
|
+
*,
|
|
140
|
+
scope: Scope,
|
|
141
|
+
limit: int,
|
|
142
|
+
source_site: str | None = None,
|
|
143
|
+
) -> ListPage: ...
|
|
144
|
+
|
|
145
|
+
def ingest_messages(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
messages: list[Message],
|
|
149
|
+
scope: Scope,
|
|
150
|
+
provenance: Provenance,
|
|
151
|
+
metadata: dict[str, Any] | None = None,
|
|
152
|
+
) -> IngestResult: ...
|
|
153
|
+
|
|
154
|
+
def ingest_verbatim(
|
|
155
|
+
self,
|
|
156
|
+
*,
|
|
157
|
+
content: str,
|
|
158
|
+
scope: Scope,
|
|
159
|
+
provenance: Provenance,
|
|
160
|
+
metadata: dict[str, Any] | None = None,
|
|
161
|
+
) -> IngestResult: ...
|