diff options
| author | t <t@tjp.lol> | 2026-06-07 14:57:58 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-07 14:57:58 -0600 |
| commit | 17a985cb41727b8a1bca4d95f334106c09386040 (patch) | |
| tree | 237a7f1e0d45dafb61ace13f5112ecd31170df4c | |
| parent | 1eddee33902757f7e06993825a3ad1011e7ec346 (diff) | |
Alias Conversation instead of facading it; merge addAssistantMessage
A facade that forwards every method 1:1 is worse than paring the real type
down to the intended surface and aliasing it. conversation.Conversation's
interface is already the public surface we want (init/deinit, the
add*/replace* builders, and messages/allocator as plain data fields), so
public.zig now aliases it straight through and Agent.conversation() returns
a borrowed *Conversation for in-place surgery. Drops the ConversationData
alias and the pointer-wrapper.
Merged addAssistantMessageWithUsage into addAssistantMessage(blocks, ?usage)
on the real type (separate method deleted), so the alias has the intended
one-method shape; all call sites updated.
Only Agent and Stream remain true facades -- they have genuinely-internal
fields plus API-shaping renames (Stream.phase->state, Phase->State) an alias
can't express.
| -rw-r--r-- | docs/libpanto-cleanup.md | 18 | ||||
| -rw-r--r-- | libpanto/src/agent.zig | 22 | ||||
| -rw-r--r-- | libpanto/src/anthropic_messages_json.zig | 10 | ||||
| -rw-r--r-- | libpanto/src/conversation.zig | 22 | ||||
| -rw-r--r-- | libpanto/src/file_system_jsonl_store.zig | 2 | ||||
| -rw-r--r-- | libpanto/src/openai_chat_json.zig | 6 | ||||
| -rw-r--r-- | libpanto/src/provider_anthropic_messages.zig | 2 | ||||
| -rw-r--r-- | libpanto/src/provider_openai_chat.zig | 2 | ||||
| -rw-r--r-- | libpanto/src/public.zig | 53 | ||||
| -rw-r--r-- | src/main.zig | 2 | ||||
| -rw-r--r-- | src/system_prompt.zig | 18 |
11 files changed, 71 insertions, 86 deletions
diff --git a/docs/libpanto-cleanup.md b/docs/libpanto-cleanup.md index a495ff5..bc1e1aa 100644 --- a/docs/libpanto-cleanup.md +++ b/docs/libpanto-cleanup.md @@ -71,9 +71,25 @@ touching `agent.zig`. ### Three buckets — wrap, handle, or alias +> **Updated during implementation:** `Conversation` moved out of the +> behavioral-wrap bucket. Its internal interface was pared down to exactly +> the intended public surface (constructors + the `add*`/`replace*` +> builders; `messages`/`allocator` are fine as public data fields for a Zig +> object), so `public.zig` now **aliases** `conversation.Conversation` +> straight through instead of pointer-wrapping it. `Agent.conversation()` +> returns a borrowed `*Conversation` for in-place surgery. Only `Agent` and +> `Stream` remain true façades — they have genuinely-internal fields +> (`config`/`registry`/`session`/`open_stream_fn`/... and +> `queue`/`response`/`pending_error`/...) plus API-shaping renames +> (`Stream.phase`→`state`, `Phase`→`State`) that an alias can't express. +> `addAssistantMessageWithUsage` was merged into +> `addAssistantMessage(blocks, ?usage)` on the real type (the separate +> method deleted), so the alias already has the intended one-method shape. + | bucket | rule | types | | --- | --- | --- | -| **behavioral** | wrap a **pointer** to a heap-pinned internal | `Agent`, `Stream`, `Conversation` | +| **behavioral** | wrap a **pointer** to a heap-pinned internal | `Agent`, `Stream` | +| **behavioral (pared + aliased)** | trim the real type to the public surface, then alias | `Conversation` | | **data (read/output)** | **alias** straight through; inspected field-by-field | `Event` (+ nested), `Usage`, `Pricing`, `SessionInfo` | | **data (constructed)** | **alias** straight through; Zig users build them with `ArrayList` directly | `ContentBlock`, `Message`, the block types, `Config` family, `ResultPart` | diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index 6d62b8a..45347ea 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -1552,7 +1552,7 @@ const StubResponse = struct { } const moved = try blocks.toOwnedSlice(self.allocator); defer self.allocator.free(moved); - try self.conv.addAssistantMessageWithUsage(moved, self.turn.usage); + try self.conv.addAssistantMessage(moved, self.turn.usage); const msg = self.conv.messages.items[self.conv.messages.items.len - 1]; try out.push(.{ .message_complete = .{ .message = msg, .usage = self.turn.usage } }); @@ -2651,11 +2651,11 @@ test "compact: summarizes prefix, keeps suffix, system survives" { try conv.addUserMessage("first question here with several words"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with several words") }, - }); + }, null); try conv.addUserMessage("second recent question"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "second recent answer") }, - }); + }, null); const res = try agent.compact("Summarize the conversation.", null); try testing.expect(res.compacted); @@ -2714,21 +2714,21 @@ test "compact: restated suffix usage reconstructs a fresh cumulative chain" { // = 600. Its real usage (with cache buckets) is irrelevant // post-compaction. try conv.addUserMessage("first question here with several words"); - try conv.addAssistantMessageWithUsage( + try conv.addAssistantMessage( &.{.{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with words") }}, .{ .input = 500, .output = 50, .cache_read = 40, .cache_write = 10 }, ); // Kept turn 1: cumulative 608 (delta 8 over prefix). Reasoning is a // subset of output. try conv.addUserMessage("kept question"); // 2 words => ceil(2*1.3)=3 tokens - try conv.addAssistantMessageWithUsage( + try conv.addAssistantMessage( &.{.{ .Text = try conversation.textualBlockFromSlice(allocator, "kept answer") }}, .{ .input = 600, .output = 8, .reasoning = 5 }, ); // Kept turn 2: cumulative 614 (delta 6). Cache buckets present to prove // they collapse into `input` on restatement. try conv.addUserMessage("two more words here"); // 4 words => ceil(4*1.3)=6 - try conv.addAssistantMessageWithUsage( + try conv.addAssistantMessage( &.{.{ .Text = try conversation.textualBlockFromSlice(allocator, "final answer") }}, .{ .input = 600, .output = 4, .cache_read = 8, .cache_write = 2 }, ); @@ -2788,7 +2788,7 @@ test "compact: no-op when conversation already fits the budget" { try conv.addUserMessage("hi"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "hello") }, - }); + }, null); const res = try agent.compact("Summarize.", null); try testing.expect(!res.compacted); @@ -2825,11 +2825,11 @@ test "compact: extra instructions are appended to the system prompt" { try conv.addUserMessage("question one two three"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer one two three") }, - }); + }, null); try conv.addUserMessage("question two"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer two") }, - }); + }, null); const res = try agent.compact("Base prompt.", "keep bug #3 details"); try testing.expect(res.compacted); @@ -2867,7 +2867,7 @@ test "runStep: auto-compacts on context overflow and retries once" { try conv.addUserMessage("first question with several words here"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with several words") }, - }); + }, null); try drainTurn(&agent, "second recent question"); @@ -3247,7 +3247,7 @@ test "runStep: context-overflow compaction fires a compaction retry notification try conv.addUserMessage("first question with several words here"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with several words") }, - }); + }, null); var rr = RetryRecorder{ .allocator = allocator }; defer rr.deinit(); diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig index 940d55e..c2b133e 100644 --- a/libpanto/src/anthropic_messages_json.zig +++ b/libpanto/src/anthropic_messages_json.zig @@ -712,7 +712,7 @@ test "serializeRequest - signed assistant Thinking blocks round-trip" { .signature = sig, } }, .{ .Text = try conversation.textualBlockFromSlice(allocator, "the answer is 42") }, - }); + }, null); const cfg = testConfig("claude-x"); var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); @@ -755,7 +755,7 @@ test "serializeRequest - unsigned Thinking blocks are dropped" { .signature = null, } }, .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer") }, - }); + }, null); const cfg = testConfig("claude-x"); var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); @@ -968,7 +968,7 @@ test "serializeRequest - dotted names are wire-encoded in tools and history" { try input.appendSlice(allocator, "{}"); try conv.addAssistantMessage(&.{ .{ .ToolUse = .{ .id = id, .name = name, .input = input } }, - }); + }, null); var tools = tool_registry_mod.ToolRegistry.init(allocator); defer tools.deinit(); @@ -1006,7 +1006,7 @@ test "serializeRequest - assistant ToolUse becomes tool_use content block" { try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "calling tool") }, .{ .ToolUse = .{ .id = id, .name = name, .input = input } }, - }); + }, null); var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); defer empty_tools.deinit(); @@ -1144,7 +1144,7 @@ test "serializeRequest - empty ToolUse input becomes {} not invalid JSON" { const name = try allocator.dupe(u8, "noargs"); try conv.addAssistantMessage(&.{ .{ .ToolUse = .{ .id = id, .name = name, .input = .empty } }, - }); + }, null); var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); defer empty_tools.deinit(); diff --git a/libpanto/src/conversation.zig b/libpanto/src/conversation.zig index 1fc5016..a436378 100644 --- a/libpanto/src/conversation.zig +++ b/libpanto/src/conversation.zig @@ -292,15 +292,11 @@ pub const Conversation = struct { }); } - /// Append an assistant message. Ownership of the blocks is transferred - /// to the conversation; the caller must not deinit them after this call. - pub fn addAssistantMessage(self: *Conversation, blocks: []const ContentBlock) !void { - return self.addAssistantMessageWithUsage(blocks, null); - } - - /// Append an assistant message tagged with its provider-reported - /// `usage`. Ownership of the blocks is transferred to the conversation. - pub fn addAssistantMessageWithUsage( + /// Append an assistant message, optionally tagged with its + /// provider-reported `usage` (pass `null` for none). Ownership of the + /// blocks is transferred to the conversation; the caller must not + /// deinit them after this call. + pub fn addAssistantMessage( self: *Conversation, blocks: []const ContentBlock, usage: ?Usage, @@ -415,7 +411,7 @@ test "Conversation - add messages and verify content" { try conv.addUserMessage("Hello!"); try conv.addAssistantMessage(&.{ .{ .Text = try textualBlockFromSlice(allocator, "Hi there!") }, - }); + }, null); try std.testing.expectEqual(@as(usize, 3), conv.messages.items.len); @@ -455,7 +451,7 @@ test "Conversation - deinit frees without leaks" { try conv.addUserMessage("user message"); try conv.addAssistantMessage(&.{ .{ .Text = try textualBlockFromSlice(allocator, "response") }, - }); + }, null); conv.deinit(); } @@ -468,7 +464,7 @@ test "ContentBlock - Thinking variant" { try conv.addAssistantMessage(&.{ .{ .Thinking = .{ .text = try textualBlockFromSlice(allocator, "hmm...") } }, .{ .Text = try textualBlockFromSlice(allocator, "answer") }, - }); + }, null); try std.testing.expectEqual(@as(usize, 2), conv.messages.items[0].content.items.len); try std.testing.expectEqualStrings("hmm...", conv.messages.items[0].content.items[0].Thinking.text.items); @@ -574,7 +570,7 @@ test "latestCompactionIndex - null when never compacted" { try conv.addUserMessage("hi"); try conv.addAssistantMessage(&.{ .{ .Text = try textualBlockFromSlice(allocator, "hello") }, - }); + }, null); try std.testing.expect(latestCompactionIndex(conv.messages.items) == null); } diff --git a/libpanto/src/file_system_jsonl_store.zig b/libpanto/src/file_system_jsonl_store.zig index 10b65b3..bbf8ea5 100644 --- a/libpanto/src/file_system_jsonl_store.zig +++ b/libpanto/src/file_system_jsonl_store.zig @@ -2055,7 +2055,7 @@ test "FileSystemJSONLStore catalog: create → append → load round-trips" { var conv = conversation_mod.Conversation.init(testing.allocator); defer conv.deinit(); try conv.addUserMessage("ping"); - try conv.addAssistantMessage(&.{}); + try conv.addAssistantMessage(&.{}, null); const id: session_store_mod.WireIdentity = .{ .api_style = .openai_chat, .base_url = "u", .model = "m" }; var batch = [_]session_store_mod.PersistentMessage{ diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig index 2307ce8..53d4b0c 100644 --- a/libpanto/src/openai_chat_json.zig +++ b/libpanto/src/openai_chat_json.zig @@ -640,7 +640,7 @@ test "serializeRequest - assistant Thinking blocks are stripped from outbound hi try conv.addAssistantMessage(&.{ .{ .Thinking = .{ .text = try conversation.textualBlockFromSlice(allocator, "thinking step") } }, .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer here") }, - }); + }, null); const cfg = testConfig("gpt-4o"); var tools = emptyTools(); @@ -865,7 +865,7 @@ test "serializeRequest - assistant ToolUse becomes tool_calls" { try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "calling tool") }, .{ .ToolUse = .{ .id = id, .name = name, .input = args } }, - }); + }, null); var tools = emptyTools(); defer tools.deinit(); @@ -1053,7 +1053,7 @@ test "serializeRequest - compaction summary trims superseded prefix" { try conv.addUserMessage("ancient question"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "ancient answer") }, - }); + }, null); // Compaction summary resets conversation context. try conv.addCompactionSummary("summary of the ancient exchange"); // Kept-verbatim suffix replayed after the summary. diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig index 4bd146a..d76793b 100644 --- a/libpanto/src/provider_anthropic_messages.zig +++ b/libpanto/src/provider_anthropic_messages.zig @@ -502,7 +502,7 @@ const StreamState = struct { defer self.allocator.free(moved_blocks); const usage: ?provider_mod.Usage = if (self.usage_seen) self.usage else null; - try conv.addAssistantMessageWithUsage(moved_blocks, usage); + try conv.addAssistantMessage(moved_blocks, usage); const msg = conv.messages.items[conv.messages.items.len - 1]; try out.push(.{ .message_complete = .{ .message = msg, .usage = usage } }); diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig index 30bccb6..ed646e8 100644 --- a/libpanto/src/provider_openai_chat.zig +++ b/libpanto/src/provider_openai_chat.zig @@ -635,7 +635,7 @@ const StreamState = struct { const moved_blocks = try self.blocks.toOwnedSlice(self.allocator); defer self.allocator.free(moved_blocks); - try conv.addAssistantMessageWithUsage(moved_blocks, self.usage); + try conv.addAssistantMessage(moved_blocks, self.usage); const msg = conv.messages.items[conv.messages.items.len - 1]; try out.push(.{ .message_complete = .{ .message = msg, .usage = self.usage } }); diff --git a/libpanto/src/public.zig b/libpanto/src/public.zig index 56a0676..31d5f7b 100644 --- a/libpanto/src/public.zig +++ b/libpanto/src/public.zig @@ -82,43 +82,14 @@ pub const CompactionSummaryBlock = conversation_mod.CompactionSummaryBlock; pub const Usage = conversation_mod.Usage; pub const effectiveSystemBlocks = conversation_mod.effectiveSystemBlocks; -/// The owned conversation value type. This is what `Session.load` returns -/// and what `Agent.init` adopts. Build one standalone with `init` for +/// The conversation type. Aliased straight through: its interface has been +/// pared down to exactly the public surface (constructors `init`/`deinit`, +/// the `add*`/`replace*` builders, and the `messages`/`allocator` data +/// fields), so no façade is needed. This is what `Session.load` returns, +/// what `Agent.init` adopts, and what `Agent.conversation()` borrows a +/// pointer to (for in-place surgery). Build one standalone with `init` for /// by-hand construction; once handed to an `Agent` it is owned by the agent. -/// For *operating on* an agent's live conversation, use the `Conversation` -/// handle (below) via `Agent.conversation()`. -pub const ConversationData = conversation_mod.Conversation; - -/// A borrowed handle onto a conversation owned by an `Agent`. Pointer- -/// wrapping makes the "don't move the conversation" invariant structural: -/// the handle is a cheap copyable view, minted per-call by -/// `Agent.conversation()`, borrowing state owned by its `Agent`. -pub const Conversation = struct { - inner: *conversation_mod.Conversation, - - /// Read the in-memory messages directly, for context-management surgery. - pub fn messages(self: Conversation) []conversation_mod.Message { - return self.inner.messages.items; - } - pub fn addUserMessage(self: Conversation, text: []const u8) !void { - return self.inner.addUserMessage(text); - } - pub fn addAssistantMessage(self: Conversation, blocks: []const ContentBlock, usage: ?Usage) !void { - if (usage) |u| { - return self.inner.addAssistantMessageWithUsage(blocks, u); - } - return self.inner.addAssistantMessage(blocks); - } - pub fn addSystemMessage(self: Conversation, text: []const u8) !void { - return self.inner.addSystemMessage(text); - } - pub fn replaceSystemMessage(self: Conversation, text: []const u8) !void { - return self.inner.replaceSystemMessage(text); - } - pub fn addCompactionSummary(self: Conversation, text: []const u8) !void { - return self.inner.addCompactionSummary(text); - } -}; +pub const Conversation = conversation_mod.Conversation; // =========================================================================== // Tools (data + small façade) @@ -199,7 +170,7 @@ pub const Agent = struct { io: std.Io, config: *const Config, session: Session, - maybe_conversation: ?ConversationData, + maybe_conversation: ?Conversation, ) !Agent { const inner = try allocator.create(agent_mod.Agent); inner.* = agent_mod.Agent.init(allocator, io, config, session, maybe_conversation); @@ -246,9 +217,11 @@ pub const Agent = struct { return self.inner.compactAndPersist(override_system_prompt, extra); } - /// A borrowed handle onto the agent's live conversation. - pub fn conversation(self: Agent) Conversation { - return .{ .inner = &self.inner.conversation }; + /// A borrowed pointer to the agent's live conversation, for in-place + /// context-management surgery. Valid for the agent's lifetime; do not + /// retain past it. + pub fn conversation(self: Agent) *Conversation { + return &self.inner.conversation; } /// The id of the session this agent appends to. diff --git a/src/main.zig b/src/main.zig index 61a88bd..2887730 100644 --- a/src/main.zig +++ b/src/main.zig @@ -358,7 +358,7 @@ pub fn main(init: std.process.Init) !void { // On resume, reconstruct the conversation from the store. (The // dangling-prompt recovery feature was dropped in R2.) On a fresh // session, the agent starts with an empty conversation. - var adopted_conversation: ?panto.ConversationData = null; + var adopted_conversation: ?panto.Conversation = null; if (is_resume) { adopted_conversation = try session.load(); const sid = session.info.id; diff --git a/src/system_prompt.zig b/src/system_prompt.zig index 39c3ea8..eea0b97 100644 --- a/src/system_prompt.zig +++ b/src/system_prompt.zig @@ -193,7 +193,7 @@ pub fn reconcileResumeLayers( const resolved = try resolveLayers(arena, io, base_dir, user_dir, project_dir); const config_blocks = try resolved.blocks(arena); - const window = try effectiveConfigWindow(arena, agent.conversation().messages()); + const window = try effectiveConfigWindow(arena, agent.conversation().messages.items); if (blocksEqual(config_blocks, window)) return; @@ -339,7 +339,7 @@ test "blocksEqual - length and content" { test "effectiveConfigWindow - no replace anchors to leading system blocks" { const alloc = testing.allocator; - var conv = panto.ConversationData.init(alloc); + var conv = panto.Conversation.init(alloc); defer conv.deinit(); try conv.addSystemMessage("seed"); @@ -357,7 +357,7 @@ test "effectiveConfigWindow - no replace anchors to leading system blocks" { test "effectiveConfigWindow - anchors to last replace block" { const alloc = testing.allocator; - var conv = panto.ConversationData.init(alloc); + var conv = panto.Conversation.init(alloc); defer conv.deinit(); try conv.addSystemMessage("old seed"); @@ -483,7 +483,7 @@ const SPAgentHarness = struct { fn init( self: *SPAgentHarness, session: panto.Session, - adopted: ?panto.ConversationData, + adopted: ?panto.Conversation, ) !void { self.config = .{ .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } }, @@ -502,9 +502,9 @@ const SPAgentHarness = struct { /// memory until the first assistant message; a realistic resume only /// sees what a completed turn persisted.) fn forceFlush(self: *SPAgentHarness) !void { - var conv = panto.ConversationData.init(testing.allocator); + var conv = panto.Conversation.init(testing.allocator); defer conv.deinit(); - try conv.addAssistantMessage(&.{}); + try conv.addAssistantMessage(&.{}, null); const id: panto.WireIdentity = .{ .api_style = .openai_chat, .base_url = "u", .model = "m" }; var batch = [_]panto.PersistentMessage{ .{ .message = conv.messages.items[0], .identity = id }, @@ -557,7 +557,7 @@ test "seedFresh then no-config-change resume is a no-op" { try h2.init(sess2, conv2); defer h2.deinit(); try reconcileResumeLayers(arena, testing.io, null, null, project_dir, h2.agent); - const eff_after = try panto.effectiveSystemBlocks(arena, h2.agent.conversation().messages()); + const eff_after = try panto.effectiveSystemBlocks(arena, h2.agent.conversation().messages.items); try testing.expectEqual(seeded_count, eff_after.items.len); } @@ -600,7 +600,7 @@ test "resume after config change appends replace + append sequence" { try reconcileResumeLayers(arena, testing.io, null, null, project_dir, h2.agent); // The effective prompt now reflects only the new config blocks. - const eff = try panto.effectiveSystemBlocks(arena, h2.agent.conversation().messages()); + const eff = try panto.effectiveSystemBlocks(arena, h2.agent.conversation().messages.items); try testing.expectEqual(@as(usize, 2), eff.items.len); try testing.expectEqualStrings("new seed", eff.items[0]); try testing.expectEqualStrings("new append", eff.items[1]); @@ -608,7 +608,7 @@ test "resume after config change appends replace + append sequence" { // A second no-op resume must not change the effective prompt (anchors // to the new `replace` window, not the stale original seed). try reconcileResumeLayers(arena, testing.io, null, null, project_dir, h2.agent); - const eff2 = try panto.effectiveSystemBlocks(arena, h2.agent.conversation().messages()); + const eff2 = try panto.effectiveSystemBlocks(arena, h2.agent.conversation().messages.items); try testing.expectEqual(@as(usize, 2), eff2.items.len); } |
