diff --git a/src/agent_compact.zig b/src/agent_compact.zig index 437cb09e..7c448c5d 100644 --- a/src/agent_compact.zig +++ b/src/agent_compact.zig @@ -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(); @@ -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 diff --git a/src/agent_request.zig b/src/agent_request.zig index d4d45091..b429d509 100644 --- a/src/agent_request.zig +++ b/src/agent_request.zig @@ -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 @@ -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; @@ -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, @@ -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");