diff options
| author | t <t@tjp.lol> | 2026-06-18 13:38:32 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-18 13:39:49 -0600 |
| commit | 7c2d8825660f73198149c0b2ce26166b16ba3532 (patch) | |
| tree | ae57a23e5dcb905b29718c63a0ed2f6c65d93d53 /libpanto-c | |
| parent | fc77c3900ef80ff0e2934c03ca37dfc309a6e7b9 (diff) | |
Preserve content blocks and thinking origin in C store append
Round-trip persistent message content through the C session store bridge
instead of dropping it, including tool-use, tool-result, system, and
thinking blocks. Reconstruct borrowed C content blocks with arena-backed
helpers, carry message identity fields like API style/model/reasoning, and
add tests covering append marshalling and thinking signature origin
round-tripping.
Diffstat (limited to 'libpanto-c')
| -rw-r--r-- | libpanto-c/include/panto.h | 59 | ||||
| -rw-r--r-- | libpanto-c/src/lib.zig | 295 |
2 files changed, 341 insertions, 13 deletions
diff --git a/libpanto-c/include/panto.h b/libpanto-c/include/panto.h index d0c272f..c43b39f 100644 --- a/libpanto-c/include/panto.h +++ b/libpanto-c/include/panto.h @@ -58,7 +58,36 @@ typedef struct { size_t message_index; } PantoToolDispatchComplete; typedef struct { PantoEventTag tag; union { PantoMessageRole message_start; PantoBlockStart block_start; PantoToolDetails tool_details; PantoContentDelta content_delta; PantoBlockComplete block_complete; PantoMessageComplete message_complete; PantoProviderRetry provider_retry; PantoToolDispatchStart tool_dispatch_start; PantoToolDispatchComplete tool_dispatch_complete; } data; } PantoEvent; -typedef struct { PantoMessageRole role; bool has_usage; PantoUsage usage; bool has_metadata; PantoSlice metadata; } PantoPersistentMessage; +/* One content block of a message being appended. A flattened, borrow-only + * view: every slice points into the in-memory message and is valid only for + * the duration of the append_messages call. `text` carries the Text body, + * Thinking text, ToolUse input JSON, concatenated ToolResult text, or + * System/CompactionSummary text depending on `tag`. `tool_id` is the ToolUse + * id or ToolResult tool_use_id; `tool_name` is the ToolUse name; `is_error` + * is the ToolResult error flag; `system_mode` is the System block mode; + * `thinking_signature` is the Thinking signature (empty when absent), and the + * `thinking_signature_*` fields record the provider/style/model that emitted + * it. To replay a thinking block faithfully (Anthropic drops thinking blocks + * whose signature lacks a matching origin) a store must persist the signature + * AND all three origin fields, then feed them back via the message builder. */ +typedef struct { + PantoContentBlockTag tag; + PantoSlice text; + PantoSlice tool_id; + PantoSlice tool_name; + bool is_error; + PantoSystemMode system_mode; + PantoSlice thinking_signature; + PantoAPIStyle thinking_signature_api_style; + PantoSlice thinking_signature_base_url; + PantoSlice thinking_signature_model; +} PantoOwnedContentBlock; + +/* `api_style`/`base_url`/`model`/`reasoning` are the per-message wire identity + * (the provider that produced this message), which can drift within a single + * conversation (compaction on a different model, a mid-chat model switch). A + * store should persist these per message rather than from static config. */ +typedef struct { PantoMessageRole role; bool has_usage; PantoUsage usage; bool has_metadata; PantoSlice metadata; PantoAPIStyle api_style; PantoSlice base_url; PantoSlice model; PantoReasoningEffort reasoning; const PantoOwnedContentBlock *content; size_t content_len; } PantoPersistentMessage; typedef struct { PantoStatus (*create)(void *ctx, PantoSession **out); @@ -93,6 +122,34 @@ PANTO_EXPORT PantoStatus panto_conversation_replace_system_message(PantoConversa PANTO_EXPORT PantoStatus panto_conversation_add_user_text(PantoConversation *conversation, const char *text); PANTO_EXPORT PantoStatus panto_conversation_add_assistant_text(PantoConversation *conversation, const char *text, bool has_usage, PantoUsage usage); PANTO_EXPORT PantoStatus panto_conversation_add_compaction_summary(PantoConversation *conversation, const char *text); + +/* Rich, block-level message builder. Accumulate content blocks, then commit + * the whole message to a conversation in one call — the path for rebuilding a + * persisted conversation that contains tool calls or thinking. The builder + * uses the same allocator as the conversation it is committed to. + * + * Usage: create -> add_* (one per block, in order) -> conversation_add_message. + * `panto_conversation_add_message` ALWAYS consumes the builder (it is freed on + * both success and failure); do not destroy it afterwards. Call + * `panto_message_builder_destroy` only to discard a builder you never commit. + * Each `text`/`signature`/etc. argument is copied. */ +typedef struct PantoMessageBuilder PantoMessageBuilder; +PANTO_EXPORT PantoMessageBuilder *panto_message_builder_create(PantoMessageRole role); +PANTO_EXPORT void panto_message_builder_destroy(PantoMessageBuilder *builder); +PANTO_EXPORT PantoStatus panto_message_builder_add_text(PantoMessageBuilder *builder, const char *text); +/* `signature` may be NULL/empty when the provider emitted no signature, in + * which case the `signature_*` origin args are ignored. When a signature is + * supplied, pass the origin (style/base_url/model) it was produced under so + * the block can be replayed; an Anthropic request drops thinking blocks whose + * signature origin does not match the request's base_url/model. */ +PANTO_EXPORT PantoStatus panto_message_builder_add_thinking(PantoMessageBuilder *builder, const char *text, const char *signature, PantoAPIStyle signature_api_style, const char *signature_base_url, const char *signature_model); +PANTO_EXPORT PantoStatus panto_message_builder_add_tool_use(PantoMessageBuilder *builder, const char *id, const char *name, const char *input); +PANTO_EXPORT PantoStatus panto_message_builder_add_tool_result(PantoMessageBuilder *builder, const char *tool_use_id, const char *text, bool is_error); +PANTO_EXPORT PantoStatus panto_message_builder_add_system(PantoMessageBuilder *builder, const char *text, PantoSystemMode mode); +/* Commit the builder's blocks as one message of the builder's role. Consumes + * (frees) the builder. `usage` applies to assistant turns; pass has_usage=false + * otherwise. */ +PANTO_EXPORT PantoStatus panto_conversation_add_message(PantoConversation *conversation, PantoMessageBuilder *builder, bool has_usage, PantoUsage usage); PANTO_EXPORT PantoStatus panto_session_create_null(PantoSession **out); PANTO_EXPORT PantoStatus panto_session_create(PantoSessionStore *store, PantoSessionInfo info, PantoSession **out); PANTO_EXPORT void panto_session_destroy(PantoSession *session); diff --git a/libpanto-c/src/lib.zig b/libpanto-c/src/lib.zig index 716e16b..7aa735f 100644 --- a/libpanto-c/src/lib.zig +++ b/libpanto-c/src/lib.zig @@ -11,6 +11,7 @@ const PantoStream = opaque {}; const PantoConversation = opaque {}; const PantoSession = opaque {}; const PantoSessionStore = opaque {}; +const PantoMessageBuilder = opaque {}; const AgentHandle = struct { agent: *panto.Agent, config: *panto.Config }; const ConversationHandle = struct { conversation: panto.Conversation }; @@ -20,6 +21,7 @@ const NullStoreHandle = struct { store: panto.NullStore }; const FileSystemJSONLStoreHandle = struct { store: panto.FileSystemJSONLStore }; const ToolHandle = struct { ctx: *anyopaque, invoke: PantoToolInvokeFn, destroy: ?PantoToolDestroyFn, decl: panto.ToolDecl }; const ToolSourceHandle = struct { ctx: *anyopaque, invoke_batch: PantoToolSourceInvokeBatchFn, destroy: ?PantoToolDestroyFn, name: []u8, decls: []panto.ToolDecl }; +const MessageBuilder = struct { role: panto.MessageRole, blocks: std.ArrayList(panto.ContentBlock) = .empty }; pub const PantoSlice = extern struct { ptr: ?[*]const u8, len: usize }; pub const PantoStatus = enum(c_int) { ok = 0, err = 1 }; @@ -54,7 +56,8 @@ pub const PantoProviderRetry = extern struct { attempt: usize, max_attempts: usi pub const PantoToolDispatchStart = extern struct { count: usize }; pub const PantoToolDispatchComplete = extern struct { message_index: usize }; pub const PantoEvent = extern struct { tag: PantoEventTag, data: extern union { message_start: PantoMessageRole, block_start: PantoBlockStart, tool_details: PantoToolDetails, content_delta: PantoContentDelta, block_complete: PantoBlockComplete, message_complete: PantoMessageComplete, provider_retry: PantoProviderRetry, tool_dispatch_start: PantoToolDispatchStart, tool_dispatch_result: PantoToolDispatchComplete, tool_dispatch_complete: PantoToolDispatchComplete } }; -pub const PantoPersistentMessage = extern struct { role: PantoMessageRole, has_usage: bool, usage: PantoUsage, has_metadata: bool, metadata: PantoSlice }; +pub const PantoOwnedContentBlock = extern struct { tag: PantoContentBlockTag, text: PantoSlice, tool_id: PantoSlice, tool_name: PantoSlice, is_error: bool, system_mode: PantoSystemMode, thinking_signature: PantoSlice, thinking_signature_api_style: PantoAPIStyle, thinking_signature_base_url: PantoSlice, thinking_signature_model: PantoSlice }; +pub const PantoPersistentMessage = extern struct { role: PantoMessageRole, has_usage: bool, usage: PantoUsage, has_metadata: bool, metadata: PantoSlice, api_style: PantoAPIStyle, base_url: PantoSlice, model: PantoSlice, reasoning: PantoReasoningEffort, content: ?[*]const PantoOwnedContentBlock, content_len: usize }; pub const PantoSessionStoreVTable = extern struct { create: *const fn (*anyopaque, **PantoSession) callconv(.c) PantoStatus, list: *const fn (*anyopaque, *PantoSessionInfoList) callconv(.c) PantoStatus, @@ -212,19 +215,32 @@ export fn panto_conversation_block_text(c: *PantoConversation, mi: usize, bi: us } export fn panto_conversation_block_tool_id(c: *PantoConversation, mi: usize, bi: usize) PantoSlice { const b = blockAt(c, mi, bi) orelse return .{ .ptr = null, .len = 0 }; - return switch (b.*) { .ToolUse => |t| outSlice(t.id), .ToolResult => |t| outSlice(t.tool_use_id), else => .{ .ptr = null, .len = 0 } }; + return switch (b.*) { + .ToolUse => |t| outSlice(t.id), + .ToolResult => |t| outSlice(t.tool_use_id), + else => .{ .ptr = null, .len = 0 }, + }; } export fn panto_conversation_block_tool_name(c: *PantoConversation, mi: usize, bi: usize) PantoSlice { const b = blockAt(c, mi, bi) orelse return .{ .ptr = null, .len = 0 }; - return switch (b.*) { .ToolUse => |t| outSlice(t.name), else => .{ .ptr = null, .len = 0 } }; + return switch (b.*) { + .ToolUse => |t| outSlice(t.name), + else => .{ .ptr = null, .len = 0 }, + }; } export fn panto_conversation_block_tool_result_is_error(c: *PantoConversation, mi: usize, bi: usize) bool { const b = blockAt(c, mi, bi) orelse return false; - return switch (b.*) { .ToolResult => |t| t.is_error, else => false }; + return switch (b.*) { + .ToolResult => |t| t.is_error, + else => false, + }; } export fn panto_conversation_block_system_mode(c: *PantoConversation, mi: usize, bi: usize) PantoSystemMode { const b = blockAt(c, mi, bi) orelse return .append; - return switch (b.*) { .System => |s| @enumFromInt(@intFromEnum(s.mode)), else => .append }; + return switch (b.*) { + .System => |s| @enumFromInt(@intFromEnum(s.mode)), + else => .append, + }; } export fn panto_conversation_add_system_message(c: *PantoConversation, text: ?[*:0]const u8) PantoStatus { unwrapConversation(c).addSystemMessage(cstr(text)) catch |e| return setErr("{t}", .{e}); @@ -252,6 +268,104 @@ export fn panto_conversation_add_compaction_summary(c: *PantoConversation, text: unwrapConversation(c).addCompactionSummary(cstr(text)) catch |e| return setErr("{t}", .{e}); return .ok; } +fn unwrapBuilder(b: *PantoMessageBuilder) *MessageBuilder { + return @ptrCast(@alignCast(b)); +} +/// Take ownership of `block` into the builder, freeing it on OOM. +fn pushBlock(b: *MessageBuilder, block: panto.ContentBlock) PantoStatus { + b.blocks.append(allocator, block) catch |e| { + var blk = block; + blk.deinit(allocator); + return setErr("{t}", .{e}); + }; + return .ok; +} +export fn panto_message_builder_create(role: PantoMessageRole) ?*PantoMessageBuilder { + const b = allocator.create(MessageBuilder) catch { + _ = setErr("OutOfMemory", .{}); + return null; + }; + b.* = .{ .role = @enumFromInt(@intFromEnum(role)) }; + return @ptrCast(b); +} +export fn panto_message_builder_destroy(builder: ?*PantoMessageBuilder) void { + if (builder) |ptr| { + const b = unwrapBuilder(ptr); + for (b.blocks.items) |*blk| blk.deinit(allocator); + b.blocks.deinit(allocator); + allocator.destroy(b); + } +} +export fn panto_message_builder_add_text(builder: *PantoMessageBuilder, text: ?[*:0]const u8) PantoStatus { + const b = unwrapBuilder(builder); + const tb = panto.textualBlockFromSlice(allocator, cstr(text)) catch |e| return setErr("{t}", .{e}); + return pushBlock(b, .{ .Text = tb }); +} +export fn panto_message_builder_add_thinking(builder: *PantoMessageBuilder, text: ?[*:0]const u8, signature: ?[*:0]const u8, signature_api_style: PantoAPIStyle, signature_base_url: ?[*:0]const u8, signature_model: ?[*:0]const u8) PantoStatus { + const b = unwrapBuilder(builder); + var tb = panto.textualBlockFromSlice(allocator, cstr(text)) catch |e| return setErr("{t}", .{e}); + const sig: ?[]const u8 = if (cstr(signature).len != 0) (allocator.dupe(u8, cstr(signature)) catch |e| { + tb.deinit(allocator); + return setErr("{t}", .{e}); + }) else null; + var origin: ?panto.SignatureOrigin = null; + if (sig != null and cstr(signature_base_url).len != 0) { + origin = panto.SignatureOrigin.init(allocator, @enumFromInt(@intFromEnum(signature_api_style)), cstr(signature_base_url), cstr(signature_model)) catch |e| { + tb.deinit(allocator); + if (sig) |s| allocator.free(s); + return setErr("{t}", .{e}); + }; + } + return pushBlock(b, .{ .Thinking = .{ .text = tb, .signature = sig, .signature_origin = origin } }); +} +export fn panto_message_builder_add_tool_use(builder: *PantoMessageBuilder, id: ?[*:0]const u8, name: ?[*:0]const u8, input: ?[*:0]const u8) PantoStatus { + const b = unwrapBuilder(builder); + const id_d = allocator.dupe(u8, cstr(id)) catch |e| return setErr("{t}", .{e}); + const name_d = allocator.dupe(u8, cstr(name)) catch |e| { + allocator.free(id_d); + return setErr("{t}", .{e}); + }; + const input_tb = panto.textualBlockFromSlice(allocator, cstr(input)) catch |e| { + allocator.free(id_d); + allocator.free(name_d); + return setErr("{t}", .{e}); + }; + return pushBlock(b, .{ .ToolUse = .{ .id = id_d, .name = name_d, .input = input_tb } }); +} +export fn panto_message_builder_add_tool_result(builder: *PantoMessageBuilder, tool_use_id: ?[*:0]const u8, text: ?[*:0]const u8, is_error: bool) PantoStatus { + const b = unwrapBuilder(builder); + const id_d = allocator.dupe(u8, cstr(tool_use_id)) catch |e| return setErr("{t}", .{e}); + var tb = panto.textualBlockFromSlice(allocator, cstr(text)) catch |e| { + allocator.free(id_d); + return setErr("{t}", .{e}); + }; + var parts: std.ArrayList(panto.ResultPartStored) = .empty; + parts.append(allocator, .{ .text = tb }) catch |e| { + allocator.free(id_d); + tb.deinit(allocator); + return setErr("{t}", .{e}); + }; + return pushBlock(b, .{ .ToolResult = .{ .tool_use_id = id_d, .parts = parts, .is_error = is_error } }); +} +export fn panto_message_builder_add_system(builder: *PantoMessageBuilder, text: ?[*:0]const u8, mode: PantoSystemMode) PantoStatus { + const b = unwrapBuilder(builder); + const tb = panto.textualBlockFromSlice(allocator, cstr(text)) catch |e| return setErr("{t}", .{e}); + return pushBlock(b, .{ .System = .{ .text = tb, .mode = @enumFromInt(@intFromEnum(mode)) } }); +} +export fn panto_conversation_add_message(c: *PantoConversation, builder: *PantoMessageBuilder, has_usage: bool, u: PantoUsage) PantoStatus { + const b = unwrapBuilder(builder); + const cc = unwrapConversation(c); + // Consume the builder regardless of outcome. addMessage takes ownership of + // each block's bytes on success, so we only ever free the list backing + // array + the builder shell here — never the per-block bytes (that would + // double-free what the conversation now owns). + defer { + b.blocks.deinit(allocator); + allocator.destroy(b); + } + cc.addMessage(b.role, b.blocks.items, if (has_usage) zigUsage(u) else null) catch |e| return setErr("{t}", .{e}); + return .ok; +} export fn panto_session_create_null(out: **PantoSession) PantoStatus { const h = allocator.create(SessionHandle) catch return setErr("OutOfMemory", .{}); var ns = panto.NullStore.init(allocator); @@ -405,11 +519,56 @@ fn cStoreLoad(ctx: *anyopaque, id: []const u8) anyerror!?panto.Conversation { panto_conversation_destroy(out); return conv; } +const empty_slice: PantoSlice = .{ .ptr = null, .len = 0 }; +/// Flatten an in-memory content block into the borrow-only C view. Slices +/// alias the message's buffers except for ToolResult text, which is +/// concatenated into `aa` (valid until the caller frees that allocator). +fn ownedBlock(aa: std.mem.Allocator, b: *const panto.ContentBlock) !PantoOwnedContentBlock { + var out: PantoOwnedContentBlock = .{ .tag = @enumFromInt(@intFromEnum(b.*)), .text = empty_slice, .tool_id = empty_slice, .tool_name = empty_slice, .is_error = false, .system_mode = .append, .thinking_signature = empty_slice, .thinking_signature_api_style = .openai_chat, .thinking_signature_base_url = empty_slice, .thinking_signature_model = empty_slice }; + switch (b.*) { + .Text => |t| out.text = outSlice(t.items), + .Thinking => |t| { + out.text = outSlice(t.text.items); + if (t.signature) |s| out.thinking_signature = outSlice(s); + if (t.signature_origin) |o| { + out.thinking_signature_api_style = @enumFromInt(@intFromEnum(o.api_style)); + out.thinking_signature_base_url = outSlice(o.base_url); + out.thinking_signature_model = outSlice(o.model); + } + }, + .ToolUse => |t| { + out.text = outSlice(t.input.items); + out.tool_id = outSlice(t.id); + out.tool_name = outSlice(t.name); + }, + .ToolResult => |tr| { + var buf: panto.TextualBlock = .empty; + try tr.appendTextInto(aa, &buf); + out.text = outSlice(buf.items); + out.tool_id = outSlice(tr.tool_use_id); + out.is_error = tr.is_error; + }, + .System => |s| { + out.text = outSlice(s.text.items); + out.system_mode = @enumFromInt(@intFromEnum(s.mode)); + }, + .CompactionSummary => |s| out.text = outSlice(s.text.items), + } + return out; +} fn cStoreAppend(ctx: *anyopaque, session_id: []const u8, messages: []panto.PersistentMessage) anyerror!void { const h: *StoreHandle = @ptrCast(@alignCast(ctx)); const cmsgs = try allocator.alloc(PantoPersistentMessage, messages.len); defer allocator.free(cmsgs); - for (messages, 0..) |m, i| cmsgs[i] = .{ .role = @enumFromInt(@intFromEnum(m.message.role)), .has_usage = m.usage != null, .usage = if (m.usage) |u| usage(u) else .{ .input = 0, .output = 0, .cache_read = 0, .cache_write = 0, .reasoning = 0 }, .has_metadata = m.message.metadata != null, .metadata = outSlice(m.message.metadata orelse "") }; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const aa = arena.allocator(); + for (messages, 0..) |m, i| { + const blocks = m.message.content.items; + const cblocks = try aa.alloc(PantoOwnedContentBlock, blocks.len); + for (blocks, 0..) |*b, j| cblocks[j] = try ownedBlock(aa, b); + cmsgs[i] = .{ .role = @enumFromInt(@intFromEnum(m.message.role)), .has_usage = m.usage != null, .usage = if (m.usage) |u| usage(u) else .{ .input = 0, .output = 0, .cache_read = 0, .cache_write = 0, .reasoning = 0 }, .has_metadata = m.message.metadata != null, .metadata = outSlice(m.message.metadata orelse ""), .api_style = @enumFromInt(@intFromEnum(m.identity.api_style)), .base_url = outSlice(m.identity.base_url), .model = outSlice(m.identity.model), .reasoning = @enumFromInt(@intFromEnum(m.identity.reasoning)), .content = if (cblocks.len == 0) null else cblocks.ptr, .content_len = cblocks.len }; + } if (h.vtable.append_messages(h.ctx, outSlice(session_id), cmsgs.ptr, cmsgs.len) != .ok) return error.CStoreAppendFailed; } fn cStoreListZ(ctx: *anyopaque) anyerror![]panto.SessionInfo { @@ -491,11 +650,38 @@ fn builtinStoreLoad(ctx: *anyopaque, id: PantoSlice, out: **PantoConversation) c out.* = @ptrCast(ch); return .ok; } +/// Rebuild an in-memory content block from its borrow-only C view. The +/// inverse of `ownedBlock`; allocates into `aa` (a transient arena freed by +/// the caller after the store has copied what it keeps). +fn zigBlock(aa: std.mem.Allocator, ob: PantoOwnedContentBlock) !panto.ContentBlock { + return switch (ob.tag) { + .Text => .{ .Text = try panto.textualBlockFromSlice(aa, slice(ob.text)) }, + .Thinking => .{ .Thinking = .{ .text = try panto.textualBlockFromSlice(aa, slice(ob.text)), .signature = if (ob.thinking_signature.len != 0) try aa.dupe(u8, slice(ob.thinking_signature)) else null, .signature_origin = if (ob.thinking_signature.len != 0 and ob.thinking_signature_base_url.len != 0) try panto.SignatureOrigin.init(aa, @enumFromInt(@intFromEnum(ob.thinking_signature_api_style)), slice(ob.thinking_signature_base_url), slice(ob.thinking_signature_model)) else null } }, + .ToolUse => .{ .ToolUse = .{ .id = try aa.dupe(u8, slice(ob.tool_id)), .name = try aa.dupe(u8, slice(ob.tool_name)), .input = try panto.textualBlockFromSlice(aa, slice(ob.text)) } }, + .ToolResult => blk: { + var parts: std.ArrayList(panto.ResultPartStored) = .empty; + try parts.append(aa, .{ .text = try panto.textualBlockFromSlice(aa, slice(ob.text)) }); + break :blk .{ .ToolResult = .{ .tool_use_id = try aa.dupe(u8, slice(ob.tool_id)), .parts = parts, .is_error = ob.is_error } }; + }, + .System => .{ .System = .{ .text = try panto.textualBlockFromSlice(aa, slice(ob.text)), .mode = @enumFromInt(@intFromEnum(ob.system_mode)) } }, + .CompactionSummary => .{ .CompactionSummary = .{ .text = try panto.textualBlockFromSlice(aa, slice(ob.text)) } }, + }; +} fn builtinStoreAppend(ctx: *anyopaque, session_id: PantoSlice, messages: [*]const PantoPersistentMessage, len: usize) callconv(.c) PantoStatus { const s: *panto.SessionStore = @ptrCast(@alignCast(ctx)); const pmsgs = allocator.alloc(panto.PersistentMessage, len) catch return setErr("OutOfMemory", .{}); defer allocator.free(pmsgs); - for (messages[0..len], 0..) |m, i| pmsgs[i] = .{ .message = .{ .role = @enumFromInt(@intFromEnum(m.role)), .content = .empty, .metadata = if (m.has_metadata) slice(m.metadata) else null }, .usage = if (m.has_usage) zigUsage(m.usage) else null, .identity = .{ .api_style = .openai_chat, .base_url = "", .model = "" } }; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const aa = arena.allocator(); + for (messages[0..len], 0..) |m, i| { + var content: std.ArrayList(panto.ContentBlock) = .empty; + if (m.content) |cptr| { + content.ensureTotalCapacity(aa, m.content_len) catch return setErr("OutOfMemory", .{}); + for (cptr[0..m.content_len]) |ob| content.appendAssumeCapacity(zigBlock(aa, ob) catch |e| return setErr("{t}", .{e})); + } + pmsgs[i] = .{ .message = .{ .role = @enumFromInt(@intFromEnum(m.role)), .content = content, .metadata = if (m.has_metadata) slice(m.metadata) else null }, .usage = if (m.has_usage) zigUsage(m.usage) else null, .identity = .{ .api_style = @enumFromInt(@intFromEnum(m.api_style)), .base_url = slice(m.base_url), .model = slice(m.model), .reasoning = @enumFromInt(@intFromEnum(m.reasoning)) } }; + } s.appendMessages(slice(session_id), pmsgs) catch |e| return setErr("{t}", .{e}); return .ok; } @@ -664,6 +850,76 @@ test "c abi exports compile" { try std.testing.expect(true); } +var test_append_ok: bool = false; +fn testCaptureAppend(_: *anyopaque, _: PantoSlice, messages: [*]const PantoPersistentMessage, len: usize) callconv(.c) PantoStatus { + test_append_ok = blk: { + if (len != 1) break :blk false; + const m = messages[0]; + if (m.role != .assistant) break :blk false; + const p = m.content orelse break :blk false; + if (m.content_len != 3) break :blk false; + const b = p[0..3]; + if (b[0].tag != .ToolUse) break :blk false; + if (!std.mem.eql(u8, slice(b[0].tool_id), "call-1")) break :blk false; + if (!std.mem.eql(u8, slice(b[0].tool_name), "search")) break :blk false; + if (!std.mem.eql(u8, slice(b[0].text), "{\"q\":1}")) break :blk false; + if (b[1].tag != .Thinking) break :blk false; + if (!std.mem.eql(u8, slice(b[1].text), "hmm")) break :blk false; + if (!std.mem.eql(u8, slice(b[1].thinking_signature), "sig")) break :blk false; + if (b[1].thinking_signature_api_style != .anthropic_messages) break :blk false; + if (!std.mem.eql(u8, slice(b[1].thinking_signature_base_url), "https://api")) break :blk false; + if (!std.mem.eql(u8, slice(b[1].thinking_signature_model), "claude")) break :blk false; + if (b[2].tag != .ToolResult) break :blk false; + if (!b[2].is_error) break :blk false; + if (!std.mem.eql(u8, slice(b[2].tool_id), "call-1")) break :blk false; + if (!std.mem.eql(u8, slice(b[2].text), "boom")) break :blk false; + if (m.api_style != .anthropic_messages) break :blk false; + if (!std.mem.eql(u8, slice(m.base_url), "https://api")) break :blk false; + if (!std.mem.eql(u8, slice(m.model), "claude")) break :blk false; + if (m.reasoning != .high) break :blk false; + break :blk true; + }; + return .ok; +} + +test "cStoreAppend marshals content blocks (incl. thinking origin)" { + var content: std.ArrayList(panto.ContentBlock) = .empty; + defer { + for (content.items) |*b| b.deinit(allocator); + content.deinit(allocator); + } + try content.append(allocator, .{ .ToolUse = .{ .id = try allocator.dupe(u8, "call-1"), .name = try allocator.dupe(u8, "search"), .input = try panto.textualBlockFromSlice(allocator, "{\"q\":1}") } }); + try content.append(allocator, .{ .Thinking = .{ .text = try panto.textualBlockFromSlice(allocator, "hmm"), .signature = try allocator.dupe(u8, "sig"), .signature_origin = try panto.SignatureOrigin.init(allocator, .anthropic_messages, "https://api", "claude") } }); + var parts: std.ArrayList(panto.ResultPartStored) = .empty; + try parts.append(allocator, .{ .text = try panto.textualBlockFromSlice(allocator, "boom") }); + try content.append(allocator, .{ .ToolResult = .{ .tool_use_id = try allocator.dupe(u8, "call-1"), .parts = parts, .is_error = true } }); + + var msgs = [_]panto.PersistentMessage{.{ .message = .{ .role = .assistant, .content = content }, .identity = .{ .api_style = .anthropic_messages, .base_url = "https://api", .model = "claude", .reasoning = .high } }}; + + var vt: PantoSessionStoreVTable = .{ .create = builtinStoreCreate, .list = builtinStoreList, .free_session_infos = builtinStoreFreeInfos, .resolve = builtinStoreResolve, .latest = builtinStoreLatest, .load = builtinStoreLoad, .append_messages = testCaptureAppend, .destroy = null }; + var dummy: u8 = 0; + var handle: StoreHandle = .{ .ctx = &dummy, .vtable = &vt }; + + test_append_ok = false; + try cStoreAppend(@ptrCast(&handle), "sess-1", &msgs); + try std.testing.expect(test_append_ok); +} + +test "zigBlock round-trips an owned thinking block" { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const aa = arena.allocator(); + const ob: PantoOwnedContentBlock = .{ .tag = .Thinking, .text = outSlice("reasoning"), .tool_id = empty_slice, .tool_name = empty_slice, .is_error = false, .system_mode = .append, .thinking_signature = outSlice("sig"), .thinking_signature_api_style = .anthropic_messages, .thinking_signature_base_url = outSlice("https://api"), .thinking_signature_model = outSlice("claude") }; + const block = try zigBlock(aa, ob); + try std.testing.expect(block == .Thinking); + try std.testing.expectEqualStrings("reasoning", block.Thinking.text.items); + try std.testing.expectEqualStrings("sig", block.Thinking.signature.?); + const origin = block.Thinking.signature_origin.?; + try std.testing.expect(origin.api_style == .anthropic_messages); + try std.testing.expectEqualStrings("https://api", origin.base_url); + try std.testing.expectEqualStrings("claude", origin.model); +} + fn cloneToolDecl(d: PantoToolDecl) !panto.ToolDecl { return .{ .name = try dupSlice(d.name), .description = try dupSlice(d.description), .schema_json = try dupSlice(d.schema_json) }; } @@ -725,17 +981,29 @@ fn convertResultParts(parts: PantoResultParts) !panto.ResultParts { export fn panto_agent_register_tool(a: *PantoAgent, decl: PantoToolDecl, ctx: *anyopaque, invoke: PantoToolInvokeFn, destroy: ?PantoToolDestroyFn) PantoStatus { const ah: *AgentHandle = @ptrCast(@alignCast(a)); const h = allocator.create(ToolHandle) catch return setErr("OutOfMemory", .{}); - h.* = .{ .ctx = ctx, .invoke = invoke, .destroy = destroy, .decl = cloneToolDecl(decl) catch |e| { allocator.destroy(h); return setErr("{t}", .{e}); } }; + h.* = .{ .ctx = ctx, .invoke = invoke, .destroy = destroy, .decl = cloneToolDecl(decl) catch |e| { + allocator.destroy(h); + return setErr("{t}", .{e}); + } }; ah.agent.registerTool(.{ .decl = h.decl, .ctx = h, .vtable = &.{ .invoke = toolInvoke, .deinit = toolDeinit } }) catch |e| return setErr("{t}", .{e}); return .ok; } export fn panto_agent_register_tool_source(a: *PantoAgent, name: PantoSlice, decls: [*]const PantoToolDecl, len: usize, ctx: *anyopaque, invoke_batch: PantoToolSourceInvokeBatchFn, destroy: ?PantoToolDestroyFn) PantoStatus { const ah: *AgentHandle = @ptrCast(@alignCast(a)); const h = allocator.create(ToolSourceHandle) catch return setErr("OutOfMemory", .{}); - h.name = dupSlice(name) catch |e| { allocator.destroy(h); return setErr("{t}", .{e}); }; - h.decls = allocator.alloc(panto.ToolDecl, len) catch |e| { allocator.free(h.name); allocator.destroy(h); return setErr("{t}", .{e}); }; + h.name = dupSlice(name) catch |e| { + allocator.destroy(h); + return setErr("{t}", .{e}); + }; + h.decls = allocator.alloc(panto.ToolDecl, len) catch |e| { + allocator.free(h.name); + allocator.destroy(h); + return setErr("{t}", .{e}); + }; for (decls[0..len], 0..) |d, i| h.decls[i] = cloneToolDecl(d) catch |e| return setErr("{t}", .{e}); - h.ctx = ctx; h.invoke_batch = invoke_batch; h.destroy = destroy; + h.ctx = ctx; + h.invoke_batch = invoke_batch; + h.destroy = destroy; ah.agent.registerToolSource(.{ .name = h.name, .tools = h.decls, .ctx = h, .vtable = &.{ .invoke_batch = sourceInvokeBatch, .deinit = sourceDeinit } }) catch |e| return setErr("{t}", .{e}); return .ok; } @@ -748,7 +1016,10 @@ export fn panto_result_parts_destroy(parts: PantoResultParts) void { if (parts.ptr) |p| { for (p[0..parts.len]) |part| switch (part.tag) { .text => freePS(part.data.text), - .media => { if (part.data.media.has_media_type) freePS(part.data.media.media_type); freePS(part.data.media.data); }, + .media => { + if (part.data.media.has_media_type) freePS(part.data.media.media_type); + freePS(part.data.media.data); + }, }; allocator.free(p[0..parts.len]); } |
