summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--libpanto/src/agent.zig442
-rw-r--r--libpanto/src/anthropic_messages_json.zig2
-rw-r--r--libpanto/src/openai_chat_json.zig2
-rw-r--r--libpanto/src/public.zig140
-rw-r--r--libpanto/src/tool.zig84
-rw-r--r--libpanto/src/tool_registry.zig6
-rw-r--r--libpanto/src/tool_source.zig8
-rw-r--r--src/command.zig4
-rw-r--r--src/extension_loader.zig4
-rw-r--r--src/lua_bridge.zig33
-rw-r--r--src/lua_runtime.zig16
-rw-r--r--src/main.zig2
-rw-r--r--src/system_prompt.zig18
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);
}