Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
175117c
fix(honcho): bind config provenance so background threads stop resolv…
erosika Aug 10, 2026
c516f8e
fix(honcho): cache clients per identity with a rotation-stable creden…
erosika Aug 10, 2026
3fbdd39
fix(honcho): propagate contextvars to all plugin background threads
erosika Aug 10, 2026
4e4b175
fix(memory): propagate contextvars through MemoryManager background l…
erosika Aug 10, 2026
74328cf
test(honcho): pin multi-profile client isolation end to end
erosika Aug 10, 2026
5b3cfc6
fix(honcho): drop unread _client_slot_timeouts bookkeeping
erosika Aug 11, 2026
f9bd435
fix: replace double-lambda with functools.partial, close from_env con…
kshitijk4poor Aug 13, 2026
6f795c8
fix(memory): read endpoint.baseUrl from Honcho config; accept HONCHO_URL
cfdude Jul 31, 2026
3cfefa3
fix(honcho): honor host-specific baseUrl
LeonSGP43 Apr 23, 2026
23bfa72
fix(honcho): use _host_block helper for dot-form legacy host key fall…
Morad37 Jun 2, 2026
18a3e93
test(honcho): pin the composed baseUrl precedence chain and the dot-f…
erosika Aug 10, 2026
d117944
fix(honcho): drop non-printable base_url values before client init
Bartok9 Jul 11, 2026
63c8e1f
fix(honcho): resolve peers host keys via profile_host_key (underscore…
spfcraze Aug 1, 2026
7d06d9a
fix(honcho): honor explicit top-level apiKey on local base_urls; warn…
erosika Aug 10, 2026
c1c19c3
fix(honcho): surface honcho_reasoning backend failures instead of 'No…
erosika Aug 10, 2026
0f085ed
fix(honcho): enforce saveMessages write containment + reject gateway-…
dtownsel Aug 9, 2026
cef78ef
honcho: honor saveMessages=false across all automatic write paths
Eapwrk Jul 19, 2026
dc5cdc4
fix(honcho): extend saveMessages=false guard to shutdown() flush
strzhao Aug 7, 2026
f87a816
fix(honcho): persist one-sided turns under the empty-content guard
erosika Aug 10, 2026
ab78888
fix(honcho): join the session manager's async-writer thread on provid…
erosika Aug 10, 2026
5f3db52
fix(honcho): honor writeFrequency in sync_turn by routing through man…
erosika Aug 10, 2026
ba6b335
fix(honcho): skip memory-file migration on non-owner sessions (task #…
Aug 8, 2026
dc23017
fix(honcho): resolve migration owner gate through _resolve_user_peer_id
erosika Aug 10, 2026
020039b
fix(honcho): gate memory-file migration on the declared owner
erosika Aug 12, 2026
253c70c
fix: track and join honcho-memwrite thread in shutdown
kshitijk4poor Aug 13, 2026
655b94f
chore: map contributor emails for adopted commits
kshitijk4poor Aug 13, 2026
f718b62
fix: restore main's ui_meta and context-length mock (stale-base rever…
kshitijk4poor Aug 13, 2026
2937ff4
fix: drop uv.lock change to avoid team-review requirement
kshitijk4poor Aug 13, 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
22 changes: 20 additions & 2 deletions agent/memory_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,8 +559,13 @@ def _run() -> None:
except Exception as exc: # pragma: no cover - re-raised by caller
error_box["value"] = exc

# Propagate the caller's contextvars (profile HERMES_HOME override)
# to the prefetch thread — see _submit_background.
import contextvars
from functools import partial

thread = threading.Thread(
target=_run,
target=partial(contextvars.copy_context().run, _run),
daemon=True,
name=f"memory-prefetch-{provider.name}",
)
Expand Down Expand Up @@ -696,7 +701,20 @@ def _run() -> None:
# -- Background dispatch -------------------------------------------------

def _submit_background(self, fn, *, kind: str = "write") -> None:
"""Queue ``fn`` on the serialized worker and track its durability class."""
"""Queue ``fn`` on the serialized worker and track its durability class.

The submitted callable is wrapped with the CALLER's contextvars:
profile isolation in multi-profile processes (gateway multiplexer,
dashboard, cron) is a ContextVar-scoped HERMES_HOME override, and
executor worker threads start with empty contexts — without the
wrap, a provider resolving ambient state (config paths, secrets)
from the worker would silently land on the default profile.
"""
import contextvars
from functools import partial

ctx = contextvars.copy_context()
fn = partial(ctx.run, fn)
executor = self._get_sync_executor()
if executor is None:
if self._shutting_down:
Expand Down
2 changes: 2 additions & 0 deletions contributors/emails/carnie-bot@openclaw.local
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
menhguin
# agent bot from PR #82038
2 changes: 2 additions & 0 deletions contributors/emails/daniel21436@hotmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
strzhao
# PR #81214 adoption
2 changes: 2 additions & 0 deletions contributors/emails/danielrpike9@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Bartok9
# PR #62757 adoption
2 changes: 2 additions & 0 deletions contributors/emails/dillontownsel@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dtownsel
# PR #82130 adoption
2 changes: 2 additions & 0 deletions contributors/emails/eri@plasticlabs.ai
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
erosika
# PR author
2 changes: 2 additions & 0 deletions contributors/emails/mohamed.origami@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Morad37
# PR #37671 adoption
2 changes: 2 additions & 0 deletions contributors/emails/rsherman@velocityinteractive.com
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
cfdude
# PR #43803 adoption
2 changes: 1 addition & 1 deletion plugins/memory/honcho/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ Pick **[e]** at the prompt to set the three keys directly instead of going throu
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `writeFrequency` | string/int | `"async"` | `"async"` (background), `"turn"` (sync per turn), `"session"` (batch on end), or integer N (every N turns) |
| `saveMessages` | bool | `true` | Persist messages to Honcho API |
| `saveMessages` | bool | `true` | Persist messages to Honcho API. When `false`, all automatic writes are skipped — raw turns (`sync_turn`), conclusion mirroring (`on_memory_write`), and session-end/shutdown flushes — while read and tools paths stay fully functional. |

### Session Resolution

Expand Down
151 changes: 116 additions & 35 deletions plugins/memory/honcho/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,36 @@

from agent.memory_manager import sanitize_context
from agent.memory_provider import TRIVIAL_PROMPT_RE, MemoryProvider, is_trivial_prompt
from plugins.memory.honcho.client import spawn_context_thread
from tools.registry import tool_error

logger = logging.getLogger(__name__)


# Gateway-internal notifications can arrive through the same user-role channel
# as genuine user messages. They are execution metadata, not conversation, and
# must never become durable personal memory. Keep this deliberately anchored:
# a human discussing one of these strings mid-message is still valid input.
_INTERNAL_GATEWAY_TURN_RE = re.compile(
r"^\s*(?:"
r"\[ASYNC (?:DELEGATION )?(?:BATCH )?COMPLETE[^\]]*\]|"
r"\[CONTEXT COMPACTION[^\]]*\]|"
r"\[CONTEXT SUMMARY\]:?|"
r"\[PRIOR CONTEXT[^\]]*\]|"
r"\[Your active task list was preserved across context compression\]|"
r"\[IMPORTANT: Background process \d+ matched watch pattern[^\n]*|"
r"A background fan-out of \d+ subagent\(s\) you dispatched earlier has finished\.|"
r"A background subagent you dispatched earlier has finished\."
r")",
re.IGNORECASE,
)


def _is_internal_gateway_turn(text: str) -> bool:
"""Return True for machine-generated gateway/delegation notifications."""
return bool(_INTERNAL_GATEWAY_TURN_RE.match(text or ""))


# ---------------------------------------------------------------------------
# Tool schemas (moved from tools/honcho_tools.py)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -467,9 +492,8 @@ def _run() -> None:
self._manager = None
logger.warning("Honcho background session init failed: %s", e)

self._init_thread = threading.Thread(
target=_run,
daemon=True,
self._init_thread = spawn_context_thread(
_run,
name="honcho-session-init",
)
self._init_thread.start()
Expand Down Expand Up @@ -546,9 +570,8 @@ def _prewarm_dialectic() -> None:
self._dialectic_empty_streak += 1

self._prefetch_thread_started_at = time.monotonic()
prewarm_thread = threading.Thread(
target=_prewarm_dialectic,
daemon=True,
prewarm_thread = spawn_context_thread(
_prewarm_dialectic,
name="honcho-prewarm-dialectic",
)
prewarm_thread.start()
Expand Down Expand Up @@ -776,9 +799,7 @@ def _fetch_base() -> None:
except Exception as e:
logger.debug("Honcho first-turn base context failed: %s", e)

_bt = threading.Thread(
target=_fetch_base, daemon=True, name="honcho-base-first"
)
_bt = spawn_context_thread(_fetch_base, name="honcho-base-first")
_bt.start()
_base_wait = (
max(0.0, first_turn_base_deadline - time.monotonic())
Expand Down Expand Up @@ -853,8 +874,8 @@ def _run_first_turn() -> None:
self._dialectic_empty_streak += 1

self._prefetch_thread_started_at = time.monotonic()
first_turn_thread = threading.Thread(
target=_run_first_turn, daemon=True, name="honcho-prefetch-first"
first_turn_thread = spawn_context_thread(
_run_first_turn, name="honcho-prefetch-first"
)
first_turn_thread.start()
self._prefetch_thread = first_turn_thread
Expand Down Expand Up @@ -1009,9 +1030,7 @@ def _run():
self._dialectic_empty_streak += 1

self._prefetch_thread_started_at = time.monotonic()
prefetch_thread = threading.Thread(
target=_run, daemon=True, name="honcho-prefetch"
)
prefetch_thread = spawn_context_thread(_run, name="honcho-prefetch")
prefetch_thread.start()
self._prefetch_thread = prefetch_thread

Expand Down Expand Up @@ -1390,9 +1409,20 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st

Messages exceeding the Honcho API limit (default 25k chars) are
split into multiple messages with continuation markers.

Honors saveMessages: false — the provider then never persists raw
turns to Honcho (read/tools paths stay fully functional).
"""
if self._cron_skipped:
return
# ``saveMessages`` is the operator's hard write gate. Previously it
# was parsed into HonchoClientConfig but never enforced here, so a
# cached hybrid provider kept writing even after containment was set.
if self._config and not getattr(self._config, "save_messages", True):
return
if _is_internal_gateway_turn(user_content):
logger.debug("Honcho sync skipped machine-generated gateway turn")
return
if self._recall_mode == "tools" and not self._session_ready():
return
if not self._session_ready():
Expand All @@ -1402,23 +1432,33 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st
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()
# Skip only when the whole turn is empty. An interrupted or tool-only
# turn can legitimately have an empty assistant side; the user's
# message must still be persisted (the manager already drops
# empty-user turns upstream). Empty sides are skipped per-loop below
# so we never write empty-string messages either.
if not clean_user_content and not clean_assistant_content:
return

def _sync():
try:
session = self._manager.get_or_create(self._session_key)
for chunk in self._chunk_message(clean_user_content, msg_limit):
session.add_message("user", chunk)
for chunk in self._chunk_message(clean_assistant_content, msg_limit):
session.add_message("assistant", chunk)
self._manager._flush_session(session)
if clean_user_content:
for chunk in self._chunk_message(clean_user_content, msg_limit):
session.add_message("user", chunk)
if clean_assistant_content:
for chunk in self._chunk_message(clean_assistant_content, msg_limit):
session.add_message("assistant", chunk)
# Route through save() so writeFrequency is honored —
# _flush_session() directly bypassed "session"/N batching
# and flushed every turn regardless of config.
self._manager.save(session)
except Exception as e:
logger.debug("Honcho sync_turn failed: %s", e)

if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=5.0)
self._sync_thread = threading.Thread(
target=_sync, daemon=True, name="honcho-sync"
)
self._sync_thread = spawn_context_thread(_sync, name="honcho-sync")
self._sync_thread.start()

def on_memory_write(
Expand All @@ -1439,6 +1479,11 @@ def on_memory_write(
return
if self._cron_skipped:
return
# ``saveMessages`` is the operator's hard write gate; the memory-tool
# mirror is an automatic Honcho mutation path and must respect it too,
# otherwise containment would only cover conversation turns.
if self._config and not getattr(self._config, "save_messages", True):
return
if self._recall_mode == "tools" and not self._session_ready():
return
if not self._session_ready():
Expand All @@ -1451,13 +1496,15 @@ def _write():
except Exception as e:
logger.debug("Honcho memory mirror failed: %s", e)

t = threading.Thread(target=_write, daemon=True, name="honcho-memwrite")
t.start()
self._memwrite_thread = spawn_context_thread(_write, name="honcho-memwrite")
self._memwrite_thread.start()

def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
"""Flush all pending messages to Honcho on session end."""
if self._cron_skipped:
return
if not getattr(self._config, "save_messages", True):
return
if not self._manager:
return
if not self._session_initialized and self._init_thread and self._init_thread.is_alive():
Expand Down Expand Up @@ -1534,13 +1581,31 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
return tool_error("Missing required parameter: query")
peer = args.get("peer", "user")
reasoning_level = args.get("reasoning_level")
result = self._manager.dialectic_query(
self._session_key, query,
reasoning_level=reasoning_level,
peer=peer,
# Explicit reasoning bypasses the automatic-injection cap.
apply_injection_cap=False,
)
try:
result = self._manager.dialectic_query(
self._session_key, query,
reasoning_level=reasoning_level,
peer=peer,
# Explicit reasoning bypasses the automatic-injection cap.
apply_injection_cap=False,
# Explicit tool call: surface timeouts/server errors as
# errors instead of collapsing them into "no result",
# which is indistinguishable from an empty answer.
raise_errors=True,
)
except HonchoAuthError:
# Let the outer dispatch's auth-specific handler render this.
raise
except Exception as e:
logger.warning("honcho_reasoning failed: %s", e)
return tool_error(
f"Honcho reasoning query failed ({e}). This is a backend "
"error, not an empty result — the peer may still have "
"relevant context. Slow dialectic calls at higher "
"reasoning levels can exceed the configured timeout; "
"consider a lower reasoning_level or raising the "
"'timeout' value in honcho.json."
)
# Update cadence tracker so auto-injection respects the gap after an explicit call
self._last_dialectic_turn = self._turn_count
return json.dumps({"result": result or "No result from Honcho."})
Expand Down Expand Up @@ -1607,13 +1672,29 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
return tool_error(f"Honcho {tool_name} failed: {e}")

def shutdown(self) -> None:
for t in (self._prefetch_thread, self._sync_thread):
for t in (self._prefetch_thread, self._sync_thread, getattr(self, "_memwrite_thread", None)):
if t and t.is_alive():
t.join(timeout=5.0)
# Flush any remaining messages
if self._manager and not (self._init_thread and self._init_thread.is_alive() and not self._session_initialized):
manager = self._manager
if manager and self._init_thread and self._init_thread.is_alive() and not self._session_initialized:
manager = None
# Honors saveMessages: false — skip persistence, but thread cleanup
# still runs: the session manager's async-writer thread must be
# joined either way so daemon threads aren't left blocked in httpx
# I/O during interpreter finalization.
if not getattr(self._config, "save_messages", True):
if manager:
try:
manager.stop_async_writer()
except Exception:
pass
return
if manager:
try:
self._manager.flush_all()
# manager.shutdown() = flush_all() + join the async-writer
# thread. Previously only flush_all() ran here, leaving the
# writer thread alive at exit.
manager.shutdown()
except Exception:
pass

Expand Down
7 changes: 5 additions & 2 deletions plugins/memory/honcho/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1110,8 +1110,11 @@ def _all_profile_host_configs() -> list[tuple[str, str, dict]]:
for p in profiles:
if p.name == "default":
continue
h = f"{HOST}.{p.name}"
results.append((p.name, h, hosts.get(h, {})))
h = profile_host_key(p.name)
# _host_block (not hosts.get) so legacy dot-form keys
# ("hermes.work") stay readable per the README's back-compat
# promise — the canonical key resolves first, legacy falls back.
results.append((p.name, h, _host_block(cfg, h)))

return results

Expand Down
Loading
Loading