From 270cd00129551647e7c764a13836e03e0b2dc4e2 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 18 Jun 2026 14:15:55 -0600 Subject: preserve signature origins across compactions --- libpanto-c/include/panto.h | 16 +++++++++++++--- libpanto-c/src/lib.zig | 46 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) (limited to 'libpanto-c') diff --git a/libpanto-c/include/panto.h b/libpanto-c/include/panto.h index c43b39f..3b4a22a 100644 --- a/libpanto-c/include/panto.h +++ b/libpanto-c/include/panto.h @@ -139,13 +139,23 @@ 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. */ + * supplied you may 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. + * The origin args are OPTIONAL: pass PANTO_API_STYLE 0 and NULL base_url/model + * to omit them and instead rely on the enclosing message's identity (see + * `panto_message_builder_set_identity`), which is the preferred, per-message + * source of truth — no per-block identity copy required. */ 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); +/* Attach the wire identity (provider/style/model + reasoning) that produced + * this message. This is the per-message source of truth for replaying thinking + * signatures and is preserved through compaction, so a kept-verbatim turn is + * not re-stamped with the compaction model. Strings are copied. Optional; when + * omitted, thinking replay relies on each block's own signature origin. */ +PANTO_EXPORT PantoStatus panto_message_builder_set_identity(PantoMessageBuilder *builder, PantoAPIStyle api_style, const char *base_url, const char *model, PantoReasoningEffort reasoning); /* 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. */ diff --git a/libpanto-c/src/lib.zig b/libpanto-c/src/lib.zig index 7aa735f..9d584a0 100644 --- a/libpanto-c/src/lib.zig +++ b/libpanto-c/src/lib.zig @@ -21,7 +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 }; +const MessageBuilder = struct { role: panto.MessageRole, blocks: std.ArrayList(panto.ContentBlock) = .empty, identity: ?panto.WireIdentity = null }; pub const PantoSlice = extern struct { ptr: ?[*]const u8, len: usize }; pub const PantoStatus = enum(c_int) { ok = 0, err = 1 }; @@ -293,9 +293,26 @@ export fn panto_message_builder_destroy(builder: ?*PantoMessageBuilder) void { const b = unwrapBuilder(ptr); for (b.blocks.items) |*blk| blk.deinit(allocator); b.blocks.deinit(allocator); + if (b.identity) |id| panto.freeWireIdentity(allocator, id); allocator.destroy(b); } } +/// Attach the per-message wire identity that produced this message. Used when +/// rebuilding a conversation for resumption so the identity survives a later +/// compaction (and so a thinking signature replays to the right model without +/// a per-block identity copy). The strings are copied. +export fn panto_message_builder_set_identity(builder: *PantoMessageBuilder, api_style: PantoAPIStyle, base_url: ?[*:0]const u8, model: ?[*:0]const u8, reasoning: PantoReasoningEffort) PantoStatus { + const b = unwrapBuilder(builder); + const id = panto.dupeWireIdentity(allocator, .{ + .api_style = @enumFromInt(@intFromEnum(api_style)), + .base_url = cstr(base_url), + .model = cstr(model), + .reasoning = @enumFromInt(@intFromEnum(reasoning)), + }) catch |e| return setErr("{t}", .{e}); + if (b.identity) |old| panto.freeWireIdentity(allocator, old); + b.identity = id; + return .ok; +} 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}); @@ -361,9 +378,16 @@ export fn panto_conversation_add_message(c: *PantoConversation, builder: *PantoM // double-free what the conversation now owns). defer { b.blocks.deinit(allocator); + if (b.identity) |id| panto.freeWireIdentity(allocator, id); allocator.destroy(b); } cc.addMessage(b.role, b.blocks.items, if (has_usage) zigUsage(u) else null) catch |e| return setErr("{t}", .{e}); + // Transfer the builder's identity onto the freshly appended message. The + // `defer` frees the builder's copy; null it out so ownership moves cleanly. + if (b.identity) |id| { + cc.messages.items[cc.messages.items.len - 1].identity = id; + b.identity = null; + } return .ok; } export fn panto_session_create_null(out: **PantoSession) PantoStatus { @@ -920,6 +944,26 @@ test "zigBlock round-trips an owned thinking block" { try std.testing.expectEqualStrings("claude", origin.model); } +test "message builder set_identity attaches per-message identity" { + var conv: *PantoConversation = undefined; + try std.testing.expectEqual(PantoStatus.ok, panto_conversation_create(&conv)); + defer panto_conversation_destroy(conv); + + const b = panto_message_builder_create(.assistant).?; + try std.testing.expectEqual(PantoStatus.ok, panto_message_builder_add_thinking(b, "reasoned", "sig", .openai_chat, null, null)); + try std.testing.expectEqual(PantoStatus.ok, panto_message_builder_set_identity(b, .anthropic_messages, "https://api.anthropic.com", "claude", .default)); + try std.testing.expectEqual(PantoStatus.ok, panto_conversation_add_message(conv, b, false, undefined)); + + const cc = unwrapConversation(conv); + const id = cc.messages.items[0].identity.?; + try std.testing.expect(id.api_style == .anthropic_messages); + try std.testing.expectEqualStrings("https://api.anthropic.com", id.base_url); + try std.testing.expectEqualStrings("claude", id.model); + // No per-block origin was supplied; the message identity carries replay + // provenance on its own. + try std.testing.expect(cc.messages.items[0].content.items[0].Thinking.signature_origin == null); +} + fn cloneToolDecl(d: PantoToolDecl) !panto.ToolDecl { return .{ .name = try dupSlice(d.name), .description = try dupSlice(d.description), .schema_json = try dupSlice(d.schema_json) }; } -- cgit v1.3