diff options
| author | t <t@tjp.lol> | 2026-06-01 08:21:00 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-01 11:20:56 -0600 |
| commit | 1beefefc69beee214430eb5bd2528a4f5692d2a8 (patch) | |
| tree | a459dbde5da17babe7d872999341d2006ebfe02e /libpanto/src/provider_openai_chat.zig | |
| parent | 5c016cfdbcb122e2b4f0496ef946642d68953c4b (diff) | |
Rename system extension layer to base for clarity
Replace all references to the "system" layer with "base" to better reflect
its role as the foundational extension/tool layer in panto's hierarchy.
The layer hierarchy now consistently uses: project > user > base.
Includes:
- Update agent README and build.zig documentation
- Refactor tool registry and config module to support layered lookups
- Add TestHarness abstraction for cleaner test setup
- Improve JSON serialization with wire-encoded tool names
- Add glob pattern matching for tool/extension discovery
Diffstat (limited to 'libpanto/src/provider_openai_chat.zig')
| -rw-r--r-- | libpanto/src/provider_openai_chat.zig | 114 |
1 files changed, 62 insertions, 52 deletions
diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig index fcbc0bb..d70eb30 100644 --- a/libpanto/src/provider_openai_chat.zig +++ b/libpanto/src/provider_openai_chat.zig @@ -21,63 +21,34 @@ const provider_mod = @import("provider.zig"); const sse_mod = @import("sse.zig"); const json_mod = @import("openai_chat_json.zig"); const config_mod = @import("config.zig"); +const tool_registry_mod = @import("tool_registry.zig"); + +/// Decode a wire tool name (`__` -> `.`) in place within an assembled +/// name buffer. Decoding only ever shrinks the buffer (reads stay ahead +/// of writes), so aliasing src/dst is safe; we then truncate to the +/// decoded length. Unambiguous because internal names never contain a +/// literal `__` (enforced at registration). +fn decodeNameInPlace(name_buf: *conversation.TextualBlock) void { + const decoded = tool_registry_mod.decodeName(name_buf.items, name_buf.items); + name_buf.items.len = decoded.len; +} /// Active streaming block type tracked by the state machine. Mirrors the /// `ContentBlock` union variants but adds `.none` for "no block open yet". const ActiveBlock = enum { none, text, thinking, tool_use }; -pub const OpenAIChatProvider = struct { +/// A single OpenAI Chat streaming request. Transient: constructed per +/// `streamStep`, holds only borrowed state (allocator, io, the global HTTP +/// client, and the active config). Carries nothing across requests, so it +/// is created inline by the free `streamStep` entry point below. +pub const OpenAIChatRequest = struct { allocator: Allocator, io: Io, - config: config_mod.OpenAIChatConfig, - http_client: http.Client, - - pub fn init(allocator: Allocator, io: Io, cfg: config_mod.OpenAIChatConfig) OpenAIChatProvider { - return .{ - .allocator = allocator, - .io = io, - .config = cfg, - .http_client = .{ .allocator = allocator, .io = io }, - }; - } - - pub fn deinit(self: *OpenAIChatProvider) void { - self.http_client.deinit(); - } - - /// Return a `Provider` interface bound to this concrete provider. - pub fn provider(self: *OpenAIChatProvider) provider_mod.Provider { - return .{ .ptr = self, .vtable = &vtable }; - } - - const vtable: provider_mod.ProviderVTable = .{ - .streamStep = vtableStreamStep, - .deinit = vtableDeinit, - }; - - fn vtableStreamStep( - ptr: *anyopaque, - conv: *conversation.Conversation, - tools: *const provider_mod.ToolRegistry, - receiver: *provider_mod.Receiver, - ) anyerror!void { - const self: *OpenAIChatProvider = @ptrCast(@alignCast(ptr)); - return self.streamStep(conv, tools, receiver); - } - - /// Called via the `Provider` interface. Tears down the impl AND frees - /// its heap allocation, since `Provider.init` is the one that allocated - /// it. Direct stack-allocated users (tests, embedders) call `deinit` - /// themselves and never hit this path. - fn vtableDeinit(ptr: *anyopaque) void { - const self: *OpenAIChatProvider = @ptrCast(@alignCast(ptr)); - const allocator = self.allocator; - self.deinit(); - allocator.destroy(self); - } + config: *const config_mod.OpenAIChatConfig, + http_client: *http.Client, pub fn streamStep( - self: *OpenAIChatProvider, + self: *OpenAIChatRequest, conv: *conversation.Conversation, tools: *const provider_mod.ToolRegistry, receiver: *provider_mod.Receiver, @@ -92,7 +63,7 @@ pub const OpenAIChatProvider = struct { } fn streamStepInner( - self: *OpenAIChatProvider, + self: *OpenAIChatRequest, conv: *conversation.Conversation, tools: *const provider_mod.ToolRegistry, receiver: *provider_mod.Receiver, @@ -108,7 +79,7 @@ pub const OpenAIChatProvider = struct { const uri = try Uri.parse(url); // Build the request body. - const body = try json_mod.serializeRequest(self.allocator, &self.config, conv, tools); + const body = try json_mod.serializeRequest(self.allocator, self.config, conv, tools); defer self.allocator.free(body); // Auth header @@ -490,6 +461,12 @@ const StreamState = struct { return; } + // The model echoes the wire-encoded tool name (`__` for `.`). + // Decode in place now that the full name is assembled, so the + // conversation, receiver callbacks, and dispatch all see the + // internal (dotted) name. Decoding never grows the buffer. + decodeNameInPlace(&tu.name_buf); + // If no arguments ever arrived, we haven't emitted onBlockStart // yet — do it now so the receiver sees a balanced start/complete. try self.emitStartIfNeeded(receiver, &tu); @@ -640,7 +617,7 @@ const testing = std.testing; /// about post-stream conversation state rather than callback observability. const NoopReceiver = struct { fn make() provider_mod.Receiver { - return .{ .ptr = @constCast(@ptrCast(&dummy)), .vtable = &vt }; + return .{ .ptr = @ptrCast(@constCast(&dummy)), .vtable = &vt }; } var dummy: u8 = 0; const vt: provider_mod.ReceiverVTable = .{ @@ -676,7 +653,7 @@ fn runStreamedTurn( if (std.mem.eql(u8, payload, "[DONE]")) break; // Process every chunk through to [DONE], including the // post-finish_reason usage chunk. Mirrors the production loop - // in OpenAIChatProvider.streamStep. + // in OpenAIChatRequest.streamStep. try handleEvent(allocator, payload, &state, receiver); } try state.finalize(receiver, conv); @@ -847,6 +824,39 @@ test "fragmented tool_call id and name are reassembled" { try testing.expectEqualStrings("{\"host\":\"a.com\"}", tu.input.items); } +test "inbound wire tool name is decoded to dotted form (even split across __)" { + // The model echoes the wire name it was given (`std__read`). It is + // decoded to the internal `std.read` for the conversation/session/ + // dispatch. The decode happens after full assembly, so a `__` split + // across two deltas (`std_` + `_read`) decodes correctly. + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("read a file"); + + var recv = NoopReceiver.make(); + + const events = [_][]const u8{ + \\{"choices":[{"delta":{"role":"assistant"}}]} + , + \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"std_"}}]}}]} + , + \\{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"_read"}}]}}]} + , + \\{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]} + , + \\{"choices":[{"delta":{},"finish_reason":"tool_calls"}]} + , + "[DONE]", + }; + + try runStreamedTurn(allocator, &conv, &recv, &events); + + const tu = conv.messages.items[1].content.items[0].ToolUse; + try testing.expectEqualStrings("std.read", tu.name); +} + /// A Receiver that records the sequence of callback events as compact /// strings. Useful for asserting per-block start/complete ordering. const RecordingReceiver = struct { |
