const std = @import("std"); const config_mod = @import("config.zig"); const conversation = @import("conversation.zig"); const tool_registry_mod = @import("tool_registry.zig"); const session_mod = @import("session.zig"); pub const ToolRegistry = tool_registry_mod.ToolRegistry; pub const Usage = session_mod.Usage; pub const ContentBlockType = enum { Text, Thinking, ToolUse, ToolResult, }; /// Heuristic detector for provider context-overflow rejections, applied to /// an HTTP 400 error response body. Both OpenAI-compatible and Anthropic /// APIs reject oversized requests on the input side with HTTP 400 and a /// recognizable message; matching it lets the agent compact and retry /// instead of surfacing a hard error. /// /// Markers (case-sensitive substrings, as the wire emits them): /// - OpenAI: `context_length_exceeded`, `maximum context length` /// - Anthropic: `prompt is too long` pub fn isContextOverflowBody(body: []const u8) bool { const markers = [_][]const u8{ "context_length_exceeded", "maximum context length", "prompt is too long", "context window", }; for (markers) |m| { if (std.mem.indexOf(u8, body, m) != null) return true; } return false; } /// Vtable for receiving streaming events from a Provider. /// /// The lifecycle callbacks (`onMessageStart` ... `onMessageComplete`) return /// `anyerror!void`. Returning an error aborts the in-flight turn: the Provider /// stops streaming, calls `onError(err)`, and propagates `err` out of /// `streamStep`. No partial assistant message is appended to the conversation. /// /// Tool-use identity (`id`, `name`) is delivered via `onToolDetails`, fired /// once per ToolUse block at the earliest moment both fields are known. For /// Anthropic this is immediately after `onBlockStart`, before any deltas. For /// OpenAI Chat Completions this may be partway through the arg-deltas, since /// the wire protocol can split `id` and `name` across multiple streaming /// chunks. The only guarantees are: it fires strictly after the block's /// `onBlockStart`, strictly before its `onBlockComplete`, and at most once /// per ToolUse block. It never fires for non-ToolUse blocks. If a tool_use /// block is dropped because identity never fully arrived, `onToolDetails` /// (and `onBlockComplete`) never fire for it. /// /// `onError` is the receiver's cleanup hook. It fires exactly once per failed /// turn, whether the error originated in the receiver itself (a write failure) /// or in the Provider (HTTP/parse/stream failure). It is the last callback the /// receiver will see for that turn. `onError` itself cannot fail; receivers /// must swallow secondary failures during cleanup. /// /// `onMessageComplete`'s `usage` argument carries the wire-reported token /// counts for the just-finished assistant turn. Providers fire this exactly /// once per successful turn. `usage` is `null` only when the wire genuinely /// did not deliver any usage information — chiefly OpenAI-compatible proxies /// (OpenRouter, vLLM, some self-hosted backends) that ignore /// `stream_options.include_usage`. Receivers that compute cost should record /// the null case explicitly ("unknown") rather than treating it as zero. pub const ReceiverVTable = struct { onMessageStart: *const fn (*anyopaque, conversation.MessageRole) anyerror!void, onBlockStart: *const fn (*anyopaque, ContentBlockType, usize) anyerror!void, onToolDetails: *const fn (*anyopaque, usize, []const u8, []const u8) anyerror!void, onContentDelta: *const fn (*anyopaque, usize, []const u8) anyerror!void, onBlockComplete: *const fn (*anyopaque, usize, conversation.ContentBlock) anyerror!void, onMessageComplete: *const fn (*anyopaque, conversation.Message, ?Usage) anyerror!void, onError: *const fn (*anyopaque, anyerror) void, }; pub const Receiver = struct { ptr: *anyopaque, vtable: *const ReceiverVTable, pub fn onMessageStart(self: Receiver, role: conversation.MessageRole) !void { try self.vtable.onMessageStart(self.ptr, role); } pub fn onBlockStart(self: Receiver, block_type: ContentBlockType, index: usize) !void { try self.vtable.onBlockStart(self.ptr, block_type, index); } pub fn onToolDetails(self: Receiver, block_index: usize, id: []const u8, name: []const u8) !void { try self.vtable.onToolDetails(self.ptr, block_index, id, name); } pub fn onContentDelta(self: Receiver, block_index: usize, delta: []const u8) !void { try self.vtable.onContentDelta(self.ptr, block_index, delta); } pub fn onBlockComplete(self: Receiver, block_index: usize, block: conversation.ContentBlock) !void { try self.vtable.onBlockComplete(self.ptr, block_index, block); } pub fn onMessageComplete(self: Receiver, message: conversation.Message, usage: ?Usage) !void { try self.vtable.onMessageComplete(self.ptr, message, usage); } pub fn onError(self: Receiver, err: anyerror) void { self.vtable.onError(self.ptr, err); } }; /// Drive one streaming provider turn against the active config snapshot. /// /// This is the single dispatch point: it switches on `cfg.provider`'s /// `APIStyle` tag, builds a transient per-request object bound to the /// process-global HTTP client, and runs it. There is no persistent /// provider object — every turn re-reads `cfg`, so swapping the agent's /// `*const Config` between turns changes provider, model, base_url, and /// the visible tool set with no transport teardown. /// /// The tool registry is taken from `cfg.registry`; the serializers receive /// it directly. pub fn streamStep( allocator: std.mem.Allocator, io: std.Io, cfg: *const config_mod.Config, conv: *conversation.Conversation, receiver: *Receiver, ) anyerror!void { // Imported lazily to break the circular module graph: // provider.zig <- provider_openai_chat.zig <- provider.zig. const provider_openai_chat = @import("provider_openai_chat.zig"); const provider_anthropic_messages = @import("provider_anthropic_messages.zig"); const client = config_mod.httpClient(); switch (cfg.provider) { .openai_chat => |*c| { var req: provider_openai_chat.OpenAIChatRequest = .{ .allocator = allocator, .io = io, .config = c, .http_client = client, }; return req.streamStep(conv, cfg.registry, receiver); }, .anthropic_messages => |*c| { var req: provider_anthropic_messages.AnthropicMessagesRequest = .{ .allocator = allocator, .io = io, .config = c, .http_client = client, }; return req.streamStep(conv, cfg.registry, receiver); }, } } /// The shape of `streamStep`, exposed as a function-pointer type so the /// agent can carry an injectable seam (real dispatch in production, a stub /// in tests) without resurrecting a per-provider vtable. pub const StreamFn = *const fn ( allocator: std.mem.Allocator, io: std.Io, cfg: *const config_mod.Config, conv: *conversation.Conversation, receiver: *Receiver, ) anyerror!void; test "isContextOverflowBody - matches known markers, rejects others" { const t2 = std.testing; try t2.expect(isContextOverflowBody("{\"error\":{\"code\":\"context_length_exceeded\"}}")); try t2.expect(isContextOverflowBody("This model's maximum context length is 8192 tokens")); try t2.expect(isContextOverflowBody("prompt is too long: 250000 tokens > 200000 maximum")); try t2.expect(!isContextOverflowBody("{\"error\":{\"code\":\"invalid_api_key\"}}")); try t2.expect(!isContextOverflowBody("rate limit exceeded")); }