Adds a built-in 'bunnny' skin preset with a hot-pink coquette palette:
- Hot pink (#FF3366) borders with Barbie-pink (#FF69B4) accents
- Lavender-blush (#FFF0F5) text on deep-plum (#2A0E1E) surfaces
- Coquette spinner verbs (sparkling, twirling, tying a little bow)
- Heart/sparkle/flower spinner faces (♡ ✧ ✿ ❀ ෆ)
- Heart (♡) prompt symbol and tool prefix
- (ノ◕ヮ◕)ノ*:・゚✧ kaomoji in welcome + help header
- Custom HERMES <3 banner_logo in pink gradient
- banner_hero of twin coquette bunnies holding paws, framed with
floating sparkles, hearts, and flowers to fill the banner width
Skin is cosmetic only — agent_name stays 'Hermes Agent'. Adds entry
to the skins.md docs table and ignores .venv/ in .gitignore.
Follow-ups to the salvaged #84009 commits:
- Add 'session_switch' to _RESET_END_REASONS: a reset continuation's
parent can be promoted to session_switch (resume the reset parent,
then switch away), which permanently hid pre-marker legacy children —
reopen-time stamping cannot rescue them because the parent is being
ended, not reopened. Probe-verified before/after.
- Share the legacy reset-child heuristic via _legacy_reset_child_sql()
so _RESET_CHILD_SQL and reopen_session()'s stamping UPDATE cannot
drift, and derive find_latest_gateway_session_for_peer's two recovery
fence literals from _RESET_END_REASONS_SQL (was a third hand-written
copy of the same set).
- Exclude reset children (marker + legacy shape) from the
resolve_resume_session_id forward walker: resuming a reset parent
could redirect into the post-reset conversation — the exact context
the user reset away. Regression tests cover both shapes plus the
walker's original compression-tip behavior; mutation-checked.
on_memory_write spawns a fire-and-forget daemon thread that was never
stored on self, so shutdown() couldn't join it — the exact problem the
PR fixes for the async writer thread. Store as self._memwrite_thread
and include it in the shutdown join loop.
Review follow-up for salvaged PR #83500.
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.
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>
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>
sync_turn called manager._flush_session() directly, which flushes
synchronously every turn no matter what writeFrequency says — the
"async", "session", and every-N-turns modes were dead configuration
on the main turn path. Route through save(), the dispatcher that
actually implements those modes.
Same bug class reported in #19650 (starship-s) and #72708 (Diaspar4u);
this takes the minimal one-line routing fix without their broader
lifecycle refactors.
Co-authored-by: starship-s <45587122+starship-s@users.noreply.github.com>
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.
The containment commit skipped the whole turn when either side was
empty, which would drop a real user message on interrupted or
tool-only turns. Keep the guard for fully-empty turns only and skip
empty sides individually inside the sync loop.
Salvages #67559 — original gated sync_turn/on_memory_write/on_session_end but missed shutdown(), whose flush_all() still persisted on exit. hermes-sweeper review (salvageability=high) flagged this as the one gap.
Guard sits after the worker-thread joins, not at the top: cleanup is independent of persistence, and a top-of-method return would leak _prefetch_thread/_sync_thread. Adds TestShutdown and clarifies the saveMessages=false README row.
Credit @Matroskin86 (original PR author).
The saveMessages knob has been parsed by HonchoClientConfig since its
introduction but was never consumed: sync_turn, on_memory_write and
on_session_end persisted to Honcho regardless. With saveMessages=false the
provider now never writes automatically (raw turns, memory-write conclusion
mirroring, session-end flush) while read/tools paths stay fully functional.
Guard uses getattr with a True default so legacy/injected configs keep the
old behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018giroL5zeMPnPxERxAxXHY
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.
Two silent-auth-failure paths from #36098 (also #66125):
- the local-URL guard only escaped the 'local' placeholder when the
HOST BLOCK had apiKey. A top-level apiKey in honcho.json — explicit
user intent, and what 'hermes honcho setup' writes for single-host
configs — was dropped on the floor, so AUTH_USE_AUTH self-hosts
401'd on every request. Now any explicit key in honcho.json (host
block or top level) is honored; only env-sourced keys are still
treated as likely-cloud and skipped for local URLs.
- named-profile host blocks do not inherit the default host's apiKey
(credential isolation is by design), but the failure was silent:
the profile ran unauthenticated and every tool said 'no context'.
Affirm isolation and warn loudly at config-resolution time instead,
the outcome #66125 proposed if inheritance is rejected.
_all_profile_host_configs() built per-profile host keys inline as
f"{HOST}.{profile}" ("hermes.work") while profile_host_key() — used by
honcho status/enable/sync and the runtime memory plugin — produces the
underscore form ("hermes_work"). The lookup always missed, so
'hermes honcho peers' showed "(not set)" / leaked the raw malformed key
into the AI-peer column for every non-default profile. Profile names
needing sanitization (dots/spaces) were doubly broken.
Verified live: with hosts["hermes_work"] populated, cmd_peers showed
'work ... hermes.work' before the fix and 'work ... hermes' after.
Tests: host keys match the writer form, sanitized profile names resolve,
peers output shows populated identities with no key leak, and clean
fallback for profiles without a block.
Salvage of #2757 by @teyrebaz33 — rebased onto current Honcho plugin layout.
Stray control characters (e.g. terminal escapes pasted into HONCHO_BASE_URL
or config baseUrl) are dropped with a warning so SDK construction cannot
crash startup on Invalid non-printable ASCII character errors.
Adds the regression test #37671 shipped without (dot-form legacy host
block must keep its explicit apiKey on local base_urls instead of
silently degrading to the 'local' placeholder and 401ing every write),
its inverse (no host key -> placeholder), and an invariant test pinning
the full resolution order the three adopted fixes compose into:
host block > endpoint.baseUrl > flat root > HONCHO_BASE_URL > HONCHO_URL.
_resolve_or_create_client() used a plain dict.get(config.host) that
fails for dot-form profile host keys (e.g. "hermes.profile_a") even
though the _host_block() helper defined nearby handles the legacy
dot-form → underscore-form fallback correctly. The result:
_host_has_key evaluates to False for every authenticating user,
so effective_api_key is set to "local" and every Honcho API call
returns 401 Invalid JWT — cascade failure into silent data loss
for cross-peer queries and message sync.
Fixes by calling the existing _host_block() helper instead of
reimplementing the direct lookup. Local variable renamed from
_host_block → _host_block_local to avoid shadowing the function.
Closes#37436
HonchoClientConfig.from_global_config() only consulted top-level
baseUrl / base_url / HONCHO_BASE_URL in ~/.honcho/config.json. The
Honcho SDK's native config format — and what Claude Desktop writes —
nests the URL at endpoint.baseUrl. Users with that config format had
their self-hosted Honcho container silently ignored: every honcho_*
call routed to https://api.honcho.dev with a workspace_id that does not
exist there, so tools returned empty data with no error anywhere.
Resolution order in from_global_config(), highest first:
1. endpoint.baseUrl (SDK-native, what Claude Desktop writes)
2. baseUrl / base_url (root-level, existing behavior)
3. HONCHO_BASE_URL (existing env var)
4. HONCHO_URL (the SDK's own env var, honcho/client.py:234)
HONCHO_URL is also read in from_env(). from_global_config() delegates to
from_env() whenever the config file is missing or unreadable, so an env
fallback wired into only one of the two would silently do nothing for
users with no config file.
A non-dict endpoint value falls through cleanly rather than raising.
Existing users are unaffected — the new sources are consulted only when
the existing ones resolve to None.
The INFO log for the base_url-unset case now says so explicitly instead
of printing only the host. The SDK resolves that case from its own
ENVIRONMENTS map (honcho/client.py:36-39), which for environment=
production means the public cloud; a self-hosted user whose config was
not picked up otherwise sees a healthy-looking startup line.
Closes#43800.
- _submit_background and _prefetch_provider: replace unreadable
(lambda inner: (lambda: ctx.run(inner)))(fn) with functools.partial(ctx.run, fn)
- from_env(): set config_path=resolve_config_path() so bound_config_path()
doesn't re-resolve from ContextVar on daemon threads (the exact bug
the PR fixes for from_global_config)
Review follow-ups for salvaged PR #83525.
The dict was written on every build and popped/cleared on eviction and
reset, but no read site remained — timeout staleness detection moved
into the cache key itself (a timeout change produces a new identity and
_slot_for evicts the old slot), which the isolation tests already pin.
Flagged in review by @spfcraze.
Drives the real resolution chain against real honcho.json files under
temp HERMES_HOMEs with the same ContextVar override the multiplexer and
dashboard use. Pins:
- #69123's minimal repro: two profile scopes get distinct clients with
their own workspaces and bearers
- the daemon-thread case: a bound config acquires its profile's client
from a thread that cannot see the ContextVar, and
spawn_context_thread carries the override where a plain Thread
(control test) does not
- credential identity: account swap on the same path/host creates a new
client and EVICTS the old slot; the OAuth fingerprint survives
access-token rotation but changes on re-auth; timeout changes rebuild
via the key
- provenance capture and its stability outside the profile scope
Two-profile repro shape from #69142 (NaMinhyeok); scenario set extends
the multiplex isolation tests from #81401 (angel12).
Co-authored-by: NaMinhyeok <NaMinhyeok@users.noreply.github.com>
Co-authored-by: angel12 <angel12@users.noreply.github.com>
MemoryManager dispatches provider sync_turn/queue_prefetch work on a
single-worker executor and hot prefetch on a plain thread. Neither
carried the caller's contextvars, so in multi-profile processes the
provider work ran outside the profile's ContextVar-scoped HERMES_HOME
override — any ambient resolution inside a provider landed on the
default profile.
Wrap the submitted callable and the prefetch thread target with
contextvars.copy_context().run, mirroring the gateway's
_run_in_executor_with_context pattern. Provider-agnostic: benefits
every external memory provider, not just Honcho.
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>
Replaces the process-wide first-config-wins client singleton with a
per-identity slot map. The singleton baked the first profile's
workspace_id and bearer into one shared client, so in multi-profile
processes (gateway multiplexer, dashboard, cron) every profile's
memory landed in whichever workspace initialized first — cross-tenant
bleed with no error (#69123, #74065).
cache key: (host, workspace, base_url, environment, provenance paths,
effective timeout, credential fingerprint). the fingerprint hashes the
OAuth REFRESH token (stable across in-place access-token rotation,
changes on re-auth/account switch) or the static api key — so
re-running 'hermes honcho setup' to switch accounts produces a new
identity instead of silently reusing the old account's client and
writing tenant B's data with tenant A's bearer, a hole per-path keys
alone cannot close.
same-identity slots with a different fingerprint or timeout are
EVICTED on replacement, so credential churn can't accumulate pinned
clients — the replaced client's pools close when its last holder
drops. timeout changes rebuild via the key (the old explicit staleness
check is subsumed). failed in-place OAuth rotation resets only the
client's own slot. reset_honcho_client() clears everything, preserving
test and oauth-flow re-login semantics.
per-config-identity caching was first proposed in #69142; the
provenance-key shape follows #81401. this implementation adds the
credential fingerprint and eviction they lacked.
Co-authored-by: NaMinhyeok <NaMinhyeok@users.noreply.github.com>
Co-authored-by: angel12 <angel12@users.noreply.github.com>
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>
Ports the test coverage from PR #29496 onto the salvaged implementation:
adapter-level serialization/workspace isolation/disconnect sealing, and
gateway-level ID-correlated concurrent duplicate tools plus the editable
text fallback when the native stream fails.
Adds platforms.slack.extra.native_task_cards: when enabled, live tool
calls render as Slack-native plan/task cards via chat.startStream /
chat.appendStream (task_display_mode: plan, task_update chunks) instead
of text/edit progress bubbles. ID-bearing tool_start/tool_complete
callbacks correlate concurrent same-name tool calls correctly; any
native API failure falls back to one continuously edited text update.
The stream is stopped exactly once when the turn finalizes.
Salvaged from PR #29496 onto current main (TurnRunner/TurnContext seam);
closes#29483.
Slack's Agents & AI Apps feature ships a native streaming surface that
renders a live-typing message instead of the edit-based progressive
updates the adapter used until now.
The adapter now implements the existing draft-streaming interface:
- supports_draft_streaming() opts in whenever the app is connected and
native streaming hasn't been detected as unavailable.
- send_draft() starts a stream on the first frame (chat.startStream,
anchored to the resolved thread_ts, with recipient_team_id/user_id
for channel streams) and appends only the delta on subsequent frames
(chat.appendStream is append-only). The consumer's trailing cursor
glyph is stripped before delta computation.
- Unlike Telegram drafts (ephemeral, replaced by a real sendMessage),
a Slack stream IS the final message. send() therefore intercepts the
turn-final delivery for a chat with an active stream whose streamed
text is a prefix of the final content, and seals it via
chat.stopStream with the remaining delta instead of posting a
duplicate. Rich Block Kit (when enabled) is applied to the sealed
message via chat_update, mirroring the finalize path in edit_message.
- Feature-gate errors from chat.startStream (not_allowed,
missing_scope, unknown_method, ...) are cached on the adapter so
subsequent runs skip straight to edit-based streaming with a single
warning naming the fix (enable Agents & AI Apps for the app);
transient errors only disable drafts for the current run via the
consumer's existing send_draft failure handling.
- Segment breaks (new draft_id) and disconnect() seal any open stream
so chats are never left with a dangling live-typing indicator.
No consumer or config changes: streaming.transport auto/draft now
lights up native streaming on Slack through the same interface
Telegram drafts use, and the edit-based path remains the fallback.
Follow-ups on salvaged #81419:
- Honor the plugins.enabled allow-list / plugins.disabled deny-list (same
opt-in contract as the general PluginManager) — installed != loaded.
- Skip callables that require arguments: general plugins share the
hermes_agent.plugins group with register(ctx) targets; invoking them
zero-arg would TypeError-spam every startup.
- Fix test docstring (entry points are discovered FIRST, lowest precedence)
and docs mechanism wording; document the config gate.
- New tests: opt-in gate, deny-list, register(ctx) never invoked.
E2E-verified with a real pip-built package against a temp HERMES_HOME.
Model-provider discovery was filesystem-only (bundled dir, $HERMES_HOME,
legacy providers/*.py). The general PluginManager scans the
hermes_agent.plugins entry-point group but deliberately does NOT import
kind=model-provider manifests (providers/ owns their lifecycle), so a
pip-installed provider was recorded yet never called register_provider() —
it never appeared in the picker, contradicting the 'Distribute via pip' docs.
Add a _discover_entry_point_providers() step that scans the
hermes_agent.plugins group and imports each entry, supporting both a
module:func callable target and a bare self-registering module target.
- Runs BEFORE filesystem plugins (lowest precedence): last-writer-wins means
bundled/$HERMES_HOME profiles always override a pip provider of the same
name, so a third-party package cannot hijack a first-party provider id.
- Per-entry failures are isolated (logged + skipped), so one broken package
can't break discovery.
- Docs updated to describe the real mechanism; tests cover callable + module
targets, failure isolation, and first-party precedence.
Spill files (terminal overflow, hook context, web_extract full text,
subagent summaries) were written with plain open()/write_text into
predictable directories. A pre-planted symlink at any of those paths
redirected the write onto an arbitrary user-owned file, and raw
pre-redaction terminal/hook spills landed world-readable under the
default umask.
New tools/spill_safety.py helpers create files with
O_CREAT|O_EXCL|O_NOFOLLOW (a link-shaped path fails the write instead of
following it) and overwrite via lstat-checked unlink + exclusive
re-create, so even the redaction rewrite cannot be diverted. Private
tier (0o700 dir / 0o600 file) covers raw terminal and hook spills;
cache/web and cache/delegation keep umask perms because those dirs are
bind-mounted into remote backends that must read them.
Pattern borrowed from DeepSeek Harness dsh-spill-local (MIT):
private root + exclusive owner-only opens for spill artifacts.
save_context_length() and _invalidate_cached_context_length() did an
unguarded read-modify-write into $HERMES_HOME/context_length_cache.yaml.
The plain `open(path, "w")` truncates the file before the dump runs. If
the process is killed mid-dump, the file is left empty or partial. The
next _load_context_cache() swallows the YAML error and returns {} —
silently wiping every persisted context length. A concurrent process
reading between truncate and dump-complete also sees a torn file.
After the cache is lost, every model re-probes the network, and when a
probe fails it falls back to the generic 256K default — so a user on a
1M-window model ends up with a wrong, short context window.
Hermes routinely runs several processes against one shared $HERMES_HOME
(a cron agent plus an interactive session, multiple gateway sessions),
so this is hit in normal use.
Switch both writers to the existing utils.atomic_yaml_write helper
(temp file + fsync + os.replace, symlink- and mode-preserving). The real
file is only ever swapped from a fully written temp file, so an
interrupted write leaves the previous cache intact and readers never see
a partial file. Matches the atomic-write pattern already used for
auth.json, config.yaml, and other persisted state.
Makes the persistent model context-length cache write crash-safe. The
old non-atomic write could truncate or wipe the entire cache on an
interrupted or concurrent write, which then forces models onto the wrong
fallback context window. The fix routes both cache writers through the
repo's atomic temp-file + os.replace helper.
N/A
- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 🔒 Security fix
- [ ] 📝 Documentation update
- [ ] ✅ Tests (adding or improving test coverage)
- [ ] ♻️ Refactor (no behavior change)
- [ ] 🎯 New skill (bundled or hub)
- `agent/model_metadata.py`: `save_context_length()` and
`_invalidate_cached_context_length()` now write via
`utils.atomic_yaml_write` instead of a truncating `open(path, "w")`.
Added the `atomic_yaml_write` import.
- `tests/agent/test_model_metadata.py`: added
`test_write_failure_leaves_existing_cache_intact` — simulates a crash
during the atomic swap and asserts the existing cache survives
byte-for-byte with no stray temp file.
1. `pytest tests/agent/test_model_metadata.py -q` — 98 pass, including
the new crash-safety test.
2. The new test seeds a valid cache, forces the swap step to raise, and
confirms the file is not truncated and no `.cache_*.tmp` is left.
3. `ruff check agent/model_metadata.py` passes.
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix
- [x] I've run the affected tests (`pytest tests/agent/test_model_metadata.py -q`) and they pass
- [x] I've added tests for my changes
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) — the helper uses os.replace, which is atomic on both
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
Reapply the non-positive context-length guards onto the post-history-replacement
mainline without carrying any stale branch history. save_context_length() now
refuses to persist length <= 0 (keeping upstream's normalized _context_cache_key),
and get_model_context_length() drops non-positive cache hits at the head of the
invalidation chain (Codex/Kimi/MiniMax/Grok branches become elif) so a poisoned
entry re-resolves instead of short-circuiting to 0.
Refresh of PR #25812; original head d62ed5eb92f057d8c707ba937b44f168f2df0677.
_resolve_thread_id() falls back to _last_inbound_thread[chat_id] when no
explicit thread is present. That fallback exists for interactive DMs, where
Google Chat spawns a fresh thread per top-level user message and the adapter
drops thread_id to keep the session key stable. It also fired for cron
deliveries, which carry job_id in their metadata but no thread: the output
landed as a reply inside the last inbound thread instead of starting a new
top-level message.
Bypass the _last_inbound_thread fallback when metadata has a job_id (i.e. the
message is an automated cron delivery), so cron output posts at top level
unless an explicit thread is requested.
_inherit_notify_subs (link_tasks / triage-decompose / create-parents path)
copied only platform/chat/thread/user/profile, dropping chat_type,
user_id_alt, delivery_mode, and delivery_metadata. A DM-originated child
completion then fell back to chat_type='group' and woke a fresh
group-scoped session instead of the originating DM; Telegram DM-topic subs
lost their persisted reply-fallback metadata (issue #73030).
Consolidates the duplicated inline inheritance block in create_task onto
the single-owner helper — one inheritance path, every column, ONE owner.
Sabotage-verified regression tests for both the link_tasks and
create-with-parents paths.
The NS-570 epoch stamp clears a drain marker that survives a machine
restart — but it assumes every drain-gated action ends in a restart. When
a maintenance action completes WITHOUT recreating the container and the
writer never cancels the drain, the orphaned marker still carries the
current epoch, so the 1s drain watcher honours it forever and the gateway
bounces every inbound message with the 'draining for a maintenance
action' text (observed in the field: a Hermes Cloud instance refused all
Telegram turns for ~3 days).
The marker already records requested_at; now the readers check it. A
marker older than DRAIN_REQUEST_MAX_AGE_SECONDS (1h) reads as stale in
drain_requested() and drain_notification_suppressed(), with a loud
warning log. Leniency mirrors the epoch check: a missing or unparseable
timestamp still reads as drain-active (fail-safe toward quiescing), and
a legitimately long drain keeps a sanctioned keep-alive — re-calling
write_drain_request() refreshes requested_at.
Fixes#85433
An empty/blank model id reaching get_model_context_length() can't be
meaningfully resolved — and it's worse than a miss: the endpoint
metadata fuzzy matcher ('model in key or key in model') is vacuously
true for "", so it matches an ARBITRARY catalog entry from the live
/v1/models response and returns whatever context length that entry
happens to have, persisting it under a junk '@<base_url>' cache key.
This started failing CI on main when the Nous portal catalog changed:
tests/run_agent/test_primary_runtime_restore.py constructs agents with
model='' against the live portal URL, the arbitrary match now lands on
a 32K entry, and init_agent raises the 64K-floor ValueError
(test_allowed_for_nous_anthropic_messages, red on every PR's slice).
Guard early: a blank model id falls back to DEFAULT_FALLBACK_CONTEXT
immediately, before any cache write or network probe.
Salvaged from #65515 by @whirmill (rebased onto current main; the
guard now sits after the malformed-base_url normalization added since,
and carries an explanatory comment for the fuzzy-match footgun).
Fixes the red slice on #85444, #85452 and every other open PR.
Co-authored-by: whirmill <5079591+whirmill@users.noreply.github.com>