diff options
| author | t <t@tjp.lol> | 2026-06-07 21:56:51 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-07 21:56:57 -0600 |
| commit | 75dedacfbeab1fea281d1bce124b920dcf878844 (patch) | |
| tree | be1f46a59979d7cafeb12fef7865c6603bcc639d | |
| parent | eb4179f9958deeae408a0a06b1f8ae6437e089db (diff) | |
further libpanto public API name cleanup
| -rw-r--r-- | libpanto/src/agent.zig | 442 | ||||
| -rw-r--r-- | libpanto/src/anthropic_messages_json.zig | 2 | ||||
| -rw-r--r-- | libpanto/src/openai_chat_json.zig | 2 | ||||
| -rw-r--r-- | libpanto/src/public.zig | 140 | ||||
| -rw-r--r-- | libpanto/src/tool.zig | 84 | ||||
| -rw-r--r-- | libpanto/src/tool_registry.zig | 6 | ||||
| -rw-r--r-- | libpanto/src/tool_source.zig | 8 | ||||
| -rw-r--r-- | src/command.zig | 4 | ||||
| -rw-r--r-- | src/extension_loader.zig | 4 | ||||
| -rw-r--r-- | src/lua_bridge.zig | 33 | ||||
| -rw-r--r-- | src/lua_runtime.zig | 16 | ||||
| -rw-r--r-- | src/main.zig | 2 | ||||
| -rw-r--r-- | src/system_prompt.zig | 18 |
13 files changed, 364 insertions, 397 deletions
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index 8a7fbe4..378205a 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -133,13 +133,13 @@ fn isValidToolInput(input: []const u8) bool { return parsed.value == .object; } -fn invalidInputResult(allocator: Allocator, input: []const u8) ![]tool_mod.ResultPart { +fn invalidInputResult(allocator: Allocator, input: []const u8) !tool_mod.ResultParts { const msg = try std.fmt.allocPrint( allocator, "Tool call was not executed: tool input was incomplete or invalid JSON. Partial input: {s}", .{input}, ); - return tool_mod.ownedTextResult(allocator, msg); + return tool_mod.ResultParts.fromTextOwned(allocator, msg); } /// What to do with an error returned by tool dispatch. @@ -169,14 +169,14 @@ fn toolErrorResult( allocator: Allocator, tool_name: []const u8, err: anyerror, -) ![]tool_mod.ResultPart { +) !tool_mod.ResultParts { const msg = try std.fmt.allocPrint( allocator, "Tool execution failed for `{s}`: {s}\n" ++ "You may fix the arguments, try a different tool, or explain the failure to the user.", .{ tool_name, @errorName(err) }, ); - return tool_mod.ownedTextResult(allocator, msg); + return tool_mod.ResultParts.fromTextOwned(allocator, msg); } /// The user's submission that opens a turn. A struct (not a bare slice) so @@ -186,30 +186,47 @@ pub const UserMessage = struct { text: []const u8, }; +/// Outcome of a compaction attempt. +pub const CompactionResult = struct { + /// Whether the conversation was actually compacted. False means the + /// active conversation already fit within the keep-verbatim budget + /// (nothing to summarize) — the conversation is unchanged. + compacted: bool, + /// Number of whole turns kept verbatim after the summary. + kept_turns: usize = 0, + /// Number of conversation messages folded into the summary. + summarized_messages: usize = 0, +}; + pub const Agent = struct { - allocator: Allocator, - io: Io, + _allocator: Allocator, + _io: Io, /// The active configuration snapshot, consulted fresh at the top of /// every turn. Immutable while a turn is in flight; swap this pointer /// (`setConfig`) between turns to change provider/model/base_url /// atomically. The pointee is owned by the embedder, not the agent. The - /// tool set is no longer part of the snapshot — it lives on `registry`. - config: *const Config, + /// tool set is no longer part of the snapshot — it lives on `_registry`. + _config: *const Config, /// The tool set this agent exposes. Owned by the agent: created empty at /// `init`, populated via `registerTool`/`registerToolSource`, torn down /// in `deinit`. Read fresh by the agent loop each turn, so a /// registration between turns is visible at the next turn boundary. - registry: ToolRegistry, + _registry: ToolRegistry, /// The live conversation the agent drives. Owned by the agent (adopted /// at `init`); torn down in `deinit`. Turn-driving methods operate on /// this directly rather than taking a `*Conversation` parameter. + /// + /// This is the one intended-public field (every other field is + /// underscore-prefixed internal state): a borrowed handle on the live + /// conversation, for in-place context-management surgery. Valid for the + /// agent's lifetime; do not retain past it. conversation: conversation.Conversation, /// The session this agent appends to. Minted from the store at `init` /// (fresh: `store.create()`) or supplied by the embedder on resume /// (`resolve`/`latest`). `Session.append` proxies to the store and /// updates the session's last-used wire identity. The embedder owns the /// underlying store, which must outlive the agent. - session: session_store_mod.Session, + _session: session_store_mod.Session, /// 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, @@ -229,49 +246,62 @@ pub const Agent = struct { /// non-null — the resume path: open a store, ask it for the /// conversation, hand it here. When null, a fresh empty conversation is /// created. Either way the agent owns and tears down the conversation. + /// The inner is heap-pinned at `init`, so the returned `*Agent` is a + /// cheap, movable handle ("don't move the Agent" stops being a rule + /// anyone can violate). The caller owns it and must `deinit` it. pub fn init( allocator: Allocator, io: Io, config: *const Config, session: session_store_mod.Session, maybe_conversation: ?conversation.Conversation, - ) Agent { - return .{ - .allocator = allocator, - .io = io, - .config = config, - .registry = ToolRegistry.init(allocator), + ) !*Agent { + const self = try allocator.create(Agent); + self.* = .{ + ._allocator = allocator, + ._io = io, + ._config = config, + ._registry = ToolRegistry.init(allocator), .conversation = maybe_conversation orelse conversation.Conversation.init(allocator), - .session = session, + ._session = session, }; + return self; } pub fn deinit(self: *Agent) void { // The agent owns the conversation, the tool registry, and the // session handle's `info` (minted by `store.create()` or resolved // by the embedder and handed in). It borrows the config snapshot - // and the underlying store, which the embedder tears down. - self.registry.deinit(); + // and the underlying store, which the embedder tears down. It is + // self-heap-pinned (`init` allocated it), so it frees itself last. + const allocator = self._allocator; + self._registry.deinit(); self.conversation.deinit(); - self.session.info.deinit(self.allocator); + self._session.info.deinit(allocator); + allocator.destroy(self); + } + + /// The id of the session this agent appends to. + pub fn sessionId(self: *const Agent) []const u8 { + return self._session.info.id; } /// Add a single tool to this agent's tool set. Visible at the next turn. pub fn registerTool(self: *Agent, tool: Tool) !void { - try self.registry.register(tool); + try self._registry.register(tool); } /// Add a tool source (a dynamic group of tools) to this agent's tool /// set. Visible at the next turn. pub fn registerToolSource(self: *Agent, src: ToolSource) !void { - try self.registry.registerSource(src); + try self._registry.registerSource(src); } /// The wire-format provider identity stamped on persisted entries, /// derived from the active config snapshot. Ground truth: never a CLI /// alias, never any `api_key` material. fn wireIdentity(self: *const Agent) session_store_mod.WireIdentity { - return self.config.provider.wireIdentity(); + return self._config.provider.wireIdentity(); } /// Swap the active configuration snapshot. Takes effect at the start of @@ -279,7 +309,7 @@ pub const Agent = struct { /// tool handler that runs between provider steps; never mutates a /// snapshot a turn is currently reading. pub fn setConfig(self: *Agent, config: *const Config) void { - self.config = config; + self._config = config; } @@ -310,8 +340,8 @@ pub const Agent = struct { .replace => try self.conversation.replaceSystemMessage(text), } try turn_persist.persistTurn( - self.allocator, - &self.session, + self._allocator, + &self._session, &self.conversation, start, self.wireIdentity(), @@ -345,15 +375,15 @@ pub const Agent = struct { const user_start = self.conversation.messages.items.len; try self.conversation.addUserMessage(message.text); try turn_persist.persistTurn( - self.allocator, - &self.session, + self._allocator, + &self._session, &self.conversation, user_start, self.wireIdentity(), &.{}, ); - const s = try self.allocator.create(Stream); + const s = try self._allocator.create(Stream); s.* = Stream.init(self); return s; } @@ -366,16 +396,16 @@ pub const Agent = struct { const id = self.wireIdentity(); if (self._auto_compacted) { try turn_persist.persistCompaction( - self.allocator, - &self.session, + self._allocator, + &self._session, &self.conversation, id, &.{}, ); } else { try turn_persist.persistTurn( - self.allocator, - &self.session, + self._allocator, + &self._session, &self.conversation, start, id, @@ -421,7 +451,7 @@ pub const Agent = struct { var attempt: usize = 1; while (true) { var diag: provider_mod.ProviderDiagnostic = .{}; - const ps = self._open_stream_fn(self.allocator, self.io, cfg, &self.registry, &self.conversation, &diag) catch |err| { + const ps = self._open_stream_fn(self._allocator, self._io, cfg, &self._registry, &self.conversation, &diag) catch |err| { if (err == error.ContextOverflow) { return self.handleContextOverflow(cfg, out, err); } @@ -441,7 +471,7 @@ pub const Agent = struct { } }); if (delay_ms > 0) { const ms: i64 = @intCast(@min(delay_ms, std.math.maxInt(i64))); - self.io.sleep(.fromMilliseconds(ms), .real) catch |e| return e; + self._io.sleep(.fromMilliseconds(ms), .real) catch |e| return e; } attempt += 1; continue; @@ -461,7 +491,7 @@ pub const Agent = struct { err: anyerror, ) !provider_mod.ProviderStream { if (self._auto_compacted) return err; // already retried once this turn - const sys = self.config.compaction.compaction_prompt orelse return err; + const sys = self._config.compaction.compaction_prompt orelse return err; const res = try self._compactInPlace(sys, null); if (!res.compacted) return err; // nothing to shed; give up self._auto_compacted = true; @@ -474,7 +504,7 @@ pub const Agent = struct { } }); // Retry the same request against the compacted context. var diag: provider_mod.ProviderDiagnostic = .{}; - return self._open_stream_fn(self.allocator, self.io, cfg, &self.registry, &self.conversation, &diag); + return self._open_stream_fn(self._allocator, self._io, cfg, &self._registry, &self.conversation, &diag); } /// Compute the backoff delay (ms) for the just-failed `attempt` @@ -497,7 +527,7 @@ pub const Agent = struct { var delay: u64 = @intFromFloat(capped); if (policy.jitter and delay > 0) { if (self._retry_prng == null) { - const ns = std.Io.Clock.now(.real, self.io).nanoseconds; + const ns = std.Io.Clock.now(.real, self._io).nanoseconds; const seed: u64 = @truncate(@as(u128, @bitCast(@as(i128, ns)))); self._retry_prng = std.Random.DefaultPrng.init(seed); } @@ -507,18 +537,6 @@ pub const Agent = struct { return delay; } - /// Outcome of a compaction attempt. - pub const CompactionResult = struct { - /// Whether the conversation was actually compacted. False means the - /// active conversation already fit within the keep-verbatim budget - /// (nothing to summarize) — the conversation is unchanged. - compacted: bool, - /// Number of whole turns kept verbatim after the summary. - kept_turns: usize = 0, - /// Number of conversation messages folded into the summary. - summarized_messages: usize = 0, - }; - /// Compact the conversation: summarize an older prefix into a single /// `.CompactionSummary` block and keep a recent suffix of whole turns /// verbatim. Mutates `self.conversation` in place. @@ -551,11 +569,11 @@ pub const Agent = struct { const messages = conv.messages.items; // Project per-message usage off the conversation for sizing. - const usages = try self.allocator.alloc(?conversation_Usage, messages.len); - defer self.allocator.free(usages); + const usages = try self._allocator.alloc(?conversation_Usage, messages.len); + defer self._allocator.free(usages); for (messages, 0..) |m, i| usages[i] = m.usage; - const split = compaction_mod.computeSplit(messages, usages, self.config.compaction.keep_verbatim); + const split = compaction_mod.computeSplit(messages, usages, self._config.compaction.keep_verbatim); // Determine the active conversation start (after any prior summary). const active_start: usize = if (conversation.latestCompactionIndex(messages)) |a| a + 1 else 0; @@ -576,17 +594,17 @@ pub const Agent = struct { // Serialize the prefix transcript and carry forward the latest // existing summary (chained-compaction invariant). const transcript = try compaction_mod.serializeTranscript( - self.allocator, + self._allocator, messages[active_start..split.prefix_end], ); - defer self.allocator.free(transcript); + defer self._allocator.free(transcript); const previous_summary = compaction_mod.latestSummaryText(messages); - const body = try compaction_mod.buildRequestBody(self.allocator, transcript, previous_summary); - defer self.allocator.free(body); + const body = try compaction_mod.buildRequestBody(self._allocator, transcript, previous_summary); + defer self._allocator.free(body); const summary = try self.runCompactionRequest(system_prompt, body, extra_instructions); - defer self.allocator.free(summary.text); + defer self._allocator.free(summary.text); try self.rewriteWithSummary(conv, split.prefix_end, summary.text, summary.size); @@ -613,13 +631,13 @@ pub const Agent = struct { extra_instructions: ?[]const u8, ) !CompactionResult { const system_prompt = override_system_prompt orelse - self.config.compaction.compaction_prompt orelse + self._config.compaction.compaction_prompt orelse return error.NoCompactionPrompt; const res = try self._compactInPlace(system_prompt, extra_instructions); if (res.compacted) { try turn_persist.persistCompaction( - self.allocator, - &self.session, + self._allocator, + &self._session, &self.conversation, self.wireIdentity(), &.{}, @@ -640,7 +658,7 @@ pub const Agent = struct { summary: []const u8, summary_size: u64, ) !void { - const alloc = self.allocator; + const alloc = self._allocator; const old = conv.messages.items; var rebuilt: std.ArrayList(conversation.Message) = .empty; @@ -730,7 +748,7 @@ pub const Agent = struct { body: []const u8, extra_instructions: ?[]const u8, ) !CompactionSummary { - const alloc = self.allocator; + const alloc = self._allocator; // Assemble the effective compaction system prompt (+ extra // instructions for a `/compact $ARGUMENTS` run). @@ -754,10 +772,10 @@ pub const Agent = struct { // Try the configured compaction model first, then fall back to the // active chat model on any failure. - if (self.config.compaction.model) |comp_provider| { + if (self._config.compaction.model) |comp_provider| { const cfg: config_mod.Config = .{ .provider = comp_provider, - .compaction = self.config.compaction, + .compaction = self._config.compaction, }; if (self.runSingleCompactionTurn(&cfg, &empty_registry, sys_text, body)) |summary| { return summary; @@ -767,8 +785,8 @@ pub const Agent = struct { } const cfg: config_mod.Config = .{ - .provider = self.config.provider, - .compaction = self.config.compaction, + .provider = self._config.provider, + .compaction = self._config.compaction, }; return self.runSingleCompactionTurn(&cfg, &empty_registry, sys_text, body); } @@ -794,7 +812,7 @@ pub const Agent = struct { system_prompt: []const u8, body: []const u8, ) !CompactionSummary { - const alloc = self.allocator; + const alloc = self._allocator; var conv = conversation.Conversation.init(alloc); defer conv.deinit(); try conv.addSystemMessage(system_prompt); @@ -806,7 +824,7 @@ pub const Agent = struct { // stream until it commits the assistant message. var queue = stream_mod.EventQueue.init(alloc); defer queue.deinit(); - var ps = try self._open_stream_fn(alloc, self.io, cfg, registry, &conv, null); + var ps = try self._open_stream_fn(alloc, self._io, cfg, registry, &conv, null); defer ps.deinit(); while (true) { const status = try ps.produce(&queue); @@ -846,7 +864,7 @@ pub const Agent = struct { const conv = &self.conversation; // Build the flat call list (in original order) and group calls // by owning registration. - var calls: std.array_list.Managed(FlatCall) = .init(self.allocator); + var calls: std.array_list.Managed(FlatCall) = .init(self._allocator); defer calls.deinit(); for (assistant_msg.content.items) |block| { @@ -858,13 +876,13 @@ pub const Agent = struct { .tool_name = tu.name, .input = tu.input.items, .entry = null, - .result = try invalidInputResult(self.allocator, tu.input.items), + .result = try invalidInputResult(self._allocator, tu.input.items), .err = null, .is_error = true, }); continue; } - const entry = self.registry.lookup(tu.name) orelse { + const entry = self._registry.lookup(tu.name) orelse { // Unknown tool: don't abort. Synthesize an error result so // the model can correct, and so this ToolUse still gets its // matching ToolResult (providers reject a follow-up request @@ -874,7 +892,7 @@ pub const Agent = struct { .tool_name = tu.name, .input = tu.input.items, .entry = null, - .result = try toolErrorResult(self.allocator, tu.name, error.UnknownTool), + .result = try toolErrorResult(self._allocator, tu.name, error.UnknownTool), .err = null, .is_error = true, }); @@ -895,12 +913,12 @@ pub const Agent = struct { // runs; the `member_indices` are positions into `calls` (the // original call order) so we can write back results without // re-ordering. - var groups: std.array_list.Managed(Group) = .init(self.allocator); + var groups: std.array_list.Managed(Group) = .init(self._allocator); defer { - for (groups.items) |*g| g.deinit(self.allocator); + for (groups.items) |*g| g.deinit(self._allocator); groups.deinit(); } - try buildGroups(self.allocator, calls.items, &groups); + try buildGroups(self._allocator, calls.items, &groups); // Spawn one concurrent task per group via `std.Io.Group`. // Single-tool groups run the tool's vtable; source groups run @@ -912,10 +930,10 @@ pub const Agent = struct { // `cancel` is idempotent with `await`; if anything below this // point errors before we successfully `await`, this releases // the group's resources. - defer task_group.cancel(self.io); + defer task_group.cancel(self._io); errdefer { for (calls.items) |*c| { - if (c.result) |r| tool_mod.freeResultParts(self.allocator, r); + if (c.result) |r| r.deinit(self._allocator); } } @@ -926,7 +944,7 @@ pub const Agent = struct { // safety net rather than a hard failure. var ran_concurrently = true; for (groups.items) |*g| { - task_group.concurrent(self.io, runGroup, .{ self, g, calls.items }) catch |e| { + task_group.concurrent(self._io, runGroup, .{ self, g, calls.items }) catch |e| { if (e == error.ConcurrencyUnavailable) { ran_concurrently = false; break; @@ -937,16 +955,16 @@ pub const Agent = struct { if (ran_concurrently) { // `error.Canceled` here means cancellation propagated into this // dispatch from above; surface it like any other error. - try task_group.await(self.io); + try task_group.await(self._io); } else { // Cancel any tasks that were spawned before the failure, then // run all groups serially. Only entry-bearing calls are touched // by `runGroup`; the pre-seeded error results (unknown tool, // invalid input) have `entry == null` and must be left intact. - task_group.cancel(self.io); + task_group.cancel(self._io); for (calls.items) |*c| { if (c.entry == null) continue; - if (c.result) |r| tool_mod.freeResultParts(self.allocator, r); + if (c.result) |r| r.deinit(self._allocator); c.result = null; c.err = null; } @@ -963,10 +981,10 @@ pub const Agent = struct { if (classifyToolError(e) == .hard_fail) return e; // Replace any partial result with a synthesized error result. if (c.result) |r| { - tool_mod.freeResultParts(self.allocator, r); + r.deinit(self._allocator); c.result = null; } - c.result = try toolErrorResult(self.allocator, c.tool_name, e); + c.result = try toolErrorResult(self._allocator, c.tool_name, e); c.err = null; c.is_error = true; } @@ -974,10 +992,10 @@ pub const Agent = struct { // Assemble ToolResult blocks in original call order. var content: std.ArrayList(conversation.ContentBlock) = .empty; errdefer { - for (content.items) |*b| b.deinit(self.allocator); - content.deinit(self.allocator); + for (content.items) |*b| b.deinit(self._allocator); + content.deinit(self._allocator); } - try content.ensureTotalCapacity(self.allocator, calls.items.len); + try content.ensureTotalCapacity(self._allocator, calls.items.len); for (calls.items) |*c| { const result_parts = c.result orelse { @@ -986,23 +1004,23 @@ pub const Agent = struct { return error.MissingToolResult; }; c.result = null; // ownership transferred below - defer tool_mod.freeResultParts(self.allocator, result_parts); + defer result_parts.deinit(self._allocator); - const id_copy = try self.allocator.dupe(u8, c.tool_use_id); - errdefer self.allocator.free(id_copy); + const id_copy = try self._allocator.dupe(u8, c.tool_use_id); + errdefer self._allocator.free(id_copy); var stored: std.ArrayList(conversation.ResultPartStored) = .empty; errdefer { - for (stored.items) |*p| p.deinit(self.allocator); - stored.deinit(self.allocator); + for (stored.items) |*p| p.deinit(self._allocator); + stored.deinit(self._allocator); } - try stored.ensureTotalCapacity(self.allocator, result_parts.len); - for (result_parts) |part| { + try stored.ensureTotalCapacity(self._allocator, result_parts.items.len); + for (result_parts.items) |part| { switch (part) { .text => |t| { var buf: conversation.TextualBlock = .empty; - errdefer buf.deinit(self.allocator); - try buf.appendSlice(self.allocator, t); + errdefer buf.deinit(self._allocator); + try buf.appendSlice(self._allocator, t); stored.appendAssumeCapacity(.{ .text = buf }); }, .media => |m| { @@ -1010,36 +1028,36 @@ pub const Agent = struct { // (when the tool gave no hint), resize large // rasters, then base64-encode for storage. Tools // hand over raw bytes only. - const processed = image_mod.process(self.allocator, m.data, m.media_type) catch |e| { + const processed = image_mod.process(self._allocator, m.data, m.media_type) catch |e| { // Media processing failure: keep the turn alive by // dropping the attachment and noting it as text, // rather than aborting. `UnknownMediaType` gets a // friendly note; other failures name the error. var note: conversation.TextualBlock = .empty; - errdefer note.deinit(self.allocator); + errdefer note.deinit(self._allocator); if (e == error.UnknownMediaType) { - try note.appendSlice(self.allocator, "[unrecognized binary attachment dropped]"); + try note.appendSlice(self._allocator, "[unrecognized binary attachment dropped]"); } else { const txt = try std.fmt.allocPrint( - self.allocator, + self._allocator, "[media attachment dropped: {s}]", .{@errorName(e)}, ); - defer self.allocator.free(txt); - try note.appendSlice(self.allocator, txt); + defer self._allocator.free(txt); + try note.appendSlice(self._allocator, txt); } stored.appendAssumeCapacity(.{ .text = note }); continue; }; - defer self.allocator.free(processed.data); + defer self._allocator.free(processed.data); - const mt = try self.allocator.dupe(u8, processed.media_type); - errdefer self.allocator.free(mt); + const mt = try self._allocator.dupe(u8, processed.media_type); + errdefer self._allocator.free(mt); const enc = std.base64.standard.Encoder; var buf: conversation.TextualBlock = .empty; - errdefer buf.deinit(self.allocator); - try buf.resize(self.allocator, enc.calcSize(processed.data.len)); + errdefer buf.deinit(self._allocator); + try buf.resize(self._allocator, enc.calcSize(processed.data.len)); _ = enc.encode(buf.items, processed.data); stored.appendAssumeCapacity(.{ .media = .{ .media_type = mt, .data = buf } }); @@ -1054,7 +1072,7 @@ pub const Agent = struct { } }); } - try conv.messages.append(self.allocator, .{ + try conv.messages.append(self._allocator, .{ .role = .user, .content = content, }); @@ -1110,7 +1128,7 @@ pub const Stream = struct { fn init(agent: *Agent) Stream { return .{ ._agent = agent, - ._queue = stream_mod.EventQueue.init(agent.allocator), + ._queue = stream_mod.EventQueue.init(agent._allocator), .state = .turn_start, ._start = agent.conversation.messages.items.len, }; @@ -1122,7 +1140,7 @@ pub const Stream = struct { self.persistTail(); if (self._response) |ps| ps.deinit(); self._queue.deinit(); - self._agent.allocator.destroy(self); + self._agent._allocator.destroy(self); } fn persistTail(self: *Stream) void { @@ -1154,7 +1172,7 @@ pub const Stream = struct { // Re-read the config snapshot at each response boundary // so a mid-conversation swap takes effect here, never // mid-stream. - const cfg = self._agent.config; + const cfg = self._agent._config; const ps = self._agent.openWithRetries(cfg, &self._queue) catch |err| { // Surface the failure after any queued retry notices // (pushed before each backoff) are drained. @@ -1185,7 +1203,7 @@ pub const Stream = struct { } self.state = .turn_start; // Emit a retry notice so consumers see the stall. - const cfg = self._agent.config; + const cfg = self._agent._config; const delay_ms = self._agent.backoffDelayMs(cfg.retry, 1, null); self._queue.push(.{ .provider_retry = .{ .attempt = 1, @@ -1198,7 +1216,7 @@ pub const Stream = struct { }; if (delay_ms > 0) { const ms: i64 = @intCast(@min(delay_ms, std.math.maxInt(i64))); - self._agent.io.sleep(.fromMilliseconds(ms), .real) catch |e| { + self._agent._io.sleep(.fromMilliseconds(ms), .real) catch |e| { self.state = .failed; return e; }; @@ -1276,7 +1294,7 @@ const FlatCall = struct { /// Owned result parts from `Tool.invoke` or `ToolSource.invoke_batch`. /// Allocated with the agent's allocator. Transferred into a /// ToolResultBlock on success. - result: ?[]tool_mod.ResultPart, + result: ?tool_mod.ResultParts, /// If non-null, the worker reported a failure for this call. After /// dispatch it is classified: host failures abort the turn, everything @@ -1368,7 +1386,7 @@ fn runGroup(agent: *Agent, group: *Group, calls: []FlatCall) void { .single => |sg| { const i = sg.call_index; const c = &calls[i]; - const out = sg.tool.vtable.invoke(sg.tool.ctx, c.input, agent.allocator) catch |e| { + const out = sg.tool.vtable.invoke(sg.tool.ctx, c.input, agent._allocator) catch |e| { c.err = e; return; }; @@ -1381,17 +1399,17 @@ fn runGroup(agent: *Agent, group: *Group, calls: []FlatCall) void { fn runSourceGroup(agent: *Agent, sg: Group.SourceGroup, calls: []FlatCall) void { const n = sg.member_indices.len; - const batch_calls = agent.allocator.alloc(tool_source_mod.Call, n) catch |e| { + const batch_calls = agent._allocator.alloc(tool_source_mod.Call, n) catch |e| { for (sg.member_indices) |i| calls[i].err = e; return; }; - defer agent.allocator.free(batch_calls); + defer agent._allocator.free(batch_calls); - const batch_results = agent.allocator.alloc(tool_source_mod.CallResult, n) catch |e| { + const batch_results = agent._allocator.alloc(tool_source_mod.CallResult, n) catch |e| { for (sg.member_indices) |i| calls[i].err = e; return; }; - defer agent.allocator.free(batch_results); + defer agent._allocator.free(batch_results); for (sg.member_indices, 0..) |idx, j| { batch_calls[j] = .{ @@ -1405,12 +1423,12 @@ fn runSourceGroup(agent: *Agent, sg: Group.SourceGroup, calls: []FlatCall) void sg.source.ctx, batch_calls, batch_results, - agent.allocator, + agent._allocator, ) catch |e| { // Whole-batch failure: free any partial successes the source // already wrote, then mark every member as failed. for (batch_results) |r| switch (r) { - .ok => |b| tool_mod.freeResultParts(agent.allocator, b), + .ok => |b| b.deinit(agent._allocator), .err => {}, }; for (sg.member_indices) |i| calls[i].err = e; @@ -1636,8 +1654,8 @@ const TestHarness = struct { /// agent's empty registry is freed and replaced; the harness registry /// is left empty (its `deinit` becomes a no-op free). fn seedInto(self: *TestHarness, agent: *Agent) void { - agent.registry.deinit(); - agent.registry = self.registry; + agent._registry.deinit(); + agent._registry = self.registry; self.registry = ToolRegistry.init(self.registry.allocator); } @@ -1669,10 +1687,10 @@ const EchoTool = struct { const vt: Tool.VTable = .{ .invoke = invoke, .deinit = deinit }; - fn invoke(ctx: *anyopaque, input: []const u8, allocator: Allocator) anyerror![]tool_mod.ResultPart { + fn invoke(ctx: *anyopaque, input: []const u8, allocator: Allocator) anyerror!tool_mod.ResultParts { const self: *EchoTool = @ptrCast(@alignCast(ctx)); const msg = try std.fmt.allocPrint(allocator, "{s}{s}", .{ self.prefix_owned, input }); - return tool_mod.ownedTextResult(allocator, msg); + return tool_mod.ResultParts.fromTextOwned(allocator, msg); } fn deinit(ctx: *anyopaque, allocator: Allocator) void { @@ -1713,7 +1731,7 @@ const BarrierTool = struct { const vt: Tool.VTable = .{ .invoke = invoke, .deinit = deinit }; - fn invoke(ctx: *anyopaque, _: []const u8, allocator: Allocator) anyerror![]tool_mod.ResultPart { + fn invoke(ctx: *anyopaque, _: []const u8, allocator: Allocator) anyerror!tool_mod.ResultParts { const self: *BarrierTool = @ptrCast(@alignCast(ctx)); const arrived = self.barrier.arrived.fetchAdd(1, .acq_rel); if (arrived < self.barrier.thread_ids.len) { @@ -1725,7 +1743,7 @@ const BarrierTool = struct { if (i > 50_000) return error.BarrierTimeout; std.Thread.yield() catch {}; } - return tool_mod.textResult(allocator, "done"); + return tool_mod.ResultParts.fromText(allocator, "done"); } fn deinit(ctx: *anyopaque, allocator: Allocator) void { @@ -1755,7 +1773,7 @@ const FailingTool = struct { const vt: Tool.VTable = .{ .invoke = invoke, .deinit = deinit }; - fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror![]tool_mod.ResultPart { + fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror!tool_mod.ResultParts { return error.ToolExploded; } @@ -1784,7 +1802,7 @@ const HardFailTool = struct { const vt: Tool.VTable = .{ .invoke = invoke, .deinit = deinit }; - fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror![]tool_mod.ResultPart { + fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror!tool_mod.ResultParts { return error.Canceled; } @@ -1900,12 +1918,12 @@ test "agent persists user, assistant, and tool-result messages of a turn" { var cap = CapturingStore.init(allocator); defer cap.deinit(); - var agent = Agent.init(allocator, io, &h.config, cap.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, cap.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); - try drainTurn(&agent, "call a tool"); + try drainTurn(agent, "call a tool"); // Persisted, in order: user prompt, assistant(ToolUse), user(ToolResult), // assistant(text). @@ -1937,12 +1955,12 @@ test "agent runs a turn against NullStore without persisting or erroring" { h.activate(); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); - try drainTurn(&agent, "hello"); + try drainTurn(agent, "hello"); // Nothing crashed; the conversation has the user + assistant messages. try testing.expectEqual(@as(usize, 2), agent.conversation.messages.items.len); @@ -2044,7 +2062,7 @@ const TestSource = struct { continue; }; results[i] = .{ - .ok = tool_mod.ownedTextResult(allocator, msg) catch |e| { + .ok = tool_mod.ResultParts.fromTextOwned(allocator, msg) catch |e| { results[i] = .{ .err = e }; continue; }, @@ -2173,7 +2191,7 @@ const PartialSource = struct { ) anyerror!void { for (calls, 0..) |_, j| { if (j == 0) { - results[j] = .{ .ok = try tool_mod.textResult(allocator, "ok") }; + results[j] = .{ .ok = try tool_mod.ResultParts.fromText(allocator, "ok") }; } else { results[j] = .{ .err = error.PerCallBoom }; } @@ -2227,14 +2245,14 @@ test "runStep dispatches a tool call and loops to a final text turn" { try h.registry.register(try EchoTool.create(allocator, "echo", "ECHO:")); h.activate(); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; - try drainTurn(&agent, "call a tool"); + try drainTurn(agent, "call a tool"); try testing.expectEqual(@as(usize, 4), conv.messages.items.len); @@ -2278,14 +2296,14 @@ test "runStep dispatches multiple tool calls in parallel" { try h.registry.register(try BarrierTool.create(allocator, "barrierC", &barrier)); h.activate(); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; - try drainTurn(&agent, "go"); + try drainTurn(agent, "go"); const tr_msg = conv.messages.items[2]; try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len); @@ -2318,14 +2336,14 @@ test "runStep: native tool handler error becomes an error result and the model g try h.registry.register(try FailingTool.create(allocator, "boom")); h.activate(); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; - try drainTurn(&agent, "break it"); + try drainTurn(agent, "break it"); // user, assistant(tool_use), user(tool_result), assistant(text) try testing.expectEqual(@as(usize, 4), conv.messages.items.len); @@ -2352,14 +2370,14 @@ test "runStep: unknown tool becomes an error tool result and the loop continues" defer h.deinit(); h.activate(); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; - try drainTurn(&agent, "call a ghost"); + try drainTurn(agent, "call a ghost"); // messages: user, assistant(tool_use), user(tool_result), assistant(text) try testing.expectEqual(@as(usize, 4), conv.messages.items.len); @@ -2385,14 +2403,14 @@ test "runStep with no tool calls returns after one provider step" { defer h.deinit(); h.activate(); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; - try drainTurn(&agent, "hello"); + try drainTurn(agent, "hello"); try testing.expectEqual(@as(usize, 2), conv.messages.items.len); try testing.expectEqualStrings("hi", conv.messages.items[1].content.items[0].Text.items); @@ -2412,13 +2430,13 @@ test "runStep surfaces EmptyAssistantResponse when provider commits an empty mes defer h.deinit(); h.activate(); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); - try testing.expectError(error.EmptyAssistantResponse, drainTurn(&agent, "hi")); + try testing.expectError(error.EmptyAssistantResponse, drainTurn(agent, "hi")); } // ------------ ToolSource tests ------------ @@ -2443,17 +2461,17 @@ test "runStep delivers all source-backed calls in one batch on one thread" { try h.registry.registerSource(try TestSource.create(allocator, "panto-lua", &.{ "lua_x", "lua_y" })); h.activate(); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; - try drainTurn(&agent, "go"); + try drainTurn(agent, "go"); // Locate the source and inspect its observed batches. - const view = agent.registry.lookup("lua_x") orelse return error.NotFound; + const view = agent._registry.lookup("lua_x") orelse return error.NotFound; const src_ptr = view.entry.source.source; const test_src: *TestSource = @ptrCast(@alignCast(src_ptr.ctx)); @@ -2495,16 +2513,16 @@ test "runStep: distinct sources run on distinct threads in parallel" { try h.registry.registerSource(try TestSource.create(allocator, "src_b", &.{"src_b_t"})); h.activate(); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); - try drainTurn(&agent, "go"); + try drainTurn(agent, "go"); - const view_a = agent.registry.lookup("src_a_t") orelse return error.NotFound; - const view_b = agent.registry.lookup("src_b_t") orelse return error.NotFound; + const view_a = agent._registry.lookup("src_a_t") orelse return error.NotFound; + const view_b = agent._registry.lookup("src_b_t") orelse return error.NotFound; const sa: *TestSource = @ptrCast(@alignCast(view_a.entry.source.source.ctx)); const sb: *TestSource = @ptrCast(@alignCast(view_b.entry.source.source.ctx)); @@ -2533,14 +2551,14 @@ test "runStep: source whole-batch error becomes per-call error results and conti try h.registry.registerSource(try FailingSource.create(allocator, "fs", &.{ "fa", "fb" })); h.activate(); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; - try drainTurn(&agent, "kaboom"); + try drainTurn(agent, "kaboom"); // user, assistant(tool_use x2), user(tool_result x2), assistant(text) try testing.expectEqual(@as(usize, 4), conv.messages.items.len); @@ -2577,14 +2595,14 @@ test "runStep: mixed single Tools and source-backed tools coexist in one turn" { try h.registry.registerSource(try TestSource.create(allocator, "src", &.{ "src_t1", "src_t2" })); h.activate(); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; - try drainTurn(&agent, "go"); + try drainTurn(agent, "go"); const tr_msg = conv.messages.items[2]; try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len); @@ -2617,20 +2635,20 @@ test "setConfig swaps provider between turns; agent tool set persists" { }; var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &cfg_a, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &cfg_a, ns.store().create(), null); defer agent.deinit(); try agent.registerTool(try EchoTool.create(allocator, "late", "B:")); agent._open_stream_fn = stub.install(); // The tool is visible regardless of which config is active. - try testing.expect(agent.registry.lookup("late") != null); + try testing.expect(agent._registry.lookup("late") != null); agent.setConfig(&cfg_b); - try testing.expect(agent.registry.lookup("late") != null); + try testing.expect(agent._registry.lookup("late") != null); // A real turn after the swap still resolves `late`, then loops to the // final text turn. const conv = &agent.conversation; - try drainTurn(&agent, "go"); + try drainTurn(agent, "go"); const tr = conv.messages.items[2].content.items[0].ToolResult; try testing.expectEqualStrings("2", tr.tool_use_id); @@ -2656,9 +2674,9 @@ test "compact: summarizes prefix, keeps suffix, system survives" { // adding the longer first turn exceeds it. h.config.compaction = .{ .keep_verbatim = 10 }; var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2719,9 +2737,9 @@ test "compact: restated suffix usage reconstructs a fresh cumulative chain" { // summarizes the prefix. h.config.compaction = .{ .keep_verbatim = 20 }; var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2793,9 +2811,9 @@ test "compact: no-op when conversation already fits the budget" { h.activate(); h.config.compaction = .{ .keep_verbatim = 1_000_000 }; var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2831,9 +2849,9 @@ test "compact: extra instructions are appended to the system prompt" { h.activate(); h.config.compaction = .{ .keep_verbatim = 1 }; var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2872,9 +2890,9 @@ test "runStep: auto-compacts on context overflow and retries once" { h.activate(); 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); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2884,7 +2902,7 @@ test "runStep: auto-compacts on context overflow and retries once" { .{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with several words") }, }, null); - try drainTurn(&agent, "second recent question"); + try drainTurn(agent, "second recent question"); try testing.expect(agent._auto_compacted); // After compaction + retry: [system, summary, user q2, assistant final]. @@ -2916,14 +2934,14 @@ test "runStep: context overflow without compaction prompt propagates" { defer h.deinit(); h.activate(); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); // No compaction_system_prompt set -> overflow propagates. - try testing.expectError(error.ContextOverflow, drainTurn(&agent, "hi")); + try testing.expectError(error.ContextOverflow, drainTurn(agent, "hi")); } // ----------------------------------------------------------------------------- @@ -2988,16 +3006,16 @@ test "runStep: provider 429 retries then succeeds without duplicate messages" { defer h.deinit(); fastRetryHarness(&h); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; var rr = RetryRecorder{ .allocator = allocator }; defer rr.deinit(); - try drainTurnRecording(&agent, "hi", &rr); + try drainTurnRecording(agent, "hi", &rr); // Two failures + one success. try testing.expectEqual(@as(usize, 3), stub.calls_made); @@ -3032,15 +3050,15 @@ test "runStep: provider 500 retries with backoff notification" { defer h.deinit(); fastRetryHarness(&h); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); var rr = RetryRecorder{ .allocator = allocator }; defer rr.deinit(); - try drainTurnRecording(&agent, "hi", &rr); + try drainTurnRecording(agent, "hi", &rr); try testing.expectEqual(@as(usize, 2), stub.calls_made); try testing.expectEqual(@as(usize, 1), rr.infos.items.len); @@ -3070,15 +3088,15 @@ test "runStep: provider auth failure does not retry" { defer h.deinit(); fastRetryHarness(&h); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + 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)); + try testing.expectError(error.ProviderAuthFailed, drainTurnRecording(agent, "hi", &rr)); // Exactly one attempt, no retry notification. try testing.expectEqual(@as(usize, 1), stub.calls_made); @@ -3109,15 +3127,15 @@ test "runStep: retries exhaust and hard-fail after max_attempts" { defer h.deinit(); fastRetryHarness(&h); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + 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)); + try testing.expectError(error.ProviderUnavailable, drainTurnRecording(agent, "hi", &rr)); // 4 attempts total (max_attempts), 3 retry notifications. try testing.expectEqual(@as(usize, 4), stub.calls_made); @@ -3147,15 +3165,15 @@ test "runStep: Retry-After is honored and reported" { // Cap below the Retry-After to verify the policy cap applies. h.config.retry = .{ .initial_delay_ms = 0, .max_delay_ms = 1, .jitter = false }; var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); var rr = RetryRecorder{ .allocator = allocator }; defer rr.deinit(); - try drainTurnRecording(&agent, "hi", &rr); + try drainTurnRecording(agent, "hi", &rr); try testing.expectEqual(@as(usize, 1), rr.infos.items.len); // Reported Retry-After is the raw provider value... @@ -3182,14 +3200,14 @@ test "runStep: cancellation from a tool still hard-fails" { try h.registry.register(try HardFailTool.create(allocator, "hard")); h.activate(); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; - try testing.expectError(error.Canceled, drainTurn(&agent, "go")); + try testing.expectError(error.Canceled, drainTurn(agent, "go")); // Turn aborts: no tool result appended (user + assistant only). try testing.expectEqual(@as(usize, 2), conv.messages.items.len); } @@ -3213,14 +3231,14 @@ test "runStep: source per-call error produces a per-call error result and contin try h.registry.registerSource(try PartialSource.create(allocator, "ps", &.{ "pa", "pb" })); h.activate(); var ns = null_store_mod.NullStore.init(allocator); - var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; - try drainTurn(&agent, "go"); + try drainTurn(agent, "go"); try testing.expectEqual(@as(usize, 4), conv.messages.items.len); const tr_msg = conv.messages.items[2]; @@ -3252,9 +3270,9 @@ test "runStep: context-overflow compaction fires a compaction retry notification h.activate(); 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); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); defer agent.deinit(); - h.seedInto(&agent); + h.seedInto(agent); agent._open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -3266,7 +3284,7 @@ test "runStep: context-overflow compaction fires a compaction retry notification var rr = RetryRecorder{ .allocator = allocator }; defer rr.deinit(); - try drainTurnRecording(&agent, "second recent question", &rr); + try drainTurnRecording(agent, "second recent question", &rr); try testing.expect(agent._auto_compacted); // Exactly one notification, flagged as a compaction retry with no delay. diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig index c2b133e..bccf692 100644 --- a/libpanto/src/anthropic_messages_json.zig +++ b/libpanto/src/anthropic_messages_json.zig @@ -903,7 +903,7 @@ test "parseStreamEvent - unknown type" { const tool_mod = @import("tool.zig"); const StaticToolVT = struct { - fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror![]tool_mod.ResultPart { + fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror!tool_mod.ResultParts { return error.NotImplementedInTest; } fn deinit_(_: *anyopaque, _: Allocator) void {} diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig index 53d4b0c..b30b424 100644 --- a/libpanto/src/openai_chat_json.zig +++ b/libpanto/src/openai_chat_json.zig @@ -766,7 +766,7 @@ const tool_mod = @import("tool.zig"); /// Minimal in-test tool: borrows its name/description/schema slices from /// the test's stack. The vtable's deinit is a no-op since nothing is owned. const StaticToolVT = struct { - fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror![]tool_mod.ResultPart { + fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror!tool_mod.ResultParts { return error.NotImplementedInTest; } fn deinit_(_: *anyopaque, _: Allocator) void {} diff --git a/libpanto/src/public.zig b/libpanto/src/public.zig index 8a6fcd1..5488220 100644 --- a/libpanto/src/public.zig +++ b/libpanto/src/public.zig @@ -12,14 +12,19 @@ //! 2. Construct & operate on conversations — build a `Conversation` by //! hand, do context-management surgery. //! -//! Three façade buckets: -//! - **behavioral** (`Agent`, `Stream`, `Conversation`): wrap a pointer to -//! a heap-pinned internal, exposing only the chosen methods. +//! Every name here is a straight alias of an internal decl: the internal +//! types have been shaped to *be* the public API (public methods + a +//! transparent public field or two, every other field underscore-prefixed +//! internal state), so this file selects the surface rather than wrapping +//! it. Three buckets: +//! - **behavioral** (`Agent`, `Stream`, `Conversation`): heap-pinned +//! drivers whose `init` hands back a cheap, movable pointer; the chosen +//! methods plus a transparent field (`Agent.conversation`, +//! `Stream.state`) are the surface. //! - **data (read/output)** (`Event`, `Usage`, `Pricing`, `SessionInfo`): -//! aliased straight through; inspected field-by-field. +//! inspected field-by-field. //! - **data (constructed)** (`ContentBlock`, `Message`, the block types, -//! the `Config` family): aliased straight through; Zig users build them -//! with `ArrayList` directly. +//! the `Config` family): Zig users build them with `ArrayList` directly. const std = @import("std"); @@ -92,7 +97,7 @@ pub const effectiveSystemBlocks = conversation_mod.effectiveSystemBlocks; pub const Conversation = conversation_mod.Conversation; // =========================================================================== -// Tools (data + small façade) +// Tools (data, aliased) // =========================================================================== pub const Tool = tool_mod.Tool; @@ -103,24 +108,13 @@ pub const ToolCallResult = tool_source_mod.CallResult; pub const MediaPart = tool_mod.MediaPart; pub const ResultPart = tool_mod.ResultPart; -/// Result-part ergonomics: a thin struct wrapper around `[]ResultPart` -/// (a bare alias can't carry methods). -pub const ResultParts = struct { - items: []ResultPart, - - pub fn fromText(alloc: std.mem.Allocator, text: []const u8) !ResultParts { - return .{ .items = try tool_mod.textResult(alloc, text) }; - } - pub fn fromTextOwned(alloc: std.mem.Allocator, text: []u8) !ResultParts { - return .{ .items = try tool_mod.ownedTextResult(alloc, text) }; - } - pub fn deinit(self: ResultParts, alloc: std.mem.Allocator) void { - tool_mod.freeResultParts(alloc, self.items); - } -}; +/// Result-part ergonomics: a thin wrapper around `[]ResultPart` carrying +/// the `fromText`/`fromTextOwned` constructors and `deinit`. Aliased +/// straight through. +pub const ResultParts = tool_mod.ResultParts; // =========================================================================== -// Stream (behavioral, pointer-wrapped) +// Stream (behavioral, heap-pinned) // =========================================================================== pub const Event = stream_mod.Event; @@ -137,92 +131,30 @@ pub const ProviderRetryInfo = provider_mod.ProviderRetryInfo; /// event), `null` is exhaustion, an error is a genuine failure. pub const Stream = agent_mod.Stream; -/// The transparent turn state machine (`Stream.state`). -pub const State = agent_mod.Stream.State; - // =========================================================================== -// Agent (behavioral, pointer-wrapped) +// Agent (behavioral, heap-pinned) // =========================================================================== pub const UserMessage = agent_mod.UserMessage; -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: ?Conversation, - ) !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); - } - pub fn registerToolSource(self: Agent, src: ToolSource) !void { - return self.inner.registerToolSource(src); - } - pub fn setConfig(self: Agent, cfg: *const Config) void { - self.inner.setConfig(cfg); - } - /// Submit a user message and begin a turn, returning a heap-pinned - /// `*Stream` the caller owns and must `deinit`. - pub fn run(self: Agent, message: UserMessage) !*Stream { - return self.inner.run(message); - } +pub const CompactionResult = agent_mod.CompactionResult; - /// 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); - } - /// 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.setSystemPrompt(text); - } - - /// Compact and persist (the explicit `/compact` entry point). - /// `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.compact(override_system_prompt, extra); - } - - /// 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. - pub fn sessionId(self: Agent) []const u8 { - return self.inner.session.info.id; - } -}; +/// The agent loop driver. Aliased straight through: its public surface is +/// exactly the chosen methods (`init`/`deinit`, `registerTool`, +/// `registerToolSource`, `setConfig`, `run`, `addSystemMessage`, +/// `setSystemPrompt`, `compact`, `sessionId`) plus the transparent +/// `conversation` field; every other field is underscore-prefixed internal +/// state. +/// +/// `init` heap-pins the agent and returns a `*Agent` — a cheap, movable +/// handle out of the gate ("don't move the Agent" stops being a rule anyone +/// can violate). The caller owns it and must `deinit` it. `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`. +/// +/// Operate on the live conversation in place via the `conversation` field +/// (valid for the agent's lifetime; do not retain past it). +pub const Agent = agent_mod.Agent; // =========================================================================== // Pricing (data, aliased) diff --git a/libpanto/src/tool.zig b/libpanto/src/tool.zig index 60a7736..c96dfee 100644 --- a/libpanto/src/tool.zig +++ b/libpanto/src/tool.zig @@ -31,10 +31,11 @@ pub const MediaPart = struct { data: []const u8, }; -/// One element of a tool's result. A tool returns a `[]ResultPart`; the -/// agent assembles these into a `ToolResultBlock`. Bytes referenced by a -/// part are owned by the allocator passed to `invoke` / `invoke_batch`; -/// ownership transfers to the agent, which frees them. +/// One element of a tool's result. A tool returns a `ResultParts` (a thin +/// wrapper around `[]ResultPart`); the agent assembles these into a +/// `ToolResultBlock`. Bytes referenced by a part are owned by the allocator +/// passed to `invoke` / `invoke_batch`; ownership transfers to the agent, +/// which frees them. pub const ResultPart = union(enum) { text: []const u8, media: MediaPart, @@ -51,11 +52,42 @@ pub const ResultPart = union(enum) { } }; -/// Free a `[]ResultPart` and every part it owns. -pub fn freeResultParts(allocator: Allocator, parts: []ResultPart) void { - for (parts) |p| p.deinit(allocator); - allocator.free(parts); -} +/// A tool's full result: an owned slice of `ResultPart`s. The value the +/// `Tool`/`ToolSource` vtable returns and the agent loop assembles — a thin +/// wrapper around `[]ResultPart` that carries the construction/teardown +/// ergonomics a bare slice alias can't. Build one with +/// `fromText`/`fromTextOwned` (or wrap a hand-built slice as +/// `.{ .items = slice }`); release it (slice + every part's bytes) with +/// `deinit`. +pub const ResultParts = struct { + items: []ResultPart, + + /// A single text part that owns `text` (duped from the input slice). + pub fn fromText(allocator: Allocator, text: []const u8) !ResultParts { + const owned = try allocator.dupe(u8, text); + errdefer allocator.free(owned); + const parts = try allocator.alloc(ResultPart, 1); + parts[0] = .{ .text = owned }; + return .{ .items = parts }; + } + + /// A single text part wrapping an already-owned `text` slice. Takes + /// ownership of `text` (frees it if the allocation below fails). + pub fn fromTextOwned(allocator: Allocator, text: []u8) !ResultParts { + const parts = allocator.alloc(ResultPart, 1) catch |e| { + allocator.free(text); + return e; + }; + parts[0] = .{ .text = text }; + return .{ .items = parts }; + } + + /// Free the slice and every part it owns. + pub fn deinit(self: ResultParts, allocator: Allocator) void { + for (self.items) |p| p.deinit(allocator); + allocator.free(self.items); + } +}; pub const Tool = struct { /// Metadata: `name`, `description`, `schema_json`. Borrowed — the @@ -78,11 +110,13 @@ pub const Tool = struct { /// `input` is the raw JSON bytes the provider sent. The tool is /// responsible for parsing them if it cares about their structure. /// - /// Returns an owned slice of `ResultPart`s allocated with - /// `allocator`; each part's bytes are likewise owned. These become - /// the parts of the ToolResult block sent back to the LLM. The - /// agent takes ownership and frees the slice and every part (see - /// `freeResultParts`). + /// Returns a `ResultParts` allocated with `allocator`; each part's + /// bytes are likewise owned. These become the parts of the + /// ToolResult block sent back to the LLM. The agent takes ownership + /// and frees the slice and every part (see `ResultParts.deinit`). + /// Build the return value with `ResultParts.fromText` / + /// `.fromTextOwned` for the common single-text case, or wrap a + /// hand-built slice as `.{ .items = slice }`. /// /// Returning an error normally becomes a model-visible error /// `ToolResult`: the agent synthesizes an error result for this @@ -100,7 +134,7 @@ pub const Tool = struct { ctx: *anyopaque, input: []const u8, allocator: Allocator, - ) anyerror![]ResultPart, + ) anyerror!ResultParts, /// Called when the tool is unregistered or the registry is torn /// down. Frees any resources owned by `ctx`, including `ctx` @@ -113,23 +147,3 @@ pub const Tool = struct { }; }; -/// Convenience: allocate a single-element `[]ResultPart` holding one text -/// part that owns `text` (duped from the input slice). -pub fn textResult(allocator: Allocator, text: []const u8) ![]ResultPart { - const owned = try allocator.dupe(u8, text); - errdefer allocator.free(owned); - const parts = try allocator.alloc(ResultPart, 1); - parts[0] = .{ .text = owned }; - return parts; -} - -/// Convenience: wrap an already-owned `text` slice as a single-element -/// `[]ResultPart`. Takes ownership of `text`. -pub fn ownedTextResult(allocator: Allocator, text: []u8) ![]ResultPart { - const parts = allocator.alloc(ResultPart, 1) catch |e| { - allocator.free(text); - return e; - }; - parts[0] = .{ .text = text }; - return parts; -} diff --git a/libpanto/src/tool_registry.zig b/libpanto/src/tool_registry.zig index ec3dcbf..430cda5 100644 --- a/libpanto/src/tool_registry.zig +++ b/libpanto/src/tool_registry.zig @@ -344,10 +344,10 @@ const TestTool = struct { .deinit = deinit, }; - fn invoke(ctx: *anyopaque, input: []const u8, allocator: Allocator) anyerror![]tool_mod.ResultPart { + fn invoke(ctx: *anyopaque, input: []const u8, allocator: Allocator) anyerror!tool_mod.ResultParts { const self: *TestTool = @ptrCast(@alignCast(ctx)); self.invocations += 1; - return tool_mod.textResult(allocator, input); + return tool_mod.ResultParts.fromText(allocator, input); } fn deinit(ctx: *anyopaque, allocator: Allocator) void { @@ -439,7 +439,7 @@ const TestSource = struct { continue; }; results[i] = .{ - .ok = tool_mod.ownedTextResult(allocator, buf) catch |e| { + .ok = tool_mod.ResultParts.fromTextOwned(allocator, buf) catch |e| { results[i] = .{ .err = e }; continue; }, diff --git a/libpanto/src/tool_source.zig b/libpanto/src/tool_source.zig index bf1f5a1..d5bc716 100644 --- a/libpanto/src/tool_source.zig +++ b/libpanto/src/tool_source.zig @@ -36,6 +36,7 @@ const tool = @import("tool.zig"); /// single dispatch path. pub const ToolDecl = tool.ToolDecl; pub const ResultPart = tool.ResultPart; +pub const ResultParts = tool.ResultParts; /// One pending invocation passed to `invoke_batch`. Slices borrowed from /// the caller for the duration of the call. @@ -49,9 +50,10 @@ pub const Call = struct { /// Result for a single call. Mirrors the success/error split of /// `Tool.invoke`'s return shape. Owned by the caller-supplied allocator. pub const CallResult = union(enum) { - /// Owned parts (slice + each part's bytes), freed by libpanto after - /// assembling the ToolResult block (see `tool.freeResultParts`). - ok: []ResultPart, + /// Owned parts (the `ResultParts` slice + each part's bytes), freed by + /// libpanto after assembling the ToolResult block (see + /// `tool.ResultParts.deinit`). + ok: ResultParts, err: anyerror, }; diff --git a/src/command.zig b/src/command.zig index 0f6fb01..588490c 100644 --- a/src/command.zig +++ b/src/command.zig @@ -33,9 +33,9 @@ pub const Context = struct { allocator: std.mem.Allocator, /// The active agent (a copyable public handle). Commands reach the - /// conversation via `agent.conversation()` and compact via + /// conversation via `agent.conversation` and compact via /// `agent.compact(...)`. - agent: panto.Agent, + agent: *panto.Agent, /// REPL output writer and its backing file writer (for flushing). stdout: *std.Io.Writer, diff --git a/src/extension_loader.zig b/src/extension_loader.zig index ff49022..6d74559 100644 --- a/src/extension_loader.zig +++ b/src/extension_loader.zig @@ -495,7 +495,7 @@ const testing = std.testing; fn xokText(result: panto.ToolCallResult) []const u8 { switch (result) { .ok => |parts| { - for (parts) |p| { + for (parts.items) |p| { if (p == .text) return p.text; } return ""; @@ -507,7 +507,7 @@ fn xokText(result: panto.ToolCallResult) []const u8 { /// Test helper: free a results slice (parts on `.ok`). fn xfreeResults(results: []panto.ToolCallResult) void { for (results) |r| switch (r) { - .ok => |b| (panto.ResultParts{ .items = b }).deinit(testing.allocator), + .ok => |b| b.deinit(testing.allocator), .err => {}, }; } diff --git a/src/lua_bridge.zig b/src/lua_bridge.zig index 444cedc..9276e1d 100644 --- a/src/lua_bridge.zig +++ b/src/lua_bridge.zig @@ -262,7 +262,7 @@ pub fn pushJsonAsLua( } /// Read a tool handler's return value at `idx` and convert it into an -/// owned `[]panto.ResultPart`. Two accepted shapes: +/// owned `panto.ResultParts`. Two accepted shapes: /// /// * a plain string -> one `.text` part; /// * a table `{ text = "...", attachments = { { media_type = "...", @@ -275,14 +275,14 @@ pub fn readHandlerResult( L: *c.lua_State, idx: c_int, allocator: Allocator, -) BridgeError![]panto.ResultPart { +) BridgeError!panto.ResultParts { const ty = c.lua_type(L, idx); if (ty == T_STRING) { var len: usize = 0; const ptr = c.lua_tolstring(L, idx, &len); if (ptr == null) return BridgeError.BadHandlerReturn; - return ((panto.ResultParts.fromText(allocator, ptr[0..len]) catch - return BridgeError.OutOfMemory).items); + return panto.ResultParts.fromText(allocator, ptr[0..len]) catch + return BridgeError.OutOfMemory; } if (ty != T_TABLE) return BridgeError.BadHandlerReturn; return readHandlerResultTable(L, idx, allocator); @@ -315,7 +315,7 @@ fn readHandlerResultTable( L: *c.lua_State, idx: c_int, allocator: Allocator, -) BridgeError![]panto.ResultPart { +) BridgeError!panto.ResultParts { var parts: std.ArrayList(panto.ResultPart) = .empty; errdefer { for (parts.items) |p| p.deinit(allocator); @@ -372,7 +372,8 @@ fn readHandlerResultTable( } } - return parts.toOwnedSlice(allocator) catch BridgeError.OutOfMemory; + const items = parts.toOwnedSlice(allocator) catch return BridgeError.OutOfMemory; + return .{ .items = items }; } // --------------------------------------------------------------------------- @@ -796,9 +797,9 @@ test "handler invocation: input parsed, result captured" { } const result = try readHandlerResult(L, -1, std.testing.allocator); - defer (panto.ResultParts{ .items = result }).deinit(std.testing.allocator); - try std.testing.expectEqual(@as(usize, 1), result.len); - try std.testing.expectEqualStrings("got: hello", result[0].text); + defer result.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 1), result.items.len); + try std.testing.expectEqualStrings("got: hello", result.items[0].text); } test "readHandlerResult: table with text and attachments" { @@ -821,13 +822,13 @@ test "readHandlerResult: table with text and attachments" { } const result = try readHandlerResult(L, -1, std.testing.allocator); - defer (panto.ResultParts{ .items = result }).deinit(std.testing.allocator); - try std.testing.expectEqual(@as(usize, 3), result.len); - try std.testing.expectEqualStrings("see image", result[0].text); - try std.testing.expectEqualStrings("image/png", result[1].media.media_type.?); - try std.testing.expectEqualStrings("AAA=", result[1].media.data); - try std.testing.expectEqualStrings("application/pdf", result[2].media.media_type.?); - try std.testing.expectEqualStrings("BBB=", result[2].media.data); + defer result.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 3), result.items.len); + try std.testing.expectEqualStrings("see image", result.items[0].text); + try std.testing.expectEqualStrings("image/png", result.items[1].media.media_type.?); + try std.testing.expectEqualStrings("AAA=", result.items[1].media.data); + try std.testing.expectEqualStrings("application/pdf", result.items[2].media.media_type.?); + try std.testing.expectEqualStrings("BBB=", result.items[2].media.data); } test "handler crash: error message surfaces via xpcall traceback hook" { diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig index c15ee63..eb760f8 100644 --- a/src/lua_runtime.zig +++ b/src/lua_runtime.zig @@ -550,7 +550,7 @@ const Slot = struct { ok: bool = false, /// Result payload as owned parts. Allocated from `allocator`. /// Caller frees via `panto.ResultParts.deinit`. - value: ?[]panto.ResultPart = null, + value: ?panto.ResultParts = null, /// On `ok = false`, an owned copy of the error message. err_msg: ?[]u8 = null, }; @@ -703,11 +703,11 @@ fn runBatch( continue; } if (slot.ok) { - results[i] = .{ .ok = slot.value orelse (try panto.ResultParts.fromText(allocator, "")).items }; + results[i] = .{ .ok = slot.value orelse try panto.ResultParts.fromText(allocator, "") }; // Free the err_msg if both ended up set somehow. if (slot.err_msg) |m| allocator.free(m); } else { - if (slot.value) |v| (panto.ResultParts{ .items = v }).deinit(allocator); + if (slot.value) |v| v.deinit(allocator); std.log.warn( "panto-lua: tool '{s}' failed: {s}", .{ @@ -734,13 +734,13 @@ fn formatToolError( allocator: Allocator, tool_name: []const u8, message: []const u8, -) ![]panto.ResultPart { +) !panto.ResultParts { const text = try std.fmt.allocPrint( allocator, "panto-lua: tool '{s}' failed: {s}", .{ tool_name, message }, ); - return (try panto.ResultParts.fromTextOwned(allocator, text)).items; + return panto.ResultParts.fromTextOwned(allocator, text); } /// Start one coroutine: create a thread under the runtime's lua_State, @@ -848,7 +848,7 @@ fn invokeCoroutineSync( input: []const u8, allocator: Allocator, err_msg_out: *?[]u8, -) ![]panto.ResultPart { +) !panto.ResultParts { const co = c.lua_newthread(L) orelse return RuntimeError.LuaInitFailed; defer c.lua_settop(L, c.lua_gettop(L) - 1); @@ -1065,7 +1065,7 @@ const testing = std.testing; fn okText(result: panto.ToolCallResult) []const u8 { switch (result) { .ok => |parts| { - for (parts) |p| { + for (parts.items) |p| { if (p == .text) return p.text; } return ""; @@ -1077,7 +1077,7 @@ fn okText(result: panto.ToolCallResult) []const u8 { /// Test helper: free a results slice (parts on `.ok`). fn freeResults(results: []panto.ToolCallResult) void { for (results) |r| switch (r) { - .ok => |b| (panto.ResultParts{ .items = b }).deinit(testing.allocator), + .ok => |b| b.deinit(testing.allocator), .err => {}, }; } diff --git a/src/main.zig b/src/main.zig index a4d524b..2a1de27 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, message: panto.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); diff --git a/src/system_prompt.zig b/src/system_prompt.zig index eea0b97..6676442 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: *panto.Agent, ) !void { const user_dir = try userLayerDir(arena, environ_map); const project_dir = try projectLayerDir(arena, io); @@ -151,7 +151,7 @@ pub fn seedFreshLayers( base_dir: ?[]const u8, user_dir: ?[]const u8, project_dir: ?[]const u8, - agent: panto.Agent, + agent: *panto.Agent, ) !void { const resolved = try resolveLayers(arena, io, base_dir, user_dir, project_dir); const blocks = try resolved.blocks(arena); @@ -174,7 +174,7 @@ pub fn reconcileResume( io: Io, environ_map: *const std.process.Environ.Map, base_dir: []const u8, - agent: panto.Agent, + agent: *panto.Agent, ) !void { const user_dir = try userLayerDir(arena, environ_map); const project_dir = try projectLayerDir(arena, io); @@ -188,12 +188,12 @@ pub fn reconcileResumeLayers( base_dir: ?[]const u8, user_dir: ?[]const u8, project_dir: ?[]const u8, - agent: panto.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.items); if (blocksEqual(config_blocks, window)) return; @@ -473,7 +473,7 @@ 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: *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. @@ -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.items); + 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.items); + 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.items); + const eff2 = try panto.effectiveSystemBlocks(arena, h2.agent.conversation.messages.items); try testing.expectEqual(@as(usize, 2), eff2.items.len); } |
