_fallback_summary_for_window logged "Feasibility skip" and the compress()
comment said "feasibility skip", but the flag is now also set when the
summary model is benched. Say "Pre-LLM summary skip" so the log matches
both causes. Wording only; no test asserts the old text.
record_completed_compaction carried two overlapping comments and logged
"via pre-LLM feasibility skip" although the path now also covers the
benched summary model; the anti-thrash comment still said it drops
"counters" when only the ineffective count is reset. Split the long
streak/skip tuple assignment in the stall-retry path and drop the
`is True` hedge (the flag is always a bool on the built-in compressor).
The mixin already owns _set_fallback_probe_at and the model-config key;
the matching load lived inline in bind_session_state, which forced
context_compressor.py to import the key. Move it to
_load_fallback_probe_at() and make the key private to the mixin module.
Rotating compaction (compression.in_place: false) calls on_session_start
with boundary_reason="compression"; bind_session_state zeroes
_fallback_probe_at and reloads it from the fresh child row, which has no
model-config key. The fallback streak was carried but the deadline was
not, so the benched summary model was re-benched with a new window on
every rotation and never probed again. Capture the deadline before the
rebind and persist it onto the child row alongside the streak.
Code-health ratchet: agent/context_compressor.py grew past its cap on main.
Move _feasibility_skip, _fallback_streak_skip and _set_fallback_probe_at
(plus their constants) byte-identical into a PreLlmSkipMixin in the new
agent/context_compressor_prellm.py sibling, matching the existing
SummaryDispatchMixin layout, to keep context_compressor.py under its size
cap. No behaviour change; no test patches the moved names.
The bench probe clock was in-memory monotonic time, so every rebuilt
compressor (the gateway rebuilds the AIAgent on cache eviction) came up
with a durable fallback streak but no clock, armed a fresh 300s window and
benched again. If rebuilds arrive faster than the window, the summary model
is never re-probed and the session stays on deterministic summaries until
a manual /compress.
Store the deadline as wall-clock time on the session row (model_config
key, the same durable path the proactive-prune runway uses) and load it in
bind_session_state, mirroring the anti-thrash recovery deadline (#100185).
A deadline more than one window in the future (clock jumped back) is
re-armed from now.
Two fallback summaries in a row tripped the anti-thrash breaker even when
each fallback compaction was effective, so automatic compaction was blocked
and the session rode into the provider's hard context limit. Only
ineffective compactions now trip the breaker; a fallback streak >= 2 instead
benches the summary model (deterministic drop, one probe per recovery
window), which still stops the paid fallback loop from #63008. force=True
(/compress) always calls the model.
Salvaged from #129431.
Co-authored-by: Pascal Bérard <pberard@nvidia.com>
After the aux summary fell back to the main model, the main provider, base_url and
api_key were written into the call and a stall-fallback pin overrode only the fields
it set. A fallback_chain entry without base_url/api_key kept the main host (the
fallback was never reached), or sent the main provider's API key to the fallback's
host. The pin now replaces the whole route; the main route applies only when no pin.
`_fallback_to_main_for_compression` clears `summary_model` to mean "use the
main model". But `call_llm(task="compression", ...)` resolves an unspecified
route from `auxiliary.compression`, i.e. the model that just failed. The
fallback therefore re-calls the failing auxiliary model; since
`_summary_model_fallen_back` is already set, no second fallback is allowed,
`_generate_summary` returns None, and with
`compression.abort_on_summary_failure` the whole compression is skipped.
Observed with an auxiliary model whose weekly quota was exhausted: two
identical 429s, `failure_class=summary_generation_aborted`,
`fallback_used=false`, and a session that kept growing uncompressed.
Add `_apply_summary_route()` as the single place that pins the summary wire
route, and use it from both compression entry points (`_generate_summary`
and `_micro_summarize_one`, which had the same defect).
Three existing tests asserted the buggy contract (`assert "model" not in
kwargs`, commented "no model kwarg -> call_llm uses main"); they now assert
the main model is named explicitly.
Salvaged from #88247 (slimmed: docstring, 2 invariant tests; dropped the redundant
_last_summary_auth_failure reset — a main-model success already clears terminal flags).
Fixes#123362.
Gate r1 (2c Low): the comment in _latest_user_task_snapshot claimed the
split-turn gate measures the same authored text, which no longer holds.
Keep only the reason the reply quote is dropped first. No net line growth.
The oversized-active-turn split (#80449) refuses to fire when the
authored turn-opening request exceeds 1,400 characters. A long /goal
continuation prompt therefore pins its entire autonomous run in the
protected tail: every automatic pass finds an empty compress window,
records no_progress, arms the 300 s structural backoff, and the session
grows into the provider context limit. /compress hits the same empty
window.
The token soft ceiling already bounds the request row, and the split
request is restated verbatim after the handoff by
_reappend_inflight_user_task, so the character cap only blocks
progress. Drop it from the split gate. The historical task snapshot
keeps its own cap and reply-quote handling.
(cherry picked from commit 0081611ff1f088ae0cca86d916ee802e74fe465a)
Gate r3 finding 2c: when _summary_placement merged the handoff into
tail[0] and that row is an assistant(tool_calls), the dedup's
move-carrier-to-window-start step popped it away from its tool results
(broken pairing) and still left an assistant row first.
Snapshot the window before the dedup filter; if the filter removed a row
and the carrier is a tool-call row (_template_visible_role is None),
restore the snapshot and skip the dedup this cycle. The output then
matches the pre-stack layout for that shape; the standalone-carrier
move-and-flip path is unchanged.
Gate finding 2ab (BN05 final): when the deduped in-flight original was the
first non-system row (cron / first-compression shape), removing it left the
window opening system -> assistant(tool_calls) -> tool -> carrier. The
carrier-flip guard judges template-visible roles, which skip tool_calls
rows, so it saw nothing wrong; native Gemini maps that leading row to a
model functionCall turn, which its API rejects as the first turn.
After the dedup, if the first non-system row is not a user row, move the
carrier there (the existing flip then makes it role=user). The kept test now
asserts the raw role of compressed[1], since the template-visible check
cannot see a leading tool-call row.
Gate finding (reuse + quality, Low, convergent): the replay-uid block
hand-rolled agent.message_metadata.message_uid_or_none (non-empty str or
None) and repeated the "message_uid" literal that MESSAGE_UID names. Use
the helper and the constant, hoisting the function's existing lazy
message_metadata import (record_absorbed_message) so it covers both.
Behaviour-equivalent: same uid acceptance, same row filter.
Gate finding (quality, Low): `in {None, "system", "assistant"}` lists every
value _last_template_visible_role yields in a compacted window except
"user", so the set spelled out a complement. `!= "user"` states the actual
condition (no visible user turn precedes the carrier). No behaviour change.
When the in-flight request survived in the protected head, the replay now
removes that original (one active row per message_uid). But the summary
role was chosen against a head that still ended on that user row, so the
compacted window then opened system -> assistant(tool_calls) -> tool ->
assistant summary: no user turn before the carrier, which strict
alternation templates (#58753 class) reject.
When the removal leaves the assistant carrier with no preceding visible
user, switch to the _force_user_leading layout: carrier role=user with the
request merged after _SUMMARY_END_MARKER (the existing merge path records
the uid as absorbed, so it stays single).
Co-authored-by: Yuan Li <dskwelmcy@163.com>
#132141 landed the shared outcome helper; render the write_file refusal through it
so the stub reads like the other marked stubs ("[write_file] a.md FAILED: ...") and
fold the test into the TestSummarizeToolResultOutcome tables (one failed row, one
success row) instead of a separate parametrized function.
_sum_write_file always rendered "[write_file] wrote to PATH (N lines)" from
the call's args, so a write the stale-write guard refused
({"error": "Refusing to overwrite ...", "stale_write_blocked": true}) - or
any other write_file error - compressed into the same stub as a successful
write, and the post-compaction agent reported a change that never landed.
Read the result's error first and say FAILED with a bounded preview.
Salvaged from #131247 (write_file half only; the patch-tool half is covered
by the templated-stub outcome fix in #132141).
Gate finding BN03.r1.2c (Medium): status == "blocked" alone was treated
as a refusal, but successful kanban results use it as a task state --
kanban_block (and kanban_create with initial_status="blocked") return
{"ok": true, "status": "blocked", ...}. Those summarized as
"[kanban_block] BLOCKED, not run", so after pruning or fallback
compaction the model would read its own successful block/create as
refused and might repeat it (e.g. a duplicate card).
Every real refusal envelope carries a non-empty error string
(terminal _error_json / _blocked_json, tool_error); the kanban success
payloads have none. Require it. Adds the kanban_block success payload
as a negative row in the existing parametrize.
Phase-1 pruning and the fallback summary describe each old tool result with
a per-tool one-liner. For a call an approval or write guard refused, those
read as done: a denied `rm -rf build` became "[terminal] ran `rm -rf build`
-> exit -1, 1 lines output", a denied protected write became
"[write_file] wrote to AGENTS.md (2 lines)", and the "The user has NOT
consented ... Do NOT retry" instruction was dropped. Denials are 300+ chars,
over the 200-char prune floor, so this hits every long session.
Summarize a refused result (BLOCKED error, status blocked/pending_approval)
as "BLOCKED, not run" and keep the no-consent note, for every tool. Like the
clarify summary (#128346), it stays below _PRUNE_MIN_CHARS so later passes
keep it. Other results are summarized exactly as before.
(cherry picked from commit fd506bbccd3df8783dce0c0a1895f1aaf1585cb4)
The #131412 escape gate summed _estimate_msg_budget_tokens(m) with its
default charge_stale_thinking=True, so every older assistant row was
charged for reasoning the wire never carries, and the native-Anthropic
budget was ignored. On reasoning routes that strip stale thinking the
gate fired on regions under the soft ceiling on the wire, dropped the
last visible reply from the tail and reopened#29824 (probe: no
reasoning cut=2 reply kept; 1200-char reasoning_content on two older
tool-call rows cut=5 reply dropped; main keeps cut=2).
Factor the walk's per-row pricer out of _walk_tail_budget into
_tail_row_pricer and use it for both the walk and the gate, so the
escape agrees with the #84371 accounting. The existing anchor test now
parametrizes the reasoning-heavy shape; it is red without this change
(cut 5 != 2) and green with it. (BN04 2c High; quality/reuse Medium.)
The comment above the #131412 escape gate said it fires whenever the
anchored region is over the soft ceiling, but the gate also requires
tool_calls in that region. Name both conditions so the comment matches
the code (BN04 gate finding, Low).
The over-ceiling escape added for #131412 measured the anchored region with
_walk_tail_budget from the anchored cut, which reports "over the ceiling"
whenever any single region after a visible reply exceeds the soft ceiling
(tiny budgets, one big tool output). That dropped the last visible assistant
reply from the tail and reopened#29824
(test_reporter_repro_long_tool_run_after_visible_reply,
test_oversized_tool_output_does_not_strand_reply).
Escape only when the anchored region actually holds tool-call bodies to
reclaim (mirroring the #80449 user-split's tool_calls gate) AND its raw
token sum exceeds the soft ceiling. The textless oversized active turn is
still bounded; benign #29824 shapes keep the reply anchor.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
The #80449 oversized-turn split only relaxes the USER anchor, and only
when that anchor actually pulls the cut back (user_anchored_cut <
cut_idx). In a long agentic turn whose assistant rows carry only
tool_calls, the latest user message sits inside the token-budget tail,
so the user anchor does not move, the split never fires, and
_ensure_last_assistant_message_in_tail then pulls the cut back to the
newest TEXT-BEARING assistant — which is the PREVIOUS turn's closer.
The whole oversized active turn rides the protected tail, the middle
collapses (middle_window_tokens=0), and every manual/auto compress
no-ops in ~30ms with failure_class=no_progress: the session is
permanently uncompressible (#131412, same wedge class as #80449/#105663).
Give the assistant anchor the same bound the user anchor got: when the
anchored region exceeds the tail soft ceiling (measured with the walk's
own accounting, #84371) and mid-turn splits are allowed, keep the walk's
tool-group-aligned cut instead of anchoring. Rolling micro-compaction
(allow_split_turn=False) keeps the old anchoring behavior, and the
N-user promise (#70250) is untouched.
(cherry picked from commit 9259ea925c1eb95b3da7bc2fb5ad4499c6afd46b)
Last templated summarizer that ignored the payload outcome: a delegate_task tool_error
(unknown action, spawn failure) compressed into the same stub as a successful delegation.
_skill_result_failure_suffix is now the outcome probe for every templated,
browser, web, vision, cron, process and unmapped-tool stub, but its name and
docstring still said "skill", which suggests skill-specific logic. Rename it
to _result_failure_suffix (all callers are in this module; no test imports it).
Split out the dict-taking _payload_failure_suffix so _sum_cronjob_manage and
_sum_process_manage parse the result once instead of twice (a process poll
can carry a large output), and build the exit-code suffix through
_failure_suffix like every other branch. No behaviour change.
Gate findings BN03b r1 quality + efficiency (Low).
kill_process records exit_code -15 with completion_reason "killed", so a
later poll of a process the agent stopped on purpose compressed into
"[process] poll ... FAILED: exit code -15". A manual cron run the scheduler
was already firing reports execution_skipped, and was stubbed as
"FAILED: Already being fired ...", although the job may be running right
now. After compaction the agent read both as failures.
Skip the exit-code suffix when completion_reason is "killed", and mark
execution_skipped as " SKIPPED: <reason>" (_failure_suffix takes the label);
execution_error and execution_success=False still mark FAILED.
Gate findings BN03b r1 2c + quality (Low, convergent).
The outcome suffix stopped at _sum_template, so the rest of the same bug
class still compressed a failed call into a stub that reads like a
success: web_extract {"error": "403 Forbidden"}, a failed browser_* call,
a refused vision_analyze, and every unmapped tool. The unmapped fallback
covers every MCP and plugin tool, so it was the widest remaining gap.
Append the same top-level outcome suffix in _sum_web_extract, _sum_browser,
the vision_analyze summarizer and the generic fallback in
_summarize_tool_result_unguarded. Success payloads and non-JSON content
give the same stub as before.
Gate finding BN03b r1 2c (Medium).
A manual cronjob run reports a dead or skipped job inside the job view while the tool
call itself succeeded, so the payload is a clean {"success": true, "job": {...}} and the
top-level outcome probe finds nothing to mark. A poll of a finished process reports
exit_code and completion_reason instead of an error, so a non-zero exit is invisible the
same way. Both compressed into the byte-identical success stub, which is the failure this
series removes.
Each tool now reads its own payload: the cronjob stub looks at this call's
execution_error / execution_skipped / execution_success and at nothing else, because the
job view also carries state left by earlier runs; the process stub marks a non-zero
exit code, while a process still running reports none and stays unmarked.
(cherry picked from commit 295e23e3bfc084f20844bf518a244d41421d3f66)
_sum_template formatted from the tool arguments and the result's length only, so every
tool routed through it dropped its outcome. A refused read, a rate-limited search, a
rejected cronjob, an unknown process session and a tts call with no voice all compressed
into the same stub as the success they never were, and the post-compaction agent went on
to report the success (#131244).
Append the failure suffix the skill summarizers already use, so the templated family keeps
its outcome from the one helper that already knows how to read it. Stubs for calls that
report no failure are unchanged.
Complementary to #131247, which gives write_file and patch summarizers of their own.
(cherry picked from commit 6302f87ff52c10db2b43521b733914748b4bcca2)
_authored_request_text and _reappend_inflight_user_task each unwrapped the
in-flight replay header with identical code, so a later change to that
format could update one and miss the other. Both now call
_restated_request_text; the reappend path still keeps the reply pointer so
the restated task retains its disambiguation.
The oversized-active-turn split (#80449) only fires when the turn-opening
request is at most _ACTIVE_TASK_MAX_CHARS. On messaging platforms the gateway
prepends `[Replying to: "<quoted message>"]` to a reply, so a short reply to a
long assistant answer measured as a long request: the split never fired, the
user anchor kept the whole in-progress turn in the protected tail, the
compress window collapsed to nothing, and every automatic pass became a
structural no-op (300 s backoff) while the turn kept growing past the
threshold. /compress hit the same empty window.
Measure the authored text instead: strip one leading gateway reply pointer
(optionally behind the Discord triggering note), and the in-flight replay
header first so a request restated by an earlier compaction keeps splitting
on later passes. The deterministic task snapshot uses the same text when a
row exceeds the cap, so head elision can no longer keep the quote and drop
the request. The token-size guard on the whole row is unchanged, and a quote
whose closing delimiter cannot be found keeps the conservative anchor.
(cherry picked from commit ccaf61b3b651a7fd68ffc0c69ab965881f8850b6)
_native_anthropic_budget read self.api_mode directly while _stale_thinking_on_wire,
called one line earlier in _walk_tail_budget, reads the same route facts as optional
attributes. A compressor built without __init__ (test_compressor_image_tokens) raised
AttributeError in the walk; real instances always set the fields, so this only aligns
the two route predicates.
The compressor's native tail budget read rejected-signature fingerprints
from the session DB only. With persistence disabled (or a failed persist)
the agent's preflight stripped the rejected blocks while the tail walk
still priced them, so the two estimates diverged after a signature
recovery. The agent now binds its session-keyed rejection set onto its
context_compressor (same set object, so later updates reach both), and
the tail walk prefers that in-memory set for its bound session before
falling back to the persisted fingerprints.
#129620 made stale_thinking_reaches_wire() true for preserved-thinking native Claude
(c19252d), so _walk_tail_budget charged the canonical reasoning keys on every turn,
while preflight priced the native projection minus suppressed blocks (643ca56,
9e0ae8d). Measured on a real SessionDB: after a signature rejection the walk charged
243 vs 115 for preflight, and storage-only reasoning rows (e.g. written by another
provider before a model switch) 2033 vs 27, although the wire carried no thinking.
On that route the walk now prices each row through the same projection and the
session's persisted rejection fingerprints. Other routes are unchanged.
Preserve genuine selections around comma-normalized historical notices and require closed quoted question/choice fields before recognizing a notice suffix. Cover real historical producer outputs, embedded terminators, prefix lookalikes, and explicit status through public compression dispatch.
Match the complete single-query notice rather than its prefix, and recognize comma-normalized headless notice envelopes before filtering individual selections. Preserve current explicit status and independent non-notice selections.
Co-authored-by: Andrex Ibiza, MBA <84248988+andrexibiza@users.noreply.github.com>
Match the classic CLI cancel notice as its whole text so real answers that start "The user cancelled" survive, add the hermes chat -q single-query notice, and drop only notice items from a legacy multi-select so other selections are kept.
The pre-#127760 classic CLI Ctrl+C path persisted "The user cancelled. ..." as a status-less user_response. Add it to the legacy sentinel prefixes, now a named module constant, and to the regression parameterization. Drop a leftover debug print.
Keep explicit status authoritative and restore sentinel recognition only for persisted pre-status responses. Exercise repeated pruning and public compaction with both legacy shapes, multi-select answers, and large question payloads.
Co-authored-by: Yuan Li <dskwelmcy@163.com>
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder
Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.
* feat(clarify): one questions[] shape with per-question status and one outcome
The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.
Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.
The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.
* feat(clarify): every surface asks through the one question card
CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.
Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.
Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.
Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.
* fix(clarify): cancelled for a released messaging card, labels win over the skip word
A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.
A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.
The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.
* fix(tui): re-editing a multi-select answer keeps its picks
The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.
Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.
* test(clarify): align three checks with the one-answer confirm and the regenerated keys
The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.
* test(tui): the one-question card header is singular
- Strip persistence-only fields from a select_context() selection: the request copy was stripped before
the hook, but an engine handing back the conversation_messages clones put timestamp, _row_id and the
new ids on the wire (pre-existing for the old fields).
- Every row carries a message_uid whoever wrote it: an AFTER INSERT trigger mints one for a row inserted
without it (an older build writing into a v31 store); the one-time backfill is gated by its own
state_meta marker so a store whose schema version cannot advance (no FTS5) never rescans.
- One merge-witness helper (record_absorbed_message) for every fold of a durable message: alternation
repair's user and assistant merges, the compressor's in-flight restatement onto the carrier, the real
user anchor folded into a scaffolding turn (the anchor's uid leads), micro-compaction's adjacent-user
merge.
- tool_call_uids follows tool_calls: it is a payload column, so an assistant merge's unioned map is
rewritten with the row instead of being overwritten by the stale stored map on adoption.
- A result pairs with the nearest call naming its provider id: each assistant row's ids shadow earlier
occurrences in the batch, restore and live-list indexes, a call row without a map leaves its result
unpaired, and nothing pairs across a user turn.
- A composite rewind returns and installs the replacement row's uid on the live scaffold.
- Tests: import_foreign_history, hermes sessions recover and backup import carry the ids; the ACP
restore test accounts for the uid on restored rows.
(cherry picked from commit b30636a49cb29fdd21ba4100eef9d810897338be)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule
The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:
- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
names, restored dead definitions) and the three re-export stub modules
(gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
`plugins.allow_deprecated_imports` escape hatch
An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.
hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).
In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.
* chore: retrigger CI (zero-job startup_failure phantom)
* test: drop resolution allowlist rows for the two deleted which() sites
hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
62ceddd342 cut raw args to HEAD+4096 before redaction to save time. The
PEM redaction pattern only matches a complete BEGIN...END block. A long key
whose END fell past the cut stayed unredacted, and once an earlier key was
redacted and the text shrank, its body landed in the 1200-char head that
goes into the persisted summary. Go back to the BASE order: redact the full
args, then apply the MAX/HEAD cut. This is a cold path (once per summarized
call per compaction), and _SUMMARY_INPUT_MAX_CHARS still bounds the prompt.
Extend the kept canonical-args test with a two-PEM input that leaks on
62ceddd342 and passes now.
Follow-ups to making tool-call args byte-exact:
- _record_compression_regions measured canonical_messages slices while
compress_start/compress_end are indices into the pruned copy that head/tail
are assembled from; measure the pruned rows actually sent, as before. This
also removes the only canonical slicing, so blank-echo classification drift
between the two copies can no longer misalign anything.
- _render_tool_call_for_summary redacted the full (now unbounded) args before
cutting to 1200 chars; cut to head+4096 first. Output unchanged for args
within that window.
- pressure_hits always equalled demoted once arg truncation left; fold it.
- Drop the fixture-only tautological assert in the guardrail test helper.
- Reword stale compress()/compression_marker docstrings that still described
canonical head/tail and compressor-written arg markers.
The arg-truncation removal deleted the only uses of the marker constants in
agent/context_compressor.py (ruff F401), and the test module had been
importing _COMPRESSION_MARKER_PREFIX through it, so test_context_compressor
line 137 raised NameError. Import it from its home, agent.compression_marker.