//! 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, }; /// 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, }; 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, /// 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, }; /// Per-provider transport/auth/model configuration. Tagged by `APIStyle`. pub const ProviderConfig = union(APIStyle) { openai_chat: OpenAIChatConfig, anthropic_messages: AnthropicMessagesConfig, 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, }, }; } }; /// 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(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(); }