diff options
| author | t <t@tjp.lol> | 2026-06-02 10:50:05 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-02 11:11:29 -0600 |
| commit | 16ea45b6854232541fb45f7d2e5b767118cccf43 (patch) | |
| tree | 1bf37d766e4b9de2b646c1475497ae7e9d5699c7 | |
| parent | 9c64a7d4462a11674e2dea481b037b5f5d9c62fc (diff) | |
tool call resiliency
| -rw-r--r-- | docs/todos.md | 3 | ||||
| -rw-r--r-- | libpanto/src/agent.zig | 33 | ||||
| -rw-r--r-- | libpanto/src/anthropic_messages_json.zig | 22 | ||||
| -rw-r--r-- | libpanto/src/config.zig | 5 | ||||
| -rw-r--r-- | libpanto/src/openai_chat_json.zig | 3 | ||||
| -rw-r--r-- | libpanto/src/provider_anthropic_messages.zig | 69 | ||||
| -rw-r--r-- | libpanto/src/session_manager.zig | 160 | ||||
| -rw-r--r-- | src/config_file.zig | 4 | ||||
| -rw-r--r-- | src/main.zig | 70 | ||||
| -rw-r--r-- | src/models_toml.zig | 4 |
10 files changed, 303 insertions, 70 deletions
diff --git a/docs/todos.md b/docs/todos.md index 78671e8..ba14677 100644 --- a/docs/todos.md +++ b/docs/todos.md @@ -1,6 +1,6 @@ ## libpanto -- [ ] user-provided system prompt (design: docs/system-prompt.md) +- [x] user-provided system prompt (design: docs/archive/system-prompt.md) - [ ] polish zig API - [ ] C ABI - [ ] Agent compaction with custom compaction prompts @@ -24,6 +24,7 @@ - [ ] markdown/prompt slash commands - [ ] additional lua extension API - [ ] Agent objects + - [ ] Conversation objects - [ ] the current agent, conversation - [ ] system prompt - [ ] usage metrics diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index 08cc91c..a42c16c 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -43,6 +43,23 @@ const Entry = tool_registry_mod.Entry; pub const Config = config_mod.Config; +fn isValidToolInput(input: []const u8) bool { + if (input.len == 0) return true; + if (input[0] != '{') return true; // legacy tests/tools may use opaque bytes + var parsed = std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, input, .{}) catch return false; + defer parsed.deinit(); + return parsed.value == .object; +} + +fn invalidInputResult(allocator: Allocator, input: []const u8) ![]u8 { + return std.fmt.allocPrint( + allocator, + "Tool call was not executed: tool input was incomplete or invalid JSON. Partial input: {s}", + .{input}, + ); +} + + pub const Agent = struct { allocator: Allocator, io: Io, @@ -132,6 +149,17 @@ pub const Agent = struct { for (assistant_msg.content.items) |block| { if (block != .ToolUse) continue; const tu = block.ToolUse; + if (!isValidToolInput(tu.input.items)) { + try calls.append(.{ + .tool_use_id = tu.id, + .tool_name = tu.name, + .input = tu.input.items, + .entry = null, + .result = try invalidInputResult(self.allocator, tu.input.items), + .err = null, + }); + continue; + } const entry = self.config.registry.lookup(tu.name) orelse { // Unknown tool: abort the turn with a clear error. return error.UnknownTool; @@ -236,7 +264,7 @@ 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, + entry: ?Entry, /// Owned result bytes from `Tool.invoke` or `ToolSource.invoke_batch`. /// Allocated with the agent's allocator. Transferred into a @@ -292,7 +320,8 @@ fn buildGroups( } for (calls, 0..) |c, i| { - switch (c.entry) { + const ent = c.entry orelse continue; + switch (ent) { .single => |t| try out.append(.{ .single = .{ .tool = t, .call_index = i } }), .source => |sr| { const gop = try pending.getOrPut(sr.source); diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig index f8bfc2e..d382de8 100644 --- a/libpanto/src/anthropic_messages_json.zig +++ b/libpanto/src/anthropic_messages_json.zig @@ -111,6 +111,26 @@ fn writeRawJson(s: *std.json.Stringify, raw: []const u8) !void { try s.write(parsed.value); } +fn writeToolUseInput(s: *std.json.Stringify, raw: []const u8) !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const parsed = std.json.parseFromSlice(std.json.Value, arena.allocator(), raw, .{}) catch { + try s.beginObject(); + try s.objectField("_invalid_input"); + try s.write(raw); + try s.endObject(); + return; + }; + if (parsed.value != .object) { + try s.beginObject(); + try s.objectField("_invalid_input"); + try s.write(raw); + try s.endObject(); + return; + } + try s.write(parsed.value); +} + /// Build the top-level Anthropic `system` string. Applies the shared /// append/replace derivation (see `conversation.effectiveSystemBlocks`), /// strips trailing newlines from each surviving block, and joins them with @@ -200,7 +220,7 @@ fn writeBlock(s: *std.json.Stringify, block: conversation.ContentBlock) !void { try s.beginObject(); try s.endObject(); } else { - try writeRawJson(s, input_bytes); + try writeToolUseInput(s, input_bytes); } try s.endObject(); }, diff --git a/libpanto/src/config.zig b/libpanto/src/config.zig index 6744b56..6b8d9a8 100644 --- a/libpanto/src/config.zig +++ b/libpanto/src/config.zig @@ -46,6 +46,7 @@ pub const OpenAIChatConfig = struct { base_url: []const u8, model: []const u8, reasoning: ReasoningEffort = .default, + max_tokens: u32 = 64_000, }; pub const AnthropicMessagesConfig = struct { @@ -55,7 +56,7 @@ pub const AnthropicMessagesConfig = struct { /// Value sent in the `anthropic-version` header. api_version: []const u8 = "2023-06-01", /// Required by Anthropic's Messages API. - max_tokens: u32 = 4096, + max_tokens: u32 = 64_000, }; /// Per-provider transport/auth/model configuration. Tagged by `APIStyle`. @@ -144,7 +145,7 @@ test "ProviderConfig - anthropic_messages variant" { } }; try t.expectEqual(APIStyle.anthropic_messages, cfg.style()); try t.expectEqualStrings("2023-06-01", cfg.anthropic_messages.api_version); - try t.expectEqual(@as(u32, 4096), cfg.anthropic_messages.max_tokens); + try t.expectEqual(@as(u32, 64_000), cfg.anthropic_messages.max_tokens); } test "ProviderConfig - openai_chat reasoning defaults to .default" { diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig index 3b81c41..8e6c9be 100644 --- a/libpanto/src/openai_chat_json.zig +++ b/libpanto/src/openai_chat_json.zig @@ -91,6 +91,9 @@ pub fn serializeRequest( try s.objectField("stream"); try s.write(true); + try s.objectField("max_tokens"); + try s.write(cfg.max_tokens); + // Ask for the final-chunk usage block. Without this the server // sends `usage: null` and we can't stamp token counts on the // session log. Most OpenAI-compatible proxies accept this; ones diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig index 02277be..5c24b8f 100644 --- a/libpanto/src/provider_anthropic_messages.zig +++ b/libpanto/src/provider_anthropic_messages.zig @@ -181,6 +181,7 @@ const StreamState = struct { /// `onMessageComplete`, the latter stamps `null`. usage: provider_mod.Usage = .{}, usage_seen: bool = false, + stop_reason: ?[]u8 = null, const ActiveBlock = struct { /// Index reported on the wire (Anthropic's content-array index). @@ -210,6 +211,7 @@ const StreamState = struct { } for (self.blocks.items) |*b| b.deinit(self.allocator); self.blocks.deinit(self.allocator); + if (self.stop_reason) |s| self.allocator.free(s); } fn ensureStarted(self: *StreamState, receiver: *provider_mod.Receiver) !void { @@ -322,6 +324,40 @@ const StreamState = struct { a.signature = try self.allocator.dupe(u8, sig); } + fn setStopReason(self: *StreamState, reason: ?[]const u8) !void { + if (self.stop_reason) |old| self.allocator.free(old); + self.stop_reason = if (reason) |r| try self.allocator.dupe(u8, r) else null; + } + + fn isJSONObject(input: []const u8) bool { + if (input.len == 0) return true; + var parsed = std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, input, .{}) catch return false; + defer parsed.deinit(); + return parsed.value == .object; + } + + fn appendAnthropicFailureResult( + allocator: Allocator, + blocks: *std.ArrayList(conversation.ContentBlock), + tool_use_id: []const u8, + input: []const u8, + stop_reason: ?[]const u8, + ) !void { + const id_copy = try allocator.dupe(u8, tool_use_id); + errdefer allocator.free(id_copy); + const msg = try std.fmt.allocPrint( + allocator, + "Tool call was not executed: Anthropic stopped before completing valid tool input JSON (stop_reason={s}). Partial input: {s}", + .{ stop_reason orelse "unknown", input }, + ); + errdefer allocator.free(msg); + var content: conversation.TextualBlock = .empty; + errdefer content.deinit(allocator); + try content.appendSlice(allocator, msg); + allocator.free(msg); + try blocks.append(allocator, .{ .ToolResult = .{ .tool_use_id = id_copy, .content = content } }); + } + /// Close the active block: append it to `blocks` and emit onBlockComplete. fn closeBlock( self: *StreamState, @@ -355,11 +391,22 @@ const StreamState = struct { .text = a.text_buf, .signature = a.signature, } }, - .tool_use => .{ .ToolUse = .{ - .id = a.tool_id.?, - .name = a.tool_name.?, - .input = a.text_buf, - } }, + .tool_use => blk: { + if (!isJSONObject(a.text_buf.items)) { + try appendAnthropicFailureResult( + self.allocator, + &self.blocks, + a.tool_id.?, + a.text_buf.items, + self.stop_reason, + ); + } + break :blk .{ .ToolUse = .{ + .id = a.tool_id.?, + .name = a.tool_name.?, + .input = a.text_buf, + } }; + }, .unsupported => unreachable, }; @@ -389,7 +436,8 @@ const StreamState = struct { self.finalized = true; if (self.active != null) { - // Wire dropped us mid-block. Close it cleanly anyway. + // Preserve an interrupted tool call so the agent can answer it + // with a synthetic error ToolResult instead of invoking it. try self.closeBlock(receiver); } @@ -434,12 +482,13 @@ fn handleEvent( if (d.signature_delta) |sig| try state.setSignature(sig); if (d.input_json_delta) |j| try state.appendInputJsonDelta(receiver, j); }, - .content_block_stop => { - try state.closeBlock(receiver); + .content_block_stop => |s| { + if (state.active) |a| { + if (a.wire_index == s.index) try state.closeBlock(receiver); + } }, .message_delta => |d| { - // We don't act on stop_reason directly; message_stop is the - // authoritative end-of-stream signal. + try state.setStopReason(d.stop_reason); state.mergeUsage(d.usage); }, .message_stop => { diff --git a/libpanto/src/session_manager.zig b/libpanto/src/session_manager.zig index 8b503f2..9e8e2c8 100644 --- a/libpanto/src/session_manager.zig +++ b/libpanto/src/session_manager.zig @@ -355,10 +355,11 @@ pub const SessionManager = struct { try truncateFileTo(io, path_copy, valid_bytes); } - // Run format migration. Currently a no-op for version 1, but the - // hook exists so future versions can rewrite the file. var migrated_header = header; - const did_migrate = migrate(allocator, &migrated_header, &entries); + var did_migrate = migrate(allocator, &migrated_header, &entries); + if (elideDanglingToolUses(allocator, &entries)) { + did_migrate = true; + } // We didn't reassign by_id during migration; rebuild if needed. if (did_migrate) { by_id.clearRetainingCapacity(); @@ -567,16 +568,11 @@ pub const SessionManager = struct { // ---------- Persistence ---------- - /// Write the header + all currently-buffered entries + `final_entry` + /// Write the header + all currently-buffered entries + `new_entries` /// to the file as a single batch. Creates the directory and file. - /// Called only when `flushed = false` and we just got an assistant - /// message. - fn flushBuffered(self: *SessionManager, final_entry: SessionEntry) !void { - // Ensure the directory exists. + fn flushBufferedMany(self: *SessionManager, new_entries: []const SessionEntry) !void { try mkdirP(self.io, self.session_dir); - // Open (create exclusively isn't required; if a stale file with the - // same UUIDv7 existed we'd just overwrite — UUIDv7s are unique). const file = try Io.Dir.cwd().createFile(self.io, self.session_file, .{ .truncate = true, .read = false, @@ -584,7 +580,6 @@ pub const SessionManager = struct { defer file.close(self.io); var offset: u64 = 0; - // Header const header_line = try session_mod.serializeHeader(self.allocator, self.header); defer self.allocator.free(header_line); try file.writePositionalAll(self.io, header_line, offset); @@ -592,7 +587,6 @@ pub const SessionManager = struct { try file.writePositionalAll(self.io, "\n", offset); offset += 1; - // Buffered entries. for (self.entries.items) |e| { const line = try session_mod.serializeEntry(self.allocator, e); defer self.allocator.free(line); @@ -602,38 +596,105 @@ pub const SessionManager = struct { offset += 1; } - // Final (the assistant message we just got). - const final_line = try session_mod.serializeEntry(self.allocator, final_entry); - defer self.allocator.free(final_line); - try file.writePositionalAll(self.io, final_line, offset); - offset += final_line.len; - try file.writePositionalAll(self.io, "\n", offset); - offset += 1; + for (new_entries) |entry| { + const line = try session_mod.serializeEntry(self.allocator, entry); + defer self.allocator.free(line); + try file.writePositionalAll(self.io, line, offset); + offset += line.len; + try file.writePositionalAll(self.io, "\n", offset); + offset += 1; + } - // Best-effort fsync — the directory itself doesn't need syncing - // for our purposes (we don't unlink/rename); the file body does. file.sync(self.io) catch {}; - self.flushed = true; self.written_bytes = offset; } + fn flushBuffered(self: *SessionManager, final_entry: SessionEntry) !void { + try self.flushBufferedMany(&.{final_entry}); + } + /// Append a single line for `entry` to the open session file. Caller /// must have already verified `flushed`. fn persistEntry(self: *SessionManager, entry: SessionEntry) !void { + try self.persistEntries(&.{entry}); + } + + pub fn appendMessagesAtomic( + self: *SessionManager, + messages: []DiskMessage, + providers: []const ?[]const u8, + models: []const ?[]const u8, + ) !void { + std.debug.assert(messages.len == providers.len); + std.debug.assert(messages.len == models.len); + if (messages.len == 0) return; + + const base_len = self.entries.items.len; + try self.entries.ensureUnusedCapacity(self.allocator, messages.len); + try self.by_id.ensureUnusedCapacity(@intCast(messages.len)); + + var entries = try self.allocator.alloc(SessionEntry, messages.len); + defer self.allocator.free(entries); + var built: usize = 0; + errdefer { + for (entries[0..built]) |*e| e.deinit(self.allocator); + } + + var prev_leaf = self.leaf_id; + for (messages, 0..) |msg, i| { + const msg_local = msg; + const id_buf = try self.newEntryId(); + errdefer self.allocator.free(id_buf); + const timestamp = try isoTimestamp(self.allocator, self.io); + errdefer self.allocator.free(timestamp); + const parent_id_copy: ?[]const u8 = if (prev_leaf) |l| try self.allocator.dupe(u8, l) else null; + errdefer if (parent_id_copy) |p| self.allocator.free(p); + const provider_copy: ?[]const u8 = if (providers[i]) |p| try self.allocator.dupe(u8, p) else null; + errdefer if (provider_copy) |p| self.allocator.free(p); + const model_copy: ?[]const u8 = if (models[i]) |m| try self.allocator.dupe(u8, m) else null; + errdefer if (model_copy) |m| self.allocator.free(m); + entries[i] = .{ .message = .{ + .base = .{ .id = id_buf, .parent_id = parent_id_copy, .timestamp = timestamp }, + .provider = provider_copy, + .model = model_copy, + .message = msg_local, + } }; + built += 1; + prev_leaf = entries[i].base().id; + } + + if (self.flushed) { + try self.persistEntries(entries); + } else { + try self.flushBufferedMany(entries); + } + + for (entries, 0..) |entry, i| { + self.entries.appendAssumeCapacity(entry); + self.by_id.putAssumeCapacity(entry.base().id, base_len + i); + } + self.leaf_id = entries[entries.len - 1].base().id; + built = 0; + } + + fn persistEntries(self: *SessionManager, entries: []const SessionEntry) !void { const file = try Io.Dir.cwd().openFile(self.io, self.session_file, .{ .mode = .write_only, }); defer file.close(self.io); - const line = try session_mod.serializeEntry(self.allocator, entry); - defer self.allocator.free(line); - - try file.writePositionalAll(self.io, line, self.written_bytes); - self.written_bytes += line.len; - try file.writePositionalAll(self.io, "\n", self.written_bytes); - self.written_bytes += 1; + var offset = self.written_bytes; + for (entries) |entry| { + const line = try session_mod.serializeEntry(self.allocator, entry); + defer self.allocator.free(line); + try file.writePositionalAll(self.io, line, offset); + offset += line.len; + try file.writePositionalAll(self.io, "\n", offset); + offset += 1; + } file.sync(self.io) catch {}; + self.written_bytes = offset; } // ============================================================================= @@ -711,6 +772,47 @@ fn migrate( return false; } +fn elideDanglingToolUses(allocator: Allocator, entries: *std.ArrayList(SessionEntry)) bool { + var needed: std.StringHashMap(void) = .init(allocator); + defer needed.deinit(); + var changed = false; + + var i = entries.items.len; + while (i > 0) { + i -= 1; + const entry = &entries.items[i]; + if (entry.* != .message) continue; + const msg = &entry.message.message; + + if (msg.role == .user) { + for (msg.content) |block| { + if (block == .tool_result) { + needed.put(block.tool_result.tool_use_id, {}) catch {}; + } + } + continue; + } + + if (msg.role != .assistant) continue; + var kept: std.ArrayList(DiskContentBlock) = .empty; + defer kept.deinit(allocator); + var removed = false; + for (msg.content) |block| { + if (block == .tool_use and !needed.contains(block.tool_use.id)) { + block.deinit(allocator); + removed = true; + continue; + } + kept.append(allocator, block) catch unreachable; + } + if (!removed) continue; + allocator.free(msg.content); + msg.content = kept.toOwnedSlice(allocator) catch unreachable; + changed = true; + } + return changed; +} + // ============================================================================= // File utilities // ============================================================================= diff --git a/src/config_file.zig b/src/config_file.zig index 533eb48..f7589fe 100644 --- a/src/config_file.zig +++ b/src/config_file.zig @@ -130,6 +130,7 @@ pub fn buildProviderConfig( const def_opt = defs.get(ref.provider, ref.model); const wire_model: []const u8 = if (def_opt) |d| d.model else ref.model; const reasoning: panto.config.ReasoningEffort = if (def_opt) |d| d.reasoning else .default; + const max_tokens: u32 = if (def_opt) |d| (d.max_tokens orelse 64_000) else 64_000; switch (prov.style) { .openai_chat => return .{ .openai_chat = .{ @@ -137,13 +138,14 @@ pub fn buildProviderConfig( .base_url = prov.base_url, .model = wire_model, .reasoning = reasoning, + .max_tokens = max_tokens, } }, .anthropic_messages => return .{ .anthropic_messages = .{ .api_key = prov.api_key, .base_url = prov.base_url, .model = wire_model, .api_version = if (def_opt) |d| (d.api_version orelse "2023-06-01") else "2023-06-01", - .max_tokens = if (def_opt) |d| (d.max_tokens orelse 4096) else 4096, + .max_tokens = max_tokens, } }, } } diff --git a/src/main.zig b/src/main.zig index 20686eb..28f1144 100644 --- a/src/main.zig +++ b/src/main.zig @@ -704,6 +704,13 @@ fn persistTurn( model: []const u8, per_message_usage: []const ?panto.session_manager.Usage, ) !void { + var batch_messages: std.ArrayList(panto.session_manager.DiskMessage) = .empty; + defer batch_messages.deinit(alloc); + var batch_providers: std.ArrayList(?[]const u8) = .empty; + defer batch_providers.deinit(alloc); + var batch_models: std.ArrayList(?[]const u8) = .empty; + defer batch_models.deinit(alloc); + var i = start_index; var assistant_seen: usize = 0; while (i < conv.messages.items.len) : (i += 1) { @@ -718,22 +725,23 @@ fn persistTurn( blocks[allocated] = try panto.session.contentBlockToDisk(alloc, block); allocated += 1; } + if (msg.role == .assistant and hasToolUseWithoutFollowingResults(conv, i)) { + for (blocks[0..allocated]) |b| b.deinit(alloc); + alloc.free(blocks); + continue; + } switch (msg.role) { .system => { // Mid-turn system messages aren't a thing in pantograph today. // Treat as harmless and persist verbatim, sans stamps. - _ = try mgr.appendMessage( - .{ .role = .system, .content = blocks }, - null, - null, - ); + try batch_messages.append(alloc, .{ .role = .system, .content = blocks }); + try batch_providers.append(alloc, null); + try batch_models.append(alloc, null); }, .user => { - _ = try mgr.appendMessage( - .{ .role = .user, .content = blocks }, - provider, - model, - ); + try batch_messages.append(alloc, .{ .role = .user, .content = blocks }); + try batch_providers.append(alloc, provider); + try batch_models.append(alloc, model); }, .assistant => { // Pair this assistant message with its usage, if the @@ -745,19 +753,37 @@ fn persistTurn( else null; assistant_seen += 1; - _ = try mgr.appendMessage( - .{ - .role = .assistant, - .content = blocks, - .provider = try alloc.dupe(u8, provider), - .model = try alloc.dupe(u8, model), - .stop_reason = try alloc.dupe(u8, "stop"), - .usage = usage, - }, - null, - null, - ); + try batch_messages.append(alloc, .{ + .role = .assistant, + .content = blocks, + .provider = try alloc.dupe(u8, provider), + .model = try alloc.dupe(u8, model), + .stop_reason = try alloc.dupe(u8, "stop"), + .usage = usage, + }); + try batch_providers.append(alloc, null); + try batch_models.append(alloc, null); }, } } + try mgr.appendMessagesAtomic(batch_messages.items, batch_providers.items, batch_models.items); +} + +fn hasToolUseWithoutFollowingResults(conv: *const panto.conversation.Conversation, index: usize) bool { + const msg = conv.messages.items[index]; + for (msg.content.items) |block| { + if (block != .ToolUse) continue; + if (index + 1 >= conv.messages.items.len) return true; + const next = conv.messages.items[index + 1]; + if (next.role != .user) return true; + var found = false; + for (next.content.items) |next_block| { + if (next_block == .ToolResult and std.mem.eql(u8, next_block.ToolResult.tool_use_id, block.ToolUse.id)) { + found = true; + break; + } + } + if (!found) return true; + } + return false; } diff --git a/src/models_toml.zig b/src/models_toml.zig index 36d8c65..790b61f 100644 --- a/src/models_toml.zig +++ b/src/models_toml.zig @@ -10,7 +10,7 @@ //! [<provider>.<alias>] //! model = <string> # wire model id; defaults to <alias> if omitted //! reasoning = <string> # default | off | minimal | low | medium | high -//! max_tokens = <int> # anthropic_messages only +//! max_tokens = <int> # per-request output token cap //! api_version = <string> # anthropic_messages only //! # pricing (all optional; USD per million tokens): //! input = <float> @@ -64,7 +64,7 @@ pub const ModelDef = struct { /// TOML omitted `model`. model: []const u8, reasoning: ReasoningEffort, - /// anthropic_messages only; null = use the provider/library default. + /// Per-request output token cap; null = use the provider/library default. max_tokens: ?u32, /// anthropic_messages only; null = use the library default. api_version: ?[]const u8, |
