summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-18 14:15:55 -0600
committert <t@tjp.lol>2026-06-18 14:16:24 -0600
commit270cd00129551647e7c764a13836e03e0b2dc4e2 (patch)
treef939bbf999dd67d996b4025b03dbfe4abd30b9f4
parent7c2d8825660f73198149c0b2ce26166b16ba3532 (diff)
preserve signature origins across compactions
-rw-r--r--libpanto-c/include/panto.h16
-rw-r--r--libpanto-c/src/lib.zig46
-rw-r--r--libpanto-go/panto.go19
-rw-r--r--libpanto-go/panto_test.go41
-rw-r--r--libpanto/src/agent.zig12
-rw-r--r--libpanto/src/anthropic_messages_json.zig79
-rw-r--r--libpanto/src/conversation.zig59
-rw-r--r--libpanto/src/file_system_jsonl_store.zig81
-rw-r--r--libpanto/src/openai_responses_json.zig13
-rw-r--r--libpanto/src/public.zig5
-rw-r--r--libpanto/src/turn_persist.zig18
11 files changed, 359 insertions, 30 deletions
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) };
}
diff --git a/libpanto-go/panto.go b/libpanto-go/panto.go
index 95e0cbd..5bfc5d8 100644
--- a/libpanto-go/panto.go
+++ b/libpanto-go/panto.go
@@ -621,6 +621,25 @@ func (b *MessageBuilder) AddSystem(text string, mode SystemMode) error {
return nil
}
+// SetIdentity attaches the wire identity (provider style/baseURL/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. Prefer
+// this over per-block origins on AddThinking when rebuilding a conversation for
+// resumption: set it from PersistentMessage's APIStyle/BaseURL/Model and the
+// thinking signatures replay correctly without copying the identity onto every
+// thinking block.
+func (b *MessageBuilder) SetIdentity(style APIStyle, baseURL, model string, reasoning ReasoningEffort) error {
+ 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_set_identity(b.ptr, C.PantoAPIStyle(style), cb, cm, C.PantoReasoningEffort(reasoning)) != 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 {
diff --git a/libpanto-go/panto_test.go b/libpanto-go/panto_test.go
index 5cfed01..fd9df70 100644
--- a/libpanto-go/panto_test.go
+++ b/libpanto-go/panto_test.go
@@ -128,6 +128,47 @@ func TestMessageBuilderRoundTrip(t *testing.T) {
}
}
+// TestMessageBuilderSetIdentity rebuilds a message with a per-message wire
+// identity (and no per-block thinking origin), the resumption path that lets
+// callers avoid copying the identity onto every thinking block.
+func TestMessageBuilderSetIdentity(t *testing.T) {
+ Init()
+ defer Deinit()
+
+ conv, err := NewConversation()
+ if err != nil {
+ t.Fatalf("NewConversation: %v", err)
+ }
+ defer conv.Close()
+
+ ab, err := NewMessageBuilder(RoleAssistant)
+ if err != nil {
+ t.Fatalf("NewMessageBuilder(assistant): %v", err)
+ }
+ // Thinking block with a signature but NO per-block origin.
+ if err := ab.AddThinking("reasoned", "sig", OpenAIChat, "", ""); err != nil {
+ t.Fatalf("AddThinking: %v", err)
+ }
+ if err := ab.AddText("answer"); err != nil {
+ t.Fatalf("AddText: %v", err)
+ }
+ // Provenance is carried at the message level instead.
+ if err := ab.SetIdentity(AnthropicMessages, "https://api.anthropic.com", "claude", ReasoningDefault); err != nil {
+ t.Fatalf("SetIdentity: %v", err)
+ }
+ if err := conv.AddMessage(ab, &Usage{Input: 10, Output: 5}); err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+
+ asst := conv.Message(0).Snapshot()
+ if asst.Role != RoleAssistant || len(asst.Content) != 2 {
+ t.Fatalf("assistant message: role=%v blocks=%d", asst.Role, len(asst.Content))
+ }
+ if b := asst.Content[0]; b.Tag != BlockThinking || b.Text != "reasoned" {
+ t.Fatalf("thinking block: %+v", b)
+ }
+}
+
func testConfig() Config {
return Config{
Provider: ProviderConfig{
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig
index 39c2f1a..ed1600f 100644
--- a/libpanto/src/agent.zig
+++ b/libpanto/src/agent.zig
@@ -83,6 +83,9 @@ fn cloneMessage(alloc: Allocator, msg: conversation.Message) !conversation.Messa
.content = content,
.usage = msg.usage,
.metadata = if (msg.metadata) |m| try alloc.dupe(u8, m) else null,
+ // Preserve the producing identity so a kept-verbatim turn isn't
+ // re-stamped with the compaction model on persist.
+ .identity = if (msg.identity) |id| try conversation.dupeWireIdentity(alloc, id) else null,
};
}
@@ -330,8 +333,6 @@ pub const Agent = struct {
self._config = config;
}
-
-
/// Add a system message (append or replace mode) to the conversation
/// and persist it. The persisted entry records the mode so replay
/// reconstructs the same effective system prompt.
@@ -2638,7 +2639,6 @@ test "runStep surfaces EmptyAssistantResponse when provider commits an empty mes
h.seedInto(agent);
agent._open_stream_fn = stub.install();
-
try testing.expectError(error.EmptyAssistantResponse, drainTurn(agent, "hi"));
}
@@ -2721,7 +2721,6 @@ test "runStep: distinct sources run on distinct threads in parallel" {
h.seedInto(agent);
agent._open_stream_fn = stub.install();
-
try drainTurn(agent, "go");
const view_a = agent._registry.lookup("src_a_t") orelse return error.NotFound;
@@ -3143,7 +3142,6 @@ test "runStep: context overflow without compaction prompt propagates" {
agent._open_stream_fn = stub.install();
// No compaction_system_prompt set -> overflow propagates.
-
try testing.expectError(error.ContextOverflow, drainTurn(agent, "hi"));
}
@@ -3269,7 +3267,6 @@ test "runStep: provider 500 retries with backoff notification" {
h.seedInto(agent);
agent._open_stream_fn = stub.install();
-
var rr = RetryRecorder{ .allocator = allocator };
defer rr.deinit();
try drainTurnRecording(agent, "hi", &rr);
@@ -3307,7 +3304,6 @@ test "runStep: provider auth failure does not retry" {
h.seedInto(agent);
agent._open_stream_fn = stub.install();
-
var rr = RetryRecorder{ .allocator = allocator };
defer rr.deinit();
try testing.expectError(error.ProviderAuthFailed, drainTurnRecording(agent, "hi", &rr));
@@ -3346,7 +3342,6 @@ test "runStep: retries exhaust and hard-fail after max_attempts" {
h.seedInto(agent);
agent._open_stream_fn = stub.install();
-
var rr = RetryRecorder{ .allocator = allocator };
defer rr.deinit();
try testing.expectError(error.ProviderUnavailable, drainTurnRecording(agent, "hi", &rr));
@@ -3477,7 +3472,6 @@ test "runStep: Retry-After is honored and reported" {
h.seedInto(agent);
agent._open_stream_fn = stub.install();
-
var rr = RetryRecorder{ .allocator = allocator };
defer rr.deinit();
try drainTurnRecording(agent, "hi", &rr);
diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig
index 1b1cf6e..5f119f3 100644
--- a/libpanto/src/anthropic_messages_json.zig
+++ b/libpanto/src/anthropic_messages_json.zig
@@ -262,7 +262,7 @@ fn writeMessage(
try s.beginArray();
for (msg.content.items, 0..) |block, bi| {
const mark = if (cache_block) |cb| cb == bi else false;
- try writeBlock(s, block, mark, cfg);
+ try writeBlock(s, block, mark, cfg, msg.identity);
}
try s.endArray();
@@ -284,6 +284,7 @@ fn writeBlock(
block: conversation.ContentBlock,
mark_cache: bool,
cfg: *const config_mod.AnthropicMessagesConfig,
+ msg_identity: ?config_mod.WireIdentity,
) !void {
switch (block) {
.Text => |tb| {
@@ -304,8 +305,7 @@ fn writeBlock(
// messages, and opaque signatures are not portable across
// provider/model boundaries.
const sig = tb.signature orelse return;
- const origin = tb.signature_origin orelse return;
- if (!origin.matches(.anthropic_messages, cfg.base_url, cfg.model)) return;
+ if (!conversation.thinkingSignatureMatches(tb, msg_identity, .anthropic_messages, cfg.base_url, cfg.model)) return;
try s.beginObject();
try s.objectField("type");
try s.write("thinking");
@@ -819,6 +819,79 @@ test "serializeRequest - cache breakpoint skips a trailing thinking block" {
try testing.expect(content[1].object.get("cache_control") == null);
}
+test "serializeRequest - thinking signature replays from message identity when block origin is absent" {
+ // A reloaded thinking block need not carry its own signature_origin: the
+ // enclosing message's identity is the per-message source of truth, so a
+ // host can rely on it alone (no per-block identity copy required).
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addAssistantMessage(&.{
+ .{
+ .Thinking = .{
+ .text = try conversation.textualBlockFromSlice(allocator, "reasoned"),
+ .signature = try allocator.dupe(u8, "EqQBCgIYAhIM1gbcDa9GJwZA"),
+ // No signature_origin on the block.
+ },
+ },
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer") },
+ }, null);
+ // Per-message identity matching the request.
+ conv.messages.items[0].identity = try conversation.dupeWireIdentity(allocator, .{
+ .api_style = .anthropic_messages,
+ .base_url = "u",
+ .model = "claude-x",
+ });
+
+ const cfg = testConfig("claude-x");
+ 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, .{});
+ defer parsed.deinit();
+ const content = parsed.value.object.get("messages").?.array.items[0]
+ .object.get("content").?.array.items;
+ try testing.expectEqualStrings("thinking", content[0].object.get("type").?.string);
+ try testing.expectEqualStrings("EqQBCgIYAhIM1gbcDa9GJwZA", content[0].object.get("signature").?.string);
+}
+
+test "serializeRequest - thinking dropped when message identity targets a different model" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addAssistantMessage(&.{
+ .{ .Thinking = .{
+ .text = try conversation.textualBlockFromSlice(allocator, "reasoned"),
+ .signature = try allocator.dupe(u8, "EqQBCgIYAhIM1gbcDa9GJwZA"),
+ } },
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer") },
+ }, null);
+ // Identity points at a *different* model than the request.
+ conv.messages.items[0].identity = try conversation.dupeWireIdentity(allocator, .{
+ .api_style = .anthropic_messages,
+ .base_url = "u",
+ .model = "some-other-model",
+ });
+
+ const cfg = testConfig("claude-x");
+ 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, .{});
+ defer parsed.deinit();
+ const content = parsed.value.object.get("messages").?.array.items[0]
+ .object.get("content").?.array.items;
+ // Only the text block survives; the unportable thinking block is dropped.
+ try testing.expectEqual(@as(usize, 1), content.len);
+ try testing.expectEqualStrings("text", content[0].object.get("type").?.string);
+}
+
test "serializeRequest - prompt_cache disabled omits all cache_control" {
const allocator = testing.allocator;
diff --git a/libpanto/src/conversation.zig b/libpanto/src/conversation.zig
index 4338563..2c63f19 100644
--- a/libpanto/src/conversation.zig
+++ b/libpanto/src/conversation.zig
@@ -258,6 +258,16 @@ pub const Message = struct {
/// commits, read back off the `Message` after `load`. Borrowed; owned by
/// whoever set it (the conversation's allocator on the replay path).
metadata: ?[]const u8 = null,
+ /// The wire-format provider identity (`api_style`/`base_url`/`model` and
+ /// the reasoning/thinking knobs) that actually produced this message.
+ /// Stamped once, when the message is first persisted, and from the disk
+ /// stamp on replay. Preserved verbatim through compaction's clone, so a
+ /// kept-verbatim turn keeps the identity of the model that generated it
+ /// rather than being re-stamped with the compaction model. This is the
+ /// per-message source of truth for thinking-signature replay — no need to
+ /// pin the identity onto each thinking block. When set, the `base_url`
+ /// and `model` slices are owned by the conversation's allocator.
+ identity: ?config.WireIdentity = null,
pub fn deinit(self: *Message, alloc: Allocator) void {
for (self.content.items) |*block| {
@@ -265,9 +275,29 @@ pub const Message = struct {
}
self.content.deinit(alloc);
if (self.metadata) |m| alloc.free(m);
+ if (self.identity) |id| freeWireIdentity(alloc, id);
}
};
+/// Duplicate a `WireIdentity`'s owned slices (`base_url`, `model`) into
+/// `alloc`; scalar fields are copied as-is. The result owns its strings and
+/// must be released with `freeWireIdentity`.
+pub fn dupeWireIdentity(alloc: Allocator, id: config.WireIdentity) !config.WireIdentity {
+ const burl = try alloc.dupe(u8, id.base_url);
+ errdefer alloc.free(burl);
+ const mdl = try alloc.dupe(u8, id.model);
+ var out = id;
+ out.base_url = burl;
+ out.model = mdl;
+ return out;
+}
+
+/// Free the owned slices of a `WireIdentity` produced by `dupeWireIdentity`.
+pub fn freeWireIdentity(alloc: Allocator, id: config.WireIdentity) void {
+ alloc.free(id.base_url);
+ alloc.free(id.model);
+}
+
pub const Conversation = struct {
messages: std.ArrayList(Message) = .empty,
allocator: Allocator,
@@ -396,6 +426,35 @@ pub const Conversation = struct {
}
};
+/// Whether a thinking block's opaque signature may be replayed to a request
+/// targeting `(api_style, base_url, model)`. A signature is portable only
+/// back to the exact endpoint/model that produced it; replaying it elsewhere
+/// is rejected (Anthropic) or meaningless (OpenAI encrypted reasoning).
+///
+/// Provenance is resolved per block first (`signature_origin`, set live by
+/// the producing provider), then falls back to the enclosing message's
+/// `identity` (the per-message source of truth that survives compaction). A
+/// block with no signature, or with no provenance from either source, is not
+/// replayable — sending a signature to an unverified endpoint is unsafe.
+pub fn thinkingSignatureMatches(
+ block: ThinkingBlock,
+ msg_identity: ?config.WireIdentity,
+ api_style: config.APIStyle,
+ base_url: []const u8,
+ model: []const u8,
+) bool {
+ if (block.signature == null) return false;
+ if (block.signature_origin) |origin| {
+ return origin.matches(api_style, base_url, model);
+ }
+ if (msg_identity) |id| {
+ return id.api_style == api_style and
+ std.mem.eql(u8, id.base_url, base_url) and
+ std.mem.eql(u8, id.model, model);
+ }
+ return false;
+}
+
pub fn setThinkingOrigins(
allocator: Allocator,
blocks: []ContentBlock,
diff --git a/libpanto/src/file_system_jsonl_store.zig b/libpanto/src/file_system_jsonl_store.zig
index e47698c..50587f0 100644
--- a/libpanto/src/file_system_jsonl_store.zig
+++ b/libpanto/src/file_system_jsonl_store.zig
@@ -32,6 +32,7 @@ const Io = std.Io;
const session_mod = @import("session.zig");
const conversation_mod = @import("conversation.zig");
const session_store_mod = @import("session_store.zig");
+const turn_persist = @import("turn_persist.zig");
pub const SessionHeader = session_mod.SessionHeader;
pub const SessionEntry = session_mod.SessionEntry;
@@ -714,7 +715,6 @@ pub const SessionFile = struct {
}
return conv;
}
-
};
/// Best-effort extraction of plain prompt text from a user `StoredMessage`.
@@ -772,12 +772,31 @@ fn appendMessageToConv(
.user => .user,
.assistant => .assistant,
};
+ // Reconstruct the per-message producing identity from the wire stamp, so
+ // a later in-session compaction preserves it (rather than re-stamping the
+ // restated turn with the compaction model). Borrowed `stamp` slices are
+ // duped into the conversation allocator.
+ const identity: ?session_store_mod.WireIdentity = if (stamp) |st|
+ try conversation_mod.dupeWireIdentity(allocator, .{
+ .api_style = st.api_style,
+ .base_url = st.base_url,
+ .model = st.model,
+ .reasoning = st.reasoning,
+ .thinking = st.thinking,
+ .effort = st.effort,
+ .thinking_budget_tokens = st.thinking_budget_tokens,
+ .thinking_interleaved = st.thinking_interleaved,
+ })
+ else
+ null;
+ errdefer if (identity) |id| conversation_mod.freeWireIdentity(allocator, id);
// Carry the recorded usage forward so compaction can size the retention
// window after a session is reopened (it's null for user/system).
try conv.messages.append(allocator, .{
.role = role,
.content = content,
.usage = disk_msg.usage,
+ .identity = identity,
});
}
@@ -2147,3 +2166,63 @@ test "FileSystemJSONLStore load restores thinking signature origin from message
"claude-sonnet-4-20250514",
));
}
+
+test "persistTurn: a message's own identity overrides the uniform persist identity" {
+ // Regression for the compaction re-stamping bug: a kept-verbatim turn
+ // produced by model A must persist with A's wire identity even when the
+ // turn is persisted under model B (the compaction model). persistTurn
+ // stamps the uniform identity only on messages that don't already carry
+ // one; a message with its own identity keeps it.
+ const io = testing.io;
+
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ var catalog = try FileSystemJSONLStore.init(testing.allocator, io, sessions);
+ defer catalog.deinit();
+ const store = catalog.store();
+ var sess = store.create();
+ defer sess.info.deinit(testing.allocator);
+
+ var conv = conversation_mod.Conversation.init(testing.allocator);
+ defer conv.deinit();
+ // User turn carries no identity (will be stamped with the persist id).
+ try addUserText(&conv, "ping");
+ // Assistant turn was produced by model A — stamp its identity directly,
+ // as a live turn / a reloaded turn would have.
+ try conv.addAssistantMessage(&.{
+ .{ .Thinking = .{
+ .text = try conversation_mod.textualBlockFromSlice(testing.allocator, "thinking..."),
+ .signature = try testing.allocator.dupe(u8, "sig123"),
+ } },
+ .{ .Text = try conversation_mod.textualBlockFromSlice(testing.allocator, "pong") },
+ }, null);
+ conv.messages.items[1].identity = try conversation_mod.dupeWireIdentity(testing.allocator, .{
+ .api_style = .anthropic_messages,
+ .base_url = "https://api.anthropic.com",
+ .model = "claude-sonnet-4-20250514",
+ });
+
+ // Persist under model B (a different, "compaction" identity).
+ const persist_id: session_store_mod.WireIdentity = .{
+ .api_style = .openai_chat,
+ .base_url = "https://api.openai.com/v1",
+ .model = "gpt-4o",
+ };
+ try turn_persist.persistTurn(testing.allocator, &sess, &conv, 0, persist_id, &.{});
+
+ // Reload: the user turn took model B's identity; the assistant turn kept
+ // model A's — so its thinking signature replays to A, not B.
+ var loaded = (try store.load(sess.info.id)).?;
+ defer loaded.deinit();
+
+ const asst_origin = loaded.messages.items[1].content.items[0].Thinking.signature_origin.?;
+ try testing.expect(asst_origin.matches(.anthropic_messages, "https://api.anthropic.com", "claude-sonnet-4-20250514"));
+ try testing.expect(!asst_origin.matches(.openai_chat, "https://api.openai.com/v1", "gpt-4o"));
+
+ const user_id = loaded.messages.items[0].identity.?;
+ try testing.expectEqual(session_store_mod.APIStyle.openai_chat, user_id.api_style);
+ try testing.expectEqualStrings("gpt-4o", user_id.model);
+}
diff --git a/libpanto/src/openai_responses_json.zig b/libpanto/src/openai_responses_json.zig
index 295e79c..fb98fe1 100644
--- a/libpanto/src/openai_responses_json.zig
+++ b/libpanto/src/openai_responses_json.zig
@@ -199,8 +199,9 @@ fn writeInputForMessage(
if (block != .Thinking) continue;
const tb = block.Thinking;
const sig = tb.signature orelse continue;
- const origin = tb.signature_origin orelse continue;
- if (!origin.matches(
+ if (!conversation.thinkingSignatureMatches(
+ tb,
+ msg.identity,
if (dialect == .codex) .openai_codex_responses else .openai_responses,
cfg.base_url,
cfg.model,
@@ -643,9 +644,7 @@ test "responses serializeRequest - assistant phase metadata and reasoning signat
var conv = conversation.Conversation.init(allocator);
defer conv.deinit();
- const sig = try allocator.dupe(u8,
- "{\"type\":\"reasoning\",\"id\":\"rs_1\",\"encrypted_content\":\"sealed\"}"
- );
+ const sig = try allocator.dupe(u8, "{\"type\":\"reasoning\",\"id\":\"rs_1\",\"encrypted_content\":\"sealed\"}");
const thinking = try conversation.textualBlockFromSlice(allocator, "thinking");
const text = try conversation.textualBlockFromSlice(allocator, "answer");
try conv.addAssistantMessage(&.{
@@ -676,9 +675,7 @@ test "responses serializeRequest - mismatched signature origin skips reasoning r
var conv = conversation.Conversation.init(allocator);
defer conv.deinit();
- const sig = try allocator.dupe(u8,
- "{\"type\":\"reasoning\",\"id\":\"rs_1\",\"encrypted_content\":\"sealed\"}"
- );
+ const sig = try allocator.dupe(u8, "{\"type\":\"reasoning\",\"id\":\"rs_1\",\"encrypted_content\":\"sealed\"}");
try conv.addAssistantMessage(&.{
.{ .Thinking = .{ .text = try conversation.textualBlockFromSlice(allocator, "thinking"), .signature = sig } },
.{ .Text = try conversation.textualBlockFromSlice(allocator, "answer") },
diff --git a/libpanto/src/public.zig b/libpanto/src/public.zig
index d547af3..eb8e29a 100644
--- a/libpanto/src/public.zig
+++ b/libpanto/src/public.zig
@@ -132,6 +132,11 @@ pub const TextualBlock = conversation_mod.TextualBlock;
/// assembling `ContentBlock`s by hand (e.g. for conversation resumption).
pub const textualBlockFromSlice = conversation_mod.textualBlockFromSlice;
pub const SignatureOrigin = conversation_mod.SignatureOrigin;
+/// Duplicate / free a `Message.identity`'s owned slices. Binding code uses
+/// these to attach a per-message wire identity when rebuilding a conversation
+/// for resumption (so it survives a later compaction).
+pub const dupeWireIdentity = conversation_mod.dupeWireIdentity;
+pub const freeWireIdentity = conversation_mod.freeWireIdentity;
pub const ThinkingBlock = conversation_mod.ThinkingBlock;
pub const ToolUseBlock = conversation_mod.ToolUseBlock;
pub const ToolResultBlock = conversation_mod.ToolResultBlock;
diff --git a/libpanto/src/turn_persist.zig b/libpanto/src/turn_persist.zig
index 0147fbd..1dacfec 100644
--- a/libpanto/src/turn_persist.zig
+++ b/libpanto/src/turn_persist.zig
@@ -41,7 +41,7 @@ const Session = session_store.Session;
pub fn persistTurn(
alloc: Allocator,
session: *Session,
- conv: *const conversation.Conversation,
+ conv: *conversation.Conversation,
start_index: usize,
identity: WireIdentity,
tools: []const ToolDecl,
@@ -52,14 +52,22 @@ pub fn persistTurn(
const all_messages = conv.messages.items;
var i = start_index;
while (i < all_messages.len) : (i += 1) {
- const msg = all_messages[i];
+ const msg = &all_messages[i];
if (msg.role == .assistant and hasToolUseWithoutFollowingResults(conv, i)) {
continue;
}
+ // Stamp the producing identity once, on first persist. A message that
+ // already carries one (e.g. a kept-verbatim turn carried through
+ // compaction, or one rebuilt from disk) keeps it — only the messages
+ // freshly produced under `identity` are stamped now. This is what
+ // makes the stamp survive a later compaction onto a different model.
+ if (msg.identity == null) {
+ msg.identity = try conversation.dupeWireIdentity(alloc, identity);
+ }
try batch.append(alloc, .{
- .message = msg,
+ .message = msg.*,
.usage = msg.usage,
- .identity = identity,
+ .identity = msg.identity.?,
.conversation = all_messages,
.tools_available = tools,
});
@@ -75,7 +83,7 @@ pub fn persistTurn(
pub fn persistCompaction(
alloc: Allocator,
session: *Session,
- conv: *const conversation.Conversation,
+ conv: *conversation.Conversation,
identity: WireIdentity,
tools: []const ToolDecl,
) !void {