mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 17:46:32 -04:00
273 lines
9.4 KiB
Zig
273 lines
9.4 KiB
Zig
const std = @import("std");
|
|
const zenai = @import("zenai");
|
|
const lp = @import("lightpanda");
|
|
|
|
const log = lp.log;
|
|
const Config = lp.Config;
|
|
const App = @import("../App.zig");
|
|
const ToolExecutor = @import("ToolExecutor.zig");
|
|
const Terminal = @import("Terminal.zig");
|
|
const Command = @import("Command.zig");
|
|
const CommandExecutor = @import("CommandExecutor.zig");
|
|
|
|
const Self = @This();
|
|
|
|
const default_system_prompt =
|
|
\\You are a web browsing assistant powered by the Lightpanda browser.
|
|
\\You can navigate to websites, read their content, interact with forms,
|
|
\\click links, and extract information.
|
|
\\
|
|
\\When helping the user, navigate to relevant pages and extract information.
|
|
\\Use the semantic_tree or interactiveElements tools to understand page structure
|
|
\\before clicking or filling forms. Be concise in your responses.
|
|
;
|
|
|
|
allocator: std.mem.Allocator,
|
|
ai_client: AiClient,
|
|
tool_executor: *ToolExecutor,
|
|
terminal: Terminal,
|
|
cmd_executor: CommandExecutor,
|
|
messages: std.ArrayListUnmanaged(zenai.provider.Message),
|
|
message_arena: std.heap.ArenaAllocator,
|
|
tools: []const zenai.provider.Tool,
|
|
model: []const u8,
|
|
system_prompt: []const u8,
|
|
|
|
const AiClient = union(Config.AiProvider) {
|
|
anthropic: *zenai.anthropic.Client,
|
|
openai: *zenai.openai.Client,
|
|
gemini: *zenai.gemini.Client,
|
|
|
|
fn toProvider(self: AiClient) zenai.provider.Client {
|
|
return switch (self) {
|
|
.anthropic => |c| .{ .anthropic = c },
|
|
.openai => |c| .{ .openai = c },
|
|
.gemini => |c| .{ .gemini = c },
|
|
};
|
|
}
|
|
};
|
|
|
|
pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self {
|
|
const api_key = opts.api_key orelse getEnvApiKey(opts.provider) orelse {
|
|
log.fatal(.app, "missing API key", .{
|
|
.hint = "Set the API key via --api-key or environment variable",
|
|
});
|
|
return error.MissingApiKey;
|
|
};
|
|
|
|
const tool_executor = try ToolExecutor.init(allocator, app);
|
|
errdefer tool_executor.deinit();
|
|
|
|
const self = try allocator.create(Self);
|
|
errdefer allocator.destroy(self);
|
|
|
|
const ai_client: AiClient = switch (opts.provider) {
|
|
.anthropic => blk: {
|
|
const client = try allocator.create(zenai.anthropic.Client);
|
|
client.* = zenai.anthropic.Client.init(allocator, api_key, .{});
|
|
break :blk .{ .anthropic = client };
|
|
},
|
|
.openai => blk: {
|
|
const client = try allocator.create(zenai.openai.Client);
|
|
client.* = zenai.openai.Client.init(allocator, api_key, .{});
|
|
break :blk .{ .openai = client };
|
|
},
|
|
.gemini => blk: {
|
|
const client = try allocator.create(zenai.gemini.Client);
|
|
client.* = zenai.gemini.Client.init(allocator, api_key, .{});
|
|
break :blk .{ .gemini = client };
|
|
},
|
|
};
|
|
|
|
const tools = tool_executor.getTools() catch {
|
|
log.fatal(.app, "failed to initialize tools", .{});
|
|
return error.ToolInitFailed;
|
|
};
|
|
|
|
self.* = .{
|
|
.allocator = allocator,
|
|
.ai_client = ai_client,
|
|
.tool_executor = tool_executor,
|
|
.terminal = Terminal.init(null),
|
|
.cmd_executor = undefined,
|
|
.messages = .empty,
|
|
.message_arena = std.heap.ArenaAllocator.init(allocator),
|
|
.tools = tools,
|
|
.model = opts.model orelse defaultModel(opts.provider),
|
|
.system_prompt = opts.system_prompt orelse default_system_prompt,
|
|
};
|
|
|
|
self.cmd_executor = CommandExecutor.init(allocator, tool_executor, &self.terminal);
|
|
|
|
return self;
|
|
}
|
|
|
|
pub fn deinit(self: *Self) void {
|
|
self.message_arena.deinit();
|
|
self.messages.deinit(self.allocator);
|
|
self.tool_executor.deinit();
|
|
switch (self.ai_client) {
|
|
inline else => |c| {
|
|
c.deinit();
|
|
self.allocator.destroy(c);
|
|
},
|
|
}
|
|
self.allocator.destroy(self);
|
|
}
|
|
|
|
pub fn run(self: *Self) void {
|
|
self.terminal.printInfo("Lightpanda Agent (type 'quit' to exit)");
|
|
log.debug(.app, "tools loaded", .{ .count = self.tools.len });
|
|
const info = std.fmt.allocPrint(self.allocator, "Provider: {s}, Model: {s}", .{
|
|
@tagName(std.meta.activeTag(self.ai_client)),
|
|
self.model,
|
|
}) catch null;
|
|
self.terminal.printInfo(info orelse "Ready.");
|
|
if (info) |i| self.allocator.free(i);
|
|
|
|
while (true) {
|
|
const line = self.terminal.readLine("> ") orelse break;
|
|
defer self.terminal.freeLine(line);
|
|
|
|
if (line.len == 0) continue;
|
|
|
|
const cmd = Command.parse(line);
|
|
switch (cmd) {
|
|
.exit => break,
|
|
.natural_language => {
|
|
// "quit" as a convenience alias
|
|
if (std.mem.eql(u8, line, "quit")) break;
|
|
|
|
self.processUserMessage(line) catch |err| {
|
|
const msg = std.fmt.allocPrint(self.allocator, "Request failed: {s}", .{@errorName(err)}) catch "Request failed";
|
|
self.terminal.printError(msg);
|
|
};
|
|
},
|
|
else => self.cmd_executor.execute(cmd),
|
|
}
|
|
}
|
|
|
|
self.terminal.printInfo("Goodbye!");
|
|
}
|
|
|
|
fn processUserMessage(self: *Self, user_input: []const u8) !void {
|
|
const ma = self.message_arena.allocator();
|
|
|
|
// Add system prompt as first message if this is the first user message
|
|
if (self.messages.items.len == 0) {
|
|
try self.messages.append(self.allocator, .{
|
|
.role = .system,
|
|
.content = self.system_prompt,
|
|
});
|
|
}
|
|
|
|
// Add user message
|
|
try self.messages.append(self.allocator, .{
|
|
.role = .user,
|
|
.content = try ma.dupe(u8, user_input),
|
|
});
|
|
|
|
// Loop: send to LLM, execute tool calls, repeat until we get text
|
|
var max_iterations: u32 = 20;
|
|
while (max_iterations > 0) : (max_iterations -= 1) {
|
|
const provider_client = self.ai_client.toProvider();
|
|
var result = provider_client.generateContent(self.model, self.messages.items, .{
|
|
.tools = self.tools,
|
|
.max_tokens = 4096,
|
|
}) catch |err| {
|
|
log.err(.app, "AI API error", .{ .err = err });
|
|
return error.ApiError;
|
|
};
|
|
defer result.deinit();
|
|
|
|
log.debug(.app, "LLM response", .{
|
|
.finish_reason = @tagName(result.finish_reason),
|
|
.has_text = result.text != null,
|
|
.has_tool_calls = result.tool_calls != null,
|
|
});
|
|
|
|
// Handle tool calls (check for tool_calls presence, not just finish_reason,
|
|
// because some providers like Gemini return finish_reason=STOP for tool calls)
|
|
if (result.tool_calls) |tool_calls| {
|
|
// Add the assistant message with tool calls
|
|
try self.messages.append(self.allocator, .{
|
|
.role = .assistant,
|
|
.content = if (result.text) |t| try ma.dupe(u8, t) else null,
|
|
.tool_calls = try self.dupeToolCalls(tool_calls),
|
|
});
|
|
|
|
// Execute each tool call and collect results
|
|
var tool_results: std.ArrayListUnmanaged(zenai.provider.ToolResult) = .empty;
|
|
|
|
for (tool_calls) |tc| {
|
|
self.terminal.printToolCall(tc.name, tc.arguments);
|
|
|
|
var tool_arena = std.heap.ArenaAllocator.init(self.allocator);
|
|
defer tool_arena.deinit();
|
|
|
|
const tool_result = self.tool_executor.call(tool_arena.allocator(), tc.name, tc.arguments) catch "Error: tool execution failed";
|
|
self.terminal.printToolResult(tc.name, tool_result);
|
|
|
|
try tool_results.append(ma, .{
|
|
.id = try ma.dupe(u8, tc.id),
|
|
.name = try ma.dupe(u8, tc.name),
|
|
.content = try ma.dupe(u8, tool_result),
|
|
});
|
|
}
|
|
|
|
// Add tool results as a message
|
|
try self.messages.append(self.allocator, .{
|
|
.role = .tool,
|
|
.tool_results = try tool_results.toOwnedSlice(ma),
|
|
});
|
|
|
|
continue;
|
|
}
|
|
|
|
// Text response
|
|
if (result.text) |text| {
|
|
std.debug.print("\n", .{});
|
|
self.terminal.printAssistant(text);
|
|
std.debug.print("\n\n", .{});
|
|
|
|
try self.messages.append(self.allocator, .{
|
|
.role = .assistant,
|
|
.content = try ma.dupe(u8, text),
|
|
});
|
|
} else {
|
|
self.terminal.printInfo("(no response from model)");
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
fn dupeToolCalls(self: *Self, calls: []const zenai.provider.ToolCall) ![]const zenai.provider.ToolCall {
|
|
const ma = self.message_arena.allocator();
|
|
const duped = try ma.alloc(zenai.provider.ToolCall, calls.len);
|
|
for (calls, 0..) |tc, i| {
|
|
duped[i] = .{
|
|
.id = try ma.dupe(u8, tc.id),
|
|
.name = try ma.dupe(u8, tc.name),
|
|
.arguments = try ma.dupe(u8, tc.arguments),
|
|
};
|
|
}
|
|
return duped;
|
|
}
|
|
|
|
fn getEnvApiKey(provider_type: Config.AiProvider) ?[:0]const u8 {
|
|
return switch (provider_type) {
|
|
.anthropic => std.posix.getenv("ANTHROPIC_API_KEY"),
|
|
.openai => std.posix.getenv("OPENAI_API_KEY"),
|
|
.gemini => std.posix.getenv("GOOGLE_API_KEY") orelse std.posix.getenv("GEMINI_API_KEY"),
|
|
};
|
|
}
|
|
|
|
fn defaultModel(provider_type: Config.AiProvider) []const u8 {
|
|
return switch (provider_type) {
|
|
.anthropic => "claude-sonnet-4-20250514",
|
|
.openai => "gpt-4o",
|
|
.gemini => "gemini-2.5-flash",
|
|
};
|
|
}
|