mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
b785f22989
commit
45cd01a904
@@ -82,6 +82,8 @@ OpenHuman skips the wait. Connect your accounts, let [auto-fetch](https://tinyhu
|
||||
|
||||
In just one sync pass, the agent has full (compressed) context of your inbox, your calendar, your repos, your docs, your messages. No training period. No "give it a few weeks.". It becomes you, controlled by you.
|
||||
|
||||
Already self-host [agentmemory](https://github.com/rohitg00/agentmemory) across other coding agents? OpenHuman ships an optional `Memory` backend that proxies to it — set `memory.backend = "agentmemory"` in `config.toml` and the same durable store powers OpenHuman alongside Claude Code, Cursor, Codex, and OpenCode. See the [agentmemory backend](https://tinyhumans.gitbook.io/openhuman/features/obsidian-wiki/agentmemory-backend) page for setup.
|
||||
|
||||
## OpenHuman vs Other Agent Harnesses
|
||||
|
||||
High-level comparison (products evolve, so verify against each vendor). OpenHuman is built to **minimize vendor sprawl**, keep **workflow knowledge on-device**, and give the agent a **persistent memory** of your data, not only chat.
|
||||
@@ -91,7 +93,7 @@ High-level comparison (products evolve, so verify against each vendor). OpenHuma
|
||||
| **Open-source** | 🚫 Proprietary | ✅ MIT | ✅ MIT | ✅ GNU |
|
||||
| **Simple to start** | ✅ Desktop + CLI | ⚠️ Terminal-first | ⚠️ Terminal-first | ✅ Clean UI, minutes |
|
||||
| **Cost** | ⚠️ Sub + add-ons | ⚠️ BYO models | ⚠️ BYO models | ✅ One sub + TokenJuice |
|
||||
| **Memory** | ✅ Chat-scoped | ⚠️ Plugin-reliant | ✅ Self-learning | 🚀 Memory Tree + Obsidian vault |
|
||||
| **Memory** | ✅ Chat-scoped | ⚠️ Plugin-reliant | ✅ Self-learning | 🚀 Memory Tree + Obsidian vault, optional [agentmemory](https://github.com/rohitg00/agentmemory) backend |
|
||||
| **Integrations** | ⚠️ Few connectors | ⚠️ BYO | ⚠️ BYO | 🚀 118+ via OAuth |
|
||||
| **Auto-fetch** | 🚫 None | 🚫 None | 🚫 None | ✅ 20-min sync into memory |
|
||||
| **API sprawl** | 🚫 Extra keys | 🚫 BYOK | 🚫 Multi-vendor | ✅ One account |
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* [Meeting Agents](features/mascot/meeting-agents.md)
|
||||
* [Obsidian-Style Memory](features/obsidian-wiki/README.md)
|
||||
* [Memory Trees](features/obsidian-wiki/memory-tree.md)
|
||||
* [agentmemory backend](features/obsidian-wiki/agentmemory-backend.md)
|
||||
* [Auto-fetch from Integrations](features/obsidian-wiki/auto-fetch.md)
|
||||
* [Third-party Integrations (118+)](features/integrations/README.md)
|
||||
* [Triggers](features/integrations/triggers.md)
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
description: >-
|
||||
Optional `Memory` trait backend that delegates to a locally-running
|
||||
agentmemory REST server, for users who self-host agentmemory across
|
||||
Claude Code, Cursor, Codex, OpenCode, and OpenHuman.
|
||||
icon: database
|
||||
---
|
||||
|
||||
# agentmemory backend
|
||||
|
||||
OpenHuman's default `Memory` trait backend is `sqlite` — the unified store
|
||||
documented in [Memory Trees](memory-tree.md). For users who already
|
||||
self-host [agentmemory](https://github.com/rohitg00/agentmemory) — typically
|
||||
because they want a single durable memory shared across Claude Code,
|
||||
Cursor, Codex, OpenCode, and OpenHuman — OpenHuman exposes an opt-in
|
||||
backend that proxies every trait call through agentmemory's REST surface.
|
||||
|
||||
Selecting `backend = "agentmemory"` skips OpenHuman's SQLite + embedder
|
||||
path entirely. agentmemory owns the storage, embedding, and retrieval
|
||||
layers. OpenHuman becomes a thin REST client.
|
||||
|
||||
## When to use this
|
||||
|
||||
Use the agentmemory backend if:
|
||||
|
||||
- You already run `npx -y @agentmemory/agentmemory` for one or more
|
||||
coding agents and want OpenHuman to share the same durable store.
|
||||
- You want hybrid BM25 + vector + graph retrieval without provisioning a
|
||||
separate embedder on the OpenHuman side.
|
||||
- You prefer agentmemory's lifecycle (consolidation, retention scoring,
|
||||
auto-forget, graph extraction) over OpenHuman's unified store.
|
||||
|
||||
Keep the default `sqlite` backend if:
|
||||
|
||||
- You want self-contained, single-process operation with no external
|
||||
daemon dependency.
|
||||
- You rely on OpenHuman-specific Memory Tree features (chunking,
|
||||
sealing, summary trees) that operate on top of the SQLite store. The
|
||||
Memory Tree pipeline is unaffected by the trait backend — it operates
|
||||
on the host's document store, orthogonally — but the agentmemory
|
||||
backend is most valuable when you've already standardised on
|
||||
agentmemory across other agents.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. **Install + start agentmemory** (one terminal):
|
||||
|
||||
```bash
|
||||
npx -y @agentmemory/agentmemory
|
||||
```
|
||||
|
||||
Defaults to `http://localhost:3111` (REST) + `ws://localhost:49134`
|
||||
(engine). First boot generates an HMAC secret at `~/.agentmemory/.hmac`
|
||||
and prints it once.
|
||||
|
||||
2. **Point OpenHuman at it** in your `config.toml`:
|
||||
|
||||
```toml
|
||||
[memory]
|
||||
backend = "agentmemory"
|
||||
# Defaults below — set only when overriding.
|
||||
# agentmemory_url = "http://localhost:3111"
|
||||
# agentmemory_secret = "" # HMAC bearer token, optional
|
||||
# agentmemory_timeout_ms = 5000
|
||||
```
|
||||
|
||||
3. **Restart OpenHuman**. The factory short-circuits the SQLite path
|
||||
and logs `[memory::factory] using agentmemory backend at <url>`.
|
||||
|
||||
That's it. Existing OpenHuman call sites (`store`, `recall`, `get`,
|
||||
`list`, `forget`, `namespace_summaries`, `count`, `health_check`) work
|
||||
unchanged.
|
||||
|
||||
## Config keys
|
||||
|
||||
| Field | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `agentmemory_url` | `http://localhost:3111` | Base URL for the agentmemory REST server |
|
||||
| `agentmemory_secret` | _none_ | Optional HMAC bearer token. Sent as `Authorization: Bearer <secret>` |
|
||||
| `agentmemory_timeout_ms` | `5000` | Per-request reqwest timeout |
|
||||
|
||||
When `backend == "agentmemory"`, the following existing `MemoryConfig`
|
||||
fields are **ignored** — agentmemory owns its own embedding stack via
|
||||
`~/.agentmemory/.env`:
|
||||
|
||||
- `embedding_provider`
|
||||
- `embedding_model`
|
||||
- `embedding_dimensions`
|
||||
- `sqlite_open_timeout_secs`
|
||||
|
||||
Setting them on this path is a no-op. The local-AI Ollama health-gate
|
||||
also doesn't run on this path — agentmemory's daemon manages its own
|
||||
embedder lifecycle.
|
||||
|
||||
## Field mapping
|
||||
|
||||
OpenHuman's `MemoryEntry` ↔ agentmemory wire row:
|
||||
|
||||
| OpenHuman field | agentmemory field | Notes |
|
||||
|---|---|---|
|
||||
| `namespace` | `project` | Defaults to `"default"` when empty |
|
||||
| `key` | `title` | |
|
||||
| `content` | `content` | |
|
||||
| `id` | `id` | agentmemory-generated (`mem_<rand>`) |
|
||||
| `category: Core` | `type: "fact"` | |
|
||||
| `category: Daily` | `type: "conversation"` | |
|
||||
| `category: Conversation` | `type: "conversation"` | |
|
||||
| `category: Custom(s)` | `type: "fact"` + `concepts: [s]` | Custom tag rolled into the concepts array so it remains queryable |
|
||||
| `session_id` | `sessionIds: [...]` | OpenHuman exposes a single id; agentmemory persists an array |
|
||||
| `timestamp` | `updatedAt` (RFC3339) | Falls back to `createdAt` if `updatedAt` is absent |
|
||||
| `score` (recall hits only) | smart-search `score` | Populated on `recall` responses, `None` on `get` / `list` |
|
||||
|
||||
agentmemory carries additional fields — `concepts` (auto-extracted),
|
||||
`files` (path tags), `strength` (retention score), `version`,
|
||||
`supersedes` (the lifecycle chain) — that this backend leaves at
|
||||
defaults. They're internal to agentmemory's lifecycle layer and don't
|
||||
need to round-trip through OpenHuman's trait.
|
||||
|
||||
## Trait method → endpoint
|
||||
|
||||
| `Memory` method | agentmemory REST | Notes |
|
||||
|---|---|---|
|
||||
| `store` | `POST /agentmemory/remember` | `{project, title, content, type, concepts, sessionIds}` |
|
||||
| `recall` | `POST /agentmemory/smart-search` | Hybrid BM25 + vector + graph |
|
||||
| `get` | `POST /agentmemory/smart-search` | + client-side exact-title filter |
|
||||
| `list` | `GET /agentmemory/memories?latest=true&project=<ns>` | |
|
||||
| `forget` | `get(ns, key)` → `POST /agentmemory/forget` | Two-step: resolve id then forget |
|
||||
| `namespace_summaries` | `GET /agentmemory/projects` | Returns `[{name, count, lastUpdated}]` |
|
||||
| `count` | `GET /agentmemory/health` | Reads `memories` field |
|
||||
| `health_check` | `GET /agentmemory/livez` | |
|
||||
|
||||
`RecallOpts.category`, `RecallOpts.session_id`, and `RecallOpts.min_score`
|
||||
are applied as **client-side filters** on the smart-search response.
|
||||
agentmemory's REST surface doesn't expose them as server-side filters
|
||||
today. For very large recall windows (limit > 100) prefer issuing a
|
||||
tighter query string to reduce server-side work over relying on
|
||||
client-side post-filtering.
|
||||
|
||||
## Security
|
||||
|
||||
When `agentmemory_secret` is set, the client honours agentmemory's
|
||||
v0.9.12 plaintext-bearer guard contract:
|
||||
|
||||
- **Loopback hosts** (`localhost`, `127.0.0.1`, `::1`) over `http://` —
|
||||
allowed. Local dev path.
|
||||
- **`https://`** to any host — allowed.
|
||||
- **Plaintext HTTP to a non-loopback host** — emits a one-time stderr
|
||||
warning at construction time. The bearer is observable on the wire.
|
||||
- **`AGENTMEMORY_REQUIRE_HTTPS=1`** (process env, ASCII-case-insensitive
|
||||
matches `1` or `true`) — escalates the warning into a hard refusal at
|
||||
client construction. The backend fails to start rather than leak the
|
||||
bearer once.
|
||||
|
||||
Production deploys should set `AGENTMEMORY_REQUIRE_HTTPS=1` so a
|
||||
misconfigured TLS terminator fails loud rather than silently leaking.
|
||||
|
||||
The plaintext-bearer guard mirrors the integration plugin guards in
|
||||
agentmemory's [PR #315](https://github.com/rohitg00/agentmemory/pull/315)
|
||||
so an operator who's seen the warning on Hermes / OpenClaw / pi will
|
||||
recognise the same message on OpenHuman.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Failure | Backend behaviour |
|
||||
|---|---|
|
||||
| Daemon unreachable at startup | `from_config` succeeds (URL parses), but `health_check()` returns false on first call. Trait methods bubble up `reqwest` transport errors |
|
||||
| Network timeout | `anyhow::Error` per trait contract; surfaces to caller |
|
||||
| 4xx / 5xx response | `anyhow::Error` with status + body snippet |
|
||||
| Bearer over plaintext non-loopback (no env) | One-time stderr warning, request proceeds |
|
||||
| Bearer over plaintext non-loopback + `AGENTMEMORY_REQUIRE_HTTPS=1` | Hard refusal at construction time |
|
||||
| Empty `agentmemory_url` | Hard refusal at construction time with hint to leave it unset for the default |
|
||||
| Invalid URL syntax | Hard refusal at construction time with the parser error |
|
||||
|
||||
**No automatic fallback to SQLite.** If the daemon is down at boot, the
|
||||
backend surfaces the transport error loudly. Operators flip back to
|
||||
`backend = "sqlite"` in `config.toml` to recover. Rationale: a silent
|
||||
SQLite fallback would hide a misconfigured daemon — "private, simple,
|
||||
predictable" wins over "magically tolerant".
|
||||
|
||||
## Performance notes
|
||||
|
||||
The backend is a thin REST proxy — it adds one HTTP round-trip per
|
||||
trait call. Practical implications:
|
||||
|
||||
- `store` and `forget` are single-RTT.
|
||||
- `recall`, `get`, `list` are single-RTT.
|
||||
- `forget` against an unknown key is two-RTT (the implicit `get` lookup
|
||||
+ a no-op confirmation). Caller can short-circuit this by checking
|
||||
the return value of a prior `list`.
|
||||
- agentmemory's REST is `127.0.0.1` by default — same-host latency is
|
||||
sub-millisecond. Over a managed deploy with HTTPS termination, expect
|
||||
~10–30ms per RTT.
|
||||
- The default per-request timeout is 5 seconds. Bump
|
||||
`agentmemory_timeout_ms` if you're seeing intermittent timeouts on
|
||||
cold-start of the iii engine; agentmemory's first-request latency
|
||||
after a long idle can stretch toward 3–5s depending on persistence
|
||||
state.
|
||||
|
||||
## Migration: from SQLite to agentmemory
|
||||
|
||||
There's no in-place migration today. The recommended path:
|
||||
|
||||
1. Export your existing memories from the SQLite store via OpenHuman's
|
||||
existing export RPC (or by direct SQL).
|
||||
2. Walk the export and POST each row to `/agentmemory/remember` with
|
||||
the same `project` + `title` + `content`. agentmemory will assign
|
||||
new ids; the OpenHuman side picks them up on first `list`.
|
||||
3. Set `backend = "agentmemory"` and restart.
|
||||
|
||||
A dedicated bulk import path is filed as a follow-up.
|
||||
|
||||
## Implementation reference
|
||||
|
||||
In-tree files:
|
||||
|
||||
- [`store/agentmemory/mod.rs`](https://github.com/tinyhumansai/openhuman/tree/main/src/openhuman/memory/store/agentmemory/mod.rs) — module surface
|
||||
- [`store/agentmemory/backend.rs`](https://github.com/tinyhumansai/openhuman/tree/main/src/openhuman/memory/store/agentmemory/backend.rs) — `impl Memory for AgentMemoryBackend`
|
||||
- [`store/agentmemory/client.rs`](https://github.com/tinyhumansai/openhuman/tree/main/src/openhuman/memory/store/agentmemory/client.rs) — reqwest wrapper + plaintext-bearer guard
|
||||
- [`store/agentmemory/mapping.rs`](https://github.com/tinyhumansai/openhuman/tree/main/src/openhuman/memory/store/agentmemory/mapping.rs) — `MemoryEntry` ↔ agentmemory JSON
|
||||
- [`tests/agentmemory_backend.rs`](https://github.com/tinyhumansai/openhuman/tree/main/tests/agentmemory_backend.rs) — 12 axum-mock integration tests
|
||||
|
||||
Related upstream:
|
||||
|
||||
- agentmemory repo — <https://github.com/rohitg00/agentmemory>
|
||||
- agentmemory REST contract — `~/.agentmemory/.env` keys + endpoint
|
||||
list in the agentmemory README
|
||||
- v0.9.12 plaintext-bearer guard — agentmemory PR #315
|
||||
@@ -166,3 +166,13 @@ Open it from the bottom navigation bar.
|
||||
**Search & retrieval.** A search bar over the Memory Tree. Source-scoped, topic-scoped or global queries are all supported, and any result links back to the underlying chunk file in your Obsidian vault for full provenance.
|
||||
|
||||
**Routing.** The Intelligence tab also surfaces which model the agent is using per task - see [Automatic Model Routing](../model-routing/).
|
||||
|
||||
## Swapping the backend
|
||||
|
||||
The Memory Tree pipeline (chunker → score → seal → summarise) is the
|
||||
default. Operators who self-host [agentmemory](https://github.com/rohitg00/agentmemory)
|
||||
across multiple agents and want OpenHuman to share that same durable
|
||||
store can opt into an external backend via `MemoryConfig.backend =
|
||||
"agentmemory"` — see [agentmemory backend](agentmemory-backend.md) for
|
||||
config keys, field mapping, endpoint table, security, and failure
|
||||
modes.
|
||||
|
||||
@@ -208,6 +208,21 @@ const CAPABILITIES: &[Capability] = &[
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: None,
|
||||
},
|
||||
Capability {
|
||||
id: "intelligence.agentmemory_backend",
|
||||
name: "agentmemory Memory Backend",
|
||||
domain: "intelligence",
|
||||
category: CapabilityCategory::Intelligence,
|
||||
description: "Opt-in Memory trait backend that delegates every store/recall/get/list/forget \
|
||||
call to a locally-running agentmemory REST server. Selected via \
|
||||
`memory.backend = \"agentmemory\"` in config.toml. Allows users who self-host \
|
||||
agentmemory across Claude Code, Cursor, Codex, and OpenCode to share a single durable \
|
||||
memory store. Default backend remains sqlite; selecting agentmemory is non-breaking.",
|
||||
how_to: "Set `memory.backend = \"agentmemory\"` in config.toml. \
|
||||
See gitbooks/features/obsidian-wiki/agentmemory-backend.md for setup and config keys.",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: LOCAL_RAW,
|
||||
},
|
||||
Capability {
|
||||
id: "intelligence.memory_workspace",
|
||||
name: "Memory Workspace",
|
||||
|
||||
@@ -33,7 +33,7 @@ impl Default for StorageProviderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[serde(default)]
|
||||
pub struct MemoryConfig {
|
||||
@@ -51,6 +51,26 @@ pub struct MemoryConfig {
|
||||
pub min_relevance_score: f64,
|
||||
#[serde(default)]
|
||||
pub sqlite_open_timeout_secs: Option<u64>,
|
||||
|
||||
/// Base URL for the `agentmemory` REST server. Honored only when
|
||||
/// `backend = "agentmemory"`. Defaults to `http://localhost:3111`
|
||||
/// (the agentmemory loopback default).
|
||||
#[serde(default)]
|
||||
pub agentmemory_url: Option<String>,
|
||||
|
||||
/// Optional bearer token sent as `Authorization: Bearer <secret>`
|
||||
/// to the agentmemory REST server. When unset, the backend speaks
|
||||
/// to a local agentmemory daemon without authentication. Setting a
|
||||
/// secret + a non-loopback host enables the v0.9.12 plaintext-bearer
|
||||
/// guard semantics on the client side: the backend refuses to send
|
||||
/// the token over plaintext HTTP when the host is not loopback.
|
||||
#[serde(default)]
|
||||
pub agentmemory_secret: Option<String>,
|
||||
|
||||
/// Per-request timeout for the agentmemory REST client, in
|
||||
/// milliseconds. Defaults to 5000 ms.
|
||||
#[serde(default)]
|
||||
pub agentmemory_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
fn default_memory_backend() -> String {
|
||||
@@ -91,10 +111,38 @@ impl Default for MemoryConfig {
|
||||
embedding_dimensions: default_embedding_dims(),
|
||||
min_relevance_score: default_min_relevance_score(),
|
||||
sqlite_open_timeout_secs: None,
|
||||
agentmemory_url: None,
|
||||
agentmemory_secret: None,
|
||||
agentmemory_timeout_ms: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Manual `Debug` implementation that redacts `agentmemory_secret`. Without
|
||||
// this, any `format!("{cfg:?}")` / `tracing::debug!(?cfg, ...)` / panic
|
||||
// message capturing a `MemoryConfig` would dump the bearer token in
|
||||
// plaintext — directly against the repo rule "Never log secrets, raw
|
||||
// JWTs, API keys, credentials, or full PII in debug logs".
|
||||
impl std::fmt::Debug for MemoryConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("MemoryConfig")
|
||||
.field("backend", &self.backend)
|
||||
.field("auto_save", &self.auto_save)
|
||||
.field("embedding_provider", &self.embedding_provider)
|
||||
.field("embedding_model", &self.embedding_model)
|
||||
.field("embedding_dimensions", &self.embedding_dimensions)
|
||||
.field("min_relevance_score", &self.min_relevance_score)
|
||||
.field("sqlite_open_timeout_secs", &self.sqlite_open_timeout_secs)
|
||||
.field("agentmemory_url", &self.agentmemory_url)
|
||||
.field(
|
||||
"agentmemory_secret",
|
||||
&self.agentmemory_secret.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field("agentmemory_timeout_ms", &self.agentmemory_timeout_ms)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Which inference backend the memory_tree's LLM calls (extractor +
|
||||
/// summariser) should use.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# agentmemory backend
|
||||
|
||||
Optional `Memory` backend that delegates every trait call to a locally-running
|
||||
[agentmemory](https://github.com/rohitg00/agentmemory) REST server (default
|
||||
`http://localhost:3111`).
|
||||
|
||||
Selected via:
|
||||
|
||||
```toml
|
||||
[memory]
|
||||
backend = "agentmemory"
|
||||
```
|
||||
|
||||
The default backend stays `sqlite`; selecting `"agentmemory"` is opt-in and
|
||||
non-breaking for existing configs.
|
||||
|
||||
## Why another backend (and why not via MCP tools)
|
||||
|
||||
OpenHuman already supports MCP servers as tools, but that path is
|
||||
agent-facing — the LLM picks `recall` / `save` as tool calls per turn. The
|
||||
`Memory` trait is the *host-facing* surface: harness, archivist, reflection,
|
||||
prompt-section builders all consume it directly, without going through
|
||||
tool-call latency.
|
||||
|
||||
Plugging in as a `Memory` backend lets agentmemory back things like
|
||||
`mem::context` injection in `loadMemoryRules`, reflection passes, and the
|
||||
namespace summaries that drive agent-side discovery — none of which would
|
||||
route through MCP today.
|
||||
|
||||
It also lets operators who self-host agentmemory across multiple agents
|
||||
(Claude Code, Cursor, Codex, OpenCode, plus OpenHuman) share a single
|
||||
durable memory.
|
||||
|
||||
## Config keys
|
||||
|
||||
| Field | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `agentmemory_url` | `http://localhost:3111` | Base URL for the agentmemory REST server |
|
||||
| `agentmemory_secret` | `None` | Optional HMAC bearer token sent as `Authorization: Bearer <secret>` |
|
||||
| `agentmemory_timeout_ms` | `5000` | Per-request reqwest timeout |
|
||||
|
||||
When `backend == "agentmemory"`, the existing `embedding_provider` /
|
||||
`embedding_model` / `embedding_dimensions` fields are **ignored** —
|
||||
agentmemory owns its own embedding stack via `~/.agentmemory/.env`. Setting
|
||||
them on this path is a no-op.
|
||||
|
||||
## Field mapping
|
||||
|
||||
| OpenHuman `MemoryEntry` | agentmemory wire |
|
||||
|---|---|
|
||||
| `namespace` | `project` (defaults to `"default"` if empty) |
|
||||
| `key` | `title` |
|
||||
| `content` | `content` |
|
||||
| `MemoryCategory::Core` | `type: "fact"` |
|
||||
| `MemoryCategory::Daily` | `type: "conversation"` |
|
||||
| `MemoryCategory::Conversation` | `type: "conversation"` |
|
||||
| `MemoryCategory::Custom(s)` | `type: "fact"`, `concepts: [s]` |
|
||||
| `session_id` | `sessionIds: [...]` |
|
||||
| `timestamp` | `updatedAt` (RFC3339), falling back to `createdAt` |
|
||||
| `score` (recall hits) | smart-search `score` |
|
||||
|
||||
agentmemory has additional fields (`concepts`, `files`, `strength`,
|
||||
`version`, `supersedes`) that this backend leaves at defaults — they're
|
||||
internal to agentmemory's lifecycle layer.
|
||||
|
||||
## Trait method → endpoint
|
||||
|
||||
| `Memory` method | agentmemory REST |
|
||||
|---|---|
|
||||
| `store` | `POST /agentmemory/remember` |
|
||||
| `recall` | `POST /agentmemory/smart-search` (hybrid BM25 + vector + graph) |
|
||||
| `get` | `POST /agentmemory/smart-search` then exact-title filter |
|
||||
| `list` | `GET /agentmemory/memories?latest=true&project=<ns>` |
|
||||
| `forget` | `get(ns, key)` → `POST /agentmemory/forget` with the id |
|
||||
| `namespace_summaries` | `GET /agentmemory/projects` |
|
||||
| `count` | `GET /agentmemory/health` (`memories` field) |
|
||||
| `health_check` | `GET /agentmemory/livez` |
|
||||
|
||||
`RecallOpts.category` / `session_id` / `min_score` are applied as
|
||||
client-side filters on the smart-search response (agentmemory's REST
|
||||
surface doesn't expose them as server-side filters today).
|
||||
|
||||
## Security: plaintext-bearer guard
|
||||
|
||||
When `agentmemory_secret` is set, the client refuses to send the token to a
|
||||
non-loopback host over `http://`. Loopback (`localhost`, `127.0.0.1`, `::1`)
|
||||
+ plaintext is allowed for local dev; everything else needs `https://` or
|
||||
the daemon must be reachable on loopback.
|
||||
|
||||
Set `AGENTMEMORY_REQUIRE_HTTPS=1` as a process env var to harden this from
|
||||
"warn on stderr" to "refuse to construct the client" — useful in
|
||||
production where a misconfigured TLS terminator should fail loud rather
|
||||
than leak the secret once.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Failure | Behaviour |
|
||||
|---|---|
|
||||
| agentmemory daemon down at construction time | `health_check()` returns false; trait methods bubble up the `reqwest` transport error |
|
||||
| Network timeout | Returns `anyhow::Error` per trait contract; surfaces to caller |
|
||||
| 4xx / 5xx response | Returns `anyhow::Error` with the response status + body snippet |
|
||||
| Bearer over plaintext HTTP non-loopback | Warns on stderr (matches agentmemory's own client guard from v0.9.12 PR #315) |
|
||||
| Bearer over plaintext HTTP + `AGENTMEMORY_REQUIRE_HTTPS=1` | Hard refusal at construction time |
|
||||
|
||||
No automatic fallback to `sqlite` — if the daemon is down at boot, the
|
||||
service fails loud. Operators flip back to `backend = "sqlite"` in config
|
||||
to recover. Rationale (per issue #1664 alignment): "private, simple,
|
||||
predictable" — a silent SQLite fallback hides a misconfigured daemon.
|
||||
@@ -0,0 +1,333 @@
|
||||
//! `impl Memory for AgentMemoryBackend` — the hot path for OpenHuman <→
|
||||
//! agentmemory traffic.
|
||||
//!
|
||||
//! The upstream agentmemory REST contract (endpoints, payloads, lifecycle
|
||||
//! semantics) lives at <https://github.com/rohitg00/agentmemory>. This
|
||||
//! module pins the OpenHuman-visible projection of that contract; the
|
||||
//! field-mapping table is in `mapping.rs` and the security guard is in
|
||||
//! `client.rs`.
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::openhuman::config::MemoryConfig;
|
||||
use crate::openhuman::memory::traits::{
|
||||
Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts,
|
||||
};
|
||||
|
||||
use super::client::AgentMemoryClient;
|
||||
use super::mapping::{
|
||||
ForgetRequest, ForgetResponse, HealthResponse, MemoriesResponse, ProjectsResponse,
|
||||
RememberRequest, RememberResponse, SmartSearchRequest, SmartSearchResponse, WireMemory,
|
||||
DEFAULT_PROJECT,
|
||||
};
|
||||
|
||||
/// Memory backend that proxies every trait call through agentmemory's REST
|
||||
/// surface. Construct via [`AgentMemoryBackend::from_config`].
|
||||
pub struct AgentMemoryBackend {
|
||||
client: AgentMemoryClient,
|
||||
}
|
||||
|
||||
impl AgentMemoryBackend {
|
||||
/// Build from a [`MemoryConfig`]. Reads the optional
|
||||
/// `agentmemory_url` / `agentmemory_secret` / `agentmemory_timeout_ms`
|
||||
/// fields and falls back to documented defaults
|
||||
/// (`http://localhost:3111`, no secret, 5000ms timeout).
|
||||
pub fn from_config(config: &MemoryConfig) -> Result<Self> {
|
||||
let client = AgentMemoryClient::new(
|
||||
config.agentmemory_url.as_deref(),
|
||||
config.agentmemory_secret.as_deref(),
|
||||
config.agentmemory_timeout_ms,
|
||||
)?;
|
||||
log::debug!(
|
||||
"[memory::agentmemory] backend initialised against {}",
|
||||
client.base()
|
||||
);
|
||||
Ok(Self { client })
|
||||
}
|
||||
}
|
||||
|
||||
fn namespace_or_default(ns: &str) -> &str {
|
||||
if ns.is_empty() {
|
||||
DEFAULT_PROJECT
|
||||
} else {
|
||||
ns
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookup cap for `get()` / `forget()` exact-title resolution.
|
||||
///
|
||||
/// agentmemory does not expose a `(project, title)` lookup endpoint, so
|
||||
/// `get()` fans out via smart-search and filters client-side for an exact
|
||||
/// title match. A small cap (e.g. 5) drops valid exact matches that rank
|
||||
/// lower in BM25+vector score. 100 is high enough that an exact title
|
||||
/// never falls off the page in practice while keeping the response
|
||||
/// payload bounded.
|
||||
const EXACT_LOOKUP_LIMIT: usize = 100;
|
||||
|
||||
#[async_trait]
|
||||
impl Memory for AgentMemoryBackend {
|
||||
fn name(&self) -> &str {
|
||||
"agentmemory"
|
||||
}
|
||||
|
||||
async fn store(
|
||||
&self,
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
content: &str,
|
||||
category: MemoryCategory,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
log::debug!(
|
||||
"[memory::agentmemory] store namespace={namespace:?} key={key:?} session_id={session_id:?} category={category:?}"
|
||||
);
|
||||
let body = RememberRequest::build(
|
||||
namespace_or_default(namespace),
|
||||
key,
|
||||
content,
|
||||
&category,
|
||||
session_id,
|
||||
);
|
||||
let _: RememberResponse = self.client.post_json("agentmemory/remember", &body).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recall(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
opts: RecallOpts<'_>,
|
||||
) -> Result<Vec<MemoryEntry>> {
|
||||
log::debug!(
|
||||
"[memory::agentmemory] recall query={query:?} limit={limit} namespace={:?} category={:?} session={:?} min_score={:?}",
|
||||
opts.namespace, opts.category, opts.session_id, opts.min_score,
|
||||
);
|
||||
let project = opts.namespace.map(|s| {
|
||||
if s.is_empty() {
|
||||
DEFAULT_PROJECT.to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
});
|
||||
let body = SmartSearchRequest {
|
||||
query: query.to_string(),
|
||||
limit,
|
||||
project,
|
||||
};
|
||||
let resp: SmartSearchResponse = self
|
||||
.client
|
||||
.post_json("agentmemory/smart-search", &body)
|
||||
.await?;
|
||||
|
||||
let mut entries: Vec<MemoryEntry> = resp
|
||||
.results
|
||||
.into_iter()
|
||||
.map(WireMemory::into_entry)
|
||||
.collect();
|
||||
let before = entries.len();
|
||||
|
||||
if let Some(cat) = opts.category.as_ref() {
|
||||
entries.retain(|e| &e.category == cat);
|
||||
}
|
||||
if let Some(session) = opts.session_id {
|
||||
entries.retain(|e| e.session_id.as_deref() == Some(session));
|
||||
}
|
||||
if let Some(min_score) = opts.min_score {
|
||||
// Scoreless rows (e.g. direct fetches that never went through
|
||||
// smart-search) cannot prove they meet the threshold — drop
|
||||
// them rather than letting them through silently.
|
||||
entries.retain(|e| e.score.is_some_and(|s| s >= min_score));
|
||||
}
|
||||
if entries.len() != before {
|
||||
log::trace!(
|
||||
"[memory::agentmemory] recall client-filter retained {}/{} hits",
|
||||
entries.len(),
|
||||
before,
|
||||
);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn get(&self, namespace: &str, key: &str) -> Result<Option<MemoryEntry>> {
|
||||
log::debug!("[memory::agentmemory] get namespace={namespace:?} key={key:?}");
|
||||
let project = namespace_or_default(namespace);
|
||||
let body = SmartSearchRequest {
|
||||
query: key.to_string(),
|
||||
limit: EXACT_LOOKUP_LIMIT,
|
||||
project: Some(project.to_string()),
|
||||
};
|
||||
let resp: SmartSearchResponse = self
|
||||
.client
|
||||
.post_json("agentmemory/smart-search", &body)
|
||||
.await?;
|
||||
let hit = resp
|
||||
.results
|
||||
.into_iter()
|
||||
.find(|r| r.title.as_deref() == Some(key))
|
||||
.map(WireMemory::into_entry);
|
||||
log::trace!(
|
||||
"[memory::agentmemory] get namespace={namespace:?} key={key:?} matched={}",
|
||||
hit.is_some()
|
||||
);
|
||||
Ok(hit)
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
namespace: Option<&str>,
|
||||
category: Option<&MemoryCategory>,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Vec<MemoryEntry>> {
|
||||
log::debug!(
|
||||
"[memory::agentmemory] list namespace={namespace:?} category={category:?} session_id={session_id:?}"
|
||||
);
|
||||
// When the caller passes Some(""), normalise to the "default"
|
||||
// project so the wire query stays consistent. When they pass
|
||||
// None, list across every project — matching the trait's
|
||||
// optional-namespace contract.
|
||||
let path = match namespace {
|
||||
Some(ns) => format!(
|
||||
"agentmemory/memories?latest=true&project={}",
|
||||
url_encode(namespace_or_default(ns))
|
||||
),
|
||||
None => "agentmemory/memories?latest=true".to_string(),
|
||||
};
|
||||
let resp: MemoriesResponse = self.client.get_json(&path).await?;
|
||||
let mut entries: Vec<MemoryEntry> = resp
|
||||
.memories
|
||||
.into_iter()
|
||||
.map(WireMemory::into_entry)
|
||||
.collect();
|
||||
let before = entries.len();
|
||||
if let Some(cat) = category {
|
||||
entries.retain(|e| &e.category == cat);
|
||||
}
|
||||
if let Some(session) = session_id {
|
||||
entries.retain(|e| e.session_id.as_deref() == Some(session));
|
||||
}
|
||||
if entries.len() != before {
|
||||
log::trace!(
|
||||
"[memory::agentmemory] list client-filter retained {}/{} rows",
|
||||
entries.len(),
|
||||
before,
|
||||
);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn forget(&self, namespace: &str, key: &str) -> Result<bool> {
|
||||
log::debug!("[memory::agentmemory] forget namespace={namespace:?} key={key:?}");
|
||||
// agentmemory's /forget takes an id, not (project, title). Look
|
||||
// the key up first via smart-search (mirrors `get` above), then
|
||||
// POST /forget against that id. If no exact title match exists,
|
||||
// return Ok(false) — same contract as the SQLite backend's
|
||||
// delete-by-(namespace, key).
|
||||
let Some(target) = self.get(namespace, key).await? else {
|
||||
log::trace!(
|
||||
"[memory::agentmemory] forget namespace={namespace:?} key={key:?} unresolved -> noop"
|
||||
);
|
||||
return Ok(false);
|
||||
};
|
||||
let body = ForgetRequest {
|
||||
id: target.id.clone(),
|
||||
};
|
||||
let resp: ForgetResponse = self.client.post_json("agentmemory/forget", &body).await?;
|
||||
log::debug!(
|
||||
"[memory::agentmemory] forget namespace={namespace:?} key={key:?} id={} forgotten={}",
|
||||
target.id,
|
||||
resp.forgotten,
|
||||
);
|
||||
Ok(resp.forgotten)
|
||||
}
|
||||
|
||||
async fn namespace_summaries(&self) -> Result<Vec<NamespaceSummary>> {
|
||||
log::debug!("[memory::agentmemory] namespace_summaries");
|
||||
let resp: ProjectsResponse = self.client.get_json("agentmemory/projects").await?;
|
||||
Ok(resp
|
||||
.projects
|
||||
.into_iter()
|
||||
.map(|p| NamespaceSummary {
|
||||
namespace: p.name,
|
||||
count: p.count,
|
||||
last_updated: p.last_updated,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count(&self) -> Result<usize> {
|
||||
log::debug!("[memory::agentmemory] count");
|
||||
let resp: HealthResponse = self.client.get_json("agentmemory/health").await?;
|
||||
Ok(resp.memories.unwrap_or(0))
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
let ok = self.client.livez().await;
|
||||
log::debug!("[memory::agentmemory] health_check ok={ok}");
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal `application/x-www-form-urlencoded` style encoder for query-string
|
||||
/// values. We only need to escape `/`, `?`, `#`, `&`, `=`, `+`, space, and
|
||||
/// non-ASCII bytes — anything else can pass through unencoded. This avoids
|
||||
/// pulling in `percent-encoding` for one call site.
|
||||
fn url_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(ch),
|
||||
_ => {
|
||||
let mut buf = [0u8; 4];
|
||||
for byte in ch.encode_utf8(&mut buf).as_bytes() {
|
||||
out.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Probe whether an agentmemory daemon is reachable at the configured URL.
|
||||
/// Used by the factory at startup so a `backend = "agentmemory"` config
|
||||
/// against a daemon that isn't running can fail loud at boot rather than
|
||||
/// silently swallow every store/recall call.
|
||||
pub async fn probe_agentmemory_reachable(config: &MemoryConfig) -> Result<()> {
|
||||
let client = AgentMemoryClient::new(
|
||||
config.agentmemory_url.as_deref(),
|
||||
config.agentmemory_secret.as_deref(),
|
||||
config.agentmemory_timeout_ms,
|
||||
)?;
|
||||
if !client.livez().await {
|
||||
anyhow::bail!(
|
||||
"agentmemory daemon is not reachable at {} \
|
||||
(set MemoryConfig.backend = \"sqlite\" to fall back to the local store; \
|
||||
see https://github.com/rohitg00/agentmemory for daemon setup)",
|
||||
client.base()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn url_encode_passes_through_safe_chars() {
|
||||
assert_eq!(url_encode("plain_text-123.~"), "plain_text-123.~");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_encode_percent_escapes_specials() {
|
||||
assert_eq!(url_encode("a b"), "a%20b");
|
||||
assert_eq!(url_encode("a/b"), "a%2Fb");
|
||||
assert_eq!(url_encode("a&b=c"), "a%26b%3Dc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_encode_handles_unicode() {
|
||||
// 中文 → utf-8 bytes E4 B8 AD E6 96 87
|
||||
assert_eq!(url_encode("中文"), "%E4%B8%AD%E6%96%87");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//! HTTP client wrapper for the agentmemory REST server.
|
||||
//!
|
||||
//! Wraps `reqwest::Client` with the agentmemory base URL, optional bearer
|
||||
//! token, a configurable per-request timeout, and a plaintext-bearer guard
|
||||
//! that refuses to send the secret over `http://<non-loopback>` per the
|
||||
//! v0.9.12 contract from upstream agentmemory PR #315 (see
|
||||
//! <https://github.com/rohitg00/agentmemory>).
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
/// Default agentmemory REST endpoint when no override is configured.
|
||||
pub const DEFAULT_AGENTMEMORY_URL: &str = "http://localhost:3111";
|
||||
|
||||
/// Default per-request timeout. Generous enough to absorb a cold-start on
|
||||
/// the iii engine + a vector recall round-trip on a 100k-memory store.
|
||||
pub const DEFAULT_TIMEOUT_MS: u64 = 5_000;
|
||||
|
||||
/// Thin HTTP wrapper around the agentmemory REST surface.
|
||||
pub struct AgentMemoryClient {
|
||||
http: reqwest::Client,
|
||||
base: Url,
|
||||
secret: Option<String>,
|
||||
}
|
||||
|
||||
impl AgentMemoryClient {
|
||||
/// Builds a client configured for the given URL + optional secret +
|
||||
/// timeout. The plaintext-bearer guard fires here, before any request
|
||||
/// goes on the wire — a misconfigured deploy fails loud at
|
||||
/// construction time rather than silently leaking the token.
|
||||
pub fn new(url: Option<&str>, secret: Option<&str>, timeout_ms: Option<u64>) -> Result<Self> {
|
||||
let raw = url.unwrap_or(DEFAULT_AGENTMEMORY_URL).trim();
|
||||
if raw.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"agentmemory_url cannot be empty — leave it unset to use {DEFAULT_AGENTMEMORY_URL}"
|
||||
));
|
||||
}
|
||||
let parsed = Url::parse(raw)
|
||||
.with_context(|| format!("agentmemory_url is not a valid URL: {raw}"))?;
|
||||
|
||||
if let Some(secret) = secret.filter(|s| !s.trim().is_empty()) {
|
||||
enforce_plaintext_bearer_guard(&parsed, secret)?;
|
||||
}
|
||||
|
||||
let timeout = Duration::from_millis(timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS));
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.context("failed to build reqwest client for agentmemory backend")?;
|
||||
|
||||
Ok(Self {
|
||||
http,
|
||||
base: parsed,
|
||||
secret: secret
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Base URL (mostly for log lines + error context).
|
||||
pub fn base(&self) -> &Url {
|
||||
&self.base
|
||||
}
|
||||
|
||||
/// `GET <base>/<path>`. Returns a 404 as `Ok(None)` for the get-by-key
|
||||
/// shape; everything else surfaces as an error.
|
||||
pub async fn get_optional<T: DeserializeOwned>(&self, path: &str) -> Result<Option<T>> {
|
||||
let url = self.url_for(path)?;
|
||||
log::trace!("[memory::agentmemory] GET {url}");
|
||||
let resp = self
|
||||
.http
|
||||
.request(Method::GET, url.clone())
|
||||
.headers(self.headers()?)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("GET {url}"))?;
|
||||
|
||||
match resp.status() {
|
||||
StatusCode::NOT_FOUND => Ok(None),
|
||||
s if s.is_success() => {
|
||||
Ok(Some(resp.json::<T>().await.with_context(|| {
|
||||
format!("failed to decode JSON response from GET {url}")
|
||||
})?))
|
||||
}
|
||||
s => Err(decode_error(&url, s, resp.text().await.ok())),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET <base>/<path>` expecting a 200 + JSON body.
|
||||
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
|
||||
self.get_optional(path)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("agentmemory returned 404 for GET {path}"))
|
||||
}
|
||||
|
||||
/// `POST <base>/<path>` with a JSON body, expecting a 2xx response.
|
||||
pub async fn post_json<B: Serialize, T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T> {
|
||||
let url = self.url_for(path)?;
|
||||
log::trace!("[memory::agentmemory] POST {url}");
|
||||
let resp = self
|
||||
.http
|
||||
.request(Method::POST, url.clone())
|
||||
.headers(self.headers()?)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("POST {url}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(decode_error(&url, status, resp.text().await.ok()));
|
||||
}
|
||||
resp.json::<T>()
|
||||
.await
|
||||
.with_context(|| format!("failed to decode JSON response from POST {url}"))
|
||||
}
|
||||
|
||||
/// `GET <base>/agentmemory/livez` — booleanises the health check.
|
||||
pub async fn livez(&self) -> bool {
|
||||
let Ok(url) = self.url_for("agentmemory/livez") else {
|
||||
return false;
|
||||
};
|
||||
let Ok(headers) = self.headers() else {
|
||||
return false;
|
||||
};
|
||||
match self
|
||||
.http
|
||||
.request(Method::GET, url)
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.status().is_success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn url_for(&self, path: &str) -> Result<Url> {
|
||||
let mut joined = self.base.clone();
|
||||
let trimmed = path.trim_start_matches('/');
|
||||
// Split off `?query` so it doesn't get appended as a literal path
|
||||
// segment — `path_segments_mut().extend(split('/'))` would
|
||||
// percent-encode the `?` and the server would 404.
|
||||
let (path_part, query_part) = match trimmed.split_once('?') {
|
||||
Some((p, q)) => (p, Some(q)),
|
||||
None => (trimmed, None),
|
||||
};
|
||||
joined
|
||||
.path_segments_mut()
|
||||
.map_err(|_| anyhow!("agentmemory base URL cannot be a base: {}", self.base))?
|
||||
.pop_if_empty()
|
||||
.extend(path_part.split('/'));
|
||||
if let Some(q) = query_part {
|
||||
joined.set_query(Some(q));
|
||||
}
|
||||
Ok(joined)
|
||||
}
|
||||
|
||||
fn headers(&self) -> Result<HeaderMap> {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
if let Some(secret) = &self.secret {
|
||||
let value = format!("Bearer {secret}");
|
||||
let header = HeaderValue::from_str(&value)
|
||||
.context("agentmemory_secret is not a valid HTTP header value")?;
|
||||
h.insert(AUTHORIZATION, header);
|
||||
}
|
||||
Ok(h)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors the v0.9.12 plaintext-bearer guard from agentmemory's first-party
|
||||
/// integration plugins: a bearer token must never cross plaintext HTTP to a
|
||||
/// non-loopback host. Loopback (`localhost`, `127.0.0.1`, `::1`) is exempt;
|
||||
/// `https://` is exempt. `AGENTMEMORY_REQUIRE_HTTPS=1` escalates the warning
|
||||
/// path to a hard refusal even on loopback so a misconfigured production
|
||||
/// deploy can fail loud rather than leak the secret once.
|
||||
fn enforce_plaintext_bearer_guard(url: &Url, _secret: &str) -> Result<()> {
|
||||
if url.scheme().eq_ignore_ascii_case("https") {
|
||||
return Ok(());
|
||||
}
|
||||
let host = url.host_str().unwrap_or("");
|
||||
let loopback = matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]");
|
||||
let require_https = std::env::var("AGENTMEMORY_REQUIRE_HTTPS")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if require_https && url.scheme() != "https" {
|
||||
return Err(anyhow!(
|
||||
"agentmemory_secret is set and AGENTMEMORY_REQUIRE_HTTPS=1 \
|
||||
refuses to send the bearer over scheme `{}` (host {host}). \
|
||||
Switch agentmemory_url to https:// or unset AGENTMEMORY_REQUIRE_HTTPS.",
|
||||
url.scheme(),
|
||||
));
|
||||
}
|
||||
|
||||
if !loopback {
|
||||
log::warn!(
|
||||
"[memory::agentmemory] agentmemory_secret is set and agentmemory_url ({url}) \
|
||||
is plaintext HTTP to a non-loopback host ({host}). The bearer will be \
|
||||
observable on the wire. Set AGENTMEMORY_REQUIRE_HTTPS=1 to make this a \
|
||||
hard error, or switch to https://."
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_error(url: &Url, status: StatusCode, body: Option<String>) -> anyhow::Error {
|
||||
let body = body.unwrap_or_default();
|
||||
let snippet = if body.len() > 512 {
|
||||
// Snap to the previous char boundary so we never slice through
|
||||
// the middle of a multi-byte UTF-8 scalar — an emoji or accented
|
||||
// character at byte 512 would otherwise panic the error-decode
|
||||
// path with `byte index 512 is not a char boundary`, defeating
|
||||
// the whole point of this helper.
|
||||
let mut end = 512;
|
||||
while end > 0 && !body.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}…", &body[..end])
|
||||
} else {
|
||||
body
|
||||
};
|
||||
anyhow!(
|
||||
"agentmemory returned {status} for {url}: {snippet}",
|
||||
snippet = snippet.trim()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Tests in this module mutate the process-global
|
||||
/// `AGENTMEMORY_REQUIRE_HTTPS` env var, which is not thread-safe under
|
||||
/// cargo's default parallel test runner. Serialise them through a
|
||||
/// shared mutex so a stray `set_var` from one test can't race with a
|
||||
/// `remove_var` in another (and so the cleanup path runs even when a
|
||||
/// test panics mid-way through, via the mutex guard's `Drop`).
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
struct EnvGuard {
|
||||
prev: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn set(value: &str) -> Self {
|
||||
let prev = std::env::var_os("AGENTMEMORY_REQUIRE_HTTPS");
|
||||
// SAFETY: env mutation is wrapped because Rust 2024 marks it
|
||||
// unsafe; the call is gated by the env_lock() critical section
|
||||
// so no other test in this module is observing the env
|
||||
// concurrently.
|
||||
unsafe { std::env::set_var("AGENTMEMORY_REQUIRE_HTTPS", value) };
|
||||
Self { prev }
|
||||
}
|
||||
|
||||
fn remove() -> Self {
|
||||
let prev = std::env::var_os("AGENTMEMORY_REQUIRE_HTTPS");
|
||||
unsafe { std::env::remove_var("AGENTMEMORY_REQUIRE_HTTPS") };
|
||||
Self { prev }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: still under the same env_lock() critical section.
|
||||
unsafe {
|
||||
match self.prev.take() {
|
||||
Some(v) => std::env::set_var("AGENTMEMORY_REQUIRE_HTTPS", v),
|
||||
None => std::env::remove_var("AGENTMEMORY_REQUIRE_HTTPS"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_plaintext_is_allowed_without_require_https() {
|
||||
let _lock = env_lock();
|
||||
let _guard = EnvGuard::remove();
|
||||
let url = Url::parse("http://localhost:3111").unwrap();
|
||||
assert!(enforce_plaintext_bearer_guard(&url, "secret").is_ok());
|
||||
|
||||
let url = Url::parse("http://127.0.0.1:3111").unwrap();
|
||||
assert!(enforce_plaintext_bearer_guard(&url, "secret").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_is_always_allowed_even_with_require_https() {
|
||||
let _lock = env_lock();
|
||||
let _guard = EnvGuard::set("1");
|
||||
let url = Url::parse("https://memory.example.com").unwrap();
|
||||
assert!(enforce_plaintext_bearer_guard(&url, "secret").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_non_loopback_with_require_https_is_refused() {
|
||||
let _lock = env_lock();
|
||||
let _guard = EnvGuard::set("1");
|
||||
let url = Url::parse("http://memory.example.com:3111").unwrap();
|
||||
let err = enforce_plaintext_bearer_guard(&url, "secret").unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("refuses"),
|
||||
"expected refusal, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_error_does_not_panic_on_long_unicode_body() {
|
||||
// Build a body whose byte length crosses the 512 boundary mid
|
||||
// multi-byte scalar — pre-fix this would panic with
|
||||
// `byte index 512 is not a char boundary`.
|
||||
let unicode = "ü".repeat(400); // each "ü" is 2 bytes → 800 bytes total
|
||||
let url = Url::parse("http://127.0.0.1/x").unwrap();
|
||||
let err = decode_error(&url, StatusCode::BAD_REQUEST, Some(unicode));
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("400"), "expected status in message: {msg}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Maps OpenHuman `MemoryEntry` / `MemoryCategory` to the agentmemory REST
|
||||
//! wire shapes and back.
|
||||
//!
|
||||
//! Wire contract: <https://github.com/rohitg00/agentmemory> — see the
|
||||
//! upstream README for the full endpoint list and field semantics.
|
||||
//!
|
||||
//! agentmemory has a richer wire shape (concepts, files, strength, version,
|
||||
//! supersedes) that the backend leaves at defaults — those fields are
|
||||
//! internal to agentmemory's lifecycle layer and don't need to round-trip
|
||||
//! through OpenHuman's trait. We map the OpenHuman-visible columns and let
|
||||
//! agentmemory own the rest.
|
||||
|
||||
use crate::openhuman::memory::traits::{MemoryCategory, MemoryEntry};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Globally well-known "default" project name used when an OpenHuman caller
|
||||
/// doesn't pass a namespace. Matches the trait's `GLOBAL_NAMESPACE` semantics.
|
||||
pub const DEFAULT_PROJECT: &str = "default";
|
||||
|
||||
/// agentmemory's per-memory `type` field. `MemoryCategory::Core` maps to
|
||||
/// "fact", everything `MemoryCategory::Daily` / `Conversation` maps to
|
||||
/// "conversation", and `Custom(s)` maps to "fact" with `s` rolled into the
|
||||
/// `concepts` array so it remains queryable.
|
||||
fn category_to_type(category: &MemoryCategory) -> &'static str {
|
||||
match category {
|
||||
MemoryCategory::Core | MemoryCategory::Custom(_) => "fact",
|
||||
MemoryCategory::Daily | MemoryCategory::Conversation => "conversation",
|
||||
}
|
||||
}
|
||||
|
||||
fn type_to_category(t: Option<&str>, concepts: &[String]) -> MemoryCategory {
|
||||
match t {
|
||||
Some("conversation") => MemoryCategory::Conversation,
|
||||
Some("fact") | None => {
|
||||
if let Some(first) = concepts.first() {
|
||||
MemoryCategory::Custom(first.clone())
|
||||
} else {
|
||||
MemoryCategory::Core
|
||||
}
|
||||
}
|
||||
Some(other) => MemoryCategory::Custom(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Outgoing payload for `POST /agentmemory/remember`.
|
||||
///
|
||||
/// Owned fields rather than borrowed slices so the value remains
|
||||
/// `Send + 'static`-friendly when handed to an async runtime / event bus.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RememberRequest {
|
||||
pub project: String,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub concepts: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "sessionIds")]
|
||||
pub session_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl RememberRequest {
|
||||
pub fn build(
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
content: &str,
|
||||
category: &MemoryCategory,
|
||||
session_id: Option<&str>,
|
||||
) -> Self {
|
||||
let concepts = match category {
|
||||
MemoryCategory::Custom(s) => vec![s.clone()],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let project = if namespace.is_empty() {
|
||||
DEFAULT_PROJECT.to_string()
|
||||
} else {
|
||||
namespace.to_string()
|
||||
};
|
||||
Self {
|
||||
project,
|
||||
title: key.to_string(),
|
||||
content: content.to_string(),
|
||||
kind: category_to_type(category).to_string(),
|
||||
concepts,
|
||||
session_ids: session_id.map(|s| vec![s.to_string()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outgoing payload for `POST /agentmemory/smart-search`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SmartSearchRequest {
|
||||
pub query: String,
|
||||
pub limit: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub project: Option<String>,
|
||||
}
|
||||
|
||||
/// Outgoing payload for `POST /agentmemory/forget`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ForgetRequest {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// Generic agentmemory memory row. agentmemory carries more fields than this
|
||||
/// — we only deserialise what OpenHuman's `MemoryEntry` needs, leaving the
|
||||
/// rest in a flatten-bag if a future caller wants them.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct WireMemory {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub project: Option<String>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub concepts: Vec<String>,
|
||||
#[serde(default, rename = "sessionIds")]
|
||||
pub session_ids: Vec<String>,
|
||||
#[serde(default, rename = "updatedAt")]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(default, rename = "createdAt")]
|
||||
pub created_at: Option<String>,
|
||||
/// Present on smart-search hits, absent on direct fetches.
|
||||
#[serde(default)]
|
||||
pub score: Option<f64>,
|
||||
}
|
||||
|
||||
impl WireMemory {
|
||||
/// Project the wire row into an OpenHuman `MemoryEntry`. `key` falls
|
||||
/// back to the agentmemory `id` when no title is present — for raw
|
||||
/// observation rows that never went through `remember`.
|
||||
pub fn into_entry(self) -> MemoryEntry {
|
||||
let timestamp = self
|
||||
.updated_at
|
||||
.or(self.created_at)
|
||||
.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
|
||||
let category = type_to_category(self.kind.as_deref(), &self.concepts);
|
||||
let key = self.title.unwrap_or_else(|| self.id.clone());
|
||||
let session_id = self.session_ids.into_iter().next();
|
||||
MemoryEntry {
|
||||
id: self.id,
|
||||
key,
|
||||
content: self.content.unwrap_or_default(),
|
||||
namespace: self.project,
|
||||
category,
|
||||
timestamp,
|
||||
session_id,
|
||||
score: self.score,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /agentmemory/smart-search` response envelope. The `mode` field is
|
||||
/// either `"full"` or `"compact"` depending on the requested `format`; both
|
||||
/// modes share the same `results` array shape.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SmartSearchResponse {
|
||||
#[serde(default)]
|
||||
pub results: Vec<WireMemory>,
|
||||
}
|
||||
|
||||
/// `GET /agentmemory/memories` response envelope.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MemoriesResponse {
|
||||
#[serde(default)]
|
||||
pub memories: Vec<WireMemory>,
|
||||
}
|
||||
|
||||
/// `GET /agentmemory/health` response envelope. agentmemory returns a much
|
||||
/// richer payload; we only need the `memories` count.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct HealthResponse {
|
||||
#[serde(default)]
|
||||
pub memories: Option<usize>,
|
||||
}
|
||||
|
||||
/// `GET /agentmemory/projects` response envelope.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ProjectsResponse {
|
||||
#[serde(default)]
|
||||
pub projects: Vec<ProjectSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ProjectSummary {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub count: usize,
|
||||
#[serde(default, rename = "lastUpdated")]
|
||||
pub last_updated: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST /agentmemory/remember` returns the saved memory's id.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RememberResponse {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// `POST /agentmemory/forget` returns whether anything was removed.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ForgetResponse {
|
||||
#[serde(default)]
|
||||
pub forgotten: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remember_request_maps_core_to_fact() {
|
||||
let req = RememberRequest::build(
|
||||
"demo",
|
||||
"auth-stack",
|
||||
"uses HMAC bearer tokens",
|
||||
&MemoryCategory::Core,
|
||||
None,
|
||||
);
|
||||
assert_eq!(req.kind, "fact");
|
||||
assert!(req.concepts.is_empty());
|
||||
assert_eq!(req.project, "demo");
|
||||
assert_eq!(req.title, "auth-stack");
|
||||
assert!(req.session_ids.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_request_maps_custom_into_concepts() {
|
||||
let req = RememberRequest::build(
|
||||
"demo",
|
||||
"k",
|
||||
"v",
|
||||
&MemoryCategory::Custom("ops".into()),
|
||||
Some("ses-1"),
|
||||
);
|
||||
assert_eq!(req.kind, "fact");
|
||||
assert_eq!(req.concepts, vec!["ops".to_string()]);
|
||||
assert_eq!(req.session_ids.as_deref(), Some(&["ses-1".to_string()][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_request_falls_back_to_default_project_on_empty_namespace() {
|
||||
let req = RememberRequest::build("", "k", "v", &MemoryCategory::Core, None);
|
||||
assert_eq!(req.project, DEFAULT_PROJECT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_memory_into_entry_preserves_score_on_search_hits() {
|
||||
let wire = WireMemory {
|
||||
id: "mem_1".into(),
|
||||
project: Some("demo".into()),
|
||||
title: Some("auth-stack".into()),
|
||||
content: Some("uses HMAC".into()),
|
||||
kind: Some("fact".into()),
|
||||
concepts: vec!["auth".into()],
|
||||
session_ids: vec!["ses-1".into()],
|
||||
updated_at: Some("2026-05-14T00:00:00Z".into()),
|
||||
created_at: None,
|
||||
score: Some(0.87),
|
||||
};
|
||||
let entry = wire.into_entry();
|
||||
assert_eq!(entry.id, "mem_1");
|
||||
assert_eq!(entry.key, "auth-stack");
|
||||
assert_eq!(entry.namespace.as_deref(), Some("demo"));
|
||||
assert_eq!(entry.session_id.as_deref(), Some("ses-1"));
|
||||
assert_eq!(entry.score, Some(0.87));
|
||||
assert_eq!(entry.category, MemoryCategory::Custom("auth".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_memory_into_entry_falls_back_to_id_when_title_missing() {
|
||||
let wire = WireMemory {
|
||||
id: "mem_2".into(),
|
||||
project: None,
|
||||
title: None,
|
||||
content: None,
|
||||
kind: Some("conversation".into()),
|
||||
concepts: vec![],
|
||||
session_ids: vec![],
|
||||
updated_at: None,
|
||||
created_at: Some("2026-05-14T00:00:00Z".into()),
|
||||
score: None,
|
||||
};
|
||||
let entry = wire.into_entry();
|
||||
assert_eq!(entry.key, "mem_2");
|
||||
assert_eq!(entry.category, MemoryCategory::Conversation);
|
||||
assert_eq!(entry.timestamp, "2026-05-14T00:00:00Z");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! # AgentMemory Backend
|
||||
//!
|
||||
//! Thin REST adapter that implements OpenHuman's `Memory` trait against a
|
||||
//! locally-running [agentmemory](https://github.com/rohitg00/agentmemory)
|
||||
//! server. Selected via `MemoryConfig.backend = "agentmemory"`; the default
|
||||
//! backend stays `sqlite` and the rest of the codebase is unaffected.
|
||||
//!
|
||||
//! Embedding model selection is owned by agentmemory's own config
|
||||
//! (`~/.agentmemory/.env`) — OpenHuman's `embedding_provider` /
|
||||
//! `embedding_model` / `embedding_dimensions` fields are ignored when this
|
||||
//! backend is selected, because the agentmemory daemon does its own hybrid
|
||||
//! BM25 + vector + graph retrieval and would otherwise re-embed every
|
||||
//! incoming payload against a mismatched dim.
|
||||
//!
|
||||
//! See `agentmemory/README.md` for setup + the env-var contract.
|
||||
|
||||
mod backend;
|
||||
mod client;
|
||||
mod mapping;
|
||||
|
||||
pub use backend::AgentMemoryBackend;
|
||||
pub use client::DEFAULT_AGENTMEMORY_URL;
|
||||
|
||||
/// Returns the documented default base URL for the agentmemory daemon
|
||||
/// (`http://localhost:3111`). Exposed for log lines / errors so callers
|
||||
/// don't have to import the constant by name.
|
||||
pub fn agentmemory_default_url() -> &'static str {
|
||||
DEFAULT_AGENTMEMORY_URL
|
||||
}
|
||||
@@ -19,9 +19,24 @@ use crate::openhuman::embeddings::{
|
||||
self, EmbeddingProvider, DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, DEFAULT_CLOUD_EMBEDDING_MODEL,
|
||||
DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL,
|
||||
};
|
||||
use crate::openhuman::memory::store::agentmemory::AgentMemoryBackend;
|
||||
use crate::openhuman::memory::store::unified::UnifiedMemory;
|
||||
use crate::openhuman::memory::traits::Memory;
|
||||
|
||||
/// Stable wire string for the agentmemory backend selector.
|
||||
///
|
||||
/// When `MemoryConfig.backend` matches this (ASCII case-insensitive), the
|
||||
/// memory factory short-circuits the SQLite + embedder path and returns an
|
||||
/// [`AgentMemoryBackend`] that proxies trait calls through agentmemory's
|
||||
/// REST surface. OpenHuman's `embedding_provider` / `embedding_model` /
|
||||
/// `embedding_dimensions` are ignored on this path — agentmemory owns its
|
||||
/// embedding stack via `~/.agentmemory/.env`.
|
||||
pub const AGENTMEMORY_BACKEND: &str = "agentmemory";
|
||||
|
||||
fn is_agentmemory_backend(name: &str) -> bool {
|
||||
name.eq_ignore_ascii_case(AGENTMEMORY_BACKEND)
|
||||
}
|
||||
|
||||
/// One-shot guard so the Ollama health-gate fallback only reports to Sentry
|
||||
/// once per process lifetime. Memory is constructed many times per session
|
||||
/// (once per agent in the harness), so an unguarded `report_error` would
|
||||
@@ -349,6 +364,25 @@ fn create_memory_full(
|
||||
local_ai: Option<&LocalAiConfig>,
|
||||
workspace_dir: &Path,
|
||||
) -> anyhow::Result<Box<dyn Memory>> {
|
||||
// 0. Short-circuit the unified path when the user has explicitly
|
||||
// selected the agentmemory backend. agentmemory owns its own
|
||||
// embedding stack, persistence, and graph layer — wiring a local
|
||||
// embedder + SQLite store on top of it would duplicate the
|
||||
// embedding pipeline and create a divergence between OpenHuman's
|
||||
// cached vectors and agentmemory's. Fail loud at boot if the daemon
|
||||
// isn't reachable (per the issue #1664 fallback decision).
|
||||
if is_agentmemory_backend(&config.backend) {
|
||||
log::info!(
|
||||
"[memory::factory] using agentmemory backend at {}",
|
||||
config
|
||||
.agentmemory_url
|
||||
.as_deref()
|
||||
.unwrap_or(crate::openhuman::memory::store::agentmemory_default_url()),
|
||||
);
|
||||
let backend = AgentMemoryBackend::from_config(config)?;
|
||||
return Ok(Box::new(backend));
|
||||
}
|
||||
|
||||
// 1. Resolve the intended provider from config.
|
||||
let intended = effective_embedding_settings(config, local_ai);
|
||||
let local_ai_opt_in = local_ai
|
||||
|
||||
@@ -19,10 +19,13 @@
|
||||
pub mod types;
|
||||
mod unified;
|
||||
|
||||
mod agentmemory;
|
||||
mod client;
|
||||
mod factories;
|
||||
mod memory_trait;
|
||||
|
||||
pub use agentmemory::{agentmemory_default_url, AgentMemoryBackend, DEFAULT_AGENTMEMORY_URL};
|
||||
|
||||
pub use client::{MemoryClient, MemoryClientRef, MemoryState};
|
||||
pub use factories::{
|
||||
create_memory, create_memory_for_migration, create_memory_with_local_ai,
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
//! Integration tests for the agentmemory `Memory` backend.
|
||||
//!
|
||||
//! Spins up a small axum mock server that mimics the agentmemory REST
|
||||
//! contract (matches the v0.9.12 wire shapes) and exercises every trait
|
||||
//! method end-to-end. Tests are kept in `tests/` so they share the public
|
||||
//! crate surface — they do not reach into private modules.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use openhuman_core::openhuman::config::MemoryConfig;
|
||||
use openhuman_core::openhuman::memory::store::AgentMemoryBackend;
|
||||
use openhuman_core::openhuman::memory::traits::{Memory, MemoryCategory, RecallOpts};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct MockState {
|
||||
memories: Arc<Mutex<Vec<MockMemory>>>,
|
||||
next_id: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct MockMemory {
|
||||
id: String,
|
||||
project: Option<String>,
|
||||
title: Option<String>,
|
||||
content: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
#[serde(default)]
|
||||
concepts: Vec<String>,
|
||||
#[serde(default, rename = "sessionIds")]
|
||||
session_ids: Vec<String>,
|
||||
#[serde(rename = "updatedAt")]
|
||||
updated_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
score: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RememberRequest {
|
||||
project: String,
|
||||
title: String,
|
||||
content: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
concepts: Vec<String>,
|
||||
#[serde(default, rename = "sessionIds")]
|
||||
session_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SmartSearchRequest {
|
||||
query: String,
|
||||
limit: usize,
|
||||
#[serde(default)]
|
||||
project: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ForgetRequest {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MemoriesQuery {
|
||||
#[serde(default)]
|
||||
project: Option<String>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
latest: Option<String>,
|
||||
}
|
||||
|
||||
async fn start_mock_server() -> (SocketAddr, MockState) {
|
||||
let state = MockState::default();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/agentmemory/livez",
|
||||
get(|| async { Json(serde_json::json!({"service":"agentmemory","status":"ok"})) }),
|
||||
)
|
||||
.route(
|
||||
"/agentmemory/health",
|
||||
get(handle_health).with_state(state.clone()),
|
||||
)
|
||||
.route(
|
||||
"/agentmemory/remember",
|
||||
post(handle_remember).with_state(state.clone()),
|
||||
)
|
||||
.route(
|
||||
"/agentmemory/smart-search",
|
||||
post(handle_smart_search).with_state(state.clone()),
|
||||
)
|
||||
.route(
|
||||
"/agentmemory/forget",
|
||||
post(handle_forget).with_state(state.clone()),
|
||||
)
|
||||
.route(
|
||||
"/agentmemory/memories",
|
||||
get(handle_memories).with_state(state.clone()),
|
||||
)
|
||||
.route(
|
||||
"/agentmemory/projects",
|
||||
get(handle_projects).with_state(state.clone()),
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
(addr, state)
|
||||
}
|
||||
|
||||
async fn handle_health(State(state): State<MockState>) -> Json<serde_json::Value> {
|
||||
let n = state.memories.lock().unwrap().len();
|
||||
Json(serde_json::json!({"memories": n, "status": "ok"}))
|
||||
}
|
||||
|
||||
async fn handle_remember(
|
||||
State(state): State<MockState>,
|
||||
Json(req): Json<RememberRequest>,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
let mut next_id = state.next_id.lock().unwrap();
|
||||
*next_id += 1;
|
||||
let id = format!("mem_{}", *next_id);
|
||||
state.memories.lock().unwrap().push(MockMemory {
|
||||
id: id.clone(),
|
||||
project: Some(req.project),
|
||||
title: Some(req.title),
|
||||
content: Some(req.content),
|
||||
kind: Some(req.kind),
|
||||
concepts: req.concepts,
|
||||
session_ids: req.session_ids,
|
||||
updated_at: Some("2026-05-14T00:00:00Z".to_string()),
|
||||
score: None,
|
||||
});
|
||||
(StatusCode::CREATED, Json(serde_json::json!({ "id": id })))
|
||||
}
|
||||
|
||||
async fn handle_smart_search(
|
||||
State(state): State<MockState>,
|
||||
Json(req): Json<SmartSearchRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let memories = state.memories.lock().unwrap();
|
||||
let q = req.query.to_lowercase();
|
||||
let project = req.project.as_deref();
|
||||
let hits: Vec<MockMemory> = memories
|
||||
.iter()
|
||||
.filter(|m| project.is_none_or(|p| m.project.as_deref() == Some(p)))
|
||||
.filter(|m| {
|
||||
m.title
|
||||
.as_deref()
|
||||
.map(|t| t.to_lowercase().contains(&q))
|
||||
.unwrap_or(false)
|
||||
|| m.content
|
||||
.as_deref()
|
||||
.map(|c| c.to_lowercase().contains(&q))
|
||||
.unwrap_or(false)
|
||||
|| m.concepts.iter().any(|c| c.to_lowercase().contains(&q))
|
||||
})
|
||||
.take(req.limit)
|
||||
.cloned()
|
||||
.map(|mut m| {
|
||||
m.score = Some(0.9);
|
||||
m
|
||||
})
|
||||
.collect();
|
||||
Json(serde_json::json!({ "mode": "compact", "results": hits }))
|
||||
}
|
||||
|
||||
async fn handle_forget(
|
||||
State(state): State<MockState>,
|
||||
Json(req): Json<ForgetRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let mut memories = state.memories.lock().unwrap();
|
||||
let before = memories.len();
|
||||
memories.retain(|m| m.id != req.id);
|
||||
let forgotten = memories.len() < before;
|
||||
Json(serde_json::json!({ "forgotten": forgotten }))
|
||||
}
|
||||
|
||||
async fn handle_memories(
|
||||
State(state): State<MockState>,
|
||||
Query(q): Query<MemoriesQuery>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let memories = state.memories.lock().unwrap();
|
||||
let filtered: Vec<MockMemory> = memories
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
q.project
|
||||
.as_deref()
|
||||
.is_none_or(|p| m.project.as_deref() == Some(p))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
Json(serde_json::json!({ "memories": filtered }))
|
||||
}
|
||||
|
||||
async fn handle_projects(State(state): State<MockState>) -> Json<serde_json::Value> {
|
||||
use std::collections::BTreeMap;
|
||||
let memories = state.memories.lock().unwrap();
|
||||
let mut by_project: BTreeMap<String, (usize, Option<String>)> = BTreeMap::new();
|
||||
for m in memories.iter() {
|
||||
let ns = m.project.clone().unwrap_or_else(|| "default".to_string());
|
||||
let entry = by_project.entry(ns).or_insert((0, None));
|
||||
entry.0 += 1;
|
||||
if entry.1.is_none() {
|
||||
entry.1 = m.updated_at.clone();
|
||||
}
|
||||
}
|
||||
let projects: Vec<serde_json::Value> = by_project
|
||||
.into_iter()
|
||||
.map(|(name, (count, last_updated))| {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"count": count,
|
||||
"lastUpdated": last_updated,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Json(serde_json::json!({ "projects": projects }))
|
||||
}
|
||||
|
||||
fn make_config(addr: SocketAddr) -> MemoryConfig {
|
||||
MemoryConfig {
|
||||
backend: "agentmemory".to_string(),
|
||||
agentmemory_url: Some(format!("http://{addr}")),
|
||||
agentmemory_timeout_ms: Some(2_000),
|
||||
..MemoryConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_passes_against_running_daemon() {
|
||||
let (addr, _state) = start_mock_server().await;
|
||||
let backend = AgentMemoryBackend::from_config(&make_config(addr)).unwrap();
|
||||
assert!(backend.health_check().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_fails_when_daemon_is_unreachable() {
|
||||
let cfg = MemoryConfig {
|
||||
backend: "agentmemory".to_string(),
|
||||
agentmemory_url: Some("http://127.0.0.1:1".to_string()),
|
||||
agentmemory_timeout_ms: Some(500),
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let backend = AgentMemoryBackend::from_config(&cfg).unwrap();
|
||||
assert!(!backend.health_check().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_then_get_round_trip() {
|
||||
let (addr, _state) = start_mock_server().await;
|
||||
let backend = AgentMemoryBackend::from_config(&make_config(addr)).unwrap();
|
||||
|
||||
backend
|
||||
.store(
|
||||
"demo",
|
||||
"auth-stack",
|
||||
"Service uses HMAC bearer tokens; refresh every 24h.",
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entry = backend
|
||||
.get("demo", "auth-stack")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("expected to recall the stored memory");
|
||||
assert_eq!(entry.key, "auth-stack");
|
||||
assert_eq!(entry.namespace.as_deref(), Some("demo"));
|
||||
assert!(entry.content.contains("HMAC"));
|
||||
assert_eq!(entry.category, MemoryCategory::Core);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_then_recall_finds_matching_memory() {
|
||||
let (addr, _state) = start_mock_server().await;
|
||||
let backend = AgentMemoryBackend::from_config(&make_config(addr)).unwrap();
|
||||
|
||||
backend
|
||||
.store(
|
||||
"demo",
|
||||
"k1",
|
||||
"hmac bearer auth refresh",
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
backend
|
||||
.store(
|
||||
"demo",
|
||||
"k2",
|
||||
"stripe webhook handling",
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let opts = RecallOpts {
|
||||
namespace: Some("demo"),
|
||||
..RecallOpts::default()
|
||||
};
|
||||
let hits = backend.recall("hmac", 10, opts).await.unwrap();
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].key, "k1");
|
||||
assert!(hits[0].score.unwrap() > 0.5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recall_filters_by_session_id_client_side() {
|
||||
let (addr, _state) = start_mock_server().await;
|
||||
let backend = AgentMemoryBackend::from_config(&make_config(addr)).unwrap();
|
||||
|
||||
backend
|
||||
.store(
|
||||
"demo",
|
||||
"k1",
|
||||
"hmac one",
|
||||
MemoryCategory::Core,
|
||||
Some("ses-1"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
backend
|
||||
.store(
|
||||
"demo",
|
||||
"k2",
|
||||
"hmac two",
|
||||
MemoryCategory::Core,
|
||||
Some("ses-2"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let opts = RecallOpts {
|
||||
namespace: Some("demo"),
|
||||
session_id: Some("ses-1"),
|
||||
..RecallOpts::default()
|
||||
};
|
||||
let hits = backend.recall("hmac", 10, opts).await.unwrap();
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].session_id.as_deref(), Some("ses-1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recall_min_score_drops_scoreless_and_below_threshold_hits() {
|
||||
let (addr, _state) = start_mock_server().await;
|
||||
let backend = AgentMemoryBackend::from_config(&make_config(addr)).unwrap();
|
||||
|
||||
backend
|
||||
.store("demo", "k", "hmac auth refresh", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Mock always returns score=0.9; a threshold above that should
|
||||
// drop the hit. Scoreless rows are not relevant on this path
|
||||
// (smart-search hits always carry a score in the mock).
|
||||
let opts = RecallOpts {
|
||||
namespace: Some("demo"),
|
||||
min_score: Some(0.95),
|
||||
..RecallOpts::default()
|
||||
};
|
||||
let hits = backend.recall("hmac", 10, opts).await.unwrap();
|
||||
assert!(
|
||||
hits.is_empty(),
|
||||
"min_score = 0.95 should drop the 0.9 hit, got {hits:?}"
|
||||
);
|
||||
|
||||
let opts_loose = RecallOpts {
|
||||
namespace: Some("demo"),
|
||||
min_score: Some(0.5),
|
||||
..RecallOpts::default()
|
||||
};
|
||||
let hits_loose = backend.recall("hmac", 10, opts_loose).await.unwrap();
|
||||
assert_eq!(hits_loose.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_with_no_namespace_returns_every_project() {
|
||||
let (addr, _state) = start_mock_server().await;
|
||||
let backend = AgentMemoryBackend::from_config(&make_config(addr)).unwrap();
|
||||
|
||||
backend
|
||||
.store("alpha", "a1", "x", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
backend
|
||||
.store("beta", "b1", "y", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let all = backend.list(None, None, None).await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
let mut ns: Vec<_> = all
|
||||
.iter()
|
||||
.map(|e| e.namespace.clone().unwrap_or_default())
|
||||
.collect();
|
||||
ns.sort();
|
||||
assert_eq!(ns, vec!["alpha", "beta"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_filters_by_namespace_and_category() {
|
||||
let (addr, _state) = start_mock_server().await;
|
||||
let backend = AgentMemoryBackend::from_config(&make_config(addr)).unwrap();
|
||||
|
||||
backend
|
||||
.store("alpha", "a1", "fact", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
backend
|
||||
.store("alpha", "a2", "convo", MemoryCategory::Conversation, None)
|
||||
.await
|
||||
.unwrap();
|
||||
backend
|
||||
.store("beta", "b1", "fact", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let all_alpha = backend.list(Some("alpha"), None, None).await.unwrap();
|
||||
assert_eq!(all_alpha.len(), 2);
|
||||
|
||||
let only_facts = backend
|
||||
.list(Some("alpha"), Some(&MemoryCategory::Core), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(only_facts.len(), 1);
|
||||
assert_eq!(only_facts[0].key, "a1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forget_removes_existing_memory_and_reports_missing() {
|
||||
let (addr, _state) = start_mock_server().await;
|
||||
let backend = AgentMemoryBackend::from_config(&make_config(addr)).unwrap();
|
||||
|
||||
backend
|
||||
.store("demo", "doomed", "delete me", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(backend.forget("demo", "doomed").await.unwrap());
|
||||
// Second time around the key is gone.
|
||||
assert!(!backend.forget("demo", "doomed").await.unwrap());
|
||||
// Unknown key reports missing without an error.
|
||||
assert!(!backend.forget("demo", "never-existed").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_summaries_aggregate_per_project() {
|
||||
let (addr, _state) = start_mock_server().await;
|
||||
let backend = AgentMemoryBackend::from_config(&make_config(addr)).unwrap();
|
||||
|
||||
backend
|
||||
.store("alpha", "a1", "x", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
backend
|
||||
.store("alpha", "a2", "y", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
backend
|
||||
.store("beta", "b1", "z", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut summaries = backend.namespace_summaries().await.unwrap();
|
||||
summaries.sort_by(|a, b| a.namespace.cmp(&b.namespace));
|
||||
assert_eq!(summaries.len(), 2);
|
||||
assert_eq!(summaries[0].namespace, "alpha");
|
||||
assert_eq!(summaries[0].count, 2);
|
||||
assert_eq!(summaries[1].namespace, "beta");
|
||||
assert_eq!(summaries[1].count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn count_reads_total_from_health_endpoint() {
|
||||
let (addr, _state) = start_mock_server().await;
|
||||
let backend = AgentMemoryBackend::from_config(&make_config(addr)).unwrap();
|
||||
|
||||
backend
|
||||
.store("demo", "k1", "x", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
backend
|
||||
.store("demo", "k2", "y", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(backend.count().await.unwrap(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn name_returns_agentmemory_string() {
|
||||
let (addr, _state) = start_mock_server().await;
|
||||
let backend = AgentMemoryBackend::from_config(&make_config(addr)).unwrap();
|
||||
assert_eq!(backend.name(), "agentmemory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_config_rejects_empty_url() {
|
||||
let cfg = MemoryConfig {
|
||||
backend: "agentmemory".to_string(),
|
||||
agentmemory_url: Some(" ".to_string()),
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
// `AgentMemoryBackend` does not derive `Debug` (its inner `reqwest::Client`
|
||||
// is opaque), so use a `match` instead of `.unwrap_err()`.
|
||||
match AgentMemoryBackend::from_config(&cfg) {
|
||||
Ok(_) => panic!("expected error for empty url"),
|
||||
Err(err) => assert!(
|
||||
err.to_string().contains("cannot be empty"),
|
||||
"unexpected error: {err}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_config_rejects_invalid_url() {
|
||||
let cfg = MemoryConfig {
|
||||
backend: "agentmemory".to_string(),
|
||||
agentmemory_url: Some("not a url".to_string()),
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
match AgentMemoryBackend::from_config(&cfg) {
|
||||
Ok(_) => panic!("expected error for invalid url"),
|
||||
Err(err) => assert!(
|
||||
err.to_string().contains("not a valid URL"),
|
||||
"expected URL error, got: {err}"
|
||||
),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user