From 7e70a103be8d558568f165e27301f383316bfffa Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:54:55 +0800 Subject: [PATCH] compaction: hold the next pass when the last one bought no room An automatic compaction that left the context over the compaction point fired again on the next step. When the provider's compacted state, or a summary that must keep a long live turn verbatim, could not get under the line, every step paid a compaction and the model reread what it had just lost. A compaction that leaves the context over the line within one step now holds the next one until the context grows by a twentieth of the window, or reaches the 95% wall where recovery may trim. The in-stream server directive uses the same raised threshold while holding, so the server does not compact on every request either. Fixes #1686 --- src/agent.zig | 1 + src/agent_server_compact.zig | 3 +- src/agent_turn.zig | 21 +++++++- src/compact_backoff.zig | 95 ++++++++++++++++++++++++++++++++++++ src/main.zig | 2 +- 5 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 src/compact_backoff.zig diff --git a/src/agent.zig b/src/agent.zig index 60edc4b1..6eec14be 100644 --- a/src/agent.zig +++ b/src/agent.zig @@ -225,6 +225,7 @@ pub const Agent = struct { compact_summary_failures: u8 = 0, // #379: consecutive complete-but-unusable (empty/truncated) summaries compact_pin_degraded: bool = false, // #581: pinned suffix over budget — skip+say once per unresolved turn compact_stall: @import("compact_cut.zig").Stall = .{}, // #706: same-boundary no-progress cuts + compact_backoff: @import("compact_backoff.zig").Backoff = .{}, // #1686: compactions that bought no room empty_completion_retries: u8 = 0, // degenerate empty completions re-asked this turn (agent_empty_completion.zig) length_cutoffs: u8 = 0, // cut-off or runaway replies rewound this turn (agent_length_cutoff.zig) named_work_nudges: u8 = 0, // named-file / zero-tool nudge this turn (named_work.zig) diff --git a/src/agent_server_compact.zig b/src/agent_server_compact.zig index 58a4840d..be416349 100644 --- a/src/agent_server_compact.zig +++ b/src/agent_server_compact.zig @@ -107,7 +107,8 @@ pub fn directive(self: anytype) ?u64 { if (self.provider.serverCompactUrl() != null) return null; if (self.server_compaction_request and manualRoute(self.provider) == .in_stream) return 1_000; if (!enabled(self.provider)) return null; - const threshold = self.provider.compactAt(); + // #1686: while a compaction that bought no room holds the next one. + const threshold = @import("compact_backoff.zig").threshold(self.compact_backoff, self.provider.compactAt(), self.provider.context); // A request carrying the directive answers every GPT-6 `response.steer` // with steering_not_supported (probed on Codex). It is inert below its // threshold, so a root turn that can steer leaves it off until the diff --git a/src/agent_turn.zig b/src/agent_turn.zig index 09666286..97f4fcc9 100644 --- a/src/agent_turn.zig +++ b/src/agent_turn.zig @@ -2,6 +2,7 @@ const std = @import("std"); const Agent = @import("agent.zig").Agent; +const main_mod = @import("main.zig"); const empty_completion = @import("agent_empty_completion.zig"); const turn_inbox = @import("turn_inbox.zig"); @@ -46,7 +47,9 @@ pub fn run(self: *Agent, root_turn_prepared: *std.atomic.Value(bool)) anyerror![ // meter (last_context_tokens, server-reported) catches up. Estimate the // full input locally and compact BEFORE sending so we never ship an // over-cap request (codex run_pre_sampling_compact / opencode isOverflow). - if (self.inputOverCompactThreshold() and !readBatchFirst(self)) { + const over_compact = self.inputOverCompactThreshold(); + @import("compact_backoff.zig").step(&self.compact_backoff, self.history_rewrites, over_compact); + if (over_compact and !readBatchFirst(self) and !compactionHeld(self)) { // Bracket the compaction with codex-WS resets, mirroring how every // other compactOrRecover call site is bookended by runTurn's // closeCodexWs (299 + defer 300). Mid-turn the WS can be live with a @@ -103,6 +106,22 @@ pub fn run(self: *Agent, root_turn_prepared: *std.atomic.Value(bool)) anyerror![ } } +/// #1686: the previous compaction left the context over the line; wait for +/// real growth (or the 95% wall) instead of compacting on every step. +fn compactionHeld(self: *Agent) bool { + const context = self.effectiveContextTokens(); + const backoff = @import("compact_backoff.zig"); + switch (backoff.gate(&self.compact_backoff, context, self.provider.context, self.provider.nearContextLimit(context))) { + .compact => return false, + .hold => return true, + .hold_started => { + if (self.tracer) |tr| tr.note("compact", "held: the last compaction left the context over the line"); + if (!main_mod.json_mode) self.say("[compaction freed too little room; compacting again once the context grows]\n", .{}) catch {}; + return true; + }, + } +} + /// Over the compaction line, but the history ends on a tool batch the model /// has not read and almost everything else is that batch. A compaction keeps /// the batch verbatim (compact_cut.unreadToolBatch), so it could only diff --git a/src/compact_backoff.zig b/src/compact_backoff.zig new file mode 100644 index 00000000..2739e07e --- /dev/null +++ b/src/compact_backoff.zig @@ -0,0 +1,95 @@ +//! #1686: an automatic compaction that leaves the context over the compaction +//! point fires again on the very next step. When the provider's compacted +//! state, or a summary that must keep a long live turn verbatim, cannot get +//! under the line, every step paid a compaction and the model reread what it +//! had just lost. A compaction that bought no room holds the next one until +//! the context grows by a twentieth of the window, or reaches the 95% wall +//! where recovery may trim. The in-stream server directive follows the hold. + +const std = @import("std"); + +pub const Backoff = struct { + rewrites: u32 = 0, // history_rewrites at the previous step + steps: u32 = std.math.maxInt(u32), // model steps since history was last rewritten + resume_at: u64 = 0, // context that ends the hold; 0 when not holding +}; + +pub const Gate = enum { compact, hold, hold_started }; + +/// Once per model step, before the pre-send gate. `over`: the context is at or +/// past the compaction point. +pub fn step(b: *Backoff, rewrites: u32, over: bool) void { + if (rewrites != b.rewrites) { + b.rewrites = rewrites; + b.steps = 0; + } else b.steps +|= 1; + if (!over) b.resume_at = 0; // real room: compaction worked +} + +/// The pre-send gate found `context` over the compaction point. +pub fn gate(b: *Backoff, context: u64, window: u64, near_limit: bool) Gate { + if (near_limit) { + b.resume_at = 0; + return .compact; + } + if (b.resume_at != 0) { + if (context < b.resume_at) return .hold; + b.resume_at = 0; + return .compact; + } + // History was rewritten this step or the one before and is still over. + if (b.steps > 1) return .compact; + b.resume_at = context +| @max(window / 20, 1); + return .hold_started; +} + +/// The in-stream compaction threshold while holding: the server must not +/// compact on every request either. Never past the 95% wall. +pub fn threshold(b: Backoff, base: u64, window: u64) u64 { + if (b.resume_at <= base) return base; + return @max(base, @min(b.resume_at, window - window / 20)); +} + +test "a compaction that leaves the context over the line holds the next one" { + var b: Backoff = .{}; + const window: u64 = 800_000; + // Long-running session: no recent rewrite, so the first trip compacts. + b.steps = 10; + step(&b, 0, true); + try std.testing.expectEqual(Gate.compact, gate(&b, 645_000, window, false)); + // The compaction rewrote history, and the next step is still over. + step(&b, 1, true); + try std.testing.expectEqual(Gate.hold_started, gate(&b, 641_000, window, false)); + try std.testing.expectEqual(@as(u64, 681_000), b.resume_at); + try std.testing.expectEqual(@as(u64, 681_000), threshold(b, 640_000, window)); + step(&b, 1, true); + try std.testing.expectEqual(Gate.hold, gate(&b, 660_000, window, false)); + // Grown by a twentieth of the window: compact once more. + step(&b, 1, true); + try std.testing.expectEqual(Gate.compact, gate(&b, 682_000, window, false)); + try std.testing.expectEqual(@as(u64, 640_000), threshold(b, 640_000, window)); +} + +test "the wall and real relief both end a hold" { + var b: Backoff = .{}; + const window: u64 = 800_000; + step(&b, 1, true); + try std.testing.expectEqual(Gate.hold_started, gate(&b, 750_000, window, false)); + try std.testing.expectEqual(@as(u64, 760_000), threshold(b, 640_000, window)); // capped at 95% + step(&b, 1, true); + try std.testing.expectEqual(Gate.compact, gate(&b, 765_000, window, true)); + + step(&b, 2, true); + try std.testing.expectEqual(Gate.hold_started, gate(&b, 650_000, window, false)); + step(&b, 2, false); + try std.testing.expectEqual(@as(u64, 0), b.resume_at); + try std.testing.expectEqual(@as(u64, 640_000), threshold(b, 640_000, window)); +} + +test "a compaction that bought several steps is not a stall" { + var b: Backoff = .{}; + step(&b, 1, false); + step(&b, 1, false); + step(&b, 1, true); + try std.testing.expectEqual(Gate.compact, gate(&b, 641_000, 800_000, false)); +} diff --git a/src/main.zig b/src/main.zig index c77ae41a..9779da97 100644 --- a/src/main.zig +++ b/src/main.zig @@ -570,7 +570,7 @@ test { // ── Unit tests (`zig build test`): pull in tests from imported modu _ = @import("agent_gateway_retry.zig"); // #1019: flake vs overflow classification must stay reachable _ = @import("agent_responses.zig"); // #1019: type:error frame message extraction _ = @import("agent_server_compact.zig"); // server-side autocompact (codex Responses) - _ = .{ @import("compact_status.zig"), @import("compact_mix.zig") }; // compact status + GRAFF_COMPACT_MIX mixture arm + _ = .{ @import("compact_status.zig"), @import("compact_mix.zig"), @import("compact_backoff.zig") }; // compact status + GRAFF_COMPACT_MIX mixture arm + #1686 backoff _ = .{ @import("agent_clef_compact.zig"), @import("agent_clef_compact_tests.zig"), @import("agent_clef_archive.zig") }; // gateway /v1/compact client (Clef pruning) + archive mode _ = @import("agent_compact_recover.zig"); // compaction last-resort recovery (split for the ceiling) _ = @import("agent_request_search_tests.zig");