diff --git a/README.md b/README.md index ff5dcf088..8711fc26c 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/gitbooks/SUMMARY.md b/gitbooks/SUMMARY.md index 8e1613f91..f5a8a663b 100644 --- a/gitbooks/SUMMARY.md +++ b/gitbooks/SUMMARY.md @@ -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) diff --git a/gitbooks/features/obsidian-wiki/agentmemory-backend.md b/gitbooks/features/obsidian-wiki/agentmemory-backend.md new file mode 100644 index 000000000..4db231e4a --- /dev/null +++ b/gitbooks/features/obsidian-wiki/agentmemory-backend.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 `. + +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 ` | +| `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_`) | +| `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=` | | +| `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 β€” +- agentmemory REST contract β€” `~/.agentmemory/.env` keys + endpoint + list in the agentmemory README +- v0.9.12 plaintext-bearer guard β€” agentmemory PR #315 diff --git a/gitbooks/features/obsidian-wiki/memory-tree.md b/gitbooks/features/obsidian-wiki/memory-tree.md index 7262d6cf3..bdf28a665 100644 --- a/gitbooks/features/obsidian-wiki/memory-tree.md +++ b/gitbooks/features/obsidian-wiki/memory-tree.md @@ -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. diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs index 85278fa38..625408dc0 100644 --- a/src/openhuman/about_app/catalog.rs +++ b/src/openhuman/about_app/catalog.rs @@ -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", diff --git a/src/openhuman/config/schema/storage_memory.rs b/src/openhuman/config/schema/storage_memory.rs index d40d8eb83..1f22c7637 100644 --- a/src/openhuman/config/schema/storage_memory.rs +++ b/src/openhuman/config/schema/storage_memory.rs @@ -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, + + /// 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, + + /// Optional bearer token sent as `Authorization: Bearer ` + /// 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, + + /// Per-request timeout for the agentmemory REST client, in + /// milliseconds. Defaults to 5000 ms. + #[serde(default)] + pub agentmemory_timeout_ms: Option, } 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(|_| ""), + ) + .field("agentmemory_timeout_ms", &self.agentmemory_timeout_ms) + .finish() + } +} + /// Which inference backend the memory_tree's LLM calls (extractor + /// summariser) should use. /// diff --git a/src/openhuman/memory/store/agentmemory/README.md b/src/openhuman/memory/store/agentmemory/README.md new file mode 100644 index 000000000..f99e78bbc --- /dev/null +++ b/src/openhuman/memory/store/agentmemory/README.md @@ -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 ` | +| `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=` | +| `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. diff --git a/src/openhuman/memory/store/agentmemory/backend.rs b/src/openhuman/memory/store/agentmemory/backend.rs new file mode 100644 index 000000000..817124590 --- /dev/null +++ b/src/openhuman/memory/store/agentmemory/backend.rs @@ -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 . 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 { + 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> { + 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 = 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> { + 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> { + 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 = 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 { + 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> { + 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 { + 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"); + } +} diff --git a/src/openhuman/memory/store/agentmemory/client.rs b/src/openhuman/memory/store/agentmemory/client.rs new file mode 100644 index 000000000..671a11ee5 --- /dev/null +++ b/src/openhuman/memory/store/agentmemory/client.rs @@ -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://` per the +//! v0.9.12 contract from upstream agentmemory PR #315 (see +//! ). + +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, +} + +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) -> Result { + 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 /`. Returns a 404 as `Ok(None)` for the get-by-key + /// shape; everything else surfaces as an error. + pub async fn get_optional(&self, path: &str) -> Result> { + 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::().await.with_context(|| { + format!("failed to decode JSON response from GET {url}") + })?)) + } + s => Err(decode_error(&url, s, resp.text().await.ok())), + } + } + + /// `GET /` expecting a 200 + JSON body. + pub async fn get_json(&self, path: &str) -> Result { + self.get_optional(path) + .await? + .ok_or_else(|| anyhow!("agentmemory returned 404 for GET {path}")) + } + + /// `POST /` with a JSON body, expecting a 2xx response. + pub async fn post_json( + &self, + path: &str, + body: &B, + ) -> Result { + 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::() + .await + .with_context(|| format!("failed to decode JSON response from POST {url}")) + } + + /// `GET /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 { + 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 { + 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) -> 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, + } + + 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}"); + } +} diff --git a/src/openhuman/memory/store/agentmemory/mapping.rs b/src/openhuman/memory/store/agentmemory/mapping.rs new file mode 100644 index 000000000..ce8a4bfa9 --- /dev/null +++ b/src/openhuman/memory/store/agentmemory/mapping.rs @@ -0,0 +1,291 @@ +//! Maps OpenHuman `MemoryEntry` / `MemoryCategory` to the agentmemory REST +//! wire shapes and back. +//! +//! Wire contract: β€” 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, + #[serde(skip_serializing_if = "Option::is_none", rename = "sessionIds")] + pub session_ids: Option>, +} + +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, +} + +/// 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, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub content: Option, + #[serde(default, rename = "type")] + pub kind: Option, + #[serde(default)] + pub concepts: Vec, + #[serde(default, rename = "sessionIds")] + pub session_ids: Vec, + #[serde(default, rename = "updatedAt")] + pub updated_at: Option, + #[serde(default, rename = "createdAt")] + pub created_at: Option, + /// Present on smart-search hits, absent on direct fetches. + #[serde(default)] + pub score: Option, +} + +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, +} + +/// `GET /agentmemory/memories` response envelope. +#[derive(Debug, Clone, Deserialize)] +pub struct MemoriesResponse { + #[serde(default)] + pub memories: Vec, +} + +/// `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, +} + +/// `GET /agentmemory/projects` response envelope. +#[derive(Debug, Clone, Deserialize)] +pub struct ProjectsResponse { + #[serde(default)] + pub projects: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ProjectSummary { + pub name: String, + #[serde(default)] + pub count: usize, + #[serde(default, rename = "lastUpdated")] + pub last_updated: Option, +} + +/// `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"); + } +} diff --git a/src/openhuman/memory/store/agentmemory/mod.rs b/src/openhuman/memory/store/agentmemory/mod.rs new file mode 100644 index 000000000..531da31e3 --- /dev/null +++ b/src/openhuman/memory/store/agentmemory/mod.rs @@ -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 +} diff --git a/src/openhuman/memory/store/factories.rs b/src/openhuman/memory/store/factories.rs index bbc83841f..07d3ca971 100644 --- a/src/openhuman/memory/store/factories.rs +++ b/src/openhuman/memory/store/factories.rs @@ -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> { + // 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 diff --git a/src/openhuman/memory/store/mod.rs b/src/openhuman/memory/store/mod.rs index 2febd0593..740563a06 100644 --- a/src/openhuman/memory/store/mod.rs +++ b/src/openhuman/memory/store/mod.rs @@ -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, diff --git a/tests/agentmemory_backend.rs b/tests/agentmemory_backend.rs new file mode 100644 index 000000000..1cc011097 --- /dev/null +++ b/tests/agentmemory_backend.rs @@ -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>>, + next_id: Arc>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MockMemory { + id: String, + project: Option, + title: Option, + content: Option, + #[serde(rename = "type")] + kind: Option, + #[serde(default)] + concepts: Vec, + #[serde(default, rename = "sessionIds")] + session_ids: Vec, + #[serde(rename = "updatedAt")] + updated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + score: Option, +} + +#[derive(Debug, Deserialize)] +struct RememberRequest { + project: String, + title: String, + content: String, + #[serde(rename = "type")] + kind: String, + #[serde(default)] + concepts: Vec, + #[serde(default, rename = "sessionIds")] + session_ids: Vec, +} + +#[derive(Debug, Deserialize)] +struct SmartSearchRequest { + query: String, + limit: usize, + #[serde(default)] + project: Option, +} + +#[derive(Debug, Deserialize)] +struct ForgetRequest { + id: String, +} + +#[derive(Debug, Deserialize)] +struct MemoriesQuery { + #[serde(default)] + project: Option, + #[serde(default)] + #[allow(dead_code)] + latest: Option, +} + +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) -> Json { + let n = state.memories.lock().unwrap().len(); + Json(serde_json::json!({"memories": n, "status": "ok"})) +} + +async fn handle_remember( + State(state): State, + Json(req): Json, +) -> (StatusCode, Json) { + 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, + Json(req): Json, +) -> Json { + let memories = state.memories.lock().unwrap(); + let q = req.query.to_lowercase(); + let project = req.project.as_deref(); + let hits: Vec = 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, + Json(req): Json, +) -> Json { + 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, + Query(q): Query, +) -> Json { + let memories = state.memories.lock().unwrap(); + let filtered: Vec = 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) -> Json { + use std::collections::BTreeMap; + let memories = state.memories.lock().unwrap(); + let mut by_project: BTreeMap)> = 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 = 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}" + ), + } +}