Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4a677ad
fix(honcho): pinPeerName opt-in keeps memory unified across platforms…
briandevans Apr 24, 2026
4fab0dc
fix(honcho): require strict True for pin_peer_name to survive MagicMo…
briandevans Apr 24, 2026
c8f1f4e
fix(honcho): truncate resolve_session_name output to Honcho's 100-cha…
Sanjays2402 Apr 22, 2026
7b3501b
fix(honcho): thread-safe session cache via RLock
hekaru-agent Apr 21, 2026
9129220
fix: strip leaked memory context from commentary
dontcallmejames Apr 18, 2026
4560ccf
fix: harden memory-context leak boundaries
dontcallmejames Apr 21, 2026
afeaaae
Fix Honcho HOME-aware global config fallback
HiddenPuppy Apr 21, 2026
45c8d9c
fix(honcho): CLI credential guard rejects self-hosted baseUrl configs
sasha-id Apr 24, 2026
7af8d3b
fix(plugins/memory/honcho): default Honcho SDK HTTP timeout to 30s
twozle Apr 21, 2026
695e722
fix(honcho): hold RLock across new_session's get_or_create to close race
erosika Apr 24, 2026
9fc518a
fix(honcho): buffer partial memory-context spans across stream deltas
erosika Apr 24, 2026
f2f4145
fix(gateway): scrub memory-context leaks from vision auto-analysis ou…
erosika Apr 24, 2026
c66acc9
style(honcho): hoist hashlib import; validate baseUrl scheme before '…
erosika Apr 24, 2026
275ac5e
compat(honcho): accept metadata kwarg on on_memory_write ABC bump
erosika Apr 26, 2026
afe3f58
fix(honcho): keep legacy schemeless baseUrl configs working
erosika Apr 26, 2026
31fba28
feat(honcho): explain why when honcho_profile returns an empty card
erosika Apr 27, 2026
0ed3b75
chore(release): map honcho-consolidation contributor emails
erosika Apr 27, 2026
70924a6
fix(memory): narrow scrub surface to known wrapper boundaries
erosika Apr 27, 2026
dabd8d8
style: trim verbose comment blocks added by previous commit
erosika Apr 27, 2026
d73abfa
fix(memory): drop scrub from interim commentary + final response
erosika Apr 27, 2026
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
119 changes: 114 additions & 5 deletions agent/memory_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,124 @@ def sanitize_context(text: str) -> str:
return text


def build_memory_context_block(raw_context: str) -> str:
"""Wrap prefetched memory in a fenced block with system note.

The fence prevents the model from treating recalled context as user
discourse. Injected at API-call time only — never persisted.
class StreamingContextScrubber:
"""Stateful scrubber for streaming text that may contain split memory-context spans.

The one-shot ``sanitize_context`` regex cannot survive chunk boundaries:
a ``<memory-context>`` opened in one delta and closed in a later delta
leaks its payload to the UI because the non-greedy block regex needs
both tags in one string. This scrubber runs a small state machine
across deltas, holding back partial-tag tails and discarding
everything inside a span (including the system-note line).

Usage::

scrubber = StreamingContextScrubber()
for delta in stream:
visible = scrubber.feed(delta)
if visible:
emit(visible)
trailing = scrubber.flush() # at end of stream
if trailing:
emit(trailing)

The scrubber is re-entrant per agent instance. Callers building new
top-level responses (new turn) should create a fresh scrubber or call
``reset()``.
"""

_OPEN_TAG = "<memory-context>"
_CLOSE_TAG = "</memory-context>"

def __init__(self) -> None:
self._in_span: bool = False
self._buf: str = ""

def reset(self) -> None:
self._in_span = False
self._buf = ""

def feed(self, text: str) -> str:
"""Return the visible portion of ``text`` after scrubbing.

Any trailing fragment that could be the start of an open/close tag
is held back in the internal buffer and surfaced on the next
``feed()`` call or discarded/emitted by ``flush()``.
"""
if not text:
return ""
buf = self._buf + text
self._buf = ""
out: list[str] = []

while buf:
if self._in_span:
idx = buf.lower().find(self._CLOSE_TAG)
if idx == -1:
# Hold back a potential partial close tag; drop the rest
held = self._max_partial_suffix(buf, self._CLOSE_TAG)
self._buf = buf[-held:] if held else ""
return "".join(out)
# Found close — skip span content + tag, continue
buf = buf[idx + len(self._CLOSE_TAG):]
self._in_span = False
else:
idx = buf.lower().find(self._OPEN_TAG)
if idx == -1:
# No open tag — hold back a potential partial open tag
held = self._max_partial_suffix(buf, self._OPEN_TAG)
if held:
out.append(buf[:-held])
self._buf = buf[-held:]
else:
out.append(buf)
return "".join(out)
# Emit text before the tag, enter span
if idx > 0:
out.append(buf[:idx])
buf = buf[idx + len(self._OPEN_TAG):]
self._in_span = True

return "".join(out)

def flush(self) -> str:
"""Emit any held-back buffer at end-of-stream.

If we're still inside an unterminated span the remaining content is
discarded (safer: leaking partial memory context is worse than a
truncated answer). Otherwise the held-back partial-tag tail is
emitted verbatim (it turned out not to be a real tag).
"""
if self._in_span:
self._buf = ""
self._in_span = False
return ""
tail = self._buf
self._buf = ""
return tail

@staticmethod
def _max_partial_suffix(buf: str, tag: str) -> int:
"""Return the length of the longest buf-suffix that is a tag-prefix.

Case-insensitive. Returns 0 if no suffix could start the tag.
"""
tag_lower = tag.lower()
buf_lower = buf.lower()
max_check = min(len(buf_lower), len(tag_lower) - 1)
for i in range(max_check, 0, -1):
if tag_lower.startswith(buf_lower[-i:]):
return i
return 0


def build_memory_context_block(raw_context: str) -> str:
"""Wrap prefetched memory in a fenced block with system note."""
if not raw_context or not raw_context.strip():
return ""
clean = sanitize_context(raw_context)
if clean != raw_context:
logger.warning("memory provider returned pre-wrapped context; stripped")
return (
"<memory-context>\n"
"[System note: The following is recalled memory context, "
Expand Down
2 changes: 2 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -8483,6 +8483,7 @@ async def _enrich_message_with_vision(
The enriched message string with vision descriptions prepended.
"""
from tools.vision_tools import vision_analyze_tool
from agent.memory_manager import sanitize_context

analysis_prompt = (
"Describe everything visible in this image in thorough detail. "
Expand All @@ -8501,6 +8502,7 @@ async def _enrich_message_with_vision(
result = json.loads(result_json)
if result.get("success"):
description = result.get("analysis", "")
description = sanitize_context(description)
enriched_parts.append(
f"[The user sent an image~ Here's what I can see:\n{description}]\n"
f"[If you need a closer look, use vision_analyze with "
Expand Down
7 changes: 6 additions & 1 deletion hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
import threading
import time
from pathlib import Path

from agent.memory_manager import sanitize_context
from hermes_constants import get_hermes_home
from typing import Any, Callable, Dict, List, Optional, TypeVar

Expand Down Expand Up @@ -1155,7 +1157,10 @@ def get_messages_as_conversation(

messages = []
for row in rows:
msg = {"role": row["role"], "content": row["content"]}
content = row["content"]
if row["role"] in {"user", "assistant"} and isinstance(content, str):
content = sanitize_context(content).strip()
msg = {"role": row["role"], "content": content}
if row["tool_call_id"]:
msg["tool_call_id"] = row["tool_call_id"]
if row["tool_name"]:
Expand Down
87 changes: 81 additions & 6 deletions plugins/memory/honcho/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import time
from typing import Any, Dict, List, Optional

from agent.memory_manager import sanitize_context
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error

Expand All @@ -37,7 +38,10 @@
"description": (
"Retrieve or update a peer card from Honcho — a curated list of key facts "
"about that peer (name, role, preferences, communication style, patterns). "
"Pass `card` to update; omit `card` to read."
"Pass `card` to update; omit `card` to read. If the card is empty, the "
"result includes a `hint` field explaining why (observation disabled, "
"fresh peer, dialectic layer still warming up, etc.) — this is NOT an "
"error. Peer cards accumulate over time from observed conversation."
),
"parameters": {
"type": "object",
Expand Down Expand Up @@ -1056,6 +1060,63 @@ def _chunk_message(content: str, limit: int) -> list[str]:

return chunks

def _empty_profile_hint(self, peer: str) -> Dict[str, Any]:
"""Build a diagnostic hint when honcho_profile returns an empty card.

A literal "No profile facts available yet." tells the model nothing
about WHY. The model then often surfaces it to the user as a cryptic
error. This hint enumerates the likely causes so the model can
explain the situation (or retry with a different peer).

Ordered by likelihood for a typical deployment:
1. Observation is disabled for this peer
2. Card hasn't accumulated yet (fresh peer, not enough dialectic
cycles — dialectic cadence runs every N turns)
3. Self-hosted Honcho backend doesn't support peer cards
(honcho-ai server < 3.x)
"""
cfg = self._config
reasons: List[str] = []

if cfg is not None:
if peer == "user":
observe_me = bool(getattr(cfg, "user_observe_me", True))
observe_others = bool(getattr(cfg, "user_observe_others", True))
else:
observe_me = bool(getattr(cfg, "ai_observe_me", True))
observe_others = bool(getattr(cfg, "ai_observe_others", True))
if not (observe_me or observe_others):
reasons.append(
f"observation is disabled for peer '{peer}' "
f"(user_observe_me/ai_observe_me in config)"
)

cadence = getattr(self, "_dialectic_cadence", 1)
turn = getattr(self, "_turn_count", 0)
if turn < max(2, cadence):
reasons.append(
f"this session has only {turn} turn(s); peer cards accumulate "
f"as the dialectic layer reasons over conversation history "
f"(cadence every {cadence} turn(s))"
)

if not reasons:
reasons.append(
"peer card has no facts yet — Honcho's dialectic layer builds "
"this over time from observed turns; self-hosted Honcho < 3.x "
"does not support peer cards at all"
)

return {
"result": "No profile facts available yet.",
"hint": (
"This is not an error. "
+ "; ".join(reasons)
+ ". Try honcho_reasoning for a synthesized answer, or "
"honcho_search to query raw conversation excerpts."
),
}

def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
"""Record the conversation turn in Honcho (non-blocking).

Expand All @@ -1068,13 +1129,15 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st
return

msg_limit = self._config.message_max_chars if self._config else 25000
clean_user_content = sanitize_context(user_content or "").strip()
clean_assistant_content = sanitize_context(assistant_content or "").strip()

def _sync():
try:
session = self._manager.get_or_create(self._session_key)
for chunk in self._chunk_message(user_content, msg_limit):
for chunk in self._chunk_message(clean_user_content, msg_limit):
session.add_message("user", chunk)
for chunk in self._chunk_message(assistant_content, msg_limit):
for chunk in self._chunk_message(clean_assistant_content, msg_limit):
session.add_message("assistant", chunk)
self._manager._flush_session(session)
except Exception as e:
Expand All @@ -1087,8 +1150,20 @@ def _sync():
)
self._sync_thread.start()

def on_memory_write(self, action: str, target: str, content: str) -> None:
"""Mirror built-in user profile writes as Honcho conclusions."""
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.

``metadata`` is accepted for compatibility with the write-origin
work landed in main (commit 6a957a74); it's not yet threaded into
the Honcho conclusion payload. Left as a follow-up so this PR
stays focused on the 7-PR consolidation and its review follow-ups.
"""
if action != "add" or target != "user" or not content:
return
if self._cron_skipped:
Expand Down Expand Up @@ -1154,7 +1229,7 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
return json.dumps({"result": f"Peer card updated ({len(result)} facts).", "card": result})
card = self._manager.get_peer_card(self._session_key, peer=peer)
if not card:
return json.dumps({"result": "No profile facts available yet."})
return json.dumps(self._empty_profile_hint(peer))
return json.dumps({"result": card})

elif tool_name == "honcho_search":
Expand Down
33 changes: 31 additions & 2 deletions plugins/memory/honcho/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,9 +273,38 @@ def _write_config(cfg: dict, path: Path | None = None) -> None:


def _resolve_api_key(cfg: dict) -> str:
"""Resolve API key with host -> root -> env fallback."""
"""Resolve API key with host -> root -> env fallback.

For self-hosted instances configured with ``baseUrl`` instead of an API
key, returns ``"local"`` so that credential guards throughout the CLI
don't reject a valid configuration. The ``baseUrl`` is scheme-validated
(http/https only) so that a typo like ``baseUrl: true`` can't silently
pass the guard. Schemeless strings that look like host:port (legacy
config shapes, e.g. ``localhost:8000``) still pass — the Honcho SDK
will reject them itself with a clearer error than ours.
"""
host_key = ((cfg.get("hosts") or {}).get(_host_key()) or {}).get("apiKey")
return host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
key = host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
if not key:
base_url = cfg.get("baseUrl") or cfg.get("base_url") or os.environ.get("HONCHO_BASE_URL", "")
base_url = (base_url or "").strip()
if base_url:
from urllib.parse import urlparse
try:
parsed = urlparse(base_url)
except (TypeError, ValueError):
parsed = None
if parsed and parsed.scheme in ("http", "https") and parsed.netloc:
return "local"
# Schemeless but looks like a host (contains '.' or ':' and isn't
# a boolean literal): let it through so legacy configs don't
# regress into "no API key configured" when they previously worked.
lowered = base_url.lower()
if lowered not in ("true", "false", "none", "null") and any(
c in base_url for c in ".:"
) and not base_url.isdigit():
return "local"
return key


def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
Expand Down
Loading
Loading