summaryrefslogtreecommitdiff
path: root/libpanto/src
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 /libpanto/src
parent7c2d8825660f73198149c0b2ce26166b16ba3532 (diff)
preserve signature origins across compactions
Diffstat (limited to 'libpanto/src')
-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
7 files changed, 241 insertions, 26 deletions
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 {