Skip to content
Closed
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
45 changes: 43 additions & 2 deletions agent/memory_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import json
import logging
import re
import inspect
from typing import Any, Dict, List, Optional

from agent.memory_provider import MemoryProvider
Expand Down Expand Up @@ -312,7 +313,39 @@ def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
)
return "\n\n".join(parts)

def on_memory_write(self, action: str, target: str, content: str) -> None:
@staticmethod
def _provider_memory_write_metadata_mode(provider: MemoryProvider) -> str:
"""Return how to pass metadata to a provider's memory-write hook."""
try:
signature = inspect.signature(provider.on_memory_write)
except (TypeError, ValueError):
return "keyword"

params = list(signature.parameters.values())
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params):
return "keyword"
if "metadata" in signature.parameters:
return "keyword"

accepted = [
p for p in params
if p.kind in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
)
]
if len(accepted) >= 4:
return "positional"
return "legacy"

def on_memory_write(
self,
action: str,
target: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Notify external providers when the built-in memory tool writes.

Skips the builtin provider itself (it's the source of the write).
Expand All @@ -321,7 +354,15 @@ def on_memory_write(self, action: str, target: str, content: str) -> None:
if provider.name == "builtin":
continue
try:
provider.on_memory_write(action, target, content)
metadata_mode = self._provider_memory_write_metadata_mode(provider)
if metadata_mode == "keyword":
provider.on_memory_write(
action, target, content, metadata=dict(metadata or {})
)
elif metadata_mode == "positional":
provider.on_memory_write(action, target, content, dict(metadata or {}))
else:
provider.on_memory_write(action, target, content)
except Exception as e:
logger.debug(
"Memory provider '%s' on_memory_write failed: %s",
Expand Down
15 changes: 12 additions & 3 deletions agent/memory_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@
on_turn_start(turn, message, **kwargs) — per-turn tick with runtime context
on_session_end(messages) — end-of-session extraction
on_pre_compress(messages) -> str — extract before context compression
on_memory_write(action, target, content) — mirror built-in memory writes
on_memory_write(action, target, content, metadata=None) — mirror built-in memory writes
on_delegation(task, result, **kwargs) — parent-side observation of subagent work
"""

from __future__ import annotations

import logging
from abc import ABC, abstractmethod
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -220,12 +220,21 @@ def save_config(self, values: Dict[str, Any], hermes_home: str) -> None:
should all have ``env_var`` set and this method stays no-op).
"""

def on_memory_write(self, action: str, target: str, content: str) -> None:
def on_memory_write(
self,
action: str,
target: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when the built-in memory tool writes an entry.

action: 'add', 'replace', or 'remove'
target: 'memory' or 'user'
content: the entry content
metadata: structured provenance for the write, when available. Common
keys include ``write_origin``, ``execution_context``, ``session_id``,
``parent_session_id``, ``platform``, and ``tool_name``.

Use to mirror built-in memory writes to your backend.
"""
8 changes: 7 additions & 1 deletion plugins/memory/byterover/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,13 @@ def _sync():
)
self._sync_thread.start()

def on_memory_write(self, action: str, target: str, content: str) -> None:
def on_memory_write(
self,
action: str,
target: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Mirror built-in memory writes to ByteRover."""
if action not in ("add", "replace") or not content:
return
Expand Down
10 changes: 8 additions & 2 deletions plugins/memory/holographic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import json
import logging
import re
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional

from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
Expand Down Expand Up @@ -240,7 +240,13 @@ def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
return
self._auto_extract_facts(messages)

def on_memory_write(self, action: str, target: str, content: str) -> None:
def on_memory_write(
self,
action: str,
target: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Mirror built-in memory writes as facts."""
if action == "add" and self._store and content:
try:
Expand Down
8 changes: 7 additions & 1 deletion plugins/memory/honcho/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1087,7 +1087,13 @@ def _sync():
)
self._sync_thread.start()

def on_memory_write(self, action: str, target: str, content: str) -> None:
def on_memory_write(
self,
action: str,
target: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Mirror built-in user profile writes as Honcho conclusions."""
if action != "add" or target != "user" or not content:
return
Expand Down
8 changes: 7 additions & 1 deletion plugins/memory/openviking/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,13 @@ def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
except Exception as e:
logger.warning("OpenViking session commit failed: %s", e)

def on_memory_write(self, action: str, target: str, content: str) -> None:
def on_memory_write(
self,
action: str,
target: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Mirror built-in memory writes to OpenViking as explicit memories."""
if not self._client or action != "add" or not content:
return
Expand Down
8 changes: 7 additions & 1 deletion plugins/memory/retaindb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,13 @@ def _dispatch(self, tool_name: str, args: dict) -> Any:

# ── Optional hooks ─────────────────────────────────────────────────────

def on_memory_write(self, action: str, target: str, content: str) -> None:
def on_memory_write(
self,
action: str,
target: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Mirror built-in memory writes to RetainDB."""
if action != "add" or not content or not self._client:
return
Expand Down
17 changes: 15 additions & 2 deletions plugins/memory/supermemory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,17 +614,30 @@ def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
except Exception:
logger.warning("Supermemory session ingest failed", exc_info=True)

def on_memory_write(self, action: str, target: str, content: str) -> None:
def on_memory_write(
self,
action: str,
target: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
if not self._active or not self._write_enabled or not self._client:
return
if action != "add" or not (content or "").strip():
return
write_metadata = {
"source": "hermes_memory",
"target": target,
"type": "explicit_memory",
}
if metadata:
write_metadata.update(metadata)

def _run():
try:
self._client.add_memory(
content.strip(),
metadata={"source": "hermes_memory", "target": target, "type": "explicit_memory"},
metadata=write_metadata,
entity_context=self._entity_context,
)
except Exception:
Expand Down
52 changes: 52 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,8 @@ def __init__(

# Track conversation messages for session logging
self._session_messages: List[Dict[str, Any]] = []
self._memory_write_origin = "assistant_tool"
self._memory_write_context = "foreground"

# Cached system prompt -- built once per session, only rebuilt on compression
self._cached_system_prompt: Optional[str] = None
Expand Down Expand Up @@ -3047,7 +3049,10 @@ def _run_review():
quiet_mode=True,
platform=self.platform,
provider=self.provider,
parent_session_id=self.session_id,
)
review_agent._memory_write_origin = "background_review"
review_agent._memory_write_context = "background_review"
review_agent._memory_store = self._memory_store
review_agent._memory_enabled = self._memory_enabled
review_agent._user_profile_enabled = self._user_profile_enabled
Expand Down Expand Up @@ -3095,6 +3100,32 @@ def _run_review():
t = threading.Thread(target=_run_review, daemon=True, name="bg-review")
t.start()

def _build_memory_write_metadata(
self,
*,
write_origin: Optional[str] = None,
execution_context: Optional[str] = None,
task_id: Optional[str] = None,
tool_call_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Build provenance metadata for external memory-provider mirrors."""
metadata: Dict[str, Any] = {
"write_origin": write_origin or getattr(self, "_memory_write_origin", "assistant_tool"),
"execution_context": (
execution_context
or getattr(self, "_memory_write_context", "foreground")
),
"session_id": self.session_id or "",
"parent_session_id": self._parent_session_id or "",
"platform": self.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
"tool_name": "memory",
}
if task_id:
metadata["task_id"] = task_id
if tool_call_id:
metadata["tool_call_id"] = tool_call_id
return {k: v for k, v in metadata.items() if v not in (None, "")}

def _apply_persist_user_message_override(self, messages: List[Dict]) -> None:
"""Rewrite the current-turn user message before persistence/return.

Expand Down Expand Up @@ -7748,6 +7779,19 @@ def flush_memories(self, messages: list = None, min_turns: int = None):
old_text=args.get("old_text"),
store=self._memory_store,
)
if self._memory_manager and args.get("action") in ("add", "replace"):
try:
self._memory_manager.on_memory_write(
args.get("action", ""),
flush_target,
args.get("content", ""),
metadata=self._build_memory_write_metadata(
write_origin="memory_flush",
execution_context="flush_memories",
),
)
except Exception:
pass
if not self.quiet_mode:
print(f" 🧠 Memory flush: saved to {args.get('target', 'memory')}")
except Exception as e:
Expand Down Expand Up @@ -7968,6 +8012,10 @@ def _invoke_tool(self, function_name: str, function_args: dict, effective_task_i
function_args.get("action", ""),
target,
function_args.get("content", ""),
metadata=self._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=tool_call_id,
),
)
except Exception:
pass
Expand Down Expand Up @@ -8479,6 +8527,10 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe
function_args.get("action", ""),
target,
function_args.get("content", ""),
metadata=self._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=getattr(tool_call, "id", None),
),
)
except Exception:
pass
Expand Down
52 changes: 52 additions & 0 deletions tests/agent/test_memory_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ def on_memory_write(self, action, target, content):
self.memory_writes.append((action, target, content))


class MetadataMemoryProvider(FakeMemoryProvider):
"""Provider that opts into write metadata."""

def on_memory_write(self, action, target, content, metadata=None):
self.memory_writes.append((action, target, content, metadata or {}))


# ---------------------------------------------------------------------------
# MemoryProvider ABC tests
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -862,6 +869,51 @@ def test_on_memory_write_add(self):
mgr.on_memory_write("add", "memory", "new fact")
assert p.memory_writes == [("add", "memory", "new fact")]

def test_on_memory_write_metadata_passed_to_opt_in_provider(self):
"""Providers that accept metadata receive structured write provenance."""
mgr = MemoryManager()
p = MetadataMemoryProvider("ext")
mgr.add_provider(p)

mgr.on_memory_write(
"add",
"memory",
"new fact",
metadata={
"write_origin": "assistant_tool",
"execution_context": "foreground",
"session_id": "sess-1",
},
)

assert p.memory_writes == [
(
"add",
"memory",
"new fact",
{
"write_origin": "assistant_tool",
"execution_context": "foreground",
"session_id": "sess-1",
},
)
]

def test_on_memory_write_metadata_keeps_legacy_provider_compatible(self):
"""Old 3-arg providers keep working when the manager receives metadata."""
mgr = MemoryManager()
p = FakeMemoryProvider("ext")
mgr.add_provider(p)

mgr.on_memory_write(
"add",
"user",
"legacy provider fact",
metadata={"write_origin": "assistant_tool"},
)

assert p.memory_writes == [("add", "user", "legacy provider fact")]

def test_on_memory_write_replace(self):
"""on_memory_write fires for 'replace' actions."""
mgr = MemoryManager()
Expand Down
Loading
Loading