From c2f727e188c1558bcc6b34569af2feab3b0366c0 Mon Sep 17 00:00:00 2001 From: T Date: Mon, 25 May 2026 22:54:43 -0600 Subject: phase 3 part 1 --- libpanto/src/anthropic_messages_json.zig | 250 ++++++++++++++++++++++++++++++- 1 file changed, 243 insertions(+), 7 deletions(-) (limited to 'libpanto/src/anthropic_messages_json.zig') diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig index 8f44edc..355b3ce 100644 --- a/libpanto/src/anthropic_messages_json.zig +++ b/libpanto/src/anthropic_messages_json.zig @@ -15,6 +15,7 @@ const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const conversation = @import("conversation.zig"); const config_mod = @import("config.zig"); +const tool_registry_mod = @import("tool_registry.zig"); // ----------------------------------------------------------------------------- // Request serialization @@ -33,6 +34,7 @@ pub fn serializeRequest( allocator: Allocator, cfg: *const config_mod.AnthropicMessagesConfig, conv: *const conversation.Conversation, + tools: *const tool_registry_mod.ToolRegistry, ) ![]u8 { var aw: Writer.Allocating = .init(allocator); errdefer aw.deinit(); @@ -59,6 +61,23 @@ pub fn serializeRequest( try s.write(system_buf.items); } + if (tools.count() > 0) { + try s.objectField("tools"); + try s.beginArray(); + var it = tools.iterator(); + while (it.next()) |t| { + try s.beginObject(); + try s.objectField("name"); + try s.write(t.name); + try s.objectField("description"); + try s.write(t.description); + try s.objectField("input_schema"); + try writeRawJson(&s, t.schema_json); + try s.endObject(); + } + try s.endArray(); + } + // Emit messages (everything that isn't .system). try s.objectField("messages"); try s.beginArray(); @@ -73,6 +92,24 @@ pub fn serializeRequest( return try aw.toOwnedSlice(); } +/// Splice a pre-encoded JSON value into the current stringifier position. +/// On parse failure, emit `{}` so we never produce invalid wire JSON. +fn writeRawJson(s: *std.json.Stringify, raw: []const u8) !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const parsed = std.json.parseFromSlice( + std.json.Value, + arena.allocator(), + raw, + .{}, + ) catch { + try s.beginObject(); + try s.endObject(); + return; + }; + try s.write(parsed.value); +} + fn collectSystemPrompt( conv: *const conversation.Conversation, out: *std.ArrayList(u8), @@ -136,8 +173,38 @@ fn writeBlock(s: *std.json.Stringify, block: conversation.ContentBlock) !void { try s.write(sig); try s.endObject(); }, - // ToolUse / ToolResult: phase 3+. - .ToolUse, .ToolResult => {}, + .ToolUse => |tu| { + try s.beginObject(); + try s.objectField("type"); + try s.write("tool_use"); + try s.objectField("id"); + try s.write(tu.id); + try s.objectField("name"); + try s.write(tu.name); + try s.objectField("input"); + // Anthropic expects `input` as a nested JSON object, not a + // string. The block's input bytes were assembled from + // `input_json_delta` fragments — splice them in verbatim. + // Treat empty as an empty object. + const input_bytes = tu.input.items; + if (input_bytes.len == 0) { + try s.beginObject(); + try s.endObject(); + } else { + try writeRawJson(s, input_bytes); + } + try s.endObject(); + }, + .ToolResult => |tr| { + try s.beginObject(); + try s.objectField("type"); + try s.write("tool_result"); + try s.objectField("tool_use_id"); + try s.write(tr.tool_use_id); + try s.objectField("content"); + try s.write(tr.content.items); + try s.endObject(); + }, } } @@ -382,7 +449,9 @@ test "serializeRequest - system extracted into top-level field" { try conv.addUserMessage("Hello!"); const cfg = testConfig("claude-sonnet-4-20250514"); - const body = try serializeRequest(allocator, &cfg, &conv); + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); defer allocator.free(body); var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); @@ -417,7 +486,9 @@ test "serializeRequest - multiple system messages concatenated with newlines" { try conv.addUserMessage("Hi"); const cfg = testConfig("claude-x"); - const body = try serializeRequest(allocator, &cfg, &conv); + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); defer allocator.free(body); var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); @@ -436,7 +507,9 @@ test "serializeRequest - no system messages omits the system field" { try conv.addUserMessage("Hi"); const cfg = testConfig("claude-x"); - const body = try serializeRequest(allocator, &cfg, &conv); + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); defer allocator.free(body); var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); @@ -460,7 +533,9 @@ test "serializeRequest - signed assistant Thinking blocks round-trip" { }); const cfg = testConfig("claude-x"); - const body = try serializeRequest(allocator, &cfg, &conv); + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); defer allocator.free(body); var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); @@ -501,7 +576,9 @@ test "serializeRequest - unsigned Thinking blocks are dropped" { }); const cfg = testConfig("claude-x"); - const body = try serializeRequest(allocator, &cfg, &conv); + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); defer allocator.free(body); var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); @@ -636,3 +713,162 @@ test "parseStreamEvent - unknown type" { defer pe.deinit(); try testing.expectEqual(StreamEventTag.unknown, @as(StreamEventTag, pe.event)); } + +// ----------------------------------------------------------------------------- +// Phase 3: tools serialization, ToolUse + ToolResult content blocks +// ----------------------------------------------------------------------------- + +const tool_mod = @import("tool.zig"); + +const StaticToolVT = struct { + fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror![]u8 { + return error.NotImplementedInTest; + } + fn deinit_(_: *anyopaque, _: Allocator) void {} + const v: tool_mod.Tool.VTable = .{ .invoke = invoke, .deinit = deinit_ }; +}; +var static_tool_ctx_sentinel: u8 = 0; +fn makeStaticTool( + name: []const u8, + description: []const u8, + schema: []const u8, +) tool_mod.Tool { + return .{ + .name = name, + .description = description, + .schema_json = schema, + .ctx = &static_tool_ctx_sentinel, + .vtable = &StaticToolVT.v, + }; +} + +test "serializeRequest - emits tools array when registry non-empty" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("call something"); + + var tools = tool_registry_mod.ToolRegistry.init(allocator); + defer tools.deinit(); + try tools.register(makeStaticTool( + "echo", + "Echo a message back.", + \\{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]} + )); + + const cfg = testConfig("claude-x"); + const body = try serializeRequest(allocator, &cfg, &conv, &tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + + const arr = parsed.value.object.get("tools").?.array.items; + try testing.expectEqual(@as(usize, 1), arr.len); + try testing.expectEqualStrings("echo", arr[0].object.get("name").?.string); + try testing.expectEqualStrings("Echo a message back.", arr[0].object.get("description").?.string); + + const schema = arr[0].object.get("input_schema").?.object; + try testing.expectEqualStrings("object", schema.get("type").?.string); + try testing.expect(schema.get("properties").? == .object); +} + +test "serializeRequest - assistant ToolUse becomes tool_use content block" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + + const id = try allocator.dupe(u8, "tu_1"); + const name = try allocator.dupe(u8, "echo"); + var input: conversation.TextualBlock = .empty; + try input.appendSlice(allocator, "{\"message\":\"hi\"}"); + + try conv.addAssistantMessage(&.{ + .{ .Text = try conversation.textualBlockFromSlice(allocator, "calling tool") }, + .{ .ToolUse = .{ .id = id, .name = name, .input = input } }, + }); + + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const cfg = testConfig("claude-x"); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + + const content = parsed.value.object.get("messages").?.array.items[0] + .object.get("content").?.array.items; + try testing.expectEqual(@as(usize, 2), content.len); + try testing.expectEqualStrings("text", content[0].object.get("type").?.string); + + try testing.expectEqualStrings("tool_use", content[1].object.get("type").?.string); + try testing.expectEqualStrings("tu_1", content[1].object.get("id").?.string); + try testing.expectEqualStrings("echo", content[1].object.get("name").?.string); + // `input` is a nested JSON object, NOT a string. + const inp = content[1].object.get("input").?.object; + try testing.expectEqualStrings("hi", inp.get("message").?.string); +} + +test "serializeRequest - user ToolResult becomes tool_result content block" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + + const id = try allocator.dupe(u8, "tu_1"); + var content: conversation.TextualBlock = .empty; + try content.appendSlice(allocator, "the answer is 42"); + + var blocks: std.ArrayList(conversation.ContentBlock) = .empty; + try blocks.append(allocator, .{ .ToolResult = .{ .tool_use_id = id, .content = content } }); + try conv.messages.append(allocator, .{ .role = .user, .content = blocks }); + + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const cfg = testConfig("claude-x"); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + + const msg = parsed.value.object.get("messages").?.array.items[0].object; + try testing.expectEqualStrings("user", msg.get("role").?.string); + + const arr = msg.get("content").?.array.items; + try testing.expectEqual(@as(usize, 1), arr.len); + try testing.expectEqualStrings("tool_result", arr[0].object.get("type").?.string); + try testing.expectEqualStrings("tu_1", arr[0].object.get("tool_use_id").?.string); + try testing.expectEqualStrings("the answer is 42", arr[0].object.get("content").?.string); +} + +test "serializeRequest - empty ToolUse input becomes {} not invalid JSON" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + + const id = try allocator.dupe(u8, "tu_1"); + const name = try allocator.dupe(u8, "noargs"); + try conv.addAssistantMessage(&.{ + .{ .ToolUse = .{ .id = id, .name = name, .input = .empty } }, + }); + + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const cfg = testConfig("claude-x"); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + + const tu = parsed.value.object.get("messages").?.array.items[0] + .object.get("content").?.array.items[0].object; + try testing.expectEqualStrings("tool_use", tu.get("type").?.string); + try testing.expect(tu.get("input").? == .object); + try testing.expectEqual(@as(usize, 0), tu.get("input").?.object.count()); +} -- cgit v1.3