Add a user setting 'large_text_paste_as_attachment' (default: on) that lets
users paste large text inline instead of having it auto-converted to .md file
attachments. The toggle lives in Control Center → Chat tab and persists via
settings.json, following the established upstream pattern for boolean settings.
Mirror the #5128 fast-path suffix repair when profile context is present
but requested_provider is omitted, which async delegation completion can
trigger via process_wakeup.
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
The combined credential redactor (~15 regex passes per string) runs on the
full message history of every /api/session response. Under concurrent
dashboard polling of unchanged sessions this became the dominant CPU cost
and, behind the GIL in the single-process server, serialized requests to
10-44s each — surfacing as repeated 'connection lost' in the browser as
the proxy/browser timed out and the frontend retried, piling on threads.
The redactor is pure and deterministic (force=True, fixed masking), so
identical strings always map to identical output and are safe to memoize
without invalidation. Wrap it in an lru_cache(maxsize=4096), gated to
strings <=16KB so a few giant tool-output dumps can't evict the many small
recurring strings or balloon RSS. Repeated polls become O(1) cache hits
(measured 3.7ms -> 0.002ms per 3KB marker string).
_redact_fn_cached is preserved as the call-site name so the existing
monkeypatch-based redaction tests bypass the cache unchanged (69/69 pass).
Windows locks session JSON files briefly during antivirus scans or
browser polling, causing os.replace() to raise WinError 5
(ERROR_ACCESS_DENIED). Add _safe_replace() helper that retries with
exponential backoff (50ms to 800ms, max 5 attempts) on PermissionError.
No-op on non-Windows platforms.
Replaces all 5 os.replace() call sites in models.py:
- Session.save() (main file + backup)
- _write_session_index() (full rebuild + fast path)
- prune_session_from_index()
A turn started SERVER-SIDE (self-wake, cron, restart hook) fans a
`server_turn_started` frame onto the per-session live-view SSE channel so an
open tab renders it live. But while a tab is HIDDEN the WebUI deliberately does
NOT hold that persistent SSE open (connection-pool budget — #3992 / #4151), so
`get_session_channel()` returns None at fan-out time and the turn is dropped for
that tab. The user only saw the turn after a manual interaction (refresh / send)
re-subscribed the channel and replayed it.
Bridge the gap with a lightweight poll of `/api/session/status` (one short GET
per ~6s tick — NOT a held connection, so the connection-pool budget the hidden
guard protects is preserved) that attaches the existing live renderer when it
sees a *live* active stream.
Backend (`api/session_ops.py`):
- `session_status()` now exposes `active_stream_id`, derived through a new
`_live_active_stream_id()` helper that only returns the id when the stream is
genuinely live (present in STREAMS / ACTIVE_RUNS). A stale id left by a
crashed/restarted run surfaces as None, so the poller never attaches a
renderer to a dead stream. Additive field — existing consumers ignore it.
Frontend (`static/messages.js`):
- `_startHiddenActiveStreamPoll` / `_stopHiddenActiveStreamPoll` /
`_attachServerInitiatedStream` implement the poll lifecycle. The poll fires
one immediate tick on hide (so an in-flight turn is caught without waiting a
full interval), attaches mid-flight turns via the reconnecting/replay path,
and stops on re-show (the real SSE takes over), session switch, or once a
stream is rendering.
- Started on BOTH hidden paths: the `visibilitychange` hook's hidden branch (a
visible tab going to background) and `startSessionStream`'s hidden
early-return skip (a session opened while already hidden). The visible path
and `stopSessionStream` clear it.
Tests:
- `tests/test_hidden_tab_server_initiated_turn.py` — backend live-validation
(stale id → None; STREAMS/ACTIVE_RUNS id returned) and frontend lifecycle /
both-hidden-paths / replay-attach source locks.
- Widened the brittle fixed-width source-window slices in
`tests/test_issue3996_sse_visibility.py` (1600/1700 → 2400) so the existing
`visibilitychange` / hidden-skip assertions still find their markers after the
poll-start lines were inserted into `startSessionStream`.
Prevent historical assistant reasoning_content from being replayed to
generic/local providers by default while preserving current behavior for
known reasoning-capable provider protocols.
Adds a webui.reasoning_content_replay policy read by the sanitizer:
auto, strip, and preserve. Auto strips for local/generic backends such as
LM Studio/Ollama/llama.cpp and preserves for explicitly known provider
protocols that may require reasoning replay.
This only affects provider-facing sanitized history. It does not modify
stored session/display reasoning metadata.
Codex flagged that get_last_workspace() falls back to the GLOBAL last_workspace.txt
before the profile config.yaml — so a named profile without its own
last_workspace.txt would leak the global path, re-introducing the #5169 bug.
Add get_profile_default_workspace() (profile last_workspace.txt -> config.yaml ->
terminal.cwd -> default, NO global fallback) and call it from /api/profile/active.
Adds a regression test: global last_workspace.txt present + named profile with
only config.yaml workspace must return the profile path, never the global.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Expose a profile-scoped default_workspace on GET /api/profile/active (resolved
by the profile-aware get_last_workspace(), fail-open) and hydrate boot.js from
it, overriding the global GET /api/settings value. GET /api/settings stays
global-only so the settings UI can't clobber the shared default. Combines
claw-io's #5173 (primary) with the fail-open backend hardening + regression test
from #5171.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Codex deep-gate caught a CORE regression in the original top-N cap: capping
_apply_sidebar_state_db_overrides also capped the SOURCE classification that
/api/sessions filters on BEFORE the lazy lineage correction, so a stale-CLI JSON
row whose state.db source is webui could be silently filtered out of a WebUI-only
sidebar once it fell beyond the cap. Split the read: the indexed sessions-table
source/title lookup runs for ALL rows; only the expensive messages COUNT/MAX
aggregation (the actual 5-18s bottleneck) is capped to top-N. Added a 500-row
end-to-end regression with a real state.db proving the beyond-cap stale-CLI row
stays webui.
On power-user instances with thousands of sessions, GET /api/sessions
blocked 5-18s in the all_sessions.state_db_overrides stage:
_apply_sidebar_state_db_overrides read state.db for every row (2400+ ids)
on every concurrent poll. With the gateway-watcher reading and the agent
writing the same state.db, this became lock/IO contention that piled up
concurrent polls and flapped the UI to 'Connection lost'.
Cap the override lookup to the top-N most-recent (paint-priority) rows
that are actually visible, mirroring the existing lineage-enrichment cap
(#4638). The caller passes an already pinned-first/newest-first sorted
list, so the top-N covers the visible window; rows beyond the cap keep
their JSON source/title and are corrected lazily on history-panel open.
Cap defaults to 300, env-configurable via
HERMES_WEBUI_STATE_DB_OVERRIDE_TOP_N (0 disables). Fails open.
Reported by @latipun, reproduced by @b3nw.
Co-authored-by: latipun <latipun@users.noreply.github.com>
When session model_provider differs from profile model.provider, do not
expose profile_default_model for suffix repair. Fast path also requires
profile_provider to match requested_provider when profile_provider is set.
Addresses cross-provider mis-resolution (custom:other-proxy + bare suffix).
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
Mirror the worker's LRU-eviction ACTIVE_RUNS liveness check in
_evict_session_agent so truncate/clear/model-switch can't close the SessionDB
out from under an in-flight turn on the same session (#5096 Bug D follow-up).
The cache handle still drops (harmless — worker holds a local ref); only the
lifecycle commit + _session_db.close() are skipped while a run is active.
Co-authored-by: claw-io <claw-io@users.noreply.github.com>
Refs #5127.
When _resolve_compatible_session_model_state takes the #1855 fast path with
a bare runtime model suffix and a slash-qualified profile default, repair to
the full qualified ID for custom/custom:* providers.
Also load profile model.default when the session already has model_provider
so wakeup turns can apply that repair.
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
A config.yaml with an explicit empty/null `providers:` key parses to None in
PyYAML. `cfg.get("providers", {})` only returns the {} default when the key is
*absent*, so an explicit null yields None instead of an empty mapping. master
already guards each read with `isinstance(providers_cfg, dict)`, but the value
fed to that guard is still a footgun: the natural chained form
`cfg.get("providers", {}).get(...)` (already fixed once in api/config.py)
crashes with AttributeError on None.
Harden every providers-key read in the three remaining files to
`cfg.get("providers") or {}` so an explicit null degrades to an empty dict at
the source, matching the existing api/config.py convention. Sites hardened:
api/onboarding.py:578 (_provider_api_key_present)
api/providers.py:1051 (_provider_has_shadowed_codex_oauth_value)
api/providers.py:1219 (_provider_has_key)
api/providers.py:1265 (_get_provider_api_key)
api/providers.py:2348 (get_providers catalog)
api/providers.py:2781 (_clean_provider_key_from_config)
api/routes.py:5045 (_context_length_config_api_key_for_provider)
api/routes.py:5110 (_context_length_lookup_inputs_for_model)
api/routes.py:15916 (_handle_live_models)
Adds tests/test_none_providers_config_guard.py: per-file source-form pins (fail
on master / on any single-file revert) plus behavioural checks driving the real
functions with `providers: None` to prove no crash and parity with the
empty-mapping config. Updates the existing test_issue3717 routes-string pin to
match the hardened form.
This is a focused salvage of grab-bag PR #3967 -- only the None-providers
hardening is taken; the PR's unrelated changes (account-usage worker-pool
refactor, function removals) are intentionally dropped.
Co-authored-by: lidi1011 <lidi1011@users.noreply.github.com>
WebUI's api/streaming.py:_resolve_image_input_mode was a LOCAL
re-implementation of agent/image_routing.py:decide_image_input_mode that
had DIVERGED from the canonical function. The local copy never consulted
the active model's vision capability — it only checked a handful of config
heuristics — so it returned "native" (embedding the image as an image_url
part) even for models KNOWN to lack vision. That silently sends pixels to a
blind model (#21160-class failure) instead of routing through the text
(vision_analyze) pipeline.
Fix: delegate to the canonical agent.image_routing.decide_image_input_mode
(single source of truth), with a try/except fallback that preserves the
historical WebUI behaviour when the agent package is not importable (e.g.
the WebUI standalone test environment).
Carve-out preserved: the canonical router conservatively returns "text" for
UNKNOWN/custom models (no models.dev capability data). WebUI historically
forwards those NATIVELY and relies on the agent's strip-and-retry guard
(_try_shrink_image_parts_in_messages / _strip_images_from_messages) to
downgrade on a provider rejection. We keep that: a canonical "text" verdict
is only honoured when there's a real signal — an explicit user choice
(image_input_mode: text or a configured auxiliary.vision backend) or a model
KNOWN to lack vision. Otherwise we forward native.
Tests:
- Existing gateway test (forwards_image_attachments_as_multimodal_parts)
stays green — unknown "test-model" still forwards native.
- New non-vacuous regression: a model KNOWN to be text-only
(supports_vision == False) now routes "text" — this FAILS against master,
where the old local copy returned "native" for the same config.
- Added coverage for the unknown-model carve-out, known-vision native path,
explicit-text-signal precedence, and the agent-unavailable fallback.
Salvage of #4113 by @gkd2323c. The original PR delegated fully to canonical
but did not add the unknown-model carve-out, which would have regressed the
WebUI native-forwarding behaviour the gateway test encodes.
Co-authored-by: gkd2323c <gkd2323c@users.noreply.github.com>
Surfaces prompt-cache hit rate on the Insights page so cache-efficiency
(a key cost-optimisation signal) is visible at a glance.
Backend (api/routes.py, _handle_insights):
- Aggregate cache_read_tokens per model and per day, plus a total.
- Graceful fallback for older agent state.db schemas lacking the
cache_read_tokens column (COALESCE + OperationalError second query).
- New response fields: total_cache_read_tokens, total_cache_hit_percent,
per-model cache_read_tokens + cache_hit_percent, per-day cache_read_tokens.
Frontend:
- Models table gains a Cache column (panels.js + style.css 6-col grid).
- Daily bar tooltip includes the bounded cache hit rate.
- New i18n keys insights_model_cache / insights_cache_hit in all 13 locales.
Correctness fix (maintainer-flagged on #3912): the original computed
hit-rate as cache_read / input_tokens (reads-over-misses), which EXCEEDS
100% on cache-heavy sessions. This uses the FULL prompt total as the
denominator -- cache_read / (input_tokens + cache_read) -- via the shared
api.usage.prompt_cache_hit_percent helper, so it is bounded 0-100% and
means "% of the prompt served from cache". Returns None (rendered as a
muted dash) when there are no cache reads.
Tests (tests/test_insights.py):
- Updated existing assertions for the new response shape.
- Added a non-vacuous <=100% boundary test: a cache-heavy case
(cache_read=9900, input=100) where the OLD formula reports 9900% but the
bounded denominator yields 99%. Verified the test FAILS under the old
formula and PASSES under the fix.
Salvage note: PR #3912's diff was ~99 releases stale (mostly re-derived old
code, illusory +5405). This ports ONLY the real ~190-line feature fresh
onto current master.
Closes#3911
Co-authored-by: TomBanksAU <TomBanksAU@users.noreply.github.com>
CLI/TUI/Desktop sessions were *recreated* on every POST to /api/chat/start
instead of *claimed*, because the claim/synthesis logic only ran on the GET
stub path. GET /api/session already synthesized a read-only stub from
state.db, but POST /api/chat/start unconditionally 404'd on any session_id
without a WebUI JSON sidecar — and the frontend 404 handler then ran its
empty-state self-heal, stripping the URL and silently discarding the user's
typed message.
This closes the GET/POST asymmetry by routing both endpoints through a new
shared helper, _claim_or_synthesize_cli_session, that materialises a
WebUI-owned Session on first write (via synth.save()).
Preserved guards:
- Denylist of foreign-owned sources via _is_claimable_cli_source:
{claude_code, cron, external_agent, gateway, messaging, unknown}; cli/tui/
desktop fall through to CLAIMABLE.
- The not_claimable arm returns 403 (NOT 404) and never reaches synth.save()
(ownership guard). 403-vs-404 matters: the frontend 404 handler runs the
empty-state self-heal that strips the URL, so a legitimately-listed
read-only session must surface a refusal instead of vanishing.
- created_at mapping from state.db is load-bearing (Session.save() doesn't
touch created_at), so claimed sidecars no longer sort as "Jan 1 1970".
- The 500 path uses _sanitize_error so a raw OSError can't leak the sidecar
filesystem path.
- The #2782 deleted-WebUI-session 404 self-heal contract is preserved via
_session_index_marks_was_webui.
- The GET path's _session_visible_to_active_profile gate is preserved.
Polish over the original PR:
- Test docstring rationale corrected re: updated_at — Session.save() defaults
touch_updated_at=True so the claimed sidecar's updated_at is stamped to
"now" regardless; only created_at is preserved. Assertions unchanged.
- touch_updated_at=True default kept (a claimed session jumps to "just now",
which is the desired UX); no test pins persisted updated_at to the state.db
value.
- Added a path-leak assertion to the 500-sanitisation test: the sanitized
string must not contain a '/' segment from the session-store root.
Salvage of #4153.
Co-authored-by: merodahero <merodahero@users.noreply.github.com>
Sidebar rows whose backing state.db session has zero messages (a '+'-click
that opened a row but the first turn never committed, or a sidebar nav that
opened then closed before any message landed) now get pruned instead of
lingering forever. The existing #3238/#4591 orphan-prune path only handled
CLI/API-server sidecars; this adds a parallel pass for native-WebUI rows.
- api/models.py: add agent_session_zero_message_sids() batch helper that
mirrors agent_session_rows_existing()'s safe-degrade contract (returns
frozenset() on any error so a transient failure never causes data loss).
Add a small tombstone file at SESSION_DIR/_pruned_webui_orphans.json
(capped at the last 500 sids) so the next poll's
recover_missing_index_sidecars does not re-add a pruned orphan to the
sidebar index — the per-poll fsync'd index write + state.db probe loop
the original PR would have hit on every poll for every orphan. The
helper, the load, and the full-scan fallback all honor the tombstone.
new_session() and import_cli_session() clear any matching tombstone
entry so a fresh session with the same id isn't shadowed.
- api/routes.py: extract the webui zero-message prune into a private
_prune_orphaned_webui_zero_message_sessions() helper, then call it
from BOTH branches of _build_session_list_cache_payload. Established
installs pin show_cli_sessions=False (per api/config.py:7637-7648)
and are exactly the long-time users who accumulated the #4985 404
orphans — the original PR only ran the prune in the if branch and
silently skipped the else branch (maintainer review
IC_kwDOR1LuPM8AAAABHsyFGg). The helper also honors the tombstone
defensively so the prune can't thrash on a row the index still
carries from a prior poll.
- tests/test_issue4985_orphaned_webui_zero_message.py: 39 regression
tests — the original 25 (7 helper-direct + 12 monkeypatch predicate
tests + 6 real-pipeline end-to-end tests) plus 14 follow-up tests
driven by the MUST-FIX / tombstone / r6 signal-swap reviews:
* test_helper_prunes_orphan_keeps_safety_retained_rows — direct call
to the extracted helper with a row set covering every shape the
gate is responsible for.
* test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_false
— explicit regression for the established-install case the
original PR missed.
* test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_true
— companion positive control.
* test_tombstone_persists_across_polls — drives two pipeline builds,
asserts prune fires once total and the tombstoned sid is NOT
re-added to SESSION_INDEX_FILE.
* test_tombstone_does_not_block_new_session_with_same_id — defensive
cover for the new_session() / import_cli_session() tombstone clear.
* test_tombstone_trimmed_to_last_N_entries — verifies the cap.
* test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_true
— companion positive control (show_cli_sessions=True path).
* test_tombstone_self_heals_when_message_added — confirms
Session.save()'s belt-and-suspenders tombstone clear fires when
a sidecar commit lands with len(messages) > 0.
* test_tombstone_loader_is_fail_open_on_corrupt_file[non-json|version-mismatch|non-dict-root]
— 3-parameter regression for the fail-open corrupt-file contract
(#5023 lesson).
* test_sidecar_only_webui_session_is_retained — sidecar JSON carries
real messages but state.db.messages is empty (mirroring lag); the
r6 loaded-sidecar probe retains the row.
* test_sidecar_only_webui_session_self_heals_tombstone — the r4.5
self-heal path combined with the r6 sidecar-only retention rule.
* test_truly_empty_sidecar_with_title_still_pruned — companion
negative test that the r6 fix does NOT over-retain empty sidecars
with non-Untitled titles.
* test_sidecar_only_webui_session_retained_when_show_cli_sessions_false
— established-install path also respects the r6 retention rule.
- CHANGELOG.md: Unreleased entry under Fixed.
fix: target post-#1171 survivors + hoist + tombstone + r6 sidecar-load probe
The r5 sidecar-only retention signal keyed off the row's cached
``message_count`` — which is stale-positive on the very phantom rows
#4985 exists to prune (sidecar ``messages`` empty but cached count > 0).
The r6 signal swaps that cached-count probe for a ``Session.load(sid)
.messages`` probe, gated to ``state.db``-empty candidates only so the
common live-row path stays a no-op. Three retain tests now write real
populated ``messages`` arrays (not just ``[]``) so the loaded-sidecar
check has something to find; the stale-count phantom test is left as
the canonical r6 regression (its ``messages=[]`` + ``message_count=5``
sidecar is exactly the shape the r6 loaded-sidecar check correctly
classifies as orphan). Maintainer review 4584722701.
Adds a configurable frame-src directive so an extension can embed a
self-hosted web app in an iframe (e.g. an 'external app tab' pinning
Grafana / Vaultwarden / a dashboard). Mirrors the existing
HERMES_WEBUI_CSP_CONNECT_EXTRA knob: space-separated http(s) origins with
optional *.subdomain wildcard + port, validated, ignored if malformed
(directive-injection / paths / ws scheme / bad ports all rejected).
- Default frame-src is 'self' only, so existing same-origin iframes are
unchanged; default-off when the env var is unset.
- frame-ancestors stays 'none' -- this only governs what THIS page may embed,
never who may embed the WebUI.
- Threaded through both the enforced and report-only CSP builders + the
server.py Handler wrapper, so both headers stay identical.
Docs in docs/EXTENSIONS.md. 8 new regression tests
(tests/test_csp_frame_src_extra.py) cover default, valid widening, enforced
parity, directive-injection rejection, path/ws/port rejection, and
connect/frame independence. Existing CSP test suite still green.
Note: full suite shows 2 pre-existing order-dependent flakes
(test_issue1574 spawn-context, test_issue3283 import-order) that pass in
isolation and are untouched by this CSP-only diff.
Adds a dedicated GET /api/commands/moa/resolve endpoint + resolve_moa_config()
that reads the MoA preset/usage server-side. The /moa send-path passes only a
moa_config:true boolean to /api/chat/start; the server re-resolves the real
config itself (never trusts a browser-supplied dict) and threads it per-turn into
the live agent run. Fails closed (409) on gateway-backed sessions.
Replaces the originally-proposed broad 43-command subprocess approach (bounced)
with this narrow, security-reviewed /moa route. Maintainer fixes on top of the
contributor's converged design: moa_config is added to run_conversation only
when not None (older-agent TypeError safety) at the main call AND both auth
self-heal retry sites, and forwarded through the legacy-journal runtime adapter.
Gate: Codex SAFE (after the maintainer fixes) + full suite 10784 pass + the
activity-stream regression gate clean.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
When a settled assistant turn carries _anchor_activity_scene and has mixed
content[] (text interleaved with tool_use), promote that ordering into anchor
scene rows (prose / tool / thinking rows in sequence) instead of only the raw
content[] fallback, and mirror the same scene model in the backend hydration
path so a cold reload reconstructs an identical transcript.
Also fixes: post-tool_use text/thinking in a NON-FINAL assistant message was
silently dropped — only the turn-final assistant's post-last-tool text is the
"final answer" tail; earlier assistant messages keep post-tool content as
activity rows. Tail thinking is excluded from the final-answer text but still
emitted as a thinking row (verified on both JS and Python paths).
Tool-row de-duplication is conservative — ID-equal always merges; name/
invocation matches are cardinality-gated and same-message; body-prefix is only
a secondary confirmation inside the id-flexible branch (also requires matching
started_at + compatible name + invocation). It biases toward an extra visible
tool card over ever silently merging two distinct calls.
Gate: Codex SAFE (tail-thinking edge verified already-handled both sides),
Opus SHIP (dedup fails toward duplicate-not-loss; non-final preservation, XSS,
non-mixed regression all clean), full suite 10778 passed (only the 4 known
pre-existing flakes). One non-blocking follow-up noted: bring the prose/thinking
peer-dedup into JS↔Python parity (cosmetic cold-reload extra-narration row).
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Manual /compress shrinks the model-facing context_messages but keeps the
visible messages[] transcript. The old context returned via two paths (#4836):
(1) append-only state.db reconciliation re-appending pre-compression rows, and
(2) startup .bak recovery treating the intentional shrink as data loss.
Half A (routes.py _handle_session_compress): persist truncation_watermark +
truncation_boundary (= watermark of the compressed context), set
compression_anchor_mode="manual", refresh last_prompt_tokens, stamp missing
timestamps on the compressed context, and delete the now-stale .bak. The
boundary==watermark stamp drives the #4772 reconciliation logic onto its
conservative path (block replay of pre-compression rows) while post-compression
turns still merge once sidecar timestamps advance past the watermark.
Half B (session_recovery.py): the .bak recovery guard. MAINTAINER FIX over the
original PR — the contributor suppressed recovery whenever
compression_anchor_mode=="manual", a flag set once at compress and never
cleared, which PERMANENTLY disabled #1558 crash-recovery for any compressed
session (real data loss). Recovery is now suppressed only when the session was
intentionally compressed AND the .bak is genuinely the pre-compression backup,
discriminated by the compaction marker (the same _context_messages_include_
compression_marker signal reconciliation uses): a marked .bak post-dates the
compression -> recover; an unmarked .bak with a larger context is the
shrink-undoing pre-compression one -> suppress. Fail-open on any error.
Resolves an Opus-gate edge in the first cut (length-only heuristic wrongly
suppressed a loss that shrank BOTH messages and context). Two new non-vacuous
regression tests: post-compression real loss recovers, and the both-shrunk
marked-backup case recovers.
Co-authored-by: hyl-ailab <hyl-ailab@users.noreply.github.com>
The streaming path already honored a max_tokens cap from the active profile's
config.yaml, but there was no UI to manage it. Settings > Preferences now has a
"Max output tokens" field that writes a root-level max_tokens override through
the profile config (not settings.json, which the streaming worker never reads
for this), preserving unrelated YAML keys under _cfg_lock. Blank clears the
override so agent.max_tokens fallback resumes; get_max_tokens_status() surfaces
root / effective / fallback so the field shows what a new turn would use.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
In cross-container Docker setups the agent-health check resolves the gateway's
runtime status from a shared gateway_state.json. After the keyword-form
read_runtime_status(pid_path=...) call fails against an older agent signature,
_read_gateway_runtime_status fell back to read_runtime_status(pid_path) passing
the sibling gateway.pid path positionally — so the reader parsed PID metadata as
runtime metadata and gateway_state/updated_at never resolved, breaking the
freshness check (issue #5030, #1879).
The positional fallback now passes the correct gateway_state.json sibling path
(pid_path.with_name(runtime_status_file), where runtime_status_file comes from
the agent's _RUNTIME_STATUS_FILE attr, defaulting to gateway_state.json). The
path computation is hoisted so both fallback branches use the correct file. The
keyword happy-path is unchanged for agents that accept pid_path=..., and normal
PID-based health is unaffected.
Verified: the new test test_cross_container_runtime_status_reads_sibling_runtime_file
fails on master without the fix and passes with it; #716 PID-health tests still pass.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
When a turn is steered, the agent embeds an
[OUT-OF-BAND USER MESSAGE]...[/OUT-OF-BAND USER MESSAGE] control block, and that
block gets persisted into the session's messages/context_messages (appended onto
a tool-result entry — verified present in real prod session JSON, in both
messages[] and context_messages[]). WebUI then replays context_messages into the
provider-facing conversation history every turn, so the model kept re-seeing the
internal control marker: wasted tokens + stale control data.
Adds a non-mutating, recursive _strip_oob_blocks() (regex, DOTALL+IGNORECASE,
non-greedy) wired into every model-facing chokepoint:
_sanitize_messages_for_api, _api_safe_message_positions,
_restore_reasoning_metadata projection, and the gateway runs-API
conversation_history (context_messages + prefill). The compression path
(/session/compress) inherits the strip via _sanitize_messages_for_api.
The marker stays intact in the persisted transcript (display/reload/audit
fidelity); it's removed only on the way to the provider. Idempotent, only strips
a complete open+close pair, and does not mutate persisted session objects (the
content key is reassigned to a freshly-built value).
Maintainer hardening (Opus gate): relaxed the opening pattern from a mandatory
`]\r?\n` to `]\s*?` so a single-line OOB block is also stripped (DOTALL .*?
already spans newlines) — robustness against a format that omits the newline.
Co-authored-by: Stacey2911 <Stacey2911@users.noreply.github.com>
Cancelling a running turn briefly collapsed the already-streamed reasoning,
tool cards, and text to a bare "Task cancelled" marker before a follow-up
GET /api/session repopulated them — a visible race between the terminal
cancel SSE event and the refetch (#4076, #1361).
The terminal cancel event now carries a canonical, redacted session snapshot
captured under the per-session agent lock right after the cancelled turn is
persisted (including the recovered partial-assistant message). The frontend
applies that embedded snapshot immediately via _applyCancelSessionPayload and
skips the GET round-trip. The snapshot is a fully detached deep copy (redaction
rebuilds every container) so it cannot alias or be staled by concurrent writes;
it is only applied when its session_id matches the active view; and a
missing/failed snapshot degrades cleanly to the existing GET fallback.
The seven other cancel call sites are standardized via _cancel_event_payload()
(message + type + status), backward-compatible (no session key when none).
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
A threading.Timer per RequestDiagnostics spawns one OS thread per request, held
alive until finish() cancels it. Under sustained /api/sessions poll load this
exhausts the per-process thread cap (RuntimeError: can't start new thread) and
the server stops accepting connections. Replace with a single lazy-init
watchdog daemon thread that scans pending diags every ~1s; public API
unchanged. Caps timeout-tracking at 1 thread/process regardless of request rate.
On the default local in-process backend, an approval card whose stream
ended while still pending (cancel/fork/provider-error/completion) left the
agent queue entry dropped and the _pending mirror reconciled away. Clicking
Approve/Deny then sent an approval_id that matched nothing, so the handler
returned a bare {ok:false}; since #4771 the frontend surfaces that as
'Approval response not accepted.' with a stuck card (reported by
@santastabber on v0.51.666 and b3nw).
Distinguish a genuinely stale card (no pending approval for the session ->
benign {ok:true, stale_cleared:true} so the UI clears the orphan) from a
stale-id click made while a DIFFERENT approval is live (still ok:false so it
can never resolve the wrong command -- #527 guard preserved). Frontend
clears the orphan card on stale_cleared even if the displayed id drifted.
Co-authored-by: b3nw <b3nw@users.noreply.github.com>