diff options
| -rw-r--r-- | libpanto-c/include/panto.h | 16 | ||||
| -rw-r--r-- | libpanto-c/src/lib.zig | 46 | ||||
| -rw-r--r-- | libpanto-go/panto.go | 19 | ||||
| -rw-r--r-- | libpanto-go/panto_test.go | 41 | ||||
| -rw-r--r-- | libpanto/src/agent.zig | 12 | ||||
| -rw-r--r-- | libpanto/src/anthropic_messages_json.zig | 79 | ||||
| -rw-r--r-- | libpanto/src/conversation.zig | 59 | ||||
| -rw-r--r-- | libpanto/src/file_system_jsonl_store.zig | 81 | ||||
| -rw-r--r-- | libpanto/src/openai_responses_json.zig | 13 | ||||
| -rw-r--r-- | libpanto/src/public.zig | 5 | ||||
| -rw-r--r-- | libpanto/src/turn_persist.zig | 18 |
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 { |
