diff options
| -rw-r--r-- | docs/libpanto-cleanup.md | 50 | ||||
| -rw-r--r-- | libpanto/src/agent.zig | 23 | ||||
| -rw-r--r-- | libpanto/src/config.zig | 8 | ||||
| -rw-r--r-- | libpanto/src/public.zig | 93 | ||||
| -rw-r--r-- | src/command.zig | 8 | ||||
| -rw-r--r-- | src/compaction.zig | 2 | ||||
| -rw-r--r-- | src/main.zig | 20 | ||||
| -rw-r--r-- | src/system_prompt.zig | 107 |
8 files changed, 191 insertions, 120 deletions
diff --git a/docs/libpanto-cleanup.md b/docs/libpanto-cleanup.md index 5fb8db3..a495ff5 100644 --- a/docs/libpanto-cleanup.md +++ b/docs/libpanto-cleanup.md @@ -116,18 +116,26 @@ natural there as well.) > (`ResultParts.fromText`/`fromTextOwned`/`deinit`, replacing the freestanding > `textResult`/`ownedTextResult`/`freeResultParts`). > -> **FLAGGED:** the CLI still reaches two internal namespaces — `panto.agent` -> and `panto.conversation` — because it is a *deep* embedder that drives the -> loop below the curated `Agent`/`Conversation` façades: it needs -> system-prompt seeding *through* the agent (`agent.addSystemMessage`), -> `compaction_system_prompt`, the injectable `open_stream_fn` test seam, -> direct `agent.conversation` field access, raw standalone `Conversation` -> values (the façade `Conversation` only wraps a pointer to an agent-owned -> one), and `compactAndPersist`. These two namespaces remain re-exported from -> `public.zig` as a documented deep-embedder escape hatch (not part of the -> stable surface a C-ABI/binding should use). Closing this gap — growing the -> façade with standalone `Conversation` construction and agent -> system-prompt/compaction plumbing — is the natural follow-up. +> **Holes plugged (escape hatch removed).** The deep-embedder gap is closed; +> the CLI now uses **only** the curated `public.zig` surface (zero +> `panto.agent`/`panto.conversation` reaches), and the transitional +> internal-namespace re-exports are deleted. The additions that closed it: +> - **`Agent.init` / `Agent.deinit`** on the public façade (heap-pin the +> inner; the handle is a cheap copyable value passed by-value everywhere). +> - **`Agent.addSystemMessage(text)`** (`.append`) and +> **`Agent.setSystemPrompt(text)`** (`.replace`) — the two `SystemMode`s. +> - **`CompactionConfig.compaction_prompt`** owns the compaction system +> prompt (auto-compaction reads it; the old `Agent.compaction_system_prompt` +> field is deleted). **`Agent.compact(override_system_prompt: ?[]const u8, +> extra)`** falls back to it when the override is null. +> - **`ConversationData`** = the owned conversation value type (what +> `Session.load` returns and `Agent.init` adopts), distinct from the +> borrowed `Conversation` handle returned by `Agent.conversation()`. +> - **`Agent.sessionId()`** accessor. +> +> `compactAndPersist` was an internal detail — the public `Agent.compact()` +> *is* it (renamed, persists by default); the CLI `/compact` command now +> calls `agent.compact(...)`. ## The two jobs the API must serve @@ -165,7 +173,7 @@ pub const OpenAIChatConfig = config.OpenAIChatConfig; pub const AnthropicMessagesConfig = config.AnthropicMessagesConfig; pub const APIStyle = config.APIStyle; pub const ReasoningEffort = config.ReasoningEffort; -pub const CompactionConfig = config.CompactionConfig; +pub const CompactionConfig = config.CompactionConfig; // now owns `compaction_prompt` pub const RetryConfig = config.RetryConfig; ``` @@ -185,9 +193,14 @@ pub const Agent = struct { pub fn setConfig(self: Agent, config: *const Config) void; // swap provider/model between turns pub fn run(self: Agent, message: UserMessage) !Stream; - pub fn compact(self: Agent, system_prompt: []const u8, extra: ?[]const u8) !CompactionResult; // persists + // override_system_prompt falls back to config.compaction.compaction_prompt: + pub fn compact(self: Agent, override_system_prompt: ?[]const u8, extra: ?[]const u8) !CompactionResult; // persists + + pub fn addSystemMessage(self: Agent, text: []const u8) !void; // .append, persists + pub fn setSystemPrompt(self: Agent, text: []const u8) !void; // .replace, persists pub fn conversation(self: Agent) Conversation; // borrowed handle + pub fn sessionId(self: Agent) []const u8; }; pub const UserMessage = struct { text: []const u8 }; // top-level, NOT nested in Agent @@ -201,8 +214,13 @@ Changes from today: longer means rebuilding the whole tool list. - **`UserMessage` is top-level**, defined in `public.zig` (translated to the internal `agent.Agent.UserMessage` inside `run`). -- **`Agent.addSystemMessage` is dropped** — system messages are a - `Conversation` concern (see below). +- **`Agent.addSystemMessage` / `setSystemPrompt` are kept on `Agent`** (they + persist through the turn-path, which a bare `Conversation` mutation does + not). *Updated during implementation:* the plan originally proposed + dropping `Agent.addSystemMessage` in favor of `Conversation`-only system + messages, but the CLI's system-prompt seeding/reconciliation needs the + agent to *persist* the system entry with its `SystemMode`, so the two + agent methods stay (façade `addSystemMessage`/`setSystemPrompt`). - **`compactAndPersist` → `compact`** (persists by default). The pure no-persist transform stays a private internal helper. - **`registry()` / `httpClient`-style accessors not exposed.** diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index 5f9bee5..6d62b8a 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -206,11 +206,6 @@ pub const Agent = struct { /// Injectable streaming seam. Defaults to the real provider dispatch /// (`provider_mod.openStream`); tests override it with a stub. open_stream_fn: provider_mod.OpenStreamFn = provider_mod.openStream, - /// Compaction system prompt used for automatic compaction on context - /// overflow. Borrowed; set by the embedder (resolved from its - /// `COMPACTION.md` layers). When null, auto-compaction is disabled and - /// a context-overflow error propagates unchanged. - compaction_system_prompt: ?[]const u8 = null, /// Set by the embedder after `runStep` returns to learn whether an /// automatic compaction occurred this turn (so it can persist the /// rewritten conversation). Reset at the top of each `runStep`. @@ -454,7 +449,7 @@ pub const Agent = struct { err: anyerror, ) !provider_mod.ProviderStream { if (self.auto_compacted) return err; // already retried once this turn - const sys = self.compaction_system_prompt orelse return err; + const sys = self.config.compaction.compaction_prompt orelse return err; const res = try self.compact(sys, null); if (!res.compacted) return err; // nothing to shed; give up self.auto_compacted = true; @@ -595,11 +590,19 @@ pub const Agent = struct { /// if anything was compacted, appends the new compaction window /// (summary + restated kept suffix) to the store. Returns the same /// `CompactionResult` for the embedder to report. + /// + /// `override_system_prompt`, when non-null, is the compaction system + /// prompt for this run; when null it falls back to + /// `config.compaction.compaction_prompt`. With neither set, this errors + /// (`error.NoCompactionPrompt`). pub fn compactAndPersist( self: *Agent, - system_prompt: []const u8, + override_system_prompt: ?[]const u8, extra_instructions: ?[]const u8, ) !CompactionResult { + const system_prompt = override_system_prompt orelse + self.config.compaction.compaction_prompt orelse + return error.NoCompactionPrompt; const res = try self.compact(system_prompt, extra_instructions); if (res.compacted) { try turn_persist.persistCompaction( @@ -2852,13 +2855,12 @@ test "runStep: auto-compacts on context overflow and retries once" { var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); - h.config.compaction = .{ .keep_verbatim = 10 }; + h.config.compaction = .{ .keep_verbatim = 10, .compaction_prompt = "Summarize the conversation." }; var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(&agent); agent.open_stream_fn = stub.install(); - agent.compaction_system_prompt = "Summarize the conversation."; const conv = &agent.conversation; try conv.addSystemMessage("you are helpful"); @@ -3233,13 +3235,12 @@ test "runStep: context-overflow compaction fires a compaction retry notification var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); - h.config.compaction = .{ .keep_verbatim = 10 }; + h.config.compaction = .{ .keep_verbatim = 10, .compaction_prompt = "Summarize the conversation." }; var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); h.seedInto(&agent); agent.open_stream_fn = stub.install(); - agent.compaction_system_prompt = "Summarize the conversation."; const conv = &agent.conversation; try conv.addSystemMessage("you are helpful"); diff --git a/libpanto/src/config.zig b/libpanto/src/config.zig index 203a091..5e01bb1 100644 --- a/libpanto/src/config.zig +++ b/libpanto/src/config.zig @@ -116,6 +116,14 @@ pub const WireIdentity = struct { pub const CompactionConfig = struct { keep_verbatim: u32 = 20_000, model: ?ProviderConfig = null, + /// The compaction system prompt. Used both for automatic compaction on + /// context overflow and as the default for an explicit `Agent.compact` + /// call (which may override it per-call). Borrowed; set by the embedder + /// (e.g. resolved from its `COMPACTION.md` layers, or a built-in + /// default). When null, auto-compaction is disabled and a + /// context-overflow error propagates unchanged, and an explicit + /// `compact` with no override is a no-op error. + compaction_prompt: ?[]const u8 = null, }; /// Policy for retrying transient provider/API failures. Conservative diff --git a/libpanto/src/public.zig b/libpanto/src/public.zig index 951bd3a..56a0676 100644 --- a/libpanto/src/public.zig +++ b/libpanto/src/public.zig @@ -64,7 +64,7 @@ pub const RetryConfig = config_mod.RetryConfig; pub const WireIdentity = config_mod.WireIdentity; // =========================================================================== -// Conversation construction (data, aliased — Job 2) +// Conversation construction (data, aliased) // =========================================================================== pub const ContentBlock = conversation_mod.ContentBlock; @@ -82,9 +82,17 @@ pub const CompactionSummaryBlock = conversation_mod.CompactionSummaryBlock; pub const Usage = conversation_mod.Usage; pub const effectiveSystemBlocks = conversation_mod.effectiveSystemBlocks; -/// A borrowed handle onto a conversation owned by an `Agent`, or a -/// standalone-owned conversation built by hand. Pointer-wrapping makes the -/// "don't move the conversation" invariant structural. +/// The owned conversation value type. This is what `Session.load` returns +/// and what `Agent.init` adopts. 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, @@ -113,7 +121,7 @@ pub const Conversation = struct { }; // =========================================================================== -// Tools (data + small façade — Job 1) +// Tools (data + small façade) // =========================================================================== pub const Tool = tool_mod.Tool; @@ -179,6 +187,31 @@ pub const CompactionResult = agent_mod.Agent.CompactionResult; pub const Agent = struct { inner: *agent_mod.Agent, + /// Construct an agent. The inner is heap-pinned at `init`, so the + /// returned `Agent` is a cheap copyable handle ("don't move the Agent" + /// stops being a rule anyone can violate). `session` is minted via + /// `store.create()` (fresh) or `resolve`/`latest` (resume). When + /// `maybe_conversation` is non-null it is adopted (ownership + /// transferred) — the resume path hands the value returned by + /// `Session.load`. + pub fn init( + allocator: std.mem.Allocator, + io: std.Io, + config: *const Config, + session: Session, + maybe_conversation: ?ConversationData, + ) !Agent { + const inner = try allocator.create(agent_mod.Agent); + inner.* = agent_mod.Agent.init(allocator, io, config, session, maybe_conversation); + return .{ .inner = inner }; + } + + pub fn deinit(self: Agent) void { + const allocator = self.inner.allocator; + self.inner.deinit(); + allocator.destroy(self.inner); + } + pub fn registerTool(self: Agent, t: Tool) !void { return self.inner.registerTool(t); } @@ -191,18 +224,41 @@ pub const Agent = struct { pub fn run(self: Agent, message: UserMessage) !Stream { return .{ .inner = try self.inner.run(.{ .text = message.text }) }; } + + /// Append a system message to the conversation and persist it (the + /// `.append` `SystemMode`: it adds to the effective prompt). + pub fn addSystemMessage(self: Agent, text: []const u8) !void { + return self.inner.addSystemMessage(text, .append); + } + /// Replace the effective system prompt and persist it (the `.replace` + /// `SystemMode`: it discards all prior system text). On replay the log + /// reconstructs the same effective prompt. + pub fn setSystemPrompt(self: Agent, text: []const u8) !void { + return self.inner.addSystemMessage(text, .replace); + } + /// Compact and persist (the explicit `/compact` entry point). - pub fn compact(self: Agent, system_prompt: []const u8, extra: ?[]const u8) !CompactionResult { - return self.inner.compactAndPersist(system_prompt, extra); + /// `override_system_prompt` falls back to + /// `config.compaction.compaction_prompt` when null. `extra` is appended + /// to the compaction prompt for this run (the `/compact $ARGUMENTS` + /// path). + pub fn compact(self: Agent, override_system_prompt: ?[]const u8, extra: ?[]const u8) !CompactionResult { + 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 }; } + + /// The id of the session this agent appends to. + pub fn sessionId(self: Agent) []const u8 { + return self.inner.session.info.id; + } }; // =========================================================================== -// Pricing (data, aliased — Job 1) +// Pricing (data, aliased) // =========================================================================== pub const Pricing = pricing_mod.Pricing; @@ -220,27 +276,6 @@ pub const NullStore = null_store_mod.NullStore; pub const FileSystemJSONLStore = fs_store_mod.FileSystemJSONLStore; // =========================================================================== -// Deep-embedder escape hatches. -// -// The `panto` CLI is a *deep* embedder: it drives the agent loop at a level -// the curated `Agent`/`Conversation` façades deliberately do not expose -// (system-prompt seeding through the agent, `compaction_system_prompt`, the -// injectable `open_stream_fn` test seam, direct `conversation` field access, -// raw standalone `Conversation` values, `compactAndPersist`, etc.). Rather -// than inflate the curated surface to cover one in-tree embedder, these two -// internal namespaces stay reachable for embedders who opt into the -// unstable internal API. A C-ABI or language binding should use the curated -// surface above, not these. -// -// FLAGGED (see docs/libpanto-cleanup.md): exposing `agent`/`conversation` -// internals is a known gap between the curated façade and what a deep -// embedder needs. Revisit if/when the façade grows the missing operations -// (e.g. `Conversation` standalone construction, agent system-prompt and -// compaction-prompt plumbing). -pub const agent = agent_mod; -pub const conversation = conversation_mod; - -// =========================================================================== // Tests // =========================================================================== diff --git a/src/command.zig b/src/command.zig index 49ad5b8..0f6fb01 100644 --- a/src/command.zig +++ b/src/command.zig @@ -32,10 +32,10 @@ const panto = @import("panto"); pub const Context = struct { allocator: std.mem.Allocator, - /// The active agent. Owns the conversation (`agent.conversation`) and - /// the session store (`agent.session_store`); commands reach both - /// through it rather than holding separate pointers. - agent: *panto.agent.Agent, + /// The active agent (a copyable public handle). Commands reach the + /// conversation via `agent.conversation()` and compact via + /// `agent.compact(...)`. + agent: panto.Agent, /// REPL output writer and its backing file writer (for flushing). stdout: *std.Io.Writer, diff --git a/src/compaction.zig b/src/compaction.zig index 4ec5732..5d64f98 100644 --- a/src/compaction.zig +++ b/src/compaction.zig @@ -22,7 +22,7 @@ pub fn register(registry: *command.Registry) !void { fn run(args: []const u8, ctx: *command.Context) anyerror!void { const extra: ?[]const u8 = if (args.len > 0) args else null; - const res = ctx.agent.compactAndPersist(ctx.compaction_prompt, extra) catch |err| { + const res = ctx.agent.compact(ctx.compaction_prompt, extra) catch |err| { try ctx.stdout.print("\n[compaction failed: {s}]\n> ", .{@errorName(err)}); try ctx.stdout_file.flush(); return; diff --git a/src/main.zig b/src/main.zig index 995735f..61a88bd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -144,7 +144,7 @@ const CLIRenderer = struct { /// it terminates (`turn_complete` → `next()` returns null). A failure /// surfaces as the error from `next()`; the caller renders it. The stream is /// always `deinit`ed (persisting the turn tail) on every exit path. -fn driveTurn(agent: *panto.agent.Agent, message: panto.agent.Agent.UserMessage, renderer: *CLIRenderer) !void { +fn driveTurn(agent: panto.Agent, message: panto.UserMessage, renderer: *CLIRenderer) !void { var stream = try agent.run(message); defer stream.deinit(); while (try stream.next()) |ev| try renderer.render(ev); @@ -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.conversation.Conversation = null; + var adopted_conversation: ?panto.ConversationData = null; if (is_resume) { adopted_conversation = try session.load(); const sid = session.info.id; @@ -376,11 +376,11 @@ pub fn main(init: std.process.Init) !void { // built but before the first turn, so in-place registration is visible. // System-prompt seeding/reconciliation below runs *through* the agent // so those entries persist. - const active_config: panto.Config = .{ + var active_config: panto.Config = .{ .provider = provider_config, .compaction = compaction_cfg, }; - var agent = panto.agent.Agent.init( + const agent = try panto.Agent.init( alloc, io, &active_config, @@ -424,7 +424,7 @@ pub fn main(init: std.process.Init) !void { io, init.environ_map, luarocks_rt.layout.agent_dir, - &agent, + agent, ); } else { // Fresh session — source and install the system prompt. @@ -433,7 +433,7 @@ pub fn main(init: std.process.Init) !void { io, init.environ_map, luarocks_rt.layout.agent_dir, - &agent, + agent, ); } @@ -476,7 +476,9 @@ pub fn main(init: std.process.Init) !void { init.environ_map, luarocks_rt.layout.agent_dir, ); - agent.compaction_system_prompt = compaction_prompt; + // The compaction prompt is resolved after extension load; set it on the + // config the agent re-reads each turn (visible before the first turn). + active_config.compaction.compaction_prompt = compaction_prompt; const banner_base: []const u8 = switch (provider_config) { inline else => |c| c.base_url, @@ -521,7 +523,7 @@ pub fn main(init: std.process.Init) !void { var cmd_ctx: command.Context = .{ .allocator = alloc, - .agent = &agent, + .agent = agent, .stdout = stdout, .stdout_file = &stdout_file, .compaction_prompt = compaction_prompt, @@ -570,7 +572,7 @@ pub fn main(init: std.process.Init) !void { // auto-compaction) through its session store — the CLI no longer // touches persistence. cli_renderer.beginTurn(); - driveTurn(&agent, .{ .text = line }, &cli_renderer) catch |err| { + driveTurn(agent, .{ .text = line }, &cli_renderer) catch |err| { cli_renderer.renderError(err); try stdout.print("\n[error: {s}]\n", .{@errorName(err)}); }; diff --git a/src/system_prompt.zig b/src/system_prompt.zig index 500b135..39c3ea8 100644 --- a/src/system_prompt.zig +++ b/src/system_prompt.zig @@ -137,7 +137,7 @@ pub fn seedFresh( io: Io, environ_map: *const std.process.Environ.Map, base_dir: []const u8, - agent: *panto.agent.Agent, + agent: panto.Agent, ) !void { const user_dir = try userLayerDir(arena, environ_map); const project_dir = try projectLayerDir(arena, io); @@ -151,13 +151,13 @@ pub fn seedFreshLayers( base_dir: ?[]const u8, user_dir: ?[]const u8, project_dir: ?[]const u8, - agent: *panto.agent.Agent, + agent: panto.Agent, ) !void { const resolved = try resolveLayers(arena, io, base_dir, user_dir, project_dir); const blocks = try resolved.blocks(arena); for (blocks) |text| { // The agent adds to its conversation and persists the entry. - try agent.addSystemMessage(text, .append); + try agent.addSystemMessage(text); } } @@ -174,7 +174,7 @@ pub fn reconcileResume( io: Io, environ_map: *const std.process.Environ.Map, base_dir: []const u8, - agent: *panto.agent.Agent, + agent: panto.Agent, ) !void { const user_dir = try userLayerDir(arena, environ_map); const project_dir = try projectLayerDir(arena, io); @@ -188,21 +188,21 @@ pub fn reconcileResumeLayers( base_dir: ?[]const u8, user_dir: ?[]const u8, project_dir: ?[]const u8, - agent: *panto.agent.Agent, + agent: panto.Agent, ) !void { 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.items); + const window = try effectiveConfigWindow(arena, agent.conversation().messages()); if (blocksEqual(config_blocks, window)) return; - // Re-seed: one `replace` for the seed, then one `append` per append. - // The agent adds to its conversation and persists each entry (with the - // correct system mode). - try agent.addSystemMessage(config_blocks[0], .replace); + // Re-seed: one `setSystemPrompt` (replace) for the seed, then one + // `addSystemMessage` (append) per append. The agent adds to its + // conversation and persists each entry (with the correct system mode). + try agent.setSystemPrompt(config_blocks[0]); for (config_blocks[1..]) |text| { - try agent.addSystemMessage(text, .append); + try agent.addSystemMessage(text); } } @@ -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.conversation.Conversation.init(alloc); + var conv = panto.ConversationData.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.conversation.Conversation.init(alloc); + var conv = panto.ConversationData.init(alloc); defer conv.deinit(); try conv.addSystemMessage("old seed"); @@ -473,38 +473,45 @@ fn openTmpStore(arena: Allocator, root: []const u8) !panto.FileSystemJSONLStore /// harness only holds the config to keep the agent's borrowed pointer valid. const SPAgentHarness = struct { config: panto.Config, - agent: panto.agent.Agent, + agent: panto.Agent, + /// A second handle onto the same session (a copyable value proxying to + /// the same store + id) so the test can `forceFlush` without reaching + /// agent internals. `session_id` mirrors the agent's session id. + session: panto.Session, + session_id: []const u8, fn init( self: *SPAgentHarness, session: panto.Session, - adopted: ?panto.conversation.Conversation, - ) void { + adopted: ?panto.ConversationData, + ) !void { self.config = .{ .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } }, }; - self.agent = panto.agent.Agent.init(testing.allocator, testing.io, &self.config, session, adopted); + self.session = session; + self.session_id = session.info.id; + self.agent = try panto.Agent.init(testing.allocator, testing.io, &self.config, session, adopted); } fn deinit(self: *SPAgentHarness) void { self.agent.deinit(); } -}; -/// Force the session to flush to disk by appending an assistant message -/// through the agent's session. (System/user messages buffer in memory -/// until the first assistant message; a realistic resume only sees what a -/// completed turn persisted.) -fn forceFlush(agent: *panto.agent.Agent) !void { - var conv = panto.conversation.Conversation.init(testing.allocator); - defer conv.deinit(); - try conv.addAssistantMessage(&.{}); - const id: panto.WireIdentity = .{ .api_style = .openai_chat, .base_url = "u", .model = "m" }; - var batch = [_]panto.PersistentMessage{ - .{ .message = conv.messages.items[0], .identity = id }, - }; - try agent.session.append(&batch); -} + /// Force the session to flush to disk by appending an assistant message + /// through the (shared) session handle. (System/user messages buffer in + /// 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); + defer conv.deinit(); + try conv.addAssistantMessage(&.{}); + const id: panto.WireIdentity = .{ .api_style = .openai_chat, .base_url = "u", .model = "m" }; + var batch = [_]panto.PersistentMessage{ + .{ .message = conv.messages.items[0], .identity = id }, + }; + try self.session.append(&batch); + } +}; test "seedFresh then no-config-change resume is a no-op" { const alloc = testing.allocator; @@ -526,12 +533,12 @@ test "seedFresh then no-config-change resume is a no-op" { var session_id_len: usize = 0; { var h: SPAgentHarness = undefined; - h.init(store.create(), null); + try h.init(store.create(), null); defer h.deinit(); - try seedFreshLayers(arena, testing.io, null, null, project_dir, &h.agent); - try forceFlush(&h.agent); // persist seed (+ assistant) to disk - @memcpy(session_id_buf[0..h.agent.session.info.id.len], h.agent.session.info.id); - session_id_len = h.agent.session.info.id.len; + try seedFreshLayers(arena, testing.io, null, null, project_dir, h.agent); + try h.forceFlush(); // persist seed (+ assistant) to disk + @memcpy(session_id_buf[0..h.session_id.len], h.session_id); + session_id_len = h.session_id.len; } const session_id = session_id_buf[0..session_id_len]; @@ -547,10 +554,10 @@ test "seedFresh then no-config-change resume is a no-op" { var sess2 = (try store.resolve(session_id)).?; const conv2 = try sess2.load(); var h2: SPAgentHarness = undefined; - h2.init(sess2, conv2); + 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.items); + try reconcileResumeLayers(arena, testing.io, null, null, project_dir, h2.agent); + const eff_after = try panto.effectiveSystemBlocks(arena, h2.agent.conversation().messages()); try testing.expectEqual(seeded_count, eff_after.items.len); } @@ -572,12 +579,12 @@ test "resume after config change appends replace + append sequence" { var session_id_len: usize = 0; { var h: SPAgentHarness = undefined; - h.init(store.create(), null); + try h.init(store.create(), null); defer h.deinit(); - try seedFreshLayers(arena, testing.io, null, null, project_dir, &h.agent); - try forceFlush(&h.agent); // persist old seed (+ assistant) to disk - @memcpy(session_id_buf[0..h.agent.session.info.id.len], h.agent.session.info.id); - session_id_len = h.agent.session.info.id.len; + try seedFreshLayers(arena, testing.io, null, null, project_dir, h.agent); + try h.forceFlush(); // persist old seed (+ assistant) to disk + @memcpy(session_id_buf[0..h.session_id.len], h.session_id); + session_id_len = h.session_id.len; } const session_id = session_id_buf[0..session_id_len]; @@ -588,20 +595,20 @@ test "resume after config change appends replace + append sequence" { var sess2 = (try store.resolve(session_id)).?; const conv2 = try sess2.load(); var h2: SPAgentHarness = undefined; - h2.init(sess2, conv2); + try h2.init(sess2, conv2); defer h2.deinit(); - try reconcileResumeLayers(arena, testing.io, null, null, project_dir, &h2.agent); + 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.items); + const eff = try panto.effectiveSystemBlocks(arena, h2.agent.conversation().messages()); 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]); // 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.items); + try reconcileResumeLayers(arena, testing.io, null, null, project_dir, h2.agent); + const eff2 = try panto.effectiveSystemBlocks(arena, h2.agent.conversation().messages()); try testing.expectEqual(@as(usize, 2), eff2.items.len); } |
