summaryrefslogtreecommitdiff
path: root/libpanto/src/anthropic_messages_json.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/anthropic_messages_json.zig')
-rw-r--r--libpanto/src/anthropic_messages_json.zig250
1 files changed, 243 insertions, 7 deletions
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());
+}