Commit Graph

63 Commits

Author SHA1 Message Date
kshitijk4poor
d14734df1f fix(honcho): thread registry rides agent.memory_provider.spawn_context_thread
main folded the plugin's contextvars-inheriting thread wrapper into
agent.memory_provider.spawn_context_thread. The registry this stack adds for
join_plugin_threads keeps a thin client.spawn_context_thread that calls the core
spawner and records the thread under its owner; the four spawn sites import it
from client. main's inherit-profile test builds a real provider now that
_spawn_write is an instance method (the owner is what gets joined).
2026-09-13 19:05:39 +05:30
kshitijk4poor
f1273ed704 refactor(honcho): one reclaim-key helper, one cache-size constant, no dead writer starter
_SESSION_CACHE_MAX_SIZE was assigned twice with two comments describing one
constant. _retain_for_retry and _keep_until_flushed shared the has-unsynced /
current-owner / reclaim body and differed only in what to do when a newer object
owns the key; _reclaim_key_locked returns that owner and each caller keeps its
tail. _ensure_async_writer had no production caller (save() uses the _locked
form under _async_thread_lock); removed, tests retargeted, constructor comment
fixed. shutdown() sets _shutting_down under _async_thread_lock like the other
site so save()'s flag check and the enqueue cannot interleave with it.

tests/test_honcho_session_cache_bounds.py hoists its mid-file imports.
2026-09-13 19:05:39 +05:30
kshitijk4poor
b2ee58d24a fix(honcho): prune orphaned observation flags; share the local-platform set; drop test-shape defenses
A flush that rebuilds an evicted session's SDK session stores its observation
flags again, and the cap pass pruned every per-session dict but that one, so the
dict grew one entry per evicted-then-flushed session. The cap pass now prunes it
with the rest.

The peer-failure notice classified platforms with its own {"cli","tui","desktop"}
set, so an ACP session read the gateway wording ("do not suggest peerName"); it
now uses agent.coding_context.INTERACTIVE_CODING_PLATFORMS, which includes acp.

Three getattr/try-except guards existed only for test doubles (a bare __new__
provider, a SimpleNamespace config); the real types always carry the attribute.
Removed, and the two tests build real objects. An unset timeout resolves to the
client's 30s default, so the join-budget test expects 30, not the 5s floor.

Timing tests keep the >= 2s wall-clock bound the testing rules ask for.
2026-09-13 19:05:39 +05:30
kshitijk4poor
eed52456f6 fix(honcho): an author peer joins with its session's synced observation flags
main's _join_observation_flags returned the manager-wide booleans with a comment
saying #103889 would plug the per-session flags in here; this is that plug. The
two main-side tests that stubbed the old two-tuple _get_or_create_honcho_session
return move to the three-tuple.
2026-09-13 19:05:39 +05:30
Erosika
29113b24d8 fix(honcho): give the writer join and its drain the shutdown deadline
stop_async_writer bounded only the join and then drained the queue with no deadline, so a shutdown whose budget was already spent could still start uploads, and honcho-ai's add_messages has no per-call timeout. The join and the drain now share the shutdown deadline, no upload starts once it has passed, the writer skips its 2s retry once shutdown began, and shutdown logs one warning with the count left unsynced. The docstrings now say what the budget can do: stop new uploads and bound lock waits, while an upload already in flight runs to the client's HTTP timeout.
2026-09-13 19:05:39 +05:30
Erosika
3861167d63 fix(honcho): keep a failed late save reachable for flush_all after an eviction
A clean session can be evicted while its caller still holds it, and the caller's next save in turn mode flushed inline and never put the object back, so a failed upload left the batch nowhere flush_all() looks. Main reinserted after every flush. A failed save-time flush now reinserts the session when its key is free, or holds it in a retry list when a newer object owns the key, and flush_all() covers both; the collision path in _keep_until_flushed honors the flush result the same way.
2026-09-13 19:05:39 +05:30
Erosika
8523402db0 fix(honcho): bound the shutdown flush by the shutdown deadline
Provider shutdown handed the manager a remaining budget, but flush_all ran first with no deadline and blocked on each session's flush lock. An async upload still in flight held that lock, so shutdown waited the full HTTP timeout past its declared budget. flush_all and the queue drain now take the deadline, skip a session whose lock or budget is gone, and log one warning with the count of messages that stayed unsynced.
2026-09-13 19:05:39 +05:30
Erosika
d532d83eca refactor(honcho): trim duplicated tests and long docstrings
Parametrize the sessionStart, injection-log, dashboard user_id, unresolved-peer
and deferred-save tests that differed only in their inputs. Share the blocking
remote in the concurrent flush tests. Fold _as_flag onto a word table. Cut the
added docstrings to the what and the one non-obvious why.
2026-09-13 19:05:39 +05:30
Erosika
beab8b6f27 fix(honcho): keep observation flags across a flush rebuild, never orphan an evicted session, namespace dashboard logins
`_flush_session` discarded the observation flags when it rebuilt an evicted SDK session, and the
cached path returned none, so recall fell back to the config snapshot. Both paths now return and
store the flags. A deferred `save()` on a session the cap evicted puts it back in the cache, or
flushes it inline when a newer object owns the key. `save()` and `stop_async_writer()` share the
writer lock, and the writer drains its queue after the join, so a put that raced shutdown is
written. The trim after a flush runs under the cache lock. The shutdown join takes the remaining
budget instead of a fixed ten seconds.

The injection audit file is created owner-only, and `logging: "false"` reads as off. The desktop
passes `<provider>:<user id>` so a basic-auth alice and an OIDC alice are two peers. When a
gateway platform supplies no user id, the peer notice and tool error no longer recommend
peerName, which would merge every user of that gateway onto one peer. README documents
`injection.sessionStart`, `logging`, and what a dashboard login does to peer resolution.
2026-09-13 19:05:39 +05:30
Erosika
c3a5649aac fix(honcho): join every plugin thread on shutdown within one budget
provider shutdown joined the dialectic, sync and memwrite threads for 5s
each and then the async writer, but the session-init thread,
honcho-base-first and honcho-context-prefetch were never joined. any of
them still blocked in httpx when the interpreter finalized aborted the
process with SIGABRT 134 (#37632, #60616, #33485). the 5s join was also
shorter than the 30s http timeout a blocked call can hold (#33485).

spawn_context_thread now registers each thread under its owner (the
provider or the manager) in a weak registry, and shutdown joins every live
thread of both owners inside one deadline: at least 5s, or the configured
http timeout when longer. the manager refuses new prefetch threads once
shutdown began and flushes a late save() inline instead of respawning the
writer. threads that outlive the budget are named in a warning.

the sdk client exposes close() on its http pool. one client is shared by
every manager with the same identity in a gateway process, so a per-agent
shutdown cannot close it; close_honcho_clients() closes all pools and is
registered with atexit when the first client is built, the pattern the
hindsight and mem0 plugins use. follows #69070, #33701, #7627.
2026-09-13 19:05:39 +05:30
Erosika
0cb2977e84 fix(honcho): cap the manager caches and keep unsynced sessions out of eviction
the idle sweep from #71463 left three growth paths open. _peers_cache had no
bound at all, _session_observation (from #98941) grew one entry per session
id and kept orphans when an init failed after add_peers, and a burst of
distinct sessions inside one ttl window was not bounded. the sweep also
evicted sessions whose messages had not reached honcho yet, which in
"session" write mode drops the only copy, and _flush_session re-inserted an
evicted session into the cache with no observation flags, so recall for it
routed from the config snapshot instead of its server config.

_cache and _sessions_cache now cap at 128 entries and _peers_cache at 512,
evicting least recently used first (dict order, refreshed on every hit).
a session with unsynced messages is never evicted by the sweep or the cap.
_configure_session_peers returns the synced flags and get_or_create stores
them under _cache_lock next to the cache entry, so the observation dict can
hold no id the cache does not; eviction drops both. recall reads go through
_cached_session, which stamps updated_at, so a read-only session survives
the idle sweep. follows #71461, #71463, #98936.
2026-09-13 19:05:39 +05:30
Erosika
ff29c27003 fix(honcho): hold a per-session lock across select, send and the _synced flip
_flush_session read the unsynced messages, posted them, and only then set
_synced. in a short-lived run the async writer draining save()'s queue and
the exit-time flush_all() both saw the same batch unsynced, so every turn
of a one-shot run landed in honcho twice (#92458).

each HonchoSession now carries an RLock that _flush_session holds around
the whole select, send, mark sequence. the second flusher enters after the
first marked the batch and sends nothing; different sessions still flush
in parallel. the lock lives on the session object instead of a manager
dict keyed by session id (#86094, #92787): it needs no eviction, and two
flushers of one message list can never hold different locks. tests
adapted from #86094 and #92787.
2026-09-13 19:05:39 +05:30
Aleksei Ivanov
280ac22663 fix(honcho): bound local session cache growth
HonchoSession.messages grew forever -- add_message() appended every
turn and _flush_session() only marked entries synced, never trimmed
them. HonchoSessionManager's four caches (_cache, _peers_cache,
_sessions_cache, _context_cache) had no eviction path besides an
explicit /new reset. A long-lived channel that is never manually
reset accumulates both for the gateway's entire uptime.

Honcho is the durable source of truth (get_or_create() already
re-fetches history from Honcho on a cache miss), so both bounds are
safe: evicting an idle local entry only costs one extra Honcho
round-trip next time that key is used.

- Trim already-synced messages beyond a retention window right after
  a successful flush; unsynced messages are never touched.
- Add a rate-limited idle-TTL sweep triggered opportunistically from
  get_or_create(), so no new background task/watcher wiring is
  needed.

Fixes #71461
2026-09-13 19:05:39 +05:30
liuhao1024
a4939af48b fix(memory): scope honcho observation flags per session (#98936)
The four observation booleans on HonchoSessionManager were manager-wide
mutable state initialized from per-session server configs: every session
setup overwrote them, so the last session to initialize retuned recall
routing for all other sessions the manager serves. Store the server-synced
flags under each session's own id instead; the manager-level fields stay
as the config snapshot and sessions that never synced fall back to them.
2026-09-13 19:05:39 +05:30
teknium1
9ba5850e95 refactor(memory-plugins): background threads inherit the profile context; JSON sidecar reads and the holographic config.yaml write use core primitives
Six of eight memory providers spawned plain threading.Thread for prefetch/sync/
writer work. A plain thread starts with an EMPTY contextvars.Context, so under
multiplex profiles the worker resolved the DEFAULT profile's HERMES_HOME (and
fails closed on scoped secrets). honcho and hindsight had each noticed and
written their own copy_context() wrapper; core had a third in memory_manager.
One canonical pair now lives on the ABC module every provider already imports:
agent/memory_provider.py::ctx_bound / spawn_context_thread. memory_manager,
honcho, hindsight, mem0, retaindb, byterover, supermemory and openviking all use
it; the honcho and hindsight wrappers and memory_manager._ctx_bound are deleted.

Five "json.loads(path.read_text()) or {}" readers (mem0._read_mem0_json,
honcho client/oauth/cli _read_config, hindsight save_config/_load_config) fold
into utils.read_json_or_empty, the read half of every read-merge-atomic_json_write
sidecar store.

holographic.save_config was the only config.yaml writer in the tree that
bypassed hermes_cli.config.save_config: raw open("w") + yaml.dump with no config
lock, no managed-mode refusal, no atomic replace, and a swallowed exception. It
now calls save_config(..., merge_existing=True). Behavior change: a managed
install refuses the write (previously silently rewrote config.yaml); other
sections are deep-merged instead of round-tripped through a raw dump.

openviking._hermes_home_path guarded an impossible ImportError of
hermes_constants (the module already imports agent.*) with a ~/.hermes fallback
that is wrong on Windows and under profile overrides; it is replaced by
get_hermes_home() directly.

Tests: tests/plugins/memory/test_provider_threads_inherit_profile.py drives each
provider's real spawn path with a fake backend and asserts the thread sees the
spawner's HERMES_HOME override (sabotage: retaindb back on threading.Thread ->
red). tests/plugins/memory/test_holographic_save_config.py pins merge-with-
existing-sections and managed-mode refusal (sabotage: raw yaml.dump -> red).
2026-09-13 05:19:48 -07:00
Erosika
431cd9084b fix(honcho): bound the joined author peer memory by session count
_joined_author_peers kept an entry for every honcho session the manager ever wrote to. It now holds at most _SESSION_CACHE_MAX_SIZE sessions and drops the oldest past that, so a forgotten session's authors rejoin on their next write. A failed join no longer leaves an empty entry behind.
2026-09-10 10:45:57 -07:00
Erosika
bcac7e9465 fix(honcho): read an author join's observation flags through one manager method
The join read the manager-wide user_observe_me and user_observe_others directly. It now asks _join_observation_flags(honcho_session_id), which returns the same values today. #103889 stores the effective flags per session and replaces the body of that method.
2026-09-10 10:45:57 -07:00
Erosika
170589dd10 fix(honcho): every bot author gets its own peer or its turn is skipped
A gateway platform marks a bot sender with its raw user id and a bot flag, never a `bot:` id.
`resolve_author_peer_id` treated that author as a human, so `pinUserPeer` collapsed a bot onto
`peerName` and an unresolved peer opened the a2a session under the human's peer. The resolver now
takes `is_bot` and gives every bot its own peer. `sync_turn` skips the turn when no peer resolves
or when the peer equals this agent's `aiPeer`.

The a2a session key carries an eight-character digest of the author id, so two ids that sanitize
alike stay in separate sessions. During a bot-authored turn `honcho_conclude` and `honcho_profile`
refuse writes and the built-in memory mirror is skipped, because conclusions and cards describe
the human. The README paragraph on bot DMs now matches the code.
2026-09-10 10:45:57 -07:00
Erosika
f7d5ac3230 feat(honcho): write bot dms into their own a2a session
A DM relayed from another Hermes profile ran as a turn in the recipient's
Bot Chat session. sync_turn wrote the bot's words and the recipient's reply
into that session, and before per-author writes they landed under the
human's peer. The human's representation absorbed conversations the human
never had.

The turn context now marks such turns with scope a2a:<bot id>.
sync_turn routes a bot-authored turn into a separate Honcho session keyed
<session>:a2a:<sanitized bot id>, created with the sender bot as its user
peer, and never writes it into the human's session. The key is deterministic
so every turn from the same bot reaches the same session, and it stays
inside Honcho's 100 character session id limit. Recall still reads the
human's session only.

a2aSessions (host block, then root, default true) turns the routing on.
With it off, bot-authored turns are skipped. A bot turn that names no
author id is skipped as well, because nothing can key its session. Human
turns are unchanged.

get_or_create takes a user_peer_id override so the a2a session's roster is
the bot and the assistant, not the runtime human.
2026-09-10 10:45:57 -07:00
Erosika
6aff2fc65b feat(honcho): write each turn under its author's peer
The manager resolved one user peer in `get_or_create` and froze it onto the
session, then `_flush_session` chose between it and the assistant peer by
role. Every user turn in a shared session landed on that one peer, so the
first person to message the agent collected everyone else's facts — and a
Honcho conclusion, once derived, is not self-correcting.

`resolve_author_peer_id` maps the turn's author onto its own peer using the
alias-then-prefix order `_resolve_user_peer_id` already applies, so an
aliased account reaches the same peer whichever turn it wrote. `sync_turn`
resolves it before starting the write thread, so a following turn cannot
retag a queued write. `_flush_session` then writes each user message under
that peer.

A shared session's roster is open — people and other agents arrive after
the session exists — so `_author_peer_for_session` joins a peer when it
first writes instead of enumerating participants at init. Joins are
remembered per session, and a failed join still writes under the right
peer, losing only the observe config.

Three cases return None and keep the session's own peer: no author named,
the author IS the session's peer, and `pinPeerName` set — that flag is an
explicit request to unify identities, so it still collapses authors in a
shared chat.

An unnamed author stays unattributed rather than defaulting to the owner.
That preserves today's behavior for the transports that send no author, so
those turns still reach the session peer; #83500 owner-gated the memory-file
migration for the same reason.

Display names never become peer IDs — they are attacker-influenceable on
any platform where participants set their own name.
2026-09-10 10:45:57 -07:00
Teknium
2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:

    git revert <this sha>

removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.

What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
  so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
  tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
  relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)

Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
2026-09-03 17:13:22 -07:00
Teknium
ecf760db96 simplify(compat): plugins/memory — drop 15 re-exports + 1 alias, repoint 13 test callers
hindsight: drop _PORT_HEALTH_GRACE_ENV/_sanitize_bank_segment facade re-exports (tests -> .embedded/.settings).
honcho: drop 5 tool-schema re-exports, _credential_fingerprint (-> client_cache), _is_auth_error (-> session_auth),
and the _redact_tokens alias (callers renamed to redact_tokens).
openviking: drop 5 _setup re-exports; tests call openviking_module._setup.* directly.
2026-09-03 13:04:16 -07:00
Teknium
e83816a4d1 review-fix(comments): restore lost #NNNN rationale comments across non-test source (mechanical sweep, condensed, code unchanged)
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.
2026-09-03 09:44:26 -07:00
Teknium
def8c585a8 refactor(honcho): tighten provider tool handlers and session peer-config sync 2026-09-02 22:19:33 -07:00
Teknium
33f883219a refactor(honcho): compact client/session modules (no behavior change) 2026-09-02 21:36:44 -07:00
Teknium
64f430a051 refactor(plugins/memory): honcho — extract session context/auth/peers/migration, dialectic, client cache, tool schemas; unify _parse_* config helpers; table-driven CLI 2026-09-02 13:30:09 -07:00
Erosika
9e77d83354 fix(honcho): gate memory-file migration on the declared owner
The previous gate compared session.user_peer_id against a fresh
_resolve_user_peer_id() call on the same manager. Both values come from
the same resolver with the same inputs, so a non-owner triggering a new
session in a shared channel passed the check and received the owner's
MEMORY.md/USER.md under their peer.

The owner is now a config fact: _declared_owner_peer_id() returns the
sanitized peerName, and migration runs only when the session's user peer
is that peer. Without a declared peerName, migration runs only when no
runtime gateway identity is present (the single-operator CLI path).
Aliases still work: a platform ID mapped onto peerName resolves to the
owner peer before the comparison.

Tests now derive each session's user peer from the real resolver instead
of hand-picking mismatched ids, so the non-owner test fails against the
old gate.
2026-08-13 23:43:15 +05:30
Erosika
27021f5f84 fix(honcho): resolve migration owner gate through _resolve_user_peer_id
The owner gate from #82038 compared against config.peer_name directly,
which is None for most single-user setups — sanitizing None would raise
and the gate never accounted for pinned/runtime/aliased identities.
Resolve the owner the same way sessions do, and add the non-owner skip
regression test the original PR shipped without.

Co-authored-by: menhguin <menhguin@users.noreply.github.com>
2026-08-13 23:43:15 +05:30
carnie[bot]
7bad91c51d fix(honcho): skip memory-file migration on non-owner sessions (task #00000801)
migrate_memory_files() uploads USER.md/MEMORY.md with peer=user_peer — the
session's runtime user. In shared channels, a non-owner's new thread uploads
the owner's full profile under the NON-OWNER's peer; Honcho's deriver then
attributes the owner's psychometrics/medical/biography to that person. This
was the root contamination vector (55/70 contaminated sessions carried the
payload). Skip migration unless the session user is the configured owner.
SOUL.md unaffected (uploads under assistant peer).

Co-authored-by: Minh Nguyen <menhguin@users.noreply.github.com>
2026-08-13 23:43:15 +05:30
Erosika
9cfff1546d fix(honcho): join the session manager's async-writer thread on provider shutdown
Provider shutdown() only called manager.flush_all(), which drains the
queue but never joins the async-writer thread — manager.shutdown()
exists and nothing called it. The writer thread could still be blocked
in httpx I/O at interpreter exit (the #37632 crash class). Now
shutdown() calls manager.shutdown() (flush + join) when persistence is
enabled, and a new manager.stop_async_writer() (join only, no flush)
when saveMessages is false, so containment and clean teardown compose.
2026-08-13 23:43:15 +05:30
Erosika
c28c434706 fix(honcho): surface honcho_reasoning backend failures instead of 'No result'
dialectic_query collapsed every backend failure to an empty string, so
the explicit honcho_reasoning tool rendered timeouts, server errors,
and genuinely-empty answers identically as 'No result from Honcho.'
(#36098 issue 4). Operators debugging 'search works but reasoning does
not' were sent down representation/observation rabbit holes when the
real cause was a 30s timeout on a medium-reasoning dialectic call.

Add raise_errors to dialectic_query (default false — automatic
injection keeps its fail-quiet behavior and cadence backoff) and pass
it from the explicit tool call, returning a tool error that names the
failure and points at the timeout knob. Auth errors keep their
dedicated handler.
2026-08-13 23:43:15 +05:30
Erosika
671f9cbafa fix(honcho): propagate contextvars to all plugin background threads
Profile isolation is a ContextVar; plain threading.Thread targets start
with an empty context, so the plugin's nine daemon threads (session
init, prewarm, first-turn base/prefetch, prefetch, sync, memwrite,
async writer, context prefetch) resolved ambient state — config path,
active host, hermes home, oauth token paths — against the DEFAULT
profile whenever they ran under a routed profile's turn.

Adds spawn_context_thread(), which copies the caller's context at spawn
time so the thread sees the profile scope it was created under, and
routes every plugin thread spawn through it. Defense-in-depth under the
bound-config work: even ambient resolution on these threads now lands
on the right profile.

The copy_context approach follows the gateway's own
_run_in_executor_with_context pattern; #81401 applied it to the init
thread, this extends it to all nine spawns.

Co-authored-by: angel12 <angel12@users.noreply.github.com>
2026-08-13 23:43:15 +05:30
Erosika
8b7d0ed4c7 fix(honcho): bind config provenance so background threads stop resolving the wrong profile
Profile isolation in every multi-profile process (gateway multiplexer,
dashboard, cron) is a ContextVar (set_hermes_home_override) that
threading.Thread targets cannot see. The plugin's daemon threads —
async writer, prefetch, sync, first-turn, init — all funnel through
HonchoSessionManager.honcho, which called get_honcho_client() with NO
config, re-resolving resolve_config_path()/resolve_active_host() from
the ContextVar-blind thread context: every background memory access
landed on the DEFAULT profile. Worse, the OAuth paths did the same, so
a token refresh on a daemon thread could persist the rotated token
into the wrong profile's honcho.json, and a 401 recovery could burn
the wrong profile's single-use refresh token.

- HonchoClientConfig gains provenance (config_path, hermes_home)
  captured at resolution time inside the caller's profile scope, with
  bound_config_path() for consumers
- manager.honcho passes the bound config instead of re-resolving
- OAuth paths (_apply_fresh_oauth_token, _refresh_cached_oauth,
  _reauth_required, _force_reauth) use the bound path
- the honcho.json timeout memo becomes path-keyed instead of
  single-slot, so multi-profile processes stop thrashing it and
  returning profile A's timeout for profile B

Groundwork for per-identity client caching (#69123, #74065); the
provenance-field shape follows #81401.

Co-authored-by: angel12 <angel12@users.noreply.github.com>
2026-08-13 23:43:15 +05:30
kshitij
edfe4f5136 refactor(honcho): dedupe refresh-failure handling; harden exchange budget, dogpile cooldown, and rebuild race
Follow-ups from review of #80590:

- oauth.py: extract _rotate_and_persist() — the twin ~18-line
  OAuthRefreshError permanent/transient handling blocks in
  ensure_fresh_token and force_refresh_token were byte-identical
  except the log verb.
- oauth.py: cap the exchange cycle at _REFRESH_TOTAL_BUDGET_SECONDS
  (20s). The retry runs while holding the global refresh locks on the
  path to a memory call; a timed-out first attempt no longer earns a
  second full 15s exchange (~32s lock hold -> <=20s).
- oauth.py: transient-failure cooldown (_refresh_failure_at, 30s).
  Waiting threads and later turns fail open to the stale token instead
  of serializing their own full exchange cycles against an endpoint
  that just failed. Cleared on successful rotation and re-login.
- oauth.py: mtime-gate reauth_required()'s config read — the dead-grant
  state persists until re-login, and the verdict can only change when
  the config file is rewritten; drop the per-call read+parse.
- oauth.py: derive _TOKEN_VALUE_RE from ACCESS_TOKEN_PREFIX /
  REFRESH_TOKEN_PREFIX so a prefix change can't silently break
  redaction; promote redact_tokens to public (session.py imported the
  private name).
- session.py: fast path in _reauth_required — skip config-path
  resolution entirely while no grant is dead (runs before every SDK
  call).
- session.py: client-generation counter closes the fetch/store race in
  _sdk_session/_get_or_create_peer — an object resolved from the old
  client mid-rebuild is no longer cached (it would 401 forever and burn
  a token rotation per retry).
- __init__.py: drop the getattr/callable/except triple-guard in
  _pop_auth_notice; the manager is always None or HonchoSessionManager.

7 new tests (budget, cooldown x3, generation guard, fast path); all
mutation-checked (disabling each guard fails its test). honcho_plugin
293 passed; plugins/memory 285 passed; live E2E against a real HTTP
token endpoint re-verified.
2026-08-08 14:40:46 +05:30
Erosika
864035b241 fix(honcho): route every authenticated sdk call through one 401-recovery helper
_authed_call checks the dead-grant marker before calling, retries a
confirmed auth failure once after a forced refresh, and records the
failure for the one-time notice. Operations re-resolve their peer and
session objects inside the call, so a retry after a client rebuild no
longer reuses objects bound to the old transport. Tool handlers now
return an explicit auth error instead of an empty result, and non-auth
failures keep their fail-open behavior.
2026-08-08 14:40:46 +05:30
Erosika
da1f8779ef style(honcho): trim auth recovery comments to one line each 2026-08-08 14:40:46 +05:30
Erosika
b1414baa09 fix(honcho): stop classifying bare '401' digits as auth errors; redact session-side auth logs
_is_auth_error matched the substring '401' anywhere in an error string,
so a latency figure ('retry after 4010 ms'), a request id, or a
workspace name containing those digits classified as an auth failure.
A false positive calls _force_reauth, which runs a real token exchange;
the server rotates the refresh token on every exchange, and a lost
rotation response leaves Hermes holding a superseded token whose later
replay revokes the whole grant — the exact wedge this branch fixes.

The status attribute check (SDK AuthenticationError carries status=401)
does the real work and stays first. A concrete non-401 status now wins
over ambiguous text. The text fallback keeps only specific markers:
'invalid or expired access token', 'authentication failed' (not bare
'authentication', which also matches auth-infrastructure outage
messages), 'unauthorized', and '401' only with HTTP context ('HTTP
401', 'status 401'), never as a bare number. The classifier is biased
toward false negatives: a missed auth error costs one un-recovered
call, a false positive spends a rotation.

Also redacts token values in _record_auth_failure, _auth_error_message,
and the two retry warnings, matching oauth.py. The SDK's auth errors
carry no token values today, but this is the one credential path where
an upstream regression would leak silently.

Tests: the four false-positive strings stay non-auth, HTTP-context 401s
still match, a concrete 429 status beats 'authentication failed' text,
and the recorded failure plus notice redact token values.
2026-08-08 14:40:46 +05:30
Erosika
ecfc427b28 fix(honcho): skip memory calls while the oauth grant is dead
reauth_required() existed but nothing called it, so after a grant died
every dialectic fire and sync flush still sent a Honcho API call that
401ed. dialectic_query and _flush_session now check the dead-grant flag
first and skip the call: dialectic raises HonchoAuthError (exempt from
cadence backoff), sync returns False with the failure recorded so the
one-time notice still fires.

The check compares the on-disk refresh-token digest, so a re-login flips
it back with no network call and the next cadence resumes immediately.
Transient auth errors keep the existing force-refresh-and-retry path.

Four new tests: a dead grant issues no dialectic or sync call, and a
re-login resumes both without waiting.
2026-08-08 14:40:46 +05:30
Erosika
6ea01262fc fix(honcho): recover memory from mid-session oauth 401s and tell the user once
An expired access token could pause Honcho memory for hours with no
user-facing signal: ensure_fresh_token swallowed every exchange failure
and returned the stale token, no code handled a 401 from the Honcho API,
and each failed dialectic cycle widened the cadence backoff. Hypothesis
for the trigger (not confirmed): the refresh POST times out after the
server already rotated the token pair, Hermes keeps the old refresh
token, and the eventual replay lands outside the server's 60-second
rotation grace window, which revokes the whole grant.

- oauth: the exchange reads the token endpoint's error body instead of
  discarding it. invalid_grant and other permanent OAuth errors mark the
  grant dead so no code retries a revoked grant; transient failures retry
  once immediately, which keeps a replayed refresh token inside the grace
  window. Log lines redact token values.
- oauth: force_refresh_token() rotates the token now, ignoring local
  expiry, to recover from a server-side 401.
- session: dialectic_query and _flush_session treat a 401 as a trigger to
  force one token rotation and retry the call exactly once. A persistent
  auth failure raises HonchoAuthError (dialectic) or records the failure
  (sync) instead of being returned as an empty result.
- provider: injects a one-time notice into the memory context so the
  model tells the user memory is paused and 'hermes honcho setup'
  restores it. Auth failures no longer widen the dialectic cadence
  backoff.

New tests cover the exchange retry, invalid_grant terminality plus
re-login recovery, forced refresh, 401 retry on both the sync and
dialectic paths, the one-time notice, and the backoff exemption.
2026-08-08 14:40:46 +05:30
eapwrk
bd1a850fa2 fix(honcho): network-hermetic unit tests + lazy async writer start
Re-port of PR #67576:
- plugins/memory/honcho/session.py: start the async writer thread lazily on
  first enqueue via _ensure_async_writer (idempotent, lock-guarded) instead
  of eagerly in __init__; shutdown tolerates a never-started thread
- tests/honcho_plugin/conftest.py: package-wide socket guard so no honcho
  unit test can reach a live server
- tests/honcho_plugin/test_network_isolation.py: regression tests
- test_async_memory.py / test_oauth_flow.py: rebased hunks onto the pruned
  suite (pruned tests not resurrected)

Salvaged-from: #67576
Co-authored-by: eapwrk <eapwrk@gmail.com>
2026-07-29 18:55:10 -07:00
RainbowAndSun
9a887e7c5b fix(honcho): use _resolve_observer_target for user context in session context
_fetch_session_context was using user_peer_id directly as observer
when calling _fetch_peer_context, bypassing _resolve_observer_target.
This caused honcho_context to return empty data because the observer
perspective was wrong (user instead of hermes/assistant).

Fixed by resolving observer via _resolve_observer_target(session, 'user'),
consistent with all other call sites (get_peer_card, honcho_search, etc.).
2026-07-16 22:25:22 -07:00
Erosika
2ad6ab17e3 fix(honcho): enforce recall latency and budget contracts 2026-07-16 12:48:48 -07:00
vizi0uz
56816f4232 fix(honcho): stop clipping honcho_reasoning tool results to the injection budget
dialecticMaxChars (default 600) is documented as the budget for the dialectic
supplement auto-injected into the system prompt every turn — a small recurring
cost that is correct to bound tightly. But dialectic_query() applied that cap
unconditionally, so explicit honcho_reasoning tool calls — where the model
deliberately spends a turn asking for a synthesized answer — were silently
truncated mid-word to 600 chars with a trailing " …", no error surfaced. The
full answer is returned by Honcho server-side; the clip happens client-side.

The auto-injection path already has its own token-based budget (contextTokens,
enforced in prefetch() via _truncate_to_budget), so the char cap's real job is a
cheap always-on guardrail for that recurring injection. Explicit tool results
are already bounded server-side by Honcho's dialectic MAX_OUTPUT_TOKENS and don't
need the injection cap — sibling tools (honcho_search, honcho_context) don't
post-clip their results either.

Add apply_injection_cap (default True, preserving current behavior) to
dialectic_query(); the honcho_reasoning tool handler passes False so it returns
Honcho's full synthesized answer. Auto-injection is unchanged. Tests cover both
the capped injection path and the uncapped tool path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:48:48 -07:00
vizi0uz
f4669f34cf feat(honcho): add list mode to honcho_conclude so delete can resolve a real conclusion id
honcho_conclude's delete action was unreachable in practice: no tool ever
surfaced a real conclusion id for the model to pass as delete_id.
honcho_search only searches the separate Message resource space, and the
SDK's ConclusionScope.list()/.query() (which do return real Conclusion.id
values) were never wired into any tool.

Adds an optional list mode to honcho_conclude (query to search, omit to
browse recent conclusions), backed by a new
HonchoSessionManager.list_conclusions(). No new tool, no changes to the
create/delete signatures or their conclusions_of() routing.
2026-07-16 12:48:48 -07:00
k4z4n0v4
c1c59e3474 fix(honcho): make honcho_search do real cross-session message search
honcho_search routed through search_context() -> peer.context(search_query=),
which returns the peer's standing representation + card. The search_query arg
does not turn that endpoint into a search, so results were effectively
query-independent: the same representation blob regardless of the query. Factual
lookups ('what medication', 'which value did we pick') returned noise.

Rewire search_context() to call the workspace message-search endpoint
(Honcho.search) with a peer_perspective filter: RRF-ranked (hybrid semantic +
full-text) raw message excerpts spanning every session the peer was a member of,
across all authors, membership-time-scoped. This is the cross-session factual
recall primitive.

peer_perspective is chosen over the alternatives because it is the only scope
that is simultaneously (a) cross-session, (b) inclusive of assistant-authored
facts about the peer (peer-author search drops these, and they are a large part
of what you want to recall about yourself), and (c) privacy-scoped to the peer's
own sessions (plain workspace search leaks other peers' sessions).

- snippets are labeled by author so the model can tell user-stated facts from
  assistant-derived ones
- max_tokens is now an enforced budget (was accepted but meaningless)
- graceful fallback to peer-authored search if peer_perspective is unsupported
- query length clamped under the embedding input cap

Replaces 3 change-detector tests that asserted the old representation-dump
behavior with 4 that assert the message-search contract + fallback path.
2026-07-16 12:48:48 -07:00
Eri Barrett
ba9e3a491b feat(memory): Honcho OAuth connect — desktop and CLI flows + token refresh (#44335)
* feat(memory): OAuth token storage and refresh for the Honcho provider

* feat(memory): refresh the Honcho OAuth token in the client and session

* feat(memory): zero-CLI loopback OAuth authorization flow

* feat(memory): generic memory-provider OAuth connect endpoints

* feat(desktop): memory-provider OAuth connect link

* feat(memory): CLI OAuth sign-in with source-tagged authorize links

* fix(memory): IP-literal loopback redirect and consent config_path on the authorize link

* fix(memory): profile-scope the memory-provider OAuth endpoints

* refactor(desktop): generic memory-provider OAuth client functions

* docs(memory): trim OAuth module docstrings to the invariants

* docs(memory): document OAuth connect as an optional auth method

* fix(memory): send home-relative display path to consent, not the absolute path

* perf(memory): cache OAuth token expiry in memory to skip the hot-path disk read

* fix(memory): log OAuth refresh failures at warning, not debug

* feat(memory): fall back to an OS-assigned loopback port when 8765 is taken

* test(memory): cover the desktop Connect launcher, status, and provider dispatch

* fix(desktop): keep the memory-provider dropdown one size regardless of connect state

* fix(desktop): move the memory connect link to the description line, leaving the dropdown untouched

* refactor(memory): move OAuth connect routes out of web_server into a memory-layer router

* refactor(desktop): import MemoryConnect directly, drop the single-export barrel

* fix(memory): launch CLI OAuth sign-in right after the auth choice, not after the wizard

* fix(desktop): auto-clear the OAuth error state instead of leaving it sticky

* test(honcho): isolate auth-method prompt from deployment-shape wizard tests

main's wizard suite scripts the cloud prompts without the OAuth auth-method step; auto-answer it in the shared helper so the answer lists stay shape-only.

* docs(honcho): document query-adaptive reasoning level (reasoningHeuristic)

README never mentioned reasoningHeuristic and listed reasoningLevelCap as an orphaned cap with the wrong default (— vs "high"). Add the query-adaptive scaling note + the reasoningHeuristic/reasoningLevelCap rows (grouped under Dialectic & Reasoning), matching the wording already on the hosted honcho.md page, and add a pointer from the memory-providers overview.

* fix(honcho): default the CLI peer prompt to the OAuth consent name

The CLI runs the grant with apply_config=False, so the peerName the user just entered at consent was dropped and the wizard's 'Your name' prompt fell back to $USER. Surface it as a transient OAuthCredential.consent_peer_name (set even when config isn't merged) and seed the prompt default from it.

* feat(honcho): split OAuth client_id by surface (cli=hermes-agent, desktop=hermes-desktop)

resolve_endpoints now picks the client_id from the initiating surface and
threads it through authorize -> token exchange -> persisted grant -> refresh,
so the CLI and desktop register as distinct OAuth clients. Surface-specific
env overrides (HONCHO_OAUTH_CLIENT_ID_CLI/_DESKTOP) win over the generic
HONCHO_OAUTH_CLIENT_ID, which still overrides every surface.

* feat(honcho): show OAuth vs API key in status; detect existing OAuth in setup

status now prints 'Auth: OAuth (clientId, token valid Xm/expired)' instead of
masking the OAuth access token as a generic API key; setup notes an existing
OAuth grant when re-run.

* docs(honcho): drop 'shared pool' wording from unified observation mode help

* fix(honcho): cross-process lock around OAuth refresh to prevent grant revocation

The in-process threading lock can't stop a sibling process (another profile or
the desktop app sharing honcho.json) from replaying the single-use refresh
token and tripping reuse-detection, which revokes the whole grant. Guard the
read-refresh-persist section with an OS file lock on <config>.lock so only one
process rotates at a time; the others re-read the freshly-persisted token.
Best-effort: platforms without flock degrade to in-process serialization.

* refactor(honcho): one OAuth client (hermes-agent) for all surfaces

Collapse the per-surface client_id split. CLI and desktop now use a single
client_id (hermes-agent); consent branding/UI still adapt via the source query
param. One grant identity means no clientId-vs-refresh-token desync that could
get the grant revoked. HONCHO_OAUTH_CLIENT_ID still overrides for self-hosting.

* fix(honcho): per-session resolves to session_id, never remapped by title

Reorder resolve_session_name so stable identifiers win over labels: gateway
per-chat key first, then the per-session session_id, then the cwd map / title.
A (possibly auto-generated) title can no longer remap a live per-session
conversation onto a second Honcho session mid-stream — fixes the desktop, which
is per-conversation via session_id. Consequence: a gateway's per-chat key now
also wins over a title (titles never remap a stable id).
2026-06-22 19:16:47 -05:00
Erosika
c89393b711 chore(honcho): trim peer-card fallback comment 2026-05-27 10:49:33 -07:00
Dora (kyra-nest)
bcae3fcc4e fix(honcho): align user context peer perspective
Use the shared observer/target resolver for session context so peer='user' and explicit configured peer IDs query Honcho from the same assistant-observed perspective when allowed. Add regression coverage for user alias, explicit peer, and self-observer fallback.
2026-05-27 10:49:33 -07:00
David Doan
1800a1c796 fix(honcho): align peer-card read and write paths
honcho_profile(peer="user") returned an empty card even when Honcho
held a populated peer card for the user. Two independent bugs combined
to produce the symptom:

1. Read path: get_peer_card() called _fetch_peer_card(observer, target=user),
   which hits GET /peers/{observer}/card?target={user} — the observer's local
   card of the user. On self-hosted Honcho v3 this slot is empty unless writes
   also use it. The peer card lives on the user peer itself
   (GET /peers/{user}/card). Add a fallback: when the observer-target slot is
   empty and a target exists, retry against the target peer's own card.

2. Write path: set_peer_card() resolved only the target peer and called
   user_peer.set_card(card). The read path uses the assistant peer as
   observer, so writes and reads addressed different Honcho card scopes.
   Align set_peer_card() with _resolve_observer_target() so writes go to
   assistant_peer.set_card(card, target=user_peer_id), matching the read.

Both paths now use the same observer/target resolution, and the read
path additionally falls back to the target's own card for compatibility
with deployments where cards were written directly to the peer.

Closes: related to #13375, #17124, #20729
2026-05-27 10:49:33 -07:00
mavrickdeveloper
30b391ab36 Avoid Honcho runtime peer collisions
(cherry picked from commit 4ae3c1a22894fdf753603d6d3fc13a319e653a85)
2026-05-27 10:49:33 -07:00