From 357d41a4c59720f96a005b8190471032af211358 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 01:44:54 +0000 Subject: [PATCH 1/2] fix: rebuild the tool catalog after a wide-native rlm showcase ADR 0030 showcased rlm after a 4-wide read/bash batch and only invalidated the cached catalog. The next xAI Responses body was `"tools":,` (empty toolsJson printed after the field), so scatter-sum and multi-read 400'd mid-turn. Rebuild on showcase, ensure before every request, and write [] for an empty catalog so that shape cannot reach the wire. --- src/agent.zig | 5 ++++- src/agent_tools.zig | 2 +- src/native_fold.zig | 9 +++++++++ src/native_fold_rlm.zig | 39 +++++++++++++++++++++++++++++++++++++++ src/serde.zig | 9 +++++++++ 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/agent.zig b/src/agent.zig index 7f3ad6ac..1514cd7d 100644 --- a/src/agent.zig +++ b/src/agent.zig @@ -375,9 +375,12 @@ pub const Agent = struct { const fold = @import("native_fold.zig"); if (fold.noticeContext(self.effectiveContextTokens(), self.provider.compactAt())) { self.invalidateRootTools(); - try self.ensureRootTools(self.provider.kind); } } + // Any mid-turn invalidate (wide-native showcase, MCP join, load) + // must rebuild before toolsJson() is read. ensure is a no-op when + // the active encoding is already populated. + try self.ensureRootTools(self.provider.kind); const hist_len = self.messages.items.len; const root = try self.request(if (self.text_only) null else self.toolsJson()); const done = switch (self.provider.kind) { diff --git a/src/agent_tools.zig b/src/agent_tools.zig index dcb1a8c1..38b24ae5 100644 --- a/src/agent_tools.zig +++ b/src/agent_tools.zig @@ -73,7 +73,7 @@ pub fn runTools(self: *Agent, calls: []const ToolCall) ![]ExecResult { var names: [32][]const u8 = undefined; const n = @min(calls.len, names.len); for (calls[0..n], 0..) |c, i| names[i] = c.name; - if (native_fold.noticeWideNative(names[0..n])) self.invalidateRootTools(); + _ = native_fold.noticeWideNativeAndRefresh(self, names[0..n]); } const results = try self.arena.alloc(ExecResult, calls.len); const eval_index = eval_control.evalCallIndex(calls); diff --git a/src/native_fold.zig b/src/native_fold.zig index dc385bf5..efc4c99d 100644 --- a/src/native_fold.zig +++ b/src/native_fold.zig @@ -359,6 +359,15 @@ fn refreshAgentCatalog(agent: anytype) void { agent.ensureRootTools(agent.provider.kind) catch {}; } +/// ADR 0030 wide-native showcase: mark rlm loaded and rebuild the cached +/// catalog. Invalidate-only left `toolsJson()` empty, so the next Responses +/// body was `"tools":,` and xAI 400'd the rlm-suite scatter tasks. +pub fn noticeWideNativeAndRefresh(agent: anytype, names: []const []const u8) bool { + if (!noticeWideNative(names)) return false; + refreshAgentCatalog(agent); + return true; +} + /// The native half of load_tool_schemas, called from agent_tools before the /// MCP handler: an input naming folded native tools loads them (real schema /// in the result, enabled for the session). Returns null when the input diff --git a/src/native_fold_rlm.zig b/src/native_fold_rlm.zig index caea1592..b7e20620 100644 --- a/src/native_fold_rlm.zig +++ b/src/native_fold_rlm.zig @@ -161,6 +161,45 @@ test "wide native batch showcases rlm; MCP fan-out does not" { try std.testing.expect(fold.listed()); } +test "wide-native showcase rebuilds the cached catalog (not invalidate-only)" { + const provider_mod = @import("provider.zig"); + const FakeAgent = struct { + provider: struct { kind: provider_mod.Provider.Kind } = .{ .kind = .responses }, + invalidations: usize = 0, + rebuilds: usize = 0, + pub fn invalidateRootTools(self: *@This()) void { + self.invalidations += 1; + } + pub fn ensureRootTools(self: *@This(), kind: provider_mod.Provider.Kind) !void { + try std.testing.expectEqual(provider_mod.Provider.Kind.responses, kind); + self.rebuilds += 1; + } + }; + + const saved_spec = rlm_spec.available; + defer { + rlm_spec.available = saved_spec; + fold.resetRlmDiscovery(); + } + isolate(); + rlm_spec.available = true; + + var agent: FakeAgent = .{}; + try std.testing.expect(!fold.noticeWideNativeAndRefresh(&agent, &.{ "read_file", "codedb", "bash" })); + try std.testing.expectEqual(@as(usize, 0), agent.invalidations); + try std.testing.expectEqual(@as(usize, 0), agent.rebuilds); + + try std.testing.expect(fold.noticeWideNativeAndRefresh(&agent, &.{ "read_file", "codedb", "bash", "webfetch" })); + try std.testing.expect(fold.listed()); + try std.testing.expect(fold.isLoaded("rlm")); + try std.testing.expectEqual(@as(usize, 1), agent.invalidations); + try std.testing.expectEqual(@as(usize, 1), agent.rebuilds); + + try std.testing.expect(!fold.noticeWideNativeAndRefresh(&agent, &.{ "read_file", "codedb", "bash", "webfetch" })); + try std.testing.expectEqual(@as(usize, 1), agent.invalidations); + try std.testing.expectEqual(@as(usize, 1), agent.rebuilds); +} + test "context below 50% of compactAt does not showcase; crossing it does" { const saved_spec = rlm_spec.available; defer { diff --git a/src/serde.zig b/src/serde.zig index 2af99a8e..291953e2 100644 --- a/src/serde.zig +++ b/src/serde.zig @@ -334,6 +334,9 @@ pub fn writeKimiTools(s: *std.json.Stringify, arena: Allocator, raw: []const u8) /// catalog that needs no repair, or that we cannot parse, goes out as its /// original bytes so the usual request keeps a byte-identical cached prefix. pub fn writeOpenAITools(s: *std.json.Stringify, arena: Allocator, raw: []const u8) !void { + // Empty catalog must still be a JSON array. Printing the raw empty slice + // after `"tools":` produced `"tools":,` (xAI 400). + if (raw.len == 0) return s.print("[]", .{}); const value = std.json.parseFromSliceLeaky(Value, arena, raw, .{ .allocate = .alloc_always }) catch return s.print("{s}", .{raw}); if (value != .array) return s.print("{s}", .{raw}); var repaired = false; @@ -485,3 +488,9 @@ test "openai and responses tools give a typeless MCP root schema type object (#2 try std.testing.expectEqualStrings(composed, try renderOpenAITools(arena, composed)); try std.testing.expectEqualStrings("not json", try renderOpenAITools(arena, "not json")); } + +test "empty OpenAI tools catalog writes [] not a missing JSON value" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + try std.testing.expectEqualStrings("[]", try renderOpenAITools(arena_state.allocator(), "")); +} From 30f44c6916b2a6d18dbe66d05e2ab51f86fb2291 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 02:16:18 +0000 Subject: [PATCH 2/2] docs: tell later agents why the catalog rebuild after rlm showcase stays Invalidate-only after a 4-wide native batch shipped `"tools":,` and xAI 400'd the rlm rematch. Comments at the call sites plus ADR 0030 name the failure so this does not get "simplified" back. --- docs/adr/0030-rlm-late-showcase.md | 7 +++++++ src/agent.zig | 5 ++--- src/agent_tools.zig | 3 +++ src/native_fold.zig | 13 +++++++++---- src/native_fold_rlm.zig | 1 + src/serde.zig | 7 +++++-- 6 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/adr/0030-rlm-late-showcase.md b/docs/adr/0030-rlm-late-showcase.md index 13f955dd..f53e44a3 100644 --- a/docs/adr/0030-rlm-late-showcase.md +++ b/docs/adr/0030-rlm-late-showcase.md @@ -67,3 +67,10 @@ sticks for the process. `--old` resets discovery. - Revisit if a measured coding one-shot regresses to N serial reads because the model never emits a 4-wide batch, never crosses 50% of compactAt, and never sees `rlm`. +- **Showcase must rebuild the cached catalog.** `noticeWideNative` / + `noticeContext` only flip `g_loaded`. `invalidateRootTools` without + `ensureRootTools` leaves `toolsJson()` as `""`, and the next Responses + body is `"tools":,` — xAI 400. The 2026-08-28 grok-build rematch + dropped the rlm suite to 3/5 (`scatter-sum`, `multi-read`) while SWE + stayed 5/6 (no 4-wide native batch). Call + `noticeWideNativeAndRefresh`. Do not revert that to invalidate-only. diff --git a/src/agent.zig b/src/agent.zig index 1514cd7d..4b937ddc 100644 --- a/src/agent.zig +++ b/src/agent.zig @@ -377,9 +377,8 @@ pub const Agent = struct { self.invalidateRootTools(); } } - // Any mid-turn invalidate (wide-native showcase, MCP join, load) - // must rebuild before toolsJson() is read. ensure is a no-op when - // the active encoding is already populated. + // Safety net: empty toolsJson after invalidate is `"tools":,` + // on Responses (xAI 400). ensure is a no-op if already filled. try self.ensureRootTools(self.provider.kind); const hist_len = self.messages.items.len; const root = try self.request(if (self.text_only) null else self.toolsJson()); diff --git a/src/agent_tools.zig b/src/agent_tools.zig index 38b24ae5..ec562462 100644 --- a/src/agent_tools.zig +++ b/src/agent_tools.zig @@ -73,6 +73,9 @@ pub fn runTools(self: *Agent, calls: []const ToolCall) ![]ExecResult { var names: [32][]const u8 = undefined; const n = @min(calls.len, names.len); for (calls[0..n], 0..) |c, i| names[i] = c.name; + // ADR 0030: ≥4 native reads/bash showcases rlm. Must rebuild the + // cached catalog here — invalidate-only shipped `"tools":,` on the + // next request (rlm rematch 2026-08-28). See noticeWideNativeAndRefresh. _ = native_fold.noticeWideNativeAndRefresh(self, names[0..n]); } const results = try self.arena.alloc(ExecResult, calls.len); diff --git a/src/native_fold.zig b/src/native_fold.zig index efc4c99d..bae9bf2e 100644 --- a/src/native_fold.zig +++ b/src/native_fold.zig @@ -193,7 +193,9 @@ fn unmark(name: []const u8) void { /// showcase — that re-invites the each() footgun (ADR 0029). const wide_native = [_][]const u8{ "read_file", "codedb", "bash", "webfetch" }; -/// True when this batch newly showcased rlm (caller rebuilds the catalog). +/// True when this batch newly showcased rlm. Does NOT rebuild the Agent +/// catalog — `g_loaded` alone leaves `toolsJson()` empty after invalidate. +/// Production call sites must use `noticeWideNativeAndRefresh`. pub fn noticeWideNative(names: []const []const u8) bool { if (listed() or !@import("rlm_spec.zig").available) return false; var n: usize = 0; @@ -359,9 +361,12 @@ fn refreshAgentCatalog(agent: anytype) void { agent.ensureRootTools(agent.provider.kind) catch {}; } -/// ADR 0030 wide-native showcase: mark rlm loaded and rebuild the cached -/// catalog. Invalidate-only left `toolsJson()` empty, so the next Responses -/// body was `"tools":,` and xAI 400'd the rlm-suite scatter tasks. +/// ADR 0030 wide-native showcase + catalog rebuild. The 2026-08-28 rematch +/// (`scatter-sum` / `multi-read`) 400'd because `runTools` called +/// `noticeWideNative` then `invalidateRootTools` and never `ensureRootTools`. +/// Next xAI Responses body was `"tools":,` (`toolsJson()` is `""` after +/// invalidate). Do not "fix" this back to invalidate-only — SWE hid it +/// (no 4-wide native batch) while the rlm suite dropped to 3/5. pub fn noticeWideNativeAndRefresh(agent: anytype, names: []const []const u8) bool { if (!noticeWideNative(names)) return false; refreshAgentCatalog(agent); diff --git a/src/native_fold_rlm.zig b/src/native_fold_rlm.zig index b7e20620..90c9124e 100644 --- a/src/native_fold_rlm.zig +++ b/src/native_fold_rlm.zig @@ -161,6 +161,7 @@ test "wide native batch showcases rlm; MCP fan-out does not" { try std.testing.expect(fold.listed()); } +// Guards the 2026-08-28 rematch 400: showcase without rebuild emptied toolsJson. test "wide-native showcase rebuilds the cached catalog (not invalidate-only)" { const provider_mod = @import("provider.zig"); const FakeAgent = struct { diff --git a/src/serde.zig b/src/serde.zig index 291953e2..a905acd9 100644 --- a/src/serde.zig +++ b/src/serde.zig @@ -334,8 +334,10 @@ pub fn writeKimiTools(s: *std.json.Stringify, arena: Allocator, raw: []const u8) /// catalog that needs no repair, or that we cannot parse, goes out as its /// original bytes so the usual request keeps a byte-identical cached prefix. pub fn writeOpenAITools(s: *std.json.Stringify, arena: Allocator, raw: []const u8) !void { - // Empty catalog must still be a JSON array. Printing the raw empty slice - // after `"tools":` produced `"tools":,` (xAI 400). + // Last-resort: `toolsJson()` after invalidate is "". Printing that after + // `"tools":` is `"tools":,` — xAI 400, rlm rematch 2026-08-28. Write [] + // so a missed rebuild cannot ship invalid JSON. The real fix is + // noticeWideNativeAndRefresh + ensureRootTools before the next request. if (raw.len == 0) return s.print("[]", .{}); const value = std.json.parseFromSliceLeaky(Value, arena, raw, .{ .allocate = .alloc_always }) catch return s.print("{s}", .{raw}); if (value != .array) return s.print("{s}", .{raw}); @@ -489,6 +491,7 @@ test "openai and responses tools give a typeless MCP root schema type object (#2 try std.testing.expectEqualStrings("not json", try renderOpenAITools(arena, "not json")); } +// Pin the last-resort: empty toolsJson must not become `"tools":,` on the wire. test "empty OpenAI tools catalog writes [] not a missing JSON value" { var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena_state.deinit();