Applies the batch-disposition SALVAGE conditions from #64231: the hook id
moves to the taxonomy transform-family name, and run-all-then-pick-first
dispatch now logs a runtime warning when a valid-but-losing classification
is skipped (the #64714 skipped-transform rule). Chaining semantics are
stated explicitly at the VALID_HOOKS entry, the dispatch helper docstring,
and the hooks.md catalog row and detail section.
Rebased onto current main, where the OpenRouter tool-use 404 is now
handled natively (the bundled demo's exact reason to exist), so the demo
plugin is removed per the standalone-repo policy and every test now uses
a synthetic unclaimed error (fake provider, neutral message, no status
code) that no present or future built-in rule can claim.
classify_api_error is now explicitly Python-plugin-only: VALID_HOOKS
doubles as the shell-hook allow-list, but the shell response parser has
no channel for the classification directive, so shell registrations are
refused at config parse with a warning instead of being silently
ignored (new SHELL_UNSUPPORTED_HOOKS set + regression test).
The hook is documented in the hooks reference as the third
behavior-changing hook, with the full kwargs contract, return shape,
and the Python-only note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnMCvi2vXqfs996AjVeF2F
Adds a plugin seam at the top of agent/error_classifier.classify_api_error()
(step 0, before the built-in pipeline) so model-provider plugins can classify
their provider's error quirks without patching core:
- New "classify_api_error" entry in VALID_HOOKS. Callbacks receive the parsed
error context (provider, model, status_code, error_type, error_code,
error_message, error_body, error, approx_tokens, context_length,
num_messages), self-scope on `provider`, and return None to pass or a dict
{"reason": "<FailoverReason name>", ...optional recovery-hint overrides}.
- get_plugin_error_classification() helper mirrors
get_pre_tool_call_block_message(): first valid result wins, invalid dicts
and unknown reasons are skipped, callback exceptions are isolated — a
broken plugin can never break classification. Zero behavior change when no
plugin claims the error (all 179 existing classifier tests pass untouched).
- Bundled reference plugin `openrouter-tool-use-404` (opt-in, like all
bundled standalone plugins) re-implements PR #58451: OpenRouter's
"No endpoints found that support tool use" 404 carries no
_MODEL_NOT_FOUND_PATTERNS signal, so it classifies as unknown/retryable
and the retry loop burns 3-5 attempts on a deterministic rejection.
The plugin classifies it as model_not_found (retryable=False,
should_fallback=True) so the fast-fallback path fires immediately —
demonstrating a waiting core PR converted to a publishable plugin.
Motivation: ~10 open PRs are single-provider error-classification patches
(#58451, #58355, #58502, #58474, #58366, ...). This hook turns that whole
class of contribution into plugin territory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWMcB7RPSYUpsXDfBgwjzM
delegate_task gains a control plane: action='list' / 'steer' / 'stop'
let the parent agent see, redirect, and early-stop its own running
subagents mid-flight — the model-facing counterpart of the TUI's
delegation.pause / subagent.interrupt / subagent.steer RPCs.
- action='list': live children of this conversation's spawn tree
(ids, goal, status, running_seconds, accepting_steer, live
transcript path). Ownership is enforced via a _delegate_parent_ref
weakref chain stamped at child build time, so a conversation can
only control its own descendants, never a sibling tree.
- action='steer': queues text into a running child via the existing
steer_subagent() registry path (delivered at the child's next tool
boundary; missed steers surface as missed_steer in the completion).
- action='stop': interrupt_subagent() — child stops at its next
iteration boundary, partial result still re-enters as a completion.
- Spawn dispatch response now includes subagent_ids + control hint.
- Control actions run synchronously (never backgrounded) and bypass
the spawn pause gate and depth limit; they also never consume the
per-turn subagent spawn cap, and remain usable once the cap is hit
(that is when stop matters most).
- Small-model robustness (found live with gpt-5.4-mini on Nous
Portal): tasks=[] alongside goal no longer trips the "Batch mode
requires at least 2 tasks" gate — treated as single-goal.
- CLI display: control calls render as "steer sa-…" / "list" instead
of an empty goal.
Live-tested E2E on Nous Portal (fable-5 + gpt-5.4-mini): full
spawn→list→steer→stop cycle, plus a steer-efficacy run where the
child acked the steer mid-essay and switched topics before finishing.
A type="compaction" item is the server-side stand-in for history that has
already been pruned, and it rides the same codex_reasoning_items sidecar as
per-turn reasoning. e00965a7e taught compaction pruning to filter that
sidecar instead of popping it so checkpoints survive on every retained
message.
The thinking-only sanitizer reaches the same sidecar from the other
direction and asks a coarser question: does any item have type ==
"reasoning"? A commentary turn carrying both a reasoning item and a
checkpoint answers yes, so the whole message is dropped from the wire copy
and the only copy of the checkpoint goes with it. The request then carries
neither the compacted history nor the checkpoint standing in for it.
Extract has_compaction_checkpoint() into agent/native_compaction.py — the
module that owns the concept, and where merge_interim_reasoning_items()
already spelled the same predicate inline — and consult it before the
thinking-only verdict. A reasoning-only carrier is still dropped.
Two reliability gaps from #82777:
1. Rejection matcher required only a field-name mention, so a transient
5xx/timeout whose body echoed the request (which contains
context_management) permanently downgraded native compaction for the
session. Now requires rejection language (unknown/unsupported/invalid/...)
alongside the field name, and when a parsed HTTP status is available,
400 specifically — non-400 statuses never match. Message-only transports
(no status attribute) keep working unchanged.
2. compression.codex_responses_native was coerced with bool(), so the
strings "false"/"off" enabled the feature. Now uses the shared
utils.is_truthy_value helper.
Conversation-loop call site passes api_error.status_code through.
Sabotage-verified: reverting the matcher to field-name-only fails the new
echo and non-400 tests.
Simplify-pass follow-ups on the salvage stack (all guard tests re-run,
mutation-checked):
1. conversation_loop.py: moved `_preflight_compression_blocked = False`
from 9 per-site copies into the restart_with_rebuilt_messages handler
(its single consumer). Besides removing the 9 duplicated blocks, this
fixes a 10th pre-existing retry-loop site (content-filter stall
failover, #32421) that set the flag and broke WITHOUT clearing the
preflight block — a content-filter failover previously restarted with
preflight compression still blocked against the fallback's smaller
window, the same #84733 bug class. The outer-loop empty-response site
keeps its own clear (it never passes through the handler). New AST
guard test_restart_handler_clears_preflight_block pins the hoisted
clear (mutation-checked).
2. agent_runtime_helpers.py: extracted _raw_cache_ttl_from_config() —
prompt_caching_disabled_from_config and configured_cache_ttl were
verbatim copies of the same config read. Added VALID_CACHE_TTLS.
3. prompt_caching.py: added is_qwen_model() next to
ALIBABA_FAMILY_PROVIDERS; effective_cache_ttl and
anthropic_prompt_cache_policy now share both the family set and the
qwen predicate — neither can desync.
4. Guard-test hardening: assert every _try_activate_fallback reference
is a direct `if agent._try_activate_fallback(...):` site, so a future
`activated = ...` form can't silently escape the restart-discipline
guard.
Follow-ups on the salvaged #84782 (webtecnica):
1. conversation_loop.py: the empty-response fallback site sits directly
in the OUTER iteration loop, not the retry loop. The salvaged commit's
`break` there exited the conversation loop and ended the turn without
ever calling the just-activated fallback (caught by CI:
test_empty_response_triggers_fallback_provider). Restored `continue`
(which already re-runs the pre-API preflight at the top of the next
outer iteration) while keeping the `_preflight_compression_blocked`
reset. The other 9 sites are inside the retry loop, where `break` to
the restart_with_rebuilt_messages handler is correct.
2. test_prompt_cache_ttl_propagation.py: made the AST guard loop-aware —
retry-loop sites must break, outer-loop sites must continue (the old
assertion pinned the bug in (1)). Mutation-checked both directions.
3. test_failover_identity.py: added `model` to the SimpleNamespace agent
fixture — _redecorate_prompt_cache_for_provider now reads agent.model
for the per-destination TTL clamp (2 CI failures).
4. prompt_caching.py / agent_runtime_helpers.py: single source of truth
for the alibaba-family provider set — ALIBABA_FAMILY_PROVIDERS lives
in prompt_caching and anthropic_prompt_cache_policy imports it, so the
cache-policy opt-in and the TTL clamp can never desync.
5. auxiliary_client.py: threaded the configured tier into
_replan_synchronous_cache_sections via new configured_cache_ttl()
(no live agent on that path) — the aux half of #84733's report also
stopped regressing 1h to 5m. Guarded by
TestAuxFallbackReplanThreadsTtl (mutation-checked).
6. Dropped the redundant `or "5m"` at the two threaded call sites —
effective_cache_ttl already resolves None to "5m", and the `or`
masked the cache-disabled (None) semantics.
OpenCode identifies clients by request headers, the same way OpenRouter
does. Our opencode-zen and opencode-go profiles never set any, so every
request went out with the OpenAI SDK default "OpenAI/Python x.y.z"
User-Agent and OpenCode had no way to tell the traffic was Hermes Agent.
Two changes:
- Add HTTP-Referer, X-Title, and a HermesAgent User-Agent to both
OpenCode profiles through profile.default_headers, the same path
Fireworks uses. This covers chat_completions, codex_responses,
auxiliary clients, model switches, and the models catalog fetch.
- Merge the same headers in build_anthropic_client for opencode.ai
base URLs. The Anthropic Messages route (Claude on Zen, MiniMax and
Qwen on Go) builds its client there and never sees profile headers.
Verified against the live Go relay with a real key. Both wire formats
return HTTP 200 and the requests now carry X-Title "Hermes Agent",
HTTP-Referer, and User-Agent HermesAgent/0.20.0.
Live verification (gpt-5.6 @ api.openai.com) proved the Responses server
renders NOTHING placed before a replayed compaction checkpoint: a fact
stated in a pre-checkpoint input item is invisible to the model, while the
same item after the checkpoint recalls perfectly. Hermes was replaying the
full pre-checkpoint transcript anyway — dead upload weight, and worse, every
plaintext user ask from before the boundary silently vanished from the
model's view, surviving only inside the opaque server summary. That is the
goal-drift failure mode reported against native compaction sessions.
Codex CLI never hits this because it rebuilds history client-side after
compaction, retaining user messages verbatim under a token budget. This
change is the wire-level equivalent: when a replayed checkpoint is present,
_chat_messages_to_responses_input restructures the input as
[newest checkpoint run] + [retained pre-checkpoint user messages,
newest-first within a 64K-token budget] + [post-checkpoint tail]
Histories without a checkpoint are returned unchanged, so non-native
sessions see a byte-identical wire.
New desktop_ui tool: the agent proposes an MCP server (install/enable/
authorize + a one-line reason) and blocks on mcp.setup.request until the
renderer's consent card answers mcp.setup.respond with the outcome
(installed/enabled/authorized/declined/unanswered/error). Same lifecycle
as clarify: 10-min timeout, allow_expired late answers, tool lifecycle
events forced on so the card mounts even with tool progress off. Desktop
prompt hint steers the model to the tool instead of hand-editing config;
every other surface keeps the schema out and is pointed at hermes mcp
install.
Adds a 'council' synthesis style to MoA (per preset via synthesis_style,
one-shot via the new /council command on CLI + gateway). Reference models
answer independently; the aggregator chairs the deliberation and produces
a user-facing report of consensus, per-model disagreements (with the
differing assumptions behind them), unique contributions, and a
recommendation with an explicit confidence level.
Inspired by Perplexity's Model Council rollout to Perplexity Computer
(changelog 08/04/26): pick a board of 2-8 models, run them independently,
synthesize where they agree/disagree and what each uniquely surfaces.
Port from openai/codex#37527: Terminate timed-out hook process trees.
A shell hook that forked helpers (scanners, watchers, "cmd &") and then hit
its timeout left those descendants running forever — subprocess.run() only
kills the direct child. Worse, descendants holding the inherited pipe write
ends could stall run()'s post-kill communicate() drain.
- agent/shell_hooks.py _spawn(): spawn hooks in their own process group on
POSIX (process_group=0, Python >=3.11); on timeout/error, reap the whole
tree via the shared kill_process_tree() helper, then drain bounded (1s).
Hooks that complete in time keep their descendants, so intentionally
detached helpers survive successful runs (mirrors codex semantics).
- hermes_cli/_subprocess_compat.py: rename _kill_git_process_tree ->
kill_process_tree (it was never git-specific; taskkill /T /F on Windows,
ownership-gated os.killpg on POSIX). Backward-compat alias retained.
- tests/agent/test_shell_hooks_tree_kill.py: real-subprocess regression
tests (descendant killed on timeout, preserved on success, own-group
spawn, fast-path contract, fail-open). Sabotage-verified: reverting the
process_group spawn fails exactly the two new behavior tests.
Gap proven live on main first: a forking hook timed out at 2s and its
descendant survived; same probe against this branch shows it reaped.
Salvaged from PR #64188 (@Bartok9), re-reviewed against the #64229
ownership ledger (landed in #84923).
Delivery parity (survived):
- Module-level invoke_hook/invoke_middleware/has_hook/has_middleware
lazily run plugin discovery via _delivery_manager(), so surfaces that
never import model_tools (dashboards, TUI slash workers, query mode,
cron, gateway platform events) deliver plugin callbacks instead of
silently dropping them (#50776, #67597, #67890, #50937).
- _delivery_manager() joins any in-flight background discovery first and
tolerates test doubles that monkeypatch get_plugin_manager().
Symmetric force-reload (survived):
- agent/shell_hooks.py gains re_register_config_hooks(); the force branch
of discover_and_load() calls it after a successful sweep, restoring
config.yaml shell hooks that the ledger-driven unload wiped but cannot
restore (they are config-owned, not plugin-owned) (#60036).
- unload(plugin=None) now sweeps pre-ledger _plugin_tool_names entries
out of the process-global tools.registry, mirroring the platform-name
sweep that already existed, so zombie tools cannot survive a force
reload in long-lived pre-ledger processes (#60050).
Superseded by the ownership ledger (dropped from #64188):
- _unload_global_plugin_registrations() bulk tool/platform teardown —
the ledger's reverse-order handle disposal with previous-entry
restoration covers it more precisely.
- tools.registry/platform_registry displaced-entry LIFO restore stacks —
the ledger's restore_registration() identity-checked previous-entry
restoration made them redundant.
- Discovery serialization lock + double-checked singleton — main already
has _discovery_lock on every discover/unload path and a keyed,
lock-guarded per-home manager cache (#24714 concern is covered).
Fixes tracked under #64178 (#50776, #60036, #60050, #24714, #67798,
#50937, #67597, #67890, #31480 — #31480 already handled on main by
_parse_hooks_block warn+suggest).
CI caught the file hanging AFTER '6 passed in 4.32s' until the runner's
300s SIGKILL. Two defects, same class the PR fixes:
1. The executor-refused (interpreter shutdown) fallback ran the native
call UNBOUNDED on the calling thread — a wedged pipeline would block
process exit forever. Now runs on a bounded daemon exit-thread with
the same timeout/abandon semantics as the executor lane.
2. The wedge tests left daemon workers parked on Event.wait() and live
sessions registered on the atexit shutdown hook; exit re-ran the
wedged pops (bounded, 10s each) and the per-file runner timed out.
Autouse teardown now releases every wedge and drains each runtime.
Canonical runner: 4.4s (was 300s file-timeout kill). Bare pytest was a
false green for this class — it exits before atexit replay cost shows.
The NeMo Relay native binding's scope.pop/push are synchronous and
unbounded ('returns after the scope is closed successfully'). When the
native pipeline cannot make progress, the session coordinator's turn and
session finalization block forever inside run_conversation: delegated
children finish their turns but never return, and delegation batches die
on the stall watchdog. Proven live 2026-08-10 on the staging fleet — a
falsification probe (plugin disabled, identical config) completed the
same delegation batch that wedged with the plugin active.
Bound every scope lifecycle operation that gates turn/session completion
(session push, turn push, turn pop, logical-LLM pops, session pop,
subscriber flush) by running the native call on a shared
DaemonThreadPoolExecutor and honoring a 10s result timeout. On breach a
TimeoutError propagates into each call site's existing exception
handling — warn, retain the unclosed-prefix diagnostics, continue — so
the worst case is one lost span, never a blocked agent. timeout=None
preserves byte-identical synchronous behavior for all other callers, and
interpreter-shutdown paths fall back to the synchronous call so the
atexit flush still exports.
Observability must never block the product.
Rebase over the capability-model merge dropped two behaviors the tests
pin: (1) unload_all must still unregister every _plugin_platform_names
entry from the global platform registry (pre-ledger state has no
handles); (2) list_plugin_sources() must see profile-scoped
registrations — scoped entries are plugin-registered by definition.
Adds a pre_transcription transform hook (prompt/language/model mutable,
file_path read-only, last-writer-wins per the transform_* convention)
fired before any STT backend, threads prompt to faster-whisper
(initial_prompt) and OpenAI/Groq/Mistral/DeepInfra (prompt), adds an
optional stt.prompt config key on the same plumbing, and keeps the
no-hook dispatch path byte-identical. Fixes#64168.
Documents the new surface for users: a "Transcription prompt
(vocabulary hints)" subsection in the configuration guide (composition
order, per-provider support matrix, length contract, privacy warning),
a pre_transcription entry in the hooks reference, and the mirrored row
in the plugins hook table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AG6LyYMvHC2o6HbVUozmVR
Two review items raised on #65449 (thanks @hansai-art):
1. Explicit test that post-module-load registration REBUILDS the
_PREFIX_SUBSTRINGS pre-screen tuple — plugin patterns flow through
the same fast path as built-ins, never around it. This was covered
implicitly by the masking tests; now it is asserted directly.
2. Plugin patterns are now stored keyed by registration source, giving
the #64229 lifecycle/ownership-ledger work a clean seam to drop one
plugin's patterns on unload. No public removal API is added —
additive-only stands; unload remains a host-owned lifecycle concern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnMCvi2vXqfs996AjVeF2F
Nested unbounded quantifiers ((a+)+, (?:x*)*, (a{2,})+) backtrack
catastrophically, and registered patterns run against every log line
and tool output, so a pathological pattern from a buggy plugin would
stall the host process. Registration now rejects the structural
nesting shape with a logged warning, same fail-soft contract as the
other validators.
Detection is a hand-rolled scanner matching the top-level-alternation
check's idiom: escapes and character classes skipped, group stack
tracks whether each group body contains an unbounded repeat, reject
when such a group closes into an unbounded quantifier. Overlapping
alternation ambiguity ((a|aa)+) is documented as out of scope.
Also refreshes the test module docstring left stale by the demo-plugin
unbundling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnMCvi2vXqfs996AjVeF2F
'ab|.*' compiled and carried the accepted 'ab' literal prefix while its
'.*' branch stayed unprefixed, escaping the no-redact-everything
guarantee (_extract_literal_prefix stops at '|'). Registration now
rejects top-level alternation with a regression test for exactly that
shape; grouped alternation after the prefix, escaped pipes, and
character-class pipes remain accepted.
The bundled nvapi-redaction reference plugin is removed per repo policy
(vendor integrations ship as standalone plugin repos); the end-to-end
register() coverage now uses a synthetic plugin written at test time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnMCvi2vXqfs996AjVeF2F
Every new vendor token format has required a core PR appending to
_PREFIX_PATTERNS in agent/redact.py (fw_, retaindb_, hsk-, mem0_, brv_
all landed that way; #58466/#58501 are the latest of the class). This
adds an additive-only registry so provider plugins own their format:
- agent/redact.py: register_redaction_patterns(patterns, source) —
validates each pattern (must compile; must start with >=2 literal
characters so the pre-screen substring gate keeps working and
redact-everything patterns like `.*` are structurally impossible),
dedupes against built-ins and prior registrations, then atomically
rebuilds _PREFIX_RE and _PREFIX_SUBSTRINGS. Registered patterns get
identical treatment to built-ins everywhere: same head/tail masking,
same non-reusable «redacted:label…» sentinel on file_read, same
security.redact_secrets operator opt-out. Additive-only by design —
a plugin can extend masking, never weaken it. Includes a
test/teardown reset helper.
- hermes_cli/plugins.py: PluginContext.register_redaction_patterns()
delegating with per-plugin attribution; warns and returns 0 on any
failure so a broken plugin can never break startup.
- Bundled reference plugin `nvapi-redaction` (opt-in): masks NVIDIA
API keys (nvapi-, used by NIM / build.nvidia.com) — a real format
missing from core, shipped as the one-liner plugin that previously
would have been a one-line core PR.
13 new tests: baseline gap, masking + built-ins unaffected, invalid
regex / no-literal-prefix / dedupe / non-string rejection, file_read
sentinel labeling, reset semantics, PluginContext wiring incl.
exception isolation, and a no-mocks end-to-end through the demo
plugin. Existing redaction suites (tests/agent/test_redact.py,
tests/tools/test_kanban_redaction.py) pass untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWMcB7RPSYUpsXDfBgwjzM
Closes#26193
Adds ContextReferenceProvider ABC so plugins can register custom
@-prefixes (e.g. @issue:ENG-123) with autocomplete and expansion.
Plugin output flows through existing token-limit guards. Zero
breaking changes.
Salvage of PR #64317 (@deaneeth) onto current main, implementing #64161:
observer-only on_stream_start / on_stream_delta / on_stream_end /
on_interim_message plugin hooks dispatched through a host-owned bounded
queue (one worker per callback) so plugin callbacks never run inline on
the token path. Reasoning deltas are opt-in via
plugins.stream_reasoning_deltas.
Salvage the plugin-owned static prompt idea from PR #51589 into the constrained #64167 contract: stable IDs, deterministic placement, bounded fail-open rendering, and full-prompt resume recovery without new session columns.
Co-authored-by: Topher Ross <biz@topherross.com>
Wire an optional `task=<key>` kwarg through the PluginLlm facade so a
plugin can route an LLM call through an auxiliary model slot it
registered via `ctx.register_auxiliary_task`. Registration already
existed; this adds the missing consumption half. Closes#44673.
Sub-issue 08/14 of the plugin-interface expansion tracking issue #64182.
- New optional `task:` kwarg on complete/acomplete/complete_structured/
acomplete_structured. Unset or "auto" keeps today's main-model path
byte-for-byte (task=None reaches call_llm exactly as before), so no
prompt-cache or default-behavior change.
- A set task resolves provider/model through `auxiliary.<task>` via the
existing auxiliary_client path, identical to built-in aux tasks.
- Trust gate (per the round-2 design correction): a plugin may only pass
a key it registered itself; a built-in key additionally requires
`plugins.entries.<id>.llm.allow_task_override: true`. A foreign or
unknown key is rejected with a PluginLlmTrustError and a logged warning
naming the offending plugin and key -- fail loud, NOT a silent fallback
to auto (which would mask misconfiguration and could route to the main
model the user steered elsewhere).
- The plugin_llm audit dict and audit-log line gain a `task` field.
- register_auxiliary_task now stores the plugin's canonical id
(`key or name`, the same id ctx.llm is bound to) as the slot owner, so
the trust gate matches ownership even when a manifest sets a distinct
key. For the common no-key case this equals the name (unchanged).
Tests (tests/agent/test_plugin_llm_task_routing.py, 24): _check_task
resolution incl. own/foreign/unknown/built-in-gated keys and loud
rejection; end-to-end routing sync+async+structured; production-path
forwarding into call_llm/async_call_llm (covers the task=None->task line);
and ownership resolution against the real plugin registry incl. the
name/key reconciliation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013b1XyXitAxV7phGmKWigJX
Address teknium1 review on #64189:
- Re-pull gate now delegates to each source's is_enabled(cfg) via the
registry contract, so a plugin source with custom activation logic is
honored (previously only secrets.<name>.enabled was checked).
- Add BUILTIN_SOURCE_NAMES to the registry so plugin-vs-bundled is a
single source of truth instead of a hard-coded set at the call site.
- Reconcile docs: rewrite the timing :::note to describe both the
post-discovery re-pull and the remaining import-time limitation, and
cross-link the first-process bootstrap section.
- Tests: real SecretSource subclasses, custom is_enabled activation
(positive + negative), is_enabled-raises skip, builtin-only no-op,
and a discovery-registration end-to-end re-pull check.
Follow-up to #84632:
- Guard _llama_cpp_grammar_hit inside status_code == 400 to restore
short-circuit behavior on non-400 errors (minor efficiency)
- Extract _NO_USER_QUERY_SIGNAL constant for the duplicated string
between _INVALID_MESSAGE_BODY_PATTERNS and the llama.cpp exclusion
guard, preventing silent drift if the phrase is ever changed
Local engines wrap Qwen template raise_exception("No user query found…") as
applyPromptTemplate / "Unable to generate parser for this template". That used
to match llama_cpp_grammar_pattern, strip tool schema keywords, and retry while
the real cause was a poisoned/oversized transcript after failed compression.
Classify as format_error so recovery fails fast toward /new instead.
* fix(models_dev): map meta-ai provider to models.dev 'meta' id
Muse Spark models (muse-spark-1.1/1.2/-contributor) are served via the Meta
Model API and reverse-map from api.meta.ai to the Hermes provider id 'meta-ai'.
models.dev keys the same models under the provider id 'meta'.
lookup_models_dev_context() / _get_provider_models() resolve the models.dev id
strictly via PROVIDER_TO_MODELS_DEV.get(provider) (no raw-id fallback, unlike
get_model_info()), so an unmapped 'meta-ai' missed entirely and context fell
back to the generic 256K default instead of the true ~1M window. Add the
meta-ai->meta mapping (plus a defensive meta->meta) so context and pricing
resolve from models.dev: 1.1=1,000,000; 1.2 & -contributor=1,048,576.
* add contributor email
---------
Co-authored-by: Beto de Paola <betodepaola@meta.com>
The write_file / patch file tools hard-denied ~/.ssh/config as a
"protected system/credential file", while the terminal tool only
*asked* for approval on ~/.ssh writes. That inconsistency meant a write
to ~/.ssh/config was refused via write_file but succeeded via terminal
after an approval prompt -- the same operation flip-flopping between
denied and OK depending on which tool ran it.
The SSH client config carries no private-key material, and editing it
(host aliases, ProxyJump, VS Code Remote-SSH targets) is a routine,
user-initiated task. It CAN carry ProxyCommand / Match exec directives
that run commands, so a free write is still inappropriate -- approval,
not a flat refusal, is the right policy, matching what the terminal tool
already does.
Changes:
- agent/file_safety.py: remove ~/.ssh/config from the flat credential
deny; add build_write_approval_paths() + is_write_approval_required(),
and short-circuit it out of the ~/.ssh/ prefix deny so the file is
allowed at the classifier layer. Private keys, authorized_keys, and
everything else under ~/.ssh/ stay hard-denied.
- tools/file_tools.py: _check_approval_required_write() routes ssh config
writes through the shared _run_approval_gate (once/session/always,
honors --yolo, fail-closed with no human), wired into write_file_tool
and patch_tool right after the protected-instruction gate.
- Non-interactive consumers fail closed: the ACP file bridge
(copilot_acp_client) rejects approval-required paths outright, and the
TTS output-path picker refuses them as before.
- Docs + tests updated (security.md exception note;
TestSshConfigApprovalGate covers config approval-gated, keys still
hard-denied).
Two follow-ups from live Windows sessions:
1. agent/prompt_builder.py: extend the Windows shell hint with the
native-binary path rule. Hermes disables MSYS path conversion for its
bash, so agents passing /c/Users/... or /tmp/... to NATIVE programs
(git -C, node, python, rg) hit 'cannot change to' / 'not found' while
the same path works in bash builtins — observed repeatedly in a live
session (git -C failures, git apply /tmp/x.patch failures). The hint
now says: forward-slash native form (C:/Users/x) for native tools,
$LOCALAPPDATA/Temp over /tmp for scratch files native tools read.
(/tmp is pure model habit from Linux training data — nothing
instructs it — so the hint is the right layer.)
2. tests: pin LF/CRLF preservation through write_file and patch_replace.
A live session saw a repo-LF file come back full-CRLF after an edit
(4699-line diff churn); not reproducible through current tool APIs,
so pin the correct behavior — LF files stay LF, CRLF files stay CRLF,
no mixed endings — to catch any regression on the Windows write path.
Sweep of open Windows issues affecting day-to-day agent operation
(explicitly excluding install/setup and locale classes):
- hermes_cli/_subprocess_compat.py: new split_command_line() — Windows-
safe command-line tokenizer (posix=False + quote stripping) so
backslash paths survive. POSIX behavior unchanged (plain shlex.split).
- hermes_cli/console_engine.py (#83934): console commands like
'sessions export C:\Users\me\out.jsonl' no longer silently mangle the
path into a relative filename in the cwd.
- agent/shell_hooks.py (#78293): hook commands with backslash paths now
spawn, resolve their script path, and pass hooks doctor instead of
reporting 'not executable'. All three shlex sites routed through the
shared splitter.
- agent/prompt_builder.py (#51755): system prompt now reports
Windows (11) on Windows 11 — platform.release() returns 10 for both;
distinguish via sys.getwindowsversion().build >= 22000.
- hermes_cli/commands.py (#42016): @ autocomplete no longer crashes the
prompt_toolkit event loop when rg emits a path on a different mount
(device paths \.\nul, other drive letters) — relpath ValueError is
skipped per-entry.
- tools/browser_use_cli.py (#83884): screenshot-path detection now
matches Windows drive-letter paths (C:\... and C:/...) in addition to
POSIX; Browser Use screenshots attach on Windows.
- tools/skills_hub.py + tools/skills_guard.py (#62310): the two 'MUST
stay symmetric' skill content hashes actually agree on Windows now.
Bundle keys are normalized to POSIX separators before hashing, and the
disk digest sorts by rel-posix STRING (case-sensitive) instead of Path
objects (case-insensitive on Windows). Fixes permanent false-positive
update_available for every installed skill.
Tests: tests/tools/test_windows_agent_loop_papercuts.py — 16 cases
covering each fix, including a disk-vs-bundle hash symmetry check built
with native Windows separators and a mixed-case filename.
Follow-up fixes on top of the salvaged #83678 commit:
1. Hoist the MiniMax-M3 marker exclusion ABOVE the native-Anthropic
early return. provider="anthropic" pointed at a MiniMax /anthropic
proxy is a supported override (_anthropic_base_url_override_ok), and
the is_native_anthropic branch matched on provider alone — returning
(True, True) before the M3 exclusion was reached. Two regression
tests pin the proxy route (M3 off, M2.7 still on).
2. Reuse the existing _model_name_suggests_minimax_m3() helper from
agent/model_metadata.py instead of a second inline substring copy.
3. Drop the debug kwarg on normalize_usage() — it had zero production
callers and duplicated standard logging level gating. The
cache-observability line is now a plain logger.debug scoped to
MiniMax providers on the Anthropic wire only, so the "+128 floor"
note can no longer appear for native Anthropic where it is false.
Tests updated accordingly (MiniMax logs, native Anthropic does not).
MiniMax-M3 ships server-side automatic prefix caching on the
Anthropic-compatible endpoint (content-keyed, no marker needed —
see platform.minimax.io/docs/api-reference/text-prompt-caching).
cache_control markers are NOT on its explicit-cache support list
(which covers only M2.7/M2.5/M2.1/M2).
Emitting markers on M3:
- wasted serialization overhead
- risked perturbing the server-side prefix hash
- gave users a false sense of explicit-cache savings (the
cache_read_input_tokens field carries a +128 constant floor
and cache_creation_input_tokens is always 0 for M3)
Also add an opt-in debug=True parameter to normalize_usage() that
emits a debug-level log line carrying the observable cache fields.
This is the only reliable cache signal for M3 — off by default,
debug-level, scoped to the anthropic_messages wire, so production
callers see no impact.
Pin both changes with 8 new tests:
- 4 M3 tests covering provider, host, and custom-provider paths
- 1 regression guard ensuring M2.x caching is unaffected
- 3 observability tests (off-by-default, on-with-M3, on-with-Claude)
Verified end-to-end against api.minimaxi.com/anthropic/v1/messages
with MiniMax-M3[1m]: identical system prompt hit-rate with and
without markers; cache_read field is unreliable (128 floor),
input_tokens drop (8467 -> 1) is the real hit signal.
* fix: make verify_on_stop opt-in everywhere (default False, not auto)
The verify-on-stop nudge was already judged more noise than signal: the
v31 migration flips existing installs off, the v32 migration catches the
baked-in literal-true population, and the docs tell users to 'treat off
as the effective default and opt in explicitly'. But DEFAULT_CONFIG still
shipped the "auto" sentinel, so exactly one population kept getting the
nudges: fresh installs (and any config missing the key), where "auto"
resolves ON for CLI/TUI/desktop surfaces. Live symptom: repeated
'[System: You edited code ... run verification]' interruptions the user
never asked for and had to hunt down in source to disable.
- DEFAULT_CONFIG: agent.verify_on_stop "auto" -> False (opt-in).
- verify_on_stop_enabled(): missing/unrecognized value now falls back
OFF instead of surface-aware; explicit "auto" still selects the
legacy surface-aware behavior, explicit bools unchanged, and the
HERMES_VERIFY_ON_STOP env override is untouched.
- No migration needed: v31/v32 already normalized existing installs,
and this only changes the merged default for configs without the key.
- Docs updated; default-path E2E test now asserts OFF, plus a new
missing-value regression test. Also added the standard win32 skip
marker to the symlink-based temp-dir test (pre-existing Windows
failure, same class as tests/cron/test_cron_script.py).
* test: update config goldens — verify_on_stop=False is now stripped as default
With the DEFAULT_CONFIG flip to False, the migration-write invariant
(_persist_migration / save_config strip_defaults) no longer materialises
verify_on_stop: false to disk unless the user explicitly set the key:
- V20 floor fixture (agent: {} on disk): v31's write is stripped —
agent stays {} and load_config() supplies False at read time.
- V12 floor fixture (explicit verify_on_stop: true on disk): the key is
a user-set path, so the v32 flip stays materialised as false.
- Partial-write and _persist_migration regressions now assert the key is
absent from disk and (for the merge case) that the merged view still
resolves False.
Behavior verified with a one-shot migrate_config run against both
fixture shapes.
* fix: warn agents off driving interactive console TUIs via pty on Windows
Driving 'gh auth login' (and other survey-style console TUIs) through a
pty background process on Windows silently hangs: these programs read
Win32 console key events via ReadConsoleInput, not the stdin byte
stream, so Enter keypresses submitted over process stdin never register.
The agent-visible symptom is a prompt frozen at 'Press Enter to open
browser...' while the user sees nothing, and a turn interrupt then kills
the process, invalidating any device code the user already entered on
github.com.
Two guidance fixes, both proven in a live session on Windows 10:
- agent/prompt_builder.py: extend _WINDOWS_BASH_SHELL_HINT to steer
agents toward non-interactive paths (flags, --with-token, config
files, curl-polled OAuth device flow) instead of answering console
prompts programmatically.
- skills/github/github-auth: document the pitfall and add the manual
OAuth device-flow procedure (curl against gh's public client_id,
poll for the token, finish with 'gh auth login --with-token'), which
succeeded first try after two interactive attempts hung.
* fix: send CRLF for Enter on Windows PTY submit; correct root cause in guidance
Review feedback (helix4u) was right on both counts:
1. Root cause correction. gh's 'Press Enter to open browser' prompt is
waitForEnter -> bufio.Scanner reading stdin, not a survey/console-API
prompt. The real bug is ours: submit_stdin appended a bare \n, and
through pywinpty/ConPTY a lone \n is not delivered as a line
terminator, so the child's blocking line read never returns. Verified
empirically against pywinpty 2.0.15 with a readline() child:
\n -> hang, \r -> line delivered, \r\n -> line delivered.
Fix: submit_stdin now appends \r\n for Windows PTY sessions (POSIX
PTYs and Popen pipes keep \n). Windows-only regression tests cover
the PTY and pipe branches.
2. Prompt hint rewritten: instead of claiming Windows console TUIs
cannot be driven, it now says to use process(submit) rather than raw
writes with bare \n, and to prefer non-interactive paths when a CLI
offers one.
3. Skill device flow rewritten as an executable script: parses the
device-code response, polls per the returned interval, handles
authorization_pending / slow_down (+5s per GitHub docs) /
expired_token / access_denied / unexpected responses, pipes the token
straight into gh without echoing it, and drops the undocumented
workflow scope (repo,read:org,gist is the documented minimum for
gh auth login --with-token). The pitfall note is narrowed to the
reproduced condition.
The PR added APIConnectionError handling to the main request and
iteration try blocks but missed the finalization drain loop (line ~1492).
That site catches httpx transport errors to preserve an already-completed,
already-billed response when the drain iterator fails. Without the
APIConnectionError handler, an SDK-wrapped transport error during drain
would propagate uncaught and discard the completed response.
Also strengthens the test's no-payload-leak assertion to check the full
request body and URL are absent from the log message, not just the
literal string 'payload'.
The Kimi team noticed that traffic from Hermes Coding Plan users
identifies itself as Claude (User-Agent: claude-code/0.1.0) rather
than the actual client. They asked us to update the UA so they can
properly attribute traffic and understand how their services are
accessed — especially important as they open up to more third-party
agents.
Three code paths were sending wrong/attribution-less headers to Kimi:
1. run_agent.py — _apply_client_headers_for_base_url sent
{"User-Agent": "claude-code/0.1.0"} for api.kimi.com. Now sends
the same _AI_GATEWAY_HEADERS set used for Vercel AI Gateway:
HTTP-Referer + X-Title + HermesAgent/{version} User-Agent.
2. agent/anthropic_adapter.py — the Anthropic Messages path for
api.kimi.com/coding sent 'claude-code/0.1.0'. Now sends the same
three-header attribution set.
3. plugins/model-providers/kimi-coding/__init__.py — both kimi and
kimi_cn profiles sent a static 'hermes-agent/1.0' with no
HTTP-Referer or X-Title. Now sends the full three-header set with
a dynamic version, matching the pattern used by the gmi, fireworks,
xai, and ai-gateway provider profiles.
The attribution header set (HTTP-Referer + X-Title + User-Agent) is
the canonical Hermes pattern used for OpenRouter, Vercel AI Gateway,
Fireworks, and other providers that read these headers for traffic
attribution.