From f83578fdc9264019a1a1cef8c5484a161167d3dd Mon Sep 17 00:00:00 2001 From: t Date: Tue, 7 Jul 2026 11:26:32 -0600 Subject: initial commit, moved libpanto over from the pantograph repo --- src/config.zig | 403 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 src/config.zig (limited to 'src/config.zig') diff --git a/src/config.zig b/src/config.zig new file mode 100644 index 0000000..f2f04e7 --- /dev/null +++ b/src/config.zig @@ -0,0 +1,403 @@ +//! Active configuration the agent consults on every turn. +//! +//! `ProviderConfig` is a tagged union keyed by `APIStyle`; each variant +//! carries the settings specific to one wire dialect. New providers add a +//! new tag and a new payload struct here; nothing else in libpanto needs a +//! central enum refresh. +//! +//! `Config` carries the active `ProviderConfig` (plus retry/compaction +//! policy). It is an **immutable snapshot**: the agent holds a `*const +//! Config` and re-reads it at the top of every turn, so swapping that +//! pointer (e.g. from a `panto.configure` hook) changes provider, model, +//! and base_url atomically at the next turn boundary. Because the snapshot +//! is read-only while a turn is in flight, concurrent tool workers reading +//! the old snapshot stay consistent. +//! +//! The tool set is **not** part of `Config` — it lives on the `Agent` +//! (`Agent.registerTool`/`registerToolSource`), so swapping provider/model +//! between turns no longer means rebuilding the tool list. + +const std = @import("std"); +const Io = std.Io; + +/// The wire dialect a provider speaks. +pub const APIStyle = enum { + openai_chat, + anthropic_messages, + /// OpenAI Responses API (`/responses`). Used by the ChatGPT-subscription + /// Codex backend, whose request/stream shape differs from Chat Completions. + openai_responses, + /// ChatGPT-subscription Codex Responses dialect. User config selects this + /// with `style = "openai_responses"` plus `dialect = "codex"`. + openai_codex_responses, +}; + +/// A single HTTP request header (name/value). Used for provider +/// `extra_headers` — caller-supplied headers merged onto a provider's +/// built-in request headers (e.g. GitHub Copilot's editor-identity headers, +/// or auth-derived headers from an OAuth exchange). Borrowed slices; valid +/// as long as the owning config is. +pub const Header = struct { + name: []const u8, + value: []const u8, +}; + +/// Reasoning intensity hint sent to providers that support it. +/// +/// `.default` omits the field entirely so the provider's own default applies. +/// `.off` sends `"none"` (supported by OpenRouter/NanoGPT; ignored or rejected +/// elsewhere). The remaining values mirror the `reasoning_effort` parameter +/// accepted by OpenAI reasoning models and OpenAI-compatible proxies. +pub const ReasoningEffort = enum { + default, + off, + minimal, + low, + medium, + high, +}; + +/// Anthropic extended-thinking mode. +pub const Thinking = enum { + /// Do not request extended thinking. + disabled, + /// Manual extended thinking with an explicit token budget (`thinking_budget_tokens`). + /// Supported on Haiku 4.5 and older models, but NOT on Opus 4.8+ (which + /// only accepts adaptive). Not safe as a cross-model default. + enabled, + /// Adaptive thinking: Claude decides when and how much to think. + /// Requires Opus 4.6 / Sonnet 4.6 or newer; automatic on Opus 4.7+. + adaptive, +}; + +/// Effort level sent with Anthropic adaptive thinking (`thinking = .adaptive`). +/// Ignored when `thinking` is `.enabled` or `.disabled`. +pub const Effort = enum { + low, + medium, + high, + xhigh, + max, +}; + +pub const OpenAIChatConfig = struct { + api_key: []const u8, + base_url: []const u8, + model: []const u8, + reasoning: ReasoningEffort = .default, + max_tokens: u32 = 64_000, + /// Caller-supplied request headers merged onto the built-in ones + /// (content-type/accept/authorization). Used for provider identity + /// headers and auth-exchange-derived headers. Empty by default. + /// Borrowed; valid as long as this config is. + extra_headers: []const Header = &.{}, +}; + +pub const AnthropicMessagesConfig = struct { + api_key: []const u8, + base_url: []const u8, + model: []const u8, + /// Value sent in the `anthropic-version` header. + api_version: []const u8 = "2023-06-01", + /// Required by Anthropic's Messages API. + max_tokens: u32 = 64_000, + /// Extended-thinking mode. `.enabled` sends a manual thinking block with + /// `thinking_budget_tokens`; `.adaptive` lets Claude decide and uses + /// `effort` instead. `.disabled` omits thinking entirely. + /// + /// Defaults to `.disabled`: it is the only mode accepted by every current + /// model. `.enabled` fails on Opus 4.8+ (adaptive-only) and `.adaptive` + /// fails on Haiku 4.5 (no adaptive support), so neither is a safe default + /// without parsing model strings. + thinking: Thinking = .disabled, + /// Effort level for adaptive thinking. Only emitted on the wire when + /// `thinking == .adaptive`; ignored otherwise. + effort: Effort = .medium, + /// Maximum tokens Claude may spend on internal reasoning when + /// `thinking == .enabled`. `null` falls back to `max_tokens - 1`. + /// Ignored when `thinking == .adaptive` or `.disabled`. + thinking_budget_tokens: ?u32 = 32_000, + /// When true and `thinking == .enabled`, sends the + /// `interleaved-thinking-2025-05-14` beta header so Claude can think + /// between tool calls. Ignored when `thinking == .adaptive` (interleaving + /// is automatic there) or `.disabled`. + thinking_interleaved: bool = false, + /// Use `Authorization: Bearer ...` instead of `x-api-key`. This is set + /// by the embedder from the configured auth family (not guessed from the + /// base URL): standard Anthropic uses `false`, while OAuth-backed + /// Anthropic-compatible providers such as Copilot set `true`. + use_bearer_auth: bool = false, + /// Place one `cache_control` breakpoint on the last cacheable block of + /// each request, replicating Anthropic's "automatic caching" (a single + /// advancing breakpoint) via the broadly-supported per-block marker + /// rather than the top-level field. + /// + /// Defaults on: for a normal append-only multi-turn session it's a + /// near-pure win (reads 0.1x base input vs. a one-time 1.25x write). + /// Set false when the workload makes that write premium unrecoverable + /// — e.g. one-shot requests, or an embedder that aggressively rewrites + /// history so prefixes are never reused. There the 1.25x write is pure + /// overhead with no read to amortize it. + prompt_cache: bool = true, + /// Caller-supplied request headers merged onto the built-in ones. See + /// `OpenAIChatConfig.extra_headers`. Empty by default. + extra_headers: []const Header = &.{}, +}; + +/// OpenAI Responses API config. Same transport knobs as `openai_chat`, but a +/// distinct request body (`input` items, `reasoning`, `store`, `include`) and +/// streaming event shape. Auth-derived headers (`chatgpt-account-id`) and the +/// static Codex identity headers (`OpenAI-Beta`, `originator`) ride on +/// `extra_headers`. +pub const OpenAIResponsesConfig = struct { + api_key: []const u8, + base_url: []const u8, + model: []const u8, + reasoning: ReasoningEffort = .default, + max_tokens: u32 = 64_000, + extra_headers: []const Header = &.{}, +}; + +/// Per-provider transport/auth/model configuration. Tagged by `APIStyle`. +pub const ProviderConfig = union(APIStyle) { + openai_chat: OpenAIChatConfig, + anthropic_messages: AnthropicMessagesConfig, + openai_responses: OpenAIResponsesConfig, + openai_codex_responses: OpenAIResponsesConfig, + + pub fn style(self: ProviderConfig) APIStyle { + return @as(APIStyle, self); + } + + /// The wire-format provider identity for this config: the ground-truth + /// `{api_style, base_url, model, reasoning}` that a turn is sent with. + /// Anthropic carries thinking/effort/budget/interleaved instead of + /// reasoning. Borrowed slices; valid as long as the config is. + pub fn wireIdentity(self: ProviderConfig) WireIdentity { + return switch (self) { + .openai_chat => |c| .{ + .api_style = .openai_chat, + .base_url = c.base_url, + .model = c.model, + .reasoning = c.reasoning, + }, + .anthropic_messages => |c| .{ + .api_style = .anthropic_messages, + .base_url = c.base_url, + .model = c.model, + .thinking = c.thinking, + .effort = c.effort, + .thinking_budget_tokens = c.thinking_budget_tokens, + .thinking_interleaved = c.thinking_interleaved, + }, + .openai_responses => |c| .{ + .api_style = .openai_responses, + .base_url = c.base_url, + .model = c.model, + .reasoning = c.reasoning, + }, + .openai_codex_responses => |c| .{ + .api_style = .openai_codex_responses, + .base_url = c.base_url, + .model = c.model, + .reasoning = c.reasoning, + }, + }; + } +}; + +/// Wire-format provider identity (see `ProviderConfig.wireIdentity`). This +/// is the same shape as `session_store.WireIdentity`; defined here to avoid +/// a module cycle (config must not import session_store). +/// +/// OpenAI uses `reasoning`; Anthropic uses `thinking`/`effort`/ +/// `thinking_budget_tokens`/`thinking_interleaved`. Fields unused by the +/// active provider carry their zero/default values. +pub const WireIdentity = struct { + api_style: APIStyle, + base_url: []const u8, + model: []const u8, + /// OpenAI only. + reasoning: ReasoningEffort = .default, + /// Anthropic only. + thinking: Thinking = .enabled, + /// Anthropic only; only meaningful when `thinking == .adaptive`. + effort: Effort = .medium, + /// Anthropic only; only meaningful when `thinking == .enabled`. + thinking_budget_tokens: ?u32 = 32_000, + /// Anthropic only; only meaningful when `thinking == .enabled`. + thinking_interleaved: bool = false, +}; + +/// Compaction settings the agent consults when summarizing old history. +/// +/// `keep_verbatim` is the budget (in tokens) governing how much recent +/// conversation is kept verbatim after compaction. The retention walk +/// accumulates whole turns backward and stops once the running total +/// exceeds this value, so `keep_verbatim` is an *upper bound* on the size +/// of the kept suffix (the turn that crosses the threshold is summarized, +/// not kept). +/// +/// `model`, when set, is the provider/model used to run the compaction +/// request itself. On failure (e.g. the compaction model rejects the +/// transcript for context length) the agent falls back to the active chat +/// model. When null, compaction uses the active chat model directly. +pub const CompactionConfig = struct { + keep_verbatim: u32 = 20_000, + model: ?ProviderConfig = null, + /// The compaction system prompt. Used both for automatic compaction on + /// context overflow and as the default for an explicit `Agent.compact` + /// call (which may override it per-call). Borrowed; set by the embedder + /// (e.g. resolved from its `COMPACTION.md` layers, or a built-in + /// default). When null, auto-compaction is disabled and a + /// context-overflow error propagates unchanged, and an explicit + /// `compact` with no override is a no-op error. + compaction_prompt: ?[]const u8 = null, +}; + +/// Policy for retrying transient provider/API failures. Conservative +/// defaults: four attempts (one initial + three retries) with exponential +/// backoff and jitter, capped at 10s per delay. +pub const RetryConfig = struct { + /// Total attempts including the first. `4` => initial try + up to 3 + /// retries. Must be >= 1. + max_attempts: usize = 4, + /// Base delay before the first retry, in milliseconds. + initial_delay_ms: u64 = 500, + /// Upper bound on any single backoff delay, in milliseconds. Also caps + /// a provider-supplied `Retry-After`. + max_delay_ms: u64 = 10_000, + /// Exponential growth factor applied per retry. + multiplier: f64 = 2.0, + /// When true, apply random jitter in `[0, computed_delay)` (full + /// jitter) to avoid thundering-herd retries. + jitter: bool = true, +}; + +/// An immutable snapshot of the provider/model the agent talks to, plus +/// retry and compaction policy. The agent holds a `*const Config` and +/// re-reads it each turn; replacing the pointer swaps the active +/// configuration at the next turn boundary. The tool set is owned by the +/// `Agent`, not the snapshot, so a swap never touches tools. +pub const Config = struct { + provider: ProviderConfig, + compaction: CompactionConfig = .{}, + retry: RetryConfig = .{}, + + pub fn style(self: Config) APIStyle { + return self.provider.style(); + } +}; + +// =========================================================================== +// Process-global HTTP client +// =========================================================================== +// +// `std.http.Client`'s connection pool is mutex-guarded and keyed by host, +// so a single client safely multiplexes every provider/base_url the agent +// ever switches to, across concurrent turns. We keep exactly one for the +// whole process: switching `base_url` simply leaves the old host's idle +// connections to time out (and reuses them if the user switches back). +// +// Embedders must call `initHttp` once before any turn and `deinitHttp` +// once at shutdown. + +var global_http: ?std.http.Client = null; + +/// Initialize the process-global HTTP client. Call once from the embedder's +/// `main()` before driving any agent turns. Idempotent: a second call with +/// an already-initialized client is a no-op. +pub fn initHttp(allocator: std.mem.Allocator, io: Io) void { + if (global_http != null) return; + global_http = .{ .allocator = allocator, .io = io }; +} + +/// Tear down the process-global HTTP client. Call once at shutdown, after +/// all turns have completed. +pub fn deinitHttp() void { + if (global_http) |*c| { + c.deinit(); + global_http = null; + } +} + +/// Borrow the process-global HTTP client. Asserts `initHttp` has run. +pub fn httpClient() *std.http.Client { + return &(global_http orelse @panic("libpanto: httpClient() called before initHttp()")); +} + +const t = std.testing; + +test "ProviderConfig - openai_chat variant" { + const cfg: ProviderConfig = .{ .openai_chat = .{ + .api_key = "sk-test", + .base_url = "https://api.openai.com/v1", + .model = "gpt-4o", + .reasoning = .high, + } }; + try t.expectEqual(APIStyle.openai_chat, cfg.style()); + try t.expectEqualStrings("sk-test", cfg.openai_chat.api_key); + try t.expectEqual(ReasoningEffort.high, cfg.openai_chat.reasoning); +} + +test "ProviderConfig - anthropic_messages variant" { + const cfg: ProviderConfig = .{ .anthropic_messages = .{ + .api_key = "sk-ant-test", + .base_url = "https://api.anthropic.com", + .model = "claude-sonnet-4-20250514", + } }; + try t.expectEqual(APIStyle.anthropic_messages, cfg.style()); + try t.expectEqualStrings("2023-06-01", cfg.anthropic_messages.api_version); + try t.expectEqual(@as(u32, 64_000), cfg.anthropic_messages.max_tokens); + try t.expectEqual(false, cfg.anthropic_messages.use_bearer_auth); + try t.expectEqual(Thinking.disabled, cfg.anthropic_messages.thinking); + try t.expectEqual(Effort.medium, cfg.anthropic_messages.effort); + try t.expectEqual(@as(?u32, 32_000), cfg.anthropic_messages.thinking_budget_tokens); + try t.expectEqual(false, cfg.anthropic_messages.thinking_interleaved); +} + +test "ProviderConfig - anthropic_messages wireIdentity carries thinking fields" { + const cfg: ProviderConfig = .{ .anthropic_messages = .{ + .api_key = "k", + .base_url = "https://api.anthropic.com", + .model = "claude-opus-4-8", + .thinking = .adaptive, + .effort = .high, + .thinking_budget_tokens = null, + .thinking_interleaved = false, + } }; + const id = cfg.wireIdentity(); + try t.expectEqual(APIStyle.anthropic_messages, id.api_style); + try t.expectEqual(Thinking.adaptive, id.thinking); + try t.expectEqual(Effort.high, id.effort); + try t.expectEqual(@as(?u32, null), id.thinking_budget_tokens); + try t.expectEqual(false, id.thinking_interleaved); + // reasoning field carries its zero default; not meaningful for Anthropic + try t.expectEqual(ReasoningEffort.default, id.reasoning); +} + +test "ProviderConfig - openai_chat reasoning defaults to .default" { + const cfg: ProviderConfig = .{ .openai_chat = .{ + .api_key = "k", + .base_url = "u", + .model = "m", + } }; + try t.expectEqual(ReasoningEffort.default, cfg.openai_chat.reasoning); +} + +test "Config carries provider and forwards style" { + const cfg: Config = .{ + .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } }, + }; + try t.expectEqual(APIStyle.openai_chat, cfg.style()); +} + +test "global http client: init/borrow/deinit" { + var threaded: std.Io.Threaded = .init(t.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + initHttp(t.allocator, io); + defer deinitHttp(); + initHttp(t.allocator, io); // idempotent + _ = httpClient(); +} -- cgit v1.3