summaryrefslogtreecommitdiff
path: root/libpanto/src/config.zig
blob: eca81ff4c8053e74ed464e98b85fe7a7f62b70e0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
//! 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` bundles the active `ProviderConfig` together with the
//! `ToolRegistry` the agent should expose this turn. 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, base_url, and/or the visible tool set
//! 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.

const std = @import("std");
const Io = std.Io;
const tool_registry_mod = @import("tool_registry.zig");

pub const ToolRegistry = tool_registry_mod.ToolRegistry;

/// 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,
};

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,
};

/// 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);
    }
};

/// 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,
};

/// 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 everything the agent consults per turn: which
/// provider/model to talk to, and which tools to expose. The agent holds a
/// `*const Config` and re-reads it each turn; replacing the pointer swaps
/// the active configuration wholesale at the next turn boundary.
///
/// `registry` is borrowed, not owned — its lifetime is managed by whoever
/// built the snapshot (typically the embedder). A `Config` may be copied
/// freely; copies share the same borrowed registry.
pub const Config = struct {
    provider: ProviderConfig,
    registry: *const ToolRegistry,
    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);
}

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 bundles provider + registry and forwards style" {
    var reg = ToolRegistry.init(t.allocator);
    defer reg.deinit();
    const cfg: Config = .{
        .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } },
        .registry = &reg,
    };
    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();
}