diff options
Diffstat (limited to 'libpanto')
| -rw-r--r-- | libpanto/src/anthropic_messages_json.zig | 81 | ||||
| -rw-r--r-- | libpanto/src/conversation.zig | 162 | ||||
| -rw-r--r-- | libpanto/src/openai_chat_json.zig | 79 | ||||
| -rw-r--r-- | libpanto/src/session.zig | 72 | ||||
| -rw-r--r-- | libpanto/src/session_manager.zig | 16 |
5 files changed, 396 insertions, 14 deletions
diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig index 5b65c2b..f8bfc2e 100644 --- a/libpanto/src/anthropic_messages_json.zig +++ b/libpanto/src/anthropic_messages_json.zig @@ -111,20 +111,26 @@ fn writeRawJson(s: *std.json.Stringify, raw: []const u8) !void { try s.write(parsed.value); } +/// Build the top-level Anthropic `system` string. Applies the shared +/// append/replace derivation (see `conversation.effectiveSystemBlocks`), +/// strips trailing newlines from each surviving block, and joins them with +/// a horizontal rule (`\n\n---\n\n`). Anthropic's wire format requires a +/// single string, so this rule is its concession to that constraint. fn collectSystemPrompt( conv: *const conversation.Conversation, out: *std.ArrayList(u8), allocator: Allocator, ) !void { + var blocks = try conversation.effectiveSystemBlocks(allocator, conv.messages.items); + defer blocks.deinit(allocator); + + const sep = "\n\n---\n\n"; var first = true; - for (conv.messages.items) |msg| { - if (msg.role != .system) continue; - for (msg.content.items) |block| { - if (block != .Text) continue; - if (!first) try out.append(allocator, '\n'); - try out.appendSlice(allocator, block.Text.items); - first = false; - } + for (blocks.items) |text| { + const trimmed = std.mem.trimEnd(u8, text, "\n"); + if (!first) try out.appendSlice(allocator, sep); + try out.appendSlice(allocator, trimmed); + first = false; } } @@ -208,6 +214,11 @@ fn writeBlock(s: *std.json.Stringify, block: conversation.ContentBlock) !void { try s.write(tr.content.items); try s.endObject(); }, + // System blocks never reach here: `serializeRequest` filters out + // `.system` messages before emitting the `messages` array, and the + // system text is hoisted into the top-level `system` string by + // `collectSystemPrompt`. Present only to keep the switch exhaustive. + .System => {}, } } @@ -526,7 +537,7 @@ test "serializeRequest - system extracted into top-level field" { try testing.expectEqualStrings("Hello!", content[0].object.get("text").?.string); } -test "serializeRequest - multiple system messages concatenated with newlines" { +test "serializeRequest - multiple system messages joined with horizontal rule" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); @@ -545,7 +556,57 @@ test "serializeRequest - multiple system messages concatenated with newlines" { var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); defer parsed.deinit(); try testing.expectEqualStrings( - "Be terse.\nBe accurate.", + "Be terse.\n\n---\n\nBe accurate.", + parsed.value.object.get("system").?.string, + ); +} + +test "serializeRequest - replace-mode system block wipes prior system text" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("original seed"); + try conv.addSystemMessage("original append"); + try conv.replaceSystemMessage("fresh seed"); + try conv.addSystemMessage("fresh append"); + try conv.addUserMessage("Hi"); + + 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(); + try testing.expectEqualStrings( + "fresh seed\n\n---\n\nfresh append", + parsed.value.object.get("system").?.string, + ); +} + +test "serializeRequest - trailing newlines stripped before the rule join" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("line one\n\n"); + try conv.addSystemMessage("line two\n"); + try conv.addUserMessage("Hi"); + + 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(); + try testing.expectEqualStrings( + "line one\n\n---\n\nline two", parsed.value.object.get("system").?.string, ); } diff --git a/libpanto/src/conversation.zig b/libpanto/src/conversation.zig index 6776412..f145d7f 100644 --- a/libpanto/src/conversation.zig +++ b/libpanto/src/conversation.zig @@ -51,11 +51,29 @@ pub const ToolResultBlock = struct { } }; +/// How a `.system` content block combines with the system text collected +/// before it. `append` adds to the running effective prompt; `replace` +/// discards everything collected so far and starts fresh from this block. +pub const SystemMode = enum { append, replace }; + +/// A system-prompt content block. System prompts remain `.system`-role +/// messages; this block records the mode that governs how its text folds +/// into the effective system prompt (see `effectiveSystemBlocks`). +pub const SystemBlock = struct { + text: TextualBlock = .empty, + mode: SystemMode = .append, + + pub fn deinit(self: *SystemBlock, alloc: Allocator) void { + self.text.deinit(alloc); + } +}; + pub const ContentBlock = union(enum) { Text: TextualBlock, Thinking: ThinkingBlock, ToolUse: ToolUseBlock, ToolResult: ToolResultBlock, + System: SystemBlock, pub fn deinit(self: *ContentBlock, alloc: Allocator) void { switch (self.*) { @@ -63,6 +81,7 @@ pub const ContentBlock = union(enum) { .Thinking => |*b| b.deinit(alloc), .ToolUse => |*b| b.deinit(alloc), .ToolResult => |*b| b.deinit(alloc), + .System => |*b| b.deinit(alloc), } } }; @@ -95,10 +114,30 @@ pub const Conversation = struct { }; } + /// Append a system message in `append` mode. Adds to the effective + /// system prompt. (Back-compatible: same external behavior as before + /// the `.System` block existed.) pub fn addSystemMessage(self: *Conversation, text: []const u8) !void { + return self.appendSystemBlock(text, .append); + } + + /// Append a system message in `replace` mode. When the effective + /// prompt is rebuilt (see `effectiveSystemBlocks`), this discards all + /// prior system text and starts fresh. + pub fn replaceSystemMessage(self: *Conversation, text: []const u8) !void { + return self.appendSystemBlock(text, .replace); + } + + /// Append a `.system`-role message whose single content block is a + /// `.System` block carrying `mode`. + fn appendSystemBlock(self: *Conversation, text: []const u8, mode: SystemMode) !void { const tb = try textualBlockFromSlice(self.allocator, text); var content: std.ArrayList(ContentBlock) = .empty; - try content.append(self.allocator, .{ .Text = tb }); + errdefer { + for (content.items) |*b| b.deinit(self.allocator); + content.deinit(self.allocator); + } + try content.append(self.allocator, .{ .System = .{ .text = tb, .mode = mode } }); try self.messages.append(self.allocator, .{ .role = .system, .content = content, @@ -137,6 +176,50 @@ pub const Conversation = struct { } }; +/// Derive the effective ordered list of system-text blocks from a slice of +/// messages. This is the single shared rule that governs both provider +/// serialization and session rebuild. +/// +/// Walk the messages in order; for each `.system` message's `.System` +/// block: +/// - `append`: add the block's text to the running list. +/// - `replace`: clear the running list, then add this block's text. +/// +/// The returned slices are **borrowed** from `messages` — valid only as +/// long as the underlying conversation is unmodified. The caller owns the +/// returned `ArrayList` itself and must `deinit` it (this frees the slice +/// storage, not the borrowed text). +/// +/// Running this walk over a *prefix* of the messages reconstructs the +/// effective prompt as of that point — the `/tree` faithfulness property. +pub fn effectiveSystemBlocks( + alloc: Allocator, + messages: []const Message, +) !std.ArrayList([]const u8) { + var out: std.ArrayList([]const u8) = .empty; + errdefer out.deinit(alloc); + for (messages) |msg| { + if (msg.role != .system) continue; + for (msg.content.items) |block| { + switch (block) { + .System => |sb| { + switch (sb.mode) { + .append => {}, + .replace => out.clearRetainingCapacity(), + } + try out.append(alloc, sb.text.items); + }, + // Be tolerant of plain `.Text` blocks on a system message + // (e.g. hand-built test conversations): treat them as + // append-mode text. + .Text => |tb| try out.append(alloc, tb.items), + else => {}, + } + } + } + return out; +} + test "Conversation - add messages and verify content" { const allocator = std.testing.allocator; @@ -154,7 +237,11 @@ test "Conversation - add messages and verify content" { try std.testing.expectEqual(MessageRole.system, conv.messages.items[0].role); try std.testing.expectEqualStrings( "You are a helpful assistant.", - conv.messages.items[0].content.items[0].Text.items, + conv.messages.items[0].content.items[0].System.text.items, + ); + try std.testing.expectEqual( + SystemMode.append, + conv.messages.items[0].content.items[0].System.mode, ); try std.testing.expectEqual(MessageRole.user, conv.messages.items[1].role); @@ -202,3 +289,74 @@ test "ContentBlock - Thinking variant" { try std.testing.expectEqualStrings("hmm...", conv.messages.items[0].content.items[0].Thinking.text.items); try std.testing.expectEqualStrings("answer", conv.messages.items[0].content.items[1].Text.items); } + +test "System block - addSystemMessage records append mode, replaceSystemMessage records replace mode" { + const allocator = std.testing.allocator; + + var conv = Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("base"); + try conv.replaceSystemMessage("fresh"); + + try std.testing.expectEqual(SystemMode.append, conv.messages.items[0].content.items[0].System.mode); + try std.testing.expectEqualStrings("base", conv.messages.items[0].content.items[0].System.text.items); + try std.testing.expectEqual(SystemMode.replace, conv.messages.items[1].content.items[0].System.mode); + try std.testing.expectEqualStrings("fresh", conv.messages.items[1].content.items[0].System.text.items); +} + +test "effectiveSystemBlocks - append accumulates in order" { + const allocator = std.testing.allocator; + + var conv = Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("a"); + try conv.addSystemMessage("b"); + try conv.addUserMessage("hi"); + try conv.addSystemMessage("c"); + + var blocks = try effectiveSystemBlocks(allocator, conv.messages.items); + defer blocks.deinit(allocator); + + try std.testing.expectEqual(@as(usize, 3), blocks.items.len); + try std.testing.expectEqualStrings("a", blocks.items[0]); + try std.testing.expectEqualStrings("b", blocks.items[1]); + try std.testing.expectEqualStrings("c", blocks.items[2]); +} + +test "effectiveSystemBlocks - replace wipes everything collected so far" { + const allocator = std.testing.allocator; + + var conv = Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("a"); + try conv.addSystemMessage("b"); + try conv.replaceSystemMessage("fresh"); + try conv.addSystemMessage("after"); + + var blocks = try effectiveSystemBlocks(allocator, conv.messages.items); + defer blocks.deinit(allocator); + + try std.testing.expectEqual(@as(usize, 2), blocks.items.len); + try std.testing.expectEqualStrings("fresh", blocks.items[0]); + try std.testing.expectEqualStrings("after", blocks.items[1]); +} + +test "effectiveSystemBlocks - prefix reconstructs prompt as of that point" { + const allocator = std.testing.allocator; + + var conv = Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("a"); + try conv.replaceSystemMessage("fresh"); + try conv.addSystemMessage("after"); + + // Truncate at position 1 (only the first `addSystemMessage`). + var blocks = try effectiveSystemBlocks(allocator, conv.messages.items[0..1]); + defer blocks.deinit(allocator); + try std.testing.expectEqual(@as(usize, 1), blocks.items.len); + try std.testing.expectEqualStrings("a", blocks.items[0]); +} diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig index 6991559..3b81c41 100644 --- a/libpanto/src/openai_chat_json.zig +++ b/libpanto/src/openai_chat_json.zig @@ -140,7 +140,24 @@ pub fn serializeRequest( try s.objectField("messages"); try s.beginArray(); + // Hoist the effective system prompt to the front as separate, + // individually-positioned `system` messages (one per surviving block, + // in derivation order). Keeping them distinct preserves block-level + // addressability for `/tree`-style truncation — we deliberately do NOT + // concatenate them the way Anthropic's single-string format forces. + var sys_blocks = try conversation.effectiveSystemBlocks(allocator, conv.messages.items); + defer sys_blocks.deinit(allocator); + for (sys_blocks.items) |text| { + try s.beginObject(); + try s.objectField("role"); + try s.write("system"); + try s.objectField("content"); + try s.write(text); + try s.endObject(); + } + // Then every non-system message, in its original order. for (conv.messages.items) |msg| { + if (msg.role == .system) continue; try writeMessage(&s, msg, allocator); } try s.endArray(); @@ -497,6 +514,68 @@ test "serializeRequest - system + user" { try testing.expectEqualStrings("Hello!", msgs[1].object.get("content").?.string); } +test "serializeRequest - multiple system blocks hoisted as separate leading messages" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("seed"); + try conv.addUserMessage("Hello!"); + try conv.addSystemMessage("mid-conversation append"); + try conv.addUserMessage("again"); + + const cfg = testConfig("gpt-4o"); + var tools = emptyTools(); + defer tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + + const msgs = parsed.value.object.get("messages").?.array.items; + // Two system messages first (in derivation order), then the two users. + try testing.expectEqual(@as(usize, 4), msgs.len); + try testing.expectEqualStrings("system", msgs[0].object.get("role").?.string); + try testing.expectEqualStrings("seed", msgs[0].object.get("content").?.string); + try testing.expectEqualStrings("system", msgs[1].object.get("role").?.string); + try testing.expectEqualStrings("mid-conversation append", msgs[1].object.get("content").?.string); + try testing.expectEqualStrings("user", msgs[2].object.get("role").?.string); + try testing.expectEqualStrings("Hello!", msgs[2].object.get("content").?.string); + try testing.expectEqualStrings("user", msgs[3].object.get("role").?.string); + try testing.expectEqualStrings("again", msgs[3].object.get("content").?.string); +} + +test "serializeRequest - replace-mode system block wipes prior system messages" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("original"); + try conv.replaceSystemMessage("fresh seed"); + try conv.addSystemMessage("fresh append"); + try conv.addUserMessage("Hi"); + + const cfg = testConfig("gpt-4o"); + var tools = emptyTools(); + defer tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + + const msgs = parsed.value.object.get("messages").?.array.items; + try testing.expectEqual(@as(usize, 3), msgs.len); + try testing.expectEqualStrings("system", msgs[0].object.get("role").?.string); + try testing.expectEqualStrings("fresh seed", msgs[0].object.get("content").?.string); + try testing.expectEqualStrings("system", msgs[1].object.get("role").?.string); + try testing.expectEqualStrings("fresh append", msgs[1].object.get("content").?.string); + try testing.expectEqualStrings("user", msgs[2].object.get("role").?.string); +} + test "serializeRequest - assistant Thinking blocks are stripped from outbound history" { const allocator = testing.allocator; diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig index e77d121..c1fd1c4 100644 --- a/libpanto/src/session.zig +++ b/libpanto/src/session.zig @@ -100,9 +100,18 @@ pub const MessageEntry = struct { pub const DiskMessageRole = enum { system, user, assistant }; +/// Mode for a system-role message. Mirrors `conversation.SystemMode`. +/// `append` adds to the effective prompt; `replace` discards all prior +/// system text. Only meaningful on system messages; absent on disk means +/// `append` (back-compatible with pre-mode logs). +pub const DiskSystemMode = enum { append, replace }; + pub const DiskMessage = struct { role: DiskMessageRole, content: []DiskContentBlock, // owned + /// System-message mode. Recorded only for system-role messages; an + /// absent `mode` on disk parses back as `.append`. + mode: DiskSystemMode = .append, // Assistant-only metadata. Null for system/user messages. provider: ?[]const u8 = null, // owned model: ?[]const u8 = null, // owned @@ -296,6 +305,12 @@ fn writeDiskMessage(s: *std.json.Stringify, msg: DiskMessage) !void { try s.beginObject(); try s.objectField("role"); try s.write(@tagName(msg.role)); + // `mode` is meaningful only for system messages. Emit it there so the + // append/replace semantics round-trip; omit it everywhere else. + if (msg.role == .system) { + try s.objectField("mode"); + try s.write(@tagName(msg.mode)); + } try s.objectField("content"); try s.beginArray(); for (msg.content) |block| { @@ -479,6 +494,14 @@ fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Di if (role_v != .string) return error.MissingField; const role = std.meta.stringToEnum(DiskMessageRole, role_v.string) orelse return error.UnknownRole; + // `mode` is optional; absent defaults to `.append`. Unknown values are + // tolerated as `.append` rather than rejecting an otherwise-valid log. + const mode: DiskSystemMode = blk: { + const mv = obj.get("mode") orelse break :blk .append; + if (mv != .string) break :blk .append; + break :blk std.meta.stringToEnum(DiskSystemMode, mv.string) orelse .append; + }; + const content_v = obj.get("content") orelse return error.MissingField; if (content_v != .array) return error.MissingField; var content_list = try std.ArrayList(DiskContentBlock).initCapacity(allocator, content_v.array.items.len); @@ -520,6 +543,7 @@ fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Di return .{ .role = role, .content = content, + .mode = mode, .provider = provider, .model = model, .stop_reason = stop_reason, @@ -612,6 +636,13 @@ pub fn contentBlockToDisk( const content = try allocator.dupe(u8, tr.content.items); return .{ .tool_result = .{ .tool_use_id = tuid, .content = content } }; }, + // A `.System` block becomes a disk text block; its mode rides on + // the enclosing `DiskMessage.mode` (set by the session manager), + // not on the block itself. + .System => |sb| { + const text = try allocator.dupe(u8, sb.text.items); + return .{ .text = .{ .text = text } }; + }, } } @@ -808,6 +839,47 @@ test "serialize/parse tool result message entry" { try testing.expectEqualStrings("anthropic", got.provider.?); } +test "system message mode round-trips; absent mode defaults to append" { + const a = testing.allocator; + + // replace-mode system entry round-trips. + { + var content = try a.alloc(DiskContentBlock, 1); + content[0] = .{ .text = .{ .text = try dupe(a, "fresh seed") } }; + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "aabbccdd"), + .parent_id = null, + .timestamp = try dupe(a, "2026-04-25T17:40:00Z"), + }, + .message = .{ + .role = .system, + .content = content, + .mode = .replace, + }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + try testing.expect(std.mem.indexOf(u8, line, "\"mode\":\"replace\"") != null); + + var fe = try parseLine(a, line); + defer fe.deinit(a); + try testing.expectEqual(DiskSystemMode.replace, fe.entry.message.message.mode); + } + + // A legacy system entry with no `mode` parses back as append. + { + const line = + \\{"type":"message","id":"abcdefab","parentId":null,"timestamp":"2026-04-25T17:40:00Z","message":{"role":"system","content":[{"type":"text","text":"hi"}]}} + ; + var fe = try parseLine(a, line); + defer fe.deinit(a); + try testing.expectEqual(DiskSystemMode.append, fe.entry.message.message.mode); + } +} + test "parse: null parentId is handled" { const a = testing.allocator; const line = diff --git a/libpanto/src/session_manager.zig b/libpanto/src/session_manager.zig index bf9585d..8b503f2 100644 --- a/libpanto/src/session_manager.zig +++ b/libpanto/src/session_manager.zig @@ -37,6 +37,7 @@ pub const SessionEntry = session_mod.SessionEntry; pub const MessageEntry = session_mod.MessageEntry; pub const DiskMessage = session_mod.DiskMessage; pub const DiskMessageRole = session_mod.DiskMessageRole; +pub const DiskSystemMode = session_mod.DiskSystemMode; pub const DiskContentBlock = session_mod.DiskContentBlock; pub const Usage = session_mod.Usage; pub const CURRENT_VERSION = session_mod.CURRENT_VERSION; @@ -668,8 +669,19 @@ fn appendMessageToConv( content.deinit(allocator); } try content.ensureTotalCapacity(allocator, disk_msg.content.len); + const sys_mode: conversation_mod.SystemMode = switch (disk_msg.mode) { + .append => .append, + .replace => .replace, + }; for (disk_msg.content) |db| { - const block = try session_mod.diskContentBlockToInternal(allocator, db); + var block = try session_mod.diskContentBlockToInternal(allocator, db); + // System-role text blocks become `.System` blocks carrying the + // message's recorded mode, so the append/replace derivation works + // on the rebuilt conversation exactly as it did when written. + if (disk_msg.role == .system and block == .Text) { + const tb = block.Text; + block = .{ .System = .{ .text = tb, .mode = sys_mode } }; + } content.appendAssumeCapacity(block); } const role: conversation_mod.MessageRole = switch (disk_msg.role) { @@ -1248,7 +1260,7 @@ test "SessionManager: rebuildConversation reconstructs system/user/assistant tur defer conv.deinit(); try testing.expectEqual(@as(usize, 3), conv.messages.items.len); try testing.expectEqual(conversation_mod.MessageRole.system, conv.messages.items[0].role); - try testing.expectEqualStrings("you are helpful", conv.messages.items[0].content.items[0].Text.items); + try testing.expectEqualStrings("you are helpful", conv.messages.items[0].content.items[0].System.text.items); try testing.expectEqual(conversation_mod.MessageRole.user, conv.messages.items[1].role); try testing.expectEqualStrings("hello", conv.messages.items[1].content.items[0].Text.items); try testing.expectEqual(conversation_mod.MessageRole.assistant, conv.messages.items[2].role); |
