summaryrefslogtreecommitdiff
path: root/libpanto/src/provider_anthropic_messages.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-12 14:49:51 -0600
committert <t@tjp.lol>2026-06-12 16:04:37 -0600
commitef4647f1d324cff43bb444ef4df6c253faed0d52 (patch)
tree9c5b131df15d86ed4b5473e3d1754466d0bbac63 /libpanto/src/provider_anthropic_messages.zig
parentecadcfa7a1e549d6ddb8611bdf44cac852064cea (diff)
improve errors: retries, surfacing provider errors to the user
Diffstat (limited to 'libpanto/src/provider_anthropic_messages.zig')
-rw-r--r--libpanto/src/provider_anthropic_messages.zig67
1 files changed, 65 insertions, 2 deletions
diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig
index 9740c0e..be4268e 100644
--- a/libpanto/src/provider_anthropic_messages.zig
+++ b/libpanto/src/provider_anthropic_messages.zig
@@ -187,6 +187,7 @@ pub const ResumableResponse = struct {
const vtable: provider_mod.ProviderStream.VTable = .{
.produce = produceVT,
.deinit = deinitVT,
+ .last_error = lastErrorVT,
};
fn produceVT(ptr: *anyopaque, out: *EventQueue) anyerror!ProduceStatus {
@@ -194,6 +195,11 @@ pub const ResumableResponse = struct {
return self.produce(out);
}
+ fn lastErrorVT(ptr: *anyopaque) ?[]const u8 {
+ const self: *ResumableResponse = @ptrCast(@alignCast(ptr));
+ return self.state.stream_error_message;
+ }
+
fn deinitVT(ptr: *anyopaque) void {
const self: *ResumableResponse = @ptrCast(@alignCast(ptr));
self.deinit();
@@ -274,6 +280,10 @@ const StreamState = struct {
usage: provider_mod.Usage = .{},
usage_seen: bool = false,
stop_reason: ?[]u8 = null,
+ /// Owned, human-readable description of a mid-stream `error` event
+ /// (e.g. `"overloaded_error: Overloaded"`), surfaced to the agent via
+ /// `ProviderStream.lastError` so the retry notice can show *why*.
+ stream_error_message: ?[]u8 = null,
const ActiveBlock = struct {
/// Index reported on the wire (Anthropic's content-array index).
@@ -304,6 +314,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);
+ if (self.stream_error_message) |s| self.allocator.free(s);
}
fn ensureStarted(self: *StreamState, out: *EventQueue) !void {
@@ -435,6 +446,22 @@ const StreamState = struct {
self.stop_reason = if (reason) |r| try self.allocator.dupe(u8, r) else null;
}
+ /// Record a readable description of a mid-stream `error` event, combining
+ /// the error `kind` and `message` into one owned string (either may be
+ /// absent). Replaces any previous value.
+ fn setStreamErrorMessage(self: *StreamState, kind: ?[]const u8, message: ?[]const u8) !void {
+ if (self.stream_error_message) |old| self.allocator.free(old);
+ self.stream_error_message = null;
+ self.stream_error_message = if (kind != null and message != null)
+ try std.fmt.allocPrint(self.allocator, "{s}: {s}", .{ kind.?, message.? })
+ else if (kind) |k|
+ try self.allocator.dupe(u8, k)
+ else if (message) |m|
+ try self.allocator.dupe(u8, m)
+ else
+ null;
+ }
+
/// Close the active block: append it to `blocks` and emit block_complete.
fn closeBlock(
self: *StreamState,
@@ -576,8 +603,17 @@ fn handleEvent(
if (!@import("builtin").is_test) {
std.log.err("anthropic stream error: {?s}: {?s}", .{ e.kind, e.message });
}
- // Mid-stream error event (e.g. `overloaded_error`) before the
- // message was committed: retryable malformed-stream failure.
+ // Stash a readable description so the agent's retry notice can
+ // explain *why* the stream failed instead of only showing the
+ // bare error name. Owned by `state`; freed in `deinit`.
+ state.setStreamErrorMessage(e.kind, e.message) catch {};
+ // Mid-stream error event before the message was committed. Map
+ // the common overload case to a dedicated retryable error so the
+ // UI can say "overloaded" rather than "malformed stream"; other
+ // kinds stay as the generic retryable malformed-stream error.
+ if (e.kind) |k| {
+ if (std.mem.eql(u8, k, "overloaded_error")) return error.ProviderOverloaded;
+ }
return error.ProviderStreamMalformed;
},
.unknown => {
@@ -1073,7 +1109,34 @@ test "error event propagates as Zig error" {
&state,
&queue,
);
+ // `overloaded_error` maps to the dedicated retryable error, and the
+ // provider's diagnostic is stashed for the agent's retry notice.
+ try testing.expectError(error.ProviderOverloaded, result);
+ try testing.expectEqualStrings("overloaded_error: too busy", state.stream_error_message.?);
+}
+
+test "non-overloaded error event stays malformed and stashes message" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "hi");
+
+ var queue = EventQueue.init(allocator);
+ defer queue.deinit();
+
+ var state: StreamState = .init(allocator);
+ defer state.deinit();
+
+ const result = handleEvent(
+ allocator,
+ \\{"type":"error","error":{"type":"api_error","message":"boom"}}
+ ,
+ &state,
+ &queue,
+ );
try testing.expectError(error.ProviderStreamMalformed, result);
+ try testing.expectEqualStrings("api_error: boom", state.stream_error_message.?);
}
test "two streamed turns persist assistant replies in the conversation" {