Skip to content

fix(agent): classify provider memory-ceiling 400s as overloaded, not context_overflow - #52289

Closed
briandevans wants to merge 12 commits into
NousResearch:mainfrom
briandevans:fix/agent-memory-ceiling-overloaded-52261
Closed

briandevans wants to merge 12 commits into
NousResearch:mainfrom
briandevans:fix/agent-memory-ceiling-overloaded-52261

Conversation

@briandevans

@briandevans briandevans commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Local-inference providers (oMLX / MLX with a memory guard, and similar
Metal/CUDA setups) abort a request when the prefill memory peak exceeds a
GPU/unified-memory ceiling. Their rejection text often suggests "reduce context
length" / "reduce context size", which collides with the context-overflow
patterns — so Hermes classifies a memory-ceiling 400 as context_overflow
and routes it into the compress → shrink-context → retry loop. Compression
cannot lower a prefill memory peak (the conversation is tiny — ~5.7k tokens in
the report), so it exhausts max_compression_attempts, the compression call
itself re-hits the wedged server, and the loop ends in "Cannot compress
further" → destructive session reset.

This PR adds a _MEMORY_CEILING_PATTERNS check that runs before the
context-overflow check at every classification site, classifying these as
FailoverReason.overloaded (transient, retry-with-backoff, no compression, no
reset) — the same "checked BEFORE context_overflow" guard pattern already used
for multimodal / image-too-large / request-validation 400s. overloaded
mirrors the existing 503/529 recovery: retryable=True, should_compress
defaults to False, and it is in the retryable set so the loop never enters the
client-error abort/reset path.

The same wall reaches the classifier on five different routes, and all five
now share one predicate (_is_memory_ceiling) and one recovery annotation
(_memory_ceiling_result), so the treatment cannot drift between them:

route before after
HTTP 400 (prefill guard, pre-stream) context_overflow → compress → reset overloaded, retryable, fallback
no status (mid-stream APIError) context_overflow / billing overloaded, retryable, fallback
HTTP 500/502/503/529 context_overflow / bare overloaded overloaded, retryable, fallback
HTTP 507 (model-load guard) server_error, retryable, should_fallback=False overloaded, retryable, fallback
HTTP 409 (ModelLoadingError) format_error, retryable=False overloaded, retryable, fallback
structured code (proxy-stripped message) format_error / unknown overloaded, retryable, fallback

The 507 and 409 routes and the prefill_memory_aborted code were found by
@tkaufmann against this branch's head. Evidence grades are recorded per
commit and per code comment and are not flattened:
the 507 gap and both
process-abort wordings are captured bodies from his own hosts; the 409 mapping
and the prefill_memory_aborted code pairing are read from the engine's source
and are labelled as code readings, not as evidence.

Related Issue

Fixes #52261

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • agent/error_classifier.py:
    • _MEMORY_CEILING_PATTERNS + _MEMORY_CEILING_ERROR_CODES, consumed
      through the single _is_memory_ceiling predicate and the single
      _memory_ceiling_result recovery contract.
    • Guard placed before the context-overflow check at _classify_400, the
      500/502 and 503/529 branches, _classify_by_error_code, and the no-status
      _classify_by_message streaming path.
    • New 507 branch (local-inference model-load guard: oMLX maps both
      ModelTooLargeError and InsufficientMemoryError here, reachable from
      /v1/chat/completions, /v1/completions and /v1/messages). Previously
      fell to generic "other 5xx": retryable, but should_fallback=False.
    • New 409 branch (ModelLoadingError, "load aborted: process memory limit
      exceeded"). Previously fell to generic "other 4xx": format_error,
      retryable=False.
    • prefill_memory_aborted added to _MEMORY_CEILING_ERROR_CODES — the
      admitted-then-killed sibling of prefill_memory_exceeded, selected by
      exception type in the engine's body builder.
    • Both new branches are gated on the shared predicate, so a 507 in its
      literal Insufficient Storage sense and an ordinary 409 conflict keep their
      existing treatment.
  • tests/agent/test_error_classifier.py: captured provider wordings pinned as
    module constants across the 400 / no-status / 5xx / 507 / 409 / structured-code
    paths, each with a control proving the guard is narrow, plus invariant tests
    that a genuine context-window overflow still compresses and a genuine billing
    exhaustion is still billing.

How to Test

  1. uv run --with pytest --with pytest-asyncio python3 -m pytest tests/agent/test_error_classifier.py -v
  2. Regression guard (fail-before / pass-after), verified per commit by
    restoring the pre-change blob of agent/error_classifier.py and re-running:
    • test_507_omlx_model_load_ceiling_is_overloaded_with_fallback → red as
      server_error / should_fallback=False
    • test_prefill_memory_aborted_code_is_overloaded[400] / [None] → red as
      format_error / unknown
    • test_409_memory_abort_is_overloaded_not_format_error → red as
      format_error / retryable=False
    • every control test passes in both states, proving the guards are narrow.
  3. Full file: 115 passed.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — for this update: tests/agent/ (the suite covering the only production file touched). 155 failures, and the failing set is byte-identical to clean origin/main (c896c09), which fails the same 155. Zero of them are in test_error_classifier.py, which is 115/115 green.
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (pure string-classification logic — platform-independent)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Contract Protected

Invariant: a provider memory/resource-ceiling rejection never enters the
compress-and-shrink-context path and is never reported as a client error,
regardless of which route it arrives on — HTTP 400, 409, 500/502, 503/529, 507,
a structured error code, or a no-status streaming APIError.

  • Known-bad inputs (now covered), captured: the oMLX/MLX wordings — "oMLX
    prefill memory guard rejected … dynamic ceiling is 13.50 GB … reduce context
    length"; "process memory limit exceeded … loosen memory_guard_tier" and its
    reworded 11 Aug sibling "… dynamic ceiling 60.4 GB. Close other apps to free
    RAM … raise memory_guard_tier"; the no-status "Prefill context too large for
    available memory"; and the 507 "Model 'X' (33.95GB) does not fit under the
    dynamic memory ceiling (25.22GB)".
  • Known-bad inputs, read from engine source (labelled as such, not claimed as
    captures):
    the 409 "Model 'X' load aborted: process memory limit exceeded",
    and the prefill_memory_aborted structured code.
  • Future-input coverage: _MEMORY_CEILING_PATTERNS keys on
    memory/allocation/ceiling/guard wording (OOM, llama.cpp/vLLM, Metal/CUDA),
    disjoint from token/window-count language; _MEMORY_CEILING_ERROR_CODES
    covers the reworded/proxy-stripped case where no memory substring survives.
    Two captured wordings of the same shape, nine days apart, are pinned against
    each other so a third engine copy-edit fails loudly instead of silently.
  • Negative cases: a genuine maximum context length … reduce the length 400
    still routes to context_overflow + compression; a genuine billing
    exhaustion is still billing; a plain 500/503 is untouched; a 507 with no
    memory wording stays a generic server_error; an ordinary 409 conflict stays
    a format_error.

Sibling follow-up (intentionally out of scope to keep the diff coherent): a
dedicated FailoverReason.resource_exhausted reason — instead of reusing
overloaded — would let callers surface a clearer "free memory / raise the
guard ceiling" message. Happy to widen if preferred.

Copilot AI review requested due to automatic review settings June 25, 2026 03:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a misclassification in the agent’s API error classifier where local-inference “memory ceiling / memory guard” HTTP 400s (and similar no-status streaming errors) were being treated as context_overflow, incorrectly triggering the compress → shrink-context → retry loop and potentially leading to compression exhaustion and session reset.

Changes:

  • Add _MEMORY_CEILING_PATTERNS and classify matching errors as FailoverReason.overloaded before context-overflow checks (both in 400-status and no-status paths).
  • Add targeted tests covering multiple real-world memory-ceiling wordings plus a negative/invariant test ensuring genuine context-window overflows still classify as context_overflow and trigger compression.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
agent/error_classifier.py Adds memory-ceiling pattern detection and prioritizes it ahead of context-overflow classification for 400 and no-status message paths.
tests/agent/test_error_classifier.py Adds regression tests verifying memory-ceiling errors route to overloaded (no compression) and that real context overflows still compress.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint backend/local Local shell execution P2 Medium — degraded but workaround exists labels Jun 25, 2026
@jp-cruz

jp-cruz commented Jun 25, 2026

Copy link
Copy Markdown

Thanks for jumping on this, @briandevans — this matches the core of #52261 and I'm glad to see it
moving. I was worried that I had introduced "pilot error" in my setup where LiteLLM could have been
stripping out important information from the error before returning the error body to Hermes. Had
to go back and double-check.

The guard-before-context_overflow shape and the overloaded mapping line up with what I'd
independently prototyped + validated while filing the issue, so I can offer a few additive cases I
hit during testing (all verified against current main). Happy to send these as a commit to your
branch or a follow-up if useful:

1. Consider should_fallback=True on the memory result. The deeper finding behind the issue is
that context_overflow is excluded from the provider-fallback path
(conversation_loop.py:3250-3264, via is_client_error / is_context_length_error) — so for a
memory wall the recovery that actually works is falling back to a roomier provider. With
overloaded, retryable=True (no should_fallback), a server like oMLX that stays wedged until
restart gets retried max_retries times before fallback eventually kicks in; setting
should_fallback=True lets it fall back eagerly instead of burning retries against a server that
can't satisfy this request. Small change, directly serves the issue's intent.

2. Make sure the guard beats billing, not just context_overflow. One real oMLX wording is a
status-less streaming abort: "Request aborted: process memory limit exceeded (usage 13.7 GB, ceiling 13.5 GB). Reduce context size or lower memory_guard_tier." On main that currently
classifies as billing (non-retryable, rotate-credential) because "limit exceeded" matches a
billing pattern — a different wrong bucket from context_overflow. So the memory guard needs to
run ahead of the billing/rate-limit checks too. (I placed mine before all status/message
classification to cover this; worth confirming _classify_by_message ordering does the same.)

3. oMLX also returns a structured code worth matching. I captured the raw provider body (before
LiteLLM re-wraps it):
status-less streaming abort: "Request aborted: process memory limit exceeded (usage 13.7 GB, ceiling 13.5 GB). Reduce context size or lower memory_guard_tier." On main that currently
classifies as billing (non-retryable, rotate-credential) because "limit exceeded" matches a
billing pattern — a different wrong bucket from context_overflow. So the memory guard needs to
run ahead of the billing/rate-limit checks too. (I placed mine before all status/message
classification to cover this; worth confirming _classify_by_message ordering does the same.)

3. oMLX also returns a structured code worth matching. I captured the raw provider body (before
LiteLLM re-wraps it):

{"error": {"message": "oMLX prefill memory guard rejected this prompt: ... dynamic ceiling is 13.50 GB. ... or reduce context length.",
  "type": "invalid_request_error", "code": "prefill_memory_exceeded", "omlx_code": "prefill_memory_exceeded",
  "estimated_bytes": 15978071248, "limit_bytes": 14495514624}, "type": "error"}

So it's unambiguously memory at the source (limit_bytes is in bytes), and there's a clean
code: "prefill_memory_exceeded". Two catches: (a) the classifier checks the context_overflow
substring in _classify_by_status before _classify_by_error_code, so the good code is shadowed;
and (b) an OpenAI-compatible proxy (LiteLLM) flattens the body and drops the code. Matching on the
code in addition to the message (gated to specific codes, not resource_exhausted which
already maps to rate_limit) makes it robust for direct connections and any future wording change.

I have sanitized real oMLX logs + a few extra fixtures (the 400, the streaming abort, the raw body
with the code) if they'd help your test set — just say the word and I'll open a PR against your
branch or hand them over. Either way, happy this is getting fixed.

Hope this helps!

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Verdict: Comment (prior COMMENT review exists)

Well-tested fix for memory ceiling pattern classification. The fix correctly identifies local-inference memory/resource-ceiling rejections and routes them to overloaded instead of context_overflow. Good test coverage with 3 positive cases across 400-status and no-status streaming paths.

Changes:

  • agent/error_classifier.py: New _MEMORY_CEILING_PATTERNS check before context_overflow
  • tests/agent/test_error_classifier.py: 3 new test cases

Note: This PR has a prior COMMENT review. This review adds confirmation that the fix is clean and well-tested.

Reviewed by Hermes Agent

@jp-cruz

jp-cruz commented Jun 25, 2026

Copy link
Copy Markdown

Followed up by checking out your branch and running it directly, @briandevans — here's what I found
(all against pr-52289 head; provider="custom", oMLX wordings):

case your branch after the tweaks below
400 oMLX prefill memory guard … overloaded ✓ (but should_fallback=False) overloaded + fallback
streaming process memory limit exceeded billing (retryable=False) ❌ overloaded
direct-connection raw body w/ code=prefill_memory_exceeded, reworded msg format_error overloaded
genuine maximum context length … 9000 tokens context_overflow context_overflow ✓ (unchanged)
real billing exceeded your monthly credit limit billing billing ✓ (unchanged)

The streaming one is the notable miss: "process memory limit exceeded" contains "limit exceeded",
which the billing patterns match — and in _classify_by_message the memory guard currently sits
after billing/usage/rate-limit, so it never runs for that wording. (oMLX emits this exact string on
the streaming path under memory pressure.)

Three small, structure-preserving changes fix all of it — verified, your suite stays green (165 → 169
with 4 added tests):

  1. Move the _classify_by_message memory guard ahead of the usage-limit / billing / rate-limit
    checks
    (not just ahead of context_overflow). Same reason your comment already gives for
    context_overflow, extended to the billing collision.
  2. should_fallback=True on both memory results — a memory wall's real recovery is failing over
    to a roomier provider (the path context_overflow is excluded from at
    conversation_loop.py:3250-3264); retryable alone burns retries against a server like oMLX that
    | real billing exceeded your monthly credit limit | billing ✓ | billing ✓ (unchanged) |

The streaming one is the notable miss: "process memory limit exceeded" contains "limit exceeded",
which the billing patterns match — and in _classify_by_message the memory guard currently sits
after billing/usage/rate-limit, so it never runs for that wording. (oMLX emits this exact string on
the streaming path under memory pressure.)

Three small, structure-preserving changes fix all of it — verified, your suite stays green (165 → 169
with 4 added tests):

  1. Move the _classify_by_message memory guard ahead of the usage-limit / billing / rate-limit
    checks
    (not just ahead of context_overflow). Same reason your comment already gives for
    context_overflow, extended to the billing collision.
  2. should_fallback=True on both memory results — a memory wall's real recovery is failing over
    to a roomier provider (the path context_overflow is excluded from at
    conversation_loop.py:3250-3264); retryable alone burns retries against a server like oMLX that
    stays wedged until restart.
  3. Also match the structured code when present: oMLX returns
    code: "prefill_memory_exceeded" + estimated_bytes/limit_bytes (limit in bytes — definitively
    memory). A top-level error_code guard catches direct (non-proxied) connections and survives
    message rewording; proxies like LiteLLM strip the code, so the message patterns remain the fallback.

Happy to open this as a PR against your branch (it's a +112/−12 diff with the 4 tests, ready to go),
or you can lift whichever bits you want — whatever's easiest for you. Either way this is your PR;
just want to help get it as robust as possible. I also have sanitized real oMLX logs + the raw body
JSON if useful as fixtures.

@briandevans

Copy link
Copy Markdown
Contributor Author

Thanks @jp-cruz — checking out the branch and running the real wordings is exactly the kind of verification that makes this solid. I took two of the three, and want to be straight with you about the third.

Applied in d2a75c12b:

  1. Streaming process memory limit exceededbilling — agreed, this is the real miss. "process memory limit exceeded" contains "limit exceeded", a usage-limit pattern, and in _classify_by_message the memory guard sat after the usage-limit/billing/rate-limit block, so it never ran. Reproduced it landing in billing (retryable=False) on a status-less abort. Moved the memory guard ahead of usage-limit/billing/rate-limit (not just ahead of context_overflow). Now overloaded, retryable, no credential rotation.

  2. Structured code: "prefill_memory_exceeded" — agreed and useful. Added a narrow _MEMORY_CEILING_ERROR_CODES set (prefill_memory_exceeded/omlx_prefill_memory_exceeded/memory_limit_exceeded, deliberately not resource_exhausted) and matched it in both _classify_by_error_code and _classify_400. The _classify_400 site matters because, as you noted, a reworded 400 body with the code but no memory substring hit format_error before error-code classification ever ran; a no-status one hit unknown. Both now resolve to overloaded via the code, with the message patterns still the fallback when a proxy strips it.

Four regression tests added (streaming→billing collision, 400+code-reworded, no-status+code-only, plus a billing-invariant negative guard); verified fail-before/pass-after, suite 165→169 green, adjacent failover/recovery suites green.

On point 2 (should_fallback=True), I held off, and here is the why: in the current loop should_fallback is read at exactly one site — a debug log line (conversation_loop.py:2254). The eager-failover branch keys off classified.reason in {rate_limit, billing} (line ~2798), not off the flag, and overloaded is retryable so it never reaches the is_client_error fallback path either. So setting should_fallback=True on the overloaded result would not change runtime behavior — it would not produce the eager failover you describe; it would just be inert metadata that reads as if it were wired up. Your underlying point is right (a wedged oMLX server burns max_retries before fallback, and failing over to a roomier provider is the real recovery), but delivering it correctly means teaching the eager-fallback branch to act on a memory-ceiling overloaded — a conversation_loop.py change with its own test, which I think is better as a focused follow-up than smuggled into this classifier PR. Happy to open that next, or if you have the eager-branch change already prototyped I would gladly review it.

Sanitized fixtures still welcome if you want to widen coverage — appreciate the careful pass.

@briandevans

Copy link
Copy Markdown
Contributor Author

Thank you @jp-cruz — checking out the branch and running it against real oMLX wordings is exactly the kind of verification that makes this solid, and your table nailed it.

Both misclassifications you caught are now fixed on the branch (commit d2a75c12b, pushed just after your write-up so it landed against an earlier head):

  • Streaming process memory limit exceededbilling: fixed by moving the _classify_by_message memory guard ahead of the usage-limit/billing/rate-limit checks — exactly your point that "limit exceeded" was matching the usage pattern first. Regression test: test_streaming_process_memory_limit_exceeded_is_overloaded_not_billing (also asserts no credential rotation).
  • Direct-connection reworded body → format_error: fixed by matching the structured code: "prefill_memory_exceeded" (mirrored in omlx_code) in both _classify_by_error_code and _classify_400, so a non-proxied 400 is caught even with no memory substring in the message. A LiteLLM proxy strips the code, so the message patterns remain the fallback there. Tests: test_400_prefill_memory_code_reworded_message_is_overloaded + test_no_status_prefill_memory_code_is_overloaded.

On your should_fallback point (commit fe4ae5207): I set should_fallback=True on both memory results as you suggested. One clarification for the record — in the conversation loop should_fallback is currently a diagnostic annotation rather than the failover gate; the actual failover for an overloaded result already happens at retry-exhaustion (_try_activate_fallback()), so a wedged local server does fail over to a roomier provider rather than dying on the primary. The flag now makes the annotation consistent with the other recoverable reasons (auth/billing/rate_limit) and the tests assert it. If you think a memory wall should fail over eagerly (skipping the backoff retries, like rate-limit/billing do) rather than after exhausting them, that is a reasonable separate improvement — happy to discuss, though it edges past the scope of this issue.

If you can share those sanitized oMLX logs + raw body JSON, they would make great fixtures for a follow-up. Thanks again for the thorough pass.

@jp-cruz

jp-cruz commented Jun 27, 2026

Copy link
Copy Markdown

Assembling logs and syncing repos now. Thank you @briandevans for taking a look at this. Took me a bit of time running through the issue/proposed fix manually (slow) as well as running it by both claude/chatGPT to make sure I wasn't the one hallucinating.

Here are the sanitized fixtures you asked about, @briandevans — drop them in wherever they help, no
attribution needed. Everything is scrubbed (no hosts/IPs/creds/account IDs); the GB and byte figures
are the genuine captured magnitudes, since the limit being in bytes is the whole signal.

I bundled them as a small fixtures module covering the three transport shapes the same root cause
takes, so the existing wordings in your suite and these line up 1:1:

shape what it exercises classification on clean main
windowed 400, direct structured code: prefill_memory_exceeded + estimated_bytes/limit_bytes intact context_overflow
windowed 400, code intact / message reworded the structured-code path with no memory substring format_error
same code, status-less code-only, no HTTP status unknown
proxy-flattened 400 (LiteLLM) body collapsed to OpenAIException - <msg>, code stripped — message substring only context_overflow
streaming abort, status-less process memory limit exceeded colliding with limit exceeded billing

Plus two negative controls — genuine maximum context length … 9000 tokens (must stay
context_overflow + compress) and a real exceeded your monthly credit limit (must stay billing)
— so the fixtures double as guardrails that the memory guard didn't over-capture.

The raw oMLX 400 body (before any proxy re-wrap), verbatim:

{"error": {
  "message": "oMLX prefill memory guard rejected this prompt: Prefill would require ~14.88 GB peak (current 13.56 GB + KV+SDPA 1.32 GB) but dynamic ceiling is 13.50 GB. Raise custom_ceiling_bytes in admin Memory settings (currently pinned at 13.50 GB), or reduce context length. ...",
  "type": "invalid_request_error", "param": null,
  "code": "prefill_memory_exceeded", "omlx_code": "prefill_memory_exceeded",
  "estimated_bytes": 15978071248, "limit_bytes": 14495514624
}, "type": "error"}

Full fixtures module (a single self-contained file, FakeAPIError + the cases + two pytest
fixtures) is attached/below — happy to PR it straight onto your branch as
tests/agent/fixtures_omlx_memory.py if that's easier than copy-paste; just say the word.

Thanks again for taking all of this on so cleanly — the three commits read exactly right, and
catching the should_fallback annotation-vs-gate distinction in your reply was the correct call. I
followed that thread into the loop and have one focused follow-up on it (separate comment), but this
PR stands on its own.

After posting, I had to go back and do a few sanity checks and rerun the logic a few times as I thought I was second-guessing myself after running through the code a few times. Hope this helps. Thanks for letting me contribute!

tests/agent/fixtures_omlx_memory.py — sanitized drop-in fixtures (click to expand)
"""Sanitized real-world oMLX / local-inference memory-ceiling error fixtures.

Captured live from an oMLX inference server fronted by LiteLLM (provider="custom",
model="omlx-chat") during the incident behind NousResearch/hermes-agent#52261.
All values are sanitized: no hostnames, IPs, credentials, or account identifiers.
Byte/GB figures are the genuine captured magnitudes (they are the *signal* — the
limit is in bytes, i.e. GPU memory, not tokens), so they're kept verbatim.

Intended as drop-in fixtures for tests/agent/test_error_classifier.py to widen
coverage of the memory-ceiling -> `overloaded` classification (PR #52289).

Three transport shapes are represented, because the same root cause surfaces
differently depending on the path and whether a proxy re-wraps the body:

  1. WINDOWED 400 (direct):   structured body with `code: prefill_memory_exceeded`
                              + estimated_bytes/limit_bytes intact.
  2. PROXY-FLATTENED 400:     LiteLLM collapses the body to
                              "OpenAIException - <message>" and drops the code;
                              only the message substring survives.
  3. STREAMING ABORT:         status-less mid-stream abort whose wording
                              ("process memory limit exceeded") collides with the
                              billing/usage-limit patterns.
"""

import pytest


class FakeAPIError(Exception):
    """Minimal OpenAI-SDK-style status error (mirrors the issue repro)."""

    def __init__(self, message, status_code=None, code=None, body=None):
        super().__init__(message)
        self.status_code = status_code
        self.code = code
        self.body = body


# ── Raw provider body, before any proxy re-wrap (direct connection) ──────────
# This is the unambiguous source of truth: a memory ceiling expressed in BYTES.
OMLX_RAW_400_BODY = {
    "error": {
        "message": (
            "oMLX prefill memory guard rejected this prompt: Prefill would require "
            "~14.88 GB peak (current 13.56 GB + KV+SDPA 1.32 GB) but dynamic ceiling "
            "is 13.50 GB. Raise custom_ceiling_bytes in admin Memory settings "
            "(currently pinned at 13.50 GB), or reduce context length. To continue, "
            "set Memory Guard to aggressive, raise the custom memory guard ceiling, "
            "free system memory, or compact/reduce context."
        ),
        "type": "invalid_request_error",
        "param": None,
        "code": "prefill_memory_exceeded",
        "omlx_code": "prefill_memory_exceeded",
        "estimated_bytes": 15978071248,   # ~14.88 GB
        "limit_bytes": 14495514624,       # ~13.50 GB
    },
    "type": "error",
}

# 1. Windowed 400, direct: the headline misclassification on unmodified `main`
#    (matched the bare "context length" substring -> context_overflow -> compress).
OMLX_400_DIRECT = FakeAPIError(
    OMLX_RAW_400_BODY["error"]["message"],
    status_code=400,
    code="prefill_memory_exceeded",
    body=OMLX_RAW_400_BODY,
)

# 1b. Windowed 400, code intact but message REWORDED so no memory substring matches.
#     Proves the structured-code path, not just the substring path. On `main` this
#     fell through to format_error.
OMLX_400_CODE_ONLY = FakeAPIError(
    "Request rejected by the inference backend before generation could begin.",
    status_code=400,
    code="prefill_memory_exceeded",
    body={"error": {"code": "prefill_memory_exceeded",
                    "omlx_code": "prefill_memory_exceeded",
                    "estimated_bytes": 15978071248,
                    "limit_bytes": 14495514624}},
)

# 1c. Same code, NO status code (some transports surface it status-less).
#     On `main` this hit `unknown`.
OMLX_NO_STATUS_CODE_ONLY = FakeAPIError(
    "Request rejected by the inference backend before generation could begin.",
    status_code=None,
    code="prefill_memory_exceeded",
)

# 2. Proxy-flattened 400 (LiteLLM): body collapsed, code stripped, only message left.
OMLX_400_LITELLM = FakeAPIError(
    "litellm.BadRequestError: OpenAIException - oMLX prefill memory guard rejected "
    "this prompt: Prefill would require ~13.87 GB peak (current 13.46 GB + KV+SDPA "
    "419.28 MB) but dynamic ceiling is 13.50 GB. Raise custom_ceiling_bytes in admin "
    "Memory settings, or reduce context length.",
    status_code=400,
)

# 3. Streaming abort (status-less). "process memory limit exceeded" contains
#    "limit exceeded" -> collides with billing/usage-limit patterns on `main`.
OMLX_STREAM_ABORT = FakeAPIError(
    "Request aborted: process memory limit exceeded (usage 13.7 GB, ceiling 13.5 GB). "
    "Reduce context size or lower memory_guard_tier.",
    status_code=None,
)

# ── Negative controls — must KEEP their existing classification ──────────────
# Genuine window overflow must stay context_overflow (+ compress).
GENUINE_CONTEXT_OVERFLOW = FakeAPIError(
    "This model's maximum context length is 8192 tokens. However, your messages "
    "resulted in 9000 tokens. Please reduce the length of the messages.",
    status_code=400,
    code="context_length_exceeded",
)
# Genuine billing must stay billing (memory guard must not over-capture it).
GENUINE_BILLING = FakeAPIError(
    "You exceeded your current monthly credit limit. Please add credits to continue.",
    status_code=402,
)


@pytest.fixture
def omlx_memory_cases():
    """All positive memory-ceiling fixtures keyed by transport shape."""
    return {
        "windowed_400_direct": OMLX_400_DIRECT,
        "windowed_400_code_only": OMLX_400_CODE_ONLY,
        "no_status_code_only": OMLX_NO_STATUS_CODE_ONLY,
        "proxy_flattened_400": OMLX_400_LITELLM,
        "streaming_abort": OMLX_STREAM_ABORT,
    }


@pytest.fixture
def omlx_negative_controls():
    """Cases that must NOT be captured by the memory guard."""
    return {
        "genuine_context_overflow": GENUINE_CONTEXT_OVERFLOW,
        "genuine_billing": GENUINE_BILLING,
    }

corrected for typos.

@briandevans

Copy link
Copy Markdown
Contributor Author

Thank you @jp-cruz — those sanitized fixtures and the per-shape coverage table are genuinely useful, and I went through all five transport shapes against the branch before deciding what to take. Wanted to be precise about each rather than bulk-importing.

I checked out clean main and ran your five shapes through the real classifier — your table is exactly right; every one misclassifies on main:

shape clean main this branch
windowed 400 direct context_overflow overloaded
windowed 400, code intact / msg reworded format_error overloaded
same code, status-less unknown overloaded
proxy-flattened 400 (LiteLLM) context_overflow overloaded
streaming abort, status-less billing overloaded

Added (a04dd5ed6): the proxy-flattened LiteLLM 400. This was the one shape the branch fixed but had no dedicated test pinning ittest_400_litellm_proxy_flattened_memory_guard_is_overloaded. It's the nastiest of the set: LiteLLM collapses the body to OpenAIException - <msg> and drops the structured code, so the error-code guard can't fire — only the message substring survives, and on main that substring (reduce context length) lands it in context_overflow → the compress-and-shrink wedge loop. Verified fail-before (context_overflow on main) / pass-after (overloaded); suite 169 → 170, adjacent failover/compression suites green.

Held off on the other four — they're already pinned, so adding them would be duplicate coverage of the same code paths:

  • windowed 400 direct → test_400_omlx_prefill_memory_guard_is_overloaded_not_context_overflow
  • windowed 400 code-only (reworded) → test_400_prefill_memory_code_reworded_message_is_overloaded
  • no-status code-only → test_no_status_prefill_memory_code_is_overloaded
  • streaming abort → test_streaming_process_memory_limit_exceeded_is_overloaded_not_billing

Both your negative controls are also already guarded (test_400_genuine_context_window_overflow_still_compresses, test_genuine_billing_credit_limit_still_billing).

On the fixtures_omlx_memory.py module itself — I lifted the one missing case into the existing suite rather than dropping the module in, for two reasons: it would introduce a second FakeAPIError alongside the suite's established MockAPIError, and its no_status_code_only fixture sets code as a bare attribute — but the classifier only reads the structured code out of the body (_extract_error_code walks body["error"]["code"] / body["code"]), never an attribute, so that fixture wouldn't actually exercise the code path it's meant to. The branch test for that shape puts the code in the body so it hits _classify_by_error_code for real. Didn't want orphaned/divergent fixtures in the tree.

On should_fallback (carrying over from before, just to close it cleanly): it's set True on all the memory results now (fe4ae5207) for annotation-consistency with the other recoverable reasons, and the actual failover for an overloaded result already fires at retry-exhaustion via _try_activate_fallback() — so a wedged local server does fall over to a roomier provider. Eager failover (skipping the backoff retries, the way rate_limit/billing do) is a real improvement but it's a conversation_loop.py change with its own test, and I'd rather land it as a focused follow-up than smuggle it into a classifier PR. Happy to take that next.

Really appreciate the careful pass — the proxy-flattened capture in particular was the one gap worth closing.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the focused classifier coverage. Current main still has both reported priority collisions: 400 messages reach the context-overflow branch at agent/error_classifier.py:1187-1192, while status-less "limit exceeded" messages reach usage-limit/billing handling at agent/error_classifier.py:1351-1366.

Problems

  • Current main added 5xx context-overflow routing after this PR's base (a04b7024f): agent/error_classifier.py:1019-1030 and :1033-1044 compress any matching context text. This PR only adds memory-ceiling precedence to _classify_400 and _classify_by_message, so a 500/502/503/529 memory-ceiling response containing "reduce context length" still enters compression.
  • The structured-code branch at agent/error_classifier.py:1171-1175 returns overloaded without should_fallback=True, unlike the new 400 and message-pattern branches. Its code-only regression test at tests/agent/test_error_classifier.py:728-747 does not cover that field.

Suggested changes

  • Guard the current 5xx context-overflow branches with the same narrow memory code/message detection, with 5xx regression cases and a real-overflow control.
  • Make the structured-code recovery annotation consistent with the selected memory-ceiling contract and assert it.

This is an automated hermes-sweeper review.

Comment thread agent/error_classifier.py
# server with each compression call, and ends in "Cannot compress further" →
# destructive session reset. These tokens reference memory/allocation/ceiling/
# guard wording exclusively (never a token or window count), so they are
# disjoint from genuine context-window-overflow language. Must be checked

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main also has explicit context-overflow branches for 500/502 and 503/529 at agent/error_classifier.py:1019-1044 (added in a04b7024f after this PR's base). This PR only installs the guard for 400 and no-status paths, so a 5xx memory-ceiling message containing reduce context length still enters compression. Please cover those status branches before claiming this applies at every classification site.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 620b19bb — the guard is now installed on both 5xx routes you named, ahead of the existing overflow checks rather than after them:

  • _classify_by_status, the 500/502 branch: if _is_memory_ceiling(error_msg, error_code): return _memory_ceiling_result(result_fn), placed before the overflow-as-500 check for llama.cpp/llama-server.
  • the {503, 529} branch: the same guard, ahead of the model-load-OOM overflow check — which was the likeliest 5xx home for a memory abort, since the bare overloaded fallthrough was only reachable when the body happened to omit context wording.

The 400 path (_classify_400), the structured-code path (_classify_by_error_code) and the no-status message path (_classify_by_message) route through the same _memory_ceiling_result helper, so all five paths share one annotation contract instead of five hand-written results.

Coverage for the two branches you flagged: test_5xx_memory_guard_is_overloaded_not_context_overflow and test_5xx_memory_code_reworded_message_is_overloaded are parametrized across the status codes, test_5xx_genuine_context_overflow_still_compresses pins that a real overflow still compresses, and test_503_generic_overload_unaffected_by_memory_guard / test_500_generic_server_error_unaffected_by_memory_guard are negative controls so the guard cannot swallow an ordinary 5xx.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to the SHA above, covering this thread and the _classify_by_error_code should_fallback thread alongside it: 620b19bb no longer exists. The branch was 6,030 commits behind its old base and has been rebased onto current main; both answers now live in fc34396, and the branch head is 8207128.

Nothing in either answer changed, and both are re-anchored to symbols rather than to a SHA this time. The 5xx coverage is _is_memory_ceiling at agent/error_classifier.py:1346 in the 500/502 branch and :1376 in the {503, 529} branch, each ahead of that branch's overflow check — plus :1581 in _classify_400 and :1770 in _classify_by_message, where current main's ordering also required it to sit ahead of _USAGE_LIMIT_PATTERNS, _OVERLOADED_PATTERNS, _BILLING_PATTERNS and _RATE_LIMIT_PATTERNS. The should_fallback point is _memory_ceiling_result at :497: the structured-code route at :1682 returns that shared helper rather than building its own result, so all five routes annotate identically. Regression coverage for both is in tests/agent/test_error_classifier.py under test_5xx_memory_guard_is_overloaded_not_context_overflow and test_no_status_prefill_memory_code_is_overloaded, which assert on test names that survive the next rebase.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the reply above, the request in this thread grew by two more status branches. Three commits (e347abdab78, dd5f376a89c, 0330df0a050) carried the guard past the {500, 502} / {503, 529} pair you named, to the remaining routes in _classify_by_status (agent/error_classifier.py:1201) that a local-inference memory wall can actually arrive on:

  • :1452if status_code == 507 and _is_memory_ceiling(error_msg, error_code). oMLX maps ModelTooLargeError and InsufficientMemoryError to 507 on the model-load path, a different guard from the prefill path and one that never surfaces as a 400 or as either 5xx branch you flagged. It previously fell through to the generic "other 5xx" rule — retryable, but should_fallback unset — so once the retries were spent the turn died on a host whose memory ceiling had not moved.
  • :1482if status_code == 409 and _is_memory_ceiling(error_msg, error_code). ModelLoadingError ("load aborted: process memory limit exceeded") maps to 409, which reached the generic "other 4xx" bucket and was reported as format_error, retryable=False — a transient memory abort called a malformed request.

Both are gated on the same _is_memory_ceiling predicate rather than on the bare status, so neither branch claims a whole status code: test_507_without_memory_wording_is_generic_server_error and test_409_without_memory_wording_is_still_format_error are the controls for that.

This also moves the should_fallback thread sitting alongside this one, because both new routes return the shared _memory_ceiling_result (:537) instead of building their own result. On 507 that flag is the regression; on 409 it was already set and what changed is retryable. The structured-code route at :1775 gained a second code in the same series — prefill_memory_aborted joined _MEMORY_CEILING_ERROR_CODES (:514), the sibling that oMLX's prefill-memory body builder selects by exception type, so "admitted then killed mid-prefill" now annotates identically to "turned away at admission" instead of diverging once the message is reworded.

The evidence grades behind those three are deliberately not equal, and the code says so where each one lands: the 507 shape rests on a captured response body, whereas the 409 route and the prefill_memory_aborted pairing are read from the engine's source with no reporter body and are marked NOT CAPTURED in the fixtures that construct them.

The line anchors in my reply above have all moved — the branch was rebased again — so, corrected against the current head: _memory_ceiling_result is :537 (not :497), the 500/502 guard :1386 (not :1346), the {503, 529} guard :1416 (not :1376), _classify_400 :1659 (not :1581), the structured-code route :1775 (not :1682), _classify_by_message :1862 (not :1770). fc34396 and 8207128 cited here, and c2c430d890d cited on the should_fallback thread, were rewritten by that rebase and resolve to nothing; head is f17f3af7b7b as of 2026-08-15T07:29Z.

The names are the part that survives all of this. test_5xx_memory_guard_is_overloaded_not_context_overflow (tests/agent/test_error_classifier.py:939) and test_no_status_prefill_memory_code_is_overloaded (:835) still pin the two branches you asked for, and test_507_omlx_model_load_ceiling_is_overloaded_with_fallback, test_409_memory_abort_is_overloaded_not_format_error and test_prefill_memory_aborted_code_is_overloaded pin the three additions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to the SHA above: the current head is 2976b5fbfdd160675ef44b65d8337cf75e769315; f17f3af7b7b was rewritten and is a non-ancestor of that head.

The status branches requested in this thread remain routed through the shared _memory_ceiling_result recovery contract. The durable regression anchors are test_5xx_memory_guard_is_overloaded_not_context_overflow (tests/agent/test_error_classifier.py:939) for 500/502/503/529 and test_507_omlx_model_load_ceiling_is_overloaded_with_fallback (tests/agent/test_error_classifier.py:1029) for the 507 model-load path.

Comment thread agent/error_classifier.py Outdated
if code_lower in _MEMORY_CEILING_ERROR_CODES:
return result_fn(
FailoverReason.overloaded,
retryable=True,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This structured-code path leaves should_fallback at its default False, unlike the new 400 and message-pattern memory paths. If the intended contract is that memory-ceiling results are failover-eligible, set the flag here too and assert it in the code-only regression test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 620b19bb. _classify_by_error_code no longer constructs its own result for this case — the memory-ceiling code returns _memory_ceiling_result(result_fn), and that shared helper sets should_fallback=True explicitly, with the rationale in its docstring: a local-inference memory wall stays a wall on retry, so failover is the correct recovery rather than compression.

Routing it through the shared helper also removes the per-route drift this comment was guarding against — the 400, 500/502, 503/529, code-only and no-status paths now all annotate identically instead of each setting the flags by hand.

The code-only regression test asserts the flag directly: test_no_status_prefill_memory_code_is_overloaded drives a structured code with no HTTP status and ends with assert result.should_fallback is True.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to the SHA above: 620b19bb is in the ancestry of neither this branch nor main — it was rebased away after that reply, so the citation points at nothing. Current head is c2c430d890d.

The mechanism is unchanged, and these anchors survive a rebase: _classify_by_error_code no longer builds its own result for the memory-ceiling codes, it returns the shared helper _memory_ceiling_result(result_fn) (agent/error_classifier.py:1683), defined at :497, which sets should_fallback=True as one recovery contract for every detection route — so the structured-code path can no longer be silently weaker than the 400 and message paths. test_no_status_prefill_memory_code_is_overloaded in tests/agent/test_error_classifier.py pins exactly that and closes on assert result.should_fallback is True.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both halves of this are in place. My previous two replies pinned them to commit SHAs, and this branch has been rebased twice since — c2c430d890d, which the last reply called the current head, is no longer an ancestor of it. Re-anchoring on names instead, since those survive a rebase and SHAs on this branch clearly do not.

Verified against the current head f17f3af7b7b:

The flag is set on the structured-code path. _classify_by_error_code (agent/error_classifier.py:1748) no longer builds its own result for this case — the memory-code guard at :1775 returns the shared _memory_ceiling_result helper (:537), which sets should_fallback=True. That helper is now the single construction site for this classification, reached from all four detection routes (400, 5xx, structured code, and the status-less streaming message), which is what stops the annotation drifting between them — the exact asymmetry you pointed at.

It is asserted in the code-only regression test. test_no_status_prefill_memory_code_is_overloaded, tests/agent/test_error_classifier.py:835 — a status-less body carrying only prefill_memory_exceeded with the message fully reworded, so the structured code is the only signal. It asserts should_fallback is True alongside reason == overloaded, retryable is True, and should_compress is False, so the code-only route is pinned to the same recovery contract as the others rather than a weaker subset.

On the contract question you raised: memory-ceiling results are intended to be failover-eligible, and the reasoning is recorded in the helper's docstring rather than left implicit — a local-inference memory wall stays wedged until the server restarts, so retry-with-backoff alone does not recover it and failing over to a roomier provider is the durable path. Compression is deliberately still off, since it cannot relieve a prefill memory peak.

Apologies for the churn on this thread. Going forward I will cite symbols and test names here rather than SHAs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to the SHA above: f17f3af7b7b was rewritten by the successful rebase; the current head is 2976b5fbfdd. The mechanism is unchanged: _classify_by_error_code routes memory-ceiling codes through _memory_ceiling_result at agent/error_classifier.py:1918, and test_no_status_prefill_memory_code_is_overloaded at tests/agent/test_error_classifier.py:835 still asserts should_fallback is True at :859.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026
@briandevans
briandevans force-pushed the fix/agent-memory-ceiling-overloaded-52261 branch from 29ed405 to 59e4002 Compare July 15, 2026 07:46
@briandevans

Copy link
Copy Markdown
Contributor Author

Both findings were correct and are addressed in 59e4002cb. The branch is rebased onto current main first — the 5xx routing you cite post-dates the old base (a04b7024f), so the branches to guard did not exist on it.

1. 5xx memory-ceiling collision — confirmed, fixed

Reproduced before fixing: a 503 memory-guard body classified as context_overflow with should_compress=True, i.e. exactly the wedge loop this PR exists to prevent, just reached via a different status.

assert <FailoverReason.context_overflow> == <FailoverReason.overloaded>
  ClassifiedError(reason=context_overflow, status_code=503, ..., should_compress=True)

The collision is the same one the PR already documents: _CONTEXT_OVERFLOW_PATTERNS contains "context length", and the oMLX/llama.cpp memory abort ends in "... or reduce context length." Both 5xx branches now run the narrow code/message detection ahead of the overflow check, mirroring the ordering _classify_400 already uses. In 500/502 the guard sits after the request-validation guard, matching _classify_400 exactly; the pattern sets are disjoint (memory wording vs parameter wording), so neither shadows the other.

New coverage, parametrized over 500/502/503/529:

  • message-shape and structured-code-shape memory rejections → overloaded, should_compress is False
  • real-overflow control → still context_overflow + should_compress is True, proving the guard did not disable the overflow-as-5xx handling you added
  • generic 500server_error and generic 503overloaded controls, proving the guard is narrow and does not sweep ordinary 5xx into the memory bucket

2. Inconsistent recovery annotation — confirmed, fixed at the root

You are right that the structured-code branch returned overloaded without should_fallback=True while the 400 and message branches set it, and that no test asserted the field. The assertion was genuinely absent: adding should_fallback=True reddened nothing until I added the assert.

Rather than patch the one branch, detection and the recovery contract are now factored into _is_memory_ceiling() / _memory_ceiling_result() and shared by all five sites (400, 500/502, 503/529, structured code, status-less message). The same rejection now classifies identically regardless of which route detects it, and the annotation cannot drift per-route again. The code-only case asserts should_fallback explicitly.

One correction: the line refs had drifted — the code-only test is at tests/agent/test_error_classifier.py:851, not 728-747. The finding itself held at the current location.

Verification

tests/agent/test_error_classifier.py 216 passed. Full dependent surface (tests/agent/, tests/run_agent/, plus the tests/gateway/ classifier consumers) 2574 passed, 3 skipped, 0 failures. Fail-before/pass-after confirmed in both directions: reverting only the production hunk reds 9 of the new assertions while all controls stay green.

@briandevans
briandevans force-pushed the fix/agent-memory-ceiling-overloaded-52261 branch from 6ed6b30 to 620b19b Compare July 18, 2026 23:20
@teknium1 teknium1 added the area/memory Memory subsystem: store, providers, sync, background reviews label Jul 19, 2026
@briandevans

Copy link
Copy Markdown
Contributor Author

@jp-cruz — correction to the commit ids cited above, consolidated into one comment rather than four.

The 2026-07-15 rebase-and-squash collapsed this branch to a single commit, so every SHA I cited in earlier replies is orphaned: d2a75c12b, fe4ae5207, a04dd5ed6 and 59e4002cb no longer exist on the branch. The live commit is 620b19bb1 ("fix(agent): classify provider memory-ceiling 400s as overloaded, not context_overflow") — the branch's only commit, and its current head. My inline replies from 07-26 already cite 620b19bb, so this reconciles the conversation tab with them.

Nothing was dropped in the squash. Per claim, verified against 620b19bb1:

originally cited as what it was where it lives now
d2a75c12b streaming process memory limit exceeded no longer collides with the usage-limit/billing block test_streaming_process_memory_limit_exceeded_is_overloaded_not_billingtests/agent/test_error_classifier.py:835
d2a75c12b structured code: "prefill_memory_exceeded" matched in both the error-code and 400 routes _MEMORY_CEILING_ERROR_CODESagent/error_classifier.py:376; tests test_400_prefill_memory_code_reworded_message_is_overloaded (tests/agent/test_error_classifier.py:859) and test_no_status_prefill_memory_code_is_overloaded (:885)
fe4ae5207 should_fallback=True on the memory results folded into the single recovery contract _memory_ceiling_result()agent/error_classifier.py:400, whose docstring states why
a04dd5ed6 the proxy-flattened LiteLLM 400 from your fixtures — the one shape the branch fixed but did not pin test_400_litellm_proxy_flattened_memory_guard_is_overloadedtests/agent/test_error_classifier.py:911
59e4002cb 5xx memory-ceiling collision, plus detection/annotation factored so it cannot drift per route _is_memory_ceiling()agent/error_classifier.py:383 — and _memory_ceiling_result() (:400), shared by all five detection sites (400, 500/502, 503/529, structured code, status-less message)

Test names and file paths survive a rebase where commit ids do not, so those are the durable references from here on.

Most of the rows above exist because you checked out the branch and ran real oMLX wordings rather than reading the diff — the proxy-flattened capture in particular would not have been pinned without your fixtures.

@tkaufmann

Copy link
Copy Markdown
Contributor

Independent confirmation from a second local-inference setup, with a captured incident. Hermes 0.20.0 (497c90f31), oMLX 0.5.7 on Apple Silicon (96 GB unified), served over an OpenAI-compatible endpoint. Same failure mode as #52261.

What happened

oMLX rejected a request with its prefill memory guard:

HTTP 400  oMLX prefill memory guard rejected this prompt: Prefill context too large for
available memory (preflight safety guard, kv_len=83168, min_chunk=32): predicted peak
would require ~78.31 GB (current 72.33 GB + KV 5.22 GB + min-chunk transient 772.56 MB)
but prefill safety cap is 77.76 GB (90% of metal_cap ceiling 86.40 GB). Raise kernel
iogpu.wired_limit_mb in Terminal, or reduce context length.

code: prefill_memory_exceeded   omlx_code: prefill_memory_exceeded
estimated_bytes: 84082063701    limit_bytes: 83493598003

7 ms later Hermes started a compression. It ran for 8 minutes and reduced the session from 174 messages to 9:

14:11:00,676 WARNING  API call failed (attempt 1/3) error_type=BadRequestError
14:11:00,683 INFO     context compression started: messages=174 tokens=~63,337
14:19:13,215 INFO     context compression done: messages=174->9 rough_tokens=~27,727

The conversation was never the problem: 63,337 tokens against a 262,144-token window. The memory was held by a second, idle model the engine had kept resident (28.0 GB + 35.6 GB against a 77.76 GB cap). Evicting that model fixes the request; compressing the conversation cannot. Setting a ttl_seconds on the models removed the trigger on our side, but the misclassification remains a live hazard for any long session on local inference.

Why it misclassifies

On clean main, the only pattern that matches this body is context length — and it matches solely inside the remediation hint at the end of the message:

>>> [p for p in _CONTEXT_OVERFLOW_PATTERNS if p in error_msg.lower()]
['context length']

This branch fixes it

I applied only the agent/error_classifier.py half of this PR onto 497c90f31 — it applies cleanly; the test file has drifted since June — and re-ran both real messages I had captured, plus a genuine context overflow as a control:

input clean main with this PR
400 + code: prefill_memory_exceeded (above) context_overflow, compress overloaded, no compress, fallback
streaming APIError, no code, same guard, different wording context_overflow, compress overloaded, no compress, fallback
This model's maximum context length is 262144 tokens. However, your messages resulted in 300000 tokens. context_overflow, compress context_overflow, compress

The second row is the one I would highlight: that shape carries no structured error code at all, and is caught by the available memory message pattern alone. So the pattern list is pulling real weight here, not just _MEMORY_CEILING_ERROR_CODES.

Happy to supply the full sanitized log excerpt if that helps.

@alt-glitch alt-glitch added area/compression Context compression and continuation sessions and removed sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 13, 2026
@briandevans

Copy link
Copy Markdown
Contributor Author

Thank you for this, @tkaufmann — a second engine build on completely different hardware, with a captured timeline, is worth far more than another synthetic fixture. The 174 → 9 line is the part that makes the cost concrete in a way the original issue never quite did: 63,337 tokens against a 262,144-token window, and the recovery mechanism spent eight minutes destroying 165 messages that were never the problem. Your diagnosis of the actual cause (a second idle resident model holding 28.0 + 35.6 GB) is also the thing that makes this a misclassification rather than a tuning issue — no amount of shrinking the conversation reaches memory held by another process.

The premise is still live on current main, not just on 0.20.0. I checked agent/error_classifier.py at today's tip: there is no memory-ceiling branch in it at all — no prefill_memory_exceeded, no memory guard, no available memory, no prefill would require, nothing. _CONTEXT_OVERFLOW_PATTERNS is still defined at line 276 and is still consulted at 1264 and 1283, both returning context_overflow, retryable=True, should_compress=True. So your >>> [p for p in _CONTEXT_OVERFLOW_PATTERNS if p in error_msg.lower()]['context length'] reduction reproduces exactly on the current tip. The classification is decided by the remediation hint at the tail of the message; everything upstream of it — the byte figures, the code, limit_bytes — is never read.

Row 2 is the row I'd highlight too, and the branch is built that way deliberately. Detection has two independent halves, and either alone is sufficient: _is_memory_ceiling is code in _MEMORY_CEILING_ERROR_CODES or any(pattern in message). The structured-code route is additional, not the only route. Your status-less streaming APIError carries no code, so it lands entirely on the message half — test_no_status_prefill_too_large_for_available_memory_is_overloaded pins that exact path (bare Exception, no HTTP status, no code, available memory alone doing the work).

One detail from re-running your 400 body against the pattern list, because it cuts in your favour: it matches on two patterns, memory guard and available memory. It does not match prefill would require — your 0.5.7 wording is "predicted peak would require", where the wording the pattern was written against was "Prefill would require ~13.87 GB peak". That's an argument for keeping the list wide rather than trimming it: your build reworded the sentence between releases and the classification still survives on two other tokens. Message matching is inherently a race against provider copy-editing, which is why the code half exists.

Your code is covered explicitly. _MEMORY_CEILING_ERROR_CODES is {prefill_memory_exceeded, omlx_prefill_memory_exceeded, memory_limit_exceeded} — both spellings your body carries. It is matched in _classify_400 and _classify_by_error_code, so a direct (non-proxied) connection is caught even when the message has been reworded past every pattern: test_400_prefill_memory_code_reworded_message_is_overloaded and test_no_status_prefill_memory_code_is_overloaded.

All four routes converge on one contract. 400, 5xx, structured code, and status-less message all return through a single _memory_ceiling_resultoverloaded, retryable=True, should_fallback=True, should_compress=False — precisely so the annotation cannot drift between detection routes. That is your third column verbatim. The control is pinned as well: test_400_genuine_context_window_overflow_still_compresses keeps a real maximum context length … tokens on context_overflow + compress, so the guard is not swallowing genuine overflow.

On the drift — you're right, and it is worse than "the test file has drifted". The branch sits on a base ~5993 commits behind current main, and agent/error_classifier.py has taken 10 commits on main since 2026-07-26. Two of them are directly in this area: 53bfe40a35d ("classify throttle messages before token-overflow patterns; add new overflow shapes") and 068f58740c2 (guard grammar check + extract shared constant). So main has kept widening the overflow patterns and reordering the message classifier around them, and over that same stretch added no memory-ceiling branch at all — which is your point rather than a counter to it. The asymmetry you hit is the expected shape of that: the production half applies cleanly onto 497c90f31 because it lives in a region main has not touched, while the test file conflicts because main edits it constantly. I'll rebase and reconcile the guard against the newly added overflow shapes rather than leave it sitting on a stale base.

Yes please on the sanitized log excerpt, and specifically the second shape — the streaming APIError with no structured code. That is the thinnest fixture coverage in the suite: both code-less fixtures currently in it (test_no_status_prefill_too_large_for_available_memory_is_overloaded and test_streaming_process_memory_limit_exceeded_is_overloaded_not_billing) derive from a single reporter's capture on the 13.5 GB build, so your wording would be the first independent instance of that shape. The most useful part would be the message exactly as it arrives after the transport layer has wrapped it, since everything downstream keys off that literal string — plus, if the log has it, whether that abort surfaced any HTTP status at all. Both go straight in as fixtures on the rebase.

@tkaufmann

Copy link
Copy Markdown
Contributor

Here is the sanitized excerpt for the code-less streaming shape, and the answer to your question about the HTTP status: there is none, and there is no code either, so this instance rests entirely on the message.

The capture (host, paths and session id redacted; message text verbatim):

2026-08-12 16:57:12,007 ERROR agent.chat_completion_helpers: Streaming failed before delivery: Prefill context too large for available memory (pre-chunk guard at 192 tokens, kv_len=37056): predicted peak would exceed prefill safety cap 77.8GB (90% of metal_cap ceiling 86.4GB). Raise kernel iogpu.wired_limit_mb in Terminal (currently caps Metal at 86.40 GB), or reduce context length.
Traceback (most recent call last):
  File "<hermes>/agent/chat_completion_helpers.py", line 4016, in _call
    result["response"] = _call_chat_completions(stream_attempt_id)
  File "<hermes>/agent/chat_completion_helpers.py", line 3390, in _call_chat_completions
    for chunk in stream:
  File "<hermes>/agent/relay_llm.py", line 577, in __next__
    chunk = next(self._stream)
  File "<hermes>/venv/lib/python3.11/site-packages/openai/_streaming.py", line 49, in __iter__
    for item in self._iterator:
  File "<hermes>/venv/lib/python3.11/site-packages/openai/_streaming.py", line 95, in __stream__
    raise APIError(
openai.APIError: Prefill context too large for available memory (pre-chunk guard at 192 tokens, kv_len=37056): predicted peak would exceed prefill safety cap 77.8GB (90% of metal_cap ceiling 86.4GB). Raise kernel iogpu.wired_limit_mb in Terminal (currently caps Metal at 86.40 GB), or reduce context length.
2026-08-12 16:57:12,008 WARNING [<session>] agent.conversation_loop: API call failed (attempt 1/3) error_type=APIError thread=Thread-269 (run) provider=custom base_url=http://<host>:11435/v1 model=qw36-27b-8bit-mtp:agent
2026-08-12 16:59:09,141 ERROR [<session>] agent.conversation_loop: Context length exceeded: 37,629 tokens. Cannot compress further.

That last line is the misclassification landing two minutes later: a memory rejection read as context overflow, compression attempted, and the conversation declared uncompressible at 37,629 tokens.

Why there is no status. The abort lands after the response has begun; the stream opened normally and then carried an error event. The SDK raises the base APIError from _streaming.py:95, and that class has no status_code attribute at all — only the APIStatusError subclasses do. Nothing in the frame carries a status.

Why there is no code, and how that is provable from the message text. The engine has two ways to build a streaming error body. One is prefill-aware and does carry the code; the other is generic and does not:

# prefill-aware branch
if isinstance(e, PrefillMemoryExceededError):
    error_data = _prefill_memory_openai_error_body(e)   # sets code + omlx_code
else:
    error_data = {"error": {"message": str(e), "type": "server_error"}}

# the chat streaming generator's own handler, no isinstance check
except Exception as e:
    error_data = {"error": {"message": str(e), "type": "server_error"}}

The prefill-aware builder never emits the bare exception text: it wraps it as "oMLX prefill memory guard rejected this prompt: " + str(exc) + " To continue, set Memory Guard to aggressive, …". My 400 body carries exactly that wrapper. The streaming message above carries neither the prefix nor the trailing advice — it is the bare str(exc). So it was built by the generic branch, and the body therefore has no code key. APIError.__init__ sets self.code = body.get("code"), so code is None.

Worth noting for the fixture: the guard does raise PrefillMemoryExceededError in both cases. The code survives on the pre-stream path and is dropped on the mid-stream path, purely because the chat streaming generator catches Exception without discriminating. So this is not an engine that lacks structured errors; it is the same structured error losing its structure at one exit.

For contrast, the same guard rejecting before the stream opens:

openai.BadRequestError: Error code: 400 - {'error': {'message': 'oMLX prefill memory guard rejected this prompt: Prefill context too large for available memory (preflight safety guard, kv_len=51311, min_chunk=32): predicted peak would require ~78.57 GB (current 71.22 GB + KV 3.28 GB + min-chunk transient 4.08 GB) but prefill safety cap is 77.76 GB (90% of metal_cap ceiling 86.40 GB). ...', 'type': 'invalid_request_error', 'param': None, 'code': 'prefill_memory_exceeded', 'omlx_code': 'prefill_memory_exceeded', 'estimated_bytes': 84368206439, ...}}

Counts across the whole log, so the ratio isn't overstated: the prefill guard fired four times — twice as the code-less streaming APIError (both in the session above) and twice as a 400 with code (one in that session, one the next day). There is also one openai.APIError: Request aborted: process memory limit exceeded (usage 49.8 GB, abort threshold …), which is the other code-less shape already in your suite.

On the wording, confirming your reading: the two variants differ in exactly the phrase the pattern was written against. The 400 says "predicted peak would require ~78.57 GB"; the streaming one says "predicted peak would exceed prefill safety cap 77.8GB". Neither matches prefill would require. Run against both lists:

>>> [p for p in _CONTEXT_OVERFLOW_PATTERNS if p in msg.lower()]
['context length']
>>> [p for p in _MEMORY_CEILING_PATTERNS if p in msg.lower()]
['available memory']

One token on each side. The context length hit comes from the remediation hint the engine appends, "or reduce context length" — the tail-of-the-message effect you described, and on today's main that single token is the only thing the classifier sees.

One refinement to the 262,144 figure from my original report. The number is the model's own window (text_config.max_position_embeddings), but it is not the number Hermes was working with. The log shows Could not determine context length for model 'qw36-27b-8bit-mtp:agent' — falling back to 256,000 tokens, because the value sits nested under text_config rather than at the top level of config.json. So the compression decision was taken against the 256,000 fallback, not the true 262,144. It makes no difference to the argument — 63,337 tokens is far below either — but if a fixture ever pins a window, the operative one is the fallback.

For completeness on that event, since it belongs to the 400 shape rather than this one: compression started at messages=174 tokens=~63,337 and finished 8 minutes 12 seconds later at messages=174->9 rough_tokens=~27,727.

@tkaufmann

Copy link
Copy Markdown
Contributor

Filed as #86097, with the stack, the fall-through gates and the line numbers against current main.

Impact turned out to be display-only as far as I can trace it: the agent's own path resolves the provider and gets the right window, and I could not find a place where this route's value feeds a decision. So it is a wrong "Auto-detected" figure in the settings UI for every custom-provider install, nothing worse.

@briandevans

Copy link
Copy Markdown
Contributor Author

Thanks @tkaufmann. Before touching anything I ran your three shapes against c2c430d890d and got your table back verbatim, which is what made this cheap to act on:

507 ModelTooLargeError        -> server_error  retryable=True   fallback=False
507 InsufficientMemoryError   -> server_error  retryable=True   fallback=False
409 load aborted: process ... -> format_error  retryable=False  fallback=True
code=prefill_memory_exceeded  -> overloaded    retryable=True
code=prefill_memory_aborted   -> format_error  retryable=False

All three gaps are fixed, in five commits. I have kept your evidence grading in the commit bodies and in the code comments rather than flattening it, because the difference between the 507 and the 409 is the whole reason this was worth acting on quickly.

Captured

74fcafc1f8efix(agent): route memory-ceiling 507s through the memory-ceiling contract. 507 now goes through the same _is_memory_ceiling predicate as the 400 / 5xx / structured-code / status-less routes, so it picks up the one shared _memory_ceiling_result annotation instead of a bespoke branch. You put your finger on the bit that actually matters: retrying was never wrong — your own retry succeeded 2.7 s later — but should_fallback=False is what strands the turn when the wall does not clear inside the retry budget. Test test_507_omlx_model_load_ceiling_is_overloaded_with_fallback in tests/agent/test_error_classifier.py, red before the change with exactly server_error / should_fallback=False; a control, test_507_without_memory_wording_is_generic_server_error, keeps 507 in its literal Insufficient Storage sense on the generic 5xx path. Your ModelTooLargeError body is pinned verbatim. I did not invent an InsufficientMemoryError body, since you did not post one — the fixture comment says so rather than implying both are captured.

de86bb0d1eatest(agent): pin the second captured process-memory abort wording. Your 11 Aug tail now sits next to the 2 Aug one, with the engine_core.py mtime argument written down, so the pair is on the record instead of only the earlier half. Honest label: this one is a characterisation test, not a regression test — the reworded body already classifies correctly on this branch. It is red against clean main (c896c09c429), where it comes out as billing / retryable=False. A companion test asserts the two captures genuinely differ ("loosen" and "free system memory" in the first, neither in the second), so a later tidy-up cannot collapse them into one string and quietly delete the drift they record.

Read from source, not captured — labelled that way in the commit body and in the code comment

aa40d6f3601prefill_memory_aborted added to _MEMORY_CEILING_ERROR_CODES. I took your severity framing as written: this is not a live misclassification, since the aborted body still matches on "memory guard" and "available memory". What was broken is the code layer, which is the layer that exists for the reworded or proxy-stripped case — and that case is not hypothetical here, given the 0.5.6 → 0.5.7 rewording. Test test_prefill_memory_aborted_code_is_overloaded[400] and [None], red before at format_error and at the retryable unknown bucket respectively, and it asserts that no memory pattern is present in the fixture message, so it cannot pass through the message layer by accident.

94158bfdffe — 409. The commit body opens with "CODE READING, NOT A CAPTURE", and the code comment says "UNLIKE the 507 branch above, this one is read from the engine's source, not from a captured response: no reporter has produced a 409 body." Test test_409_memory_abort_is_overloaded_not_format_error, red before at format_error / retryable=False, with a control that keeps an ordinary 409 (a model swap already in flight) on the existing 4xx path. Same predicate gate, so it stays inert unless the body carries memory wording.

Corrections — 4d01971fd7a, comments only, no non-comment line added

  • The arrow. The fixture was already U+2192; what was wrong was the comment above it, which still said the spelling "has not been established from the raw log". That is replaced with your evidence — twelve occurrences in each log, every one e2 86 92, zero ASCII, and one module-level constant with no ASCII sibling — including your own caveat that the grep proves the decoded string and the constant settles the rest. No fixture bytes changed, and there is no ASCII spelling in any production pattern list.
  • describe_ceiling_binding(). The note at _MEMORY_CEILING_PATTERNS now says plainly that memory_guard_tier and dynamic ceiling widen the net and are not the guarantee, carrying the substance of your branch table: the ceiling noun varies, ties slash-join so static/metal_cap ceiling is reachable, and the dynamic+custom and metal_cap branches point at custom_ceiling_bytes and iogpu.wired_limit_mb instead of naming the tier. Your conclusion is recorded too — nothing is unguarded, because memory limit exceeded is in the prefix and survives every branch. The reason for writing it down is that a future pruning pass should not be able to mistake the wideners for the guarantee.

Two places I did not do exactly what you suggested

  • I kept the ASCII fixture rather than dropping it, and relabelled it CONSTRUCTED — the same status this file already gives _SYNTHETIC_PREFILL_400_NO_OVERFLOW_TOKEN. Your evidence settles what the engine emits, which is why the comment claiming uncertainty had to go; but what that test asserts is a different and still-live property — that none of the matching tokens live in the separator — so a transcoding hop further downstream cannot quietly become load-bearing. Nothing in production gained an ASCII spelling. If you would still rather see it gone, say so and I will drop it.
  • I kept omlx_prefill_memory_exceeded and documented it instead. Your reading matches what this file already assumed — the comment above the set has always described omlx_code as a mirror — so what I have written in is that it has no known producer, that the captured bodies show the unprefixed spelling in both fields, and that it is retained only because it cannot false-positive and a namespacing proxy is the obvious way it would ever start appearing. The thing I did not want to leave standing is exactly what you spotted: an entry that reads as a shape someone had seen.

Full file: 115 passed. New head is de86bb0d1ea.

Noted on #86097 — the display-only tracing is useful, and I am not touching that route from here.

@briandevans
briandevans force-pushed the fix/agent-memory-ceiling-overloaded-52261 branch from de86bb0 to f17f3af Compare August 15, 2026 03:40
@briandevans

Copy link
Copy Markdown
Contributor Author

Correction to the SHAs in my comment above, @tkaufmann. The branch has been rebased onto current main — it was 229 commits behind — so every hash I cited a few hours ago has been rewritten and now points at nothing. None of the content changed: the rebase carried all 12 commits with a diffstat identical to before (agent/error_classifier.py +209, tests/agent/test_error_classifier.py +816, zero deletions). Re-pointing them:

cited above now
74fcafc1f8e — 507 routed through the memory-ceiling contract e347abdab78
aa40d6f3601prefill_memory_aborted as a ceiling code 0330df0a050
94158bfdffe — 409 routed through the memory-ceiling contract dd5f376a89c
4d01971fd7a — the comment-only corrections d01cae7b829
de86bb0d1ea — the second captured abort wording f17f3af7b7b
c2c430d890d — the head your three shapes were run against 94df04bc225

Branch head is now f17f3af7b7b.

The test names in that comment are the anchors that survive this, and none of them moved: test_507_omlx_model_load_ceiling_is_overloaded_with_fallback, test_507_without_memory_wording_is_generic_server_error, test_prefill_memory_aborted_code_is_overloaded, test_409_memory_abort_is_overloaded_not_format_error, all in tests/agent/test_error_classifier.py. Worth saying plainly that this is the second time on this PR that a rebase has orphaned a citation, which is why those comments name tests and line-anchored symbols alongside the hashes — the names keep working, the hashes do not.

Two things from the rebase itself, since both land in the classifier you have been reading:

  • main gained a provider_stream_non_json_data branch in _classify_by_error_code (deterministic request-validation failures encoded as plain-text event: error SSE behind HTTP 200), inserted at the same point as our structured memory-ceiling check — the only real conflict in the rebase. The two conditions are disjoint: provider_stream_non_json_data on one side, {prefill_memory_exceeded, prefill_memory_aborted, omlx_prefill_memory_exceeded, memory_limit_exceeded} on the other. So the ordering carries no meaning and both now sit in that function with main's branch first. No behaviour of either was changed to accommodate the other.
  • The full file is 119 passed on the new base, up from the 115 I quoted; the four extra are main's own additions, not ours.

Re-verified while I was in there: the premise is still unfixed upstream — git grep -E '_MEMORY_CEILING|prefill_memory|memory guard' origin/main -- agent/ returns nothing.

@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

fix(agent): classify provider memory-ceiling 400s as overloaded, not context_overflow

Deeply documented and exhaustively tested — the captured oMLX bodies, the 0.5.6→0.5.7 rewording analysis, and the "must be checked BEFORE overflow/usage-limit at every site" ordering are the right rigor for a classifier bug. The centralized _memory_ceiling_result contract is a good call. Observations:

  1. agent/error_classifier.py_is_memory_ceiling() matches _MEMORY_CEILING_PATTERNS against error_msg without lowercasing, while every pattern is lowercase. A provider body with a capitalized first letter — e.g. "Prefill would require ~13.87 GB peak" vs the pattern "prefill would require" — silently fails to match. The captured oMLX bodies are lowercase for the load-bearing tokens (the tests pass via "dynamic ceiling" / "memory guard" / "predicted peak would"), so the risk is latent rather than live, but a single error_msg.lower() before matching (and a test with a capitalized body) would make the guard robust against case drift.

  2. agent/error_classifier.py"memory_limit_exceeded" in _MEMORY_CEILING_ERROR_CODES is a fairly generic code string used by other ecosystems (e.g. cloud runtime quotas) that a proxy could pass through for a genuinely different failure. It is narrower than resource_exhausted, so the false-positive surface is small, but consider keeping it message-gated (only when the message also carries memory wording) or documenting why the bare code cannot collide.

  3. Ordering fragility: the memory guard must precede the overflow check (400/5xx), the usage-limit/billing check (message path), and the generic 4xx fallthrough (409) at every classification site. Each site duplicates the "check memory first" reasoning in comments. Since the tests pin the ordering per site, a future site added without the guard would regress silently. A single classify entry point that runs _is_memory_ceiling before dispatching would prevent site drift — the _memory_ceiling_result centralization is a good start in that direction.

  4. Minor: the comment blocks and fixture prose are very long relative to the code. That's a deliberate trade here (the drift evidence is genuinely load-bearing), but a condensed summary at the top of each pattern list with the full rationale moved to the issue would ease future maintenance.

…context_overflow

Local-inference memory/resource-ceiling rejections (oMLX/MLX memory guard,
OOM, structured prefill codes, "prefill context too large for available
memory") were being classified as context_overflow. That routes them into a
compress-and-shrink loop which cannot relieve a prefill memory peak and wedges
the session into a "Cannot compress further" reset loop — the prompt is often
tiny, so shrinking history does nothing.

Classify them as `overloaded` instead: retry with backoff, no compression, no
session reset (mirrors 503/529 recovery). The guard runs before the
context_overflow check in both the 400 and no-status APIError paths, and is
ordered after the empty-provider-response guard. See NousResearch#52261.
A second field report on issue NousResearch#52261 supplies the shape the suite covered
most thinly: the oMLX prefill memory guard rejecting a request *after* the
stream has opened.

That exit is structurally different from the pre-stream 400 already covered
here. The chat streaming generator catches bare ``Exception`` and builds
``{"error": {"message": str(e), "type": "server_error"}}`` without the
``isinstance(e, PrefillMemoryExceededError)`` discrimination the pre-stream
builder uses, so the structured ``prefill_memory_exceeded`` code is dropped;
and the OpenAI SDK raises the base ``APIError`` from ``_streaming.py:95``,
which has no ``status_code`` attribute at all. The same structured error
loses its structure at one exit. Neither the 400 route nor the error-code
route can fire, so the classification rests entirely on the message text —
and the only context token in that text is "context length", from the
trailing "or reduce context length" remediation hint.

Against an unguarded classifier this fixture reproduces the reported
failure exactly: reason=context_overflow, should_compress=True, on a session
of 37,629 tokens against a 256,000-token window. The reporter's log shows
the consequence two minutes later — "Context length exceeded: 37,629 tokens.
Cannot compress further."

The window figures in the test are the reporter's own corrected ones: the
model's window is 262,144, but Hermes could not read it because the value is
nested under ``text_config`` in config.json, so it fell back to 256,000 and
took the compression decision against that.

Message text is pinned verbatim as a module constant; the classifier matches
substrings, so reflowing it would silently change what is asserted.
Companion to the mid-stream capture: the same prefill memory guard, the same
engine build, rejecting before the stream opens. Here the prefill-aware body
builder runs, so the wrapper prefix and the structured
``code``/``omlx_code`` both survive — which is exactly what isolates the
streaming exit as the only thing that strips the structure.

Two things this fixture pins that the existing 400 cases did not:

- ``str(error)`` is the OpenAI SDK's full body repr, so the literal
  ``'type': 'invalid_request_error'`` is inside the matched text. That is the
  reason _REQUEST_VALIDATION_PATTERNS excludes that one entry from its own
  match; without the exclusion this 400 would be answered as a non-retryable
  format_error before any memory or overflow check ran, and the exclusion had
  no test standing on it from this direction.
- The body carries no context-overflow token at all (the reporter elided the
  remediation sentence), so against an unguarded classifier it does not reach
  the overflow branch — it falls through 400 handling to the format_error
  default, reason=format_error with retryable=False. The same rejection is
  therefore wrong in two different directions depending on which exit it
  takes, which is why both routes need the guard rather than only the
  overflow-adjacent one.
…tterns

The reporter on issue NousResearch#52261 ran both captures against the pattern lists and
the result exposes a stale entry in this list's own evidence base:

    >>> [p for p in _CONTEXT_OVERFLOW_PATTERNS if p in msg.lower()]
    ['context length']
    >>> [p for p in _MEMORY_CEILING_PATTERNS if p in msg.lower()]
    ['available memory']

"prefill would require" was written against the oMLX build that reports
"Prefill would require ~13.87 GB peak". oMLX 0.5.7 reworded that same
sentence to "predicted peak would require ~78.57 GB" on the pre-stream path
and "predicted peak would exceed prefill safety cap 77.8GB" mid-stream, so
the entry stopped matching the engine it was written for and nothing failed —
the shapes kept classifying correctly, but on one unrelated token each.

That leaves the mid-stream shape decided by a single substring on either
side: "available memory" for the memory route, "context length" for the
overflow route, and the latter comes from the appended remediation hint
rather than from the rejection itself. One more copy-edit in either sentence
flips a memory rejection back into the compression loop.

So generalize on the wording that survived the verb change and appears in
both 0.5.7 exits: the verb-independent "predicted peak would", and the two
cap names "prefill safety cap" and "metal_cap". "prefill would require" is
kept — the older wording is still in the field, and it is the only match on
the 13.5 GB build alongside "dynamic ceiling".

All three additions name an allocation ceiling in bytes, never a token or
window count, so they stay disjoint from _CONTEXT_OVERFLOW_PATTERNS; a test
now asserts that disjointness across both lists rather than leaving it as a
comment, because widening this list is exactly how genuine window overflows
would get diverted out of compression.
The pre-stream fixture was transcribed from a report that elided the
remediation sentence with a trailing "...", and a second elision dropped
``limit_bytes`` and the outer ``"type": "error"`` wrapper.  The reporter has
since supplied the complete body; all three are on the wire.

This matters beyond fixture hygiene.  The elided sentence is

    Raise kernel iogpu.wired_limit_mb in Terminal (currently caps Metal at
    86.40 GB), or reduce context length. To continue, set Memory Guard to
    aggressive, raise the custom memory guard ceiling, free system memory,
    or compact/reduce context.

which contains "context length", so the real body DOES reach the overflow
branch — the opposite of what the elided form showed.  Classified against an
unguarded classifier, the real capture is context_overflow with
should_compress=True, i.e. wrong in the SAME direction as the mid-stream
shape, not a different one.  format_error is reachable only from the
truncated string, which the engine never emits.

The body pinned here is the 13 Aug 14:11 firing (kv_len=83168), which is the
one that goes with the approx_tokens=63337 this test passes; the previously
transcribed 12 Aug 16:37 firing is equally real but pairs with different
accounting numbers.  All four existing assertions hold unchanged either way.

The truncated string is kept under a name that says it is constructed, with
its own test.  No real oMLX body omits the remediation hint, so the guard is
never asked to work without one — but pinning that it could keeps a future
engine copy-edit from silently reopening this bug.
Both claims were ours, both are comment-only, and neither ever matched the
code — the assertions they sit above pass unchanged before and after.

1. test_400_omlx_057_prefill_memory_exceeded_is_overloaded said the body
   "does not even reach the overflow branch" and was "wrong in a different
   direction from the streaming shape".  That described the elided
   transcription the fixture used to carry.  The real capture ends "or reduce
   context length", so an unguarded classifier reads it as context_overflow
   with should_compress=True — the same direction as the mid-stream shape.

   This claim is also in the message of commit 4db5a59.  That commit is
   pushed and amending it would rewrite published history, so the correction
   rides here instead.

2. test_streaming_omlx_057_prefill_abort_without_status_or_code said the
   model's window could not be read because it was nested under
   ``text_config`` in config.json, and that the 256,000 fallback was "the
   number the compression decision was actually taken against".  oMLX does
   report the window as ``{"max_model_len": 262144}`` on /v1/models, which
   agent/model_metadata.py already parses; the nested
   ``max_position_embeddings`` is a red herring.  The 256,000 is a display
   value produced by a different route.

   The reporter's stack shows that route is /api/model/info, and that the
   agent's own client had a working base_url one second later.  No log line
   records what the compressor itself resolved, so that remains a strong
   inference rather than a measurement, and the corrected comment does not
   assert it either way.  ``context_length=256000`` stays as a parameter; it
   only has to be a plausible window well above 37,629.
A third rejection shape from the same engine, and a different guard from the
two already covered: the prefill guard refuses a prompt before admitting it,
while this is the process memory enforcer aborting a request already in
flight because resident usage crossed a watermark.

    Request aborted: process memory limit exceeded (usage 49.8 GB, abort
    threshold (hard watermark) 49.2 GB, ceiling 51.8 GB). Reduce context
    length, free system memory, or loosen memory_guard_tier
    (safe -> balanced -> aggressive).

No production change is needed — "memory limit exceeded" and
"memory_guard_tier" are both already in _MEMORY_CEILING_PATTERNS, so the
guard covers this route today.  What was missing was the pin.

It is worth pinning because its unguarded failure mode is the worst of the
three and is not the overflow misroute the other two suffer.  "process memory
limit exceeded" contains "limit exceeded", a _USAGE_LIMIT_PATTERNS entry
checked ahead of the overflow branch, and the body carries none of the
transient signals that disambiguate a usage limit toward rate_limit.  So
without the guard a local Metal watermark abort classifies as billing:
non-retryable, surfaced to the user as an account problem, turn stranded.
That is a third distinct wrong answer, which is the argument for a memory
guard sitting ahead of these branches rather than beside them.

The tier separator is transcribed as "→" but could not be confirmed against
the raw log, so both spellings are pinned and asserted to classify
identically.  None of the matching tokens involve the separator.
…ract

_classify_by_status had no 507 branch, so a local-inference model-LOAD
rejection fell to the generic "other 5xx" rule: server_error,
retryable=True, should_fallback=False.

507 is a distinct guard from the prefill path this PR already covers, and
it is reachable from ordinary traffic: oMLX maps both ModelTooLargeError
and InsufficientMemoryError to 507, and /v1/chat/completions,
/v1/completions and /v1/messages all reach them through
get_engine_for_model -> get_engine.  A captured body reads "Model 'X'
(33.95GB) does not fit under the dynamic memory ceiling (25.22GB) ...
raise memory_guard_tier", i.e. it carries both "memory ceiling" and
"memory_guard_tier" but never reaches a memory check.

The bit that differs from the contract is should_fallback.  Retrying is
not wrong — the capture's own retry succeeded 2.7s later once the aborted
request released its memory — but a model that does not fit under the
host's ceiling does not begin to fit within the retry budget, so when the
wall does not clear the turn dies on the wedged host with no failover to a
roomier provider.  That is precisely the case _memory_ceiling_result was
written for.

Routed through the existing _is_memory_ceiling predicate rather than a
bespoke branch, so the annotation cannot drift from the 400 / 5xx / code /
status-less routes.  Gated on the predicate, so a 507 in its literal
Insufficient Storage sense keeps the generic 5xx treatment.

Evidence: captured against this branch's head by tkaufmann on two Apple
Silicon hosts running oMLX 0.5.7.

Regression tests (tests/agent/test_error_classifier.py):
  - test_507_omlx_model_load_ceiling_is_overloaded_with_fallback
    (red before this change: server_error / should_fallback=False)
  - test_507_without_memory_wording_is_generic_server_error (control)

Refs NousResearch#52261
_MEMORY_CEILING_ERROR_CODES held prefill_memory_exceeded but not its
sibling.  oMLX's prefill-memory body builder selects between the two by
exception type:

    code = ("prefill_memory_aborted"
            if isinstance(exc, PrefillMemoryAbortedError)
            else "prefill_memory_exceeded")

so "exceeded" is the prompt turned away at admission and "aborted" is the
prompt admitted and then killed mid-prefill.  Same guard, same memory
wall, same recovery — retry with backoff, no compression, fall back once
the retries are spent.

Scope, stated precisely: this is NOT a live misclassification today.  The
aborted body still says "memory guard" and "available memory", so
_MEMORY_CEILING_PATTERNS catches it.  What fails is the CODE layer, which
exists for the case where the message has been reworded by the engine or
flattened by a proxy — and that case is not hypothetical here, since the
0.5.6 -> 0.5.7 rewording of the prefill sentence already silently broke a
pattern written against the release it was written for.  With the memory
wording gone the two codes diverge: exceeded -> overloaded/retryable,
aborted -> format_error/not-retryable on the 400 path and the retryable
unknown bucket on the status-less one.

The code pairing is read from the engine's body builder, not captured:
neither reporter's logs contain prefill_memory_aborted.

Regression test (tests/agent/test_error_classifier.py):
  - test_prefill_memory_aborted_code_is_overloaded[400] and [None]
    (red before this change: format_error / unknown), including the
    assertion that no memory pattern is present in the fixture message,
    so the test cannot pass through the message layer.

Refs NousResearch#52261
…ract

CODE READING, NOT A CAPTURE — stated up front because it is the one part
of this series with no observed body behind it.

oMLX raises ModelLoadingError carrying "Model 'X' load aborted: process
memory limit exceeded" and server.py maps it to 409.  _classify_by_status
has no 409 branch, so it reaches the generic "other 4xx" bucket and is
reported as format_error, retryable=False: a transient memory abort
described as a malformed request.  should_fallback is already set there,
so the turn is not stranded — this is the mildest of the three gaps — but
the request is never retried against the primary either, even though the
abort clears as soon as the host reclaims memory.

No reporter has produced a 409 body; this is read from the engine's source
alone.  It is included because it is the same guard and the same wall as
the shapes that ARE captured, and because gating it on the shared
_is_memory_ceiling predicate makes it inert otherwise: a 409 in its
ordinary sense — a model swap already in flight, a duplicate request id —
keeps the existing 4xx treatment, which the control test pins.

Regression tests (tests/agent/test_error_classifier.py):
  - test_409_memory_abort_is_overloaded_not_format_error
    (red before this change: format_error / retryable=False)
  - test_409_without_memory_wording_is_still_format_error (control)

Refs NousResearch#52261
Comments only — no behaviour change, verified by the diff carrying no
non-comment added line.  Three claims in this PR's own notes were either
overstated or stale.

1. The tier-ladder separator is settled as U+2192, and this branch already
   used it.  The test fixture's comment still said the spelling "has not
   been established from the raw log"; it has been since — twelve
   occurrences in agent.log and twelve in errors.log, every one the bytes
   e2 86 92, zero occurrences of the ASCII "safe -> balanced" in either,
   and the engine holds the ladder in one module-level constant with no
   ASCII sibling.  No fixture bytes change.  The ASCII variant is
   re-labelled CONSTRUCTED — the same status the file already gives
   _SYNTHETIC_PREFILL_400_NO_OVERFLOW_TOKEN — and kept as a lower bound
   that no matching token lives in the separator, not as a second real
   spelling.

2. "memory_guard_tier" and "dynamic ceiling" are not load-bearing and the
   comment should not imply they are.  oMLX builds the remediation tail
   from describe_ceiling_binding(), whose branches vary both: the noun
   before "ceiling" is ceiling / static ceiling / dynamic ceiling /
   metal_cap ceiling depending on which cap binds (ties slash-join, so
   "static/metal_cap ceiling" is reachable), and memory_guard_tier is
   named in only some branches — the dynamic+custom branch points at
   custom_ceiling_bytes and the metal_cap branch at iogpu.wired_limit_mb
   instead.  Nothing is left unguarded, because "memory limit exceeded" is
   in the message prefix and survives every branch; the note now says so,
   so a later pruning pass cannot mistake the widening entries for the
   guarantee.

3. omlx_prefill_memory_exceeded has no known producer.  omlx_code mirrors
   code rather than being a prefixed variant, which the captured bodies in
   the tests show directly, so that set member has never matched anything
   observed.  Retained rather than dropped — it cannot false-positive, and
   a namespacing proxy is the obvious way it would start appearing — but
   now documented as defensive rather than left reading as a shape someone
   had seen.

Refs NousResearch#52261
The process-memory abort has now been captured twice with different
remediation tails: 2 Aug, which this PR already pinned, and 11 Aug on a
different host.  Only the earlier half was on the record.

Not a host difference.  engine_core.py on the first host carries an mtime
one day after its own 2 Aug capture, and the tail now comes from
describe_ceiling_binding(), which emits neither "loosen" nor "free system
memory" in any branch — so that host cannot reproduce its own earlier
wording today.  "loosen memory_guard_tier ... free system memory" became
"Close other apps to free RAM ... raise memory_guard_tier", and the bound
cap moved from a bare "ceiling" to a "dynamic ceiling" with the static cap
reported alongside.

This is the same drift the memory-accounting entries in
_MEMORY_CEILING_PATTERNS exist for, caught a second time on a different
sentence.  The first time it happened nothing failed: the pattern written
against "Prefill would require ~13.87 GB peak" silently stopped covering
the release it was written for.  A second captured wording of the same
shape is the cheapest guard against a third copy-edit doing it again.

Honest about what this is: a characterisation test, not a regression test
for any commit in this series — the reworded body already classifies
correctly on this branch, so there is no red-before against it.  It IS red
against clean origin/main (c896c09), where it classifies as billing /
retryable=False, which is the worst of the unguarded failure modes this
file documents.  A companion test asserts the two captures genuinely
differ, so a later tidy-up cannot collapse them into one string and
quietly delete the drift they record.

Refs NousResearch#52261
@briandevans
briandevans force-pushed the fix/agent-memory-ceiling-overloaded-52261 branch from f17f3af to 2976b5f Compare August 21, 2026 21:46
tkaufmann added a commit to tkaufmann/hermes-agent that referenced this pull request Sep 8, 2026
…rloaded

oMLX/MLX prefill memory-guard rejections name an allocation peak in BYTES but
close with "Reduce context length", so _CONTEXT_OVERFLOW_PATTERNS claims them
and the turn enters the compress-and-shrink loop. Compression cannot lower a
prefill peak — the prompt is usually far below the window — so it burns the
compression budget, re-hits the wedged server on every attempt and ends in
"Cannot compress further" plus a destructive session reset.

Classify them as `overloaded` instead: retry with backoff, no compression, no
session reset (mirrors 503/529 recovery).

The guard runs before the overflow check AND before the usage-limit
disambiguation. The second ordering matters more than it looks: "memory limit
exceeded" contains "limit exceeded", so a status-less rejection — a proxy that
flattened the body — is currently classified as `billing` and rotates a
healthy credential.

Sites covered:
  - _OVERFLOW_AS_5XX_RULES, which _400_TAIL_RULES extends → 400, 500, 502,
    503, 529
  - _MESSAGE_HEAD_RULES for the status-less path (ahead of usage-limit)
  - _ERROR_CODE_VERDICTS for the structured oMLX codes
  - _classify_400, because _by_status runs before _by_error_code, so a body
    whose wording a proxy stripped would otherwise fall through to
    format_error

Every pattern names memory/allocation in bytes, never a token or window count,
so the list stays disjoint from _CONTEXT_OVERFLOW_PATTERNS. Both oMLX wordings
are kept: 0.5.6 says "Prefill would require ~13.87 GB peak", 0.5.7 reworded it
to "predicted peak would require/exceed" and both are in the field. A test
pins that a genuine window overflow still compresses.

Refs NousResearch#52261. Supersedes NousResearch#52289, which predates the classifier rewrite and can
no longer be merged.
@tkaufmann

Copy link
Copy Markdown
Contributor

Heads-up: this no longer merges. The branch is over 5000 commits behind main, and error_classifier.py was rewritten in the meantime (~2000 lines down to 952, classification now driven by declarative rule tables), so the hunks have no anchor left.

The bug is still there on 2237be3559 — I re-ran the captured bodies and posted the numbers in #52261. I've opened #105463 as a fresh port against the current structure: same behaviour, 33 lines instead of 209, six tests. Happy to close this in favour of that one, or to fold anything from here into it if you'd rather keep this PR as the vehicle.

@alt-glitch alt-glitch added P1 High — major feature broken, no workaround and removed P2 Medium — degraded but workaround exists area/memory Memory subsystem: store, providers, sync, background reviews labels Sep 8, 2026
kshitijk4poor pushed a commit that referenced this pull request Sep 9, 2026
…rloaded

oMLX/MLX prefill memory-guard rejections name an allocation peak in BYTES but
close with "Reduce context length", so _CONTEXT_OVERFLOW_PATTERNS claims them
and the turn enters the compress-and-shrink loop. Compression cannot lower a
prefill peak — the prompt is usually far below the window — so it burns the
compression budget, re-hits the wedged server on every attempt and ends in
"Cannot compress further" plus a destructive session reset.

Classify them as `overloaded` instead: retry with backoff, no compression, no
session reset (mirrors 503/529 recovery).

The guard runs before the overflow check AND before the usage-limit
disambiguation. The second ordering matters more than it looks: "memory limit
exceeded" contains "limit exceeded", so a status-less rejection — a proxy that
flattened the body — is currently classified as `billing` and rotates a
healthy credential.

Sites covered:
  - _OVERFLOW_AS_5XX_RULES, which _400_TAIL_RULES extends → 400, 500, 502,
    503, 529
  - _MESSAGE_HEAD_RULES for the status-less path (ahead of usage-limit)
  - _ERROR_CODE_VERDICTS for the structured oMLX codes
  - _classify_400, because _by_status runs before _by_error_code, so a body
    whose wording a proxy stripped would otherwise fall through to
    format_error

Every pattern names memory/allocation in bytes, never a token or window count,
so the list stays disjoint from _CONTEXT_OVERFLOW_PATTERNS. Both oMLX wordings
are kept: 0.5.6 says "Prefill would require ~13.87 GB peak", 0.5.7 reworded it
to "predicted peak would require/exceed" and both are in the field. A test
pins that a genuine window overflow still compresses.

Refs #52261. Supersedes #52289, which predates the classifier rewrite and can
no longer be merged.
@kshitijk4poor

Copy link
Copy Markdown
Contributor

Superseded: the memory-ceiling → overloaded classification landed on main via #106307 (e669800e2a, a fresh port by @tkaufmann against the rule-table classifier that replaced the code this branch patches). You are credited there as the first report of the mechanism (#52261). Closing this one as landed — thank you for the original diagnosis.

mrkillbob added a commit to mrkillbob/hermes-agent that referenced this pull request Sep 10, 2026
* fix: keep Bot Mode pet selection rings inside the gallery

* fix(prompt): keep memory guidance within available tools

* fix(compression): keep lean tails lean after auxiliary feasibility

Lowering the session trigger must not replace the window-relative lean
selection budget with threshold times target_ratio. Invalidate the lean
cache through the existing property while preserving explicit legacy and
external-engine fallback behavior.

Narrow adaptation of the aux-sync diagnosis and invariants in #93576,
without adding a required recalibration method to context engines.
Related: #95681, #93576

Co-authored-by: Turgut Kural <58116817+TurgutKural@users.noreply.github.com>

* feat(desktop): let users order Group Chat rooms

Add Move up/down controls for actual rooms without changing bot or folder
ordering. Preserve default pin/activity ordering until an explicit move,
retain hidden room slots, and persist Desktop-local order through room
updates, mirror merges, and hydration. No membership or routing writes.

Adapted narrowly from the group ordering idea in archived
NousResearch/Hermes-Bot-Mode#105 by @onuraycicek; rename already exists.

Co-authored-by: Onur Aycicek <onur.m.aycicek@gmail.com>

* fix: hide inactive grouping options from delegation schema

* fix(desktop): show the focused bot's working think pose

Port Adolanium's focused-turn pose from Hermes-Bot-Mode#101 and
hermes-agent#88134 to the current typed Bot Mode implementation.
Match the busy signal's connection-qualified focused owner rather than
the gateway socket, retain worker activity, and ease transitions in
elapsed time on the existing shared face clock.

Includes owner-isolation and animated-pose invariants, both proven red
on origin/main, and native Electron before/after verification against
a real temporary Hermes backend with held loopback inference.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>

* fix(desktop): load property card guidance only on demand

* fix(cli): keep monitor repaints safe during prompt handoff

* fmt(js): `npm run fix` on merge (#106039)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fmt(js): `npm run fix` on merge (#106065)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* test(desktop): retire model catalog fixture jobs at teardown

* fix(desktop): let subagent header collapse roster and details

* feat: add GPT Image 2.5 generation and editing to OpenAI provider

* feat: add FAL GPT Image 2.5 generation and editing selections

* chore(deps): bump httpx2 in the uv group across 1 directory

Bumps the uv group with 1 update in the / directory: [httpx2](https://github.com/pydantic/httpx2).


Updates `httpx2` from 2.7.0 to 2.12.0
- [Release notes](https://github.com/pydantic/httpx2/releases)
- [Changelog](https://github.com/pydantic/httpx2/blob/main/src/httpx2/CHANGELOG.md)
- [Commits](https://github.com/pydantic/httpx2/compare/v2.7.0...v2.12.0)

---
updated-dependencies:
- dependency-name: httpx2
  dependency-version: 2.12.0
  dependency-type: direct:production
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>

* feat(desktop): default glass to 29% tint on the sidebar

* docs(desktop): document sidebar glass defaults

* fix(desktop): keep background reports behind bounded disclosures

* docs(desktop): explain background report disclosures

* fix: trim computer use tool schema guidance

* fix(desktop): pin Windows update handoff cwd

* test(desktop): exercise production cwd setup in Windows self-test

* fix(mcp-oauth): keep refresh_token when a refresh response omits it (#62333)

HermesProviderMixin._handle_refresh_response overrides the SDK's handler (to
accept any 2xx and keep token bodies out of logs) but dropped the SDK's RFC 6749
section 6 carry-forward. An authorization server that does not rotate refresh
tokens (TinyFish, Google, Zoho, Asana, Futu) answers the refresh grant without a
refresh_token; we then stored the response verbatim, erasing the only refresh
token we had, so the next expiry had nothing to refresh with and forced a
browser re-auth roughly one TTL after every login.

Carry the prior refresh_token (and scope, per section 5.1) forward on the
OAuthToken before _store_tokens, so both the live provider and the on-disk
token file keep it. A rotating AS still wins: only None fields are filled.

Tests: two invariants on the real HermesMCPOAuthProvider + HermesTokenStorage
(omitted -> preserved in memory and on disk; provided -> rotated). The
carry-forward test is red on main.

* fix(desktop): allow project creation while browsing all profiles

* test(desktop): cover project creation scope and reconnect routing

* fix(observability): attribute ACP and batch execution surfaces

Fleet telemetry showed "unknown" as the single largest execution_surface
bucket. Two construction paths were mis-attributed, both silently:

1. ACP editor sessions (VS Code / Zed / JetBrains) declare platform="acp",
   but "acp" was absent from EXECUTION_SURFACES, so the contract's
   closed-schema fallback folded every editor session into "other" --
   the bucket meant for genuinely unclassifiable traffic.

2. batch_runner built agents from _AGENT_PASSTHROUGH, which omitted
   "platform" entirely, so every batch task run reported "unknown"
   despite "batch" already being a first-class surface.

Neither is a reporting bug in the exporter: both are declaration gaps at
the construction site. "unknown" must mean "this run genuinely could not
be attributed", not "a construction site forgot to say who it was".

Changes:
- add "acp" to EXECUTION_SURFACES and map it to the "interactive"
  entrypoint alongside cli/desktop/tui
- add "acp" to the v2 wire schema enum (kept in sync by an existing test)
- pass platform through batch_runner: added to _AGENT_PASSTHROUGH, set
  self.platform = "batch" on the runner, and defaulted at the worker call
  site so callers that build a config without it stay attributable

Wire compatibility: the ingest service validates the envelope only and
stores metric bodies verbatim, so packages carrying the new value are
accepted by the already-deployed server. No coordinated deploy needed.

Tests: 12 new behavioural tests. Verified red before the fix (4 failed),
green after. Three fix-mutants confirmed killed:
  M1 revert acp from EXECUTION_SURFACES  -> 3 failed
  M2 revert acp entrypoint mapping only  -> 1 failed
  M3 revert batch passthrough            -> 1 failed
No source-text assertions; every test is a contract between the surfaces
the schema accepts and the surface each path declares. A guard test pins
that a genuinely undeclared run still reports "unknown", so attribution
cannot be "fixed" by inventing a default that hides real gaps.

* fix(desktop): keep visible renderer animations running on blur

* fix(desktop): keep pets and starmap animated without focus

* fix(desktop): keep macOS HUD visible when inactive (#102573)

* fmt(js): `npm run fix` on merge (#106231)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* feat(desktop): show messages below the thread viewport

* test(desktop): cover scrolling message counts and pane isolation

* fmt(js): `npm run fix` on merge (#106237)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(agent): a surface switch must not re-prefill the whole request (#104414)

`_stored_prompt_matches_runtime` treated `Platform` as a runtime-identity field, so
answering a live session from another surface — desktop -> TUI, or a resume after a
dashboard restart whose chat is a PTY TUI child — declared the stored prompt stale and
rebuilt it. The system prompt is the first thing in the request, so changing any byte of
it moves the first divergent byte to the head of a 220K-token request and the entire
conversation behind it re-prefills: a session that was hitting 240000/240287 came back
at 1536/219861.

The guard was not wrong about correctness — a desktop-built prompt on a terminal session
advertises inline widgets and a MEDIA: channel the TUI does not have — but the surface is
advisory metadata about the renderer, not a cache domain. Model/provider and cwd drift
change what the prompt should SAY; the surface changes only one paragraph.

Reuse the stored bytes across a surface switch and correct the paragraph where it costs
nothing to cache: `_stage_surface_switch_note` stages a one-shot note carrying the CURRENT
surface's guidance on the same per-turn user-message channel the gateway's must-deliver
notes use. It lands after the cached prefix and is stamped into the byte-stable
`api_content` sidecar, so later turns replay it instead of re-prefilling, and the prompt
converges at the next compaction — a boundary that already breaks the cache.

The saved tool_names prefix is not pinned across a switch: the tool registry is
process-global, so `_merge_preserving_prefix` would carry a saved-but-unloaded tool
forward (under `coding_context: focus` desktop gets a desktop_ui toolset the TUI cannot
run). On the same surface the tools freeze is untouched.

* fix(agent): skip the tools freeze once on a surface switch, not for the session

The first cut gated the saved tool_names pin on "the surface drifted", which stays true
for as long as the stored prompt names the old surface — i.e. until the next compaction.
On the gateway path, where a fresh AIAgent is built per turn, that left the tools freeze
off for every remaining turn, so a check_fn that flaps could reorder `tools[]` and break
the tool cache block on its own.

Gate it on the turn that actually ANNOUNCES the switch instead, and persist the fresh,
toolset-correct names there. The next turn's row already holds this surface's tools, so
the pin resumes immediately: skipped once, not disabled.

* fix(agent): the surface note must not outlive its own truth

Two holes the first cut left open, both created by the note itself.

Switching BACK to the surface the prompt was built for (desktop -> tui -> desktop) left the
`Platform:` trailer agreeing with the runtime, so nothing was staged — while the newest note
in the transcript still told the model it was on tui. And a rebuild for an unrelated reason
(a model switch) refreshed the prompt but not that note, leaving the same contradiction from
the other side.

Compare the runtime surface against what the model was last TOLD — the newest surface note
when one exists, else the prompt's own trailer — and stage from the rebuild path too. The
full surface guidance rides along only when the prompt itself is out of date; when the prompt
already describes the current surface the note just retires the stale one and points at it.

* fix(agent): hold the tools pin through a surface switch, name what it carried

The announcing turn used to skip the tools freeze and re-persist the array the new
surface had just built. That is the one mutation this fix cannot afford: tools[] is
serialized ahead of the system prompt, so rebuilding it moves the request at token 0
and re-prefills everything behind it — the exact cost #104414 measured (1% cache hit
on a 220K session), spent on the very turn the fix exists to make cheap. On a
`desktop -> tui` switch with a configured toolset selection (`_gui_surface_toolsets`
gives desktop `desktop_ui`, the TUI nothing), skipping the pin dropped ~a dozen tools
and bought back the whole miss.

The pin now holds. `_merge_preserving_prefix` still appends what the new surface
brought, so a `tui -> desktop` switch pays a break no freeze could have avoided, and
the tools it carries FORWARD are named at the end of the surface note instead of being
silently advertised: a `focus_pane` a terminal turn can only answer with
`tool_error("desktop only")` now reads as unavailable rather than as live capability.
The toolset converges at the next real rebuild boundary, where the break is already
paid.

Credit to @StanleyStetson, who caught that the tool array is evaluated ahead of the
system prompt and that the bypass reintroduced the miss this PR is about.

* fix(agent): retire stale surface notes on bot-chat refresh, isolate platform from decoys

When Bot Chat capability refresh rebuilds the system prompt for the current
surface, call _stage_surface_switch_note() so any earlier switch note sitting in
the transcript is retired instead of overriding the rebuilt prompt.

Also isolate _stored_prompt_platform() to parse only the authoritative identity
portion before '# Hermes runtime environment' (with legacy fallback for prompts
without the boundary), preventing embedder prose or HERMES_ENVIRONMENT_HINT decoys
from shadowing the real platform and falsely suppressing surface switch announcements.

Credit to @ehz0ah, who identified both correctness gaps on current main and
verified the regression scenarios.

* refactor(agent): surface-switch note lives in its own sibling; skip it where no sidecar exists

Move the six surface-switch helpers out of the conversation_loop facade
into agent/surface_switch.py (AGENTS.md: new behaviour goes in a topical
sibling), and fold the review findings on #104494:

- MoA and codex_app_server turns never stamp the api_content sidecar, so
  the staged note could not be read back from the transcript and was
  re-sent on every turn after a switch. Those modes now skip the note
  (stored prompt still reused).
- The announced surface was parsed with split(".") — a plugin platform
  with a dot in its name would never compare equal and re-stage the note
  every turn. The note now closes the name with a fixed terminator.
- One identity-line parser (identity_line_value) shared by
  _stored_prompt_matches_runtime and the switch detector instead of two
  copies of the runtime-boundary/rpartition logic; tool names via the
  existing tools.mcp_tool_agent._def_name; the transcript scan is bounded
  to the last 200 rows (it ran every turn over the whole history).
- consume_surface_switch_note reduced to a plain pop; developer-guide
  prompt-assembly.md updated (Platform is no longer an identity field);
  17 new tests trimmed to 10 (same-shape pin/retire variants folded).

Restoring Platform as an identity field still turns 5 tests red.

* simplify(agent): surface switch — reuse flatten_message_text / agent_tool_names / one runtime-boundary split

- _transcript_row_texts re-implemented agent.message_content.flatten_message_text
  and the api_content sidecar rule; the note can only land on a user row,
  so the transcript scan now skips assistant/tool rows (the bulk of the bytes).
- Three sites computed "names of agent.tools"; tools.mcp_tool_agent gains
  agent_tool_names() used by the switch note and conversation_loop, which
  also stops importing the private _def_name across modules. The name list
  is only captured when a switch was announced.
- split_runtime_boundary() is the single owner of the runtime-block
  rpartition/END check for both identity_line_value and
  _stored_prompt_matches_runtime.
- platform_surface_hint was a public alias of _platform_hint; the function is
  now platform_hint (its docstring pointed at the pre-move module).
- consume_gateway_turn_context_notes and consume_surface_switch_note share
  _pop_turn_note so the two one-shot channels have identical semantics.
- platform check hoisted above the transcript scan.

* fix(agent): row-addressed api_content backfill for pre-persisted user turns (#102194)

The api_content sidecar ('persist what you send') preserves prompt-cache
stability across turn boundaries by persisting the exact API-bound bytes
(including memory-manager prefetch, plugin injections, and API-only notes)
and substituting them on replay.

When a user turn was already materialized in the database before the
sidecar could be composed (in-place preflight compaction or a close/early
flush racing the prologue on the CLI path), the turn-start crash persist
marker-skips that message. Previously, the backfill was gated strictly on
in-place compaction (_preflight_compressed and _last_compaction_in_place),
so racing CLI flushes left api_content = NULL in SQLite and broke prompt
caching on subsequent turns (#102194).

Positional approaches (such as #102239 and #102286) using LIMIT 1 on the
newest active user row are unsafe: repeated common inputs ('ok', 'yes',
'continue') cause the backfill to match and overwrite the PREVIOUS turn's
row with the new turn's sidecar, corrupting history and breaking cache parity.

Resolve all landing blockers and review feedback from #102411:

1. Bounded state owner (Sahilvishnaliya):
   Add SessionDB.set_message_api_content(session_id, row_id, content, api_content)
   to SessionMessagesMixin in hermes_state_messages.py instead of growing
   hermes_state.py. Update set_latest_user_api_content docstring with durable
   warning on the positional hazard.

2. API-only turns & durable content selection (ehz0ah):
   When a pre-flushed clean input has an API-only difference (e.g. voice
   prefix or model-switch note):
   - Retain the differing API-facing bytes as api_content even when no
     new memory or plugin context was injected.
   - Derive the durable content guard using _override_replaces_content so
     the SQL 'content IS ?' guard matches the clean override text stored
     in the DB row rather than the restored wire text.

3. Turn prologue gating (_row_id) & fail-closed store duck-typing (ehz0ah):
   In agent/turn_context.py::_stamp_api_content_sidecar: check _row_id on
   the live user dict (stamped by _insert_message_rows and synced by
   sync_flushed_message_markers). If valid (positive int, not bool), address
   by exact ID. Do NOT fall back to positional matching when a row ID is
   present: if an external or custom wrapper lacks set_message_api_content,
   fail closed and skip rather than corrupting a neighbouring row. If absent
   but in-place compacted, fall back to positional update. On normal turns,
   skip the backfill entirely (single atomic INSERT).

4. Real lifecycle test coverage (salch-cred, ehz0ah):
   Comprehensive tests in tests/agent/test_api_content_row_addressed_backfill.py
   covering store guards, surrogate scrubbing, gate non-arming, older identical
   row protection, real close-flush row_id synchronization, API-only clean
   override preservation with exact wire replay, and duck-typed store fail-closed
   verification when set_message_api_content is absent.

Fixes #102194.
Closes #102411.

* fix(agent): read the sidecar row id under the session persist lock

_stamp_api_content_sidecar read _row_id without holding
_session_persist_lock. A close/early flush holds that lock while it
commits the row and only afterwards writes _row_id back onto the live
dict; a stamp that ran in between saw no id and skipped the backfill,
the flush finished with api_content = NULL and marked the message
persisted, and the turn-start persist skipped it — the row kept the
wrong bytes with no writer left to fix it.

Run the _row_id read and the DB backfill under the (re-entrant) lock,
re-checking _row_id after acquiring it. Race reported by @ehz0ah on

Co-authored-by: sal <141555468+salch-cred@users.noreply.github.com>
#102411; same fix shape as @salch-cred's follow-up on #103721.

* refactor(agent): one durable-row rule for the flush and the sidecar stamp; trim tests

The turn-start stamp had grown its own copy of the "what does the current
user row hold" rule (persist override = clean transcript, live content =
wire bytes = sidecar when they differ) that _db_flush_row already
implements. Two copies drift; extract durable_user_row_content() in
session_persistence and call it from both.

Also: reuse _persist_lock() instead of a third open-coded lock/nullcontext
ladder; drop the hasattr guard on set_latest_user_api_content (it predates
this fix and exists on every SessionDB); cut the comment to the WHY;
trim the new test file from 18 cases to the 7 invariants (real close
flush E2E, repeated-"ok" positional protection, API-only pre-flushed
turn, normal path writes nothing, compaction keeps positional, store
guards). Still 3 red / 4 green when agent/turn_context.py is swapped
for main's copy.

* simplify(agent): sidecar backfill — drop the hasattr guard and the duplicated row-id predicate; tests 7→6

_session_db is always a SessionDB (agent_init / delegate_tool), so the
"fail closed on a store wrapper" hasattr was defense around code that
cannot fail; the store's own guard binds the value into SQL, so the
prologue only needs the sibling idiom isinstance(_row_id, int) that
session_persistence and transcript_repair already use. The positional
hazard is explained once, on set_latest_user_api_content. The in-place
compaction test duplicated test_api_content_sidecar's
test_inplace_compaction_backfills_sidecar_into_db verbatim (its row_id
parameter was never varied); dropped, as was the positional-helper tail
of test_older_identical_row_is_untouched already covered there.

* chore: map contributor email for @0xalydev (#103581 salvage)

* fix(agent): inherit parent's full tool surface on review fork for cache parity (#103579)

Ensure unrouted background_review forks inherit the parent's full advertised
tools[] surface. Without this, skip_memory=True caused memory-provider tools
(e.g. fact_store/fact_feedback) and dynamically injected plugin/late MCP tools
to be omitted from the fork's tools array, breaking byte-exact prefix-cache parity
and incurring full cold-read costs on providers where tools are part of the cache key.
Inheriting the full parent tools array preserves complete prefix cache parity
while execution dispatch remains strictly bounded by the thread tool whitelist.

* fix(background-review): freeze review fork tool snapshot generation against compaction refresh

Freezes review_agent._tool_snapshot_generation to _FROZEN_TOOL_SNAPSHOT_GENERATION
(2_147_483_647) when inheriting the parent tool surface for same-model cache parity.

When in-place compaction boundaries trigger refresh_agent_mcp_tools(content_aware=True),
the staleness guard in _publish_tool_snapshot refuses the rebuild (snapshot_generation < published_gen),
preventing agent.tools from being reconstructed from the raw registry and preserving
inherited memory-provider and late tools across compaction boundaries (#103579).

Adds unit regression test verifying tool preservation across content_aware refresh.

Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>

* fix(background-review): inherit and freeze empty parent tools list for cache parity (#103579)

Copy and freeze review_agent._tool_snapshot_generation even when parent.tools is an empty list ([]). Previously, the truthiness check (\
ot parent_tools\) caused an empty parent tool surface to be skipped, allowing newly available late MCP or plugin tools to be retained on the review fork and leaving its snapshot generation unfrozen. This broke the byte-parity contract when no tools were active on the parent.

Returning early only when parent_tools is not an instance of list or tuple guarantees that an empty tool snapshot is faithfully inherited and frozen. Adds dedicated regression test test_unrouted_review_fork_inherits_empty_tool_surface.

* refactor(background-review): collapse the tool-surface copy to the agent_init shape; 2 tests

agent.tools is always a list (agent_init assigns it from
get_tool_definitions) and every entry is a well-formed function schema,
so the isinstance ladder over parent/entry/function/name guarded shapes
that cannot reach this helper. Use the same two lines agent_init uses;
`or []` keeps the empty-surface contract from the previous commit.
Docstring cut to the WHY (the between-turn refresh note described the
other guard). Tests trimmed to the two invariants: inherited tools
survive the compaction-boundary refresh (deep-copy isolation folded in),
and an empty parent surface is copied and frozen. Literal sentinel
asserts replaced with the constant.

* test(background-review): assert the behaviour, not the sentinel

The compaction-refresh and empty-surface tests pinned
_tool_snapshot_generation == _FROZEN_TOOL_SNAPSHOT_GENERATION next to
the behavioural assertion (refresh returns set(), tools unchanged). The
behaviour is the contract; the constant is the mechanism.

* chore(contributors): map sgarrand@gmail.com -> sgarrand

Scott Garrand (@sgarrand) identified the NixOS /bin/true systemd-probe bug
first in #102587; the salvage of #105436 credits him with a Co-authored-by
trailer, so the release script needs his mapping.

* fix(process-registry): use portable /bin/sh probe for systemd-run scope availability (#105365)

* test(process-registry): mark systemd probe tests linux_only

* test(process-registry): exercise the portable probe payload

Execute the selected no-op rather than freeze its spelling, while rejecting
/bin/true to model the NixOS failure. Mark the regression Linux-only and
retain the current user-bus environment handling.

Consolidates the earlier NixOS scope-probe report and fix in #102587 with
the PATH-independent payload from #105436. The fallback resolver is not
needed when /bin/sh is used directly.

Co-authored-by: Scott Garrand <sgarrand@gmail.com>

* fix(gateway): guard display config reads against present-but-null values

A profile config with a bare `display:` key (present-but-null) made
`user_config.get("display", {})` return None — the {} default only
applies when the key is missing — so the chained
`.get("memory_notifications")` in _wire_turn_agent_callbacks raised
AttributeError on every real gateway turn (Discord / cron). Oneshot
turns bypass this wiring, which masked the crash during smoke tests.

Use the same `or {}` guard the other gateway display readers
(display_config.py, runtime_footer.py) already apply, and fall back to
the documented default "on".

Fixes #105674

* test(gateway): fold the null/missing display cases into one parametrized test

* chore: map philmossman's contributor email (#105704 salvage)

* fix(cron): don't stamp the next occurrence on an off-tick manual run

claim_job_for_fire() derives the occurrence identity from next_run_at
before the same function advances it. On a scheduler tick next_run_at is
the occurrence being run, which is correct; on an off-tick manual run it
is the NEXT occurrence, so the execution is stamped with the identity of
a slot that has not happened yet. _job_is_due() then finds a completed
execution carrying that identity and skips the real slot, returning
before the last_dispatch write — no error, no log line, no dispatch
record.

The manual flag already guards this and both _job_is_due() and
claim_job_for_fire() honour it; the agent-facing run-now path never
declared itself. Add a keyword-only manual= parameter and pass it from
_claim_for_manual_run(). Deliberately not force=True: force also calls
_activate_job_record(), which would resume a paused or disabled job, and
the run-now tool depends on continuing to refuse those.

The local flag is renamed to manual_fire so the new parameter is not
shadowed inside the apply closure, which would raise UnboundLocalError.

Three existing tests in tests/tools/ pinned the old call signature via
assert_called_once_with; they now pin manual=True, so dropping the flag
again fails loudly rather than silently reintroducing the skip.

Restores the intent stated in #104790 — the column records the scheduled
instant an execution was claimed for, and an off-tick manual run was
claimed for none.

Fixes #105690

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cron): the dashboard "Trigger" run-now no longer stamps the next occurrence either

Second entry of the same bug class: POST /api/cron/jobs/{id}/trigger →
_fire_cron_job_for_profile → CronScheduler.fire_due → claim_fire built its claim
without `manual`, so an off-tick run from the web UI stamped the future slot exactly
like the tools path #105704 fixes. fire_due/claim_fire gain `manual` (forwarded only
when set, mirroring `force`, so third-party providers keep working) and the dashboard
trigger passes it when the provider's signature accepts it. Webhook and misfire
catch-up fires run the slot that is due and keep the stamp.

Also drops the base-green tick-stamp test (the same contract is pinned by
tests/cron/test_scheduled_occurrence.py) and documents `manual` vs `force`.

* chore: map tkaufmann's contributor email (#105463 salvage)

* fix(agent): classify local-inference memory-ceiling rejections as overloaded

oMLX/MLX prefill memory-guard rejections name an allocation peak in BYTES but
close with "Reduce context length", so _CONTEXT_OVERFLOW_PATTERNS claims them
and the turn enters the compress-and-shrink loop. Compression cannot lower a
prefill peak — the prompt is usually far below the window — so it burns the
compression budget, re-hits the wedged server on every attempt and ends in
"Cannot compress further" plus a destructive session reset.

Classify them as `overloaded` instead: retry with backoff, no compression, no
session reset (mirrors 503/529 recovery).

The guard runs before the overflow check AND before the usage-limit
disambiguation. The second ordering matters more than it looks: "memory limit
exceeded" contains "limit exceeded", so a status-less rejection — a proxy that
flattened the body — is currently classified as `billing` and rotates a
healthy credential.

Sites covered:
  - _OVERFLOW_AS_5XX_RULES, which _400_TAIL_RULES extends → 400, 500, 502,
    503, 529
  - _MESSAGE_HEAD_RULES for the status-less path (ahead of usage-limit)
  - _ERROR_CODE_VERDICTS for the structured oMLX codes
  - _classify_400, because _by_status runs before _by_error_code, so a body
    whose wording a proxy stripped would otherwise fall through to
    format_error

Every pattern names memory/allocation in bytes, never a token or window count,
so the list stays disjoint from _CONTEXT_OVERFLOW_PATTERNS. Both oMLX wordings
are kept: 0.5.6 says "Prefill would require ~13.87 GB peak", 0.5.7 reworded it
to "predicted peak would require/exceed" and both are in the field. A test
pins that a genuine window overflow still compresses.

Refs #52261. Supersedes #52289, which predates the classifier rewrite and can
no longer be merged.

* test(agent): fold the five memory-ceiling cases into one parametrized invariant

Same coverage (5 red on main, 1 guard green), one contract test instead of five.

* fix(agent): isolate periodic scheduler callbacks from blocking siblings (#102574)

* refactor(agent): one _requeue for the three heappush sites; timing test asserts ordering, not a 0.3 s bound

Fold the identical heappush(...) into PeriodicScheduler._requeue; notify() instead of
notify_all() now that the scheduler thread is the only condition waiter; drop the
PR-history paragraph from the module docstring (the commit carries it).

Tests: the blocked-sibling test asserted `sibling_ran.wait(0.30)` — a wall-clock bound
under the repo's ≥2 s flake floor; it now asserts the sibling fired while the blocker
still held its worker. The worker-start-failure fake keys on this scheduler's own
_run_callback rather than the global thread-name prefix so a leaked handle on _DEFAULT
cannot consume the single injected failure. The base-green no-overlap test is dropped
(it does not prove the fix).

* fix(agent): scope background review memory access to its trigger (#105921)

The review fork's tool whitelist granted the whole memory toolset
whenever the profile had memory enabled, regardless of which nudge
fired, so a skill-nudge fork held remove/replace on MEMORY.md it was
never asked to use; combined with the memory tool's near-limit
'consolidate now' hint, an unattended fork deleted standing rules with
no user in the loop.

- Pass review_memory from spawn_background_review_thread through
  _run_review_in_thread/_run_review_fork into _review_tool_whitelist;
  a skill-only review no longer gets the memory tool at all.
- Fail-closed operation gate in memory_tool: a background-review fork
  may add, never replace/remove (single or in a batch) — consolidation
  decisions reach a human via the review summary instead.
- Keep the deny/prompt wording in sync with the whitelist so a
  memory-less review doesn't advertise memory.

* fix(review): distinguish explicit /refine from unattended reviews and surface staged consolidations

Review follow-up on #105944 (#105921):

- explicit /refine forks now run under the refine_review write origin
  (explicit flows from the CLI/gateway handlers through
  _spawn_background_review_now and spawn_background_review_thread down
  to build_cache_parity_fork), so a user-requested review keeps the
  full memory operation set; only automatic reviews stay behind the
  unattended delete gate.
- the unattended delete gate now stages the denied replace/remove (or
  whole batch) into the pending store instead of dropping it: the
  fork's own review summary is never published, so a plain denial lost
  the consolidation request with no surfacing path. The staged proposal
  carries a proposal_staged marker that summarize surfaces as an action
  line, and a staging failure still fails closed to a plain denial.
- regression tests: explicit-path origin pass-through, refine_review
  keeping replace working, near-limit denial end to end (add rejected
  by budget -> replace staged -> proposal surfaces, store unchanged).

* fix(review): keep /refine under the background_review origin; attendedness is its own flag

The salvaged commit forked an explicit /refine under a new "refine_review" origin so
the memory delete gate would not treat it as unattended. But is_background_review()
is the key for every other review guard — skill_manager_guards (curator-owned-only,
read-before-write), skill_manager_tool (archive instead of rmtree), skill_ledger
actor, write_approval staging, the [auto] tag — so a /refine fork silently escaped
all of them.

Carry attendedness separately: the fork keeps origin "background_review" and sets
_review_attended; turn_context binds it beside the origin ContextVar; the memory
gate keys on the new is_unattended_review(). Also run the gate AFTER
_validate_single_op / the operations list check, as memory_tool's own docstring
requires, so an invalid replace is rejected now rather than staged and failed at
approve time.

* fix(sessions): serialize fresh FTS bootstrap

* fix(sessions): restore trigram after deferred bootstrap

* refactor(state): drop the table-exists probe made dead by the early return above it

* chore: map portavales's contributor email (#105694 salvage)

* fix(loop): re-anchor current_turn_user_idx after the alternation repair merges rows

prepare_iteration() runs repair_message_sequence_with_cursor() before each API
call; the repair merges adjacent user rows in place (after a compaction, the
role=user summary sits next to the protected first user message). The loop's
current_turn_user_idx was recorded at turn start, so after a merge it points
past the current user row: the per-turn context injection (prefetch/plugin
context) silently misses it, and hosts that settle the transcript by this index
(hermes-webui) write the current user turn to the FRONT of the context —
rewriting the prompt's leading messages every turn (0% prefix-cache hits at
200K+ tokens, ~100 s re-prefill per turn) and duplicating the user's question.

The in-loop compression restart path already re-anchors; do the same after a
repair that changed the list: reanchor_current_turn_user_idx (last user row
carrying this turn's text), return the index through the IterationPrep verdict
so the loop state picks it up, and mirror it into agent._persist_user_message_idx,
which hosts read when the result carries no index. The new phase parameters
default to None so direct callers keep their signature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gp366ijf39n4UUtJhZuMh

* feat(loop): export {turn_id, current_turn_user_idx} on every result envelope

Hosts that settle their own transcript by index (hermes-webui) cannot prove which
row of result["messages"] is the current user turn once this loop rewrote history
(alternation repair, compaction, post-turn micro-compaction): the instance-side
_persist_user_message_idx predates those rewrites, and a text match relabels an
identical historical prompt and claims its old answer. Only the producer can
assert the coordinate against the exact list it returns.

run_conversation now wraps the turn (_run_conversation_turn) and stamps the pair
through export_current_turn_boundary on every envelope that leaves the loop
(success, partial/error, interrupt, retry-exhausted, tool-limit, preflight
timeout, codex runtime), computed on the final messages after finalize_turn and
micro-compaction. The pair is exported only when the addressed row is this turn's
user message verbatim (reanchor's last-match rule); a rewritten row exports
nothing so hosts fail closed. The final index is mirrored into
_persist_user_message_idx for the persist override.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gp366ijf39n4UUtJhZuMh

* test(agent): prove the re-anchor through prepare_iteration; reuse the compaction _reanchor

The salvaged regression test exercised only repair_message_sequence and
reanchor_current_turn_user_idx — pre-existing helpers — so reverting the fix left it
green. It now drives prepare_iteration on a real AIAgent with adjacent user rows and
asserts the returned index addresses this turn's row and mirrors into
_persist_user_message_idx (red without the re-anchor: IndexError).

Both re-anchor sites (repair and compression restart) call
turn_context_compaction._reanchor instead of inlining "reanchor + mirror", so they
cannot drift. The export tests fold into one parametrized invariant plus the
run_conversation envelope test; the WHAT-restating comment shrinks to the WHY.

* fix(gateway): preserve shared MCP visibility across profile reloads

* fix(gateway): register shared MCP tools per profile

* fix(mcp): a profile only adopts a shared connection whose credentials match its own config

_same_server_route compared config_fingerprint alone, which by design excludes
env/headers/auth (so the schema cache survives a token rotation). Profile B with the
same URL but different headers/env therefore adopted profile A's live connection and
called tools as A. _connection_identity = route fingerprint + env + headers + auth mode,
used by both the adopt and the stale-removal checks.

Also collapses the three writers of _server_tool_scopes to two: the adoption loop
re-implemented in mcp_tool_discovery._select_new_servers is dropped —
register_connected_into_current_scope (which runs first in register_mcp_servers) is
the single adopter, and _register_candidates records scope for freshly registered tools.

* fix(gateway): ledger-bracket the queued-lane final send

When a follow-up is queued behind a turn, the first response is delivered by
the queued lane before the follow-up runs. That lane called adapter.send bare
and discarded the result: no delivery-ledger obligation was recorded, so a
final refused there (flood control, a transport that had just died) was lost
for good. Neither the boot sweep nor the runtime redelivery could see it and
the follow-up ran as if the answer had landed.

Route the queued lane's text send through the adapter's _send_final_text, the
same ledger-bracketed method the normal lane uses: the obligation is recorded
before the send under the id the normal lane would compute for the same turn,
the result finalizes it, and the reply is marked notify-worthy like every
other final. The reconcile-by-edit path is unchanged; adapters without the
base contract and sends without a session key keep the plain send.

* fix(gateway): key the queued-lane obligation on the raw inbound id

The queued lane's ledger bracket used the reply anchor as the obligation's
message reference. The anchor is None wherever replies are not used (Telegram
forum topics, Slack reaction handoffs), so two turns in one topic answering
with the same text shared an obligation id and the second record overwrote
the first's outstanding row; the id also differed from the normal lane's.

The lane now runs the adapter's record / send-with-retry / finalize sequence
itself, keyed on turn_ctx.inbound_message_id like the normal lane, while the
anchor stays the reply target. Tests cover the forum-topic identity, the row
being `attempting` while the send is in flight, and the call site passing
both ids.

* fix(gateway): carry the raw inbound id through a chained queued turn

Round-2 review. _run_agent_deliver_first_response passed the turn's inbound id to
the queued lane, but the recursive _run_agent for a chained follow-up did not, so
the chained turn ran with inbound_message_id=None. In a Telegram forum topic (no
reply anchor) two chained follow-ups answering with the same text would then key
their queued-final obligations on None and collide. The recursive call now carries
pending_event's raw message id, with a test on the chained path.

* test(gateway): let the queued-native-image fake accept the persist kwargs

Now that a queued follow-up carries its raw inbound id, the gateway passes
persist_user_platform_id to run_conversation for that turn (run_turn_runner.py
only adds it when inbound_message_id is set). The real AIAgent accepts it; the
test's fake did not. Accept **kwargs, matching the real signature.

* fix(gateway): ledger a queued chain's terminal reply under its own inbound id

The outer final send is bracketed by the adapter against the event that OPENED
the chain, so a terminal reply was recorded under the first message's id. When
two turns of one chain answered with the same text, the terminal reply computed
the earlier row's obligation id, replaced its outstanding row and marked it
delivered, so a first reply the platform had refused was never redelivered.

MessageEvent gains a documented ledger_message_id that the obligation hash
prefers, the queued follow-up returns the terminal turn's inbound id (innermost
wins on nested chains), and the handler sets it before the adapter brackets the
send. Reply routing is untouched: the anchor still comes from the event.

Three of the four new tests fail without this change; the whole tests/gateway
suite shows the same failure set before and after.

* refactor(gateway): one send_final_ledgered bracket for the normal and queued final lanes

The queued lane re-implemented _send_final_text's record / send-with-retry / finalize
sequence by duck-typing four private adapter members from gateway/. Lift the bracket
into a public BasePlatformAdapter.send_final_ledgered(event, session_key, text,
metadata, *, reply_to, is_ephemeral_response); _send_final_text keeps only the
ephemeral-delete tail on top, the queued lane calls it with the inbound-id ledger event.
ledger_message_id is a real dataclass field now, read directly.

Tests trimmed from 18 to 8 invariants (bracket recorded+delivered; flood refusal stays a
failed ledger row; forum-topic identity; plain adapter keeps plain send; normal-lane
parity; chained/terminal/deeper-chain inbound ids). Still 6 red with main's
run_notifications.py swapped in.

* fix(state): guard close-time checkpoint for replaced/deleted-generation handles (#105670)

- close() and _try_wal_checkpoint() now skip when _db_replaced or _db_wal_generation_lost
  (previously only _db_corrupt was checked) — prevents checkpointing stale-generation frames
  into the main DB, which is the shutdown-time damage reported in #105670
- _halt_if_db_generation_changed() calls _disable_close_time_checkpoint() alongside the flag
  set (3.12+: disables SQLite internal last-connection checkpoint too)
- Regression tests: halted handle must not run explicit PRAGMA checkpoint on close(),
  halt must call setconfig(NO_CKPT_ON_CLOSE), periodic _try_wal_checkpoint() must skip

* refactor(state): drop the ruff-format reflow from the checkpoint guard, keep the ~20 semantic lines

The cherry-picked commit re-wrapped hermes_state.py wholesale (+527/-131 for a
fix of about twenty lines). Restore main's layout and re-apply only the fix:
disable the close-time checkpoint on both generation-loss halts, gate the
periodic checkpoint on the sticky generation flags, and name the quarantine
reason at close.

* test(state): mark the checkpoint-guard tests linux_only instead of a bare skipif

AGENTS.md: a bare skipif(sys.platform != linux) is never listed by
scripts/ci/list_os_marked_tests.py, so the tests would run nowhere on the
OS lanes. The marker is the contract.

* fix(state): the deferred FTS rebuild retry is quarantined by the same rule as the checkpoints

retry_deferred_fts_recovery gated only on _db_corrupt ("mirrors _try_wal_checkpoint /
close") — after this PR it no longer mirrored them: on a replaced/lost-generation handle
the periodic housekeeping tick still ran FTS DDL/DML + commit, the same split-brain write
class as the #105670 checkpoint. One SessionDB._quarantine_reason() now decides for the
periodic checkpoint, close(), and the FTS retry, with the halt path's precedence
(replaced before generation loss) and the operator wording in one place.

Test: the periodic-checkpoint case folds into the close test (same setup), which now
also proves the FTS retry returns False without touching the file; the mutation with
main's schema sibling swapped in returns True (a rebuild ran).

* chore: map albert748's contributor email (#104444 salvage)

* fix(agent): persist /steer as a standalone user message

`apply_pending_steer_to_tool_results` used to smear the steer text onto
the last `role:tool` message's content. That tool row had already been
flushed to the session store and carries `_DB_PERSISTED_MARKER`; the
append-only persistence never rewrites it, so the replayable transcript
diverged from the live request bytes at the injection point — resumed
sessions (surface switch / process restart / background-review close)
missed the provider prompt cache (75-85% hit) and the user's mid-run
instructions were never part of the durable history.

The steer is now emitted as a standalone `role:user` message (marker
text preserved):
- role alternation stays legal: assistant(tool_calls) -> tool -> user is
  the documented 'user jumped in mid-run' pattern that
  `repair_message_sequence` deliberately keeps;
- the appended dict carries no `_DB_PERSISTED_MARKER`, so the next
  `_flush_messages_to_session_db` writes it to the session store —
  transcript bytes and replayed history finally agree, and the steer
  becomes searchable/retrievable like any other user message;
- the no-tool-result fallback (interrupt) still requeues the steer, which
  the caller then delivers as a normal next-turn user message.

Tests: TestSteerInjection updated for the new shape plus a persistability
assertion (no marker => flushable); tool-batch-segmentation malformed
scenario updated. steer + segmentation suites: 67 passed, 1 skipped.

* test: keep the steer suite on the canonical patch targets, not PLUGIN-COMPAT pointers

The cherry-picked commit carried an unrelated hunk repointing three patch()
targets back to run_agent.* — those are PLUGIN-COMPAT re-exports, off limits
in-tree (scripts/check_compat_pointers.py; removed 2026-09-14). Keep main's
model_tools.* / agent.process_bootstrap.OpenAI targets.

* fix(agent): the pre-API-call /steer drain also stops smearing the persisted tool row

Second site of the same bug class #104444 fixes in apply_pending_steer_to_tool_results:
_inject_steer_into_newest_tool_result (the drain that runs when a /steer lands during an
API call) mutated the newest role:tool row in place. That row was already flushed
append-only, so the replayed history diverged from the live request bytes at the
injection point and broke the prompt cache exactly like the post-batch path.

Deliver it the same way: a standalone user row inserted right after the newest tool
result (not yet persisted, so the next flush writes it to the transcript). Restash when
there is no tool row yet, unchanged. Stale comments claiming steer lands "in the newest
tool result" and agent/AGENTS.md's alternation rule now describe the real shape.

* fix(agent): a persisted /steer row survives the next prompt's alternation repair; typed for history

Both steer sites now build the row through one helper, prompt_builder.steer_user_row:
a role:user row with display_kind="steer" and no leading blank lines. The alternation
repair (_merge_consecutive_users) skips a steer-typed prev row, so a run that ended
right after a steered batch (Ctrl-C, interrupt) does not get the next real prompt
merged INTO the already-persisted steer row — which would have rewritten it in place
and re-broken live≠replay parity, the exact class this PR fixes.

TUI/desktop history projects the steer row as the user's own words instead of the
model-facing marker wrapper; 'steer' joins the display_kind union. The compression
anchor scan keeps its tool-row branch for transcripts persisted before this change and
its docstring says so.

* fix(tui): resolve default profile session names

* fix(tui): preserve names for custom profile homes

* fix(tui): fail closed on unavailable profile targets matching custom root basenames

* test(tui): add coverage for custom default roots, real session db stamping, and sibling isolation

* fix(tui): a real named profile "hermes" is not swallowed by the legacy-basename alias

"hermes" matches the profile-id regex, so canonicalising it unconditionally at the RPC
boundary misrouted a genuine <root>/profiles/hermes to the default profile. Alias only
when no such named profile exists; ".hermes" can never be a real id and stays aliased.

Also: profile_name_for_home collapses its duplicated pre/post-resolve block into one
loop over (path, resolved path) and drops the bare "parent named profiles" fallback
that bypassed named_profile_home's root check; _profile_home goes back to main's
single resolve() comparison; the symlink-loop assertion in the target-unavailable
test is no longer wrapped in a try/except that could silently skip it.

* fix(profiles): a stored <root>/profiles/<name> home names its profile even when the root carries no markers

CI: tests/test_tui_gateway_server.py::test_ensure_session_db_row_stamps_profile_name used a bare tmp
root; profile_name_for_home fell through to None and the row was stamped default. The stored home
is authoritative (its owner resolved it), so the profiles/<name> shape is sufficient.

* fix(cli): honor --resume in one-shot mode (#105892)

The -z exit path accepted --resume/-c in the parser but never forwarded
args.resume: every resumed one-shot turn silently started a fresh session,
so each wire request carried only [system, current user] and the model
lost all prior context (reported against Ollama/custom OpenAI-compatible
endpoints, but provider-independent).

Normalize session args (latest/title/--continue/--in + cwd restore) via
the chat path's _resolve_chat_session_args before the oneshot exit path
takes over, then load the resumed transcript in _run_agent through the
same contract the interactive CLI uses (compression-chain redirect,
safe-resume guard, session_meta filtering) and continue the existing
session id instead of creating a new one. An explicit --resume of an
unknown session now fails loudly instead of starting fresh.

* fix(cli): keep the resolved session id when a resumed oneshot session is empty

Review finding on #105957: `_load_resume_target` returned None for a
resolved session with no stored messages, so `hermes -z "hello" -c <title>
--create-if-missing` recorded the turn under a freshly minted session id and
the just-created titled session stayed empty. Preserve `resolved` unconditionally — the interactive /resume path keeps the selected id for an
empty session too; only the history replay is empty. Regression tests pin the
durable id for both a plain empty session and an empty compression-chain head.

* fix(cli): restore stored session runtime and reopen ended rows on oneshot resume

Review fixes (#105957):

- A resumed one-shot ignored the session's stored model/provider runtime:
  _resolve_model_and_provider()/resolve_runtime_provider() ran before
  _load_resume_target(), which only loaded the session id + transcript, so an
  ambient config (e.g. openrouter/ambient-model) served the resumed transcript
  instead of the stored route (custom:stored/stored-model). The stored runtime
  is now applied before runtime resolution, with the same contract as the
  interactive _restore_session_model(): stored model/provider/base_url/api_mode
  replace the ambient choice unless --model was passed explicitly, and a
  changed provider drops the ambient api_key so resolution re-fetches
  credentials for the restored endpoint.

- Passing the resumed id to AIAgent did not reopen the already-ended session
  row: end_session() only writes rows whose ended_at is null and the
  existing-row upsert never clears the end fields, so the resumed turn was
  recorded under a session that stayed closed and its new lifecycle boundary
  was lost. _load_resume_target() now reopens the row (best effort), same as
  the interactive resume does before continuing.

* refactor(cli): one stored_session_route for interactive and one-shot resume

_apply_stored_session_runtime was a line-for-line copy of the first half of
_restore_session_model (stored-model guard, session_gateway_runtime, bare-custom heal,
model/provider-changed check). Extract that pure decision into
cli_model_switch_mixin.stored_session_route and have both resume paths call it; the
one-shot keeps only the _ModelChoice mapping and the drop-ambient-key rule.

main.py stops re-normalising `resume` — _resolve_chat_session_args already did.
Tests trimmed from 20 to 13: near-duplicate unit tests of the private helpers go, the
end-to-end _run_agent contracts (stored runtime + reopen; explicit --model wins) and the
empty-session-keeps-id case stay.

* fix(cli): keep the no-stored-model early return ahead of the route read

CI: tests/cli/test_cli_resume_command.py builds bare HermesCLI objects without .model; the
refactor read self.model before the stored-model check the contributor's code made first.

* test(agent): the worker-start-failure test intercepts the callback worker again

`kwargs.get("target") is sched._run_callback` is always False (a bound method is a fresh object
per access), so the fake never returned Boom and the _dispatch failure branch went untested;
the test passed on the normal worker. Compare with == and assert the interception happened
(mutation: retiring the handle on start failure now fails the test).

The thread-count assertions sampled while per-fire workers were still live; quiesce every
handle with cancel(wait=) before sampling so the count is deterministic (AGENTS.md: timing tests
must not assume a quiet runner).

Follow-up to #106308.

* fix(loop): the turn-boundary export skips preflight-timeout envelopes and stops re-anchoring the persist index

Follow-up to #106312. _preflight_timeout_result carries the prior history without this turn's
user row (#7100); with a repeated prompt ("continue") the verbatim scan resolved to the
historical copy and exported it as this turn's proven boundary — the exact relabeling the export
exists to prevent. Nothing is exported for that envelope now.

The trailing `agent._persist_user_message_idx = idx` ran after finalize_turn had already flushed
the transcript, so it never influenced a persist and the next turn reset it: dead state, removed.

* fix(gateway): the ephemeral delete goes to the adapter that sent the final

Follow-up to #106316. send_final_ledgered resolved the live adapter internally and
_send_final_text resolved it a second time for _schedule_ephemeral_delete; a reconnect between
the two sent the delete to a transport that never owned result.message_id (the ownership rule
_final_delivery_adapter documents). The bracket now returns (result, adapter).

The queued lane carried the ledger identity through MessageEvent.message_id while the PR added
ledger_message_id for exactly that; it now uses the typed field, and the ledger read is
getattr-tolerant of duck-typed events (a missing attribute was swallowed as "ledger skipped").

* fix(state): VACUUM is gated by the same quarantine rule as the checkpoints

Follow-up to #106315. vacuum() ran PRAGMA wal_checkpoint + VACUUM + wal_checkpoint(TRUNCATE) on
self._conn with no quarantine check; the only guard it inherited (optimize_fts raising
DeletedWalGenerationError) was swallowed by its own try/except and the rewrite proceeded on the
split-brain handle. Mutation on main: vacuum() returned 2 and rewrote pages after the write stop.

* fix(agent): a /steer row is human input for every user-turn predicate

Follow-up to #106317. Typing the steer row (display_kind="steer") for the renderer and the
alternation-repair guard collided with the convention that any display_kind on a user row means
scaffolding: is_user_originated_turn / _is_actionable_user_turn / split_user_originated_turn
returned False for it (tail anchoring, auto-focus, dispatcher views, resume counts) while
_is_real_user_message returned True (anchor restoration) — the two predicate families disagreed
on the same row, and list_recent_user_messages (/undo, /rewind) skipped it in SQL. A steer
carries full user authority; the steer kind is now whitelisted in all four.

Also: the pre-API drain's requeue tail reuses _requeue_pending_steer instead of a copy; the TUI
history projection compares against STEER_DISPLAY_KIND; the steer() docstring describes the row.

* fix(state): guard vacuum() and optimize_fts() against quarantined SessionDB handles

A quarantined/replaced/split-generation handle must never run a full-file rewrite or an FTS5
'optimize': both read damaged or foreign pages and commit the result back, turning contained,
diagnosable corruption into an amplified one. Same guard _execute_write applies to every write.

Salvaged from #102092 onto current main: the _try_wal_checkpoint half landed via #106315's
_quarantine_reason(), so only the two rewrite sites remain.

* fix(auth): preserve independent same-account OAuth grants

* fix(auth): carry pool-row lineage into the provider-block heal

With account-identity matching gone, the providers.<id> block consolidation
only fired on shared token material. A historical fork (same copied pool-row
id, profile rotated, both pairs diverged) then healed the pool row into root
but left root's providers.openai-codex block on the spent pair; root's next
load_pool() re-seeds its device_code row FROM that block and undid the heal.

_HealPass now records that a profile pool row matched root by copied id or
shared tokens and passes that verdict to _heal_forked_provider_block, which
accepts it as lineage proof. No account-identity guessing is restored; an
independent same-account grant (no id/token match) is still left alone.

Follow-up to simpolism's #106177.

* fix: address 6 P1 findings from merged PR review threads

- repair_controller.py: build the retirement completion command through
  _governed_command_prefix() (adds -P) instead of a bare `python -m`
  invocation, matching the sibling identity command; an untrusted
  exact-head PR worktree could otherwise get prepended to sys.path.
- cli.py doctor probe: report worker_completion_policy failed whenever
  HERMES_SAFE_MODE is active, since dispatched workers inherit it and
  PluginManager skips all plugin discovery under it regardless of what
  the profile config declares.
- worker_contract.py: default a manifest's missing `name` to its
  directory name before comparing, matching parse_manifest_file()'s
  actual runtime behavior, so a name-less override plugin.yaml is no
  longer treated as absent.
- worker_contract.py: fail closed when a profile's plugins.enabled/
  disabled list still contains an unexpanded ${VAR} reference, since
  expanding it against doctor's own environment doesn't guarantee the
  dispatched worker's .env resolves it the same way.
- methods_profiles.py: catch SystemExit (not just Exception) around
  _write_raw_config_values(), which raises SystemExit for managed-scope
  keys; the shared TUI/Desktop/dashboard RPC backend must not exit on a
  refused profiles.configure write.
- config.py _preserve_env_ref_templates(): match a modified, reordered,
  unnamed list entry to the loaded item it most structurally resembles
  instead of the raw item at its new output position, so a sibling's
  unchanged ${VAR} template isn't dropped into plaintext on save.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(tui-gateway): /review shows its reviewer in the Desktop subagent stack

`slash.exec` runs on the RPC pool, outside any turn, so `/review` dispatched the
reviewer with no HERMES_UI_SESSION_ID and no steer authority bound. delegate_task
registered the child with `owner_session_id=None`, `subagent.list` (owner-scoped)
returned nothing for the parent session, and the Desktop status stack's 5s
snapshot poll reconciled the live `subagent.start` row away — the user saw only
"Review started. Results will return here." with no subagent card.

Bind the same session identity a turn binds (`_set_session_context(...,
ui_session_id=sid)` + `_current_runtime_session_record`) around `start_review`,
and clear it after. The reviewer now registers under the parent sid with the
request transport as authority, so `subagent.list`, steer, stop and the Desktop
roster all see it.

Live repro (tui_gateway stdio, real OpenRouter reviewer):
  before: registry owner_session_id=None, owner_transport=NoneType;
          subagent.list -> {"subagents": []}
  after:  owner_session_id=<parent sid>, owner_transport=StdioTransport;
          subagent.list -> [{"goal": "Review recent work", "status": "running", ...}]

* fix: address remaining P1 findings (dispatch generation, completion guard, context compressor)

- feedback_retirement.py: extend governed retirement to pr_local_ci
  receipts too -- audit-pr rejects a non-OPEN PR identity outright, so a
  card whose PR closes mid-audit had no other path to clear its pending
  ledger row and stayed stuck forever.
- controller.py: reintroduce _dispatch_generation() (lost track of
  ClaimLease.reopened during an earlier merge -- version > 1 is the same
  signal) and wrap all 3 create_or_get_task() call sites, so a reclaimed
  dispatch gets a fresh Kanban identity instead of returning the
  pre-closure done card.
- controller.py _is_staged_auto_dispatch_task(): also require no real
  "blocked" lifecycle event, so a repair worker's legitimate kanban_block
  call (same status/idempotency-prefix/evidence shape as a never-run
  staged card) isn't misclassified as a failed staging promotion and
  bounced back to ready.
- kanban_completion_policy.py: load the bundled github_pr_feedback
  package by file path instead of a bare import, so the control-plane
  completion-guard fallback works even in a dispatched worker profile
  that doesn't itself enable the plugin (previously ModuleNotFoundError,
  uncaught).
- context_compressor.py: scan the actual handoff-expanded window
  (scan.tail_start) for the current-task assignment summary instead of
  the initial compression window, so a newer assignment carried by a
  later-consumed handoff isn't shadowed by a stale in-window match (or
  missed entirely).
- test_run_agent.py: fix a NameError from an earlier merge -- an
  undefined mock_record_failure reference where the test actually needs
  hermes_cli.kanban_db.block_task patched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(desktop): SSH remote backend stops following the host's sticky active_profile

A Desktop-owned `hermes serve --isolated --ssh-session-token-file ...` child
is spawned with an explicit `--profile <name>` when the connection names a
remote profile, and with no flag for the remote root home. Without the flag,
`_apply_profile_override` read the remote host's sticky `active_profile`
file and re-homed the backend into whatever profile the user last selected
on that machine's CLI. Settings then read one config.yaml while the remote
gateway wrote another, so model picks and toggles "didn't stick".

Treat the SSH token flag as a fixed-identity marker, the same way
supervisor-launched gateway children are (#74872): a Desktop backend's
profile is chosen by the client, never by the host.

Live repro (before/after, temp HERMES_HOME with active_profile=foo):
  serve --isolated --ssh-session-token-file ...   hermes_home=<root>/profiles/foo -> <root>
  same + --profile foo                             hermes_home=<root>/profiles/foo (unchanged)
  serve (no token file, user CLI)                  hermes_home=<root>/profiles/foo (unchanged)

* fix: verification evidence ledger is inert while verify_on_stop is off

The ledger in verification_evidence.db exists only to feed the verify-on-stop
guard, but the recorder kept running on every foreground terminal command and
every file edit after #53552 turned the guard off by default. Users who never
opted in still accumulated a multi-MB database (7 MB / 4.6k rows on one install).

Every ledger entry point (record_terminal_result, record_verify_run,
mark_workspace_edited, verification_status) now checks…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/compression Context compression and continuation sessions backend/local Local shell execution comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P1 High — major feature broken, no workaround sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Provider memory/resource 400s (oMLX/MLX, local inference) misclassified as context_overflow → destructive compress/reset loop

9 participants