Commit Graph

33093 Commits

Author SHA1 Message Date
kshitijk4poor ee7fc3bc06 refactor(auth): one stat per store file in the heal fingerprint
_size duplicated _mtime_ns with a different attribute and the fingerprint
stat'ed each of the four files twice, so mtime and size could come from two
different versions of the file. _stat_sig returns both from one stat. The
persisted compare goes through a JSON round-trip so nested tuples match the
lists they read back as.
2026-09-09 21:17:35 +05:30
kshitijk4poor f3ae63162e test(auth): keep the persisted clean-mark tests to the two invariants
A fresh process with an unchanged store takes zero auth-store locks, and a
root store that gains a forked grant invalidates the persisted mark so the
heal re-runs. The corrupt-mark fallback, same-mtime size change and
no-secrets checks were pinning implementation details of the same cache.
2026-09-09 21:17:35 +05:30
John Paul Soliva 57e03e2be6 perf(auth): persist the forked-OAuth clean mark so a fresh process skips the heal's locks
`_heal_forked_single_use_oauth_grants()` runs on every `load_pool()`, and its
clean mark lived only in memory. Every fresh `hermes` invocation and every new
worker therefore re-took the heal's two nested EXCLUSIVE auth-store locks just
to rediscover a store it had already cleared — 4 acquisitions per process on a
two-provider profile, every one of them finding nothing to consolidate. Behind
a sibling process holding those locks that costs a full
`AUTH_LOCK_TIMEOUT_SECONDS` per provider before the process can do anything at
all: measured 30.1s for two providers.

Persist the mark next to the store it describes (`<profile>/cache/
oauth_heal_clean.json`, 0600, paths and stat data only — no credential
material) and consult it BEFORE taking any lock.

Outliving the process means the mark needs a stronger key than the in-memory
one did:

- The ROOT store joins the fingerprint. This heal consolidates root → profile,
  so root acquiring a counterpart turns a row the heal deliberately KEPT into a
  fork it must strip. The in-memory mark could ignore root because it died with
  the process; a persisted mark would keep skipping a heal that has become
  necessary.
- File sizes join it too, so a metadata-preserving rewrite (`rsync -t`,
  `tar -p`, a restore) cannot leave a stale mark looking current indefinitely
  rather than for one process.

Measured on an isolated HERMES_HOME with two OAuth providers:

    lock acquisitions per fresh process    4     -> 0
    contended load_pool() x2               30.1s -> 0.00s
    mark-file reads per 100 load_pool()    -     -> 2 (one per provider)

The mark stays a cache: absent, unreadable, corrupt or wrong-shaped content all
mean "unknown" and fall through to the locked heal, and a failed write only
means the next process re-runs it — the behaviour before this cache existed.
2026-09-09 21:17:35 +05:30
kshitijk4poor e2bd400233 fix(models): only cache unreachability, not HTTP errors; key the entry via base_url_origin
An HTTPError means the host answered — a 401 from a wrong API key must not
be remembered as "unreachable" for the next 60s, or a user who fixes the key
gets a cached empty catalog on the immediate re-probe. Connection-level
failures (timeouts, refused, DNS) are the only thing the cache records.

_probe_neg_key hand-rolled scheme/port defaulting that utils.base_url_origin
already provides; use it.
2026-09-09 21:16:48 +05:30
kshitijk4poor 20762c67eb test(models): trim probe negative-cache tests to two invariants 2026-09-09 21:16:48 +05:30
kshitijk4poor 4e2a871adb fix(models): clear probe negative-cache entry on a successful probe 2026-09-09 21:16:48 +05:30
finn763 48b8528e7c fix(desktop): stop UI freeze on unreachable provider Closes #81123 2026-09-09 21:16:48 +05:30
kshitijk4poor 6acd90d11a refactor(dashboard): drop the redundant hash() pre-check in coalesced_read
pending.get(key) already raises TypeError for an unhashable key.
2026-09-09 21:16:40 +05:30
kshitijk4poor a4113eb994 test(dashboard): keep the read-coalescing tests to the two admission invariants
The contributor's 15-test module pinned encoding details (frozen-arg shapes,
postponed-annotation resolution, monkeypatch seams). The two behaviour
contracts that matter survive: a 12-request /api/profiles burst admits ONE
worker and leaves /api/status responsive on a 2-token pool (this file), and
the kanban board read shares one worker per key (test_kanban_read_admission).
Both go red when the coalescing wrapper is removed from the route.
2026-09-09 21:16:40 +05:30
Gianpietro Dal Zio 653418d842 fix(dashboard): coalesce expensive reads before worker admission 2026-09-09 21:16:40 +05:30
kshitijk4poor 1eaeb73839 fix(models): OpenRouter disk snapshot follows the configured catalog TTL, not the default constant
refresh_interval_seconds() honours model_catalog.ttl_minutes / legacy ttl_hours;
reading DEFAULT_TTL_MINUTES would let the snapshot and the manifest it is
filtered from go stale on different clocks for anyone who changed the TTL.
2026-09-09 21:16:28 +05:30
kshitijk4poor 441157f184 test(models): two invariants for the OpenRouter curated-catalog disk cache 2026-09-09 21:16:28 +05:30
kshitijk4poor 8b66bc88df refactor(models): OpenRouter disk cache reuses catalog TTL and atomic JSON cache helpers 2026-09-09 21:16:28 +05:30
Hyperion 4c003069f3 fix(models): persist curated OpenRouter catalog to disk so picker opens fast
Re-derived from PR #96099 (f127ec4e) on current main; the :nitro/:floor validate hunk is omitted because main already handles routing suffixes in hermes_cli/models_validate.py.
2026-09-09 21:16:28 +05:30
kshitijk4poor b059f56fa3 chore: map contributor email for PR #96099 salvage 2026-09-09 21:16:28 +05:30
kshitijk4poor 84e1761725 chore(desktop): drop in-repo repro script (ran git stash in the checkout) 2026-09-09 21:13:48 +05:30
finn763 b4ccbccf9c fix(desktop): calm background sessions Closes #73287 2026-09-09 21:13:48 +05:30
finn763 a74e76632c perf(picker): read Nous pricing cache-only when building the picker row
_nous_picker_model_ids only uses the ids the Portal unions append — both
unions discard the pricing map (`model_ids, _ = union_with_portal_*`) — yet
it called get_pricing_for_provider("nous") without cached_only, so a cold
pricing cache paid a full /v1/models round-trip (network timeout on a slow
Portal) on the picker-open path for nothing. Pass cached_only=True; the
background pricing prewarm (#101685) fills the same cache for later opens.

Re-derived from #102099 by @finn763: the original patched
hermes_cli/model_switch.py, which 3b1ecfc0a1 decomposed; the live call site is
hermes_cli/model_switch_providers.py.

Based on #102099 by @finn763.
2026-09-09 21:12:22 +05:30
kshitijk4poor 88781af371 chore: map moken627-hub contributor email (PR #102676 salvage) 2026-09-09 21:11:56 +05:30
kshitijk4poor b36f654271 test(profiles): pin projects/tree single-flight as a behaviour contract
Replace the attribute-presence check (asserts a cache_clear attribute the decorator does not
expose) with one invariant: eight concurrent projects/tree requests run the profile scan once
and each caller gets its own copy of the payload. The scope-suite fixture now disables the TTL
instead of calling a non-existent clear hook, which silently no-op'd.
2026-09-09 21:11:56 +05:30
moken627-hub 35af06c009 fix(desktop): coalesce projects/tree sidebar scans and memoize raw config parses
The all-profiles sidebar polls GET /api/profiles/projects/tree, the one
heavy sidebar endpoint that was not wrapped in @_sidebar_singleflight_cache.
Every poll fanned out list_profiles() + _build_project_tree() over every
profile (51 on this box, ~17k SKILL.md files walked), and each profile
resolution re-parsed its config.yaml because read_user_config_raw() ran
uncached. On a 2-vCore VPS running 'hermes serve' for the Desktop remote
backend this pinned both cores (py-spy: 110-116% sustained, HostHighCPU).

- Wrap get_profiles_projects_tree in the existing single-flight cache,
  matching get_profiles_sessions_sidebar (5s TTL, errors[] not cached).
- Memoize read_user_config_raw() on a (st_dev, st_ino, st_size,
  st_mtime_ns) fingerprint under _CONFIG_LOCK, same strategy as
  read_raw_config(). Hits return a deepcopy so write-back round-trips keep
  their fresh-dict semantics; parse errors are never cached; the docstring
  no longer claims 'no caching'.
- Tests: memo semantics (deepcopy isolation, inode-replace reparse, error
  non-caching, parse-count), a wrapper-presence pin for both heavy sidebar
  endpoints, and a cold-cache autouse fixture in the scope tests (the 5s
  TTL otherwise leaks one test's payload into the next).

Measured on the affected box: list_profiles 1.9s -> 0.16s warm, serve CPU
112% -> 22-34%, host CPU 50-70% -> ~26%.
2026-09-09 21:11:56 +05:30
kshitijk4poor 0272647ad0 test(desktop): hud edge poll parks while the window is not viewed 2026-09-09 21:11:16 +05:30
finn763 b9fea361ec fix(desktop): stop idle renderer burn Closes #88275 2026-09-09 21:11:16 +05:30
kshitijk4poor ead7e91dab refactor(recovery): stream the salvaged population; table the shape rules
- Pass 1 no longer materialises every classified record (full
  `messages.content` included) until pass 2; `LayoutEvidence` keeps only the
  capped per-position value sets (+ sessions rows for the one cross-column
  invariant) and pass 2 re-streams the lost_and_found tables. A 276 MB
  corrupted store no longer has to fit in memory.
- `_sentinel_holds` / `_text_shape_holds` if-ladders become rule tables.
- Tests trimmed to the three that bind behaviour (upgraded store maps by
  name; verifier refuses when rows matched no layout; replayed history ends
  at the current schema — the drift guard). No behaviour change; reverting
  inference to "no layout" still fails the name-mapping test.
2026-09-09 18:28:57 +05:30
kshitijk4poor 17c43aba06 fix(recovery): infer the salvaged store's physical layout from its schema history
A store's physical column order depends on which schema it was created at and
which ALTER TABLE ADD COLUMNs it lived through; one hardcoded "upgraded"
layout cannot cover them. `session_schema_history` records the declared
schema of the salvaged tables over time; `reachable_physical_layouts`
replays it to enumerate every physical order a store can have. The mapper
infers the layout once per kind from the whole recovered population (one
store wrote all of them), maps cells by name, and counts records whose width
matched no layout; the recovery verifier refuses to report such a salvage as
healthy.

Rebased onto the simplified session modules (c88d60551e, b9b4600cb2,
7e5a1a11d9, 1915a0d27e); no behaviour change from the pre-rebase branch.
2026-09-09 18:28:57 +05:30
Justin Wilson 7704712168 fix(sessions): map lost_and_found cells by physical column names
Upgraded state.db files gain columns via ALTER TABLE ADD COLUMN, so
physical order diverges from SCHEMA_SQL. Prefix-mapping onto the fresh
template put started_at at 0 and shifted titles/models. Insert by name
using known physical layouts; keep the plausibility gate.

Fixes #101409
2026-09-09 18:28:57 +05:30
kshitijk4poor a7f2a593d1 fix(db): honour require_wal on the probe-unknown path; touch nothing at all
Follow-up to the probe-unknown guard:

- `require_wal=True` now raises WalUnsupportedError when the on-disk mode
  cannot be read instead of reporting an unverified "wal" (the function's
  contract is "the mode actually set").
- Drop `_apply_wal_companions` from the branch: journal_size_limit /
  synchronous pragmas were being applied to a file whose mode is unknown,
  contradicting the "touch nothing" rule the branch exists for.
- Cut the retracted #104596 mechanism narrative from comments, log text and
  docstrings; the guard is hardening (same rule the DELETE branch already
  applied), not a root-cause fix.
- Tests: one binding test per file (no pragma of any kind reaches the
  connection; require_wal raises); drop the log-dedupe test (`_log_once`
  is already covered). test_captures_cause_on_failed_init's double now
  fails only set-pragmas, matching the read-only-mount it simulates.
2026-09-09 18:26:15 +05:30
webtecnica d0653ab090 fix(db): never emit WAL set-pragma when the on-disk probe fails (#104596) 2026-09-09 18:26:15 +05:30
kshitijk4poor 9e84ce5bae refactor(state): one pre-open header reader for both probes
`is_zeroed_state_db` and `has_invalid_sqlite_header_preopen` shared the
is_file / stat / live-connection / read_header preamble; `_preopen_header`
owns it now (zeroed is the NUL subset of "no SQLite header"). The
quarantine message also names `hermes sessions recover --source <bak>` so
the preserved bytes are actionable, not just parked.
2026-09-09 18:25:11 +05:30
Ahmett101 dc8044c086 fix(state): quarantine a state.db whose page 0 is not SQLite, not just a zeroed one
Startup only quarantined a 0-byte / all-NUL state.db. A file whose first page
was clobbered with record bytes (#102198) went straight to sqlite3.connect,
which raised "file is not a database" and deleted the -wal sidecar — the one
piece of evidence that could have been recovered.

`has_invalid_sqlite_header_preopen` generalises the zeroed probe (zeroed is a
subset of "no SQLite header"; same live-connection contract, never raises).
`quarantine_invalid_state_db` moves the file AND its -wal/-shm aside as
`state.db.<zeroed|notadb>-<ts>-<pid>.bak` before anything opens it; a fresh
DB is created as before.

Re-authored on current main (the quarantine helpers moved to
hermes_state_dbfile.py in d15c61b5dc); one regression test proves the
notadb case is quarantined with its sidecars and the new DB passes
integrity_check.

Refs #102198 (the write-after-SIGTERM that clobbers page 0 is not addressed
here; this preserves the evidence instead of destroying it).
2026-09-09 18:25:11 +05:30
kshitijk4poor 9e6c4100cb fix(agent): close the interrupted tool tail on the overflow terminal
The overflow-terminal path ends the turn without reaching finalize_turn, so
a transcript that overflowed right after a tool batch ended on a raw tool
result; strict providers reject the next user turn (tool -> user). Close it
with the same final text, mirroring the truncated-tool-call terminal above.

Also: classify once before either log so an overflow no longer emits a
"so the loop can continue" WARNING followed by the contradicting "NOT
seeding" one; reset the stale-streak breaker once for both branches; drop
the "compression could not recover it" wording (this path never reached
compression); trim the test file to the three tests that bind behaviour
(stream -> terminal stub; 413 stays non-terminal; the terminal ends the
turn, closes the tool tail, carries compression_exhausted).
2026-09-09 17:45:12 +05:30
ca-shrimp 3b0e81459b fix(agent): carry compression_exhausted bit; scope overflow terminal to context_overflow
Address review P1s (andrexibiza) on #106266:

1. The overflow-terminal exit in recover_from_truncation now forwards the
   #98722 typed compression_exhausted bit (partial_result/end_turn gained the
   flag) so the gateway resets/moves future input to a clean session instead
   of leaving the bloated durable session authoritative for the next turn.

2. _overflow_terminal is scoped to FailoverReason.context_overflow ONLY.
   payload_too_large (413) has its own byte-scored recovery owner
   (turn_overflow._recover_payload_too_large, #88960/#47339) that must not be
   bypassed; a post-delta 413 keeps its normal continuation stub. Regression
   covers both lanes.

Tests: unit asserts result compression_exhausted=True on the marker; a
real streamed partial hitting a 413 payload-too-large error keeps content and
is not terminal. 50 streaming/continuation/gateway regressions pass.
2026-09-09 17:45:12 +05:30
ca-shrimp ece584b8f5 fix(agent): don't seed continuation stub after a context-overflow stream death
When a stream delivered text and then died on a context-overflow /
payload-too-large error, the partial content (often tens of KB) was seeded as
a length-continuation stub, growing the transcript monotonically. In a
session whose transcript cannot be compressed back under budget
(protect_last_n covers everything -> no_progress, or the summary would
itself be larger -> would_grow), every later request is larger than the one
that just failed — an unrecoverable loop where the user sees a 30+ minute
fake hang and the only remedy is killing the session (#106260).

classify_api_error already labels these errors context_overflow /
payload_too_large (should_compress=True). _partial_stream_stub now returns
an EMPTY stub marked _overflow_terminal for that class instead of seeding
the recovered text, and recover_from_truncation treats the marker as
terminal: the turn ends via the recovery contract with a clear message
(start /new) and the transcript is not polluted with the partial.

Normal partials (network stall, output-cap truncation, tool-call drops) are
unchanged — only the overflow error class changes behavior.

Tests: stub marker + empty content; a real streamed partial hitting a
'maximum context length' error returns the terminal stub; recover_from_
truncation ends the turn (no fragment/nudge appended) on the marker while a
normal stub still runs the continuation path. 67 streaming/continuation
regressions pass.
2026-09-09 17:45:12 +05:30
hermes-seaeye[bot] ed2d821021 fmt(js): npm run fix on merge (#106554)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-09 12:04:16 +00:00
hermes-seaeye[bot] 9e7b4eca47 fmt(js): npm run fix on merge (#106539)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-09 11:58:08 +00:00
kshitijk4poor 809654d488 refactor(desktop): keep main's boot classification and share the ws-URL guard
Follow-up to the renderer-dial retry:

- Drop the `!connectionDescriptorResolved` gate so `bootFailureIsRetryable()`
  still decides every failure main can see (#82679 contract preserved for
  post-descriptor mint failures); the renderer-owned dial is OR'd in as the
  one failure main cannot classify. The budget check now short-circuits
  before the IPC, as on main.
- Four booleans → one `stage` variable; the `isGatewayReauthRequired` term
  is dropped (reauth is raised before the dial stage, so it was unreachable).
- `isGatewayWebSocketUrl` moves to `apps/shared/src/json-rpc-gateway.ts`
  and `JsonRpcGatewayClient.connect()` uses it, so the hook's "valid dial"
  predicate cannot drift from what `connect()` accepts.
- Tests: keep the three that bind behaviour (first dial retries under a
  stale ready snapshot; invalid URL terminal; post-connect failure
  terminal), drop the four that pinned the removed gate or duplicated the
  existing #82679 bound test. 46/46 green; reverting the hook to main makes
  the retry test fail.
2026-09-09 17:19:36 +05:30
cervantesh 60e90ed6e7 fix(desktop): retry transient remote gateway boot dials 2026-09-09 17:19:36 +05:30
nftpoetrist 511633be90 fix(agent): stop the run-budget wrap-up notice from mutating a persisted tool row
_maybe_inject_run_budget_wrapup() appends its wrap-up notice to the newest
role:"tool" message in place, with no _DB_PERSISTED_MARKER check. Its sibling,
_maybe_inject_iteration_budget_warning(), got exactly this guard added in the
same recent saga (turn_iteration_prep.py), with the comment "an older turn may
already be cached."

The reachability is structural, not an edge case: _maybe_inject_run_budget_wrapup
is only ever called from prepare_iteration(), at the START of the next iteration
-- strictly after tool_executor.py's _flush_session_db_after_tool_progress has
already flushed and marked the previous iteration's tool row persisted. So every
successful injection was mutating an already-persisted row: the wire request for
that turn carried the notice, but the durable transcript never did, diverging
replay from the live bytes and invalidating the provider's prompt-cache prefix
from that row onward.

Fix:
- Add the same _DB_PERSISTED_MARKER guard to _maybe_inject_run_budget_wrapup,
  scoped to the specific tool row the reversed scan lands on (not just
  messages[-1], since this function -- unlike its sibling -- scans backward for
  the newest tool row rather than only checking the tail).
- Wire _maybe_inject_run_budget_wrapup into _flush_session_db_after_tool_progress
  (pre-flush), mirroring exactly how _maybe_inject_iteration_budget_warning is
  wired in both places. Without this, the guard alone would make the notice stop
  firing in the common case, since prepare_iteration's call site almost always
  hits an already-persisted row -- the pre-flush call site is what actually lets
  it land in durable bytes.

Verified empirically: read the real call graph (tool_executor.py's three
_flush_session_db_after_tool_progress call sites cover every tool-completion
path) to confirm the guard's premise, then added an end-to-end test using a real
AIAgent + SessionDB that flushes and checks the persisted row for the notice
text. Mutation-verified: reverting the two production files drops exactly the 2
new/updated assertions (28 pass, 2 fail); reapplying restores green (30 passed).
Also ran the sibling iteration-budget-warning and /steer suites (71 passed) to
check for interaction regressions -- none.
2026-09-09 17:04:30 +05:30
Teknium bbf91b7650 docs(cron): describe snapshot-as-pin instead of the fail-closed drift guard
The user guide said an unpinned job "fails closed" on a global model change
and documented cron.model_drift_guard; both are gone. It now explains that the
job keeps running on its creation snapshot and how to move it (per-job pin or
cron.model).
2026-09-09 04:32:13 -07:00
Teknium bcff0a920e test(cron): replace fail-closed drift tests with snapshot-as-pin invariants
The old tests encoded the skip (agent never constructed, [drift_skip] text,
alert-once bit). New invariants, proven red on origin/main: an unpinned job
with model_snapshot=A / provider_snapshot=P runs with AIAgent(model=A) and
resolve_runtime_provider(requested=P) after the global default moved to B/Q;
an explicit job pin still beats the snapshot; cron.model / cron.model_provider
still beat the snapshot; a legacy job without a snapshot follows the global
default. test_cron_drift_alert_once.py is deleted with the bit it tested; the
config-notice and impact-summary tests drop guard_enabled / model_drift_guard;
the desktop toast test asserts the informational copy.
2026-09-09 04:32:13 -07:00
Teknium 7e4d02fef5 fix(cron): unpinned jobs run on their creation-snapshot model instead of failing closed
A global model/provider change must never stop a cron job. The #44585 guard
raised [drift_skip] for every unpinned job whose provider_snapshot /
model_snapshot no longer matched the live global default, so one `hermes model`
switch silently killed whole fleets (reported by fastfinge, nitinthewiz,
Dr-ilies; 13 of 60 jobs on the project lead's box after
claude-fable-5 -> claude-fable-5.1).

The snapshot is now the job's effective pin: _load_cron_job_config prefers
job['model_snapshot'] over the global default and _resolve_job_runtime passes
job['provider_snapshot'] as `requested` when neither a per-job pin nor a
cron.model / cron.model_provider fleet default covers the axis. One INFO line
per differing axis tells the operator what the job is running on and how to
move it. Jobs without a snapshot (legacy records) still follow the global
default; the existing fallback chain still handles a snapshot provider that
fails to resolve.

Both goals of #44585 hold: no silent inherit of a paid default (the job runs on
what it was created under) and no outage. Owner decision (Teknium): "main agent
model changing should not stop crons from executing, ever".

Removed as unreachable: _check_model_drift, DRIFT_SKIP markers, the
drift_alerted alert-once bit (mark_drift_alerted + the _record_run_outcome pop),
the drift special-cases in _compose_run_delivery and
_summarize_cron_failure_for_delivery, cron_model_drift_guard_enabled and the
cron.model_drift_guard config key (v42 migration drops it from existing
configs). The PLUGIN-COMPAT clear_drift_alerted block is untouched (scheduled
revert).

The `hermes config set model.default` notice and the Desktop model-change toast
are reworded from "will fail closed / will be skipped" to "keep running on the
model they were created under"; the impact payload drops guard_enabled (all six
desktop locales updated).
2026-09-09 04:32:13 -07:00
Teknium 48465c3933 fix(profiles): --clone-all no longer copies cron jobs into the new profile
Cron jobs are scheduled work bound to the source profile and its origin
channel. A clone that inherited cron/jobs.json fired every job twice: two
gateways with identical job ids running the same weekly jobs in parallel
(double spend, duplicate deliveries) until one gateway died.

Root cause: `cron` was not in _CLONE_ALL_HISTORY_EXCLUDE_ROOT, so the
copytree in _clone_all_into carried jobs.json along. Add it to the
per-profile history exclude set (applies to any source, CLI, dashboard
and TUI/desktop RPC all funnel through create_profile), recreate the
_PROFILE_DIRS skeleton after the copy so the clone still has an empty
cron/ (and sessions/), and say so in the CLI summary line and docs.

--clone (config-only) never copied cron; export/backup keep cron as
before (an archive is a portable snapshot, not a second live profile).
2026-09-09 04:27:24 -07:00
Teknium fd6434b3b3 chore: map contributor email for #47026 salvage 2026-09-09 03:52:47 -07:00
Teknium e1838c5b5a fix: trim opencode-go 422 salvage to the invariant set
Drop the bare "tool.content" pattern (any 400 mentioning tool.content in a
non-list context would be sent through the image-strip path) and the profile
flag snapshot test; the behaviour tests (classifier verdict + proactive
downgrade) already pin the contract.
2026-09-09 03:52:47 -07:00
ericmaddox bee840bc8c fix(providers,agent): handle strict-string tool message validation and 422 on opencode-go (fixes #104731)
- Declare `supports_vision_tool_messages=False` and `supports_vision=True` on `opencode_go` provider profile in `plugins/model-providers/opencode-zen/__init__.py`
- Route HTTP 422 errors through `_IMAGE_TOOL_RULES` and add `tool.content.str`, `tool.content`, and `input should be a valid string` patterns to `_MULTIMODAL_TOOL_CONTENT_PATTERNS` in `agent/error_classifier.py`
- Add unit tests for OpenCode Go proactive tool result downgrade, HTTP 422 Console Go classification, and profile capability contract in `tests/run_agent/test_multimodal_tool_content_recovery.py` and `tests/plugins/model_providers/test_opencode_go_profile.py`
2026-09-09 03:52:47 -07:00
Yuan Chenglu (袁成路) 6a0f519d1e fix(opencode-go): set supports_vision_tool_messages=False for Xiaomi MiMo backend
## Problem

When using the opencode-go provider with Xiaomi MiMo models (e.g.
mimo-v2.5, mimo-v2.5-pro), the Hermes agent intermittently fails with:

    Error code: 400 - {'error': {'code': '400',
      'message': 'Error from provider (Xiaomi): Param Incorrect',
      'param': 'text is not set', 'type': ''}}

This occurs specifically when tool results contain multipart content
with image_url parts (e.g. browser screenshots). The opencode-go relay
forwards these as-is to the Xiaomi MiMo backend, which rejects list-type
tool message content while still accepting multimodal user messages.

## Root Cause

The OpenCodeGoProfile inherits supports_vision_tool_messages=True from
ProviderProfile (the default). When this flag is True, the agent sends
tool results with image parts directly to the model. However, Xiaomi
MiMo's API rejects this format:

> "Set to False for providers that accept multimodal user messages but
> reject list-type tool content (e.g. Xiaomi MiMo, which returns 400
> 'text is not set')."
>   — providers/base.py, line 73

The direct 'xiaomi' provider profile already correctly sets this to
False (plugins/model-providers/xiaomi/__init__.py, line 13), but the
opencode-go relay profile was missing this safeguard.

The relevant code path is in run_agent.py:_tool_result_content_for_active_model()
(line 4543), which checks _provider_supports_vision_tool_messages() when
deciding whether to embed images in tool-result messages.

## Fix

Add supports_vision_tool_messages=False to the OpenCodeGoProfile
instantiation in plugins/model-providers/opencode-zen/__init__.py.

This single-line change prevents tool-result images from being sent
as multipart content to the MiMo backend, while preserving the model's
image recognition capability through user messages and vision tool
invocations (both of which use different code paths unaffected by this
flag).

## Testing

Verified with the mimo-v2.5 model via opencode-go provider:

1. Browser tool + screenshot recognition
   → Navigated to https://www.baidu.com, took screenshot, identified
     top 3 trending topics from the image
   → Result: PASSED, recognized all topics correctly

2. Direct image as user message
   → Sent a screenshot PNG directly via --image flag, asked model to
     describe the content
   → Result: PASSED, model correctly read text from the image

3. Provider profile verification
   → Confirmed get_provider_profile('opencode-go').supports_vision_tool_messages
     returns False at runtime
   → Result: PASSED

4. No regression on non-MiMo models
   → opencode-zen provider retains supports_vision_tool_messages=True
     (unaffected)

---

fix(opencode-go): 为 Xiaomi MiMo 后端设置 supports_vision_tool_messages=False

## 问题描述

使用 opencode-go provider 搭配 Xiaomi MiMo 模型(如 mimo-v2.5、
mimo-v2.5-pro)时,Hermes agent 间歇性地抛出以下错误:

    Error code: 400 - {'error': {'code': '400',
      'message': 'Error from provider (Xiaomi): Param Incorrect',
      'param': 'text is not set', 'type': ''}}

该错误发生在工具返回结果包含 image_url 类型的 multipart 内容的场景下
(如浏览器截图)。opencode-go 中继层将这些内容原样转发给 Xiaomi MiMo
后端,而 MiMo 接受多模态用户消息,但拒绝 list-type tool message 内容。

## 根因分析

OpenCodeGoProfile 继承了 ProviderProfile 的默认值
supports_vision_tool_messages=True。当此标志为 True 时,agent 会将含
图片的工具结果直接发送给模型。但 Xiaomi MiMo API 拒绝此格式:

> providers/base.py 第 73 行注释明确指出:
> "Set to False for providers that accept multimodal user messages but
> reject list-type tool content (e.g. Xiaomi MiMo, which returns 400
> 'text is not set')."

直接的 'xiaomi' provider profile 已正确设置了该值为 False
(plugins/model-providers/xiaomi/__init__.py 第 13 行),但
opencode-go 中继 profile 遗漏了这一安全设置。

相关代码路径:run_agent.py 的 _tool_result_content_for_active_model()
方法(第 4543 行),该方法通过检查
_provider_supports_vision_tool_messages() 来决定是否在 tool-result
消息中嵌入图片。

## 修复方案

在 plugins/model-providers/opencode-zen/__init__.py 的
OpenCodeGoProfile 实例化中添加 supports_vision_tool_messages=False。

这一行改动阻止了 tool-result 图片以 multipart 格式发送给 MiMo 后端,
同时通过用户消息和 vision tool 调用的路径(使用不同代码路径,不受
此标志影响)保留了模型的图像识别能力。

## 测试验证

使用 mimo-v2.5 模型通过 opencode-go provider 验证:

1. 浏览器截图 + 图像识别
   → 导航至 https://www.baidu.com,截取首页截图,从图片中识别出
     热搜榜前三条
   → 结果:通过,正确识别所有热搜话题

2. 用户消息直接传图
   → 通过 --image 参数直接发送截图 PNG,要求模型描述图片内容
   → 结果:通过,模型正确读取图片中的文字

3. Provider profile 运行时验证
   → 确认 get_provider_profile('opencode-go')
     .supports_vision_tool_messages 在运行时返回 False
   → 结果:通过

4. 非 MiMo 模型无回归
   → opencode-zen provider 保持 supports_vision_tool_messages=True
     不受影响

## 修改文件

  plugins/model-providers/opencode-zen/__init__.py (+5 lines)

Signed-off-by: Yuan Chenglu (袁成路) <ycl_pj@163.com>
2026-09-09 03:52:47 -07:00
Teknium 416b2bd281 chore: map contributor emails for salvaged #76158 (mengyuyuan) and #105470 (webtecnica) 2026-09-09 03:52:03 -07:00
Teknium c2524736d1 fix(desktop): a late startup locale read never overrides the user's in-session pick
With the startup config read now retried (previous commits, #76158), a retry
can resolve after the user has already chosen a language in Settings and
would repaint the stale on-disk value over it. Mark the explicit pick in a
ref and let every retry outcome (success or exhausted fallback) defer to it.

Idea and guard from #105470 (webtecnica), folded onto the #76158 base.

Co-authored-by: webtecnica <webtecnica@gmail.com>
2026-09-09 03:52:03 -07:00
mengyuyuan 5353fc9b32 fix(desktop): bound i18n locale retries and keep English fallback
Address review feedback on the startup-race fix:

- Restore the permanent-failure contract: a rejected config load settles
  on English (DEFAULT_LOCALE) so the UI stays usable, as enforced by
  context.test.tsx.
- Bound the retry loop to MAX_LOCALE_RETRIES (10) at 3s intervals instead
  of retrying forever — permanent auth/backend failures reach a terminal
  English state instead of scheduling requests indefinitely.

Tests: add coverage for transient-failure recovery (first attempt rejects,
retry applies persisted display.language) and for bounded-budget exhaustion
(no retry fires after the budget is spent).
2026-09-09 03:52:03 -07:00
mengyuyuan 140eaf9e03 fix(desktop): retry i18n locale load after backend startup race
The renderer mounts before the desktop's own backend is ready, so the
first GET /api/config times out after 60s. The catch handler fell back
to DEFAULT_LOCALE (en) permanently, leaving the UI stuck in English
until a manual language switch, even when config.yaml persisted
display.language: zh.

Retry with a 3s delay on failure instead of locking the default locale.
The backend comes up, the next attempt succeeds, and the persisted
language takes effect. Cleanup clears the pending retry timer on unmount.
2026-09-09 03:52:03 -07:00