summaryrefslogtreecommitdiff
path: root/libpanto
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-05-26 20:14:37 -0600
committerT <t@tjp.lol>2026-05-27 06:26:36 -0600
commit1f0915edbe0213e8bc134922f10933468d35a172 (patch)
tree11834769555c7037f3393ef8d98bf5841846144a /libpanto
parentb788eb05c6d194b91fdc141b6655e61ccaa76ddb (diff)
finish lua runtime makeover
- new multi-tool registration via ToolSource - thread per source-or-standalone-tool - switched to zig 0.16 Io threading interface - cli: include `luv` package and run concurrent lua tools via libuv - one single long-lived lua_State for the whole cli program
Diffstat (limited to 'libpanto')
-rw-r--r--libpanto/src/agent.zig772
-rw-r--r--libpanto/src/anthropic_messages_json.zig14
-rw-r--r--libpanto/src/openai_chat_json.zig14
-rw-r--r--libpanto/src/root.zig5
-rw-r--r--libpanto/src/tool.zig27
-rw-r--r--libpanto/src/tool_registry.zig356
-rw-r--r--libpanto/src/tool_source.zig100
7 files changed, 1071 insertions, 217 deletions
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig
index 3e240d4..55396d1 100644
--- a/libpanto/src/agent.zig
+++ b/libpanto/src/agent.zig
@@ -1,35 +1,56 @@
//! The Agent owns the conversation-driving loop: provider streaming +
//! tool dispatch.
//!
-//! In phase 1/2 this was a thin pass-through to the provider. In phase 3
-//! it grows the tool-call loop: after each provider streaming step, the
-//! agent inspects the assistant message for ToolUse blocks, dispatches
-//! the registered handlers (in parallel when there are multiple), and
-//! appends a user message containing the ToolResult blocks back into the
-//! conversation. The loop continues until a turn arrives with no ToolUse
-//! blocks.
+//! On each turn, after the provider streams an assistant message, the
+//! agent inspects it for ToolUse blocks. If any are present, the agent:
+//!
+//! 1. Groups them by their *owning registration* in the registry — a
+//! single `Tool` is its own group; every `ToolSource`-backed tool
+//! whose name maps to the same source forms one group.
+//! 2. Spawns one concurrent task per group via `std.Io.Group`.
+//! A single-`Tool` group runs the tool's `invoke` once; a
+//! `ToolSource` group calls the source's `invoke_batch` with all
+//! of its calls at once. We use `Group.concurrent` (not `async`)
+//! because tool invocations may block on I/O and we need real
+//! concurrency, not just expressed asynchrony.
+//! 3. Awaits the group. ToolResult blocks are assembled in the
+//! *original* call order (i.e. the order the LLM emitted them).
+//! 4. Appends a user message containing the ToolResult blocks back
+//! into the conversation and loops.
+//!
+//! The "thread-safe" promise for single `Tool` registrations is
+//! unchanged. For `ToolSource`-backed tools, the source's runtime
+//! receives all of its calls on one thread per turn, so it can keep a
+//! single-threaded interpreter (Lua, Python, ...) without further
+//! synchronization.
const std = @import("std");
const Allocator = std.mem.Allocator;
-const Thread = std.Thread;
+const Io = std.Io;
const provider_mod = @import("provider.zig");
const conversation = @import("conversation.zig");
const tool_mod = @import("tool.zig");
+const tool_source_mod = @import("tool_source.zig");
const tool_registry_mod = @import("tool_registry.zig");
pub const Tool = tool_mod.Tool;
+pub const ToolSource = tool_source_mod.ToolSource;
pub const ToolRegistry = tool_registry_mod.ToolRegistry;
+const Entry = tool_registry_mod.Entry;
+
pub const Agent = struct {
provider: provider_mod.Provider,
allocator: Allocator,
+ io: Io,
registry: ToolRegistry,
- pub fn init(allocator: Allocator, prov: provider_mod.Provider) Agent {
+ pub fn init(allocator: Allocator, io: Io, prov: provider_mod.Provider) Agent {
return .{
.provider = prov,
.allocator = allocator,
+ .io = io,
.registry = ToolRegistry.init(allocator),
};
}
@@ -39,24 +60,23 @@ pub const Agent = struct {
self.provider.deinit();
}
- /// Register a tool. The agent's registry takes ownership.
+ /// Register a single tool. The agent's registry takes ownership.
pub fn registerTool(self: *Agent, tool: Tool) !void {
try self.registry.register(tool);
}
- /// Remove a tool by name. No-op if not registered.
+ /// Register a tool source. The agent's registry takes ownership.
+ pub fn registerToolSource(self: *Agent, src: ToolSource) !void {
+ try self.registry.registerSource(src);
+ }
+
+ /// Remove a tool by name. No-op if not registered or if the name
+ /// belongs to a source.
pub fn unregisterTool(self: *Agent, name: []const u8) void {
self.registry.unregister(name);
}
/// Drive the conversation forward until the model stops calling tools.
- ///
- /// A single `runStep` invocation may call the provider multiple times
- /// if the model chains tool calls. Each provider call streams a new
- /// assistant message into `conv`; if that message contains ToolUse
- /// blocks the agent dispatches them concurrently, appends a user
- /// message of ToolResult blocks, and loops. The loop terminates when
- /// the provider's most recent response has no ToolUse blocks.
pub fn runStep(
self: *Agent,
conv: *conversation.Conversation,
@@ -68,23 +88,17 @@ pub const Agent = struct {
const last = conv.messages.items[conv.messages.items.len - 1];
std.debug.assert(last.role == .assistant);
- // Defense-in-depth: if the provider committed an assistant
- // message with zero content blocks, something went wrong
- // upstream that wasn't surfaced as a provider error (e.g. a
- // mid-stream provider error that an older codepath swallowed,
- // or a model that genuinely returned nothing). Either way the
- // turn made no observable progress — surface it instead of
- // silently dropping back to the prompt.
+ // Defense-in-depth: a provider that silently committed an
+ // empty assistant message means the turn made no observable
+ // progress. Surface it instead of looping back to the prompt.
if (last.content.items.len == 0) return error.EmptyAssistantResponse;
if (!hasToolUseBlock(last)) return;
try self.dispatchToolCalls(conv, last);
- // Loop: feed the ToolResult message back to the provider.
}
}
- /// Returns true if the message contains at least one ToolUse block.
fn hasToolUseBlock(msg: conversation.Message) bool {
for (msg.content.items) |block| {
if (block == .ToolUse) return true;
@@ -92,92 +106,97 @@ pub const Agent = struct {
return false;
}
- /// Dispatch every ToolUse block in `assistant_msg` concurrently, then
- /// append a single user Message containing all ToolResult blocks to
- /// `conv` in the same order the tool calls appeared.
+ /// Dispatch every ToolUse block in `assistant_msg`. Groups by owning
+ /// registration; one OS thread per group; results assembled in the
+ /// original call order.
fn dispatchToolCalls(
self: *Agent,
conv: *conversation.Conversation,
assistant_msg: conversation.Message,
) !void {
- // Count tool uses for sizing.
- var n: usize = 0;
+ // Build the flat call list (in original order) and group calls
+ // by owning registration.
+ var calls: std.array_list.Managed(FlatCall) = .init(self.allocator);
+ defer calls.deinit();
+
for (assistant_msg.content.items) |block| {
- if (block == .ToolUse) n += 1;
+ if (block != .ToolUse) continue;
+ const tu = block.ToolUse;
+ const entry = self.registry.lookup(tu.name) orelse {
+ // Unknown tool: abort the turn with a clear error.
+ return error.UnknownTool;
+ };
+ try calls.append(.{
+ .tool_use_id = tu.id,
+ .tool_name = tu.name,
+ .input = tu.input.items,
+ .entry = entry.entry,
+ .result = null,
+ .err = null,
+ });
}
- std.debug.assert(n > 0);
-
- const tasks = try self.allocator.alloc(ToolCallTask, n);
- defer self.allocator.free(tasks);
+ std.debug.assert(calls.items.len > 0);
- // Populate tasks. We borrow ID/name slices from the conversation —
- // the assistant message stays in `conv` throughout dispatch, so
- // these slices remain valid until we copy them into the new
- // ToolResultBlock.
- {
- var i: usize = 0;
- for (assistant_msg.content.items) |block| {
- if (block != .ToolUse) continue;
- const tu = block.ToolUse;
- tasks[i] = .{
- .agent = self,
- .tool_use_id = tu.id,
- .tool_name = tu.name,
- .input = tu.input.items,
- .result = null,
- .err = null,
- };
- i += 1;
- }
+ // Partition into groups. A group's `kind` determines how it
+ // 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);
+ defer {
+ for (groups.items) |*g| g.deinit(self.allocator);
+ groups.deinit();
}
+ try buildGroups(self.allocator, calls.items, &groups);
- // Spawn one thread per tool call. `std.Thread.spawn` is cheap
- // (sub-millisecond on Linux/macOS) compared to typical tool
- // latency, and `Tool.invoke` is contractually thread-safe, so we
- // fan out without a pool.
- const threads = try self.allocator.alloc(Thread, n);
- defer self.allocator.free(threads);
-
- var spawned: usize = 0;
- var joined = false;
+ // Spawn one concurrent task per group via `std.Io.Group`.
+ // Single-tool groups run the tool's vtable; source groups run
+ // the source's `invoke_batch`. We use `concurrent` rather than
+ // `async` because tool work may block on I/O — under a
+ // single-threaded `Io` `async` would deadlock; `concurrent`
+ // forces real concurrency (or `error.ConcurrencyUnavailable`).
+ var task_group: Io.Group = .init;
+ // `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);
errdefer {
- // Join any in-flight threads so they don't outlive `tasks`.
- if (!joined) for (threads[0..spawned]) |t| t.join();
- for (tasks) |*task| {
- if (task.result) |r| self.allocator.free(r);
+ for (calls.items) |*c| {
+ if (c.result) |r| self.allocator.free(r);
}
}
- for (tasks, 0..) |*task, idx| {
- threads[idx] = try Thread.spawn(.{}, runToolTask, .{task});
- spawned += 1;
+ for (groups.items) |*g| {
+ try task_group.concurrent(self.io, runGroup, .{ self, g, calls.items });
}
- for (threads[0..spawned]) |t| t.join();
- joined = true;
+ // `error.Canceled` here means cancellation propagated into this
+ // dispatch from above; surface it like any other error.
+ try task_group.await(self.io);
- // Build the user ToolResult message. From here on we own all
- // result byte slices; transfer them into ToolResultBlocks.
+ // Assemble ToolResult blocks in original call order. If any
+ // call errored, prefer to abort the turn — but only after the
+ // standard errdefer above has freed remaining results.
var content: std.ArrayList(conversation.ContentBlock) = .empty;
errdefer {
for (content.items) |*b| b.deinit(self.allocator);
content.deinit(self.allocator);
}
- try content.ensureTotalCapacity(self.allocator, n);
+ try content.ensureTotalCapacity(self.allocator, calls.items.len);
- // If any task failed, prefer to abort the turn — but first move
- // every successful result into a block so it gets freed by the
- // standard cleanup path, and free errored ones eagerly (there
- // are none to move). The errdefer above handles teardown.
var first_err: ?anyerror = null;
- for (tasks) |*task| {
- if (task.err) |e| {
+ for (calls.items) |*c| {
+ if (c.err) |e| {
first_err = e;
continue;
}
- const result_bytes = task.result.?;
- task.result = null; // ownership transferred below
+ const result_bytes = c.result orelse {
+ // Internal error: every successful call should have left
+ // bytes behind. Treat as MissingToolResult.
+ first_err = error.MissingToolResult;
+ continue;
+ };
+ c.result = null; // ownership transferred below
- const id_copy = try self.allocator.dupe(u8, task.tool_use_id);
+ const id_copy = try self.allocator.dupe(u8, c.tool_use_id);
errdefer self.allocator.free(id_copy);
var content_buf: conversation.TextualBlock = .empty;
@@ -193,7 +212,6 @@ pub const Agent = struct {
if (first_err) |e| return e;
- // Wrap the ToolResult blocks into a user Message and append.
try conv.messages.append(self.allocator, .{
.role = .user,
.content = content,
@@ -201,31 +219,158 @@ pub const Agent = struct {
}
};
-/// Per-tool-call work item passed into a worker thread.
-const ToolCallTask = struct {
- agent: *Agent,
+/// One ToolUse, as flattened into the agent's dispatch list. `result`
+/// and `err` are filled in by the worker; exactly one is non-null on
+/// successful task completion.
+const FlatCall = struct {
tool_use_id: []const u8, // borrowed from assistant_msg
tool_name: []const u8, // borrowed from assistant_msg
input: []const u8, // borrowed from assistant_msg
+ entry: Entry,
- /// Owned result bytes from `Tool.invoke`. Allocated with
- /// `agent.allocator`. Transferred into a ToolResultBlock on success.
+ /// Owned result bytes from `Tool.invoke` or `ToolSource.invoke_batch`.
+ /// Allocated with the agent's allocator. Transferred into a
+ /// ToolResultBlock on success.
result: ?[]u8,
- /// If non-null, the tool failed and the turn must abort.
+ /// If non-null, the call failed and the turn must abort.
err: ?anyerror,
};
-fn runToolTask(task: *ToolCallTask) void {
- const tool = task.agent.registry.lookup(task.tool_name) orelse {
- task.err = error.UnknownTool;
+/// One dispatch group. Either a single Tool invocation, or a batch of
+/// calls headed to one ToolSource.
+const Group = union(enum) {
+ single: SingleGroup,
+ source: SourceGroup,
+
+ pub const SingleGroup = struct {
+ tool: Tool,
+ /// Index into the flat calls array.
+ call_index: usize,
+ };
+
+ pub const SourceGroup = struct {
+ source: *ToolSource,
+ /// Indices into the flat calls array. Owned by the group.
+ member_indices: []usize,
+ };
+
+ fn deinit(self: *Group, allocator: Allocator) void {
+ switch (self.*) {
+ .single => {},
+ .source => |sg| allocator.free(sg.member_indices),
+ }
+ }
+};
+
+/// Partition the flat call list into groups. Order of groups is
+/// arbitrary; order within a `source` group preserves the original
+/// call order so that batch results can be written back positionally.
+fn buildGroups(
+ allocator: Allocator,
+ calls: []const FlatCall,
+ out: *std.array_list.Managed(Group),
+) !void {
+ // Map from source pointer to the index of its group in `out`.
+ // Buffers per source, accumulated then frozen into slices.
+ var pending: std.AutoHashMap(*ToolSource, std.array_list.Managed(usize)) =
+ .init(allocator);
+ defer {
+ var it = pending.valueIterator();
+ while (it.next()) |l| l.deinit();
+ pending.deinit();
+ }
+
+ for (calls, 0..) |c, i| {
+ switch (c.entry) {
+ .single => |t| try out.append(.{ .single = .{ .tool = t, .call_index = i } }),
+ .source => |sr| {
+ const gop = try pending.getOrPut(sr.source);
+ if (!gop.found_existing) {
+ gop.value_ptr.* = std.array_list.Managed(usize).init(allocator);
+ }
+ try gop.value_ptr.append(i);
+ },
+ }
+ }
+
+ // Freeze each pending list into a source-group entry. We move
+ // ownership of the indices into `Group.source.member_indices`.
+ var pit = pending.iterator();
+ while (pit.next()) |entry| {
+ const src = entry.key_ptr.*;
+ const indices = try entry.value_ptr.toOwnedSlice();
+ try out.append(.{ .source = .{ .source = src, .member_indices = indices } });
+ }
+}
+
+/// Worker entry point. Runs one group to completion, populating
+/// `calls[i].result` or `calls[i].err` for each member call.
+///
+/// Return type is `void`, which coerces to `Io.Cancelable!void` as
+/// required by `Group.concurrent`. Tool errors are reported via
+/// `FlatCall.err`, not by returning from this function.
+fn runGroup(agent: *Agent, group: *Group, calls: []FlatCall) void {
+ switch (group.*) {
+ .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| {
+ c.err = e;
+ return;
+ };
+ c.result = out;
+ },
+ .source => |sg| runSourceGroup(agent, sg, calls),
+ }
+}
+
+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| {
+ for (sg.member_indices) |i| calls[i].err = e;
+ return;
+ };
+ defer agent.allocator.free(batch_calls);
+
+ const batch_results = agent.allocator.alloc(tool_source_mod.CallResult, n) catch |e| {
+ for (sg.member_indices) |i| calls[i].err = e;
return;
};
- const out = tool.vtable.invoke(tool.ctx, task.input, task.agent.allocator) catch |e| {
- task.err = e;
+ defer agent.allocator.free(batch_results);
+
+ for (sg.member_indices, 0..) |idx, j| {
+ batch_calls[j] = .{
+ .tool_name = calls[idx].tool_name,
+ .input = calls[idx].input,
+ };
+ batch_results[j] = .{ .err = error.SourceDroppedCall };
+ }
+
+ sg.source.vtable.invoke_batch(
+ sg.source.ctx,
+ batch_calls,
+ batch_results,
+ 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| agent.allocator.free(b),
+ .err => {},
+ };
+ for (sg.member_indices) |i| calls[i].err = e;
return;
};
- task.result = out;
+
+ // Per-call success/error.
+ for (sg.member_indices, 0..) |i, j| {
+ switch (batch_results[j]) {
+ .ok => |b| calls[i].result = b,
+ .err => |e| calls[i].err = e,
+ }
+ }
}
// -----------------------------------------------------------------------------
@@ -234,18 +379,12 @@ fn runToolTask(task: *ToolCallTask) void {
const testing = std.testing;
-/// A stub Provider that, on each call to `streamStep`, appends a
-/// pre-canned assistant message to the conversation. Used to drive the
-/// agent's tool-call loop without any HTTP plumbing.
const StubProvider = struct {
allocator: Allocator,
scripted: []const ScriptedTurn,
next: usize = 0,
const ScriptedTurn = struct {
- /// Blocks to append as the next assistant message. The producer
- /// owns these — the stub clones them per turn so the conversation
- /// can take ownership.
blocks: []const TestBlock,
};
@@ -314,7 +453,6 @@ const StubProvider = struct {
fn vtDeinit(_: *anyopaque) void {}
};
-/// Simple in-test tool: returns `prefix ++ input`. Used in dispatch tests.
const EchoTool = struct {
prefix_owned: []u8,
name_owned: []u8,
@@ -326,9 +464,11 @@ const EchoTool = struct {
errdefer allocator.free(self.name_owned);
self.prefix_owned = try allocator.dupe(u8, prefix);
return .{
- .name = self.name_owned,
- .description = "echo",
- .schema_json = "{}",
+ .decl = .{
+ .name = self.name_owned,
+ .description = "echo",
+ .schema_json = "{}",
+ },
.ctx = self,
.vtable = &vt,
};
@@ -349,14 +489,6 @@ const EchoTool = struct {
}
};
-/// Tool that records the thread it ran on, then participates in a
-/// rendezvous: every invocation must reach the barrier before any can
-/// return. If dispatch is sequential, the first invocation would deadlock
-/// (only one tool runs at a time, never reaching the threshold) — so this
-/// test only passes when invocations run truly concurrently.
-///
-/// The barrier is bounded by a spin-with-yield with a wall-time ceiling
-/// of 5 seconds; failure to reach quorum surfaces as an `error.BarrierTimeout`.
const BarrierTool = struct {
name_owned: []u8,
barrier: *Barrier,
@@ -375,9 +507,11 @@ const BarrierTool = struct {
self.name_owned = try allocator.dupe(u8, name);
self.barrier = barrier;
return .{
- .name = self.name_owned,
- .description = "barrier",
- .schema_json = "{}",
+ .decl = .{
+ .name = self.name_owned,
+ .description = "barrier",
+ .schema_json = "{}",
+ },
.ctx = self,
.vtable = &vt,
};
@@ -392,9 +526,6 @@ const BarrierTool = struct {
self.barrier.thread_ids[arrived].store(std.Thread.getCurrentId(), .release);
}
- // Spin-with-yield until everyone has arrived. ~5s ceiling at the
- // typical yield granularity is plenty for a 3-way barrier; on a
- // truly single-threaded dispatch this loop never resolves.
var i: usize = 0;
while (self.barrier.arrived.load(.acquire) < self.barrier.target) : (i += 1) {
if (i > 50_000) return error.BarrierTimeout;
@@ -410,7 +541,6 @@ const BarrierTool = struct {
}
};
-/// Tool whose invoke always errors. Used to verify the turn aborts.
const FailingTool = struct {
name_owned: []u8,
@@ -419,9 +549,11 @@ const FailingTool = struct {
errdefer allocator.destroy(self);
self.name_owned = try allocator.dupe(u8, name);
return .{
- .name = self.name_owned,
- .description = "fails",
- .schema_json = "{}",
+ .decl = .{
+ .name = self.name_owned,
+ .description = "fails",
+ .schema_json = "{}",
+ },
.ctx = self,
.vtable = &vt,
};
@@ -461,9 +593,189 @@ const NoopReceiver = struct {
fn noop6(_: *anyopaque, _: anyerror) void {}
};
+/// A configurable ToolSource for testing the grouped-dispatch path.
+/// Stores every batch it receives so tests can assert "calls X and Y
+/// arrived in the same batch on the same thread".
+const TestSource = struct {
+ name_owned: []u8,
+ decls: []tool_source_mod.ToolDecl,
+ decl_strings: std.array_list.Managed([]u8),
+ /// Sequence of (thread_id, [tool_name; n]) per batch received.
+ /// Only mutated inside `invoke_batch`. Because libpanto guarantees
+ /// at most one outstanding `invoke_batch` per source at any time
+ /// (one batch per turn per source), no synchronization is needed.
+ batches: std.array_list.Managed(Batch),
+ allocator: Allocator,
+
+ const Batch = struct {
+ thread_id: u64,
+ names: std.array_list.Managed([]u8),
+ };
+
+ fn create(
+ allocator: Allocator,
+ source_name: []const u8,
+ tool_names: []const []const u8,
+ ) !ToolSource {
+ const self = try allocator.create(TestSource);
+ errdefer allocator.destroy(self);
+
+ var strings = std.array_list.Managed([]u8).init(allocator);
+ errdefer {
+ for (strings.items) |s| allocator.free(s);
+ strings.deinit();
+ }
+
+ const name_owned = try allocator.dupe(u8, source_name);
+ try strings.append(name_owned);
+
+ const decls = try allocator.alloc(tool_source_mod.ToolDecl, tool_names.len);
+ errdefer allocator.free(decls);
+ for (tool_names, 0..) |tn, i| {
+ const n = try allocator.dupe(u8, tn);
+ try strings.append(n);
+ const d = try allocator.dupe(u8, "test src tool");
+ try strings.append(d);
+ const s = try allocator.dupe(u8, "{}");
+ try strings.append(s);
+ decls[i] = .{ .name = n, .description = d, .schema_json = s };
+ }
+
+ self.* = .{
+ .name_owned = name_owned,
+ .decls = decls,
+ .decl_strings = strings,
+ .batches = std.array_list.Managed(Batch).init(allocator),
+ .allocator = allocator,
+ };
+
+ return ToolSource{
+ .name = self.name_owned,
+ .tools = self.decls,
+ .ctx = self,
+ .vtable = &vt,
+ };
+ }
+
+ const vt: ToolSource.VTable = .{
+ .invoke_batch = invokeBatch,
+ .deinit = deinitSrc,
+ };
+
+ fn invokeBatch(
+ ctx: *anyopaque,
+ calls: []const tool_source_mod.Call,
+ results: []tool_source_mod.CallResult,
+ allocator: Allocator,
+ ) anyerror!void {
+ const self: *TestSource = @ptrCast(@alignCast(ctx));
+ var batch: Batch = .{
+ .thread_id = std.Thread.getCurrentId(),
+ .names = std.array_list.Managed([]u8).init(self.allocator),
+ };
+ for (calls) |c| {
+ const copy = try self.allocator.dupe(u8, c.tool_name);
+ try batch.names.append(copy);
+ }
+ try self.batches.append(batch);
+
+ for (calls, 0..) |c, i| {
+ results[i] = .{
+ .ok = std.fmt.allocPrint(
+ allocator,
+ "{s}->{s}",
+ .{ c.tool_name, c.input },
+ ) catch |e| {
+ results[i] = .{ .err = e };
+ continue;
+ },
+ };
+ }
+ }
+
+ fn deinitSrc(ctx: *anyopaque, _: Allocator) void {
+ const self: *TestSource = @ptrCast(@alignCast(ctx));
+ for (self.decl_strings.items) |s| self.allocator.free(s);
+ self.decl_strings.deinit();
+ for (self.batches.items) |*b| {
+ for (b.names.items) |n| self.allocator.free(n);
+ b.names.deinit();
+ }
+ self.batches.deinit();
+ self.allocator.free(self.decls);
+ self.allocator.destroy(self);
+ }
+};
+
+/// A source that always fails the whole batch by returning an error
+/// from invoke_batch (rather than recording per-call errors). Used to
+/// verify libpanto's whole-batch-failure path.
+const FailingSource = struct {
+ name_owned: []u8,
+ decls: []tool_source_mod.ToolDecl,
+ decl_strings: std.array_list.Managed([]u8),
+ allocator: Allocator,
+
+ fn create(allocator: Allocator, source_name: []const u8, tool_names: []const []const u8) !ToolSource {
+ const self = try allocator.create(FailingSource);
+ errdefer allocator.destroy(self);
+
+ var strings = std.array_list.Managed([]u8).init(allocator);
+ errdefer {
+ for (strings.items) |s| allocator.free(s);
+ strings.deinit();
+ }
+
+ const name_owned = try allocator.dupe(u8, source_name);
+ try strings.append(name_owned);
+
+ const decls = try allocator.alloc(tool_source_mod.ToolDecl, tool_names.len);
+ errdefer allocator.free(decls);
+ for (tool_names, 0..) |tn, i| {
+ const n = try allocator.dupe(u8, tn);
+ try strings.append(n);
+ const d = try allocator.dupe(u8, "fails");
+ try strings.append(d);
+ const s = try allocator.dupe(u8, "{}");
+ try strings.append(s);
+ decls[i] = .{ .name = n, .description = d, .schema_json = s };
+ }
+
+ self.* = .{
+ .name_owned = name_owned,
+ .decls = decls,
+ .decl_strings = strings,
+ .allocator = allocator,
+ };
+ return ToolSource{ .name = self.name_owned, .tools = self.decls, .ctx = self, .vtable = &vt };
+ }
+
+ const vt: ToolSource.VTable = .{ .invoke_batch = invokeBatch, .deinit = deinitSrc };
+
+ fn invokeBatch(
+ _: *anyopaque,
+ _: []const tool_source_mod.Call,
+ _: []tool_source_mod.CallResult,
+ _: Allocator,
+ ) anyerror!void {
+ return error.SourceExploded;
+ }
+
+ fn deinitSrc(ctx: *anyopaque, _: Allocator) void {
+ const self: *FailingSource = @ptrCast(@alignCast(ctx));
+ for (self.decl_strings.items) |s| self.allocator.free(s);
+ self.decl_strings.deinit();
+ self.allocator.free(self.decls);
+ self.allocator.destroy(self);
+ }
+};
+
test "registerTool and lookup via registry" {
var stub = StubProvider{ .allocator = testing.allocator, .scripted = &.{} };
- var agent = Agent.init(testing.allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(testing.allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(testing.allocator, io, stub.provider());
defer agent.deinit();
try agent.registerTool(try EchoTool.create(testing.allocator, "echo", "ECHO:"));
@@ -473,7 +785,10 @@ test "registerTool and lookup via registry" {
test "duplicate registerTool returns error" {
var stub = StubProvider{ .allocator = testing.allocator, .scripted = &.{} };
- var agent = Agent.init(testing.allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(testing.allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(testing.allocator, io, stub.provider());
defer agent.deinit();
try agent.registerTool(try EchoTool.create(testing.allocator, "echo", "A:"));
@@ -495,7 +810,10 @@ test "runStep dispatches a tool call and loops to a final text turn" {
} },
};
var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
- var agent = Agent.init(allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
defer agent.deinit();
try agent.registerTool(try EchoTool.create(allocator, "echo", "ECHO:"));
@@ -507,7 +825,6 @@ test "runStep dispatches a tool call and loops to a final text turn" {
var recv = NoopReceiver.make();
try agent.runStep(&conv, &recv);
- // user, assistant(tool_use), user(tool_result), assistant(text)
try testing.expectEqual(@as(usize, 4), conv.messages.items.len);
try testing.expectEqual(conversation.MessageRole.assistant, conv.messages.items[1].role);
@@ -527,10 +844,6 @@ test "runStep dispatches a tool call and loops to a final text turn" {
test "runStep dispatches multiple tool calls in parallel" {
const allocator = testing.allocator;
- // Use a barrier: each tool must wait until all three have arrived
- // before returning. If dispatch were sequential, the first tool
- // would hit its iteration ceiling and `error.BarrierTimeout`. Reaching
- // the barrier proves all three ran concurrently.
var barrier: BarrierTool.Barrier = .{ .target = 3 };
const scripted = [_]StubProvider.ScriptedTurn{
@@ -544,7 +857,10 @@ test "runStep dispatches multiple tool calls in parallel" {
} },
};
var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
- var agent = Agent.init(allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
defer agent.deinit();
try agent.registerTool(try BarrierTool.create(allocator, "barrierA", &barrier));
@@ -558,14 +874,12 @@ test "runStep dispatches multiple tool calls in parallel" {
var recv = NoopReceiver.make();
try agent.runStep(&conv, &recv);
- // Each tool produced one ToolResult, in original order.
const tr_msg = conv.messages.items[2];
try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len);
try testing.expectEqualStrings("a", tr_msg.content.items[0].ToolResult.tool_use_id);
try testing.expectEqualStrings("b", tr_msg.content.items[1].ToolResult.tool_use_id);
try testing.expectEqualStrings("c", tr_msg.content.items[2].ToolResult.tool_use_id);
- // And the three calls happened on three distinct threads.
const t0 = barrier.thread_ids[0].load(.acquire);
const t1 = barrier.thread_ids[1].load(.acquire);
const t2 = barrier.thread_ids[2].load(.acquire);
@@ -580,11 +894,13 @@ test "runStep propagates tool errors and aborts the turn" {
.{ .blocks = &.{
.{ .ToolUse = .{ .id = "x", .name = "boom", .input = "" } },
} },
- // Second turn should never run.
.{ .blocks = &.{.{ .Text = "should-not-see" }} },
};
var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
- var agent = Agent.init(allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
defer agent.deinit();
try agent.registerTool(try FailingTool.create(allocator, "boom"));
@@ -596,8 +912,6 @@ test "runStep propagates tool errors and aborts the turn" {
var recv = NoopReceiver.make();
try testing.expectError(error.ToolExploded, agent.runStep(&conv, &recv));
- // Conversation has user + assistant(tool_use). No ToolResult message
- // was appended because the dispatch errored before append.
try testing.expectEqual(@as(usize, 2), conv.messages.items.len);
}
@@ -610,7 +924,10 @@ test "runStep errors UnknownTool when the model calls something unregistered" {
} },
};
var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
- var agent = Agent.init(allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
defer agent.deinit();
var conv = conversation.Conversation.init(allocator);
@@ -628,7 +945,10 @@ test "runStep with no tool calls returns after one provider step" {
.{ .blocks = &.{.{ .Text = "hi" }} },
};
var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
- var agent = Agent.init(allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
defer agent.deinit();
var conv = conversation.Conversation.init(allocator);
@@ -643,17 +963,16 @@ test "runStep with no tool calls returns after one provider step" {
}
test "runStep surfaces EmptyAssistantResponse when provider commits an empty message" {
- // Mirrors the real-world failure mode where a provider silently ends the
- // turn with no content blocks — e.g. a mid-stream error that an older
- // codepath swallowed. The agent must surface the failure so the user
- // doesn't see the prompt come back with no explanation.
const allocator = testing.allocator;
const scripted = [_]StubProvider.ScriptedTurn{
.{ .blocks = &.{} },
};
var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
- var agent = Agent.init(allocator, stub.provider());
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
defer agent.deinit();
var conv = conversation.Conversation.init(allocator);
@@ -663,3 +982,158 @@ test "runStep surfaces EmptyAssistantResponse when provider commits an empty mes
var recv = NoopReceiver.make();
try testing.expectError(error.EmptyAssistantResponse, agent.runStep(&conv, &recv));
}
+
+// ------------ ToolSource tests ------------
+
+test "runStep delivers all source-backed calls in one batch on one thread" {
+ const allocator = testing.allocator;
+
+ const scripted = [_]StubProvider.ScriptedTurn{
+ .{ .blocks = &.{
+ .{ .ToolUse = .{ .id = "a", .name = "lua_x", .input = "1" } },
+ .{ .ToolUse = .{ .id = "b", .name = "lua_y", .input = "2" } },
+ .{ .ToolUse = .{ .id = "c", .name = "lua_x", .input = "3" } },
+ } },
+ .{ .blocks = &.{.{ .Text = "done" }} },
+ };
+ var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
+ defer agent.deinit();
+
+ try agent.registerToolSource(try TestSource.create(allocator, "panto-lua", &.{ "lua_x", "lua_y" }));
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addUserMessage("go");
+
+ var recv = NoopReceiver.make();
+ try agent.runStep(&conv, &recv);
+
+ // Locate the source and inspect its observed batches.
+ 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));
+
+ try testing.expectEqual(@as(usize, 1), test_src.batches.items.len);
+ const b = test_src.batches.items[0];
+ try testing.expectEqual(@as(usize, 3), b.names.items.len);
+ try testing.expectEqualStrings("lua_x", b.names.items[0]);
+ try testing.expectEqualStrings("lua_y", b.names.items[1]);
+ try testing.expectEqualStrings("lua_x", b.names.items[2]);
+
+ // ToolResults arrived in the original call order.
+ const tr_msg = conv.messages.items[2];
+ try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len);
+ try testing.expectEqualStrings("a", tr_msg.content.items[0].ToolResult.tool_use_id);
+ try testing.expectEqualStrings("lua_x->1", tr_msg.content.items[0].ToolResult.content.items);
+ try testing.expectEqualStrings("b", tr_msg.content.items[1].ToolResult.tool_use_id);
+ try testing.expectEqualStrings("lua_y->2", tr_msg.content.items[1].ToolResult.content.items);
+ try testing.expectEqualStrings("c", tr_msg.content.items[2].ToolResult.tool_use_id);
+ try testing.expectEqualStrings("lua_x->3", tr_msg.content.items[2].ToolResult.content.items);
+}
+
+test "runStep: distinct sources run on distinct threads in parallel" {
+ const allocator = testing.allocator;
+
+ const scripted = [_]StubProvider.ScriptedTurn{
+ .{ .blocks = &.{
+ .{ .ToolUse = .{ .id = "a", .name = "src_a_t", .input = "" } },
+ .{ .ToolUse = .{ .id = "b", .name = "src_b_t", .input = "" } },
+ } },
+ .{ .blocks = &.{.{ .Text = "done" }} },
+ };
+ var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
+ defer agent.deinit();
+
+ try agent.registerToolSource(try TestSource.create(allocator, "src_a", &.{"src_a_t"}));
+ try agent.registerToolSource(try TestSource.create(allocator, "src_b", &.{"src_b_t"}));
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addUserMessage("go");
+
+ var recv = NoopReceiver.make();
+ try agent.runStep(&conv, &recv);
+
+ 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));
+
+ try testing.expectEqual(@as(usize, 1), sa.batches.items.len);
+ try testing.expectEqual(@as(usize, 1), sb.batches.items.len);
+ // The two sources ran on distinct OS threads.
+ try testing.expect(sa.batches.items[0].thread_id != sb.batches.items[0].thread_id);
+}
+
+test "runStep: source whole-batch error aborts the turn" {
+ const allocator = testing.allocator;
+
+ const scripted = [_]StubProvider.ScriptedTurn{
+ .{ .blocks = &.{
+ .{ .ToolUse = .{ .id = "a", .name = "fa", .input = "" } },
+ .{ .ToolUse = .{ .id = "b", .name = "fb", .input = "" } },
+ } },
+ .{ .blocks = &.{.{ .Text = "never" }} },
+ };
+ var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
+ defer agent.deinit();
+
+ try agent.registerToolSource(try FailingSource.create(allocator, "fs", &.{ "fa", "fb" }));
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addUserMessage("kaboom");
+
+ var recv = NoopReceiver.make();
+ try testing.expectError(error.SourceExploded, agent.runStep(&conv, &recv));
+
+ // Conversation stops at user + assistant(tool_use). No ToolResult appended.
+ try testing.expectEqual(@as(usize, 2), conv.messages.items.len);
+}
+
+test "runStep: mixed single Tools and source-backed tools coexist in one turn" {
+ const allocator = testing.allocator;
+
+ const scripted = [_]StubProvider.ScriptedTurn{
+ .{ .blocks = &.{
+ .{ .ToolUse = .{ .id = "a", .name = "single", .input = "X" } },
+ .{ .ToolUse = .{ .id = "b", .name = "src_t1", .input = "Y" } },
+ .{ .ToolUse = .{ .id = "c", .name = "src_t2", .input = "Z" } },
+ } },
+ .{ .blocks = &.{.{ .Text = "done" }} },
+ };
+ var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var agent = Agent.init(allocator, io, stub.provider());
+ defer agent.deinit();
+
+ try agent.registerTool(try EchoTool.create(allocator, "single", "S:"));
+ try agent.registerToolSource(try TestSource.create(allocator, "src", &.{ "src_t1", "src_t2" }));
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addUserMessage("go");
+
+ var recv = NoopReceiver.make();
+ try agent.runStep(&conv, &recv);
+
+ const tr_msg = conv.messages.items[2];
+ try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len);
+ try testing.expectEqualStrings("S:X", tr_msg.content.items[0].ToolResult.content.items);
+ try testing.expectEqualStrings("src_t1->Y", tr_msg.content.items[1].ToolResult.content.items);
+ try testing.expectEqualStrings("src_t2->Z", tr_msg.content.items[2].ToolResult.content.items);
+}
diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig
index 355b3ce..708ede5 100644
--- a/libpanto/src/anthropic_messages_json.zig
+++ b/libpanto/src/anthropic_messages_json.zig
@@ -68,11 +68,11 @@ pub fn serializeRequest(
while (it.next()) |t| {
try s.beginObject();
try s.objectField("name");
- try s.write(t.name);
+ try s.write(t.decl.name);
try s.objectField("description");
- try s.write(t.description);
+ try s.write(t.decl.description);
try s.objectField("input_schema");
- try writeRawJson(&s, t.schema_json);
+ try writeRawJson(&s, t.decl.schema_json);
try s.endObject();
}
try s.endArray();
@@ -734,9 +734,11 @@ fn makeStaticTool(
schema: []const u8,
) tool_mod.Tool {
return .{
- .name = name,
- .description = description,
- .schema_json = schema,
+ .decl = .{
+ .name = name,
+ .description = description,
+ .schema_json = schema,
+ },
.ctx = &static_tool_ctx_sentinel,
.vtable = &StaticToolVT.v,
};
diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig
index 7ca9546..9531131 100644
--- a/libpanto/src/openai_chat_json.zig
+++ b/libpanto/src/openai_chat_json.zig
@@ -89,12 +89,12 @@ pub fn serializeRequest(
try s.objectField("function");
try s.beginObject();
try s.objectField("name");
- try s.write(t.name);
+ try s.write(t.decl.name);
try s.objectField("description");
- try s.write(t.description);
+ try s.write(t.decl.description);
try s.objectField("parameters");
// Emit the tool's JSON Schema verbatim.
- try writeRawJson(&s, t.schema_json);
+ try writeRawJson(&s, t.decl.schema_json);
try s.endObject();
try s.endObject();
}
@@ -580,9 +580,11 @@ fn makeStaticTool(
schema: []const u8,
) tool_mod.Tool {
return .{
- .name = name,
- .description = description,
- .schema_json = schema,
+ .decl = .{
+ .name = name,
+ .description = description,
+ .schema_json = schema,
+ },
.ctx = &static_tool_ctx_sentinel,
.vtable = &StaticToolVT.v,
};
diff --git a/libpanto/src/root.zig b/libpanto/src/root.zig
index bfc814b..5dacfef 100644
--- a/libpanto/src/root.zig
+++ b/libpanto/src/root.zig
@@ -4,10 +4,15 @@ pub const agent = @import("agent.zig");
pub const config = @import("config.zig");
pub const sse = @import("sse.zig");
pub const tool = @import("tool.zig");
+pub const tool_source = @import("tool_source.zig");
pub const tool_registry = @import("tool_registry.zig");
// Re-exports for ergonomic embedder use.
pub const Tool = tool.Tool;
+pub const ToolSource = tool_source.ToolSource;
+pub const ToolDecl = tool_source.ToolDecl;
+pub const ToolCall = tool_source.Call;
+pub const ToolCallResult = tool_source.CallResult;
pub const ToolRegistry = tool_registry.ToolRegistry;
// Internal modules. Not part of the public API — callers construct providers
diff --git a/libpanto/src/tool.zig b/libpanto/src/tool.zig
index 1d8d113..d9fb178 100644
--- a/libpanto/src/tool.zig
+++ b/libpanto/src/tool.zig
@@ -6,20 +6,17 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
+const tool_source = @import("tool_source.zig");
-pub const Tool = struct {
- /// Tool name. Borrowed — lifetime is owned by whoever constructs the
- /// `Tool`. Typically the same owner that backs `ctx` (e.g. a LuaTool
- /// adapter, or a static const in a native tool).
- name: []const u8,
-
- /// Human-readable purpose of the tool. Emitted to the LLM alongside the
- /// schema. Borrowed; same lifetime contract as `name`.
- description: []const u8,
+pub const ToolDecl = tool_source.ToolDecl;
- /// JSON Schema for the tool's input, as raw JSON bytes. Emitted verbatim
- /// into provider request bodies. Borrowed; same lifetime contract.
- schema_json: []const u8,
+pub const Tool = struct {
+ /// Metadata: `name`, `description`, `schema_json`. Borrowed — the
+ /// lifetime of every string in `decl` is owned by whoever
+ /// constructs the `Tool`. Typically the same owner that backs
+ /// `ctx` (e.g. an adapter for an out-of-process runtime, or a
+ /// `comptime` static in a native tool).
+ decl: ToolDecl,
/// Opaque context pointer passed back to every vtable call.
ctx: *anyopaque,
@@ -53,9 +50,9 @@ pub const Tool = struct {
/// down. Frees any resources owned by `ctx`, including `ctx`
/// itself if it was heap-allocated.
///
- /// `name`, `description`, and `schema_json` are also typically
- /// owned by the same allocation as `ctx` — the tool's deinit
- /// hook is responsible for freeing them.
+ /// The strings inside `decl` are also typically owned by the
+ /// same allocation as `ctx` — the tool's deinit hook is
+ /// responsible for freeing them.
deinit: *const fn (ctx: *anyopaque, allocator: Allocator) void,
};
};
diff --git a/libpanto/src/tool_registry.zig b/libpanto/src/tool_registry.zig
index 4ade16f..e8cb4d1 100644
--- a/libpanto/src/tool_registry.zig
+++ b/libpanto/src/tool_registry.zig
@@ -1,83 +1,194 @@
-//! Registry of `Tool`s owned by an `Agent`.
+//! Registry of tools owned by an `Agent`.
//!
-//! Tools are keyed by name. The registry takes ownership: on `unregister`
-//! or `deinit`, it calls the tool's `vtable.deinit`.
+//! Two kinds of registration coexist:
//!
-//! Iteration is not synchronized — callers must avoid mutating the registry
-//! during iteration. In the current agent loop this is naturally true: the
-//! provider iterates the registry once at request-build time, and tool
+//! - A single `Tool`: a thread-safe, self-contained handler. The
+//! registry holds one entry keyed by `tool.decl.name`.
+//! - A `ToolSource`: a batch-dispatched runtime that owns many tools.
+//! The registry holds one entry per declared tool, all pointing back
+//! at the same source (different `tool_index` per entry).
+//!
+//! Iteration yields the per-tool metadata as a uniform `ToolView` so
+//! callers (chiefly: provider request serializers) don't need to know
+//! which flavor of registration each tool came from.
+//!
+//! Iteration is not synchronized — callers must avoid mutating the
+//! registry during iteration. In the current agent loop this is naturally
+//! true: the provider iterates once at request-build time, and tool
//! registration only happens at agent setup.
const std = @import("std");
const Allocator = std.mem.Allocator;
const tool_mod = @import("tool.zig");
+const tool_source_mod = @import("tool_source.zig");
+
const Tool = tool_mod.Tool;
+const ToolSource = tool_source_mod.ToolSource;
+const ToolDecl = tool_source_mod.ToolDecl;
+
+/// Tagged registry value. The registry stores one of these per *tool
+/// name*. ToolSources expand to one entry per declared tool, each with a
+/// distinct `tool_index`.
+pub const Entry = union(enum) {
+ single: Tool,
+ source: SourceRef,
+
+ pub const SourceRef = struct {
+ source: *ToolSource,
+ /// Index into `source.tools`.
+ tool_index: usize,
+ };
+};
+
+/// Read-only view of a tool's metadata, uniform across `Tool` and
+/// `ToolSource` registrations. Returned by registry iteration and
+/// lookup.
+pub const ToolView = struct {
+ decl: ToolDecl,
+ /// Which entry this view came from. Carries enough information to
+ /// dispatch the call (single Tool vs source-backed).
+ entry: Entry,
+
+ pub fn name(self: ToolView) []const u8 {
+ return self.decl.name;
+ }
+};
pub const ToolRegistry = struct {
- tools: std.StringHashMap(Tool),
+ /// Per-tool-name entries.
+ entries: std.StringHashMap(Entry),
+ /// Heap-allocated sources, kept in a list so `deinit` can tear each
+ /// down exactly once even though many entries reference a single
+ /// source.
+ sources: std.array_list.Managed(*ToolSource),
allocator: Allocator,
pub fn init(allocator: Allocator) ToolRegistry {
return .{
- .tools = std.StringHashMap(Tool).init(allocator),
+ .entries = std.StringHashMap(Entry).init(allocator),
+ .sources = std.array_list.Managed(*ToolSource).init(allocator),
.allocator = allocator,
};
}
- /// Tear down the registry. Each remaining tool's `vtable.deinit` is
- /// invoked.
+ /// Tear down the registry. Each single `Tool`'s `vtable.deinit` is
+ /// invoked once. Each `ToolSource`'s `vtable.deinit` is invoked once
+ /// (not once per declared tool).
pub fn deinit(self: *ToolRegistry) void {
- var it = self.tools.iterator();
+ var it = self.entries.iterator();
while (it.next()) |entry| {
- const tool = entry.value_ptr.*;
- tool.vtable.deinit(tool.ctx, self.allocator);
+ switch (entry.value_ptr.*) {
+ .single => |t| t.vtable.deinit(t.ctx, self.allocator),
+ .source => {},
+ }
}
- self.tools.deinit();
+ self.entries.deinit();
+
+ for (self.sources.items) |src| {
+ src.vtable.deinit(src.ctx, self.allocator);
+ self.allocator.destroy(src);
+ }
+ self.sources.deinit();
}
- /// Register a tool. The registry takes ownership.
+ /// Register a single tool. The registry takes ownership.
///
/// Returns `error.DuplicateTool` if a tool with the same name is
- /// already registered — in that case the caller's tool is NOT taken
- /// over (the caller is responsible for tearing it down).
+ /// already registered (whether from a single Tool or from a source).
+ /// In the duplicate case the caller's tool is NOT taken over; the
+ /// caller is responsible for tearing it down.
pub fn register(self: *ToolRegistry, tool: Tool) !void {
- const gop = try self.tools.getOrPut(tool.name);
+ const gop = try self.entries.getOrPut(tool.decl.name);
if (gop.found_existing) return error.DuplicateTool;
- gop.value_ptr.* = tool;
+ gop.value_ptr.* = .{ .single = tool };
+ }
+
+ /// Register a tool source. The registry takes ownership of `src` —
+ /// it is heap-copied into the registry's source list and freed at
+ /// deinit.
+ ///
+ /// Returns `error.DuplicateTool` if any of the source's declared
+ /// tools collides with an existing registration. On collision the
+ /// source is NOT taken over (caller still owns it and must tear it
+ /// down) and any tools that *had* been inserted before the collision
+ /// are rolled back.
+ pub fn registerSource(self: *ToolRegistry, src: ToolSource) !void {
+ // First pass: check for any collision before committing.
+ for (src.tools) |decl| {
+ if (self.entries.contains(decl.name)) return error.DuplicateTool;
+ }
+
+ // Allocate the persistent heap copy of the source. From this
+ // point forward, on any failure we must free the allocation and
+ // roll back any entries we inserted.
+ const heap = try self.allocator.create(ToolSource);
+ errdefer self.allocator.destroy(heap);
+ heap.* = src;
+
+ var inserted: usize = 0;
+ errdefer {
+ // Roll back any inserts we made before the failure.
+ for (src.tools[0..inserted]) |decl| {
+ _ = self.entries.remove(decl.name);
+ }
+ }
+
+ for (src.tools, 0..) |decl, i| {
+ const gop = try self.entries.getOrPut(decl.name);
+ if (gop.found_existing) return error.DuplicateTool;
+ gop.value_ptr.* = .{ .source = .{ .source = heap, .tool_index = i } };
+ inserted = i + 1;
+ }
+
+ try self.sources.append(heap);
}
- /// Remove a tool by name. Calls the tool's `vtable.deinit`. No-op if
- /// the name is not registered.
+ /// Remove a single-tool registration by name. Calls the tool's
+ /// `vtable.deinit`. No-op if the name is not registered or if it
+ /// belongs to a source (sources are removed as a unit; not yet
+ /// exposed).
pub fn unregister(self: *ToolRegistry, name: []const u8) void {
- if (self.tools.fetchRemove(name)) |kv| {
- kv.value.vtable.deinit(kv.value.ctx, self.allocator);
+ const entry_ptr = self.entries.getPtr(name) orelse return;
+ switch (entry_ptr.*) {
+ .single => |t| {
+ _ = self.entries.remove(name);
+ t.vtable.deinit(t.ctx, self.allocator);
+ },
+ .source => {}, // ignore — sources tear down at registry deinit
}
}
- /// Look up a tool by name. The returned pointer is invalidated by any
- /// subsequent register/unregister call.
- pub fn lookup(self: *const ToolRegistry, name: []const u8) ?*const Tool {
- return self.tools.getPtr(name);
+ /// Look up a tool by name. Returns a uniform `ToolView`. Pointer
+ /// invariants are the same as `std.StringHashMap.getPtr`: invalidated
+ /// by subsequent register/unregister calls.
+ pub fn lookup(self: *const ToolRegistry, name: []const u8) ?ToolView {
+ const entry = self.entries.get(name) orelse return null;
+ return makeView(entry);
}
pub fn count(self: *const ToolRegistry) usize {
- return self.tools.count();
+ return self.entries.count();
}
- /// Iterate registered tools. Caller must not mutate the registry during
- /// iteration.
pub fn iterator(self: *const ToolRegistry) Iterator {
- return .{ .inner = self.tools.iterator() };
+ return .{ .inner = self.entries.iterator() };
}
pub const Iterator = struct {
- inner: std.StringHashMap(Tool).Iterator,
+ inner: std.StringHashMap(Entry).Iterator,
- pub fn next(self: *Iterator) ?*const Tool {
+ pub fn next(self: *Iterator) ?ToolView {
const entry = self.inner.next() orelse return null;
- return entry.value_ptr;
+ return makeView(entry.value_ptr.*);
}
};
+
+ fn makeView(entry: Entry) ToolView {
+ return switch (entry) {
+ .single => |t| .{ .decl = t.decl, .entry = entry },
+ .source => |sr| .{ .decl = sr.source.tools[sr.tool_index], .entry = entry },
+ };
+ }
};
// -----------------------------------------------------------------------------
@@ -111,9 +222,11 @@ const TestTool = struct {
.schema_owned = schema_owned,
};
return .{
- .name = self.name_owned,
- .description = self.desc_owned,
- .schema_json = self.schema_owned,
+ .decl = .{
+ .name = self.name_owned,
+ .description = self.desc_owned,
+ .schema_json = self.schema_owned,
+ },
.ctx = self,
.vtable = &vt,
};
@@ -139,6 +252,99 @@ const TestTool = struct {
}
};
+/// A minimal source backing N tools. Each tool name maps to a configured
+/// response prefix; invoke_batch returns "<prefix>:<input>" for each
+/// call. Tracks the batch sizes it was called with for inspection.
+const TestSource = struct {
+ name_owned: []u8,
+ decls: []ToolDecl,
+ /// Allocations backing every `decl`'s strings. Freed at deinit.
+ allocations: std.array_list.Managed([]u8),
+ batch_sizes: std.array_list.Managed(usize),
+ allocator: Allocator,
+
+ fn create(
+ allocator: Allocator,
+ source_name: []const u8,
+ tool_names: []const []const u8,
+ ) !ToolSource {
+ const self = try allocator.create(TestSource);
+ errdefer allocator.destroy(self);
+
+ var allocations = std.array_list.Managed([]u8).init(allocator);
+ errdefer {
+ for (allocations.items) |s| allocator.free(s);
+ allocations.deinit();
+ }
+
+ const name_owned = try allocator.dupe(u8, source_name);
+ try allocations.append(name_owned);
+
+ const decls = try allocator.alloc(ToolDecl, tool_names.len);
+ errdefer allocator.free(decls);
+
+ for (tool_names, 0..) |tn, i| {
+ const n = try allocator.dupe(u8, tn);
+ try allocations.append(n);
+ const d = try allocator.dupe(u8, "test src tool");
+ try allocations.append(d);
+ const s = try allocator.dupe(u8, "{}");
+ try allocations.append(s);
+ decls[i] = .{ .name = n, .description = d, .schema_json = s };
+ }
+
+ self.* = .{
+ .name_owned = name_owned,
+ .decls = decls,
+ .allocations = allocations,
+ .batch_sizes = std.array_list.Managed(usize).init(allocator),
+ .allocator = allocator,
+ };
+
+ return ToolSource{
+ .name = self.name_owned,
+ .tools = self.decls,
+ .ctx = self,
+ .vtable = &vt,
+ };
+ }
+
+ const vt: ToolSource.VTable = .{
+ .invoke_batch = invokeBatch,
+ .deinit = deinitSrc,
+ };
+
+ fn invokeBatch(
+ ctx: *anyopaque,
+ calls: []const tool_source_mod.Call,
+ results: []tool_source_mod.CallResult,
+ allocator: Allocator,
+ ) anyerror!void {
+ const self: *TestSource = @ptrCast(@alignCast(ctx));
+ try self.batch_sizes.append(calls.len);
+ for (calls, 0..) |call, i| {
+ const buf = std.fmt.allocPrint(
+ allocator,
+ "{s}:{s}",
+ .{ call.tool_name, call.input },
+ ) catch |e| {
+ results[i] = .{ .err = e };
+ continue;
+ };
+ results[i] = .{ .ok = buf };
+ }
+ }
+
+ fn deinitSrc(ctx: *anyopaque, _: Allocator) void {
+ const self: *TestSource = @ptrCast(@alignCast(ctx));
+ for (self.allocations.items) |s| self.allocator.free(s);
+ self.allocations.deinit();
+ self.batch_sizes.deinit();
+ self.allocator.free(self.decls);
+ self.allocator.destroy(self);
+ }
+};
+
test "register, lookup, count" {
const allocator = testing.allocator;
var reg = ToolRegistry.init(allocator);
@@ -151,7 +357,7 @@ test "register, lookup, count" {
try testing.expect(reg.lookup("echo") != null);
try testing.expect(reg.lookup("ls") != null);
try testing.expect(reg.lookup("missing") == null);
- try testing.expectEqualStrings("echo", reg.lookup("echo").?.name);
+ try testing.expectEqualStrings("echo", reg.lookup("echo").?.decl.name);
}
test "duplicate registration returns error and leaves original in place" {
@@ -200,9 +406,9 @@ test "iterator visits every tool" {
var it = reg.iterator();
while (it.next()) |t| {
- if (std.mem.eql(u8, t.name, "a")) saw_a = true;
- if (std.mem.eql(u8, t.name, "b")) saw_b = true;
- if (std.mem.eql(u8, t.name, "c")) saw_c = true;
+ if (std.mem.eql(u8, t.decl.name, "a")) saw_a = true;
+ if (std.mem.eql(u8, t.decl.name, "b")) saw_b = true;
+ if (std.mem.eql(u8, t.decl.name, "c")) saw_c = true;
}
try testing.expect(saw_a and saw_b and saw_c);
}
@@ -215,3 +421,71 @@ test "deinit frees all remaining tools" {
try reg.register(try TestTool.create(allocator, "y"));
reg.deinit();
}
+
+test "registerSource exposes every declared tool by name" {
+ const allocator = testing.allocator;
+ var reg = ToolRegistry.init(allocator);
+ defer reg.deinit();
+
+ const src = try TestSource.create(allocator, "panto-lua", &.{ "alpha", "beta", "gamma" });
+ try reg.registerSource(src);
+
+ try testing.expectEqual(@as(usize, 3), reg.count());
+ const v = reg.lookup("beta") orelse return error.NotFound;
+ try testing.expectEqualStrings("beta", v.decl.name);
+ try testing.expect(v.entry == .source);
+}
+
+test "registerSource: collision with existing single tool aborts and rolls back" {
+ const allocator = testing.allocator;
+ var reg = ToolRegistry.init(allocator);
+ defer reg.deinit();
+
+ try reg.register(try TestTool.create(allocator, "shared"));
+
+ // Build a source that includes the colliding name. We must tear it
+ // down ourselves on failure.
+ var src = try TestSource.create(allocator, "src", &.{ "first", "shared", "third" });
+ try testing.expectError(error.DuplicateTool, reg.registerSource(src));
+ src.vtable.deinit(src.ctx, allocator);
+
+ // No partial state from the source remains.
+ try testing.expectEqual(@as(usize, 1), reg.count());
+ try testing.expect(reg.lookup("first") == null);
+ try testing.expect(reg.lookup("third") == null);
+}
+
+test "registerSource: collision between two sources" {
+ const allocator = testing.allocator;
+ var reg = ToolRegistry.init(allocator);
+ defer reg.deinit();
+
+ try reg.registerSource(try TestSource.create(allocator, "a", &.{ "foo", "bar" }));
+
+ var s = try TestSource.create(allocator, "b", &.{ "baz", "foo" });
+ try testing.expectError(error.DuplicateTool, reg.registerSource(s));
+ s.vtable.deinit(s.ctx, allocator);
+
+ try testing.expectEqual(@as(usize, 2), reg.count());
+}
+
+test "source view exposes per-tool metadata uniformly" {
+ const allocator = testing.allocator;
+ var reg = ToolRegistry.init(allocator);
+ defer reg.deinit();
+
+ try reg.registerSource(try TestSource.create(allocator, "lua", &.{ "x", "y" }));
+ try reg.register(try TestTool.create(allocator, "z"));
+
+ try testing.expectEqual(@as(usize, 3), reg.count());
+
+ // Every entry has the canonical fields populated.
+ var it = reg.iterator();
+ var n: usize = 0;
+ while (it.next()) |v| : (n += 1) {
+ try testing.expect(v.decl.name.len > 0);
+ try testing.expect(v.decl.description.len > 0);
+ try testing.expect(v.decl.schema_json.len > 0);
+ }
+ try testing.expectEqual(@as(usize, 3), n);
+}
diff --git a/libpanto/src/tool_source.zig b/libpanto/src/tool_source.zig
new file mode 100644
index 0000000..bb109d8
--- /dev/null
+++ b/libpanto/src/tool_source.zig
@@ -0,0 +1,100 @@
+//! Batch-dispatched tool extension API: `ToolSource`.
+//!
+//! Where `Tool` is a single, thread-safe handler (one tool, one vtable,
+//! reentrant), `ToolSource` is a single owner of many tools whose runtime
+//! prefers to receive calls in *batches* on a single thread.
+//!
+//! Motivation: Lua. A Lua extension runtime maintains one long-lived
+//! `lua_State` so that module-globals, lazy connection pools, rate
+//! limiters, etc. survive across calls. A single `lua_State` is not safe
+//! for concurrent host entry, so the runtime can't satisfy `Tool`'s
+//! thread-safety contract directly. The runtime *can* dispatch many calls
+//! cooperatively (coroutines + an event loop), but it needs to be told
+//! all of them at once.
+//!
+//! The contract libpanto provides:
+//!
+//! - For a given turn, every `ToolUse` block whose tool name belongs to
+//! a particular source is delivered in a single `invoke_batch` call,
+//! on one thread.
+//! - Distinct sources still execute concurrently (one OS thread per
+//! source per turn), so a Lua source and a native source can run in
+//! parallel.
+//! - Single `Tool` registrations are unchanged. They each get their own
+//! thread when they appear alongside other tool calls in a turn.
+//!
+//! The "thread-safe" promise that `Tool.invoke` carries relaxes to
+//! "coroutine-safe within the source's runtime" for source-backed tools —
+//! enforcement is the source's problem.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+/// Tool metadata: everything the LLM-facing wire needs (name,
+/// description, schema) without an invocation vtable. `ToolSource`s
+/// declare their tools this way because they share a single dispatch
+/// path.
+pub const ToolDecl = struct {
+ name: []const u8,
+ description: []const u8,
+ schema_json: []const u8,
+};
+
+/// One pending invocation passed to `invoke_batch`. Slices borrowed from
+/// the caller for the duration of the call.
+pub const Call = struct {
+ /// Which of the source's declared tools this call targets.
+ tool_name: []const u8,
+ /// Raw JSON bytes the provider sent. Borrowed.
+ input: []const u8,
+};
+
+/// 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 bytes, freed by libpanto after assembling the ToolResult
+ /// block.
+ ok: []u8,
+ err: anyerror,
+};
+
+/// A grouped tool runtime.
+pub const ToolSource = struct {
+ /// Diagnostic name; surfaced in error messages and logs. Example
+ /// values: `"panto-lua"`, `"panto-python"`. Borrowed; lifetime owned
+ /// by the source.
+ name: []const u8,
+ /// Tool metadata for every tool this source owns. Borrowed.
+ tools: []const ToolDecl,
+ ctx: *anyopaque,
+ vtable: *const VTable,
+
+ pub const VTable = struct {
+ /// libpanto guarantees: for a given turn, every ToolUse call
+ /// whose tool name belongs to this source is delivered in one
+ /// `invoke_batch`, on one thread. Different sources still
+ /// execute in parallel.
+ ///
+ /// `calls` and `results` are parallel arrays of length N.
+ /// `results` is pre-allocated by libpanto; the source fills each
+ /// slot. The source decides internal scheduling — sequential,
+ /// coroutine fan-out, worker pool, etc.
+ ///
+ /// On any uncaught error returned from this function (i.e. the
+ /// fn itself returning an error rather than recording one in a
+ /// `results[i]` slot), libpanto treats the *entire batch* as
+ /// failed: it frees any `ok` slots already filled and aborts the
+ /// turn with that error.
+ invoke_batch: *const fn (
+ ctx: *anyopaque,
+ calls: []const Call,
+ results: []CallResult,
+ allocator: Allocator,
+ ) anyerror!void,
+
+ /// Called when the source is removed from the registry or the
+ /// registry is torn down. Frees any resources owned by `ctx`,
+ /// including `ctx` itself if heap-allocated.
+ deinit: *const fn (ctx: *anyopaque, allocator: Allocator) void,
+ };
+};