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
65 changes: 65 additions & 0 deletions src/agent_compact.zig
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@ pub fn compact(self: *Agent) anyerror!usize {
// (WriteFailed) rather than returning a clean overflow, so compaction could
// never run once near the cap. Old tool outputs are superseded by the summary
// anyway; truncating them keeps the request sendable + all pairing intact.
// #174: on the Responses path, prior-turn reasoning items go first — they
// dominate the resend bloat on long high-effort sessions, and the backend
// itself discards them from chained context, so dropping them can't lose
// anything the server would have kept.
_ = dropPriorTurnReasoning(self);
_ = trimOldestToolOutputs(self);
try self.messages.append(try textMessage(self.arena, "user", compact_instruction));
errdefer _ = self.messages.pop();
Expand Down Expand Up @@ -305,6 +310,66 @@ pub fn cleanUserTurn(m: Value) bool {
}
}

/// #174: drop Responses `reasoning` items older than the last user message,
/// in place (no allocation). These are exactly the items the backend discards
/// from chained context (previous_response_id), so removing them can't lose
/// anything the server would have kept — and on a long high-effort session
/// their encrypted blobs dominate the full-resend size. Reasoning at or after
/// the last user message stays: the API requires the current turn's reasoning
/// between a function_call and its output. Returns how many were dropped.
pub fn dropPriorTurnReasoning(self: *Agent) usize {
if (self.provider.kind != .responses) return 0;
var last_user: usize = 0;
for (self.messages.items, 0..) |m, i| {
if (m != .object) continue;
const role = m.object.get("role") orelse continue;
if (role == .string and std.mem.eql(u8, role.string, "user")) last_user = i;
}
var w: usize = 0;
for (self.messages.items, 0..) |m, i| {
const old_reasoning = i < last_user and m == .object and blk: {
const t = m.object.get("type") orelse break :blk false;
break :blk t == .string and std.mem.eql(u8, t.string, "reasoning");
};
if (old_reasoning) continue;
self.messages.items[w] = m;
w += 1;
}
const dropped = self.messages.items.len - w;
self.messages.shrinkRetainingCapacity(w);
return dropped;
}

test "dropPriorTurnReasoning (#174): prior-turn reasoning goes, current turn + non-responses stay" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();

var msgs = std.json.Array.init(a);
try msgs.append(try textMessage(a, "user", "turn one"));
try msgs.append(try std.json.parseFromSliceLeaky(Value, a, "{\"type\":\"reasoning\",\"encrypted_content\":\"OLD1\"}", .{}));
try msgs.append(try textMessage(a, "assistant", "reply one"));
try msgs.append(try std.json.parseFromSliceLeaky(Value, a, "{\"type\":\"reasoning\",\"encrypted_content\":\"OLD2\"}", .{}));
try msgs.append(try textMessage(a, "user", "turn two"));
try msgs.append(try std.json.parseFromSliceLeaky(Value, a, "{\"type\":\"reasoning\",\"encrypted_content\":\"CURRENT\"}", .{}));
try msgs.append(try std.json.parseFromSliceLeaky(Value, a, "{\"type\":\"function_call\",\"name\":\"bash\",\"call_id\":\"c1\",\"arguments\":\"{}\"}", .{}));

var agent: Agent = undefined;
agent.provider = .{ .id = "codex", .kind = .responses, .auth = .bearer, .url = "", .api_key = "", .model = "gpt-5", .context = 100_000 };
agent.messages = msgs;

try std.testing.expectEqual(@as(usize, 2), dropPriorTurnReasoning(&agent));
try std.testing.expectEqual(@as(usize, 5), agent.messages.items.len);
// current-turn reasoning (after the last user message) survives, in order
const kept = agent.messages.items[3].object.get("encrypted_content").?.string;
try std.testing.expectEqualStrings("CURRENT", kept);

// non-responses providers are untouched
agent.provider.kind = .openai;
try std.testing.expectEqual(@as(usize, 0), dropPriorTurnReasoning(&agent));
try std.testing.expectEqual(@as(usize, 5), agent.messages.items.len);
}

/// Index to cut history at for an emergency trim: the first clean user turn
/// at or after the midpoint, so messages[cut..] is always a valid
/// conversation start (never an orphaned tool_result). null when there is no
Expand Down
49 changes: 49 additions & 0 deletions src/agent_request.zig
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@ test "isAuthError (#148): auth failures only, not credits/rate/other" {
try std.testing.expect(!isAuthError("context length exceeded"));
}

test "fullInputEstimateTokens (#174): counts retained reasoning the chained usage never reports" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
var msgs = std.json.Array.init(a);
var agent: Agent = undefined;
agent.messages = msgs;
try std.testing.expectEqual(@as(u64, 0), fullInputEstimateTokens(&agent) / 100); // empty history ≈ nothing
// a fat encrypted-reasoning item — exactly what a WS-chained total_tokens excludes
const blob = "{\"type\":\"reasoning\",\"encrypted_content\":\"" ++ ("A" ** 8192) ++ "\"}";
try msgs.append(try std.json.parseFromSliceLeaky(Value, a, blob, .{}));
agent.messages = msgs;
const est = fullInputEstimateTokens(&agent);
try std.testing.expect(est > 2000); // ~8KB serialized / 4 bytes-per-token
}

pub fn request(self: *Agent, tools: ?[]const u8) !std.json.ObjectMap {
var force = self.strict and tools != null;
var stream_usage = true; // openai stream_options; dropped if rejected
Expand Down Expand Up @@ -223,6 +239,15 @@ pub fn request(self: *Agent, tools: ?[]const u8) !std.json.ObjectMap {
if (self.tracer) |tr| tr.note("ws", "server rejected previous_response_id — re-anchoring with full input");
continue :rebuild;
}
// #174: a context-window rejection means the true input
// size blew past the wall while the chained meter lagged —
// and the rejected request never returns usage to correct
// it, so the meter would stay stuck under compact@ and the
// session would wedge (every retry resends the same
// oversized history). Pin the meter to the window so the
// ApiError compact-and-recover path engages.
if (std.mem.indexOf(u8, msg, "exceeds the context window") != null)
self.last_context_tokens = self.provider.context;
if (self.tracer) |tr| tr.api(self.label, self.provider.model, ms, body.len, resp_body.len, 0, 0, true);
try self.sayApiError("codex api error: {s}", .{msg});
return error.ApiError;
Expand Down Expand Up @@ -425,6 +450,21 @@ pub fn errorMessage(obj: std.json.ObjectMap) ?[]const u8 {
return null;
}

/// #174: ~4-bytes/token estimate of the FULL history serialized as Responses
/// `input` items — the cost of the next full-history resend (runTurn closes
/// the WS per turn, so every turn's first request replays everything). The
/// chained WS usage can sit far below this: with previous_response_id the
/// server discards prior-turn reasoning from context, while a resend pays for
/// every retained encrypted reasoning item again. Counting discard writer —
/// no allocation.
pub fn fullInputEstimateTokens(self: *Agent) u64 {
var buf: [512]u8 = undefined;
var d: Io.Writer.Discarding = .init(&buf);
var s: std.json.Stringify = .{ .writer = &d.writer };
s.write(Value{ .array = self.messages }) catch return 0;
return d.fullCount() / 4;
}

pub fn recordUsageResponses(self: *Agent, response: std.json.ObjectMap, req_body_len: usize) void {
self.last_cache_read = 0;
// Fallback estimate (~4 bytes/token) from the serialized request body,
Expand Down Expand Up @@ -456,6 +496,15 @@ pub fn recordUsageResponses(self: *Agent, response: std.json.ObjectMap, req_body
self.last_context_tokens = est;
}
}
// #174: the server's chained number undercounts what the next full-history
// resend will cost, and the resend is the request that gets rejected — so
// the meter must never sit below the local full-input estimate. Same
// correction codex CLI applies (get_non_last_reasoning_items_tokens).
// Without it a long Extra-high session reads "95k/270k" right up until the
// backend rejects the resend for exceeding the window, and auto-compaction
// (gated on this meter) never rescues it.
const full_est = fullInputEstimateTokens(self);
if (full_est > self.last_context_tokens) self.last_context_tokens = full_est;
var cached: i64 = 0;
if (u.get("input_tokens_details")) |d| if (d == .object) {
cached = usageInt(d.object, "cached_tokens");
Expand Down
Loading