Skip to content

feat(agent): per-turn micro-compaction to amortize context compression - #74522

Closed
lxman wants to merge 17 commits into
NousResearch:mainfrom
lxman:feat/micro-compaction
Closed

feat(agent): per-turn micro-compaction to amortize context compression#74522
lxman wants to merge 17 commits into
NousResearch:mainfrom
lxman:feat/micro-compaction

Conversation

@lxman

@lxman lxman commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What & why

Batch compaction stops a session for one large summarization once the window
fills. This adds an opt-out alternative that spreads the same work across turns:
after each completed turn, finalize_turn folds the single oldest un-absorbed
exchange (an assistant message plus its tool results) into a rolling summary.

It is not a token- or time-saving optimisation — the same summarization work
happens either way. What it buys is:

  1. The long pause is amortized into per-turn increments.
  2. Context lasts longer — the middle is continuously reclaimed, so occupancy
    stays low instead of sawtoothing up to the threshold.

Off by config, on by default: compression.micro_compact: false restores
batch-only behaviour. Everything else about compression is unchanged, and the
existing threshold-based path still fires as a backstop.

Full design notes in docs/micro-compaction.md.

Measurements from a real session

A 3.5 hour whole-project code review, ~75K tokens of transcript, 400K window,
threshold 320K:

pass messages tokens delta occupancy duration
1 40 -> 39 27,479 -> 27,778 +299 8.7% 2.2s
2 61 -> 59 48,676 -> 48,128 -548 15.0% 4.5s
3 70 -> 67 58,309 -> 55,915 -2,394 17.5% 9.1s
4 84 -> 80 75,251 -> 69,818 -5,433 21.8% 36.2s
5 84 -> 80 74,659 -> 70,264 -4,395 22.0% 31.2s

Occupancy flattened at ~22% — the last two passes are identical
(84 -> 80 messages); between them the conversation added 4,841 tokens and
micro-compaction reclaimed 4,395. That is equilibrium. No batch compaction
fired
in the whole session.

Reclamation only ramps after the tail budget is crossed (here 64,000 tokens,
16% of the window). Below that nearly the whole transcript is protected tail,
so early sessions legitimately show no passes at all.

Costs, stated plainly

Pass duration was 2 to 37 seconds, median ~31s, on a 4-bit 7B local model
that was also serving other work. It runs at the end of a turn: the response has
already streamed, but the turn does not close until the pass finishes.

The dominant variable is the compression model, not the algorithm — the prompt
is only a few thousand tokens, but the call happens every turn. A reasoning
model is a poor fit here: merging one exchange into a summary is mechanical, and
a thinking model spends reasoning tokens on it for no benefit. The docs cover
this as a tuning decision rather than a recommendation, since the right answer
depends on the operator's hardware.

The first pass in a session costs tokens rather than saving them (~400 for
the marker scaffolding, paid once). From the second pass the marker is replaced
rather than added. Worth knowing before reading a single turn's numbers and
concluding it made things worse.

Design decision worth flagging

User messages are never absorbed. An exchange deliberately starts at the
assistant message. What the assistant emits is largely an account of what it did
and survives summarising; the user's own words are the intent everything else is
derived from and cannot be reconstructed from the work that followed.
Paraphrasing "use the existing helper, don't add a new one" into a summary is
how an agent ends up doing the opposite several turns later.

The cost is a floor on how small the middle can get, since user turns
accumulate. In practice that floor is low — a prompt is normally a tiny fraction
of one tool result.

Notable implementation points

  • Only the newest summary marker is kept. The rolling summary is cumulative,
    so earlier markers are strictly redundant; leaving them stacked near-duplicate
    copies (each with its own scaffolding) and the transcript grew every turn
    instead of shrinking.
  • The cursor is derived from the spliced list, never a pre-splice index. A
    splice collapses several messages into one marker, so indices shift; a stale
    cursor landed inside a later exchange's tool group and that exchange was then
    skipped entirely.
  • Resume does not lose history. The rolling summary is in-memory, so a
    resumed session recovers it from the existing marker before merging. If that
    recovery ever fails, superseding is skipped so the old marker survives.
  • archive_and_compact keeps the session DB in step; the append-only flush
    would otherwise leave the original rows active and a resume would double-load.
  • Bounded failure handling — an exchange the summarizer cannot handle is
    retried a few times then skipped, so one poison exchange cannot stall
    every turn.
  • Telemetry is one content-free JSON line per pass, in the same shape as the
    existing compression_attempt telemetry, plus
    scripts/micro_compaction_report.py to aggregate it.

How to test

pytest tests/agent/test_micro_compaction.py

19 tests covering the absorb/defrag/failure paths, the head/tail protection, the
user-turns-verbatim invariant, the shrink property, resume (including failed
rehydration), and the telemetry contract.

Also exercised with a randomized long-horizon harness — 480 conversation shapes
(varying tool-group sizes and summarizer failure modes) x 25 passes each,
asserting after every pass that the transcript stays API-valid (no orphaned tool
results), at most one marker exists, message count never grows, and head/tail
and user turns are preserved.

Manually: set compression.micro_compact: true, run a long tool-heavy session,
then python scripts/micro_compaction_report.py --per-session.

Platforms

Developed and measured on Windows 11 (Python 3.11), and deployed and verified on
macOS (Apple Silicon) and Ubuntu 24.04. Nothing here is platform-specific.

Rebased onto current main (646761c); the nine commits replayed with no
conflicts.

lxman and others added 9 commits July 29, 2026 21:45
Batch compaction pauses a session for one large summarization once the
window fills. Micro-compaction spreads that cost out: after each completed
turn, `finalize_turn` folds the single oldest un-absorbed exchange
(assistant message plus its tool results) into a rolling summary, so the
work happens in small increments during post-turn idle time instead of one
long stall.

Mechanics:
  - a cursor tracks the first message not yet absorbed, recovered from the
    transcript's last summary marker when in-memory state is unavailable;
  - protected head and tail windows are never touched, so the system prompt
    and recent turns stay verbatim;
  - the absorbed span is replaced by a marker carrying the usual
    `_compressed_summary` metadata, so resume, handoff and `/compress`
    treat it exactly like a batch summary;
  - `archive_and_compact` keeps the session DB in step, otherwise the
    append-only flush would leave the original rows active and a resume
    would double-load both summary and originals;
  - when the rolling summary itself passes a token threshold it is
    defragged: re-summarized in one shot and the cursor jumps to the tail;
  - an exchange the summarizer can't handle is retried a bounded number of
    times, then skipped, so one poison exchange can't stall every turn.

Keep only the newest summary marker. The rolling summary is cumulative, so
each marker already contains everything the previous ones held; leaving them
stacked near-duplicate copies of the same text, each with its own heading and
end-marker scaffolding, and the transcript grew on every turn instead of
shrinking. Measured over six turns on a 12-exchange conversation with tool
output: 4104 -> 4797 tokens before, 4104 -> 2572 after.

Off switch: `compression.micro_compact: false` (default on).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers what it does, the head/tail protection, the cursor and rolling
summary, defrag, how the session DB is kept in step, and the failure
paths. States the tradeoff up front: compression cost is amortized across
turns, at the price of older detail becoming summarized earlier in a
session than batch-only compaction would.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`_find_one_exchange`'s docstring described an exchange as "(optional) user
message + assistant message + its tool results", but the walk skips past
user messages and starts at the assistant, so user turns are never absorbed
into the rolling summary.

The code is right and the docstring was wrong. Assistant output is largely
an account of what was done and survives summarising with little loss. The
user's messages are the intent everything else is derived from and cannot be
reconstructed from the work that followed — paraphrasing "use the existing
helper, don't add a new one" into a summary is how an agent ends up doing
the opposite six turns later. They are also cheap: a prompt is normally a
tiny fraction of what one tool result costs.

Correct the docstring, document the property (and its cost — a floor on how
small the middle can get, since user turns accumulate), and add a test so it
stays deliberate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The `_micro_compact` docstring cited "NousResearch#82483" for the resume double-load
problem. No such issue exists — the repository's highest number is 74323,
so the reference was invented rather than looked up.

The reasoning it was attached to is correct and stays: the session flush is
append-only, so an in-memory splice alone leaves the original rows active
and a resume loads both the summary and the messages it replaced. Only the
citation was wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The existing log line reports message counts, which is the least
informative number available here: absorbing one tool-heavy exchange can
drop hundreds of tokens while moving the count by one. There was no way to
answer "is this actually helping?" from a real session.

Emit one content-free JSON line per pass, in the same shape as the batch
compaction telemetry: before/after tokens, the delta, the size of the
absorbed exchange, the rolling summary size, duration, and running
per-session totals so a whole run can be read off the last line. No
transcript content rides along.

Add scripts/micro_compaction_report.py to aggregate those lines into
passes, outcome mix, net tokens saved, mean exchange size and durations,
with an optional per-session breakdown.

Measuring it immediately surfaced something worth documenting: the first
pass in a session normally *costs* tokens. The summary marker carries a
fixed ~400 tokens of scaffolding, paid on pass one against a single
absorbed exchange. From pass two the marker is replaced rather than added,
so the overhead is already paid and each exchange is close to pure saving.
Break-even is typically the second or third pass. Tests cover the
telemetry contract, the cumulative totals, and that first-pass/later-pass
shape so nobody reads a single turn and concludes it made things worse.

The estimator costs ~5 ms at 600 messages and ~20 ms at 1200, taken twice
per pass, post-turn — and only once an exchange is actually in hand, so
turns that no-op early pay nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tokens saved is the wrong headline for this feature. Micro-compaction is
not an efficiency optimisation — the same summarization work happens either
way. What it buys is (a) that work amortized across turns instead of one
stall, and (b) a window kept low enough that a session runs much further
before needing a hard compaction at all.

Neither shows up in "net tokens saved". A session can save nothing on paper
and still be a clear win on both counts.

So the telemetry now carries occupancy: tokens_after as a share of the
compaction threshold, plus the threshold and resolved window it was
computed from. That is the number that says whether a session has headroom
left. The report leads with it, and cross-references the batch
`compression_attempt` lines already in the log so it can show how often the
long pause actually fired — ideally never.

Occupancy is read from the cached threshold only. The public
`threshold_tokens` property resolves lazily and can issue a synchronous
/models probe (NousResearch#32221); telemetry must never be the thing that blocks a
turn, so an unresolved window reports null. In practice a pass has already
resolved it via the tail calculation, so the field is populated. A test
pins the no-forcing behaviour directly against the emitter.

The report is pure ASCII: `scripts/check_subprocess_stdin.py` currently
dies on a cp1252 console before printing its results, and a diagnostic tool
that crashes on the platform it is diagnosing is worse than no tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cursor was set to the pre-splice `exchange_end`. A splice collapses the
absorbed span -- an assistant plus its tool results, often four or more
messages -- into a single marker, and may also drop a superseded marker
further back, so every index after it shifts.

The stale cursor therefore overshot, landing inside a *later* exchange's
tool group. The next pass's `_find_one_exchange` walked forward from there
to the following assistant, so the exchange it had landed inside was never
absorbed at all. On tool-bearing conversations micro-compaction was
silently doing roughly half the work it should.

Traced on a 3-tool-per-exchange transcript: the cursor sat at index 6 when
the marker was at 2, and the message count stalled at 32 instead of
continuing to 28.

Derive the cursor from the marker's actual position in the spliced result
instead, which is self-correcting regardless of how much the splice moved.
Apply it on the defrag path too, which had the same staleness.

Found by a randomized long-horizon harness (480 conversation shapes x 25
passes, varying tool-group sizes and summarizer failure modes) asserting
structural and progress invariants after every pass. Existing tests missed
it because their fixtures have no tool results, so the absorbed span is one
message and nothing shifts. The regression test uses tool groups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three corrections, all from measuring a real 3.5 hour session rather than
reasoning about the design.

"During the idle moment after a response" was wrong. A pass is a real call
to the compression model at the end of a turn: the answer has streamed, but
the turn does not close until it finishes. Measured 2 to 37 seconds, median
around 31, on a small local model. Say so.

Add the choice of `auxiliary.compression` model as its own section, because
it dominates everything else here. A pass sends only a few thousand tokens
but runs every turn, so latency is felt repeatedly, and reasoning models are
a poor fit -- merging one exchange into a summary is mechanical work, and a
thinking model spends reasoning tokens on it for no benefit. Two measured
data points are given as illustrations of the shape, explicitly not as
recommendations: the right answer depends on the operator's hardware.

Add what a working session actually looks like: occupancy climbing to ~22%
and flattening (equilibrium -- 4,841 tokens added between the last two
passes, 4,395 reclaimed), zero batch compactions, and reclamation only
ramping after the tail budget is crossed. Also state the cost in the same
breath rather than burying it.

Frame the feature as a tuning option rather than a win: it lets you choose
how the compression cost is distributed and which model pays it. It is not
a magic bullet and the docs should not imply otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rolling summary lives only in memory. A resumed session starts with an
empty one while the marker carrying every previously absorbed exchange is
still in the transcript. The first pass after a resume therefore built a
marker from a single exchange and superseded the marker holding the entire
history -- silently discarding everything micro-compaction had accumulated.

This was introduced by the supersede fix. Before it, markers piled up
wastefully, but nothing was ever lost.

Two changes, so a single failure cannot lose data:

Rehydrate. When the cursor is recovered by scanning the transcript -- the
resume path -- also recover the rolling summary from that marker, so the
next pass merges into the existing history instead of replacing it.
Extraction uses rfind for the heading because SUMMARY_PREFIX references the
heading text itself, so the first occurrence is inside the preamble.

Gate superseding. Earlier markers are dropped only when this pass's summary
is demonstrably cumulative, i.e. the rolling summary was non-empty going in.
If rehydration ever fails, the pass keeps both markers: wasteful, but the
history survives.

Tests cover the resume path, the failed-rehydration fallback, and the
round trip of a summary through a marker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alt-glitch alt-glitch added type/feature New feature or request comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/compression Context compression and continuation sessions P0 Critical — data loss, security, crash loop needs-decision Awaiting maintainer decision before any implementation sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) 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 labels Jul 30, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Current diff invokes micro-compaction after every completed turn and rewrites prior messages. AGENTS.md permits cache-invalidating history rewrites only at episodic compression boundaries; the current compressor enforces a meaningful-reclaim gate for the same reason. Please decide whether the default-on design may violate the prompt-cache contract. Related: #7682, #69360.

lxman and others added 2 commits July 29, 2026 22:06
Review raised whether default-on can be reconciled with the prompt-cache
contract in AGENTS.md, which permits mutating past context only for context
compression and treats per-conversation caching as sacred. It cannot, and the
codebase already says so in its own words.

A micro-compaction pass rewrites already-sent history, so it invalidates the
cached prefix every turn rather than at an episodic boundary. That is the exact
cost the proactive prune gates against: `proactive_prune_min_reclaim_tokens`
exists, per its own config comment, to keep rewrites to "one big episodic break
instead of a tiny break every tool iteration." Micro-compaction has no
equivalent gate -- one exchange per turn means one break per turn, by design.

Default to off. An operator who wants the amortized stall can opt in with
`compression.micro_compact: true` and accept the tradeoff knowingly; nobody
inherits a per-turn cache break from installing an update.

Also register the key in config_defaults so it is discoverable and picked up by
the update path's new-options check -- it was previously read by agent_init but
declared nowhere -- and document the cache cost in docs/micro-compaction.md
instead of only the benefit. The measurements behind the feature (occupancy
plateau, zero batch compactions) never priced cache invalidation, and the doc
now says which numbers a reader would need to measure to justify enabling it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The on/off switch was the only knob. A pass fired after every completed turn,
absorbed exactly one exchange, and there was no way to ask for less. Since a
pass is also what breaks the prompt-cache prefix, "how often does it run" and
"how often do I pay a cache break" are the same question, and it had no answer.

Add `compression.micro_compact_every_n_turns` (default 1, clamped to >= 1). At 1
the behaviour is what it was; at 5 you get a fifth of the breaks and a fifth of
the reclaim rate. The counter advances per invocation rather than per committed
pass, so a turn that finds nothing to absorb still moves the cadence along and
cannot wedge it, and a bogus 0 or negative degrades to "every turn" instead of
silently disabling compaction.

Also expose `micro_compact_defrag_threshold_tokens`, which has been a hardcoded
attribute on the compressor with no path from config since it was added.

This does not give micro-compaction the prune's reclaim-size gate -- a pass
still commits whatever the single absorbed exchange saved. It makes the break
frequency tunable, which reaches the same end by absorbing less rather than by
waiting for a bigger win. The docs now say that plainly, including that a
reclaim threshold is the obvious follow-up and does not exist yet.

Tests cover the skip-until-due window, the cursor and prefix staying untouched
on skipped turns, the clamp, and that the feature is off unless enabled.

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

lxman commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

You're right, and the codebase already makes the argument better than I would. proactive_prune_min_reclaim_tokens exists so prune rewrites stay one episodic break instead of a tiny break every tool iteration. Micro-compaction fired every turn with no way to ask for less, so one exchange per turn meant one cache break per turn. Default-on can't be squared with that.

Two changes. It's opt-in now, off unless compression.micro_compact: true. And the cadence is configurable via micro_compact_every_n_turns — at 5 you get a fifth of the breaks for a fifth of the reclaim rate, which is the dial that was missing. I also registered both keys in config_defaults.py, since agent_init was reading micro_compact but nothing declared it, and micro_compact_defrag_threshold_tokens was a hardcoded attribute with no path from config at all.

This still isn't the prune's reclaim-size gate. A pass commits whatever the single absorbed exchange saved, so raising the cadence gets you rarer breaks by absorbing less, not by waiting for a bigger win. A reclaim threshold is the obvious follow-up if you'd rather have the same semantics as the prune. The docs say that now, along with the fact that my original measurements were occupancy and batch-compaction counts and never priced cache invalidation.

@teknium1 teknium1 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.

Thanks for moving the feature to opt-in and documenting the prompt-cache tradeoff. The current implementation still has two correctness blockers.

Problems

  • agent/context_compressor.py:5607-5614 replaces only an assistant/tool span with a user marker. In a normal user → assistant transcript this creates consecutive user rows. Current main merges those rows before the next request (agent/agent_runtime_helpers.py:681-715), which folds the marker into a real user message and loses its summary metadata.
  • The defrag path unconditionally splices messages[exchange_start:compress_end] at agent/context_compressor.py:5355-5356, although _defrag_rolling_summary() only updates the rolling summary on successful auxiliary output (:5278-5282). A failed defrag therefore drops unsummarized messages; the same range can contain user turns.

Suggested changes

  • Derive marker role/placement from both adjacent roles, following the existing batch-compression handling at agent/context_compressor.py:5423-5475, and add an end-to-end alternation/persistence test.
  • Gate defrag splicing on a successful replacement and preserve user turns in both success and failure cases.

Automated hermes-sweeper review.

Comment thread agent/context_compressor.py Outdated
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY: True,
}

result = messages[:splice_start] + [summary_msg] + messages[splice_end:]

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.

This replaces an assistant span with a user marker while retaining the preceding real user turn, so ordinary user → assistant history becomes user → user. The next pre-send repair merges adjacent user rows (agent_runtime_helpers.py:681-715), folding this marker into user content and losing its metadata. Choose a role/placement that is safe relative to both neighbors, as batch compaction does.

Comment thread agent/context_compressor.py Outdated
# Check for defrag trigger
if self._needs_defrag():
self._defrag_rolling_summary(messages, exchange_start, compress_end)
result = self._splice_micro_compact_result(messages, exchange_start, compress_end)

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.

_defrag_rolling_summary() leaves the old rolling summary unchanged when the auxiliary call fails, but this unconditional splice still removes the full range. That drops unsummarized exchanges and any intervening user messages. Return a success flag and splice only after a successful replacement that preserves user turns.

…g user turns

Review found two correctness blockers. Both reproduce, and the first is worse
than reported.

The marker was pinned to role="user". An exchange starts at the assistant
message, so the row before the splice is the real user turn that opened it --
the marker landed straight after it as user -> user. conversation_loop.py:1422
runs repair_message_sequence_with_cursor before EVERY API request, and its
pass 2 merges consecutive user rows by folding the second into the first and
dropping the second dict. So the marker's _compressed_summary flag -- what
supersede, cursor resolution and resume rehydration all key off -- was
destroyed on the next request, and since the repair rewrites messages[:] in
place, the loss was persisted. The old code reaches six passes with the
summary text buried inside "question 0 ... question 5" as one user row and
zero detectable markers.

Batch compaction already solved this: pick a role that alternates against both
neighbours, and where neither works, merge and re-attach the metadata. Derive
the role the same way. assistant is the normal answer; user is the fallback,
because consecutive user rows merge and keep their text while a stray
assistant row can be read back as model output.

Second: defrag absorbs the whole middle, not one exchange, so its range spans
real user turns -- and _splice_micro_compact_result replaced the range wholesale.
It dropped every user turn in it. The review framed this as a consequence of a
failed defrag; it happened on the SUCCESS path too, which makes it a straight
contradiction of the promise in docs/micro-compaction.md that user messages are
never compacted. Splice with preserve_user_turns on that path: the assistant
and tool bulk is still reclaimed, every user turn stays verbatim.

_defrag_rolling_summary now returns whether the auxiliary call produced
anything, and the caller returns early when it did not. Splicing on failure
removed the middle in exchange for a marker built from the OLD summary, which
by definition did not describe the messages just deleted.

Also stop hardcoding _compressed_summary_has_user_turn to True. It is
provenance (NousResearch#64650) for whether the summarized text held a user turn; the
per-turn path never absorbs one, so True told the zero-user guard a user
request survives when none does. Derive it from the absorbed range.

Tests: marker survives the pre-send repair; marker never adjacent to a
same-role neighbour; failed defrag commits nothing; defrag preserves user
turns verbatim; and a multi-pass loop that runs the repair between passes.
That last one is the shape the earlier accumulation bug hid in -- single-pass
assertions and a stress harness that never invoked the repair could not see
either failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@teknium1 teknium1 added the sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users label Jul 30, 2026
lxman and others added 5 commits July 30, 2026 15:02
…role safe

A five-angle review of the design turned up one confirmed data-loss bug, one
gap in the role fix from the previous commit, and one hazard that the gap was
accidentally hiding. They interact, so they are fixed together.

Supersede deleted ANY message carrying _compressed_summary and kept the newest.
"The newest contains the others" is true of micro-compaction's own cumulative
rolling summary and of nothing else. A batch-compaction marker covers a
different span, and the rolling summary has never read it -- _resolve_compact_
cursor rehydrates from a marker only when the rolling summary is EMPTY, so in
steady state nothing folds a foreign marker in before it is dropped. Reproduced:
one micro pass on a transcript holding a batch marker leaves the batch content
in neither the transcript nor the rolling summary. That is the entire compacted
history of a session, gone silently, with no telemetry distinguishing it from a
routine supersede. Markers are now tagged with their producer and supersede
only ever drops its own; anything untagged (legacy sessions) or foreign is
left alone, even at the cost of a duplicate-looking transcript.

The role fallback was not collision-safe. When no role alternates with both
neighbours it returned "user" unconditionally, and with prev_role == "user"
that is the original bug verbatim: repair_message_sequence pass 2 folds the
second of two consecutive user rows into the first and drops it, so the marker
is the dict destroyed. The two collisions are not equivalent -- colliding
backwards loses the marker, colliding forwards merely merges a neighbour's text
into it, because the marker is then the survivor of the fold. Never collide
with the predecessor; accept a forward collision only when there is no
alternative. Reachable via consecutive assistant rows, which pass 0 normally
merges but deliberately exempts for Codex-Responses interim turns.

Fixing that unmasks a hazard it was hiding. While the fallback returned "user"
there was always a user-role row; once it stops, the last one can vanish --
especially since supersede may delete an earlier marker that was carrying
"user". Strict OpenAI-compatible backends reject a transcript with no user row
(400 "No user query found in messages"), non-retryable, and every resume
replays it. Batch compaction has a guard for exactly this; micro-compaction had
none because it never needed one. Added as a backstop on the final list, so it
accounts for both the role choice and the supersede.

Tests: a batch marker survives a pass; own markers still collapse; the role
never collides backwards across every neighbour combination; no zero-user
transcript is produced; retained user turns survive the pre-send repair. The
zero-user test uses a production-shaped fixture -- no role="system" row, since
the live messages list never carries one -- because every existing fixture
starts with system at index 0 and so never exercises the decayed-head shape
these paths depend on.

test_cumulative_savings_accumulate_across_passes now asserts monotonic
accumulation over five passes instead of a positive total at exactly four. The
producer tag adds ~10 tokens to the marker and that test passed by 5: the token
estimator shadows every key on the message, including internal ones that are
stripped before the wire, so metadata inflates the estimate it never costs in
reality. Pinning the sign to a specific pass made an unrelated metadata change
read as a regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every fixture in this file opened with a role="system" row. Micro-compaction
never sees one. `_micro_compact` is called only from `finalize_turn` on
`agent.messages`, and the system prompt is prepended to the wire copy at
request-build time (conversation_loop.py:1550) -- it is never a member of the
stored list. The one path that does pass a system-bearing list is the gateway
/compress path, which is batch compaction.

The row was not inert. It kept `_protect_head_size` >= 1 and `compress_start`
>= 1 in every test, so the decayed-head shape was unreachable: once batch
compaction has run, `_effective_protect_first_n` drops to 0 and the marker can
land at the very front of the transcript. That is the shape the marker-role and
zero-user bugs found in review depend on, and no test could reach it.

Drop the system row from all three fixtures and add a test that pins the
decayed head directly -- asserting the boundaries really do collapse to 0, that
the marker still does not collide with its predecessor, and that a real user
turn survives for strict backends.

The whole suite passes unchanged otherwise, which is the honest result: the
fictional row was hiding a shape rather than masking a live failure. Worth
having anyway, since two of the bugs fixed in the preceding commits live in the
region it made untestable.

Note for reviewers: the same pattern is widespread in the batch-compaction
tests (test_context_compressor.py has 15 such rows). Those are not wrong in the
same way -- batch genuinely serves both shapes and branches on
`last_head_role == "system"` -- but they do appear to cover only the
system-bearing one, and the main auto-compression path passes no system row.
Left alone here as out of scope for this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by probing the patch for residual criticism rather than waiting for a
reviewer to find it.

Colliding forwards is sometimes unavoidable — colliding backwards destroys the
marker, so the role picker takes the forward collision — and pass 2 then folds
the successor's text into the marker. has_user_turn was computed from the
absorbed range alone, so when that successor was a real user turn the marker
ended up holding user-authored content while still reporting itself as a pure
summary. run_agent persists display_kind="hidden" for exactly that combination,
and every transcript surface drops hidden rows, so the user's own message
disappeared from the conversation. Reproduced on the per-turn path with an
interim assistant before the exchange (pass 0 merges consecutive assistants but
exempts codex interims, which is what makes prev_role="assistant" reachable)
and a real user turn after it: swallowed REAL USER TURN 0, persisted hidden.

Predict the merge instead: when the chosen role collides forwards with a real
user turn, mark the summary as carrying a user turn, because it is about to.
That keeps the row visible and keeps the zero-user provenance honest — after
the fold a user turn genuinely is present in it.

The defrag path was already safe here by accident: its absorbed range contains
the retained user turns, so has_user_turn was already True and the row was
never hidden. Only the per-turn path, where the range is assistant plus tool
results and the flag is legitimately False, could produce the bad combination.

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

Two things a reviewer would reasonably still ask for after the previous
commits.

The forward-collision merge was implicit. When neither role alternates the
marker takes the forward collision, and pass 2 then folds the successor's text
onto it -- correct, but decided in another module, and only correct on paths
where that pass runs. Batch compaction merges deliberately in this situation
(`_merge_summary_into_tail`) rather than leaving it to a repair. Do the same:
build the merged row at splice time with the summary first, the live message
after it, the end marker already separating them, the metadata set atomically,
and drop_stale_api_content called because the content was rewritten. The
transcript is now correct straight out of the splice, with no downstream pass
required.

The zero-user backstop was unproven. It was added for a failure mode the review
flagged but nobody had reproduced, and the shapes tried until now all had the
role picker returning "user" on its own -- so the branch may well have been
dead. It is not. Reaching it needs the only user-role row to be an earlier
micro marker that supersede then removes: the picker sees prev_role="user" and
takes "assistant", supersede drops the old marker because it carries this
mechanism's tag, and no user-role row is left. There is now a test that builds
exactly that, and disabling the backstop alone fails it.

Both tests were confirmed load-bearing by reverting each change independently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Readiness re-review before pushing, and it found one more real defect.

Supersede kept the micro marker at the highest INDEX and assumed that was the
newest. It usually is -- but a marker stranded at or past the tail boundary is
invisible to _resolve_compact_cursor's scan, so the cursor resets and the new
splice lands BEFORE it. Position-based supersede then discards the fresh
cumulative marker and keeps the stale one, every pass: exchanges keep being
absorbed while their summary never lands in the transcript, and a crash or
resume rehydrates from the stale copy, losing everything absorbed since.
Reproduced, then fixed: keep summary_msg by identity -- it is the cumulative
marker by construction -- and drop the other micro-tagged markers, wherever
they sit.

Doc corrections, so a reviewer diffing prose against code finds agreement:
"never summarized" is qualified -- the defrag pass hands the summarizer the
whole un-absorbed middle, which includes user text as context, but the turns
themselves stay verbatim in the transcript; the defrag section now states that
user turns are retained and that a failed defrag commits nothing; and the batch
interaction section states that the two mechanisms' markers coexist and micro
never removes a batch marker.

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

lxman commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Both blockers confirmed by repro before touching anything. Fixed across 442d262..5c75257.

The marker role is now derived from both neighbours the way compress() does it, with one refinement: the two collisions aren't equivalent. Colliding with the predecessor destroys the marker (pass 2 folds the second consecutive user row into the first and drops its dict, metadata included), colliding with the successor is survivable. So the picker never collides backwards, and when a forward collision is forced it does the merge deliberately at splice time — summary first, live message after the end marker, metadata and drop_stale_api_content handled in one place — same shape as _merge_summary_into_tail, instead of leaving the fold to the repair pass. Tests run repair_message_sequence against the splice output directly, plus a multi-pass loop that interleaves repair between passes.

The defrag finding was broader than stated: user turns were dropped on the success path too, not just failure. _defrag_rolling_summary now returns a success flag and the caller commits nothing on failure; user turns inside the defrag range are kept verbatim after the marker on success.

Fixing your two surfaced more, so I did a full pass over the design. The supersede step could delete a batch-compaction marker — the "newest contains the others" invariant only holds within micro's own cumulative lineage, and a batch marker's content exists nowhere else once dropped. Markers now carry a producer tag and supersede only touches its own, by identity rather than index (position-based supersede kept a stale marker over the fresh cumulative one when an old marker sat past the tail boundary). Also derived _compressed_summary_has_user_turn from the absorbed range instead of hardcoding True, which turns out to fix the display_kind="hidden" gating and the zero-user provenance check that the constant was silently defeating, and added the zero-user backstop compress() has so a strict backend can't see a transcript with no user row. Test fixtures no longer open with a system role row, since the live message list never carries one and the fictional row was hiding the decayed-head shape most of this lives in.

Docs updated to match. Head is 5c75257.

kshitijk4poor added a commit that referenced this pull request Jul 31, 2026
Attribution prerequisite for salvaging PR #74522 (micro-compaction);
the contributor audit requires every commit-author email on main to
resolve to a GitHub login.
kshitijk4poor added a commit that referenced this pull request Jul 31, 2026
…eserving

Two integration bugs found during review of #74522, both confirmed with
empirical probes against the production message-repair path:

1. Alternation: the summary marker was role="user" and an exchange was a
   single assistant+tools group, so splicing between two user turns produced
   user -> marker(user) -> user. The pre-request repair_message_sequence pass
   (conversation_loop.py, runs before EVERY API call) then merged the marker
   into the neighbouring real user message: metadata gone, cursor
   unrecoverable on resume, and the summary text duplicated into the
   transcript on every later pass (the transcript GREW every turn).
   Fix: an exchange is now a full agent turn (assistant + tools + follow-up
   assistant iterations, bounded by user messages), the marker is
   assistant-role, and superseding an old marker deliberately merges the two
   adjacent real user turns (plain-text \n\n-join, identical to repair
   pass 2) so the returned transcript is alternation-valid by construction.
   Probe result: repairs 0 (was 2), marker survives, no summary leakage.

2. Defrag destroyed user messages: _defrag_rolling_summary serialized the
   whole remaining middle (user turns included) and spliced it away —
   8 of 10 user prompts destroyed in one pass, contradicting the feature's
   "your messages are never compacted" invariant. Fix: defrag now
   re-summarizes only the rolling summary TEXT and rewrites the marker
   content in place; transcript shape, cursor, and user turns untouched.
   Probe result: 10 of 10 user prompts survive.

Also: marker provenance is now COMPRESSED_SUMMARY_HAS_USER_TURN_KEY=False —
micro markers absorb only assistant/tool content (#64650 invariant), and
real user turns remain in the transcript for provenance detection.

Adds 5 regression tests (repair-pass integration, alternation on
multi-iteration tool turns, defrag user survival, defrag input scope,
marker provenance); updates the two existing tests and the design doc to
the corrected semantics. 28 tests pass.
jhjaggars-hermes added a commit to jhjaggars/hermes-agent that referenced this pull request Jul 31, 2026
* fix(desktop): open links clicked in the integrated terminal

Both of xterm's link paths activate through `window.open()`, which the
window's setWindowOpenHandler denies, so ⌘-clicking a URL did nothing but
log "Opening link blocked as opener could not be cleared" — and the OSC 8
path fronted that dead end with a raw confirm() dialog. Route both through
the desktop bridge, the path every other external link in the app takes.

⌘-click on macOS, Ctrl-click elsewhere, matching VS Code's integrated
terminal, Terminal.app, and iTerm2. A bare click stays with the selection so
a misclick on a URL can't launch a browser.

* fix(desktop): stop ⌥-click spraying cursor escapes into the terminal

⌥-drag is the app's force-selection gesture over mouse-mode TUIs, but
xterm's default alt-click-moves-cursor claims the same click and emits one
cursor left/right escape per column of travel. Shells that don't consume
them echo the raw `^[[D` burst into the buffer. One gesture, one meaning.

* fix(desktop): middle-click works on a real three-button mouse

Chromium on Windows and Linux answers a middle press inside a scroller by
starting the autoscroll pan, and the mouseup that ends the pan never becomes
an auxclick. Every surface carrying the gesture — tab strips, the session
list, the terminal rail — is a scroller, so middle-click only ever worked on
macOS, where autoscroll doesn't exist.

Arm on pointerdown, spend on the pointerup over the same element (press one
tab, release on another and nothing happens), and cancel the middle mousedown
on every press so the pan widget can't appear on a surface that owns the
button. One helper, four call sites.

* fix(desktop): closing the last main tab lands on New session

The workspace pane can't leave the tree, so "close the main tab" only ever had
one answer wired: shift the next stacked session in. With main as the only tab
there was nothing to shift and ⌘W dead-ended on the tab the user was looking
at.

closeWorkspaceTab is now the one answer for every entry point — stacked
session still wins, and with nothing stacked main drops to a fresh New session
draft. A blank draft and a full-page view stay no-ops: a blank draft already
IS the post-close state.

* fix(desktop): the main tab can be closed by gesture and menu

The tab strip decided the close gesture from the `uncloseable` flag, which the
workspace sets to keep its pane in the tree — so the one tab whose close now
does something couldn't be ⌘-clicked or middle-clicked, and its right-click
menu had no Close.

Read the gesture off the pane's registered closer instead, with the workspace
registering closeWorkspaceTab. An atom rather than a lookup, since that closer
comes from a wiring effect that lands after the strip's first paint.

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

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

* fix(desktop): the placeholder comes back when you clear the composer

Select-all + Cut emptied the text and left the composer blank — no draft,
no prompt. Delete had the same hole.

The placeholder is painted on `:empty`, and a cleared editor keeps a
scaffolding <br> so the contenteditable can't collapse to a sliver. Those
two facts collide: the moment the break lands the editor has a child,
`:empty` goes false, and the prompt never comes back.

CSS can't infer emptiness on its own either. A text node is invisible to
selectors, so `one<br>` and a lone `<br>` are the same shape — a structural
rule like `:has(> br:only-child)` paints the placeholder straight over the
user's text. The code that empties the editor is what knows, so it marks
the root and the condition reads `:is(:empty, [data-empty])`.

Both writers that reshape that root maintain the marker through one helper:
the normalizer, and renderComposerContents for a restored draft or an undo.
The message-edit composer shares the slot and the rule, so it takes the
same shared class instead of drifting on its own copy.

NousResearch#74815 fixed the draft this stashed; the placeholder is a separate seam.

* feat(tui): one token type for everything deferred in the composer

A collapsed paste and an attached image are the same idea: a `[[ … ]]`
marker sitting in the input line that stands in for a payload resolved at
submit. Model both as ComposerToken and give them one expander.

Image tokens resolve to nothing — the gateway already holds the file in
attached_images — so expandTokens eats an adjacent space to avoid leaving
a gap mid-sentence. nextImageIndex never reuses an index after a delete,
or two files would collide on one label.

* feat(tui): attach images inline at the cursor, delete the token to unattach

Every attach path now drops an `[[ Image N ]]` token where you are typing:
drag-drop, clipboard (bracketed and hotkey), /image, /paste. The composer
owns clipboard attach directly instead of calling back out to useMainApp.

Deleting the token is how you unattach — there is no second control.
updateInput is the one choke point every keystroke passes through, so
syncTokens reconciles there and detaches anything erased. That also fixes
a stale image riding along on the next unrelated turn.

Tokens and the input line get refs alongside state: paste-then-immediately
-Enter submits before React has re-rendered, and the submit path has to see
the token that was just added.

* fix(tui): stop announcing attachments outside the composer

The token in the input line is the whole receipt. Drop the notices that
duplicated it somewhere the user was not looking: the drag-drop and
clipboard sys() lines, and the attachedImageNotice / "detected file: X"
activity rows above the status bar.

attachedImageNotice and imageTokenMeta have no callers left.

* fix(sync): read org state from the org endpoints, not the personal ones (NousResearch#75237)

Org-shared skills were unusable past the first propose. Three defects, one
root cause plus two that it masked.

ROOT CAUSE — org reads went to the personal endpoint.

`SyncClient.get_refs()` / `get_object()` only ever called `/v1/sync/refs`
and `/v1/sync/objects/:hash`. Those routes are hard-scoped server-side to
the token's own owner, so asking them for `refs/org/<id>/` returns the
caller's PERSONAL refs rather than an error, and org objects 404. Both org
call sites read org state through them:

- `pull_org_skills` resolved head=None for a populated org and reported
  `{"ok": true, "head": null, "updated": []}` — org skills silently never
  arrived, which reads as "my org has no skills" rather than as a failure.
- `propose_skill` resolved base_head=None, so the FIRST propose to an org
  succeeded by accident (`from: null` happened to be correct) and EVERY
  later one CAS'd against a head it had never seen -> 409 -> a raw
  `SyncConflict` traceback. Worse, it built its root from an empty skill
  map, so a landed CAS would have REPLACED the org set rather than splicing
  into it — the 409 was accidentally preventing data loss.

Fix: `org_scope=True` on `get_refs`/`get_object`, threaded through
`get_commit_json`, `get_tree_json`, `_root_tree_of_commit`,
`_skill_trees_of_root`, and `materialize_tree` — walking an org commit needs
the org route on every hop, not just the first. Both org call sites now go
through one `_read_org_head()` helper.

ALSO FIXED

- `propose_skill` retries on conflict. When the org HEAD moves between the
  read and the CAS (another member proposing, an admin merging), it
  re-splices this one skill onto the NEW head and retries, bounded at 5
  attempts. Re-splicing rather than replaying the old root is what stops a
  concurrent proposal being dropped.
- An empty `actual` in a 409 means "the ref does not exist", not "here is a
  commit". `SyncConflict` normalizes "" to None in its constructor, and the
  personal push path redoes the CAS as a create instead of fetching "" as an
  object — which surfaced as the baffling `object  not found` (doubled
  space). This is what a client hits after switching sync planes, since
  `.sync_state` is not environment-scoped and carries a foreign head.

THE MOCK WAS THE REASON THIS SHIPPED

The test mock served org refs and org objects off the personal routes, so
21 org tests passed against a client that could not work against the real
plane. The mock now mirrors production: `/v1/sync/org/refs` and
`/v1/sync/org/objects/:hash` exist, org objects live in a separate scope,
and the personal routes refuse org content. Two existing tests had to be
corrected to assert against the org scope — they had been passing on the
mock's over-permissiveness.

Tests: 5 new (org head invisible on the personal route; second propose
splices and preserves the first; pull resolves a real org head; empty
`actual` -> None; push recovers from a stale cross-plane head). Verified
they FAIL without the fix: reverting just `_read_org_head` to the personal
route fails the second-propose test and the pre-existing splice test.
1278 passed / 0 failed across 54 suites via scripts/run_tests.sh.

Verified against PRODUCTION with a real org token, not just the mock:
- `pull_org_skills` -> head `sha256:1adf9333…`, materialized
  `software-development/gateway-gateway-connector` into the `_org` mirror
  (was head=None, updated=[]).
- A second `hermes sync propose` succeeded where it previously raised, and
  the org set afterwards contains BOTH skills with the new commit
  descending from the first.

* perf(desktop): scope background-throttling opt-out to live streaming

The process-wide disable-background-timer-throttling /
disable-backgrounding-occluded-windows switches plus a static
backgroundThrottling: false on every chat window pinned each renderer's
document.visibilityState to 'visible' for the life of the window. Every
visibility-gated backstop poll and clock tick in the renderer became an
always-on timer: an idle, minimized Hermes burned ~20% CPU around the
clock, on battery too.

Throttling is now a runtime dial. A small controller (stream-throttle.ts)
rides the merged hermes:active-work reports the quit guard already
receives: while any turn is in flight every chat window gets
setBackgroundThrottling(false) — a live answer keeps painting while
blurred, occluded, or minimized, exactly as before — and once all turns
settle (plus a 5s trailing window so the final flush lands at full
cadence) Chromium's default throttling returns and hidden windows go
quiet.

disable-renderer-backgrounding stays: process priority only, no timer
semantics, and it keeps hidden streaming fast.

* perf(desktop): let the hidden link-title window throttle

It loads arbitrary user-linked pages offscreen; unthrottled, a heavy page
burns full CPU for the window's whole lifetime. Title resolution rides
load events and main-process timers, which throttling doesn't touch.

* perf(desktop): stretch backstop polls while on battery

powerMonitor's AC/battery state is mirrored to the renderers
(store/power.ts) and visiblePoll quadruples its cadence on battery. Only
the safety-net refreshes slow down — event-driven refreshes and live
streaming are untouched.

* fix(file_ops): harden new-file umask chmod for portability

Follow-ups on top of NousResearch#70888's cherry-picked fix:

- Replace the $((0666 & ~0$u)) shell arithmetic with POSIX who-less
  'chmod "=rw"'. zsh (reachable via _find_bash's $SHELL fallback on
  bash-less hosts) parses leading-zero constants as decimal and silently
  chmods a garbage mode (e.g. 0210); the symbolic form is spec-identical
  across bash/dash/busybox-ash/zsh and degrades to mktemp's 0600
  (pre-fix behavior) rather than corrupting perms if chmod rejects it.
- Move the new-file chmod after the content stream so the temp file
  stays owner-writable while cat runs.
- Run the chmod on a '[ ! -e "$t" ]' check after cat instead of the
  stat/else branch, keeping the overwrite path untouched.
- Update the stale perms comment NousResearch#70856 called out (new files did NOT
  land with default umask perms pre-fix).
- Tests: select the atomic-write script by content instead of call
  order (the previous last-call capture only worked because the bare
  MagicMock's falsy-exit early return suppressed later execs), assert
  behavior at explicit umasks 0022/0002/0077 via parametrize, add an
  overwrite mode-preservation regression guard, and dedupe the
  real-subprocess env fake into make_real_subprocess_env() shared with
  TestSearchFilesFallbackHiddenPaths.

(webtecnica's email mapping already exists in contributors/emails/ on
current main; the PR's check-attribution red was stale-base only.)

# Conflicts:
#	tests/tools/test_file_operations.py

* chore: map jordan.mymail@gmail.com -> lxman in contributor directory

Attribution prerequisite for salvaging PR NousResearch#74522 (micro-compaction);
the contributor audit requires every commit-author email on main to
resolve to a GitHub login.

* fix(cli): stabilize custom provider identities

Use providers keys as the canonical custom-provider identity while accepting legacy bare keys, display-name slugs, bare custom fallback, and doubled custom prefixes across resolution, pickers, doctor, and runtime reverse lookup.

Co-authored-by: Bakhtier Sizhaev <bakhtiersizhaev@users.noreply.github.com>

* fix: migrate sibling custom-provider slug sites to custom_provider_slug

find_custom_provider_identity_by_model (runtime_provider.py:895,908) and
acp_adapter/server.py:149 still used the old f"custom:{_normalize_custom_provider_name(...)}"
pattern while the rest of the codebase migrated to custom_provider_slug.
For keyed providers whose display name differs from their config key, the
model-based reverse lookup would return custom:<display-name> instead of
the stable custom:<provider_key> identity every other code path returns.

Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>

* chore: sync homelab branch with upstream main

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Co-authored-by: hermes-seaeye[bot] <307254004+hermes-seaeye[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Ben Barclay <ben@nousresearch.com>
Co-authored-by: kshitijk4poor <kshitijkapoor0611@gmail.com>
Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
Co-authored-by: Bakhtier Sizhaev <bakhtiersizhaev@users.noreply.github.com>
Co-authored-by: Hermes Agent <hermes-agent@users.noreply.github.com>
@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Merged via #75345 — all 11 of your commits were cherry-picked with authorship preserved, so you show as the author of the feature in git log on main.

Thanks for an unusually well-documented contribution — the design doc, the honest cost accounting, and the measurement methodology made this a pleasure to review. Three follow-up commits rode along on the salvage:

  1. The summary marker is now assistant-role and an exchange spans a full agent turn — the user-role marker spliced between two user turns tripped the pre-request repair_message_sequence pass, which merged the marker into a real user message (metadata lost, cursor unrecoverable, summary text duplicated each turn).
  2. Defrag now re-summarizes only the rolling summary text and rewrites the marker in place — the previous version serialized the whole remaining middle including user turns, which contradicted the "your messages are never compacted" invariant.
  3. Micro markers carry a distinguishing tag so supersede/defrag can never drop or rewrite a batch-compaction marker, and compress() resets micro state — otherwise a stale rolling summary could claim cumulativeness over batch-summarized history it didn't contain.

The feature ships opt-in exactly as you designed it: compression.micro_compact: true.

jhjaggars pushed a commit to jhjaggars/hermes-agent that referenced this pull request Aug 1, 2026
* feat(desktop): Kanban — dashboard-parity board plugin on the SDK

The founding opt-in plugin (defaultEnabled: false): /kanban board + drawer,
live task_events via ctx.socket, ⌘-click bulk ops, auto-nudge dispatch,
collapsible lanes, board switcher, and prose activity — all pure SDK-consumer
work against plugins/kanban/dashboard/plugin_api.py. Backend: /boards totals
count live cards only.

* feat(desktop): SDK — useGrabScroll export + dogfood plugin touch-ups

* fix(desktop): roomier kanban create-task modal

The create form was cramped at max-w-md with a 60vh scroll cap. Widen to a
responsive w-[min(42rem,94vw)] and raise the scroll cap so the fields breathe.

* feat(kanban): scope boards to a project

Boards gain an optional project_id. When set, the board's default_workdir
mirrors the project's primary repo and every new task inherits the project —
a deterministic worktree + branch per task — unless it names its own. New
GET /projects; board create/patch/list carry project_id + resolved name; the
create dialog defaults its workspace to the board's and allows a per-task
path override. Desktop: "Board settings…" gains a project picker.

* feat(kanban): task effort estimate via the auxiliary model

An "Estimate" action asks the auto-routed auxiliary model for a rough token
count + complexity band (S/M/L) with a one-line rationale — tokens, not
dollars, since providers don't report cost reliably. POST /estimate (typed
title/body, for the create dialog) and POST /tasks/{id}/estimate (existing
cards) share one core. Desktop renders it inline ("~15k tok · Medium") with a
"makes a model call" disclaimer; SDK exports compactNumber.

* feat(kanban): talk to a running worker without a restart

A running worker now polls its comment thread and folds new operator notes
into the live turn via the OUT-OF-BAND steer channel (list_comments_after +
a heartbeat-driven bridge, watermarked so history isn't re-injected and the
worker's own notes are skipped). No block→comment→unblock dance. Desktop's
composer sends notes live ("delivered within a few seconds") with "Requeue
with note" as the restart option and a help tooltip.

* i18n(desktop): localize the kanban plugin across all four locales

Every user-facing string in the kanban plugin now routes through useI18n
(new t.kanban namespace) instead of hardcoded English — en, ja, zh, zh-hant
in lockstep (typecheck enforces parity). Column labels/help move out of the
COLUMN_META const (visual-only now) into i18n via columnLabel/columnHelp;
LOCKED_COLUMNS/ARC_TITLES/complexity copy likewise. Matches the rest of the
desktop app, which is fully localized.

* feat(desktop): plugin ctx.onDispose + self-disposing kanban bindApi

The plugin context only tracked contribution/socket disposers, so a plugin's
other side effects (store subscriptions) leaked across disable/re-enable. Add
ctx.onDispose(fn) — an arbitrary cleanup collected alongside the rest and run
on deactivate. bindApi now returns a disposer (unsubscribes its persisted-atom
listeners, closes the socket, drops the rest handle) and the kanban plugin
registers it via ctx.onDispose, so a toggle leaves nothing behind and never
duplicates listeners. Also DRYs the atom-persistence into one `persist` helper.

* i18n(desktop): move kanban to plugin-scoped ctx.i18n (per NousResearch#67303)

Now that NousResearch#67303 shipped the plugin-scoped i18n door, the kanban plugin ships
its OWN locale bundles via ctx.i18n.register instead of a core t.kanban
namespace — nothing added to core en.ts/ja/zh/zh-hant/types.ts. useKanban()
binds usePluginI18n('kanban') to the message SHAPE (one tiny generic) so
components keep their typed k.newTask / k.moveTo(label) access unchanged.

* fix(desktop): resolve contributed keybinds through the fallback chain

$bindings is seeded at module init from the actions known then, so an
action a plugin contributes later is absent from it. Both hotkey-hint
call sites did a raw bindings[id] lookup, so a plugin command rendered
with no combo in the palette and no hint on its tooltip even though the
dispatcher (which goes through $comboIndex → bindingsFor) fired it fine.

Route both through bindingsFor, the resolver that already falls back to
the stored override and the action's shipped defaults, and subscribe the
hint hook to the registry version so a late registration repaints.

Covered by behavior tests over the contributed-action contract: dispatch,
combo resolution, panel row, teardown, and the no-shadowing guard.

* feat(desktop): kanban new-task hotkey — the plugin command pattern

Creating a task was mouse-only: the header button, the empty state, or a
per-column hover +. Kanban now ships a real command, wired the way any
plugin should wire one.

One action id (kanban.newTask) registered into two areas: KEYBINDS_AREA
gives it dispatch plus a rebindable row in the shortcuts panel, and a
palette row whose `action` field points back at the same id so the live
combo renders as its hotkey hint. The handler is route-independent — it
parks the lane in $newTaskLane and navigates, so the page picks the
request up whether it was already mounted or is mounting for the first
time, then clears it so a remount can't reopen a dismissed dialog.

Default is mod+alt+n (⌘⌥N). mod+n and mod+shift+n are core built-ins a
plugin can't shadow; core uses alt only for the mod+alt+1…9 profile
slots, never with a letter, which leaves ⌘⌥<letter> free as the natural
namespace for plugin commands.

Label ships in the plugin's own locale bundles (en/ja/zh/zh-hant) via
ctx.i18n, so it localizes without a core en.ts edit.

* fix(desktop): track kanban lane phase in state, not a mirrored ref

main landed a lint rule banning refs mirrored from reactive values in an
effect — they lag a render and cause stale reads. The lane-collapse
override tracker did exactly that with a counts ref.

Hold the empty/non-empty signature in state instead. React bails out
when it's unchanged, so a poll where no lane's emptiness moved costs no
extra render, and the comparison always sees the current value.

* fix(desktop): open links clicked in the integrated terminal

Both of xterm's link paths activate through `window.open()`, which the
window's setWindowOpenHandler denies, so ⌘-clicking a URL did nothing but
log "Opening link blocked as opener could not be cleared" — and the OSC 8
path fronted that dead end with a raw confirm() dialog. Route both through
the desktop bridge, the path every other external link in the app takes.

⌘-click on macOS, Ctrl-click elsewhere, matching VS Code's integrated
terminal, Terminal.app, and iTerm2. A bare click stays with the selection so
a misclick on a URL can't launch a browser.

* fix(desktop): stop ⌥-click spraying cursor escapes into the terminal

⌥-drag is the app's force-selection gesture over mouse-mode TUIs, but
xterm's default alt-click-moves-cursor claims the same click and emits one
cursor left/right escape per column of travel. Shells that don't consume
them echo the raw `^[[D` burst into the buffer. One gesture, one meaning.

* fix(desktop): middle-click works on a real three-button mouse

Chromium on Windows and Linux answers a middle press inside a scroller by
starting the autoscroll pan, and the mouseup that ends the pan never becomes
an auxclick. Every surface carrying the gesture — tab strips, the session
list, the terminal rail — is a scroller, so middle-click only ever worked on
macOS, where autoscroll doesn't exist.

Arm on pointerdown, spend on the pointerup over the same element (press one
tab, release on another and nothing happens), and cancel the middle mousedown
on every press so the pan widget can't appear on a surface that owns the
button. One helper, four call sites.

* fix(desktop): closing the last main tab lands on New session

The workspace pane can't leave the tree, so "close the main tab" only ever had
one answer wired: shift the next stacked session in. With main as the only tab
there was nothing to shift and ⌘W dead-ended on the tab the user was looking
at.

closeWorkspaceTab is now the one answer for every entry point — stacked
session still wins, and with nothing stacked main drops to a fresh New session
draft. A blank draft and a full-page view stay no-ops: a blank draft already
IS the post-close state.

* fix(desktop): the main tab can be closed by gesture and menu

The tab strip decided the close gesture from the `uncloseable` flag, which the
workspace sets to keep its pane in the tree — so the one tab whose close now
does something couldn't be ⌘-clicked or middle-clicked, and its right-click
menu had no Close.

Read the gesture off the pane's registered closer instead, with the workspace
registering closeWorkspaceTab. An atom rather than a lookup, since that closer
comes from a wiring effect that lands after the strip's first paint.

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

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

* fix(desktop): the placeholder comes back when you clear the composer

Select-all + Cut emptied the text and left the composer blank — no draft,
no prompt. Delete had the same hole.

The placeholder is painted on `:empty`, and a cleared editor keeps a
scaffolding <br> so the contenteditable can't collapse to a sliver. Those
two facts collide: the moment the break lands the editor has a child,
`:empty` goes false, and the prompt never comes back.

CSS can't infer emptiness on its own either. A text node is invisible to
selectors, so `one<br>` and a lone `<br>` are the same shape — a structural
rule like `:has(> br:only-child)` paints the placeholder straight over the
user's text. The code that empties the editor is what knows, so it marks
the root and the condition reads `:is(:empty, [data-empty])`.

Both writers that reshape that root maintain the marker through one helper:
the normalizer, and renderComposerContents for a restored draft or an undo.
The message-edit composer shares the slot and the rule, so it takes the
same shared class instead of drifting on its own copy.

NousResearch#74815 fixed the draft this stashed; the placeholder is a separate seam.

* feat(tui): one token type for everything deferred in the composer

A collapsed paste and an attached image are the same idea: a `[[ … ]]`
marker sitting in the input line that stands in for a payload resolved at
submit. Model both as ComposerToken and give them one expander.

Image tokens resolve to nothing — the gateway already holds the file in
attached_images — so expandTokens eats an adjacent space to avoid leaving
a gap mid-sentence. nextImageIndex never reuses an index after a delete,
or two files would collide on one label.

* feat(tui): attach images inline at the cursor, delete the token to unattach

Every attach path now drops an `[[ Image N ]]` token where you are typing:
drag-drop, clipboard (bracketed and hotkey), /image, /paste. The composer
owns clipboard attach directly instead of calling back out to useMainApp.

Deleting the token is how you unattach — there is no second control.
updateInput is the one choke point every keystroke passes through, so
syncTokens reconciles there and detaches anything erased. That also fixes
a stale image riding along on the next unrelated turn.

Tokens and the input line get refs alongside state: paste-then-immediately
-Enter submits before React has re-rendered, and the submit path has to see
the token that was just added.

* fix(tui): stop announcing attachments outside the composer

The token in the input line is the whole receipt. Drop the notices that
duplicated it somewhere the user was not looking: the drag-drop and
clipboard sys() lines, and the attachedImageNotice / "detected file: X"
activity rows above the status bar.

attachedImageNotice and imageTokenMeta have no callers left.

* fix(sync): read org state from the org endpoints, not the personal ones (NousResearch#75237)

Org-shared skills were unusable past the first propose. Three defects, one
root cause plus two that it masked.

ROOT CAUSE — org reads went to the personal endpoint.

`SyncClient.get_refs()` / `get_object()` only ever called `/v1/sync/refs`
and `/v1/sync/objects/:hash`. Those routes are hard-scoped server-side to
the token's own owner, so asking them for `refs/org/<id>/` returns the
caller's PERSONAL refs rather than an error, and org objects 404. Both org
call sites read org state through them:

- `pull_org_skills` resolved head=None for a populated org and reported
  `{"ok": true, "head": null, "updated": []}` — org skills silently never
  arrived, which reads as "my org has no skills" rather than as a failure.
- `propose_skill` resolved base_head=None, so the FIRST propose to an org
  succeeded by accident (`from: null` happened to be correct) and EVERY
  later one CAS'd against a head it had never seen -> 409 -> a raw
  `SyncConflict` traceback. Worse, it built its root from an empty skill
  map, so a landed CAS would have REPLACED the org set rather than splicing
  into it — the 409 was accidentally preventing data loss.

Fix: `org_scope=True` on `get_refs`/`get_object`, threaded through
`get_commit_json`, `get_tree_json`, `_root_tree_of_commit`,
`_skill_trees_of_root`, and `materialize_tree` — walking an org commit needs
the org route on every hop, not just the first. Both org call sites now go
through one `_read_org_head()` helper.

ALSO FIXED

- `propose_skill` retries on conflict. When the org HEAD moves between the
  read and the CAS (another member proposing, an admin merging), it
  re-splices this one skill onto the NEW head and retries, bounded at 5
  attempts. Re-splicing rather than replaying the old root is what stops a
  concurrent proposal being dropped.
- An empty `actual` in a 409 means "the ref does not exist", not "here is a
  commit". `SyncConflict` normalizes "" to None in its constructor, and the
  personal push path redoes the CAS as a create instead of fetching "" as an
  object — which surfaced as the baffling `object  not found` (doubled
  space). This is what a client hits after switching sync planes, since
  `.sync_state` is not environment-scoped and carries a foreign head.

THE MOCK WAS THE REASON THIS SHIPPED

The test mock served org refs and org objects off the personal routes, so
21 org tests passed against a client that could not work against the real
plane. The mock now mirrors production: `/v1/sync/org/refs` and
`/v1/sync/org/objects/:hash` exist, org objects live in a separate scope,
and the personal routes refuse org content. Two existing tests had to be
corrected to assert against the org scope — they had been passing on the
mock's over-permissiveness.

Tests: 5 new (org head invisible on the personal route; second propose
splices and preserves the first; pull resolves a real org head; empty
`actual` -> None; push recovers from a stale cross-plane head). Verified
they FAIL without the fix: reverting just `_read_org_head` to the personal
route fails the second-propose test and the pre-existing splice test.
1278 passed / 0 failed across 54 suites via scripts/run_tests.sh.

Verified against PRODUCTION with a real org token, not just the mock:
- `pull_org_skills` -> head `sha256:1adf9333…`, materialized
  `software-development/gateway-gateway-connector` into the `_org` mirror
  (was head=None, updated=[]).
- A second `hermes sync propose` succeeded where it previously raised, and
  the org set afterwards contains BOTH skills with the new commit
  descending from the first.

* perf(desktop): scope background-throttling opt-out to live streaming

The process-wide disable-background-timer-throttling /
disable-backgrounding-occluded-windows switches plus a static
backgroundThrottling: false on every chat window pinned each renderer's
document.visibilityState to 'visible' for the life of the window. Every
visibility-gated backstop poll and clock tick in the renderer became an
always-on timer: an idle, minimized Hermes burned ~20% CPU around the
clock, on battery too.

Throttling is now a runtime dial. A small controller (stream-throttle.ts)
rides the merged hermes:active-work reports the quit guard already
receives: while any turn is in flight every chat window gets
setBackgroundThrottling(false) — a live answer keeps painting while
blurred, occluded, or minimized, exactly as before — and once all turns
settle (plus a 5s trailing window so the final flush lands at full
cadence) Chromium's default throttling returns and hidden windows go
quiet.

disable-renderer-backgrounding stays: process priority only, no timer
semantics, and it keeps hidden streaming fast.

* perf(desktop): let the hidden link-title window throttle

It loads arbitrary user-linked pages offscreen; unthrottled, a heavy page
burns full CPU for the window's whole lifetime. Title resolution rides
load events and main-process timers, which throttling doesn't touch.

* perf(desktop): stretch backstop polls while on battery

powerMonitor's AC/battery state is mirrored to the renderers
(store/power.ts) and visiblePoll quadruples its cadence on battery. Only
the safety-net refreshes slow down — event-driven refreshes and live
streaming are untouched.

* fix(file_ops): harden new-file umask chmod for portability

Follow-ups on top of NousResearch#70888's cherry-picked fix:

- Replace the $((0666 & ~0$u)) shell arithmetic with POSIX who-less
  'chmod "=rw"'. zsh (reachable via _find_bash's $SHELL fallback on
  bash-less hosts) parses leading-zero constants as decimal and silently
  chmods a garbage mode (e.g. 0210); the symbolic form is spec-identical
  across bash/dash/busybox-ash/zsh and degrades to mktemp's 0600
  (pre-fix behavior) rather than corrupting perms if chmod rejects it.
- Move the new-file chmod after the content stream so the temp file
  stays owner-writable while cat runs.
- Run the chmod on a '[ ! -e "$t" ]' check after cat instead of the
  stat/else branch, keeping the overwrite path untouched.
- Update the stale perms comment NousResearch#70856 called out (new files did NOT
  land with default umask perms pre-fix).
- Tests: select the atomic-write script by content instead of call
  order (the previous last-call capture only worked because the bare
  MagicMock's falsy-exit early return suppressed later execs), assert
  behavior at explicit umasks 0022/0002/0077 via parametrize, add an
  overwrite mode-preservation regression guard, and dedupe the
  real-subprocess env fake into make_real_subprocess_env() shared with
  TestSearchFilesFallbackHiddenPaths.

(webtecnica's email mapping already exists in contributors/emails/ on
current main; the PR's check-attribution red was stale-base only.)

# Conflicts:
#	tests/tools/test_file_operations.py

* chore: map jordan.mymail@gmail.com -> lxman in contributor directory

Attribution prerequisite for salvaging PR NousResearch#74522 (micro-compaction);
the contributor audit requires every commit-author email on main to
resolve to a GitHub login.

* fix(cli): stabilize custom provider identities

Use providers keys as the canonical custom-provider identity while accepting legacy bare keys, display-name slugs, bare custom fallback, and doubled custom prefixes across resolution, pickers, doctor, and runtime reverse lookup.

Co-authored-by: Bakhtier Sizhaev <bakhtiersizhaev@users.noreply.github.com>

* fix: migrate sibling custom-provider slug sites to custom_provider_slug

find_custom_provider_identity_by_model (runtime_provider.py:895,908) and
acp_adapter/server.py:149 still used the old f"custom:{_normalize_custom_provider_name(...)}"
pattern while the rest of the codebase migrated to custom_provider_slug.
For keyed providers whose display name differs from their config key, the
model-based reverse lookup would return custom:<display-name> instead of
the stable custom:<provider_key> identity every other code path returns.

Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>

* feat(agent): per-turn micro-compaction to amortize context compression

Batch compaction pauses a session for one large summarization once the
window fills. Micro-compaction spreads that cost out: after each completed
turn, `finalize_turn` folds the single oldest un-absorbed exchange
(assistant message plus its tool results) into a rolling summary, so the
work happens in small increments during post-turn idle time instead of one
long stall.

Mechanics:
  - a cursor tracks the first message not yet absorbed, recovered from the
    transcript's last summary marker when in-memory state is unavailable;
  - protected head and tail windows are never touched, so the system prompt
    and recent turns stay verbatim;
  - the absorbed span is replaced by a marker carrying the usual
    `_compressed_summary` metadata, so resume, handoff and `/compress`
    treat it exactly like a batch summary;
  - `archive_and_compact` keeps the session DB in step, otherwise the
    append-only flush would leave the original rows active and a resume
    would double-load both summary and originals;
  - when the rolling summary itself passes a token threshold it is
    defragged: re-summarized in one shot and the cursor jumps to the tail;
  - an exchange the summarizer can't handle is retried a bounded number of
    times, then skipped, so one poison exchange can't stall every turn.

Keep only the newest summary marker. The rolling summary is cumulative, so
each marker already contains everything the previous ones held; leaving them
stacked near-duplicate copies of the same text, each with its own heading and
end-marker scaffolding, and the transcript grew on every turn instead of
shrinking. Measured over six turns on a 12-exchange conversation with tool
output: 4104 -> 4797 tokens before, 4104 -> 2572 after.

Off switch: `compression.micro_compact: false` (default on).

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

* docs(agent): explain micro-compaction

Covers what it does, the head/tail protection, the cursor and rolling
summary, defrag, how the session DB is kept in step, and the failure
paths. States the tradeoff up front: compression cost is amortized across
turns, at the price of older detail becoming summarized earlier in a
session than batch-only compaction would.

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

* docs(agent): state that user turns are never micro-compacted

`_find_one_exchange`'s docstring described an exchange as "(optional) user
message + assistant message + its tool results", but the walk skips past
user messages and starts at the assistant, so user turns are never absorbed
into the rolling summary.

The code is right and the docstring was wrong. Assistant output is largely
an account of what was done and survives summarising with little loss. The
user's messages are the intent everything else is derived from and cannot be
reconstructed from the work that followed — paraphrasing "use the existing
helper, don't add a new one" into a summary is how an agent ends up doing
the opposite six turns later. They are also cheap: a prompt is normally a
tiny fraction of what one tool result costs.

Correct the docstring, document the property (and its cost — a floor on how
small the middle can get, since user turns accumulate), and add a test so it
stays deliberate.

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

* docs(agent): drop a fabricated issue reference

The `_micro_compact` docstring cited "NousResearch#82483" for the resume double-load
problem. No such issue exists — the repository's highest number is 74323,
so the reference was invented rather than looked up.

The reasoning it was attached to is correct and stays: the session flush is
append-only, so an in-memory splice alone leaves the original rows active
and a resume loads both the summary and the messages it replaced. Only the
citation was wrong.

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

* feat(agent): token telemetry for micro-compaction

The existing log line reports message counts, which is the least
informative number available here: absorbing one tool-heavy exchange can
drop hundreds of tokens while moving the count by one. There was no way to
answer "is this actually helping?" from a real session.

Emit one content-free JSON line per pass, in the same shape as the batch
compaction telemetry: before/after tokens, the delta, the size of the
absorbed exchange, the rolling summary size, duration, and running
per-session totals so a whole run can be read off the last line. No
transcript content rides along.

Add scripts/micro_compaction_report.py to aggregate those lines into
passes, outcome mix, net tokens saved, mean exchange size and durations,
with an optional per-session breakdown.

Measuring it immediately surfaced something worth documenting: the first
pass in a session normally *costs* tokens. The summary marker carries a
fixed ~400 tokens of scaffolding, paid on pass one against a single
absorbed exchange. From pass two the marker is replaced rather than added,
so the overhead is already paid and each exchange is close to pure saving.
Break-even is typically the second or third pass. Tests cover the
telemetry contract, the cumulative totals, and that first-pass/later-pass
shape so nobody reads a single turn and concludes it made things worse.

The estimator costs ~5 ms at 600 messages and ~20 ms at 1200, taken twice
per pass, post-turn — and only once an exchange is actually in hand, so
turns that no-op early pay nothing.

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

* feat(agent): report context occupancy, not just tokens saved

Tokens saved is the wrong headline for this feature. Micro-compaction is
not an efficiency optimisation — the same summarization work happens either
way. What it buys is (a) that work amortized across turns instead of one
stall, and (b) a window kept low enough that a session runs much further
before needing a hard compaction at all.

Neither shows up in "net tokens saved". A session can save nothing on paper
and still be a clear win on both counts.

So the telemetry now carries occupancy: tokens_after as a share of the
compaction threshold, plus the threshold and resolved window it was
computed from. That is the number that says whether a session has headroom
left. The report leads with it, and cross-references the batch
`compression_attempt` lines already in the log so it can show how often the
long pause actually fired — ideally never.

Occupancy is read from the cached threshold only. The public
`threshold_tokens` property resolves lazily and can issue a synchronous
/models probe (NousResearch#32221); telemetry must never be the thing that blocks a
turn, so an unresolved window reports null. In practice a pass has already
resolved it via the tail calculation, so the field is populated. A test
pins the no-forcing behaviour directly against the emitter.

The report is pure ASCII: `scripts/check_subprocess_stdin.py` currently
dies on a cp1252 console before printing its results, and a diagnostic tool
that crashes on the platform it is diagnosing is worse than no tool.

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

* fix(agent): derive the micro-compaction cursor from the spliced list

The cursor was set to the pre-splice `exchange_end`. A splice collapses the
absorbed span -- an assistant plus its tool results, often four or more
messages -- into a single marker, and may also drop a superseded marker
further back, so every index after it shifts.

The stale cursor therefore overshot, landing inside a *later* exchange's
tool group. The next pass's `_find_one_exchange` walked forward from there
to the following assistant, so the exchange it had landed inside was never
absorbed at all. On tool-bearing conversations micro-compaction was
silently doing roughly half the work it should.

Traced on a 3-tool-per-exchange transcript: the cursor sat at index 6 when
the marker was at 2, and the message count stalled at 32 instead of
continuing to 28.

Derive the cursor from the marker's actual position in the spliced result
instead, which is self-correcting regardless of how much the splice moved.
Apply it on the defrag path too, which had the same staleness.

Found by a randomized long-horizon harness (480 conversation shapes x 25
passes, varying tool-group sizes and summarizer failure modes) asserting
structural and progress invariants after every pass. Existing tests missed
it because their fixtures have no tool results, so the absorbed span is one
message and nothing shifts. The regression test uses tool groups.

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

* docs(agent): state the real cost, and make model choice the main knob

Three corrections, all from measuring a real 3.5 hour session rather than
reasoning about the design.

"During the idle moment after a response" was wrong. A pass is a real call
to the compression model at the end of a turn: the answer has streamed, but
the turn does not close until it finishes. Measured 2 to 37 seconds, median
around 31, on a small local model. Say so.

Add the choice of `auxiliary.compression` model as its own section, because
it dominates everything else here. A pass sends only a few thousand tokens
but runs every turn, so latency is felt repeatedly, and reasoning models are
a poor fit -- merging one exchange into a summary is mechanical work, and a
thinking model spends reasoning tokens on it for no benefit. Two measured
data points are given as illustrations of the shape, explicitly not as
recommendations: the right answer depends on the operator's hardware.

Add what a working session actually looks like: occupancy climbing to ~22%
and flattening (equilibrium -- 4,841 tokens added between the last two
passes, 4,395 reclaimed), zero batch compactions, and reclamation only
ramping after the tail budget is crossed. Also state the cost in the same
breath rather than burying it.

Frame the feature as a tuning option rather than a win: it lets you choose
how the compression cost is distributed and which model pays it. It is not
a magic bullet and the docs should not imply otherwise.

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

* fix(agent): do not destroy compacted history when a session resumes

The rolling summary lives only in memory. A resumed session starts with an
empty one while the marker carrying every previously absorbed exchange is
still in the transcript. The first pass after a resume therefore built a
marker from a single exchange and superseded the marker holding the entire
history -- silently discarding everything micro-compaction had accumulated.

This was introduced by the supersede fix. Before it, markers piled up
wastefully, but nothing was ever lost.

Two changes, so a single failure cannot lose data:

Rehydrate. When the cursor is recovered by scanning the transcript -- the
resume path -- also recover the rolling summary from that marker, so the
next pass merges into the existing history instead of replacing it.
Extraction uses rfind for the heading because SUMMARY_PREFIX references the
heading text itself, so the first occurrence is inside the preamble.

Gate superseding. Earlier markers are dropped only when this pass's summary
is demonstrably cumulative, i.e. the rolling summary was non-empty going in.
If rehydration ever fails, the pass keeps both markers: wasteful, but the
history survives.

Tests cover the resume path, the failed-rehydration fallback, and the
round trip of a summary through a marker.

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

* fix(agent): ship micro-compaction opt-in, not default-on

Review raised whether default-on can be reconciled with the prompt-cache
contract in AGENTS.md, which permits mutating past context only for context
compression and treats per-conversation caching as sacred. It cannot, and the
codebase already says so in its own words.

A micro-compaction pass rewrites already-sent history, so it invalidates the
cached prefix every turn rather than at an episodic boundary. That is the exact
cost the proactive prune gates against: `proactive_prune_min_reclaim_tokens`
exists, per its own config comment, to keep rewrites to "one big episodic break
instead of a tiny break every tool iteration." Micro-compaction has no
equivalent gate -- one exchange per turn means one break per turn, by design.

Default to off. An operator who wants the amortized stall can opt in with
`compression.micro_compact: true` and accept the tradeoff knowingly; nobody
inherits a per-turn cache break from installing an update.

Also register the key in config_defaults so it is discoverable and picked up by
the update path's new-options check -- it was previously read by agent_init but
declared nowhere -- and document the cache cost in docs/micro-compaction.md
instead of only the benefit. The measurements behind the feature (occupancy
plateau, zero batch compactions) never priced cache invalidation, and the doc
now says which numbers a reader would need to measure to justify enabling it.

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

* feat(agent): make the micro-compaction cadence configurable

The on/off switch was the only knob. A pass fired after every completed turn,
absorbed exactly one exchange, and there was no way to ask for less. Since a
pass is also what breaks the prompt-cache prefix, "how often does it run" and
"how often do I pay a cache break" are the same question, and it had no answer.

Add `compression.micro_compact_every_n_turns` (default 1, clamped to >= 1). At 1
the behaviour is what it was; at 5 you get a fifth of the breaks and a fifth of
the reclaim rate. The counter advances per invocation rather than per committed
pass, so a turn that finds nothing to absorb still moves the cadence along and
cannot wedge it, and a bogus 0 or negative degrades to "every turn" instead of
silently disabling compaction.

Also expose `micro_compact_defrag_threshold_tokens`, which has been a hardcoded
attribute on the compressor with no path from config since it was added.

This does not give micro-compaction the prune's reclaim-size gate -- a pass
still commits whatever the single absorbed exchange saved. It makes the break
frequency tunable, which reaches the same end by absorbing less rather than by
waiting for a bigger win. The docs now say that plainly, including that a
reclaim threshold is the obvious follow-up and does not exist yet.

Tests cover the skip-until-due window, the cursor and prefix staying untouched
on skipped turns, the clamp, and that the feature is off unless enabled.

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

* fix(agent): make micro-compaction alternation-safe and defrag user-preserving

Two integration bugs found during review of NousResearch#74522, both confirmed with
empirical probes against the production message-repair path:

1. Alternation: the summary marker was role="user" and an exchange was a
   single assistant+tools group, so splicing between two user turns produced
   user -> marker(user) -> user. The pre-request repair_message_sequence pass
   (conversation_loop.py, runs before EVERY API call) then merged the marker
   into the neighbouring real user message: metadata gone, cursor
   unrecoverable on resume, and the summary text duplicated into the
   transcript on every later pass (the transcript GREW every turn).
   Fix: an exchange is now a full agent turn (assistant + tools + follow-up
   assistant iterations, bounded by user messages), the marker is
   assistant-role, and superseding an old marker deliberately merges the two
   adjacent real user turns (plain-text \n\n-join, identical to repair
   pass 2) so the returned transcript is alternation-valid by construction.
   Probe result: repairs 0 (was 2), marker survives, no summary leakage.

2. Defrag destroyed user messages: _defrag_rolling_summary serialized the
   whole remaining middle (user turns included) and spliced it away —
   8 of 10 user prompts destroyed in one pass, contradicting the feature's
   "your messages are never compacted" invariant. Fix: defrag now
   re-summarizes only the rolling summary TEXT and rewrites the marker
   content in place; transcript shape, cursor, and user turns untouched.
   Probe result: 10 of 10 user prompts survive.

Also: marker provenance is now COMPRESSED_SUMMARY_HAS_USER_TURN_KEY=False —
micro markers absorb only assistant/tool content (NousResearch#64650 invariant), and
real user turns remain in the transcript for provenance detection.

Adds 5 regression tests (repair-pass integration, alternation on
multi-iteration tool turns, defrag user survival, defrag input scope,
marker provenance); updates the two existing tests and the design doc to
the corrected semantics. 28 tests pass.

* fix(agent): harden the finalize-turn micro-compaction gate against duck-typed compressors

tests/run_agent/test_proactive_prune_loop_wiring.py builds agents with a
MagicMock compressor; getattr(mock, '_micro_compact_enabled', False)
returns a truthy auto-attribute, so the hook called _micro_compact on the
mock and spliced its (empty-iterating) return over the transcript —
wiping all messages before persist (CI slice 7/8 failure).

Gate now requires _micro_compact_enabled is True, a callable
_micro_compact, and a non-empty list result before touching messages.
Same hardening protects production plugin context engines that don't
subclass ContextCompressor.

* fix(agent): protect batch-compaction markers from micro supersede/defrag

Phase 2 review findings on the salvage branch:

C1 (critical): batch and micro summary markers share
COMPRESSED_SUMMARY_METADATA_KEY, and compress() never reset micro state.
After micro absorbed exchanges 1..k, a batch compaction summarizing
1..m (m>k) could fire; the next micro pass's supersede then dropped the
batch marker (whose content the stale rolling summary does NOT contain)
and archive_and_compact immediately made the loss durable. Defrag had
the same hazard: it rewrote "the newest marker" even if that was a
batch marker. Empirically confirmed with a probe (batch marker content
destroyed in one pass).

Fix, three parts:
- Micro-created markers now carry MICRO_COMPACT_MARKER_KEY; supersede
  and defrag only ever touch micro-tagged markers. Rehydration in
  _resolve_compact_cursor tags the marker it absorbs (containment
  proof), which safely covers adopting a batch marker as the new
  rolling base after a reset.
- compress() success path resets micro rolling summary/cursor state so
  a stale summary can never claim cumulativeness over a batch marker.
- Regression tests for both directions plus the reset.

W4: _splice_micro_compact_result no longer strips _db_persisted stamps
from surviving messages. Micro archives in place under the SAME session
id (unlike batch's child-session rotation, NousResearch#57491), so surviving stamps
are accurate; stripping them meant an archive_and_compact failure left
every previously-persisted message unstamped and the next append-only
flush re-inserted them all as duplicate active rows.

W5: finalize_turn micro gate now checks agent._persist_disabled —
persistence-isolated fork agents (background review) must not burn an
aux call per review turn, and must never archive_and_compact the
canonical session rows if their compressor ever gains a DB binding.

W1: _serialize_one_exchange now delegates to _serialize_for_summary
(was a ~70-line near-verbatim copy; one serializer, one place to fix).

S4: _find_one_exchange boundary guard rejects only assistant/tool
boundaries (the actual alternation hazard) instead of requiring user —
a stray mid-list system/injected message can no longer wedge the
cursor forever.

5 new regression tests; 38 micro/prune tests, 400 compression-suite
tests, 61 finalize/persist tests pass; ruff clean.

* fix(slack): trust adapter routing after stripping self mention

* fix: trim identity prompt — remove jargon, tighten directive

Follow-up to PR NousResearch#70238. Remove 'free-response channel' and
'authorization' jargon from the model-facing prompt. Collapse the
triple-negative 'do not ask / do not reject / do not stay silent'
into a single directive. ~70 tokens vs ~175 in the contributor's
version, same semantics.

* fix(desktop): preserve voice stop across speech setup

* feat(dashboard): add resume loading overlay helpers

Extract overlay visibility helpers so the chat resume wait notice can be
tested without mounting ChatPage.

* feat(dashboard): show wait notice while resumed chat history loads

Cover the blank TUI + blinking-cursor window on session resume, then hide
the notice as soon as the first real PTY payload arrives so history can
stream in visibly.

* fix(dashboard): gate resume hydration on sanitized PTY payload

The resume wait notice cleared on the first nonempty raw PTY frame, but
the terminal is written sanitizer.next(text). The sanitizer collapses an
erase-only, all-newline, or partial-CSI resume frame to "", so a control-
only first frame hid the notice while xterm was still blank.

Gate hydration completion on the rendered payload actually written to the
terminal, and cover the control-only-first-frame case with a regression
test over the real sanitizer.

Co-authored-by: teknium1 <teknium@nousresearch.com>

* fix(desktop): restore native OAuth tokens after restart

* test(desktop): guard native OAuth parser boundary

* fix(desktop): preserve non-Error OAuth load failures

* fix(desktop): log native token decryption failures

* test(desktop): cover native OAuth persistence path

* fix(desktop): harden native token store handling

* fix(desktop): reject empty encrypted token payloads

* fix(desktop): redact gateway credentials from token logs

* fix(dashboard): set headers for JWKS requests

* fix(dashboard): set headers for Nous JWKS requests

The Nous PyJWKClient was constructed without explicit headers, while the
self_hosted provider already sends Accept + User-Agent. Without them the
Portal WAF can block the JWKS fetch, so the same failure mode remained for
the Nous dashboard-auth route. Mirror the self_hosted fix and add a
constructor-contract regression test.

* fix(cron): set headers for chronos JWKS requests

The chronos cron-fire verifier constructed PyJWKClient without explicit
headers, so its JWKS fetch to the NAS portal hit the same WAF 403 the
dashboard-auth providers already guard against. It reaches the same
portal issuer, so it's the same bug class — mirror the fix here and add
a constructor-contract regression test.

Co-authored-by: James Hodgkinson <james@terminaloutcomes.com>

* chore(contributors): map james@terminaloutcomes.com -> yaleman

* Portal free user vision fix + flux3 polling improvements (NousResearch#75448)

* flux3 polling improvments

* poll gap to 4s

* back to 5s

* vision model fix

* minor fix

* fix: keep queued paste payloads atomic (NousResearch#74797)

Co-authored-by: eloklam <22125285+eloklam@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>

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

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

* feat(models): add deepseek/deepseek-v4-flash-0731 to Nous portal and OpenRouter catalogs

Dated snapshot of deepseek-v4-flash, live on both provider endpoints
(verified via OpenRouter /api/v1/models and Nous portal /v1/models).
Context (1M via deepseek-v4-flash prefix match), reasoning stale-timeout
floor (600s), and pricing (official_models_api live billing on both
routes) all resolve without new entries. model-catalog.json regenerated
via scripts/build_model_catalog.py.

* fix(desktop): statusbar off by default

The statusbar is now opt-in. Existing users with a stored preference
keep their choice; new users get a clean bottom edge. The way back is
the view.toggleStatusbar keybind or the ⌘K row, unchanged.

* ci: keep homelab sync checks focused

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Co-authored-by: hermes-seaeye[bot] <307254004+hermes-seaeye[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Ben Barclay <ben@nousresearch.com>
Co-authored-by: kshitijk4poor <kshitijkapoor0611@gmail.com>
Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
Co-authored-by: Bakhtier Sizhaev <bakhtiersizhaev@users.noreply.github.com>
Co-authored-by: Michael Jordan <jordan.mymail@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: KCAYAAI <KCAYAAI@users.noreply.github.com>
Co-authored-by: Carbon <abdulsalamalotaibi86@gmail.com>
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
Co-authored-by: teknium1 <teknium@nousresearch.com>
Co-authored-by: Doud-FR <59610009+Doud-FR@users.noreply.github.com>
Co-authored-by: James Hodgkinson <james@terminaloutcomes.com>
Co-authored-by: rob-maron <132852777+rob-maron@users.noreply.github.com>
Co-authored-by: Yi Lok Enoch Lam <enochlam2002@gmail.com>
Co-authored-by: eloklam <22125285+eloklam@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
Co-authored-by: Hermes Agent <hermes-agent@users.noreply.github.com>
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
Attribution prerequisite for salvaging PR NousResearch#74522 (micro-compaction);
the contributor audit requires every commit-author email on main to
resolve to a GitHub login.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…eserving

Two integration bugs found during review of NousResearch#74522, both confirmed with
empirical probes against the production message-repair path:

1. Alternation: the summary marker was role="user" and an exchange was a
   single assistant+tools group, so splicing between two user turns produced
   user -> marker(user) -> user. The pre-request repair_message_sequence pass
   (conversation_loop.py, runs before EVERY API call) then merged the marker
   into the neighbouring real user message: metadata gone, cursor
   unrecoverable on resume, and the summary text duplicated into the
   transcript on every later pass (the transcript GREW every turn).
   Fix: an exchange is now a full agent turn (assistant + tools + follow-up
   assistant iterations, bounded by user messages), the marker is
   assistant-role, and superseding an old marker deliberately merges the two
   adjacent real user turns (plain-text \n\n-join, identical to repair
   pass 2) so the returned transcript is alternation-valid by construction.
   Probe result: repairs 0 (was 2), marker survives, no summary leakage.

2. Defrag destroyed user messages: _defrag_rolling_summary serialized the
   whole remaining middle (user turns included) and spliced it away —
   8 of 10 user prompts destroyed in one pass, contradicting the feature's
   "your messages are never compacted" invariant. Fix: defrag now
   re-summarizes only the rolling summary TEXT and rewrites the marker
   content in place; transcript shape, cursor, and user turns untouched.
   Probe result: 10 of 10 user prompts survive.

Also: marker provenance is now COMPRESSED_SUMMARY_HAS_USER_TURN_KEY=False —
micro markers absorb only assistant/tool content (NousResearch#64650 invariant), and
real user turns remain in the transcript for provenance detection.

Adds 5 regression tests (repair-pass integration, alternation on
multi-iteration tool turns, defrag user survival, defrag input scope,
marker provenance); updates the two existing tests and the design doc to
the corrected semantics. 28 tests pass.
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
Attribution prerequisite for salvaging PR NousResearch#74522 (micro-compaction);
the contributor audit requires every commit-author email on main to
resolve to a GitHub login.
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
…eserving

Two integration bugs found during review of NousResearch#74522, both confirmed with
empirical probes against the production message-repair path:

1. Alternation: the summary marker was role="user" and an exchange was a
   single assistant+tools group, so splicing between two user turns produced
   user -> marker(user) -> user. The pre-request repair_message_sequence pass
   (conversation_loop.py, runs before EVERY API call) then merged the marker
   into the neighbouring real user message: metadata gone, cursor
   unrecoverable on resume, and the summary text duplicated into the
   transcript on every later pass (the transcript GREW every turn).
   Fix: an exchange is now a full agent turn (assistant + tools + follow-up
   assistant iterations, bounded by user messages), the marker is
   assistant-role, and superseding an old marker deliberately merges the two
   adjacent real user turns (plain-text \n\n-join, identical to repair
   pass 2) so the returned transcript is alternation-valid by construction.
   Probe result: repairs 0 (was 2), marker survives, no summary leakage.

2. Defrag destroyed user messages: _defrag_rolling_summary serialized the
   whole remaining middle (user turns included) and spliced it away —
   8 of 10 user prompts destroyed in one pass, contradicting the feature's
   "your messages are never compacted" invariant. Fix: defrag now
   re-summarizes only the rolling summary TEXT and rewrites the marker
   content in place; transcript shape, cursor, and user turns untouched.
   Probe result: 10 of 10 user prompts survive.

Also: marker provenance is now COMPRESSED_SUMMARY_HAS_USER_TURN_KEY=False —
micro markers absorb only assistant/tool content (NousResearch#64650 invariant), and
real user turns remain in the transcript for provenance detection.

Adds 5 regression tests (repair-pass integration, alternation on
multi-iteration tool turns, defrag user survival, defrag input scope,
marker provenance); updates the two existing tests and the design doc to
the corrected semantics. 28 tests pass.
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 comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint needs-decision Awaiting maintainer decision before any implementation P0 Critical — data loss, security, crash loop sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants