diff options
Diffstat (limited to 'libpanto/src')
| -rw-r--r-- | libpanto/src/agent.zig | 772 | ||||
| -rw-r--r-- | libpanto/src/anthropic_messages_json.zig | 14 | ||||
| -rw-r--r-- | libpanto/src/openai_chat_json.zig | 14 | ||||
| -rw-r--r-- | libpanto/src/root.zig | 5 | ||||
| -rw-r--r-- | libpanto/src/tool.zig | 27 | ||||
| -rw-r--r-- | libpanto/src/tool_registry.zig | 356 | ||||
| -rw-r--r-- | libpanto/src/tool_source.zig | 100 |
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, + }; +}; |
