Commit Graph

547 Commits

Author SHA1 Message Date
kshitijk4poor
1e86b5240d refactor(compaction): name the pre-LLM summary skip in the fallback log
_fallback_summary_for_window logged "Feasibility skip" and the compress()
comment said "feasibility skip", but the flag is now also set when the
summary model is benched. Say "Pre-LLM summary skip" so the log matches
both causes. Wording only; no test asserts the old text.
2026-10-06 12:03:52 +05:30
kshitijk4poor
8fd87bd351 refactor(compaction): refresh stale skip/anti-thrash wording
record_completed_compaction carried two overlapping comments and logged
"via pre-LLM feasibility skip" although the path now also covers the
benched summary model; the anti-thrash comment still said it drops
"counters" when only the ineffective count is reset. Split the long
streak/skip tuple assignment in the stall-retry path and drop the
`is True` hedge (the flag is always a bool on the built-in compressor).
2026-10-06 12:03:52 +05:30
kshitijk4poor
1a8fb8c917 refactor(compaction): own the bench probe deadline load in PreLlmSkipMixin
The mixin already owns _set_fallback_probe_at and the model-config key;
the matching load lived inline in bind_session_state, which forced
context_compressor.py to import the key. Move it to
_load_fallback_probe_at() and make the key private to the mixin module.
2026-10-06 12:03:52 +05:30
kshitijk4poor
364319d956 fix(compaction): carry the bench probe deadline across rotating compaction
Rotating compaction (compression.in_place: false) calls on_session_start
with boundary_reason="compression"; bind_session_state zeroes
_fallback_probe_at and reloads it from the fresh child row, which has no
model-config key. The fallback streak was carried but the deadline was
not, so the benched summary model was re-benched with a new window on
every rotation and never probed again. Capture the deadline before the
rebind and persist it onto the child row alongside the streak.
2026-10-06 12:03:52 +05:30
kshitijk4poor
d6daf4bdd2 refactor(compaction): move pre-LLM summary skips into context_compressor_prellm
Code-health ratchet: agent/context_compressor.py grew past its cap on main.
Move _feasibility_skip, _fallback_streak_skip and _set_fallback_probe_at
(plus their constants) byte-identical into a PreLlmSkipMixin in the new
agent/context_compressor_prellm.py sibling, matching the existing
SummaryDispatchMixin layout, to keep context_compressor.py under its size
cap. No behaviour change; no test patches the moved names.
2026-10-06 12:03:52 +05:30
kshitijk4poor
5023d58671 fix(compaction): persist the summary-model bench probe deadline
The bench probe clock was in-memory monotonic time, so every rebuilt
compressor (the gateway rebuilds the AIAgent on cache eviction) came up
with a durable fallback streak but no clock, armed a fresh 300s window and
benched again. If rebuilds arrive faster than the window, the summary model
is never re-probed and the session stays on deterministic summaries until
a manual /compress.

Store the deadline as wall-clock time on the session row (model_config
key, the same durable path the proactive-prune runway uses) and load it in
bind_session_state, mirroring the anti-thrash recovery deadline (#100185).
A deadline more than one window in the future (clock jumped back) is
re-armed from now.
2026-10-06 12:03:52 +05:30
Elisa Martinez Abad
bdba7042a9 fix(compaction): bench a failing summary model instead of blocking compaction
Two fallback summaries in a row tripped the anti-thrash breaker even when
each fallback compaction was effective, so automatic compaction was blocked
and the session rode into the provider's hard context limit. Only
ineffective compactions now trip the breaker; a fallback streak >= 2 instead
benches the summary model (deterministic drop, one probe per recovery
window), which still stops the paid fallback loop from #63008. force=True
(/compress) always calls the model.

Salvaged from #129431.

Co-authored-by: Pascal Bérard <pberard@nvidia.com>
2026-10-06 12:03:52 +05:30
Teknium
46caa93680 fix(compression): a stall-fallback pin replaces the main route, never merges over it
After the aux summary fell back to the main model, the main provider, base_url and
api_key were written into the call and a stall-fallback pin overrode only the fields
it set. A fallback_chain entry without base_url/api_key kept the main host (the
fallback was never reached), or sent the main provider's API key to the fallback's
host. The pin now replaces the whole route; the main route applies only when no pin.
2026-10-05 15:37:17 -07:00
Anthony Ruiz
71448afc55 fix(compression): name the main model when aux summary falls back
`_fallback_to_main_for_compression` clears `summary_model` to mean "use the
main model". But `call_llm(task="compression", ...)` resolves an unspecified
route from `auxiliary.compression`, i.e. the model that just failed. The
fallback therefore re-calls the failing auxiliary model; since
`_summary_model_fallen_back` is already set, no second fallback is allowed,
`_generate_summary` returns None, and with
`compression.abort_on_summary_failure` the whole compression is skipped.

Observed with an auxiliary model whose weekly quota was exhausted: two
identical 429s, `failure_class=summary_generation_aborted`,
`fallback_used=false`, and a session that kept growing uncompressed.

Add `_apply_summary_route()` as the single place that pins the summary wire
route, and use it from both compression entry points (`_generate_summary`
and `_micro_summarize_one`, which had the same defect).

Three existing tests asserted the buggy contract (`assert "model" not in
kwargs`, commented "no model kwarg -> call_llm uses main"); they now assert
the main model is named explicitly.

Salvaged from #88247 (slimmed: docstring, 2 invariant tests; dropped the redundant
_last_summary_auth_failure reset — a main-model success already clears terminal flags).
Fixes #123362.
2026-10-05 15:37:17 -07:00
kshitijk4poor
5762cce4c9 docs(compression): drop stale split-turn clause from task-snapshot comment
Gate r1 (2c Low): the comment in _latest_user_task_snapshot claimed the
split-turn gate measures the same authored text, which no longer holds.
Keep only the reason the reply quote is dropped first. No net line growth.
2026-10-04 12:02:15 +05:30
Brian Le
41c6729421 fix(compression): split oversized turns whose request is long
The oversized-active-turn split (#80449) refuses to fire when the
authored turn-opening request exceeds 1,400 characters. A long /goal
continuation prompt therefore pins its entire autonomous run in the
protected tail: every automatic pass finds an empty compress window,
records no_progress, arms the 300 s structural backoff, and the session
grows into the provider context limit. /compress hits the same empty
window.

The token soft ceiling already bounds the request row, and the split
request is restated verbatim after the handoff by
_reappend_inflight_user_task, so the character cap only blocks
progress. Drop it from the split gate. The historical task snapshot
keeps its own cap and reply-quote handling.

(cherry picked from commit 0081611ff1f088ae0cca86d916ee802e74fe465a)
2026-10-04 12:02:15 +05:30
kshitijk4poor
2667c960d5 fix(compressor): don't move a merged tool-call carrier during replay dedup
Gate r3 finding 2c: when _summary_placement merged the handoff into
tail[0] and that row is an assistant(tool_calls), the dedup's
move-carrier-to-window-start step popped it away from its tool results
(broken pairing) and still left an assistant row first.

Snapshot the window before the dedup filter; if the filter removed a row
and the carrier is a tool-call row (_template_visible_role is None),
restore the snapshot and skip the dedup this cycle. The output then
matches the pre-stack layout for that shape; the standalone-carrier
move-and-flip path is unchanged.
2026-10-03 18:23:16 +05:30
kshitijk4poor
d20a55cb5c fix(compressor): keep the window opening on a user turn after replay dedup
Gate finding 2ab (BN05 final): when the deduped in-flight original was the
first non-system row (cron / first-compression shape), removing it left the
window opening system -> assistant(tool_calls) -> tool -> carrier. The
carrier-flip guard judges template-visible roles, which skip tool_calls
rows, so it saw nothing wrong; native Gemini maps that leading row to a
model functionCall turn, which its API rejects as the first turn.

After the dedup, if the first non-system row is not a user row, move the
carrier there (the existing flip then makes it role=user). The kept test now
asserts the raw role of compressed[1], since the template-visible check
cannot see a leading tool-call row.
2026-10-03 18:23:16 +05:30
kshitijk4poor
876e9740de refactor(compressor): key the in-flight replay dedup on message_uid_or_none
Gate finding (reuse + quality, Low, convergent): the replay-uid block
hand-rolled agent.message_metadata.message_uid_or_none (non-empty str or
None) and repeated the "message_uid" literal that MESSAGE_UID names. Use
the helper and the constant, hoisting the function's existing lazy
message_metadata import (record_absorbed_message) so it covers both.
Behaviour-equivalent: same uid acceptance, same row filter.
2026-10-03 18:23:16 +05:30
kshitijk4poor
2ceca30d57 refactor(compressor): test the carrier's predecessor role with != "user"
Gate finding (quality, Low): `in {None, "system", "assistant"}` lists every
value _last_template_visible_role yields in a compacted window except
"user", so the set spelled out a complement. `!= "user"` states the actual
condition (no visible user turn precedes the carrier). No behaviour change.
2026-10-03 18:23:16 +05:30
kshitijk4poor
7b3fc99ce8 fix(compressor): keep alternation when the in-flight replay drops the head user row
When the in-flight request survived in the protected head, the replay now
removes that original (one active row per message_uid). But the summary
role was chosen against a head that still ended on that user row, so the
compacted window then opened system -> assistant(tool_calls) -> tool ->
assistant summary: no user turn before the carrier, which strict
alternation templates (#58753 class) reject.

When the removal leaves the assistant carrier with no preceding visible
user, switch to the _force_user_leading layout: carrier role=user with the
request merged after _SUMMARY_END_MARKER (the existing merge path records
the uid as absorbed, so it stays single).

Co-authored-by: Yuan Li <dskwelmcy@163.com>
2026-10-03 18:23:16 +05:30
Yuan Li
a14e929ec0 fix(agent): replace duplicate in-flight task replay
(cherry picked from commit 581719d1daed7a6e6ce804ff756b6b383de379bd)
2026-10-03 18:23:16 +05:30
kshitijk4poor
f6c78be31e refactor(compression): reuse _result_failure_suffix for the write_file refusal stub
#132141 landed the shared outcome helper; render the write_file refusal through it
so the stub reads like the other marked stubs ("[write_file] a.md FAILED: ...") and
fold the test into the TestSummarizeToolResultOutcome tables (one failed row, one
success row) instead of a separate parametrized function.
2026-10-03 17:46:47 +05:30
Yuan Li
2d5ed7dd88 fix(compression): keep a refused write_file's outcome in its stub
_sum_write_file always rendered "[write_file] wrote to PATH (N lines)" from
the call's args, so a write the stale-write guard refused
({"error": "Refusing to overwrite ...", "stale_write_blocked": true}) - or
any other write_file error - compressed into the same stub as a successful
write, and the post-compaction agent reported a change that never landed.
Read the result's error first and say FAILED with a bounded preview.

Salvaged from #131247 (write_file half only; the patch-tool half is covered
by the templated-stub outcome fix in #132141).
2026-10-03 17:46:47 +05:30
kshitijk4poor
2cad7ef208 fix(compression): only an error-bearing result counts as a refused call
Gate finding BN03.r1.2c (Medium): status == "blocked" alone was treated
as a refusal, but successful kanban results use it as a task state --
kanban_block (and kanban_create with initial_status="blocked") return
{"ok": true, "status": "blocked", ...}. Those summarized as
"[kanban_block] BLOCKED, not run", so after pruning or fallback
compaction the model would read its own successful block/create as
refused and might repeat it (e.g. a duplicate card).

Every real refusal envelope carries a non-empty error string
(terminal _error_json / _blocked_json, tool_error); the kanban success
payloads have none. Require it. Adds the kanban_block success payload
as a negative row in the existing parametrize.
2026-10-03 17:38:45 +05:30
EloquentBrush0x
1b9594ef14 fix(compression): a denied tool call is not summarized as if it ran
Phase-1 pruning and the fallback summary describe each old tool result with
a per-tool one-liner. For a call an approval or write guard refused, those
read as done: a denied `rm -rf build` became "[terminal] ran `rm -rf build`
-> exit -1, 1 lines output", a denied protected write became
"[write_file] wrote to AGENTS.md (2 lines)", and the "The user has NOT
consented ... Do NOT retry" instruction was dropped. Denials are 300+ chars,
over the 200-char prune floor, so this hits every long session.

Summarize a refused result (BLOCKED error, status blocked/pending_approval)
as "BLOCKED, not run" and keep the no-consent note, for every tool. Like the
clarify summary (#128346), it stays below _PRUNE_MIN_CHARS so later passes
keep it. Other results are summarized exactly as before.

(cherry picked from commit fd506bbccd3df8783dce0c0a1895f1aaf1585cb4)
2026-10-03 17:38:45 +05:30
kshitijk4poor
da0bcfca83 fix(compression): price the assistant-anchor escape like the tail walk
The #131412 escape gate summed _estimate_msg_budget_tokens(m) with its
default charge_stale_thinking=True, so every older assistant row was
charged for reasoning the wire never carries, and the native-Anthropic
budget was ignored. On reasoning routes that strip stale thinking the
gate fired on regions under the soft ceiling on the wire, dropped the
last visible reply from the tail and reopened #29824 (probe: no
reasoning cut=2 reply kept; 1200-char reasoning_content on two older
tool-call rows cut=5 reply dropped; main keeps cut=2).

Factor the walk's per-row pricer out of _walk_tail_budget into
_tail_row_pricer and use it for both the walk and the gate, so the
escape agrees with the #84371 accounting. The existing anchor test now
parametrizes the reasoning-heavy shape; it is red without this change
(cut 5 != 2) and green with it. (BN04 2c High; quality/reuse Medium.)
2026-10-03 17:38:02 +05:30
kshitijk4poor
4147d8137a docs(compression): say the assistant-anchor escape also needs tool calls
The comment above the #131412 escape gate said it fires whenever the
anchored region is over the soft ceiling, but the gate also requires
tool_calls in that region. Name both conditions so the comment matches
the code (BN04 gate finding, Low).
2026-10-03 17:38:02 +05:30
kshitijk4poor
c2afbb0018 fix(compression): gate the assistant-anchor escape on an oversized tool region
The over-ceiling escape added for #131412 measured the anchored region with
_walk_tail_budget from the anchored cut, which reports "over the ceiling"
whenever any single region after a visible reply exceeds the soft ceiling
(tiny budgets, one big tool output). That dropped the last visible assistant
reply from the tail and reopened #29824
(test_reporter_repro_long_tool_run_after_visible_reply,
test_oversized_tool_output_does_not_strand_reply).

Escape only when the anchored region actually holds tool-call bodies to
reclaim (mirroring the #80449 user-split's tool_calls gate) AND its raw
token sum exceeds the soft ceiling. The textless oversized active turn is
still bounded; benign #29824 shapes keep the reply anchor.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-10-03 17:38:02 +05:30
liuhao1024
a5e9238bb7 fix(compression): bound the assistant tail anchor by the soft ceiling (#131412)
The #80449 oversized-turn split only relaxes the USER anchor, and only
when that anchor actually pulls the cut back (user_anchored_cut <
cut_idx). In a long agentic turn whose assistant rows carry only
tool_calls, the latest user message sits inside the token-budget tail,
so the user anchor does not move, the split never fires, and
_ensure_last_assistant_message_in_tail then pulls the cut back to the
newest TEXT-BEARING assistant — which is the PREVIOUS turn's closer.
The whole oversized active turn rides the protected tail, the middle
collapses (middle_window_tokens=0), and every manual/auto compress
no-ops in ~30ms with failure_class=no_progress: the session is
permanently uncompressible (#131412, same wedge class as #80449/#105663).

Give the assistant anchor the same bound the user anchor got: when the
anchored region exceeds the tail soft ceiling (measured with the walk's
own accounting, #84371) and mid-turn splits are allowed, keep the walk's
tool-group-aligned cut instead of anchoring. Rolling micro-compaction
(allow_split_turn=False) keeps the old anchoring behavior, and the
N-user promise (#70250) is untouched.

(cherry picked from commit 9259ea925c1eb95b3da7bc2fb5ad4499c6afd46b)
2026-10-03 17:38:02 +05:30
kshitijk4poor
d6e740b874 fix(compression): mark a failed delegate_task stub too
Last templated summarizer that ignored the payload outcome: a delegate_task tool_error
(unknown action, spawn failure) compressed into the same stub as a successful delegation.
2026-10-03 16:53:37 +05:30
kshitijk4poor
a6f8720b08 refactor(compression): name the shared outcome probe for what it is, parse once
_skill_result_failure_suffix is now the outcome probe for every templated,
browser, web, vision, cron, process and unmapped-tool stub, but its name and
docstring still said "skill", which suggests skill-specific logic. Rename it
to _result_failure_suffix (all callers are in this module; no test imports it).

Split out the dict-taking _payload_failure_suffix so _sum_cronjob_manage and
_sum_process_manage parse the result once instead of twice (a process poll
can carry a large output), and build the exit-code suffix through
_failure_suffix like every other branch. No behaviour change.

Gate findings BN03b r1 quality + efficiency (Low).
2026-10-03 16:53:37 +05:30
kshitijk4poor
aa04942ad5 fix(compression): don't stub a self-killed process or a skipped cron run as FAILED
kill_process records exit_code -15 with completion_reason "killed", so a
later poll of a process the agent stopped on purpose compressed into
"[process] poll ... FAILED: exit code -15". A manual cron run the scheduler
was already firing reports execution_skipped, and was stubbed as
"FAILED: Already being fired ...", although the job may be running right
now. After compaction the agent read both as failures.

Skip the exit-code suffix when completion_reason is "killed", and mark
execution_skipped as " SKIPPED: <reason>" (_failure_suffix takes the label);
execution_error and execution_success=False still mark FAILED.

Gate findings BN03b r1 2c + quality (Low, convergent).
2026-10-03 16:53:37 +05:30
kshitijk4poor
d70dfa8726 fix(compression): mark failures in web_extract, browser, vision and unmapped-tool stubs
The outcome suffix stopped at _sum_template, so the rest of the same bug
class still compressed a failed call into a stub that reads like a
success: web_extract {"error": "403 Forbidden"}, a failed browser_* call,
a refused vision_analyze, and every unmapped tool. The unmapped fallback
covers every MCP and plugin tool, so it was the widest remaining gap.

Append the same top-level outcome suffix in _sum_web_extract, _sum_browser,
the vision_analyze summarizer and the generic fallback in
_summarize_tool_result_unguarded. Success payloads and non-JSON content
give the same stub as before.

Gate finding BN03b r1 2c (Medium).
2026-10-03 16:53:37 +05:30
Konstantin Khlopkov
7de2fbb98f fix(compression): mark a dead cron run and a non-zero exit in their stubs
A manual cronjob run reports a dead or skipped job inside the job view while the tool
call itself succeeded, so the payload is a clean {"success": true, "job": {...}} and the
top-level outcome probe finds nothing to mark. A poll of a finished process reports
exit_code and completion_reason instead of an error, so a non-zero exit is invisible the
same way. Both compressed into the byte-identical success stub, which is the failure this
series removes.

Each tool now reads its own payload: the cronjob stub looks at this call's
execution_error / execution_skipped / execution_success and at nothing else, because the
job view also carries state left by earlier runs; the process stub marks a non-zero
exit code, while a process still running reports none and stays unmarked.

(cherry picked from commit 295e23e3bfc084f20844bf518a244d41421d3f66)
2026-10-03 16:53:37 +05:30
Konstantin Khlopkov
b66dcf32e0 fix(compression): keep the outcome in templated tool-result stubs
_sum_template formatted from the tool arguments and the result's length only, so every
tool routed through it dropped its outcome. A refused read, a rate-limited search, a
rejected cronjob, an unknown process session and a tts call with no voice all compressed
into the same stub as the success they never were, and the post-compaction agent went on
to report the success (#131244).

Append the failure suffix the skill summarizers already use, so the templated family keeps
its outcome from the one helper that already knows how to read it. Stubs for calls that
report no failure are unchanged.

Complementary to #131247, which gives write_file and patch summarizers of their own.

(cherry picked from commit 6302f87ff52c10db2b43521b733914748b4bcca2)
2026-10-03 16:53:37 +05:30
kshitijk4poor
2ca09f50f3 refactor(compression): share the replay-header peel between request readers
_authored_request_text and _reappend_inflight_user_task each unwrapped the
in-flight replay header with identical code, so a later change to that
format could update one and miss the other. Both now call
_restated_request_text; the reappend path still keeps the reply pointer so
the restated task retains its disambiguation.
2026-10-03 16:26:31 +05:30
Anthony Ruiz
5b3db284be fix(compression): measure the authored request, not the reply quote, before splitting an oversized turn
The oversized-active-turn split (#80449) only fires when the turn-opening
request is at most _ACTIVE_TASK_MAX_CHARS. On messaging platforms the gateway
prepends `[Replying to: "<quoted message>"]` to a reply, so a short reply to a
long assistant answer measured as a long request: the split never fired, the
user anchor kept the whole in-progress turn in the protected tail, the
compress window collapsed to nothing, and every automatic pass became a
structural no-op (300 s backoff) while the turn kept growing past the
threshold. /compress hit the same empty window.

Measure the authored text instead: strip one leading gateway reply pointer
(optionally behind the Discord triggering note), and the in-flight replay
header first so a request restated by an earlier compaction keeps splitting
on later passes. The deterministic task snapshot uses the same text when a
row exceeds the cap, so head elision can no longer keep the quote and drop
the request. The token-size guard on the whole row is unchanged, and a quote
whose closing delimiter cannot be found keeps the conservative anchor.

(cherry picked from commit ccaf61b3b651a7fd68ffc0c69ab965881f8850b6)
2026-10-03 16:26:31 +05:30
kshitijk4poor
127735f505 fix(compression): native tail budget reads route facts like its sibling predicate
_native_anthropic_budget read self.api_mode directly while _stale_thinking_on_wire,
called one line earlier in _walk_tail_budget, reads the same route facts as optional
attributes. A compressor built without __init__ (test_compressor_image_tokens) raised
AttributeError in the walk; real instances always set the fields, so this only aligns
the two route predicates.
2026-10-02 00:18:00 +05:30
kshitijk4poor
75e9105bb4 fix(compression): tail walk sees in-memory rejected thinking without a DB
The compressor's native tail budget read rejected-signature fingerprints
from the session DB only. With persistence disabled (or a failed persist)
the agent's preflight stripped the rejected blocks while the tail walk
still priced them, so the two estimates diverged after a signature
recovery. The agent now binds its session-keyed rejection set onto its
context_compressor (same set object, so later updates reach both), and
the tail walk prefers that in-memory set for its bound session before
falling back to the persisted fingerprints.
2026-10-02 00:18:00 +05:30
kshitijk4poor
e8cc8c31c4 fix(compression): tail walk prices native preserved thinking like preflight
#129620 made stale_thinking_reaches_wire() true for preserved-thinking native Claude
(c19252d), so _walk_tail_budget charged the canonical reasoning keys on every turn,
while preflight priced the native projection minus suppressed blocks (643ca56,
9e0ae8d). Measured on a real SessionDB: after a signature rejection the walk charged
243 vs 115 for preflight, and storage-only reasoning rows (e.g. written by another
provider before a model switch) 2033 vs 27, although the wire carried no thinking.

On that route the walk now prices each row through the same projection and the
session's persisted rejection fingerprints. Other routes are unchanged.
2026-10-02 00:18:00 +05:30
unsupportedpastels
7a2ee65bd2 fix(compression): classify complete legacy notice fragments
Preserve genuine selections around comma-normalized historical notices and require closed quoted question/choice fields before recognizing a notice suffix. Cover real historical producer outputs, embedded terminators, prefix lookalikes, and explicit status through public compression dispatch.
2026-10-01 02:28:11 +00:00
unsupportedpastels
bb873ade25 fix(compression): recognize normalized legacy headless notices
Match the complete single-query notice rather than its prefix, and recognize comma-normalized headless notice envelopes before filtering individual selections. Preserve current explicit status and independent non-notice selections.

Co-authored-by: Andrex Ibiza, MBA <84248988+andrexibiza@users.noreply.github.com>
2026-10-01 01:27:27 +00:00
unsupportedpastels
ddc55890f0 fix(compression): match legacy clarify notices by producer shape
Match the classic CLI cancel notice as its whole text so real answers that start "The user cancelled" survive, add the hermes chat -q single-query notice, and drop only notice items from a legacy multi-select so other selections are kept.
2026-09-30 00:32:09 +00:00
unsupportedpastels
0145fc95b4 fix(compression): treat legacy clarify cancel notice as non-response
The pre-#127760 classic CLI Ctrl+C path persisted "The user cancelled. ..." as a status-less user_response. Add it to the legacy sentinel prefixes, now a named module constant, and to the regression parameterization. Drop a leftover debug print.
2026-09-29 21:09:28 +00:00
unsupportedpastels
65f5d631ed fix(compression): distinguish legacy clarify notices from answers
Keep explicit status authoritative and restore sentinel recognition only for persisted pre-status responses. Exercise repeated pruning and public compaction with both legacy shapes, multi-select answers, and large question payloads.

Co-authored-by: Yuan Li <dskwelmcy@163.com>
2026-09-29 18:59:53 +00:00
Yuan Li
1d689d559c fix(compression): preserve legacy clarify answers
(cherry picked from commit 12c70e699a1d44b43db7642e2012273d84cf031f)
2026-09-29 18:57:11 +00:00
finn763
7083a524a7 fix(agent): stamp compaction replay rows at compaction time, not the original task time
Closes #121064
2026-09-29 11:43:34 -05:00
Siddharth Balyan
5eea87882a Clarify: one question shape and one result shape on every surface (skip, cancel, timeout and undelivered are told apart) (#127760)
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder

Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.

* feat(clarify): one questions[] shape with per-question status and one outcome

The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.

Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.

The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.

* feat(clarify): every surface asks through the one question card

CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.

Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.

Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.

Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.

* fix(clarify): cancelled for a released messaging card, labels win over the skip word

A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.

A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.

The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.

* fix(tui): re-editing a multi-select answer keeps its picks

The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.

Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.

* test(clarify): align three checks with the one-answer confirm and the regenerated keys

The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.

* test(tui): the one-question card header is singular
2026-09-29 18:33:44 +05:30
Eva
c2e2150d78 fix(sessions): close the identity gaps the review found and prove the recovery paths
- Strip persistence-only fields from a select_context() selection: the request copy was stripped before
  the hook, but an engine handing back the conversation_messages clones put timestamp, _row_id and the
  new ids on the wire (pre-existing for the old fields).
- Every row carries a message_uid whoever wrote it: an AFTER INSERT trigger mints one for a row inserted
  without it (an older build writing into a v31 store); the one-time backfill is gated by its own
  state_meta marker so a store whose schema version cannot advance (no FTS5) never rescans.
- One merge-witness helper (record_absorbed_message) for every fold of a durable message: alternation
  repair's user and assistant merges, the compressor's in-flight restatement onto the carrier, the real
  user anchor folded into a scaffolding turn (the anchor's uid leads), micro-compaction's adjacent-user
  merge.
- tool_call_uids follows tool_calls: it is a payload column, so an assistant merge's unioned map is
  rewritten with the row instead of being overwritten by the stale stored map on adoption.
- A result pairs with the nearest call naming its provider id: each assistant row's ids shadow earlier
  occurrences in the batch, restore and live-list indexes, a call row without a map leaves its result
  unpaired, and nothing pairs across a user turn.
- A composite rewind returns and installs the replacement row's uid on the live scaffold.
- Tests: import_foreign_history, hermes sessions recover and backup import carry the ids; the ACP
  restore test accounts for the uid on restored rows.

(cherry picked from commit b30636a49cb29fdd21ba4100eef9d810897338be)
2026-09-29 04:15:21 +05:30
Teknium
a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
2026-09-28 10:21:41 -07:00
kshitijk4poor
062dc1e7f0 refactor(context): drop redundant or-empty before redaction 2026-09-27 18:38:58 +05:30
kshitijk4poor
fb86bc708d fix(context): redact full tool-call args before the summarizer cut
62ceddd342 cut raw args to HEAD+4096 before redaction to save time. The
PEM redaction pattern only matches a complete BEGIN...END block. A long key
whose END fell past the cut stayed unredacted, and once an earlier key was
redacted and the text shrank, its body landed in the 1200-char head that
goes into the persisted summary. Go back to the BASE order: redact the full
args, then apply the MAX/HEAD cut. This is a cold path (once per summarized
call per compaction), and _SUMMARY_INPUT_MAX_CHARS still bounds the prompt.
Extend the kept canonical-args test with a two-PEM input that leaks on
62ceddd342 and passes now.
2026-09-27 18:38:58 +05:30
kshitijk4poor
be834681dc fix(context): measure pruned regions, bound arg redaction, drop dead counters
Follow-ups to making tool-call args byte-exact:
- _record_compression_regions measured canonical_messages slices while
  compress_start/compress_end are indices into the pruned copy that head/tail
  are assembled from; measure the pruned rows actually sent, as before. This
  also removes the only canonical slicing, so blank-echo classification drift
  between the two copies can no longer misalign anything.
- _render_tool_call_for_summary redacted the full (now unbounded) args before
  cutting to 1200 chars; cut to head+4096 first. Output unchanged for args
  within that window.
- pressure_hits always equalled demoted once arg truncation left; fold it.
- Drop the fixture-only tautological assert in the guardrail test helper.
- Reword stale compress()/compression_marker docstrings that still described
  canonical head/tail and compressor-written arg markers.
2026-09-27 18:38:58 +05:30
kshitijk4poor
66240ddc59 fix(context): restore marker import in tests, drop unused compressor imports
The arg-truncation removal deleted the only uses of the marker constants in
agent/context_compressor.py (ruff F401), and the test module had been
importing _COMPRESSION_MARKER_PREFIX through it, so test_context_compressor
line 137 raised NameError. Import it from its home, agent.compression_marker.
2026-09-27 18:38:58 +05:30