Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ Sources pull data from external services on a schedule. See [sources.md](sources

When active:
- The `memorize` tool **dual-writes**: memU (as always) plus an async `write_async` to xmemory. Failures on the xmemory side never fail the tool.
- `memory_recall` appends xmemory's single synthesized answer (its `SINGLE_ANSWER` read mode) to memU's N items, run concurrently so the dual lookup is one round-trip.
- `memory_recall` appends xmemory's read result to memU's N items, run concurrently so the dual lookup is one round-trip. Read behavior is controlled via `xmemory.read_mode` (defaults to `single-answer`).
- The memorization **sweep** (session-close / cron) stays memU-only — it does not go through the `memorize` tool handler.

| Key | Type | Default | Description |
Expand All @@ -249,6 +249,7 @@ When active:
| `xmemory.instance_id` | string | *(empty)* | The xmemory instance to bind. Both this and `api_key` are required to activate. |
| `xmemory.api_url` | string | `https://api.xmemory.ai` | API base URL. |
| `xmemory.extraction_logic` | string | `deep` | Write extraction mode: `deep` (accurate) or `fast` (high-volume). |
| `xmemory.read_mode` | string | `single-answer` | Read mode for recall: `single-answer` (synthesized natural-language answer), `raw-tables` (structured rows, JSON-rendered), or `xresponse`. |
| `xmemory.timeout` | float | `60.0` | Per-request timeout in seconds. |

## Docker
Expand Down
2 changes: 1 addition & 1 deletion docs/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ It is inert unless both `xmemory.api_key` and `xmemory.instance_id` are configur
When active:

- **`memorize` dual-writes** — the fact goes to memU (file + extraction, as always) **and** is enqueued to xmemory via async `write_async`. xmemory failures are swallowed (logged) so the tool never fails on them. The result message shows `(+ xmemory)` when the xmemory enqueue succeeded.
- **`memory_recall` adds a synthesized answer** — alongside memU's N items + category breadcrumbs, recall runs xmemory's `SINGLE_ANSWER` read **concurrently** and appends a `[xmemory] synthesized answer` section. When xmemory is disabled or returns nothing, recall output is byte-for-byte the memU-only shape.
- **`memory_recall` adds xmemory read output** — alongside memU's N items + category breadcrumbs, recall runs xmemory **concurrently** and appends a `[xmemory]` section. Read behavior is controlled by `xmemory.read_mode` (`single-answer` by default, appending a synthesized natural-language answer; `raw-tables` instead appends the matching rows as JSON, and `xresponse` is also supported). When xmemory is disabled or returns nothing, recall output is byte-for-byte the memU-only shape.
- **The memorization sweep stays memU-only** — session-close / cron indexing goes through the bridge directly, not the `memorize` tool handler, so it is unaffected.

Implementation: `nerve/memory/xmemory_bridge.py` (`XmemoryBridge`), wired into `ToolContext.xmemory_bridge` next to `memory_bridge`. Backed by the `xmemory-ai` SDK (`AsyncXmemoryClient`).
Expand Down
6 changes: 4 additions & 2 deletions nerve/agent/tools/handlers/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ async def memory_recall_handler(ctx: ToolContext, args: dict) -> ToolResult:
logger.error("Memory recall failed: %s", e)
memu_block = f"Memory recall error: {e}"

# Collect xmemory's synthesized answer (None when disabled/empty/error).
# Collect xmemory's read output (None when disabled/empty/error).
xmem_answer: str | None = None
if xmem_task is not None:
try:
Expand All @@ -121,10 +121,12 @@ async def memory_recall_handler(ctx: ToolContext, args: dict) -> ToolResult:
return ToolResult.text(memu_block)

# Both stores in play → label each source so the two are distinguishable.
# The label stays mode-agnostic: under raw-tables/xresponse the payload is
# structured rows, not a synthesized answer.
memu_part = memu_block if memu_block is not None else "No relevant memories found."
xmem_part = _clip_to_budget(xmem_answer, _MAX_XMEM_ANSWER_BYTES)
return ToolResult.text(
f"[memU] {memu_part}\n\n---\n\n[xmemory] synthesized answer:\n\n{xmem_part}"
f"[memU] {memu_part}\n\n---\n\n[xmemory]\n\n{xmem_part}"
)


Expand Down
2 changes: 2 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,7 @@ class XmemoryConfig:
instance_id: str = ""
api_url: str = "https://api.xmemory.ai"
extraction_logic: str = "deep" # "deep" (default) or "fast"
read_mode: str = "single-answer" # "single-answer" | "raw-tables" | "xresponse"
timeout: float = 60.0

@property
Expand All @@ -1318,6 +1319,7 @@ def from_dict(cls, d: dict) -> "XmemoryConfig":
instance_id=d.get("instance_id", ""),
api_url=d.get("api_url", "https://api.xmemory.ai"),
extraction_logic=d.get("extraction_logic", "deep"),
read_mode=d.get("read_mode", "single-answer"),
timeout=float(d.get("timeout", 60.0)),
)

Expand Down
140 changes: 115 additions & 25 deletions nerve/memory/xmemory_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
* ``memorize`` tool → dual-writes: memU (as today) **and** xmemory
(async ``write_async``, fire-and-forget).
* ``memory_recall`` tool → memU returns its N items/breadcrumbs **and**
this bridge appends xmemory's single synthesized answer to the query.
this bridge appends xmemory's read output for the query. The read mode
is configurable (``xmemory.read_mode``): a synthesized natural-language
answer by default (``single-answer``), or the structured ``raw-tables`` /
``xresponse`` payloads.
* The memorization *sweep* (session-close, cron) stays memU-only — it
never goes through the ``memorize`` tool handler, so it's untouched.

Expand All @@ -22,6 +25,7 @@

from __future__ import annotations

import json
import logging
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -131,6 +135,25 @@ def _extraction_logic(self) -> Any:
fast = (self._config.extraction_logic or "deep").strip().lower() == "fast"
return self._ExtractionLogic.FAST if fast else self._ExtractionLogic.DEEP

def _read_mode(self) -> Any:
"""Map the configured ``read_mode`` to the SDK enum.

Config values mirror the SDK's own wire values (``single-answer``,
``raw-tables``, ``xresponse``), so the enum resolves them directly and
any mode the SDK adds later needs no change here. Underscores are
accepted as an alias; unknown values fall back to ``single-answer``,
the configured default.
"""
mode = (self._config.read_mode or "").strip().lower().replace("_", "-")
try:
return self._ReadMode(mode)
except ValueError:
logger.warning(
"xmemory: unknown read_mode=%r, falling back to single-answer",
self._config.read_mode,
)
return self._ReadMode.SINGLE_ANSWER

async def memorize(self, text: str) -> bool:
"""Async-write ``text`` to xmemory (fire-and-forget).

Expand All @@ -149,40 +172,107 @@ async def memorize(self, text: str) -> bool:
return False

async def recall_answer(self, query: str) -> str | None:
"""Query xmemory and return its single synthesized answer.
"""Query xmemory and return its read output as text.

Uses ``SINGLE_ANSWER`` read mode — xmemory translates the question
to SQL over its knowledge graph and returns a natural-language
answer. Returns the answer string, or ``None`` when unavailable,
empty, or on any error (so recall always falls back to memU alone).
The read mode comes from ``xmemory.read_mode``: ``single-answer`` (the
default) yields the synthesized natural-language answer, while
``raw-tables`` and ``xresponse`` yield structured payloads, rendered
as JSON.

Returns ``None`` when unavailable, empty, or on any error (so recall
always falls back to memU alone).
"""
if not self.available or not query:
return None
try:
result = await self._instance.read(
query, read_mode=self._ReadMode.SINGLE_ANSWER,
)
return _extract_answer(result)
read_mode = self._read_mode()
result = await self._instance.read(query, read_mode=read_mode)
except Exception as e:
logger.warning("xmemory read failed: %s", e)
return None
return _extract_answer(
result, single_answer=read_mode == self._ReadMode.SINGLE_ANSWER,
)


def _extract_answer(result: Any, *, single_answer: bool = True) -> str | None:
"""Render an SDK ReadResult as text.

Prefers ``reader_results`` — the per-sub-query answers the server returns
when it decomposes a composite query (xmemory-ai 0.10.0+) — so each answer
stays labelled with the sub-question it answers. Falls back to the combined
``reader_result`` when the query was not decomposed, when the server
predates decomposition, or when no sub-query yielded anything usable.
"""
parts: list[str] = []
for tagged in getattr(result, "reader_results", None) or ():
body = _render_payload(
getattr(tagged, "reader_result", None), single_answer=single_answer,
)
if body is None:
# ``error`` is set only when this one sub-query could not be
# answered while the others still were. Surface it: an explicit
# "not known" beats dropping the sub-query silently.
error = _as_text(getattr(tagged, "error", None))
body = f"(unavailable: {error})" if error else None
if body is None:
continue
sub_query = _as_text(getattr(tagged, "sub_query", None))
parts.append(f"{sub_query}: {body}" if sub_query else body)

if parts:
return "\n".join(parts)

def _extract_answer(result: Any) -> str | None:
"""Pull the natural-language answer out of an SDK ReadResult.
return _render_payload(
getattr(result, "reader_result", result), single_answer=single_answer,
)

SINGLE_ANSWER mode yields ``reader_result == {"answer": "..."}``; we
stay defensive about shape (dict, object, or bare string).

def _render_payload(payload: Any, *, single_answer: bool) -> str | None:
"""Render one reader payload — a whole read's, or one sub-query's — as text.

Only ``single-answer`` payloads carry an ``answer`` envelope. The other
modes return tables, which must not be searched for an ``answer`` key: a
data row with an ``answer`` column would otherwise be mistaken for the
synthesized answer and the rest of the row dropped.
"""
if single_answer:
payload = _unwrap_answer(payload)
return _as_text(payload)


def _unwrap_answer(payload: Any) -> Any:
"""Pull the answer out of a ``single-answer`` payload.

Arrives as ``{"answer": ...}``, an object with ``.answer``, or a bare
string, depending on server/SDK version.
"""
reader = getattr(result, "reader_result", result)
answer: Any = None
if isinstance(reader, dict):
answer = reader.get("answer")
elif hasattr(reader, "answer"):
answer = reader.answer
elif isinstance(reader, str):
answer = reader
if answer is None:
if isinstance(payload, dict):
return payload.get("answer")
if isinstance(payload, str):
return payload
return getattr(payload, "answer", None)


def _as_text(value: Any) -> str | None:
"""Render a value as text — JSON for structured (non-string) payloads."""
if value is None:
return None
text = str(answer).strip()
return text or None
if not isinstance(value, str):
try:
value = json.dumps(
value, ensure_ascii=False, sort_keys=True, default=_json_default,
)
except Exception:
value = str(value)
return value.strip() or None


def _json_default(value: Any) -> Any:
for attr in ("model_dump", "dict"):
method = getattr(value, attr, None)
if callable(method):
return method()
if hasattr(value, "__dict__"):
return value.__dict__
return str(value)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ dependencies = [
"html2text>=2024.2.26",
# Optional xmemory.ai structured-memory layer — only active when an
# xmemory api_key + instance_id are configured (see XmemoryConfig).
"xmemory-ai>=0.5.3",
"xmemory-ai>=0.10.0",
# Optional observability stack — only active when langfuse keys are
# configured. All three are pure-Python wheels.
"langfuse>=3.0.0",
Expand Down
Loading
Loading