Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/agent.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/agent_server_compact.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion src/agent_turn.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions src/compact_backoff.zig
Original file line number Diff line number Diff line change
@@ -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));
}
2 changes: 1 addition & 1 deletion src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading