summaryrefslogtreecommitdiff
path: root/src/main.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-01 08:21:00 -0600
committert <t@tjp.lol>2026-06-01 11:20:56 -0600
commit1beefefc69beee214430eb5bd2528a4f5692d2a8 (patch)
treea459dbde5da17babe7d872999341d2006ebfe02e /src/main.zig
parent5c016cfdbcb122e2b4f0496ef946642d68953c4b (diff)
Rename system extension layer to base for clarity
Replace all references to the "system" layer with "base" to better reflect its role as the foundational extension/tool layer in panto's hierarchy. The layer hierarchy now consistently uses: project > user > base. Includes: - Update agent README and build.zig documentation - Refactor tool registry and config module to support layered lookups - Add TestHarness abstraction for cleaner test setup - Improve JSON serialization with wire-encoded tool names - Add glob pattern matching for tool/extension discovery
Diffstat (limited to 'src/main.zig')
-rw-r--r--src/main.zig196
1 files changed, 113 insertions, 83 deletions
diff --git a/src/main.zig b/src/main.zig
index bdb5390..aaf86b5 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -9,6 +9,8 @@ const self_exe = @import("self_exe.zig");
const subcommand = @import("subcommand.zig");
const session_paths = @import("session_paths.zig");
const models_toml = @import("models_toml.zig");
+const config_file = @import("config_file.zig");
+const glob = @import("glob.zig");
// Shorthand alias for the Lua C API. The bridge module owns the actual
// `@cImport`; we re-use it here so the smoke check uses identical types.
@@ -33,6 +35,8 @@ test {
_ = self_exe;
_ = subcommand;
_ = models_toml;
+ _ = config_file;
+ _ = glob;
}
const Receiver = panto.provider.Receiver;
@@ -195,74 +199,58 @@ fn luaSmokeCheck() void {
lua.lua_settop(L, 0);
}
-fn loadConfig(environ_map: *const std.process.Environ.Map) !panto.config.Config {
- const style_str = environ_map.get("PANTO_API_STYLE") orelse "openai_chat";
- const style = std.meta.stringToEnum(panto.config.APIStyle, style_str) orelse {
- std.debug.print(
- "error: PANTO_API_STYLE must be one of: openai_chat, anthropic_messages (got: {s})\n",
- .{style_str},
- );
- return error.InvalidApiStyle;
- };
+/// Print a friendly message for a config.toml load failure before the
+/// process exits non-zero.
+fn reportConfigError(err: anyerror) void {
+ switch (err) {
+ error.InvalidConfigToml => std.debug.print(
+ "error: a config.toml failed to parse (see log above).\n",
+ .{},
+ ),
+ error.InvalidProvider => std.debug.print(
+ "error: a [providers.*] entry is malformed (need style + base_url).\n",
+ .{},
+ ),
+ error.InvalidModelRef => std.debug.print(
+ "error: defaults.model must look like \"provider:model\".\n",
+ .{},
+ ),
+ error.UnknownDefaultProvider => std.debug.print(
+ "error: defaults.model names a provider that isn't configured (or whose API key env var isn't set).\n",
+ .{},
+ ),
+ error.PolicyConflict => std.debug.print(
+ "error: a tool/extension pattern appears in both allow and deny.\n",
+ .{},
+ ),
+ error.NoHomeDirectory => std.debug.print(
+ "error: cannot resolve config paths — set HOME or XDG_CONFIG_HOME/XDG_DATA_HOME.\n",
+ .{},
+ ),
+ else => std.debug.print("error: failed to load config: {s}\n", .{@errorName(err)}),
+ }
+}
- switch (style) {
- .openai_chat => {
- const api_key = environ_map.get("OPENAI_API_KEY") orelse {
- std.debug.print("error: OPENAI_API_KEY is required\n", .{});
- return error.MissingApiKey;
- };
- const base_url = environ_map.get("OPENAI_BASE_URL") orelse "https://api.openai.com/v1";
- const model = environ_map.get("OPENAI_MODEL") orelse {
- std.debug.print("error: OPENAI_MODEL is required\n", .{});
- return error.MissingModel;
- };
- const reasoning: panto.config.ReasoningEffort =
- if (environ_map.get("OPENAI_REASONING")) |val|
- std.meta.stringToEnum(panto.config.ReasoningEffort, val) orelse {
- std.debug.print(
- "error: OPENAI_REASONING must be one of: default, off, minimal, low, medium, high (got: {s})\n",
- .{val},
- );
- return error.InvalidReasoning;
- }
- else
- .default;
- return .{ .openai_chat = .{
- .api_key = api_key,
- .base_url = base_url,
- .model = model,
- .reasoning = reasoning,
- } };
- },
- .anthropic_messages => {
- const api_key = environ_map.get("ANTHROPIC_API_KEY") orelse {
- std.debug.print("error: ANTHROPIC_API_KEY is required\n", .{});
- return error.MissingApiKey;
- };
- const base_url = environ_map.get("ANTHROPIC_BASE_URL") orelse "https://api.anthropic.com";
- const model = environ_map.get("ANTHROPIC_MODEL") orelse {
- std.debug.print("error: ANTHROPIC_MODEL is required\n", .{});
- return error.MissingModel;
- };
- const api_version = environ_map.get("ANTHROPIC_API_VERSION") orelse "2023-06-01";
- const max_tokens: u32 = if (environ_map.get("ANTHROPIC_MAX_TOKENS")) |val|
- std.fmt.parseInt(u32, val, 10) catch {
- std.debug.print(
- "error: ANTHROPIC_MAX_TOKENS must be a positive integer (got: {s})\n",
- .{val},
- );
- return error.InvalidMaxTokens;
- }
- else
- 4096;
- return .{ .anthropic_messages = .{
- .api_key = api_key,
- .base_url = base_url,
- .model = model,
- .api_version = api_version,
- .max_tokens = max_tokens,
- } };
+/// Print a friendly message for a model-selection failure.
+fn reportModelError(err: anyerror, cfg: *const config_file.Config) void {
+ switch (err) {
+ error.NoModelSelected => {
+ std.debug.print(
+ "error: no model selected. Set `defaults.model = \"provider:alias\"` in config.toml.\n",
+ .{},
+ );
+ if (cfg.providers.len == 0) {
+ std.debug.print(
+ " (no providers resolved — check that an API key or its env var is present.)\n",
+ .{},
+ );
+ }
},
+ error.UnknownProvider => std.debug.print(
+ "error: the selected model names an unconfigured provider.\n",
+ .{},
+ ),
+ else => std.debug.print("error: model selection failed: {s}\n", .{@errorName(err)}),
}
}
@@ -294,8 +282,6 @@ pub fn main(init: std.process.Init) !void {
.agent => {},
}
- const config = try loadConfig(init.environ_map);
-
// Parse the agent-mode flags. Currently only `--resume [<id>]`.
const cli_flags = try parseAgentFlags(alloc, init.minimal.args);
defer cli_flags.deinit(alloc);
@@ -315,19 +301,47 @@ pub fn main(init: std.process.Init) !void {
const session_dir = try session_paths.sessionDirForCwd(alloc, init.environ_map, cwd);
defer alloc.free(session_dir);
- // Load the user's models.toml — a missing file is fine (empty
- // registry). Cost lookups against an empty registry return null,
- // and the display layer will format that as "unknown."
+ // Load the merged config.toml (base → user → project). Providers,
+ // default model, and tool/extension policy all come from here.
+ var app_config = config_file.load(alloc, io, init.environ_map, cwd) catch |err| {
+ reportConfigError(err);
+ std.process.exit(1);
+ };
+ defer app_config.deinit();
+
+ // Load models.toml — model aliases (wire name + knobs) and pricing.
+ // A missing file is fine (empty registries); cost lookups return
+ // null, formatted as "unknown" by the display layer.
const models_toml_path = try models_toml.configPath(alloc, init.environ_map);
defer alloc.free(models_toml_path);
- var pricing_registry = try models_toml.loadFromPath(alloc, io, models_toml_path);
- defer pricing_registry.deinit();
- std.log.debug("models.toml: {d} entries from {s}", .{ pricing_registry.count(), models_toml_path });
+ var models = try models_toml.loadFromPath(alloc, io, models_toml_path);
+ defer models.deinit();
+ std.log.debug(
+ "models.toml: {d} model def(s), {d} price entr(ies) from {s}",
+ .{ models.defs.count(), models.pricing.count(), models_toml_path },
+ );
+ // `models.pricing` is parsed and ready; the print-based CLI doesn't
+ // surface per-turn cost yet (that lands with the TUI frontend).
- const banner_model_initial: []const u8 = switch (config) {
+ // Choose the active model and assemble the provider config.
+ const model_ref = app_config.selectModel(&models.defs, null) catch |err| {
+ reportModelError(err, &app_config);
+ std.process.exit(1);
+ };
+ const provider_config = config_file.buildProviderConfig(&app_config, &models.defs, model_ref) catch |err| {
+ reportModelError(err, &app_config);
+ std.process.exit(1);
+ };
+
+ // Process-global HTTP client: one connection pool for every provider /
+ // base_url the agent may switch to. Torn down at shutdown.
+ panto.config.initHttp(alloc, io);
+ defer panto.config.deinitHttp();
+
+ const banner_model_initial: []const u8 = switch (provider_config) {
inline else => |c| c.model,
};
- const banner_provider_initial: []const u8 = @tagName(config);
+ const banner_provider_initial: []const u8 = model_ref.provider;
// Create or resume the session. Resume failures (missing/ambiguous id)
// are user errors — print a tidy message and exit 1 rather than
@@ -365,9 +379,11 @@ pub fn main(init: std.process.Init) !void {
try appendSystemToSession(alloc, &session_mgr, system_text);
}
- const prov = try panto.provider.Provider.init(alloc, io, config);
- var agent = panto.agent.Agent.init(alloc, io, prov);
- defer agent.deinit();
+ // The tool registry is owned here and referenced by the active config
+ // snapshot. Extensions register their tool source into it *before* the
+ // snapshot is built, so the agent's first turn sees them.
+ var registry = panto.ToolRegistry.init(alloc);
+ defer registry.deinit();
// Spin up the long-lived Lua runtime. All Lua extensions load into
// one `lua_State`; module-global state survives across calls. The
@@ -394,10 +410,10 @@ pub fn main(init: std.process.Init) !void {
// chance to register tools that might want to yield.
try rt.installScheduler();
- // Discover Lua extensions across three layers — system
+ // Discover Lua extensions across three layers — base
// ($PANTO_HOME/agent), user ($XDG_CONFIG_HOME/panto or
// $HOME/.config/panto), and project (./.panto). Project shadows
- // user shadows system; tool-name collisions across surviving
+ // user shadows base; tool-name collisions across surviving
// entries abort startup.
const n_ext_tools = extension_loader.discoverAndLoad(
alloc,
@@ -405,6 +421,10 @@ pub fn main(init: std.process.Init) !void {
init.environ_map,
luarocks_rt.layout.agent_dir,
rt,
+ .{
+ .extensions = &app_config.extensions,
+ .tools = &app_config.tools,
+ },
) catch |err| {
std.log.err("extension discovery failed: {t}", .{err});
return err;
@@ -412,10 +432,20 @@ pub fn main(init: std.process.Init) !void {
std.log.debug("extensions: {d} tool(s) registered", .{n_ext_tools});
if (n_ext_tools > 0) {
- try agent.registerToolSource(rt.toolSource());
+ try registry.registerSource(rt.toolSource());
}
- const banner_base: []const u8 = switch (config) {
+ // Assemble the active configuration snapshot (provider + tool registry)
+ // and create the agent holding a pointer to it. Swapping this pointer
+ // later (model/provider/tool changes) takes effect at the next turn.
+ const active_config: panto.config.Config = .{
+ .provider = provider_config,
+ .registry = &registry,
+ };
+ var agent = panto.agent.Agent.init(alloc, io, &active_config);
+ defer agent.deinit();
+
+ const banner_base: []const u8 = switch (provider_config) {
inline else => |c| c.base_url,
};
try stdout.print(