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");