The only caller passes a positive budget and a zero remainder already yields an empty head via _head_within_budget, so the early break never changed the result; the boundary branch now binds content once. Final-head gate (quality Lows).
Gate r1 Low: _head_within_budget hard-coded the 4 chars/token ratio that
agent.model_metadata already names CHARS_PER_TOKEN, and bisected even for
all-ASCII heads, whose estimate is exactly ceil(len / CHARS_PER_TOKEN) so the
budget-sized slice always fits. Return that slice directly when it is ASCII;
clamp the budget at 0 so a non-positive budget still yields ''.
Equivalence vs the previous implementation: 3000 random cases (a/ж/中/é/space,
budgets -2..20), 0 mismatches.
#132147 re-implemented the budget*4-capped estimator bisect inside
_truncate_input_text_parts, while FroRaut's #127458 introduced the same
search as _head_within_budget for the plain-string boundary. Two copies of
one search drift; the typed input_text path now calls the shared helper so
string and typed boundary items are cut by the identical rule.
Co-authored-by: FroRaut <79215537+Froraut@users.noreply.github.com>
When a native compaction checkpoint is replayed, the oldest
pre-checkpoint user message that only partly fits the retained-user
budget is cut at the front. The cut was content[: user_remaining * 4],
which counts characters, but the budget is charged with
estimate_tokens_rough, which counts UTF-8 bytes / 4 and one token per
CJK character. A Cyrillic boundary message kept about 2x the remaining
budget and a CJK one about 4x.
agent/native_compaction.py:prune_pre_checkpoint_items now cuts with a
new _head_within_budget helper, which binary-searches the longest
prefix whose _approx_tokens fits the remaining budget. ASCII text
still keeps user_remaining * 4 characters, and the str slice never
splits a UTF-8 character.
(cherry picked from commit 95b069263440372bec619bb195aa68ba42d213ca)
BN01 fold2 finding (2c Low): collapsing the over-budget branch in
d24e5875b7 read `item["content"]` unguarded. A user message whose text
is measured only via `output_text` (no `content` key) reaches that
branch when it exceeds the budget and raised KeyError, aborting the
prune. Use `item.get("content")` so it falls through to the
non-truncatable path (keep nothing, budget reset) as before the
refactor. The typed-boundary parametrize gains a row for it.
BN01 quality finding (Low): the over-budget user path had a string
branch and an else branch that each ended with their own
`user_remaining = 0`, and the else re-tested `parts_cost is not None`.
Fold them into one else that picks the head (string slice, typed-part
head, or nothing for a non-truncatable boundary), appends it when it
has non-blank text, and resets the budget once. Behaviour is unchanged:
a snapshot probe over string, blank, typed, blank-typed and text+image
boundaries prints identical output before and after.
BN01 efficiency finding (Low): the head search over an oversized
input_text part started with hi = len(text), so each of its ~20 probes
re-estimated slices up to half the whole part on every native-compaction
request. estimate_tokens_rough charges every char at least 1/4 token
(ASCII (n+3)//4, other non-CJK >= 1 UTF-8 byte, CJK-dense 1), so no
prefix longer than budget*4 chars can fit and the cap never changes the
result. Proven with a randomized real-import check (2000 cases across
ASCII, Cyrillic, CJK, emoji and mixed text: 0 mismatches); 1M Cyrillic
chars at budget 64k: 40.8ms -> 28.0ms.
BN01 quality finding (Low): _truncate_input_text_parts copied each
fully-fitting part with {**part} although it never mutates it. The
fully-fitting item path already appends the original item uncopied,
and the clipped boundary part is still a new dict. No caller mutates
the returned parts (the result is only placed into the retained
message), so the copy was dead work.
Keep the converter->pruner checkpoint/no-checkpoint case (the issue's
headline: an older completed ask must not survive while the newest
oversized typed ask is dropped) and one boundary case covering multipart
clipping, the token-estimator budget for CJK text, and an oversized
mixed text+image boundary that now retains no older ask (behaviour change:
previously the oversized boundary was skipped and an older ask kept).
Also drop the provenance-footer disclaimer from the docstring.
Parallel fix in #131819 by liuhao1024 credited here.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
auxiliary_client.py is over the 2,000-line target and may only shrink; the normalizer this PR rewrites now lives in agent/auxiliary_codex_response.py and the adapter late-imports it. No behaviour change. Fixes the code-health ratchet (8426 > 8407).
An incomplete response with incomplete_details.reason=content_filter kept
finish_reason 'content_filter'. The aux consumers (context compressor
truncation guard, micro_compaction, MCP sampling) only reject 'length', so a
filtered partial summary was committed as if it were complete. No aux
consumer reads 'content_filter', so map every provider-incomplete response to
'length' (completed tool calls still stay 'tool_calls').
The aux leak rescue re-derived the normalizer's completeness gates and got the
phase gate wrong: a completed response with a commentary item plus unphased
tool-call-shaped text came back as (text, 'stop') while the same mix with
plain text came back as 'length' (jonpol01 review on #132163).
Add recover_leaked_tool_call to _normalize_codex_response; aux passes False
because it has no continuation to re-elicit a real call. The text then stays
content and the normalizer's own phase/completion rule decides stop vs
incomplete, so the rescue block, its four private imports and the false
"continuation path will re-elicit" WARNING per aux call all go away.
The leak-like-answer rescue fell back to response.output_text whenever no
final-phase message carried text, even when the output had a commentary or
analysis item. The shared normalizer never treats output_text as the answer in
that shape (it is the streamed narration), so the aux path promoted mid-turn
commentary to a final "stop" answer. Apply the same gate: use output_text only
when no message item has a commentary/analysis phase.
Gate review r3 (2c Low #1). New matrix row: empty commentary item plus a
leak-like output_text must stay (None, "length").
The leak-like-answer rescue only refused responses with an unfinished *message*
item. A completed response whose function_call is still in_progress is equally a
partial turn (the shared normalizer flags every non-server-side item status in
_INCOMPLETE_STATUSES), yet the aux path rescued its text as a final "stop".
Mirror the normalizer's gate: any item whose type is not in
_SERVER_SIDE_TOOL_CALL_TYPES and whose status is unfinished blocks the rescue.
xAI's in_progress web_search_call on completed responses keeps working.
Gate review r3 (2c Low #2). The partial-item matrix row now uses an in_progress
function_call next to the leak-like message.
The completed-answer rescue copied the normalizer's message-item text
selection but not its output_text fallback, so a completed answer that
resembles a tool call and arrives only through output_text (empty output,
or an empty message item, from Codex-compatible hosts that return a
Responses object) was still dropped as empty "length".
Fall back to output_text when no message text is found. Commentary-only
responses keep codex_message_items set and never reach this branch.
Found by BN02 r2 quality review.
The aux rescue for completed answers that resemble a leaked tool call
gated only on response status != "incomplete". That let it override the
normalizer's other partial signals: a message item still in_progress or
incomplete, or a response that is in_progress/queued. A truncated summary
ending in `{"cmd": ...}` was then returned as a complete "stop" answer,
while plain text in the same shape correctly became "length".
Narrow the rescue to status == "completed" with no unfinished message
item (the shared _INCOMPLETE_STATUSES set). Found by BN02 r2 2c review.
Gate Lows on BN02 (efficiency/quality): pass the already computed
issuer_model to _consume_codex_event_stream instead of rebuilding the same
expression; use codex_responses_adapter._lower_or_none for status/phase
instead of re-implementing it; drop the fake stream's dead cancelled-status
mapping and output_text delta branch, which no remaining test case reaches.
No behaviour change.
Gate finding BN02.r1.2c (Low): routing aux output through the shared
_normalize_codex_response imported the main loop's leaked-tool-call
heuristic, which clears text like an action lead-in followed by
{"cmd": ...} and returns "incomplete" so the main loop can re-prompt for a
structured call. Aux has no continuation, so a completed compression summary
quoting such text was rejected as truncated ("length"). When the response
status is not incomplete and there are no tool calls, keep the final/unphased
message text and the base "stop"; status=incomplete still maps to "length".
Gate finding BN02.r1.2c (Medium): the aux incomplete-status override mapped
every status=incomplete response to "length", including responses whose
completed function_call items the shared normalizer had already classified
as "tool_calls". MCP sampling (tools/mcp_tool_sampling.py) dispatches tool
use only on finish_reason == "tool_calls", so a max_output_tokens cut after
the call was emitted silently dropped the server's tool call. Tool calls now
win, matching the main loop's _normalize_codex_response ordering.
create() re-ran classify_responses_route/_classify_responses_issuer that
_build_responses_kwargs had already computed for the same host. Carry the
issuer out on the request-local resp_kwargs (popped like _wire_aliases, never
sent) so request replay and response normalization cannot drift.
Also drop the status/incomplete_details/error attributes attached to the
Chat-shaped response: no aux consumer reads them; finish_reason ("length")
is the contract the compressor already honours.
Co-authored-by: Xipong <217837358+Xipong@users.noreply.github.com>
Key the cached projection on (home, registered qualified names) so an
enable/disable/install that changes the plugin registry rebuilds it on the
next read. This removes the force-discovery invalidation hook and the
plugin_key metadata field from hermes_cli/plugins.py (over the file-size cap)
and the duplicated scan-time disabled filters: the live filter already runs
on every read.
The messaging gateway's stacked tokens come from the native (filesystem-only)
split, and filesystem commands win every collision in the interactive map, so
looking them up there loads the same skills. The table= override was a second
guard on a boundary the split already holds; the gateway test still goes red
when the native split resolves plugin tokens.
Also pass encoding= to the new tests' write_text calls (Windows footgun lint).
get_plugin_skill_commands() re-ran discovery and re-parsed every plugin
SKILL.md on every call; it sits behind cli.get_skill_commands() and the
per-keystroke completion paths (94 ms/call measured with 60 plugin
skills). The projection is now cached on the resolved Hermes home like
the filesystem scan; config disable lists are re-applied on every read
so a config-only disable still takes effect live, and force plugin
rediscovery (enable/disable/install) invalidates the cache at the
discover_and_load chokepoint. /reload-skills rebuilds the projection
and its receipt now counts plugin skills.
Include namespaced ctx.register_skill entries in CLI/TUI completion and
invocation while keeping native messaging command maps portable. Cover
compatible -s preloading and preserve disabled, platform, environment,
and profile gates.
Based on the qualified interactive-registry approach from #85725.
Co-authored-by: Konrad Stawiski <konrad.stawiski@umed.lodz.pl>
(cherry picked from commit cd3e30b5ceb5da9ef9daabc2458d0ea2e340da04)
When a context file still overflows (small windows, other repositories), the marker
listed only char counts, so the agent could not tell whether what it lost mattered.
It now lists the markdown headings in the omitted middle (fenced # comments ignored).
Reasoning off sent thinking: {type: disabled} to both. Anthropic documents
that Opus 5.5 always thinks and 400s on disabled, and that Sonnet 5.5 400s
on disabled with a message pointing at between_tools, its lowest setting.
Every /reasoning none turn and every auxiliary call that disables thinking
(titles, compression) failed on these models.
Opus 5.5 joins Fable in keeping the omission; Sonnet 5.5 sends
between_tools. Sonnet 5 still takes the plain disable.
When the model answers beside a housekeeping tool call (memory, todo_list,
skill_manage, session_search) and the follow-up is empty, the turn ends on
that pre-tool answer (fallback_prior_turn_content, since #10472). The final
then repeats text the client already streamed before the tool, but nothing on
the wire says so: message.complete looks like a new response.
Record the reuse where it happens (the housekeeping fallback and the
budget-exhaustion reuse of a previewed verification candidate), report
response_reused only when the final still equals that text (a footer or
explanation appended later drops it), and carry it on message.complete.
Never inferred from equal text: "Done." / tool / "Done." are two responses.
_lookup_supports_vision and _accepts_tool_result_images still documented a
ProviderProfile.supports_vision probe that 08feaa98b5 deliberately removed.
That stale docstring led triage (claude-subscription-directsdk#67) to
propose re-adding it. Document the actual contract: the provider-wide flag
gates tool-result media only; per-model attachment vision comes from
ProviderProfile.model_capabilities via the catalog probe.
Add a guard test pinning that contract so the probe is not resurrected.
_external_process_spec asked PATH only (shutil.which) for a provider's
process_command, so a backend started by a LaunchAgent or Hermes Desktop
(bare PATH) reported 'claude' missing even when it lived in
~/.local/bin, ~/.claude/local, ... -- the prefixes agent/anthropic_adapter
already probes for version detection. run_oauth_setup_token had the same
PATH-only lookup.
Add anthropic_adapter.find_claude_code_cli(), which resolves a bare
claude/claude-code name via hermes_platform's locate_command over the
adapter's existing _CLAUDE_CODE_PREFIXES, and use it on a PATH miss in
_external_process_spec and in run_oauth_setup_token. PATH hits and every
other command are unchanged.
On Claude 4.7+/5.x Hermes requests thinking display=summarized. A
thinking-only end_turn hit the reasoning-only clean-stop path and returned
the summary (written by a separate model) as the answer. Skip promotion
when reasoning_details carries a signed Anthropic thinking block or a
*.native_assistant carrier so the empty-response continuation runs.
Fold2 PROFX finding 1 (reuse/quality): `_SENSITIVE_HOME_DIRS` had become a
bare alias of agent.file_safety.HOME_CREDENTIAL_DIRS with one reader. Drop
the alias and use the shared constant at that reader so there is one name
for the list. No other references exist in the repo (tests/ and plugins/
included).
Gate r1 (2c note): agent/context_references._SENSITIVE_HOME_DIRS was a hand
copy of the home credential dirs and missed .config/gcloud, so
`@file:~/.config/gcloud/application_default_credentials.json` expanded the
gcloud refresh token into context; the canonical read deny-list does not
cover it either. The gateway feeds untrusted remote text through this
expansion, so this is the same leak class the profile-export fix closes.
_SENSITIVE_HOME_DIRS now is agent.file_safety.HOME_CREDENTIAL_DIRS, the
single list the write denylist and profile export already derive from.
file_safety imports stdlib only, so there is no import cycle.
The existing canonical-denylist test gains a .config/gcloud ADC row.
Co-authored-by: Keiran Haax <196126709+keiranhaax@users.noreply.github.com>
#132201 stopped profile export shipping .ssh/.aws/.gnupg/.kube/.envrc, but
the other credential dirs the file tools already write-deny still shipped:
a skill dir copied from a home carries .docker/config.json (registry auth),
.azure/accessTokens.json, .config/gh/hosts.yml (GitHub token) and
.config/gcloud/ (ADC / credentials.db), and the default-profile export plus
named exports archived them verbatim (binary DBs skip the redact pass).
The home-relative credential dirs now live once in
agent.file_safety.HOME_CREDENTIAL_DIRS; build_write_denied_prefixes and the
profile export (_OS_CREDENTIAL_STORES) both derive from it, so the write
denylist and the export filter cannot drift. Nested stores (.config/gh) are
matched on trailing path components, so only .config/gh and .config/gcloud
are dropped and the rest of .config still ships. .config/gcloud is included
for parity with file_safety. Distribution installs treat the same paths as
installer-owned via PROFILE_CREDENTIAL_PATHS.
Co-authored-by: Keiran Haax <196126709+keiranhaax@users.noreply.github.com>
With the try/except gone, stub agents (SimpleNamespace in tests/agent/test_system_prompt.py)
raised AttributeError on the say= argument before recover_at_startup ran.
Gateway start ran the left-core migration only for the launch home, so a secondary profile that
used Home Assistant as its event platform started without the plugin and without its adapter (and a
named launcher's default-root primary was never migrated). Each secondary now runs the migration in
its own runtime scope before its plugins are discovered, and gateway start covers the launch home and
the default root (the multiplex primary). The gateway-start helpers live in gateway/run_startup.py;
run.py and agent_init.py lose the try/except wrappers, since recover_at_startup never raises.
An agent-start install of a row that ships a platform now tells the user to restart the gateway
when one serves the home: the running gateway loaded its platforms before the plugin existed.
The Home Assistant gateway platform (plugins/platforms/homeassistant) and the
homeassistant toolset (tools/homeassistant_tool.py) now ship as the official
catalog plugin `homeassistant` (NousResearch/hermes-homeassistant), with the
same platform name, toolset/tool names, config keys and env vars.
Migration (hermes_cli/left_core_migration.py, a generic "left core" table
reusing the memory-provider migration's helpers): every profile home that
used Home Assistant (HASS_TOKEN in its .env, platforms.homeassistant enabled
or holding a token, or the homeassistant toolset selected) gets the plugin
installed from the catalog at its reviewed pin by `hermes update`, and lazily
once per process at agent start / gateway start (honours
security.allow_lazy_installs). Every outcome reaches terminal, Desktop and
chat; a gateway-start outcome is handed to that home's first agent.
Core HA special cases become generic PlatformEntry seams the plugin uses:
trusted_inbound (allowlist bypass for event-bus platforms), display_tier
(built-in display defaults) and shared_env_prefixes (profile-clone ownership
rule for env shared with the plugin's tools). Cron delivery uses the existing
cron_deliver_env_var seam; HASS_TOKEN stays stripped from subprocesses via the
installed plugin's manifest secrets.
Gate r1 (2c Low): the comment in _latest_user_task_snapshot claimed the
split-turn gate measures the same authored text, which no longer holds.
Keep only the reason the reply quote is dropped first. No net line growth.
The oversized-active-turn split (#80449) refuses to fire when the
authored turn-opening request exceeds 1,400 characters. A long /goal
continuation prompt therefore pins its entire autonomous run in the
protected tail: every automatic pass finds an empty compress window,
records no_progress, arms the 300 s structural backoff, and the session
grows into the provider context limit. /compress hits the same empty
window.
The token soft ceiling already bounds the request row, and the split
request is restated verbatim after the handoff by
_reappend_inflight_user_task, so the character cap only blocks
progress. Drop it from the split gate. The historical task snapshot
keeps its own cap and reply-quote handling.
(cherry picked from commit 0081611ff1f088ae0cca86d916ee802e74fe465a)
Skill Sync only worked against a Nous Portal sync plane that was never made
publicly accessible, so no install has ever used it. We don't want it in core
right now, so this deletes it outright instead of moving it behind a plugin
hook surface nobody needs yet:
- `hermes sync` command, `tools/skills_sync_client*.py` and their tests
- the personal pull ticks (gateway housekeeping, classic CLI startup,
`hermes serve` maintenance) and the debounced post-write push
- the per-skill `sync` usage flag (`set_sync` / `is_sync_enabled`)
- the `skills/_org/<org_id>/` mirror: index gating on `.active_org`, the
`[org-shared]` / name-collision labels, the org provenance header and
`org_provenance` field on skill_view, the org delete guard and the
auto-propose `org_sharing` note on skill_manage
The code is preserved in the private NousResearch/hermes-skill-sync repo if
the feature comes back.
r2 gate (quality, Low cleanups):
- is_disabled_entry: drop the dead `bool(disabled) and`, because isdisjoint
on an empty set already gives False.
- agent/skill_commands.py: import AMBIGUOUS_SKILL_PREFIX once at module
level instead of inside two adjacent functions. There is no cycle:
agent/skill_utils.py imports neither skill_commands nor prompt_builder.
- prompt_builder hides(): drop `frontmatter_name in disabled`. The
post-resolve is_disabled_entry already matches the declared name, and
hidden copies still take part in resolution, so the index output is
unchanged. The bare `skill_name` (dir name) term stays: it is base
behaviour that list/view never had, and it is not changed here.
r2 gate (2c, Low): `skills.disabled: [a/one]` — what `hermes skills` / the
web toggle save for the same-tier duplicate shown as `a/one` — matched every
copy's relative path, so an unrelated, uniquely named skill in another tier
at the same relative path (external `e1/a/one` named `other`) vanished from
list, index, view, preload and cron while the config UI showed it enabled.
is_disabled_entry now matches the declared name or the entry's load_name
(the relative path only for an ambiguous same-tier row). skill_view applies
the path rule only when that path is the resolved copy's catalog load_name.
This is the reviewer's intent but not the literal `name == rel` check: that
check would let the disabled a/one copy load through its directory alias
(`one`), which r2 verified is refused. The extended test pins both cases.
Gate quality findings (Low): reload_skills reached into
tools.skills_tool._SKILLS_CACHE directly -- add clear_skills_cache() next
to the cache and call it. _collect_skill_candidates still said "the
caller refuses >1", but after the precedence change the caller resolves
candidates via pick_skill_candidate and only refuses same-tier ties.