Skip to content
Closed
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
7 changes: 7 additions & 0 deletions docs/adr/0030-rlm-late-showcase.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion src/agent.zig
Original file line number Diff line number Diff line change
Expand Up @@ -375,9 +375,11 @@ 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);
}
}
// 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());
const done = switch (self.provider.kind) {
Expand Down
5 changes: 4 additions & 1 deletion src/agent_tools.zig
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ 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();
// 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);
const eval_index = eval_control.evalCallIndex(calls);
Expand Down
16 changes: 15 additions & 1 deletion src/native_fold.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -359,6 +361,18 @@ fn refreshAgentCatalog(agent: anytype) void {
agent.ensureRootTools(agent.provider.kind) catch {};
}

/// 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);
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
Expand Down
40 changes: 40 additions & 0 deletions src/native_fold_rlm.zig
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,46 @@ 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 {
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 {
Expand Down
12 changes: 12 additions & 0 deletions src/serde.zig
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,11 @@ 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 {
// 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});
var repaired = false;
Expand Down Expand Up @@ -485,3 +490,10 @@ 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"));
}

// 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();
try std.testing.expectEqualStrings("[]", try renderOpenAITools(arena_state.allocator(), ""));
}