const std = @import("std"); const panto = @import("panto"); const ping_tool = @import("ping_tool.zig"); const lua_bridge = @import("lua_bridge.zig"); const lua_tool = @import("lua_tool.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. const lua = lua_bridge.c; test { std.testing.refAllDecls(@This()); _ = lua_bridge; _ = lua_tool; } const Receiver = panto.provider.Receiver; const ReceiverVTable = panto.provider.ReceiverVTable; const ContentBlockType = panto.provider.ContentBlockType; const BlockMeta = panto.provider.BlockMeta; const MessageRole = panto.conversation.MessageRole; /// Receiver that prints streaming deltas to stdout. Thinking blocks are /// dimmed with ANSI escape codes; text blocks render plain. const CLIReceiver = struct { stdout: *std.Io.Writer, file: *std.Io.File.Writer, pub fn receiver(self: *CLIReceiver) Receiver { return .{ .ptr = self, .vtable = &vtable }; } const vtable: ReceiverVTable = .{ .onMessageStart = onMessageStart, .onBlockStart = onBlockStart, .onContentDelta = onContentDelta, .onBlockComplete = onBlockComplete, .onMessageComplete = onMessageComplete, .onError = onError, }; fn onMessageStart(ptr: *anyopaque, role: MessageRole) anyerror!void { _ = ptr; _ = role; } fn onBlockStart( ptr: *anyopaque, block_type: ContentBlockType, index: usize, meta: ?BlockMeta, ) anyerror!void { _ = index; const self: *CLIReceiver = @ptrCast(@alignCast(ptr)); switch (block_type) { .Thinking => try self.stdout.writeAll("\x1b[2m[thinking] "), .ToolUse => { const name = if (meta) |m| (m.tool_name orelse "?") else "?"; try self.stdout.print("\n\x1b[36m[tool: {s}]\x1b[0m ", .{name}); }, else => {}, } try self.file.flush(); } fn onContentDelta(ptr: *anyopaque, index: usize, delta: []const u8) anyerror!void { _ = index; const self: *CLIReceiver = @ptrCast(@alignCast(ptr)); try self.stdout.writeAll(delta); try self.file.flush(); } fn onBlockComplete( ptr: *anyopaque, index: usize, block: panto.conversation.ContentBlock, ) anyerror!void { _ = index; const self: *CLIReceiver = @ptrCast(@alignCast(ptr)); switch (block) { .Thinking => try self.stdout.writeAll("\x1b[0m\n"), .ToolUse => try self.stdout.writeAll("\n"), else => {}, } try self.file.flush(); } fn onMessageComplete(ptr: *anyopaque, message: panto.conversation.Message) anyerror!void { _ = message; const self: *CLIReceiver = @ptrCast(@alignCast(ptr)); try self.stdout.writeAll("\n"); try self.file.flush(); } /// Reset any in-progress display state after a failed turn. Errors here /// must be swallowed — we're already in the error path. fn onError(ptr: *anyopaque, err: anyerror) void { _ = &err; const self: *CLIReceiver = @ptrCast(@alignCast(ptr)); // If we were rendering a Thinking block, clear the dim style so // subsequent output (including the "[error: ...]" line) is readable. self.stdout.writeAll("\x1b[0m") catch {}; self.file.flush() catch {}; } }; /// Spin up a Lua interpreter, run a no-op, tear it down. Catches /// link/compile errors on the Lua dependency at the earliest moment. /// Logs only in debug builds; release builds run silently. fn luaSmokeCheck() void { const L = lua.luaL_newstate() orelse { std.log.err("lua: luaL_newstate returned null", .{}); return; }; defer lua.lua_close(L); lua.luaL_openlibs(L); // Push _VERSION to confirm libs loaded. _ = lua.lua_getglobal(L, "_VERSION"); const ver = lua.lua_tolstring(L, -1, null); if (ver != null) { std.log.debug("lua: {s} linked OK", .{ver}); } 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; }; 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, } }; }, } } pub fn main(init: std.process.Init) !void { const alloc = init.gpa; const io = init.io; // Smoke test: prove Lua is linked. Slice 1 — no extension runtime // wired up yet, this just confirms the static lib comes through. luaSmokeCheck(); const config = try loadConfig(init.environ_map); var stdout_buffer: [4096]u8 = undefined; var stdout_file = std.Io.File.stdout().writer(io, &stdout_buffer); const stdout = &stdout_file.interface; var stdin_buffer: [4096]u8 = undefined; var stdin_file = std.Io.File.stdin().reader(io, &stdin_buffer); const stdin = &stdin_file.interface; var conv = panto.conversation.Conversation.init(alloc); defer conv.deinit(); try conv.addSystemMessage("You are a helpful assistant."); const prov = try panto.provider.Provider.init(alloc, io, config); var agent = panto.agent.Agent.init(alloc, prov); defer agent.deinit(); // smoke test: register a trivial built-in tool so we can exercise // the tool-call loop against a real LLM. try agent.registerTool(ping_tool.tool()); // Load any Lua extensions specified via `--lua ` flags. This is a // slice-2 manual hook — slice 3 will replace it with directory discovery. const argv = try init.minimal.args.toSlice(init.arena.allocator()); var i: usize = 1; while (i < argv.len) : (i += 1) { const a = argv[i]; if (std.mem.eql(u8, a, "--lua")) { i += 1; if (i >= argv.len) { std.log.err("--lua requires a path argument", .{}); return error.MissingLuaPath; } const path = argv[i]; const n = lua_tool.loadExtension(alloc, &agent.registry, path) catch |err| { std.log.err("failed to load Lua extension {s}: {t}", .{ path, err }); return err; }; std.log.debug("lua: loaded {d} tool(s) from {s}", .{ n, path }); } } const banner_model: []const u8 = switch (config) { inline else => |c| c.model, }; const banner_base: []const u8 = switch (config) { inline else => |c| c.base_url, }; try stdout.print("panto — {s}: {s} @ {s}\n", .{ @tagName(config), banner_model, banner_base }); try stdout.print("> ", .{}); try stdout_file.flush(); var cli_recv = CLIReceiver{ .stdout = stdout, .file = &stdout_file }; var recv = cli_recv.receiver(); while (true) { const maybe_line = stdin.takeDelimiter('\n') catch |err| { std.debug.print("read error: {}\n", .{err}); return; }; const line = maybe_line orelse { try stdout.writeAll("\n"); try stdout_file.flush(); break; }; if (line.len == 0) { try stdout.writeAll("\n> "); try stdout_file.flush(); continue; } try conv.addUserMessage(line); agent.runStep(&conv, &recv) catch |err| { try stdout.print("\n[error: {s}]\n", .{@errorName(err)}); }; try stdout.writeAll("\n> "); try stdout_file.flush(); } }