Reference-grounded image provider over the OpenRouter-compatible
chat-completions image protocol (Gemini Flash Image et al.). Nous Portal
proxies OpenRouter, so one provider serves both — giving pet generation a
reference-capable backend beyond OpenAI gpt-image.
Salvage of PR #48927 by @ehz0ah, which consolidates OpenViking recall
work from #41706 (@huangxun375-stack), #33260, #49975, and #32444.
Replaces stale background post-turn prefetch warming with synchronous
current-query recall. The old queue_prefetch warmed the PREVIOUS user
message while turn-start recall consumed the CURRENT one, so injected
context was always about the wrong topic.
Changes:
- prefetch() now does session-aware /api/v1/search/search with the
current query, falls back to /api/v1/search/find on failure
- Contract-safe payloads: limit, score_threshold, context_type,
session_id — no top_k, no search-body mode, no target_uri
- L2 content reads for items with level=2 or empty abstracts, capped
at full_read_limit (default 2)
- Local ranking (score + query-token overlap + leaf boost), dedup,
score threshold, and injected-char budget
- queue_prefetch() is now a no-op (background warming removed)
- Additive batched viking_read: uris param accepts up to 3 URIs
- Per-request timeout support on _VikingClient.get/post/delete
- Removes stale _prefetch_result/_prefetch_thread/_prefetch_generation
state and _invalidate_prefetch_state()
- Strengthened system_prompt_block guidance
Salvage follow-up fixes:
- Expose all 8 recall config knobs in get_config_schema() (PR #48927
had removed them; #41706 correctly exposed them). Env vars remain
as internal mechanism but are now visible in setup wizard.
- Lower default timeout 8s→4s, request_timeout 6s→3s, full_read_limit
3→2 to reduce per-turn blocking latency.
Co-authored-by: Hao Zhe <haozhe4547@gmail.com>
Co-authored-by: Eurekaxun <eurekaxun@163.com>
spectrum-ts routes stream telemetry through @photon-ai/otel's createLogger,
which sends severity>=ERROR to console.error and WARN/INFO to console.log.
The two lines the health monitor keys off land on different channels:
log.error("stream persistently failing") -> console.error (caught), but
log.warn("stream interrupted; reconnecting") -> console.log (was missed).
The original interception patched console.error only, so the recovering->
degraded escalation counter never saw the interrupt bursts that are the
primary silent-inbound symptom. Verified live against spectrum-ts 3.1.0 +
@photon-ai/otel: 3 real log.warn('stream interrupted') calls now escalate
to degraded -> process.exit(75) -> adapter reconnect.
Adds a shared classifyStreamLog() fed by both console.error and console.log,
plus a regression test asserting both channels are intercepted.
Map Hermes xhigh→max to unlock DeepSeek V4's 'Max thinking' tier
through Ollama Cloud's OpenAI-compatible /v1/chat/completions endpoint.
low/medium/high pass through unchanged; disabled/none suppress
reasoning entirely.
Empirically confirmed: reasoning_effort:max produces ~2.5× more
thinking tokens than high on deepseek-v4-pro:cloud (1576 vs 642).
Mirror built-in memory writes to external providers only after the native memory tool succeeds and is not staged for approval. Keep OpenViking's built-in memory mirroring add-only, since Hermes native memory entries do not yet have stable OpenViking file URIs for replace/remove.
Add a narrow viking_forget tool for exact user memory file deletion and document the current OpenViking write/delete behavior.
* fix: update to version 3 endpoints and adding update and delete tool
* chore: removing the test md file
* fix: prevent circuit breaker on client errors in Mem0 provider
* chore: add telemetry for platform version
* feat: add OSS mode support to Mem0 memory provider
* chore: bump mem0ai dependency to >=2.0.1 in memory plugin
* refactor: enhance dependency checks and embedder config in mem0 backend
* refactor: adjust fact storage message for OSS mode
* refactor: expand user paths, add collection recreation on dimension change for Qdrant
* fix(mem0): make MEM0_USER_ID override gateway-native ids and tag writes with channel
When MEM0_USER_ID was configured (env or mem0.json), the gateway-native id
from kwargs (Telegram numeric id, Discord snowflake, ...) still won, so the
same human ended up under different user_ids per channel and memories never
merged across CLI / Telegram / Slack / Discord. Mirrors openclaw's cfg.userId
pattern: configured override wins, gateway-native id is the fallback.
The legacy "hermes-user" placeholder default written by the setup wizard is
treated as unset to avoid silently bucketing every gateway user together.
Also tag every write with metadata.channel (cli/telegram/discord/...) so the
dashboard can offer per-channel filtered views without coupling identity to
the channel; document the read/write filter asymmetry as intentional
(reads scope to user_id only for cross-agent recall).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: improve Mem0 memory provider backend, pagination, config, and error handling
* refactor: update mem0 telemetry code, docs, and bump version
* fix(mem0): make get_config_schema() return unified schema with mode-aware required flag
Schema always includes api_key field so picker shows "API key / local" for
both modes. In OSS mode api_key.required=False so status won't mislead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: improve mem0 telemetry, add env var key and OSS mode detection
* chore: bump mem0ai lower bound to 2.0.4 (latest SDK release)
* refactor: set telemetry sample rate to 1.0 and update docs for opt‑out
* fix(mem0): resolve 15 correctness, thread-safety, and resource bugs
Thread safety:
- Protect circuit breaker counters with _breaker_lock (race between
prefetch/sync daemon threads and main thread)
- Wrap sync_turn thread creation in _sync_lock; skip if previous sync
is still alive after 5 s join to prevent duplicate memory ingestion
- Guard _schedule_flush timer creation under _queue_lock (TOCTOU race)
- Capture local `backend` reference in prefetch/sync closures so
shutdown() nulling self._backend cannot crash in-flight threads
Correctness:
- Fix bool("false")==True for rerank param; parse string values explicitly
- Guard page/top_k with max(1,...) and move int() inside try blocks
- Fix fact_count=0 always in OSS mode (Memory.add returns list, not dict)
- Fix prefetch() not clearing result when thread still alive after timeout
- Fix atexit.register accumulating on repeated initialize() calls
Backend / setup:
- Handle Qdrant named-vector collections in _recreate_collection_if_dims_changed
(vectors is a dict; .size access raised AttributeError, swallowed silently)
- Wrap QdrantClient and psycopg2 conn/cursor in try/finally to prevent leaks
- Resolve ollama_bin at top of _ensure_ollama; use it for ollama pull
- Fix embedder key lookup when LLM provider has no env_var (e.g. ollama)
Also: remove _telemetry_enabled cache (env var check is cheap), bump
required mem0ai to >=2.0.7, minor README wording fix.
* fix(mem0): fix brittle qdrant path test + add telemetry sample-rate docs
- Replace generator-throw lambda with a proper def in
test_qdrant_path_not_writable; use tmp_path instead of a hardcoded
/nonexistent path so the test is root-safe
- Add MEM0_TELEMETRY_SAMPLE_RATE to memory-providers.md (was only
in the plugin README, not the user-guide docs)
* revert: remove MEM0_TELEMETRY_SAMPLE_RATE from user-guide docs
* refactor: remove telemetry from mem0 plugin and update documentation
* fix(mem0): set stdin=DEVNULL on setup subprocess calls
The TUI stdin guard (scripts/check_subprocess_stdin.py) requires every
subprocess call in plugin code to set stdin= so it can't inherit the
gateway's JSON-RPC stdin fd. Muzzle the docker/ollama calls in the OSS
setup wizard with stdin=subprocess.DEVNULL (none need interactive input).
Also covers the docker-inspect call the linter's regex misses.
---------
Co-authored-by: chaithanyak42 <chaithanya.kumar42a@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On resource-contended hosts the embedded Hindsight daemon can exceed a
single 2s /health check; upstream then waits a grace window before
treating it as stale and killing+restarting it (hindsight-embed reads
HINDSIGHT_EMBED_PORT_HEALTH_GRACE_TIMEOUT, default 30s, into a
module-level constant at import time). Users on busy boxes had no
Hermes-side way to raise it short of hand-setting an env var.
Add a 'port_health_grace_timeout' config.json option to the Hindsight
plugin. When set, initialize() exports it to the process env BEFORE
daemon_embed_manager is imported (the import-time read is the contract).
setdefault() so an explicit operator env override always wins. Exposed
in 'hermes memory setup' for local_embedded mode.
Follow-up to #50308 / issue #13125 comment thread.
Follow-up for salvaged PR #50256. Unit tests for the three behaviors:
retryable classification of Envoy/sidecar overflow strings, per-chat typing
cooldown with stop_typing reset, and the _supervise_sidecar crash-detection
path that raises a retryable fatal (and the clean-shutdown no-op).
PostgreSQL's initdb refuses to run as root, so the embedded Hindsight
daemon could never initialize its data directory under root. The
daemon-start thread would fail, retry, and loop forever — each cycle
reloading embedding models (~958MB RAM, ~33% CPU) with no user-visible
error, leaving Hermes sluggish on a common VPS/cloud root setup.
initialize() now detects root (os.geteuid() == 0) before spawning the
daemon thread, disables local_embedded mode, and surfaces a clear
warning to both the log and the terminal so the user knows to run as a
non-root user or switch to cloud / local_external mode.
Closes#13125.
Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
The raft platform plugin's check_raft_requirements() logged a WARNING every
time it returned False. Since check_fn is called on every load_gateway_config()
(~every 10s during normal gateway operation), users who don't have the raft
CLI installed get their logs flooded with no way to suppress it — hermes plugins
disable doesn't work for bundled platform plugins, and platforms.raft.enabled:
false doesn't gate the check_fn call.
Fix: make check_raft_requirements() a silent predicate (return True/False
only, no logging), matching the convention documented and used by other
platform adapters (e.g. teams/adapter.py). The caller in
gateway/platform_registry.py create_adapter() already emits its own warning
when requirements aren't met and an adapter is actually requested — that's the
correct place for a user-facing warning (fires once per connect attempt, not
once per config load).
Fixes#49234
The prior assertion `all("turn1" in k or "turn2" in k for k in keys)` was
weak on two counts: it passes vacuously when keys is empty (a regression
that lost all state would slip through), and after turn 2 finalizes only
turn 1 lingers, so it only ever inspected turn 1 anyway. Replace it with an
exact check that one key survives, it is turn 1, and turn 2 never merged
into it — the real isolation invariant the test name claims.
Scoping the trace key by turn_id (the prior commit) fixed cross-turn
collisions but introduced a slow leak: _finish_trace only pops a key when a
turn ends cleanly (final response has content and no tool calls), so any
turn that is interrupted, ends on a tool call, or has empty final content
now leaves its uniquely-keyed entry in _TRACE_STATE forever. Previously the
constant per-session key was overwritten by the next turn, capping growth at
~1 entry per session.
Add an LRU cap (_MAX_TRACE_STATE) enforced by _evict_stale_locked, called
under _STATE_LOCK immediately before each insert. It evicts the
least-recently-updated entries (using the previously-dead last_updated_at
field) and ends their root span so nothing dangles. Regression test drives
50 non-finalizing turns against a cap of 8 and asserts the dict stays bounded
with the most-recent turns surviving.
The turn- and api-scoped branches each repeated the same
task/session/thread fallback ladder with only the infix differing. Extract
the shared prefix into _scope_prefix so a future scope dimension touches one
ladder instead of three. The legacy branch still returns a bare task_id (not
the task: prefix) for backward compatibility, so it stays separate.
Output key strings are unchanged; a new test pins them across every
task/session/turn/api combination since the keys are matched across hooks
and any drift would silently break trace finalization.
The PR added helper-level tests for _trace_key but nothing exercised the
keys through the real hooks. This adds TestTurnTraceIsolation, which drives
on_pre_llm_request / on_post_llm_call across two turns of one gateway
session (task_id == session_id, unique turn_id, api_call_count reset per
turn) and asserts each turn opens its own root trace when the first turn
fails to finalize (tool-only final step). This test fails on the pre-fix
code (only one trace opened, turn 2 absorbed into turn 1) and passes with
the scoping fix.
Also pins the turn_id-over-api_request_id key precedence: the turn-scoped
post_llm_call carries no api_request_id, so it must still resolve to the
same key as the request-scoped hooks or finalization breaks.
Resolves conflicts from the OpenViking churn that merged after #32445 was
opened (#48042/#47662 session-switch + write hardening, #47311/#47973):
- plugins/memory/openviking/__init__.py: keep both __init__ field groups
(the PR's _runtime_start_* alongside main's _prefetch_threads/_shutting_down).
- tests/plugins/memory/test_openviking_provider.py: keep BOTH the PR's new
setup-validation tests and main's session-switch/concurrency tests (disjoint
additions to the same region).
Two fixes layered while reconciling (contributor work otherwise preserved):
- Restore the merged tenant-header contract (#22414/#21232). The PR had changed
_VikingClient defaults to '' and made empty account/user OMIT the tenant
headers; main's contract is that empty falls back to 'default' and the
X-OpenViking-Account/User headers are ALWAYS sent (ROOT API keys need them).
Reverted the constructor to 'account or os.environ.get(..., "default")' and
updated the two PR tests that asserted the omit-when-empty behavior.
- Close a secret-file TOCTOU in the setup writers. _write_env_vars and
_write_ovcli_config wrote the api_key/root_api_key file and chmod 0600
AFTERWARD, leaving a world-readable window on newly-created files. Added
_precreate_secret_file() to create with 0600 before any secret bytes land.
Phase 4E (E.1 + E.2). The inbound side of Chronos: NAS POSTs the agent when a
one-shot fires; the agent verifies a NAS-minted JWT and runs the job.
E.1 — plugins/cron/chronos/verify.py:
- verify_nas_fire_token(token, expected_audience, jwks_or_key, issuer): verifies
signature against the NAS JWKS (RS/ES family; symmetric rejected), aud == this
agent, exp/nbf, iss, and purpose == "cron_fire" (so a general agent JWT can't
be replayed against the fire endpoint). Returns claims or None; never raises.
Crypto delegated to PyJWT[crypto] (already a declared dep) — no hand-rolled
JWT, no new dependency. No key configured → refuse (never unsigned-decode a
security boundary).
- get_fire_verifier(): pluggable indirection so the DQ-4 escape hatch
(direct per-job cron-key) can swap in with no handler change.
E.2 — gateway/platforms/api_server.py:
- POST /api/cron/fire (registered only when _CRON_AVAILABLE). Authenticated by
the NAS-JWT via get_fire_verifier() — NOT API_SERVER_KEY (NAS holds no API
key; this is the only inbound that triggers remote job execution, so it gets
its own purpose-scoped check). Verifier args come from cron.chronos.* config.
401 on bad/missing/forged token. 400 on missing job_id. On success: 202 +
fire_due runs in the background (so a long agent turn never trips NAS's HTTP
timeout); the store CAS claim inside fire_due de-dupes a scheduler retry.
Tests:
- test_chronos_verify (11): REAL RS256 signing — valid→claims, wrong-aud,
missing/wrong purpose, expired, wrong-iss, tampered-signature (attacker key),
no-key-refuse, empty-token, JWKS-URL key resolution, get_fire_verifier.
- test_cron_fire_webhook (5): valid→202+fire, invalid→401+no-fire, missing
token→401, missing job_id→400, and fire path does NOT require API_SERVER_KEY.
api_server regression suites (214) green.
E.3 (NAS endpoints) is a separate cross-repo PR; the wire contract lands next
(docs/chronos-managed-cron-contract.md).
Phase 4D. The first non-default CronScheduler: plugins/cron/chronos/. Inert
unless cron.provider=chronos; resolve_cron_scheduler falls back to the built-in
if unavailable, so cron never loses its trigger.
Files:
- chronos/__init__.py — ChronosCronScheduler + register(ctx).
* is_available(): config-only, NO network (portal_url + callback_url + a
stored Nous access token via get_provider_auth_state). Returns False →
resolver falls back to built-in.
* start(): reconcile() then RETURN — no blocking loop, no 60s wake (DQ-1:
this is what makes scale-to-zero real; the machine wakes only on a
NAS→agent fire).
* _arm_one_shot(job): POST NAS provision {job_id, fire_at, agent_callback_url,
dedup_key=job_id:fire_at}. Agent owns the time → sub-minute fires survive
(no scheduler 1-minute floor).
* reconcile(): converge NAS arms toward jobs.json — arm missing/changed-time,
cancel orphaned, skip paused. Cold process rebuilds from jobs.json +
idempotent dedup_key.
* on_jobs_changed(): reconcile (re-arm/cancel the affected one-shot).
* fire_due(): ABC default (CAS claim + run_one_job) THEN re-arm the next
one-shot. Job gone (one-shot done / repeat-N exhausted) → no re-arm.
- chronos/_nas_client.py — thin HTTP wrapper for provision/cancel/list using
the agent's existing refresh-aware Nous token (resolve_nous_access_token).
Names no scheduler vendor; holds no scheduler creds.
- chronos/plugin.yaml — discovery metadata.
INVARIANT: zero "qstash"/"upstash" hits in plugins/cron, gateway, hermes_cli,
website/docs — the external scheduler is a NAS-internal detail, never named
agent-side.
Tests (13, all NAS mocked, zero network): is_available off-without-config +
on-with-config + makes-no-network; arm payload incl. sub-minute + noop without
next_run; reconcile arms-all / cancels-orphan / skips-paused / skips-already-
armed; fire_due re-arms next / no re-arm when job gone / no re-arm when claim
lost.
* fix(photon): preserve text in mixed iMessage attachments
When an iMessage bubble carried both text and an attachment, spectrum-ts'
inbound mapper returned only buildAttachmentMessage(...), dropping the user's
typed text before Hermes could see it. The Photon adapter then had no 'group'
content path, so the text was lost entirely.
- adapter.py: handle a new 'group' content type that flattens text + attachment
items, preserving the typed text alongside cached media (extracted shared
_normalize_binary_payload helper).
- sidecar: emit 'group' content in normalizeContent, and ship
patch-spectrum-mixed-attachments.mjs which patches spectrum-ts' pinned mapper
(at npm postinstall AND at sidecar startup, so existing installs self-heal).
Windows robustness fixes on top of the original PR:
- The patcher's CLI guard used 'import.meta.url === file://${argv[1]}', which
never matches on Windows (file:/// + drive letter) — it silently no-opped.
Switched to pathToFileURL(argv[1]).href.
- The patcher matched \n-joined strings, so a CRLF checkout (Windows git
autocrlf) defeated every replacement. It now normalizes CRLF->LF for matching
and restores the original EOL style on write.
Co-authored-by: Yuhang Lin <yuhanglin@YuhangdeMac-mini.local>
* chore: map YuhangLin contributor email for attribution (#46513)
---------
Co-authored-by: Yuhang Lin <yuhanglin@YuhangdeMac-mini.local>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
Follow-up hardening on @ehz0ah / @harshitAgr's session-switch work (#28296):
- on_session_switch no longer runs the old-session writer-drain + pending-token
GET + commit POST inline on the caller's command thread. /new, /branch,
/resume, /undo call it synchronously, so a slow drain (up to 10s) or wedged
commit blocked the user-facing command — the same hazard #41945 fixed for
end-of-turn sync. State now rotates synchronously (cheap) and the old-session
commit is offloaded to a daemon finalizer (generalized _finalize_session_async).
- Guard the (_session_id, _turn_count) pair with _session_state_lock: sync_turn
runs on the memory-manager executor thread while the session hooks run on the
command thread, so the snapshot+reset vs increment was a cross-thread race.
- _session_needs_commit checks the committed-session guard BEFORE the
turn_count>0 shortcut, closing a double-commit window when a racing sync_turn
re-increments after commit+reset.
- Add a _shutting_down flag so deferred finalizers stop POSTing against a
torn-down client; track all prefetch threads in a set so invalidate/shutdown
join every one, not just the latest slot.
Tests: regression for the non-blocking switch (asserts the caller returns while
a slow drain is parked off-thread) and the committed-guard ordering; updated the
deferred-commit test to the unified finalizer contract.
sync_turn's bounded join could drop a still-alive previous worker by
replacing the single _sync_thread slot. The dropped worker kept POSTing
under the old sid but was no longer visible to on_session_end /
on_session_switch, so the commit could fire while orphaned writes were
still in flight — those writes landed past the commit boundary and were
never extracted.
Replace the single _sync_thread slot with _inflight_writers:
Dict[sid, Set[Thread]]. Writers self-register on spawn (sync_turn,
on_memory_write) and self-deregister on exit. The commit path drains
_drain_writers(sid, 10.0) and skips the commit if any writer for that
sid is still alive after the bounded budget.
Also trim inline review-rationale comments to short invariants per
reviewer style ask: "commit only after session writes drain" and
"drop prefetch results from older switch generations."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7537ee6f5b9ffa4c0f7b79af053aab449caf5af5)
Three follow-ups from review on #28296:
1. Sync worker outliving the bounded join. Each sync_turn POST has
_TIMEOUT=30s and there are two per turn, but on_session_end and
on_session_switch only join for 10s. If the worker is still alive
after the join, committing the old session orphans the worker's
late writes past the commit boundary — they land in an already-
committed session and never get extracted. Both hooks now re-check
is_alive() after the join and skip the commit when the worker
hasn't drained.
2. on_memory_write late session_id capture. Same shape as the
pre-fix sync_turn: f-string for the post path read self._session_id
inside the worker, so a switch between thread spawn and post call
landed the memory note in the new session. Snapshot sid at call
time, same pattern as sync_turn.
3. Stale prefetch repopulating the new session. The pre-switch
drain+clear only protects against workers that finish before the
join completes; one finishing after the clear would write its
result into the new generation's slot. Added a monotonic
_prefetch_generation; workers capture it at spawn and refuse to
write if it has advanced.
Tests: existing in-flight-sync test updated to drain (it tested the
join-before-commit happy path); four new tests cover hung-writer skip
on end + switch, on_memory_write sid capture, and prefetch generation
gating. 177/177 memory tests pass.
(cherry picked from commit 3791a87dbea518b06fc9e2e8e2da69e21a11cb41)
Two hardening fixes prompted by review on #28296:
1. sync_turn() now snapshots the target session id before spawning the
worker. The previous code read self._session_id inside the worker, so
a worker delayed past on_session_switch's bounded join could read the
rotated-in NEW id and write the OLD turn's messages into the wrong
session.
2. on_session_end() resets _turn_count to 0 after a successful commit,
making the old-session commit path idempotent with the new switch
hook. /new and compression call commit_memory_session() (which fires
on_session_end) immediately before on_session_switch; without this,
the old session would be committed twice. On commit failure we leave
_turn_count > 0 so on_session_switch retries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2ea8d5c537bccad814c483d36ddd79904bf6b55c)
OpenVikingMemoryProvider only overrides on_session_end and inherits the
base-class no-op for on_session_switch. When the agent rotates session_id
(via /new, /branch, /reset, /resume, or context compression), the
provider's cached _session_id stays at the value initialize() captured.
All subsequent sync_turn writes then land in the already-closed old
session, and on_session_end tries to commit it a second time — the new
session never accumulates messages and never triggers memory extraction.
The fix mirrors the pattern Hindsight uses (#17508):
1. Wait for any in-flight sync thread to drain under the OLD _session_id
before we mutate it, otherwise the commit below races the last
message write.
2. Commit the old session if it accumulated turns — same extraction
semantics as on_session_end. Skip if empty (nothing to extract).
3. Drain in-flight prefetch from the old session and clear its cached
result so the new session doesn't see stale recall.
4. Rotate _session_id to the new value and reset _turn_count.
Commit failures are swallowed (logged at WARN) so a flaky server can't
strand the provider on the old session forever — same posture as the
existing on_session_end commit.
(cherry picked from commit a1e7185e8aea978e76163a288ac0cd5ee911290b)
Support linking, copying, and creating ovcli.conf during OpenViking memory setup.
Make setup cancellation write nothing and cover OpenViking/Hindsight picker cancellation paths.
Follow up PR #46609's api.minimax.io reasoning report by moving the behavior out of the broad run_agent host gate and into the MiniMax provider profile. Only MiniMax-M3 on the documented OpenAI-compatible /v1 route gets reasoning_split/thinking/reasoning_effort; Anthropic-format MiniMax and non-M3 models keep their existing wire shapes.
Co-authored-by: goku94123 <gooku94123@gmail.com>
Adds an observation_scopes config key (and HINDSIGHT_RETAIN_OBSERVATION_SCOPES
env var) so retained memories can opt into per_tag / all_combinations /
custom scoping instead of Hindsight's default combined pass.
Threaded through _build_retain_kwargs so all three retain paths honor it:
auto-retain and flush-on-switch already use aretain_batch; the tool retain
path is switched from aretain to aretain_batch (functionally equivalent,
aretain just wraps a single-item batch) since aretain doesn't accept the
observation_scopes parameter.
Remove the free Parallel Search MCP path and restore the keyed Parallel backend behavior from before it was introduced.
Also drops the keyless fallback registration/display labeling tests and returns the Parallel SDK pin to the prior version.
A hard gateway exit (crash, SIGKILL, supervisor restart) left the
detached Node sidecar running with a token the next gateway run doesn't
know, so it could never be told to /shutdown. Every replacement spawn
then died on EADDRINUSE, failing each 30→300s reconnect attempt while
the orphan kept consuming the inbound gRPC stream.
Two layers:
- Lifetime binding: the adapter now holds the sidecar's stdin as a
pipe, and the sidecar (PHOTON_SIDECAR_WATCH_STDIN=1) shuts down on
stdin EOF — fired by the OS on any parent death, including SIGKILL.
- Startup reaping: before spawning, the adapter probes the port and
terminates a stale listener, but only after verifying its command
line is a Photon sidecar; a foreign listener raises a clear error
instead of being signalled.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pin spectrum-ts to exactly 3.0.0 (was ^1.18.0 plus an `npm install
spectrum-ts@latest` on every setup) so breaking SDK majors can't take
down fresh installs silently; `hermes photon setup` now runs `npm ci`.
Upgrade procedure documented in the README.
Migrate resolveSpace to the v3 namespace API: `im.space.create(phone)`
for DMs and `im.space.get(id)` for everything else — group spaces are
now rehydratable from their persisted id after a sidecar restart, which
v1 could not do.
Markdown: replies go out via the v3 `markdown()` builder (iMessage
renders natively; other Spectrum platforms degrade to plain text).
`PHOTON_MARKDOWN=false` reverts to the stripped plain-text path.
Reactions, behind PHOTON_REACTIONS (default off): lifecycle tapbacks
(👀 while processing, 👍/👎 on completion) via new sidecar /react and
/unreact endpoints with per-target reaction-handle tracking, and user
tapbacks on bot-sent messages routed to the agent as synthetic
`reaction:added:<emoji>` events.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(discord): recover from runtime gateway task exits
Salvaged from #39416 (AMEOBIUS) — cherry-picked only the task-exit
recovery; the original PR was 1081 commits behind with 28 unrelated
commits.
A post-ready discord.py WebSocket crash left the gateway split-brained:
producers stayed active while Discord stopped responding. After this fix
the adapter calls _set_fatal_error(retryable=True) + _notify_fatal_error()
so the existing GatewayRunner reconnect watcher replaces the dead adapter.
Also adds _wait_for_ready_or_bot_exit() so startup failures (SOCKS/proxy
errors, invalid tokens) surface fast instead of burning the full ready
timeout. Because connect() no longer waits via asyncio.wait_for on that
path, test_connect_releases_token_lock_on_timeout is updated to trigger
the timeout through the new helper (same lock-release contract).
3 tests pass (2 new runtime-failure tests + the updated timeout test);
test_discord_connect.py and test_discord_slash_commands.py green.
Co-Authored-By: ameobius <ameobius@local.host>
* fix(test): patch _wait_for_ready_or_bot_exit in timeout cancel test
connect() no longer uses asyncio.wait_for for the ready handshake, so
test_connect_timeout_cancels_bot_task was hanging for 30s in CI.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: ameobius <ameobius@local.host>
Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the hermes-identifying clientInfo/User-Agent/session-id prefix on
the keyless Parallel Search MCP path with a neutral 'mcp-web-client'
identity. Project policy forbids third-party usage attribution without an
explicit user opt-in (see telemetry PR policy); MCP requires a clientInfo,
so a generic one satisfies the spec without attributing traffic.
Also adds the contributor AUTHOR_MAP entry and refreshes uv.lock against
current main (parallel-web 0.6.0).
Make Parallel the web search/extract backend with a zero-setup free tier:
- Keyless (no PARALLEL_API_KEY): web_search/web_extract work out of the box via
Parallel's free hosted Search MCP (search.parallel.ai/mcp), and parallel
becomes the default backend when no other web credentials are configured
(ahead of ddgs, which is search-only). A small hand-rolled Streamable-HTTP
JSON-RPC client speaks the MCP's web_search/web_fetch tools; the existing
web_search/web_extract tools are the only tools registered.
- Keyed (PARALLEL_API_KEY set): uses the Parallel v1 REST endpoints
(client.search / client.extract with advanced_settings.full_content) — no beta.
Bumps parallel-web 0.4.2 -> 0.6.0.
- Attribution: on the free path only, results carry provider/attribution and the
CLI tool line reads "Parallel search" / "Parallel fetch"; the paid path is
unbranded.
- Selection/registration: web tools register unconditionally (free MCP backstop)
while check_web_api_key remains a real usability probe; explicit per-capability
backends are honored (so misconfig surfaces) rather than masked by the fallback.
Tested: live web_search/web_extract against search.parallel.ai in keyless and
keyed modes; unit suites for the MCP client, backend selection, and display
labeling; full agent run shows the "Parallel search" label on the free path.
The Langfuse SDK treats `data:*;base64,...` strings as media and tries to
decode them. `_truncate_text` was slicing those strings mid-payload, producing
invalid base64 and noisy "Error parsing base64 data URI" logs. Observability
only needs the metadata, not raw image/audio bytes, so redact the whole data
URI (type, media_type, length) before it reaches the SDK.
Salvaged the Langfuse fix from #39682 onto current main as a standalone,
single-concern change (the dashboard `dist/**` and plugin-discovery parts of
that PR already landed separately on main).
Co-authored-by: foras910521-lab <foras910521-lab@users.noreply.github.com>
* fix(photon): override transitive CVEs in the sidecar deps
`npm audit` flagged 7 high-severity transitive CVEs (protobufjs code injection
GHSA-66ff-xgx4-vchm + outdated @opentelemetry OTLP exporters) pulled in via
spectrum-ts -> @photon-ai/otel. npm's suggested fix downgrades spectrum-ts to a
version that targets the decommissioned spectrum host, so instead pin patched
versions via `overrides` (protobufjs 8.6.1, @opentelemetry/* 0.218.0) without
touching spectrum-ts. `npm audit` -> 0; spectrum-ts + provider still import.
* fix(photon): harden the sidecar bridge + bound the dedup cache
- constant-time sidecar control-token comparison (was `!==`, timing-attackable).
- cap the control-channel request body (2 MiB) so a compromised local peer can't
OOM the sidecar.
- wrap the inbound gRPC stream consumer in a re-subscribe loop with capped
exponential backoff + jitter — if the async iterator throws/ends it would
otherwise stop inbound forever (the adapter dedupes any replay).
- add an unhandledRejection handler so a stray rejection logs instead of killing
the process.
- dedup cache (adapter) was a true bounded LRU only for expired entries; a burst
of unique ids within the window grew it without limit. Evict oldest at the cap.
* chore: add AUTHOR_MAP entry for PhilipAD
---------
Co-authored-by: PhilipAD <philipadsouza@gmail.com>
Based on #42658 by @mnajafian-nv.
Preserves the real downstream provider/tool exception when NeMo Relay's
managed adaptive execution wraps a failing callback as an internal runtime
error. Without this, the original exception (and its retry-classification
signal, e.g. status_code) is lost behind Relay's wrapper.
Salvage changes on top of the original PR:
- Tolerant Relay-wrapper match: _is_relay_wrapped_callback_error now uses
str.startswith on the "internal error: <cls>: <msg>" prefix instead of
exact equality, so a future Relay version appending a traceback/suffix
doesn't silently defeat the unwrap. On a total format change it returns
False and falls back to the pre-fix behavior (surfacing Relay's error)
rather than masking it.
- Deduplicated the LLM and tool execute paths into a shared
_run_managed_with_downstream_preservation helper, removing ~20 lines of
copy-pasted nonlocal/try-except scaffolding that could drift out of sync.
- Added a real-middleware regression guard
(test_nemo_relay_downstream_unwrap_matches_real_middleware_wrapper_shape)
that drives hermes_cli.middleware._run_execution_chain and asserts the
plugin's _original_downstream_error unwraps the actual private
_DownstreamExecutionError wrapper. The original synthetic tests modeled the
wrapper with a local class, so a rename or shape change in core middleware
would not have been caught; this test fails loudly if that contract drifts.
Co-authored-by: mnajafian-nv <mnajafian@nvidia.com>
Extend the sidecar and Python adapter to handle `voice` content
alongside `attachment`. Voice notes are inlined as base64 (same
size-cap logic), surfaced as `MessageType.VOICE`, and include an
optional `duration` field in fallback markers when bytes are
unavailable.
Switch `list_users`, `find_user_by_phone`, `create_user`,
`register_user_if_absent`, and `refresh_user_numbers` from the
Dashboard API (Bearer token) to the Spectrum API (Basic auth with
project credentials). Update response unwrapping to handle the nested
`data.users` envelope returned by Spectrum, add `_spectrum_host()`
resolver, `_basic()` header helper, and structured error helpers.
Update tests, docs, and plugin.yaml accordingly.
Store operator and assigned iMessage numbers in `auth.json` after
setup, and surface them in `hermes photon status`. When numbers are
missing, status auto-refreshes from the dashboard without provisioning
new lines.
During `hermes photon setup`, allowlist the operator's number and set
their DM as the cron home channel when those env vars are unset. Without
this, the gateway denies the operator's own messages and cron has no
default delivery target. Re-runs never overwrite hand-tuned values.
Also teaches the sidecar's `resolveSpace` to accept a bare E.164 number
as a space identifier, resolving it to the user's DM space so
`PHOTON_HOME_CHANNEL` can be set to a phone number instead of an opaque
space id.
On shared-number plans, `/lines` has no dedicated entry, so the
`assignedPhoneNumber` field on the user object is the source of truth
for which number to text the agent. Fall back to the line inventory
only when no per-user assignment exists.
Make Photon iMessage a first-class persistent-connection channel like
Discord/Slack, using the spectrum-ts gRPC stream for both directions.
- Inbound: the sidecar forwards the SDK's app.messages gRPC stream to the
adapter over a loopback GET /inbound (NDJSON) instead of webhooks. Drops
the aiohttp webhook server, HMAC signature verification, public URL, and
PHOTON_WEBHOOK_* config; adapter reconnects with backoff.
- Management plane: device login uses client_id=photon-cli against the
single dashboard host (Bearer), matching the official photon-hq/cli;
find-or-create "Hermes Agent" project, enable Spectrum, rotate secret,
register user (with phone dedup), surface the assigned iMessage line.
- SDK projectId is the project's spectrumProjectId, not the dashboard id;
runtime creds persist to ~/.hermes/.env like every other channel.
- CLI: 6-step setup, webhook subcommands removed.
- Tests/docs updated for the gRPC flow; sidecar pins spectrum-ts ^1.17.1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Photon now allowlists registered device clients on the device-code
endpoint; the old client_id "hermes-agent" is rejected with
400 invalid_client, breaking the entire login flow. Switch to Photon's
published "photon-cli" device client and send the standard scope.
Also validate the device-flow token against /api/auth/get-session and
/api/projects/ before persisting it, and extract token candidates from
every response shape Photon has used (access_token, accessToken,
data.*, set-auth-token header) so a token that authenticates the
session lookup but is rejected by the project API fails loudly at
login instead of 404ing downstream.
Verified live: request_device_code() now returns 200 + a valid
user_code where "hermes-agent" returned 400 invalid_client.
Salvaged from #34467 by @yanxue06.
Photon now exposes attachment send (Ray Sun, photon-nousresearch), so
the Photon plugin gains outbound media to match the BlueBubbles iMessage
channel.
- sidecar: new /send-attachment endpoint wrapping space.send(attachment())
/ space.send(voice()); caption sent as a trailing text bubble.
- adapter: override send_image/send_image_file/send_voice/send_video/
send_document/send_animation. URL helpers cache to a local path first
(cache_image_from_url), file helpers pass through. Defense-in-depth
path re-validation before the path reaches the Node sidecar.
- _standalone_send (cron): send text first, then each media_file as a
/send-attachment call (is_voice -> voice builder).
- docs/README: flip the 'outbound attachments not wired' note.
Adds the last missing parity piece vs the established channels: group
chats can be made opt-in via a mention wake word, exactly like the
BlueBubbles iMessage channel.
- require_mention + mention_patterns, read from config.extra (config.yaml
via the generic gateway bridge) or PHOTON_REQUIRE_MENTION /
PHOTON_MENTION_PATTERNS env vars. Same shapes BlueBubbles accepts
(list / JSON / comma / newline), same default Hermes wake words.
- _dispatch_inbound drops unmatched group messages and strips the leading
wake word from matched ones; DMs are never gated.
- plugin.yaml + docs document both knobs and the config.yaml form.
- New test_mention_gating.py (8 tests): default-off, group drop/pass,
wake-word strip, DM bypass, custom patterns, env comma-list, invalid
regex skip.
The config.yaml -> extra bridge needed no core change — the generic
shared-key loop in gateway/config.py already iterates plugin platforms
(_shared_loop_targets += plugin_entries()), so require_mention /
mention_patterns flow through automatically.
Note: outbound media is the one capability Photon still can't reach —
Photon exposes no HTTP send-attachment endpoint yet (documented API
limitation), so the sidecar can't send files. Not faked.
Validation: 34/34 photon tests; E2E confirms config.yaml require_mention
+ custom mention_patterns bridge through load_gateway_config into a live
adapter and gate/strip correctly.
The advisory lint-diff bot flagged 17 new ty diagnostics. 6 are
`unresolved-import` for httpx/aiohttp/pytest, which is structural
(CI lint env has no project deps) and matches every other platform
plugin's noise floor. The remaining 11 are real and fixable:
- `Optional[callable]` → `Optional[Callable[..., None]]` (auth.py)
invalid-type-form on `callable` as a type expression. Added the
proper `typing.Callable` import. Two sites: on_pending in
poll_for_token, on_user_code in login_device_flow.
- Dropped three unused `# type: ignore` comments on
hermes_constants / hermes_cli.config imports — ty can resolve
those modules fine, the comments were dead.
- _supervise_sidecar(proc) widened `proc.stdout` from
`IO[Any] | None` to a narrowed local after an early `is None`
guard. Defensive against subprocesses launched without
stdout=PIPE.
- cli.py _cmd_setup: dropped the `has_existing_project = bool(...)`
intermediate, did the narrowing inline with `if existing_id and
existing_secret:` so ty can see project_id/project_secret are
non-None when create_user is called.
- test_inbound.py: replaced three `adapter.handle_message =
fake_handle # type: ignore[assignment]` with
`monkeypatch.setattr(adapter, 'handle_message', fake_handle)`.
Same behavior, no type-ignore, and the monkeypatch reverts
cleanly between tests.
Validation:
ty check plugins/platforms/photon/ tests/plugins/platforms/photon/
→ All checks passed!
tests/plugins/platforms/photon/ → 26/26 pass
py_compile clean
Windows footgun checker → 0 footguns
The previous pass moved credential reads into auth.credential_summary()
which returned a dict of pre-formatted display strings. CodeQL's
interprocedural taint analysis still flagged the cli.py prints because
the dict's values were transitively derived from load_photon_token()
and load_project_credentials().
Pattern that finally works: same as persist_webhook_signing_secret —
the helper takes an emit callback and does the formatting + emitting
itself. cli.py passes `print` as the sink and never receives any
return value derived from credential reads. CodeQL's flow stops at
the helper's emit() boundary.
Changes:
- auth.print_credential_summary(emit=print) — closure-scoped probes,
emits 6 lines (header + separator + 4 credential rows) via the
callback. Returns None.
- cli._cmd_status now calls print_credential_summary(print) then
appends the two non-credential rows (node binary, sidecar deps)
locally with no credential flow.
- Added test_print_credential_summary_emits_only_display_strings
asserting the emit callback never sees raw token/secret bytes.
Validation:
tests/plugins/platforms/photon/ → 26/26 pass
live smoke: hermes photon status (with empty HERMES_HOME) renders
the expected layout cleanly
CodeQL was still flagging three taint-flow alerts in cli.py — its
flow tracker keeps spreading the 'sensitive' label through every
variable that even touched a credential-returning function, including
'has_token = bool(load_photon_token())' and the redacted-response
dict returned by persist_webhook_signing_secret.
Refactor:
1. cli.py _cmd_status now calls a new auth.credential_summary() that
returns a {key: pre-formatted display string} dict. All probes +
bool checks happen inside the helper. cli.py never sees a token
or secret variable, only literals like '✓ stored' / '✗ missing'.
2. persist_webhook_signing_secret(webhook_data, *, on_summary=print)
now owns the formatting + writing + status messages. It returns
only a bool. The redacted-response JSON dump + 'saved to <path>'
confirmation are emitted via the on_summary callback, so cli.py
passes as the sink and never receives the path/dict back.
cli.py is now mechanical: register_webhook → persist (with print)
→ return 0/1. Zero credential-tainted variables in cli.py at all.
3. Tests updated for the new signatures and a credential_summary
guard added (the helper must never leak raw token/secret bytes
into its return strings).
Validation:
tests/plugins/platforms/photon/ → 25/25 pass
scripts/check-windows-footguns.py --all → 0 footguns
py_compile clean
Down to 4 CodeQL alerts after the last pass; all addressed:
cli.py:215 (clear-text-logging-sensitive-data)
The status banner literal 'project secret : ✓ stored' tripped
CodeQL's variable-name heuristic even though only a boolean was
interpolated. Renamed the column labels to 'project key' and
'webhook key' — fields contain only ✓ stored / ✗ missing / ⚠ unset
literals now, the word 'secret' is no longer in the source.
cli.py:283 (clear-text-logging-sensitive-data)
The fallback path for register-webhook used to echo
'PHOTON_WEBHOOK_SECRET=<value>' to stdout when the .env write
failed. Removed entirely — there is no scenario where we should
print the secret. On failure we now tell the user to fix the .env
permissions and re-register (after deleting the orphaned webhook
from the Photon dashboard).
cli.py:354 (clear-text-storage-sensitive-data) +
cli.py:276 (clear-text-logging-sensitive-data)
Replaced the hand-rolled .env writer in cli.py with the canonical
hermes_cli.config.save_env_value helper that every other API-key
persistence path uses (OpenAI key, Anthropic, Telegram, ...).
Moved the persist logic into auth.py as
persist_webhook_signing_secret(webhook_data) so the signing-secret
value never gets bound to a local in cli.py at all — cli.py hands
the raw API response straight to the helper and receives back only
the path + a redacted copy of the response for display. This both
matches project convention and removes the taint flow CodeQL was
tracking.
Bonus cleanup:
- dropped unused 'from typing import Any, Optional' in cli.py
- added 2 tests covering persist_webhook_signing_secret (writes
env successfully + returns redacted copy + no-secret-no-write)
Validation:
tests/plugins/platforms/photon/ → 24/24 pass
scripts/check-windows-footguns.py --all → 0 footguns
py_compile on all photon modules → clean
CI red on three blocking checks; all addressed:
1. Windows footguns: os.killpg() flagged as POSIX-only despite the
sys.platform != 'win32' guard. Static scanner doesn't see flow.
Added the documented '# windows-footgun: ok' suppression.
2. test (3): tests/plugins/platforms/photon/__init__.py shadowed the
real plugin's __init__.py because test_plugin_platform_interface.py
looks at PROJECT_ROOT/plugins/platforms/<name>/__init__.py with
PROJECT_ROOT=tests/ (pre-existing bug in that test, made visible
by the new test directory layout). Dropping the empty test
__init__.py restores the prior NOTSET parametrize behavior.
3. CodeQL (7 alerts in new code):
- cli.py: stop printing the first 8 chars of the bearer token after
login — even prefixes are partial credentials.
- cli.py: stop printing the first 8 chars of project_secret after
setup, same reason.
- cli.py 'hermes photon webhook register': stop dumping the raw
register-webhook response (contained signingSecret) and stop
echoing PHOTON_WEBHOOK_SECRET to stdout. Write it directly to
~/.hermes/.env (0o600), preserving existing entries; fall back
to manual instructions only if the file write fails. Photon
still only returns the secret once; this just doesn't put it
in scrollback / shell history.
- cli.py setup + status: rename project_id/project_secret/token
locals to has_* booleans before printing, breaking CodeQL's
taint flow through f-string interpolations. Drop diagnostic
prints of phone / assignedPhoneNumber that flagged as
'sensitive data' false positives.
- sidecar/index.mjs: stop returning the raw error message
(potentially containing stack trace) in HTTP 500 responses;
supervisor logs the real error to stderr, client only sees
a generic 'internal sidecar error'.
Validation:
- scripts/check-windows-footguns.py --all → 0 footguns (518 files)
- tests/plugins/platforms/photon/ → 22/22 pass
- tests/gateway/test_plugin_platform_interface.py → 7/7 pass, collects
NOTSET (matches pre-PR state)
- tests/gateway/test_platform_registry.py → 50/50 pass
- node --check sidecar/index.mjs clean
First-class iMessage support via Photon's managed Spectrum platform.
Targeted as a successor to the BlueBubbles adapter — Photon allocates
the iMessage line, handles delivery, and abuse-prevention so users
don't have to run their own Mac relay. Free tier uses Photon's shared
line pool.
Architecture:
- Inbound: signed JSON webhooks (X-Spectrum-Signature, HMAC-SHA256)
delivered to a local aiohttp listener. Dedupes on message.id,
rejects deliveries with >5min timestamp drift.
- Outbound: small supervised Node sidecar that runs the spectrum-ts
SDK. Photon does not currently expose a public HTTP send-message
endpoint; the sidecar is the only way to call Space.send() today.
When Photon ships an HTTP send endpoint we collapse the sidecar
into _sidecar_send and drop the Node dep — every other layer of
the plugin stays the same.
- Setup: 'hermes photon login' runs the RFC 8628 device-code flow;
'hermes photon setup' creates a Spectrum-enabled project, creates
a shared user (free tier), installs the sidecar's npm deps.
- Webhook management: 'hermes photon webhook register|list|delete'.
- Credentials persisted under credential_pool.photon /
credential_pool.photon_project in ~/.hermes/auth.json.
Plugin path (not built-in) — per current policy (May 2026), all new
platforms ship under plugins/platforms/. Registers itself via
ctx.register_platform() + ctx.register_cli_command(), zero edits to
core gateway code.
Tests cover:
- HMAC-SHA256 signature verification (happy path, tampered body,
wrong secret, drift, missing v0 prefix, empty inputs, non-integer
timestamp)
- Inbound dispatch for text DMs, group ids (any;+;...), and
attachment metadata markers
- Deduplication window
- check_requirements gating when Node is absent
- Device-code flow: request, header-based token return,
body-fallback token return, access_denied propagation
- Project/user/webhook API clients with mocked httpx
Known limitations (current Photon API):
- Attachments are metadata only — no download URL yet
- Outbound attachment send not wired (sidecar can add easily)
- Reactions / message effects not exposed yet
Docs: website/docs/user-guide/messaging/photon.md + sidebar entry.
Ensure failed plugin-config clear operations still re-arm managed reinitialization on the next Hermes session.
Add focused regression coverage for successful init, failed final-session clear, and next-session recovery.
Signed-off-by: mnajafian-nv <mnajafian@nvidia.com>
Clear NeMo Relay plugin-config observability only after the last active Hermes session finalizes.
Use the plugin's async-safe awaitable helper for both initialize and clear so session rotation remains safe under active event loops.
Disable the direct ATIF fallback when plugins.toml already owns the ATIF exporter lifecycle to avoid duplicate trajectory export on finalization.
The standalone Kimi/Moonshot profile (api.moonshot.ai/v1) sent both
extra_body.thinking AND a top-level reasoning_effort. With no reasoning
config it even defaulted to thinking:enabled + reasoning_effort:medium,
pairing them on every default call. Moonshot treats these as mutually
exclusive (cannot specify both 'thinking' and 'reasoning_effort').
Align with the kimi-k2 handling already shipped for the opencode-go relay:
send effort when a recognized low|medium|high is requested, otherwise fall
back to the extra_body.thinking toggle. Disabled sends thinking:disabled
only. Never both.
Reported by Cars29 (NOUS Discord). DeepSeek was deliberately left untouched:
its native endpoint accepts both (verified by the live guardrail in
test_deepseek_v4_thinking_live.py), so the report's DeepSeek claim does not
hold there.
Tests: tests/plugins/model_providers/test_kimi_profile.py pins the xor
contract across all config shapes.
on_post_llm_call extracted usage via `if response is not None:`, taking the
response-object path. But post_api_request delivers `response` as a sanitized
dict (no `.usage` attribute) alongside a separate `usage` summary dict, so
`getattr(response, "usage")` was always None and token/cost data was dropped
for every gateway turn (traces showed usage 0 / cost 0).
Gate on a real `.usage` attribute so the existing usage-dict fallback is
reached. Real response objects (post_llm_call / legacy) still take the
response-object path. Adds regression tests for both paths.
quick() and dry_run() previously trusted the stored category from
tracked.json without re-validating at delete time. Stale entries from
before #34840 could carry category="cron-output" for cron control-plane
paths (e.g. cron/jobs.json), causing quick() to delete the live
scheduler registry.
Fix:
- Fix guess_category() to only classify cron/output/** as cron-output
(was classifying ALL cron/* paths, missing the #34840 fix).
- Re-validate cron-output entries via guess_category() at delete time
in quick() and dry_run(); stale entries that are no longer classified
as cron-output are skipped and removed from tracked.json.
- Add _is_protected_cron_path() as a hard defense-in-depth guard that
blocks deletion of cron/cronjobs directories and known control-plane
files (jobs.json, .tick.lock) regardless of stored category.
- Update test_cron_subtree_categorised to match fixed guess_category
(only cron/output/* is cron-output, not all of cron/).
Tests: add 5 regression tests in TestStaleCronEntryMigration.
Three Copilot inline review comments on #37664, two worth landing
in a polish pass before merge:
1. auxiliary_client.py:270 — Copilot suggested keeping the
minimax-* entries in _API_KEY_PROVIDER_AUX_MODELS_FALLBACK as
a safety net for environments where the profile-based
resolution can't import or run plugin discovery. **Declined.**
The deepseek precedent (commit 773a0faca) explicitly removed
deepseek from the same dict for the same reason — the profile
layer is the source of truth and the dict is a legacy
pre-profiles-system fallback. We do not want to fragment the
codebase by provider: either the profile layer is authoritative
or the dict is. The minimax PR picks profile (matching deepseek)
and the dict stays cleaned up. The risk Copilot raises is
real but theoretical — plugin discovery runs at import time of
the providers module, which is the first thing any modern
Hermes entrypoint imports.
2. tests/agent/test_minimax_provider.py:162 — Copilot flagged
that the test class relies on _get_aux_model_for_provider()
resolving via provider profiles but doesn't explicitly trigger
plugin discovery. **Fixed.** Added 'import model_tools # noqa:
F401' at the top of both test_minimax_aux_is_standard and
test_minimax_aux_not_highspeed. The fixtures in the parallel
test_minimax_profile.py already did this; the legacy test in
test_minimax_provider.py was order-dependent and would silently
break if anyone reorganised the test ordering. Pinned the
dependency explicitly so the test is order-independent.
3. tests/plugins/model_providers/test_minimax_profile.py:46 —
Copilot flagged that the docstring referenced a hard-coded
line number 'hermes_cli/models.py:298' that would go stale.
**Fixed.** Replaced with the symbol reference
'hermes_cli.models._PROVIDER_MODELS[\'minimax\']' which is
stable under file edits and grep-friendly. The new docstring
also reads more naturally — readers don't have to look up
'what's at line 298' to follow the reasoning.
All 221 minimax-related tests still pass.
The minimax / minimax-cn / minimax-oauth profiles still advertised
M2.7 (and M2.7-highspeed for OAuth) as their default_aux_model,
predating the M3 release (2026-06-01). The user-facing
_PROVIDER_MODELS['minimax'] catalog top entry is M3, and the
recommended config for a Token-Plan install now sets
model.default: MiniMax-M3, so the aux default was the only
remaining drift.
Updates:
* minimax default_aux_model: M2.7 -> M3
* minimax-cn default_aux_model: M2.7 -> M3
* minimax-oauth default_aux_model: M2.7-highspeed -> M2.7
(M3 is not on the OAuth / Coding Plan tier per
platform docs as of this PR; the highspeed
variant was the 2x-cost regression from #4082
that PR #6082 collapsed to plain M2.7 for
minimax / minimax-cn but missed OAuth)
* agent/auxiliary_client.py: drop the three legacy
_API_KEY_PROVIDER_AUX_MODELS_FALLBACK entries for the minimax
family. _get_aux_model_for_provider() reads from
ProviderProfile.default_aux_model first (line 250) and only
falls back to the dict when the profile has no aux model or
the profile import fails. With the profile now set, the dict
entries are dead code and a drift hazard. Mirrors the deepseek
cleanup in 773a0faca.
* tests/agent/test_minimax_provider.py: update the existing
TestMinimaxAuxModel assertions from MiniMax-M2.7 to MiniMax-M3
(the intent — 'standard, not highspeed' — is unchanged; the
pin value is).
* tests/plugins/model_providers/test_minimax_profile.py: new
file mirroring tests/plugins/model_providers/test_deepseek_profile.py.
Pins each of the three profiles' default_aux_model and
asserts _get_aux_model_for_provider() returns it. A second
class guards against the highspeed regression coming back.
Refs:
- Closes#36196 in spirit (M3 support — the catalog half of
that issue is #36212; this PR covers the profile half)
- Related: #4082 (M2.7-highspeed 2x-cost), #6082 (previous
M2.7-highspeed -> M2.7 fix that missed OAuth + the
auxiliary_client.py fallback dict)
- Pattern: 773a0faca (same profile-layer fix for deepseek)
Adds a bundled dashboard-auth provider plugin that authenticates the
web dashboard against any conformant self-hosted OpenID Connect server
(Authentik, Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …) using
standard OIDC — no per-IDP code.
It's a pure drop-in plugin implementing the DashboardAuthProvider
protocol; it touches no core auth/runtime/login paths. Mechanics:
- OIDC discovery from {issuer}/.well-known/openid-configuration
(cached; issuer pinned; endpoints required HTTPS, loopback http
allowed for local-dev IDPs)
- authorization-code + PKCE (S256), public client
- verifies the OIDC ID token (RS256/ES256) against the discovered
jwks_uri with iss/aud pinned to the configured issuer/client_id, and
maps standard claims (sub/email/name/preferred_username, groups→org)
onto a Session
- standard refresh_token grant for silent re-auth; RFC 7009 revocation
on logout when advertised
Verifies the ID token (not the access token) because OIDC guarantees the
ID token is a signed JWT carrying identity, while access-token format is
opaque to the client per spec — the only universally-correct choice
across self-hosted IDPs.
Config via dashboard.oauth.self_hosted.{issuer,client_id,scopes} in
config.yaml or HERMES_DASHBOARD_OIDC_{ISSUER,CLIENT_ID,SCOPES} env vars
(env-wins-config, empty-is-unset — same convention as the nous plugin).
Confidential clients (client_secret) left as a documented TODO seam.
Docs: adds a Self-hosted OIDC section to the web-dashboard guide,
including a copy-paste Keycloak worked example (realm import + docker
run + dashboard wiring + login walkthrough).
Tests: 65 cases covering construction, discovery (incl. issuer
mismatch + https enforcement), start_login/PKCE, complete_login, ID
token verification, refresh/revoke, and env/config precedence.
- test_dashboard_auth_password_login.py: drives /auth/password-login
end-to-end through the REAL gated_auth_middleware (login -> session
cookie -> authenticated /api/auth/me -> transparent refresh via the RT
cookie), plus protocol-extension checks, the generic-401/404 oracle
properties, the rate limiter, and login-page rendering (form+script
when supports_password, script-free otherwise, both for mixed
providers). Reuses the existing StubAuthProvider harness convention.
- test_basic_provider.py: scrypt hash/verify, login mint, kind-claim
enforcement (access != refresh), cross-secret rejection, and the
register() config/env precedence + skip reasons.
Mutation-tested: dropping the kind-claim check in verify_session makes
test_access_token_not_accepted_as_refresh fail, confirming the test isn't
theater.