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 @@ -407,6 +407,7 @@ pub const Agent = struct {
pub const emergencyCutIndex = @import("agent_compact.zig").emergencyCutIndex;
pub const emergencyTrim = @import("agent_compact.zig").emergencyTrim;
pub const compactOrRecover = @import("agent_compact.zig").compactOrRecover;
pub const capOversizedToolOutputs = @import("agent_compact.zig").capOversizedToolOutputs;

// The live streaming path (thinking spinner, live "Thinking" reasoning
// block, and postStream itself — the root agent's streaming POST) lives
Expand Down
101 changes: 94 additions & 7 deletions src/agent_compact.zig
Original file line number Diff line number Diff line change
Expand Up @@ -400,30 +400,32 @@ fn isToolOutputMsg(m: Value) bool {
return false;
}

fn truncateStrField(arena: Allocator, o: *std.json.ObjectMap, key: []const u8, cap: usize) usize {
fn truncateStrField(arena: Allocator, o: *std.json.ObjectMap, key: []const u8, cap: usize, note: []const u8) usize {
const v = o.get(key) orelse return 0;
if (v != .string or v.string.len <= cap) return 0;
const orig = v.string.len;
const stub = std.fmt.allocPrint(arena, "{s}\n[old tool output truncated to recover context (#163)]", .{utf8Prefix(v.string, cap)}) catch return 0;
// Keep the prefix short enough that prefix + '\n' + note <= cap, so the marker
// never grows an output that was only barely over the cap.
const stub = std.fmt.allocPrint(arena, "{s}\n{s}", .{ utf8Prefix(v.string, cap -| (note.len + 1)), note }) catch return 0;
o.put(arena, key, .{ .string = stub }) catch return 0;
return orig -| stub.len;
}

/// Truncate an over-large tool-output payload in `m` in place to ~`cap` bytes,
/// preserving the message and its call/output pairing. Returns bytes reclaimed.
fn truncateToolOutput(arena: Allocator, m: *Value, cap: usize) usize {
fn truncateToolOutput(arena: Allocator, m: *Value, cap: usize, note: []const u8) usize {
if (m.* != .object) return 0;
if (m.object.get("type")) |t| if (t == .string and std.mem.eql(u8, t.string, "function_call_output"))
return truncateStrField(arena, &m.object, "output", cap);
return truncateStrField(arena, &m.object, "output", cap, note);
if (m.object.get("role")) |r| if (r == .string) {
if (std.mem.eql(u8, r.string, "tool")) return truncateStrField(arena, &m.object, "content", cap);
if (std.mem.eql(u8, r.string, "tool")) return truncateStrField(arena, &m.object, "content", cap, note);
if (std.mem.eql(u8, r.string, "user")) if (m.object.get("content")) |c| if (c == .array) {
var saved: usize = 0;
for (m.object.get("content").?.array.items) |*blk| {
if (blk.* != .object) continue;
const bt = blk.object.get("type") orelse continue;
if (bt == .string and std.mem.eql(u8, bt.string, "tool_result"))
saved += truncateStrField(arena, &blk.object, "content", cap);
saved += truncateStrField(arena, &blk.object, "content", cap, note);
}
return saved;
};
Expand Down Expand Up @@ -451,7 +453,28 @@ pub fn trimOldestToolOutputs(self: *Agent) usize {
if (!isToolOutputMsg(m.*)) continue;
seen += 1;
if (seen > total - keep_recent) break; // keep the most recent verbatim
reclaimed += truncateToolOutput(self.arena, m, stub_cap);
reclaimed += truncateToolOutput(self.arena, m, stub_cap, "[old tool output truncated to recover context (#163)]");
}
if (reclaimed > 0) self.last_context_tokens = 0; // force a re-measure next turn
return reclaimed;
}

/// #193 follow-up: bound ANY single tool output to `cap` serialized bytes before
/// send, in place, across every wire format (responses `function_call_output`,
/// openai `role:"tool"`, anthropic `tool_result` blocks). normalizeResponsesHistory
/// already hard-caps the responses output at the Responses API limit, but
/// anthropic/openai tool results had no per-output bound: one uncapped result (a
/// runaway MCP tool, a big fetch on a small-window model) could alone push the
/// input past the window — and past what emergencyTrim can reclaim, since it keeps
/// the most-recent outputs verbatim. Cap is window-proportional (Provider.perOutputCap)
/// so large-context models keep full results untouched. Preserves every call/output
/// pairing (shrinks strings, never drops a message). Returns bytes reclaimed.
pub fn capOversizedToolOutputs(self: *Agent, cap: usize) usize {
if (cap == 0) return 0;
var reclaimed: usize = 0;
for (self.messages.items) |*m| {
if (isToolOutputMsg(m.*))
reclaimed += truncateToolOutput(self.arena, m, cap, "[tool output truncated: over this model's per-result cap — read/fetch a smaller range (#193)]");
}
if (reclaimed > 0) self.last_context_tokens = 0; // force a re-measure next turn
return reclaimed;
Expand Down Expand Up @@ -544,6 +567,70 @@ test "trimOldestToolOutputs recovers a runaway tool-loop history (#163)" {
try std.testing.expectEqual(@as(usize, 11), agent.messages.items.len); // no message dropped
}

test "capOversizedToolOutputs (#193): bounds an oversized output in every wire format, leaves small ones + non-tool msgs" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
const cap: usize = 1024;
const big = try a.alloc(u8, 8192);
@memset(big, 'x');

var msgs = std.json.Array.init(a);
// responses function_call_output (oversized)
var fco: std.json.ObjectMap = .empty;
try fco.put(a, "type", .{ .string = "function_call_output" });
try fco.put(a, "call_id", .{ .string = "c1" });
try fco.put(a, "output", .{ .string = big });
try msgs.append(.{ .object = fco });
// openai role:"tool" (oversized)
var tool: std.json.ObjectMap = .empty;
try tool.put(a, "role", .{ .string = "tool" });
try tool.put(a, "tool_call_id", .{ .string = "c2" });
try tool.put(a, "content", .{ .string = big });
try msgs.append(.{ .object = tool });
// anthropic user turn carrying a tool_result block (oversized)
var user: std.json.ObjectMap = .empty;
try user.put(a, "role", .{ .string = "user" });
var blocks = std.json.Array.init(a);
var tr: std.json.ObjectMap = .empty;
try tr.put(a, "type", .{ .string = "tool_result" });
try tr.put(a, "tool_use_id", .{ .string = "c3" });
try tr.put(a, "content", .{ .string = big });
try blocks.append(.{ .object = tr });
try user.put(a, "content", .{ .array = blocks });
try msgs.append(.{ .object = user });
// a small tool output (within cap) — must be left untouched
var small: std.json.ObjectMap = .empty;
try small.put(a, "type", .{ .string = "function_call_output" });
try small.put(a, "output", .{ .string = "ok" });
try msgs.append(.{ .object = small });
// a plain assistant text message — never a tool output, left untouched
try msgs.append(try textMessage(a, "assistant", "hello"));

var agent: Agent = undefined;
agent.arena = a;
agent.messages = msgs;
agent.last_context_tokens = 42;

const reclaimed = capOversizedToolOutputs(&agent, cap);
try std.testing.expect(reclaimed > 0);
try std.testing.expectEqual(@as(usize, 0), agent.last_context_tokens); // forces a re-measure

// every oversized tool output is now within the cap, with a marker
const out0 = agent.messages.items[0].object.get("output").?.string;
try std.testing.expect(out0.len <= cap);
try std.testing.expect(std.mem.indexOf(u8, out0, "truncated") != null);
const out1 = agent.messages.items[1].object.get("content").?.string;
try std.testing.expect(out1.len <= cap);
const block = agent.messages.items[2].object.get("content").?.array.items[0];
try std.testing.expect(block.object.get("content").?.string.len <= cap);
// within-cap output and the non-tool message are untouched
try std.testing.expectEqualStrings("ok", agent.messages.items[3].object.get("output").?.string);
try std.testing.expectEqualStrings("hello", agent.messages.items[4].object.get("content").?.string);
// cap == 0 disables the cap entirely (unknown window)
try std.testing.expectEqual(@as(usize, 0), capOversizedToolOutputs(&agent, 0));
}

test "cleanUserTurn: plain user text yes; assistant/tool_result no" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
Expand Down
112 changes: 111 additions & 1 deletion src/agent_request.zig
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,102 @@ test "isAuthError (#148): auth failures only, not credits/rate/other" {
try std.testing.expect(!isAuthError("context length exceeded"));
}

/// True if `msg` is a provider's "input is over the context window" rejection,
/// across wire formats: codex/responses ("exceeds the context window"), openai
/// ("maximum context length", "context_length_exceeded"), anthropic ("prompt is
/// too long", "exceed context limit"). Drives the in-turn emergency-trim + retry
/// recovery symmetrically for every provider (#193) — before it, only the codex
/// path recovered and anthropic/openai died on an over-window turn.
fn isContextOverflow(msg: []const u8) bool {
const needles = [_][]const u8{
"context window", // codex/responses: "exceeds the context window"
"context length", // openai: "maximum context length is N tokens"
"context_length_exceeded", // openai error code echoed into the message
"context limit", // anthropic: "input length and max_tokens exceed context limit"
"prompt is too long", // anthropic: "prompt is too long: N tokens > M maximum"
"maximum context", // defensive: "maximum context ... exceeded"
};
for (needles) |n| if (std.mem.indexOf(u8, msg, n) != null) return true;
return false;
}

/// #193 follow-up: shared in-turn context-overflow recovery for the three
/// anthropic/openai error branches (streamed error event, non-streamed
/// `{"type":"error"}` envelope, and the generic apiErrorMessage path). Before
/// this, only the codex/.responses branch recovered — anthropic/openai died on an
/// over-window turn. Returns true if the caller should `continue` the rebuild loop
/// (emergency-trimmed, retry the same request once); false to fall through to the
/// normal error. Pins the meter to the window FIRST so the between-turns
/// compaction engages even when we can't recover here (the rejected request
/// returns no usage to correct the lagging meter). Guarded by `retried` (one
/// `context_retried` shared across every branch of a request) so a second overflow
/// falls through and never loops. These wire formats send the full input each
/// rebuild, so — unlike the codex branch — no closeCodexWs re-anchor is needed.
fn recoverContextOverflow(self: *Agent, msg: []const u8, retried: *bool) bool {
if (!isContextOverflow(msg)) return false;
self.last_context_tokens = self.provider.context;
if (retried.* or self.emergencyTrim() == 0) return false;
retried.* = true;
if (self.tracer) |tr| tr.note("context", "input over the window — emergency-trimmed and retrying the turn");
return true;
}

test "isContextOverflow (#193): matches every provider's overflow phrasing, not unrelated errors" {
// codex/responses, openai, anthropic wire-format rejections all recover in-turn
try std.testing.expect(isContextOverflow("Your input exceeds the context window of 272000 tokens"));
try std.testing.expect(isContextOverflow("This model's maximum context length is 128000 tokens. However, you requested 130000"));
try std.testing.expect(isContextOverflow("context_length_exceeded"));
try std.testing.expect(isContextOverflow("prompt is too long: 219373 tokens > 200000 maximum"));
try std.testing.expect(isContextOverflow("input length and max_tokens exceed context limit"));
// unrelated API errors must NOT trigger a trim + retry
try std.testing.expect(!isContextOverflow("The API Key appears to be invalid or may have expired."));
try std.testing.expect(!isContextOverflow("tool_choice is not supported"));
try std.testing.expect(!isContextOverflow("rate limit exceeded"));
try std.testing.expect(!isContextOverflow("model not found"));
}

test "recoverContextOverflow (#193): overflow trims + retries once; guard and non-overflow fall through" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
// a runaway tool-loop history emergencyTrim can reclaim (mirrors the #163 shape:
// one clean user turn then only tool outputs, so trimOldestToolOutputs recovers)
var msgs = std.json.Array.init(a);
var um: std.json.ObjectMap = .empty;
try um.put(a, "role", .{ .string = "user" });
try um.put(a, "content", .{ .string = "do a thing" });
try msgs.append(.{ .object = um });
const big = try a.alloc(u8, 5000);
@memset(big, 'x');
var i: usize = 0;
while (i < 10) : (i += 1) {
var o: std.json.ObjectMap = .empty;
try o.put(a, "type", .{ .string = "function_call_output" });
try o.put(a, "call_id", .{ .string = "c" });
try o.put(a, "output", .{ .string = big });
try msgs.append(.{ .object = o });
}
var agent: Agent = undefined;
agent.arena = a;
agent.messages = msgs;
agent.tracer = null;
agent.provider = .{ .id = "anthropic", .kind = .anthropic, .auth = .x_api_key, .url = "", .api_key = "", .model = "claude", .context = 100000 };
agent.last_context_tokens = 0;

// overflow + trimmable history -> recovers (retry the turn), guard flips
var retried = false;
try std.testing.expect(recoverContextOverflow(&agent, "prompt is too long: 999 tokens > 100 maximum", &retried));
try std.testing.expect(retried);
// a second overflow this request -> guard blocks a re-trim (no loop), but the
// meter stays pinned to the window so the between-turns compaction still engages
try std.testing.expect(!recoverContextOverflow(&agent, "prompt is too long", &retried));
try std.testing.expectEqual(agent.provider.context, agent.last_context_tokens);
// an unrelated error never recovers, regardless of the guard
var retried2 = false;
try std.testing.expect(!recoverContextOverflow(&agent, "invalid api key", &retried2));
try std.testing.expect(!retried2);
}

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();
Expand Down Expand Up @@ -114,6 +210,14 @@ pub fn request(self: *Agent, tools: ?[]const u8) !std.json.ObjectMap {
sanitizeMessagesUtf8(self.arena, &self.messages); // invalid UTF-8 (any source/format) -> '?' so content never serializes as a byte-int array the API rejects
if (self.provider.kind == .responses) normalizeResponsesHistory(self.arena, &self.messages);
if (self.provider.kind == .openai) normalizeOpenAIHistory(self.arena, &self.messages); // #99: chat-completions sibling of the above
// #193 follow-up: bound any single oversized tool output (an uncapped MCP
// result, a huge fetch on a small-window model) before send. The responses
// path already hard-caps output above (normalizeResponsesHistory); this is the
// provider-agnostic sibling so one pathological result can't alone overflow the
// window past what the in-turn recovery below can reclaim (it keeps the most
// recent outputs verbatim). Window-proportional, so large-context models keep
// full tool results untouched.
_ = self.capOversizedToolOutputs(self.provider.perOutputCap());
var context_retried = false; // #193: at most one in-turn overflow recovery per request
rebuild: while (true) {
const live = !self.sub and self.out != null and !self.stream_quiet;
Expand Down Expand Up @@ -257,7 +361,7 @@ pub fn request(self: *Agent, tools: ?[]const u8) !std.json.ObjectMap {
// 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) {
if (isContextOverflow(msg)) {
self.last_context_tokens = self.provider.context;
// #193: the local pre-send gate uses a byte/4 LOWER bound, so
// the backend can still reject an input it let through. A good
Expand Down Expand Up @@ -292,6 +396,7 @@ pub fn request(self: *Agent, tools: ?[]const u8) !std.json.ObjectMap {
const eo = if (root.get("error")) |ev| (if (ev == .object) ev.object else null) else null;
const etype = if (eo) |e| (if (e.get("type")) |tv| (if (tv == .string) tv.string else "error") else "error") else "error";
const emsg = if (eo) |e| (if (e.get("message")) |mv| (if (mv == .string) mv.string else "") else "") else "";
if (recoverContextOverflow(self, emsg, &context_retried)) continue; // #193: streamed error event that is an overflow → trim + retry
if (self.tracer) |tr| tr.api(self.label, self.provider.model, ms, body.len, resp_body.len, 0, 0, true);
try self.sayApiError("api error ({s}): {s}", .{ etype, emsg });
return error.ApiError;
Expand All @@ -315,6 +420,7 @@ pub fn request(self: *Agent, tools: ?[]const u8) !std.json.ObjectMap {
const eo = if (root.get("error")) |ev| (if (ev == .object) ev.object else null) else null;
const etype = if (eo) |e| (if (e.get("type")) |tv| (if (tv == .string) tv.string else "error") else "error") else "error";
const emsg = if (eo) |e| (if (e.get("message")) |mv| (if (mv == .string) mv.string else "") else "") else "";
if (recoverContextOverflow(self, emsg, &context_retried)) continue; // #193: anthropic {"type":"error"} overflow → trim + retry
if (self.tracer) |tr| tr.api(self.label, self.provider.model, ms, body.len, resp_body.len, 0, 0, true);
try self.sayApiError("api error ({s}): {s}", .{ etype, emsg });
return error.ApiError;
Expand Down Expand Up @@ -349,6 +455,10 @@ pub fn request(self: *Agent, tools: ?[]const u8) !std.json.ObjectMap {
continue;
}
}
// #193 follow-up: recover an anthropic/openai context-window rejection
// in-turn instead of failing the turn (before this only codex recovered;
// anthropic and openai died). Shared with the two error branches above.
if (recoverContextOverflow(self, msg, &context_retried)) continue;
if (self.tracer) |tr| tr.api(self.label, self.provider.model, ms, body.len, resp_body.len, 0, 0, true);
try self.sayApiError("api error: {s}", .{msg});
return error.ApiError;
Expand Down
13 changes: 13 additions & 0 deletions src/provider.zig
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,19 @@ pub const Provider = struct {
pub fn compactAt(p: Provider) u64 {
return p.context / 10 * 8;
}

/// #193 follow-up: the largest a SINGLE tool output may be, in serialized
/// bytes, before it is truncated at send time (capOversizedToolOutputs).
/// Window-proportional — ~50% of the window in estimated tokens (~4 bytes /
/// token) — so large-context models keep full tool results untouched and only
/// a result big enough to threaten the window on its own is bounded. This
/// guarantees no single output can alone overflow past what the in-turn
/// emergency-trim recovery can reclaim (it keeps the most-recent outputs
/// verbatim). 0 (unknown window) disables the cap.
pub fn perOutputCap(p: Provider) usize {
if (p.context == 0) return 0;
return @intCast(p.context * 2);
}
};

/// One optional API key per provider_specs entry, read from the environment.
Expand Down
Loading