summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--libpanto-c/include/panto.h59
-rw-r--r--libpanto-c/src/lib.zig295
-rw-r--r--libpanto-go/panto.go203
-rw-r--r--libpanto-go/panto_test.go126
-rw-r--r--libpanto/src/conversation.zig18
5 files changed, 681 insertions, 20 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]);
}
diff --git a/libpanto-go/panto.go b/libpanto-go/panto.go
index da3bcb1..95e0cbd 100644
--- a/libpanto-go/panto.go
+++ b/libpanto-go/panto.go
@@ -252,6 +252,13 @@ type PersistentMessage struct {
Usage *Usage
Metadata string
Content []OwnedContentBlock
+ // Per-message wire identity: the provider that produced this message. May
+ // drift within a conversation (compaction on another model, mid-chat
+ // switch), so persist these per message rather than from static config.
+ APIStyle APIStyle
+ BaseURL string
+ Model string
+ Reasoning ReasoningEffort
}
type OwnedMessage struct {
Role MessageRole
@@ -266,6 +273,14 @@ type OwnedContentBlock struct {
ToolName string
IsError bool
SystemMode SystemMode
+ // Thinking-block signature provenance. Populated for blocks delivered to
+ // AppendMessages; empty otherwise. To replay a thinking block faithfully,
+ // persist all four and feed them back via MessageBuilder.AddThinking —
+ // Anthropic drops a thinking block whose signature lacks a matching origin.
+ ThinkingSignature string
+ ThinkingSignatureStyle APIStyle
+ ThinkingSignatureBaseURL string
+ ThinkingSignatureModel string
}
type cStrings []*C.char
@@ -365,6 +380,37 @@ func CreateSession(store cSessionStore) (*Session, error) {
return s, nil
}
+// NewSession mints a *Session bound to store, carrying info (at least a
+// store-assigned ID). Unlike CreateSession/ResolveSession/LatestSession it does
+// NOT dispatch into the store's vtable, so a custom SessionStore can call it
+// from its own Create/Resolve/Latest to build the *Session it must return
+// without recursing back into itself. The session's bound store is used for
+// later load/append; pass the store the session belongs to.
+func NewSession(store cSessionStore, info SessionInfo) (*Session, error) {
+ ci := cSessionInfo(info)
+ defer freeCSessionInfo(ci)
+ var out *C.PantoSession
+ if C.panto_session_create(store.cptr(), ci, &out) != C.PANTO_OK {
+ return nil, lastError()
+ }
+ s := &Session{out}
+ runtime.SetFinalizer(s, (*Session).Close)
+ return s, nil
+}
+
+// NewNullSession mints a standalone *Session backed by a null store — load and
+// append are no-ops. Useful as a placeholder when a store performs its own
+// persistence outside the session's bound store.
+func NewNullSession() (*Session, error) {
+ var out *C.PantoSession
+ if C.panto_session_create_null(&out) != C.PANTO_OK {
+ return nil, lastError()
+ }
+ s := &Session{out}
+ runtime.SetFinalizer(s, (*Session).Close)
+ return s, nil
+}
+
func ResolveSession(store cSessionStore, id string) (*Session, bool, error) {
cid := C.CString(id)
defer C.free(unsafe.Pointer(cid))
@@ -495,6 +541,113 @@ func (c *Conversation) AddCompactionSummary(text string) error {
return nil
}
+// MessageBuilder accumulates content blocks for one message, then commits them
+// to a Conversation via Conversation.AddMessage. It is the block-level builder
+// for rebuilding a persisted conversation that contains tool calls or thinking
+// (the text-only AddUserText/AddAssistantText cannot). Add blocks in order,
+// then call AddMessage (which consumes the builder). Call Destroy only to
+// discard a builder you never commit.
+type MessageBuilder struct {
+ ptr *C.PantoMessageBuilder
+}
+
+func NewMessageBuilder(role MessageRole) (*MessageBuilder, error) {
+ p := C.panto_message_builder_create(C.PantoMessageRole(role))
+ if p == nil {
+ return nil, lastError()
+ }
+ b := &MessageBuilder{ptr: p}
+ runtime.SetFinalizer(b, (*MessageBuilder).Destroy)
+ return b, nil
+}
+
+func (b *MessageBuilder) AddText(text string) error {
+ ct := C.CString(text)
+ defer C.free(unsafe.Pointer(ct))
+ if C.panto_message_builder_add_text(b.ptr, ct) != C.PANTO_OK {
+ return lastError()
+ }
+ return nil
+}
+
+// AddThinking appends a thinking block. signature/baseURL/model carry the
+// signature provenance (see OwnedContentBlock); pass "" for signature when the
+// provider emitted none, in which case style/baseURL/model are ignored.
+func (b *MessageBuilder) AddThinking(text, signature string, style APIStyle, baseURL, model string) error {
+ ct := C.CString(text)
+ defer C.free(unsafe.Pointer(ct))
+ cs := C.CString(signature)
+ defer C.free(unsafe.Pointer(cs))
+ cb := C.CString(baseURL)
+ defer C.free(unsafe.Pointer(cb))
+ cm := C.CString(model)
+ defer C.free(unsafe.Pointer(cm))
+ if C.panto_message_builder_add_thinking(b.ptr, ct, cs, C.PantoAPIStyle(style), cb, cm) != C.PANTO_OK {
+ return lastError()
+ }
+ return nil
+}
+
+func (b *MessageBuilder) AddToolUse(id, name, input string) error {
+ cid := C.CString(id)
+ defer C.free(unsafe.Pointer(cid))
+ cn := C.CString(name)
+ defer C.free(unsafe.Pointer(cn))
+ ci := C.CString(input)
+ defer C.free(unsafe.Pointer(ci))
+ if C.panto_message_builder_add_tool_use(b.ptr, cid, cn, ci) != C.PANTO_OK {
+ return lastError()
+ }
+ return nil
+}
+
+func (b *MessageBuilder) AddToolResult(toolUseID, text string, isError bool) error {
+ cid := C.CString(toolUseID)
+ defer C.free(unsafe.Pointer(cid))
+ ct := C.CString(text)
+ defer C.free(unsafe.Pointer(ct))
+ if C.panto_message_builder_add_tool_result(b.ptr, cid, ct, C.bool(isError)) != C.PANTO_OK {
+ return lastError()
+ }
+ return nil
+}
+
+func (b *MessageBuilder) AddSystem(text string, mode SystemMode) error {
+ ct := C.CString(text)
+ defer C.free(unsafe.Pointer(ct))
+ if C.panto_message_builder_add_system(b.ptr, ct, C.PantoSystemMode(mode)) != C.PANTO_OK {
+ return lastError()
+ }
+ return nil
+}
+
+// Destroy frees an uncommitted builder. Safe to call after AddMessage (no-op).
+func (b *MessageBuilder) Destroy() {
+ if b != nil && b.ptr != nil {
+ C.panto_message_builder_destroy(b.ptr)
+ b.ptr = nil
+ }
+}
+
+// AddMessage commits the builder's blocks as one message of the builder's role,
+// with optional assistant usage. It consumes the builder (success or failure);
+// the builder must not be used afterwards.
+func (c *Conversation) AddMessage(b *MessageBuilder, u *Usage) error {
+ var cu C.PantoUsage
+ has := false
+ if u != nil {
+ cu = cUsage(*u)
+ has = true
+ }
+ st := C.panto_conversation_add_message(c.ptr, b.ptr, C.bool(has), cu)
+ b.ptr = nil
+ runtime.SetFinalizer(b, nil)
+ if st != C.PANTO_OK {
+ return lastError()
+ }
+ return nil
+}
+
type Message struct {
conv *Conversation
index uint
@@ -1209,7 +1362,16 @@ func pantoGoStoreAppendMessages(ctx unsafe.Pointer, sessionID C.PantoSlice, mess
uu := usage(m.usage)
u = &uu
}
- out[i] = PersistentMessage{Role: MessageRole(m.role), Usage: u, Metadata: goSlice(m.metadata)}
+ out[i] = PersistentMessage{
+ Role: MessageRole(m.role),
+ Usage: u,
+ Metadata: goSlice(m.metadata),
+ Content: goOwnedBlocks(m.content, m.content_len),
+ APIStyle: APIStyle(m.api_style),
+ BaseURL: goSlice(m.base_url),
+ Model: goSlice(m.model),
+ Reasoning: ReasoningEffort(m.reasoning),
+ }
}
if err := impl.AppendMessages(goSlice(sessionID), out); err != nil {
return C.PANTO_ERROR
@@ -1217,6 +1379,29 @@ func pantoGoStoreAppendMessages(ctx unsafe.Pointer, sessionID C.PantoSlice, mess
return C.PANTO_OK
}
+func goOwnedBlocks(ptr *C.PantoOwnedContentBlock, n C.size_t) []OwnedContentBlock {
+ if ptr == nil || n == 0 {
+ return nil
+ }
+ xs := unsafe.Slice(ptr, int(n))
+ out := make([]OwnedContentBlock, int(n))
+ for i, b := range xs {
+ out[i] = OwnedContentBlock{
+ Tag: ContentBlockTag(b.tag),
+ Text: goSlice(b.text),
+ ToolID: goSlice(b.tool_id),
+ ToolName: goSlice(b.tool_name),
+ IsError: bool(b.is_error),
+ SystemMode: SystemMode(b.system_mode),
+ ThinkingSignature: goSlice(b.thinking_signature),
+ ThinkingSignatureStyle: APIStyle(b.thinking_signature_api_style),
+ ThinkingSignatureBaseURL: goSlice(b.thinking_signature_base_url),
+ ThinkingSignatureModel: goSlice(b.thinking_signature_model),
+ }
+ }
+ return out
+}
+
func cSessionInfo(i SessionInfo) C.PantoSessionInfo {
return C.PantoSessionInfo{
id: cSlice(i.ID),
@@ -1231,18 +1416,22 @@ func cSessionInfo(i SessionInfo) C.PantoSessionInfo {
}
}
+func freeCSessionInfo(i C.PantoSessionInfo) {
+ C.free(unsafe.Pointer(i.id.ptr))
+ C.free(unsafe.Pointer(i.created.ptr))
+ C.free(unsafe.Pointer(i.modified.ptr))
+ C.free(unsafe.Pointer(i.last_user_message.ptr))
+ C.free(unsafe.Pointer(i.base_url.ptr))
+ C.free(unsafe.Pointer(i.model.ptr))
+}
+
func freeCSessionInfoList(l C.PantoSessionInfoList) {
if l.ptr == nil {
return
}
xs := unsafe.Slice(l.ptr, int(l.len))
for _, i := range xs {
- C.free(unsafe.Pointer(i.id.ptr))
- C.free(unsafe.Pointer(i.created.ptr))
- C.free(unsafe.Pointer(i.modified.ptr))
- C.free(unsafe.Pointer(i.last_user_message.ptr))
- C.free(unsafe.Pointer(i.base_url.ptr))
- C.free(unsafe.Pointer(i.model.ptr))
+ freeCSessionInfo(i)
}
C.free(unsafe.Pointer(l.ptr))
}
diff --git a/libpanto-go/panto_test.go b/libpanto-go/panto_test.go
index ef21f1e..5cfed01 100644
--- a/libpanto-go/panto_test.go
+++ b/libpanto-go/panto_test.go
@@ -2,6 +2,132 @@ package panto
import "testing"
+// memStore is a minimal custom SessionStore that mints its sessions via
+// NewSession (gap #1) rather than dispatching back into the vtable.
+type memStore struct {
+ self *GoSessionStore
+ created bool
+}
+
+func (m *memStore) Create() (*Session, error) {
+ m.created = true
+ return NewSession(m.self, SessionInfo{ID: "sess-1"})
+}
+func (m *memStore) List() ([]SessionInfo, error) { return nil, nil }
+func (m *memStore) Resolve(id string) (*Session, bool, error) {
+ if id != "sess-1" {
+ return nil, false, nil
+ }
+ s, err := NewSession(m.self, SessionInfo{ID: id})
+ return s, err == nil, err
+}
+func (m *memStore) Latest() (*Session, bool, error) { return m.Resolve("sess-1") }
+func (m *memStore) Load(id string) (*Conversation, bool, error) { return nil, false, nil }
+func (m *memStore) AppendMessages(string, []PersistentMessage) error {
+ return nil
+}
+
+// Gap #1: a Go store can mint and return a *Session from its own Create()
+// without recursing back into the vtable.
+func TestCustomStoreMintsSession(t *testing.T) {
+ Init()
+ defer Deinit()
+
+ impl := &memStore{}
+ gss, err := NewGoSessionStore(impl)
+ if err != nil {
+ t.Fatalf("NewGoSessionStore: %v", err)
+ }
+ defer gss.Close()
+ impl.self = gss
+
+ sess, err := gss.Create()
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ if sess == nil || sess.ptr == nil {
+ t.Fatal("Create returned nil session")
+ }
+ if !impl.created {
+ t.Fatal("custom Create() was not invoked")
+ }
+
+ got, ok, err := gss.Resolve("sess-1")
+ if err != nil || !ok || got == nil {
+ t.Fatalf("Resolve: got=%v ok=%v err=%v", got, ok, err)
+ }
+}
+
+// Gap #3: rebuild a conversation containing tool_use / tool_result / thinking
+// via MessageBuilder, then read the blocks back losslessly.
+func TestMessageBuilderRoundTrip(t *testing.T) {
+ Init()
+ defer Deinit()
+
+ conv, err := NewConversation()
+ if err != nil {
+ t.Fatalf("NewConversation: %v", err)
+ }
+ defer conv.Close()
+
+ ub, err := NewMessageBuilder(RoleUser)
+ if err != nil {
+ t.Fatalf("NewMessageBuilder(user): %v", err)
+ }
+ if err := ub.AddToolResult("call-1", "result text", false); err != nil {
+ t.Fatalf("AddToolResult: %v", err)
+ }
+ if err := conv.AddMessage(ub, nil); err != nil {
+ t.Fatalf("AddMessage(user): %v", err)
+ }
+
+ ab, err := NewMessageBuilder(RoleAssistant)
+ if err != nil {
+ t.Fatalf("NewMessageBuilder(assistant): %v", err)
+ }
+ if err := ab.AddText("let me search"); err != nil {
+ t.Fatalf("AddText: %v", err)
+ }
+ if err := ab.AddThinking("hmm", "sig", AnthropicMessages, "https://api", "claude"); err != nil {
+ t.Fatalf("AddThinking: %v", err)
+ }
+ if err := ab.AddToolUse("call-1", "search", `{"q":1}`); err != nil {
+ t.Fatalf("AddToolUse: %v", err)
+ }
+ if err := conv.AddMessage(ab, &Usage{Input: 10, Output: 5}); err != nil {
+ t.Fatalf("AddMessage(assistant): %v", err)
+ }
+
+ if got := conv.MessageCount(); got != 2 {
+ t.Fatalf("MessageCount = %d, want 2", got)
+ }
+
+ user := conv.Message(0).Snapshot()
+ if user.Role != RoleUser || len(user.Content) != 1 {
+ t.Fatalf("user message: role=%v blocks=%d", user.Role, len(user.Content))
+ }
+ if tr := user.Content[0]; tr.Tag != BlockToolResult || tr.ToolID != "call-1" || tr.Text != "result text" || tr.IsError {
+ t.Fatalf("tool_result block: %+v", tr)
+ }
+
+ asst := conv.Message(1).Snapshot()
+ if asst.Role != RoleAssistant || len(asst.Content) != 3 {
+ t.Fatalf("assistant message: role=%v blocks=%d", asst.Role, len(asst.Content))
+ }
+ if b := asst.Content[0]; b.Tag != BlockText || b.Text != "let me search" {
+ t.Fatalf("text block: %+v", b)
+ }
+ if b := asst.Content[1]; b.Tag != BlockThinking || b.Text != "hmm" {
+ t.Fatalf("thinking block: %+v", b)
+ }
+ if b := asst.Content[2]; b.Tag != BlockToolUse || b.ToolID != "call-1" || b.ToolName != "search" || b.Text != `{"q":1}` {
+ t.Fatalf("tool_use block: %+v", b)
+ }
+ if asst.Usage == nil || asst.Usage.Input != 10 || asst.Usage.Output != 5 {
+ t.Fatalf("assistant usage: %+v", asst.Usage)
+ }
+}
+
func testConfig() Config {
return Config{
Provider: ProviderConfig{
diff --git a/libpanto/src/conversation.zig b/libpanto/src/conversation.zig
index 760f3ee..4338563 100644
--- a/libpanto/src/conversation.zig
+++ b/libpanto/src/conversation.zig
@@ -370,6 +370,24 @@ pub const Conversation = struct {
});
}
+ /// Append a message of any role from a slice of content blocks, with
+ /// optional usage. The general form behind `addUserMessage` /
+ /// `addAssistantMessage`; used to rebuild a persisted message (tool calls,
+ /// thinking, mixed blocks) losslessly. Ownership of the blocks transfers
+ /// to the conversation; the caller must not deinit them after this call.
+ pub fn addMessage(self: *Conversation, role: MessageRole, blocks: []const ContentBlock, usage: ?Usage) !void {
+ var content: std.ArrayList(ContentBlock) = .empty;
+ try content.ensureTotalCapacity(self.allocator, blocks.len);
+ for (blocks) |block| {
+ content.appendAssumeCapacity(block);
+ }
+ try self.messages.append(self.allocator, .{
+ .role = role,
+ .content = content,
+ .usage = usage,
+ });
+ }
+
pub fn deinit(self: *Conversation) void {
for (self.messages.items) |*msg| {
msg.deinit(self.allocator);