mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 01:25:53 -04:00
agent: use global tools and simplify meta commands
- Initialize tools once globally instead of allocating per-agent. - Refactor meta commands to use an enum for cleaner dispatch. - Remove unused `findSchemaCanonical` and simplify command parsing.
This commit is contained in:
@@ -143,9 +143,6 @@ notification: *lp.Notification,
|
||||
browser: lp.Browser,
|
||||
session: *lp.Session,
|
||||
node_registry: CDPNode.Registry,
|
||||
/// Slice is owned by `allocator`; each entry's `parameters` JSON value points
|
||||
/// into the schema module's process-lifetime arena, so no per-entry free.
|
||||
tools: []const zenai.provider.Tool,
|
||||
terminal: Terminal,
|
||||
cmd_runner: CommandRunner,
|
||||
verifier: Verifier,
|
||||
@@ -242,7 +239,6 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
|
||||
.browser = undefined,
|
||||
.session = undefined,
|
||||
.node_registry = CDPNode.Registry.init(allocator),
|
||||
.tools = &.{},
|
||||
.terminal = .init(allocator, history_path, Config.agentVerbosity(opts), will_repl),
|
||||
.cmd_runner = undefined,
|
||||
.verifier = undefined,
|
||||
@@ -261,9 +257,6 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
|
||||
errdefer self.terminal.deinit();
|
||||
errdefer self.message_arena.deinit();
|
||||
|
||||
self.tools = try buildTools(allocator);
|
||||
errdefer allocator.free(self.tools);
|
||||
|
||||
try self.browser.init(app, .{}, null);
|
||||
errdefer self.browser.deinit();
|
||||
|
||||
@@ -308,7 +301,6 @@ pub fn deinit(self: *Agent) void {
|
||||
self.terminal.deinit();
|
||||
self.message_arena.deinit();
|
||||
self.messages.deinit(self.allocator);
|
||||
self.allocator.free(self.tools);
|
||||
self.node_registry.deinit();
|
||||
self.browser.deinit();
|
||||
self.notification.deinit();
|
||||
@@ -324,13 +316,19 @@ pub fn deinit(self: *Agent) void {
|
||||
self.allocator.destroy(self);
|
||||
}
|
||||
|
||||
fn buildTools(allocator: std.mem.Allocator) ![]const zenai.provider.Tool {
|
||||
const schemas = SlashCommand.globalSchemas();
|
||||
const tools = try allocator.alloc(zenai.provider.Tool, schemas.len);
|
||||
for (schemas, 0..) |s, i| {
|
||||
tools[i] = .{ .name = s.tool_name, .description = s.description, .parameters = s.parameters };
|
||||
// Tool definitions are compile-time constant; project them once per process.
|
||||
var global_tools_storage: [browser_tools.tool_defs.len]zenai.provider.Tool = undefined;
|
||||
var global_tools_once = std.once(initGlobalTools);
|
||||
|
||||
fn initGlobalTools() void {
|
||||
for (SlashCommand.globalSchemas(), 0..) |s, i| {
|
||||
global_tools_storage[i] = .{ .name = s.tool_name, .description = s.description, .parameters = s.parameters };
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
fn globalTools() []const zenai.provider.Tool {
|
||||
global_tools_once.call();
|
||||
return global_tools_storage[0..browser_tools.tool_defs.len];
|
||||
}
|
||||
|
||||
/// Called from the sighandler thread — sets the flag only, no terminal
|
||||
@@ -423,7 +421,7 @@ fn runTurn(self: *Agent, input: TurnInput) bool {
|
||||
fn runRepl(self: *Agent) void {
|
||||
self.terminal.printInfo("Lightpanda Agent (type '/quit' to exit)");
|
||||
self.terminal.printInfo("Tab completes/cycles through commands; the dim grey ghost shows the first match.");
|
||||
log.debug(.app, "tools loaded", .{ .count = self.tools.len });
|
||||
log.debug(.app, "tools loaded", .{ .count = globalTools().len });
|
||||
if (self.ai_client) |ai_client| {
|
||||
self.terminal.printInfoFmt("Provider: {s}, Model: {s}", .{ @tagName(std.meta.activeTag(ai_client)), self.model });
|
||||
} else {
|
||||
@@ -447,8 +445,8 @@ fn runRepl(self: *Agent) void {
|
||||
|
||||
const slash_split: ?SlashCommand.Split = if (trimmed[0] == '/') SlashCommand.splitNameRest(trimmed[1..]) else null;
|
||||
if (slash_split) |split| {
|
||||
if (SlashCommand.findMeta(split.name) != null) {
|
||||
if (self.handleMeta(split.name, split.rest)) break :repl;
|
||||
if (SlashCommand.findMeta(split.name)) |meta| {
|
||||
if (self.handleMeta(meta, split.rest)) break :repl;
|
||||
continue :repl;
|
||||
}
|
||||
}
|
||||
@@ -457,7 +455,7 @@ fn runRepl(self: *Agent) void {
|
||||
defer arena.deinit();
|
||||
const aa = arena.allocator();
|
||||
|
||||
const cmd = Command.parseWithSchemas(aa, line, SlashCommand.globalSchemas()) catch |err| switch (err) {
|
||||
const cmd = Command.parse(aa, line) catch |err| switch (err) {
|
||||
error.NotASlashCommand => {
|
||||
if (self.ai_client == null) {
|
||||
self.terminal.printError("Basic REPL (--no-llm) accepts only slash commands. Try /help, or drop --no-llm and set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY) to enable natural-language prompts.");
|
||||
@@ -498,17 +496,12 @@ fn runRepl(self: *Agent) void {
|
||||
|
||||
/// Handle a meta slash command (/quit, /help, /verbosity). These aren't part
|
||||
/// of PandaScript — they're REPL-only and never recorded. Returns `true` if
|
||||
/// the user asked to quit. Caller has already verified `name` is in
|
||||
/// `SlashCommand.meta_commands`.
|
||||
fn handleMeta(self: *Agent, name: []const u8, rest: []const u8) bool {
|
||||
if (std.mem.eql(u8, name, "quit")) return true;
|
||||
if (std.mem.eql(u8, name, "help")) {
|
||||
self.printSlashHelp(rest);
|
||||
return false;
|
||||
}
|
||||
if (std.mem.eql(u8, name, "verbosity")) {
|
||||
self.handleVerbosity(rest);
|
||||
return false;
|
||||
/// the user asked to quit.
|
||||
fn handleMeta(self: *Agent, meta: *const SlashCommand.MetaCommand, rest: []const u8) bool {
|
||||
switch (meta.kind) {
|
||||
.quit => return true,
|
||||
.help => self.printSlashHelp(rest),
|
||||
.verbosity => self.handleVerbosity(rest),
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -537,19 +530,15 @@ fn printSlashHelp(self: *Agent, target: []const u8) void {
|
||||
return;
|
||||
}
|
||||
const lookup = if (target[0] == '/') target[1..] else target;
|
||||
if (std.ascii.eqlIgnoreCase(lookup, "help")) {
|
||||
self.terminal.printInfo("/help [name] — show help for a slash command, or list all when [name] is omitted");
|
||||
return;
|
||||
}
|
||||
if (std.ascii.eqlIgnoreCase(lookup, "quit")) {
|
||||
self.terminal.printInfo("/quit — exit the REPL");
|
||||
return;
|
||||
}
|
||||
if (std.ascii.eqlIgnoreCase(lookup, "verbosity")) {
|
||||
self.terminal.printInfoFmt(
|
||||
"/verbosity <low|medium|high> — set REPL agent verbosity (currently: {s}). Bare /verbosity prints the level.",
|
||||
.{@tagName(self.terminal.verbosity)},
|
||||
);
|
||||
if (SlashCommand.findMeta(lookup)) |meta| {
|
||||
switch (meta.kind) {
|
||||
.help => self.terminal.printInfo("/help [name] — show help for a slash command, or list all when [name] is omitted"),
|
||||
.quit => self.terminal.printInfo("/quit — exit the REPL"),
|
||||
.verbosity => self.terminal.printInfoFmt(
|
||||
"/verbosity <low|medium|high> — set REPL agent verbosity (currently: {s}). Bare /verbosity prints the level.",
|
||||
.{@tagName(self.terminal.verbosity)},
|
||||
),
|
||||
}
|
||||
return;
|
||||
}
|
||||
const schema = SlashCommand.findSchema(SlashCommand.globalSchemas(), lookup) orelse {
|
||||
@@ -825,7 +814,7 @@ fn runHealTurn(self: *Agent, arena: std.mem.Allocator, prompt: []const u8) ![]Co
|
||||
ma,
|
||||
.{ .context = @ptrCast(self), .callFn = handleToolCall },
|
||||
.{
|
||||
.tools = self.tools,
|
||||
.tools = globalTools(),
|
||||
.max_tool_calls = 4,
|
||||
.max_tokens = 4096,
|
||||
.tool_choice = .auto,
|
||||
@@ -979,7 +968,7 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
|
||||
ma,
|
||||
.{ .context = @ptrCast(self), .callFn = handleToolCall },
|
||||
.{
|
||||
.tools = self.tools,
|
||||
.tools = globalTools(),
|
||||
.max_turns = 30,
|
||||
// Safety net; max_turns is the primary terminal.
|
||||
.max_tool_calls = 200,
|
||||
|
||||
@@ -53,9 +53,11 @@ pub fn executeWithResult(self: *CommandRunner, arena: std.mem.Allocator, cmd: Co
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolve `$LP_*` placeholders in string args before the tool runs. `fill`'s
|
||||
/// `value` is excluded — the tool resolves it internally and rewrites the
|
||||
/// result text so the credential never appears in the echoed confirmation.
|
||||
/// Resolve `$LP_*` placeholders in every string arg before the tool runs.
|
||||
/// `fill.value` is the one exception: the tool resolves it internally and
|
||||
/// rewrites the result text so the credential never appears in the echoed
|
||||
/// confirmation. Every other string field (selectors, urls, scripts, schemas)
|
||||
/// is substituted here.
|
||||
fn substituteStringArgs(arena: std.mem.Allocator, action: browser_tools.Action, args: ?std.json.Value) error{OutOfMemory}!?std.json.Value {
|
||||
const v = args orelse return null;
|
||||
if (v != .object) return v;
|
||||
|
||||
@@ -31,29 +31,25 @@ pub const max_hint_slots = schema.max_hint_slots;
|
||||
|
||||
pub const globalSchemas = schema.globalSchemas;
|
||||
pub const findSchema = schema.findSchema;
|
||||
pub const findSchemaCanonical = schema.findSchemaCanonical;
|
||||
pub const splitNameRest = schema.splitNameRest;
|
||||
|
||||
/// Meta slash commands handled directly by Agent.handleMeta.
|
||||
pub const MetaCommand = struct {
|
||||
kind: Kind,
|
||||
name: [:0]const u8,
|
||||
/// Ghost-text fragment shown after the name + space. Empty when the
|
||||
/// command takes no args (`/help`, `/quit`).
|
||||
hint: []const u8,
|
||||
/// Tab-completion candidates for the first positional arg.
|
||||
values: []const [:0]const u8,
|
||||
|
||||
pub const Kind = enum { help, quit, verbosity };
|
||||
};
|
||||
|
||||
pub const meta_commands = [_]MetaCommand{
|
||||
.{ .name = "help", .hint = "", .values = &.{} },
|
||||
.{ .name = "quit", .hint = "", .values = &.{} },
|
||||
.{ .name = "verbosity", .hint = "<low|medium|high>", .values = &.{ "low", "medium", "high" } },
|
||||
};
|
||||
|
||||
pub const meta_names: [meta_commands.len][:0]const u8 = blk: {
|
||||
var arr: [meta_commands.len][:0]const u8 = undefined;
|
||||
for (meta_commands, 0..) |m, i| arr[i] = m.name;
|
||||
break :blk arr;
|
||||
.{ .kind = .help, .name = "help", .hint = "", .values = &.{} },
|
||||
.{ .kind = .quit, .name = "quit", .hint = "", .values = &.{} },
|
||||
.{ .kind = .verbosity, .name = "verbosity", .hint = "<low|medium|high>", .values = &.{ "low", "medium", "high" } },
|
||||
};
|
||||
|
||||
pub fn findMeta(name: []const u8) ?*const MetaCommand {
|
||||
|
||||
@@ -64,10 +64,10 @@ stderr_is_tty: bool,
|
||||
spinner: Spinner,
|
||||
|
||||
// Flat name list for the "match any slash command" search/completion paths.
|
||||
const all_slash_names: [browser_tools.names.len + SlashCommand.meta_names.len][]const u8 = blk: {
|
||||
var arr: [browser_tools.names.len + SlashCommand.meta_names.len][]const u8 = undefined;
|
||||
const all_slash_names: [browser_tools.names.len + SlashCommand.meta_commands.len][]const u8 = blk: {
|
||||
var arr: [browser_tools.names.len + SlashCommand.meta_commands.len][]const u8 = undefined;
|
||||
for (browser_tools.names, 0..) |n, i| arr[i] = n;
|
||||
for (SlashCommand.meta_names, 0..) |m, i| arr[browser_tools.names.len + i] = m;
|
||||
for (SlashCommand.meta_commands, 0..) |m, i| arr[browser_tools.names.len + i] = m.name;
|
||||
break :blk arr;
|
||||
};
|
||||
|
||||
@@ -392,7 +392,7 @@ fn hintsCallback(input_c: [*c]const u8, arg: ?*anyopaque) callconv(.c) [*c]const
|
||||
/// Join `fragments` into `hint_buf` with single-space separators, prefixed by
|
||||
/// `lead` (typically `""` or `" "`). Null-terminates and returns the isocline
|
||||
/// C pointer, or null when there's nothing to render or the buffer would
|
||||
/// overflow. Shared by the slash and PandaScript hint renderers.
|
||||
/// overflow.
|
||||
fn writeHints(lead: []const u8, fragments: []const []const u8) [*c]const u8 {
|
||||
if (fragments.len == 0) return null;
|
||||
const cap = hint_buf.len - 1;
|
||||
@@ -516,7 +516,7 @@ fn slashHasPrefix(name: []const u8) bool {
|
||||
}
|
||||
|
||||
fn slashHasParams(name: []const u8) bool {
|
||||
if (SlashCommand.findSchemaCanonical(SlashCommand.globalSchemas(), name)) |s| return s.hints.len > 0;
|
||||
if (SlashCommand.findSchema(SlashCommand.globalSchemas(), name)) |s| return s.hints.len > 0;
|
||||
if (SlashCommand.findMeta(name)) |m| return m.hint.len > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ pub const tool_defs = [_]ToolDef{
|
||||
.{
|
||||
.name = "extract",
|
||||
.description =
|
||||
\\Extract structured data from the current page using a small JSON schema. Prefer this over `markdown` or `eval` whenever the user asked for a specific value or list (a score, price, count, profile field, headlines, …) — the result is returned as JSON AND the call is recorded as an `EXTRACT` PandaScript line, so a later replay (no LLM) prints the answer to stdout. Use `markdown` / `tree` / `interactiveElements` only to discover the right selector, then commit to one `extract` call.
|
||||
\\Extract structured data from the current page using a small JSON schema. Prefer this over `markdown` or `eval` whenever the user asked for a specific value or list (a score, price, count, profile field, headlines, …) — the result is returned as JSON AND the call is recorded as an `/extract` PandaScript line, so a later replay (no LLM) prints the answer to stdout. Use `markdown` / `tree` / `interactiveElements` only to discover the right selector, then commit to one `extract` call.
|
||||
\\
|
||||
\\Schema is a JSON object literal (pass it as a string in `schema`). Each value picks what to lift out:
|
||||
\\ "<sel>" → first match's textContent.trim() (string|null)
|
||||
|
||||
@@ -87,10 +87,6 @@ pub const Command = union(enum) {
|
||||
}
|
||||
|
||||
pub fn parse(arena: std.mem.Allocator, line: []const u8) ParseError!Command {
|
||||
return parseWithSchemas(arena, line, schema.globalSchemas());
|
||||
}
|
||||
|
||||
pub fn parseWithSchemas(arena: std.mem.Allocator, line: []const u8, schemas: []const schema.SchemaInfo) ParseError!Command {
|
||||
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
|
||||
if (trimmed.len == 0) return .{ .comment = {} };
|
||||
if (trimmed[0] == '#') return .{ .comment = {} };
|
||||
@@ -107,7 +103,7 @@ pub const Command = union(enum) {
|
||||
return .{ .accept_cookies = {} };
|
||||
}
|
||||
|
||||
const s = schema.findSchema(schemas, split.name) orelse return error.UnknownTool;
|
||||
const s = schema.findSchema(schema.globalSchemas(), split.name) orelse return error.UnknownTool;
|
||||
const args = try schema.parseValue(arena, s, split.rest);
|
||||
return .{ .tool_call = .{ .action = s.action, .args = args } };
|
||||
}
|
||||
@@ -162,8 +158,6 @@ pub const Command = union(enum) {
|
||||
};
|
||||
|
||||
pub fn next(self: *ScriptIterator) ParseError!?Entry {
|
||||
const schemas = schema.globalSchemas();
|
||||
|
||||
while (self.lines.next()) |line| {
|
||||
self.line_num += 1;
|
||||
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
|
||||
@@ -171,7 +165,7 @@ pub const Command = union(enum) {
|
||||
|
||||
const line_start = @intFromPtr(line.ptr) - @intFromPtr(self.lines.buffer.ptr);
|
||||
|
||||
if (try self.tryBlockOpener(trimmed, schemas)) |opener| {
|
||||
if (tryBlockOpener(trimmed)) |opener| {
|
||||
const start_line = self.line_num;
|
||||
const body = try self.collectMultiLineBlock(opener.quote_type);
|
||||
const span_end = self.lines.index orelse self.lines.buffer.len;
|
||||
@@ -194,7 +188,7 @@ pub const Command = union(enum) {
|
||||
.line_num = self.line_num,
|
||||
.opener_line = trimmed,
|
||||
.raw_span = self.lines.buffer[line_start..span_end],
|
||||
.command = try Command.parseWithSchemas(self.allocator, trimmed, schemas),
|
||||
.command = try Command.parse(self.allocator, trimmed),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -206,10 +200,10 @@ pub const Command = union(enum) {
|
||||
quote_type: QuoteType,
|
||||
};
|
||||
|
||||
fn tryBlockOpener(_: *ScriptIterator, line: []const u8, schemas: []const schema.SchemaInfo) ParseError!?BlockOpener {
|
||||
fn tryBlockOpener(line: []const u8) ?BlockOpener {
|
||||
if (line.len < 2 or line[0] != '/') return null;
|
||||
const split = schema.splitNameRest(line[1..]) orelse return null;
|
||||
const s = schema.findSchema(schemas, split.name) orelse return null;
|
||||
const s = schema.findSchema(schema.globalSchemas(), split.name) orelse return null;
|
||||
if (!s.isMultiLineCapable()) return null;
|
||||
const qt = QuoteType.fromLiteral(split.rest) orelse return null;
|
||||
return .{ .action = s.action, .field = s.required[0], .quote_type = qt };
|
||||
|
||||
@@ -188,12 +188,6 @@ pub fn findSchema(schemas: []const SchemaInfo, name: []const u8) ?*const SchemaI
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn findSchemaCanonical(schemas: []const SchemaInfo, name: []const u8) ?*const SchemaInfo {
|
||||
std.debug.assert(schemas.len == browser_tools.tool_defs.len);
|
||||
const action = std.meta.stringToEnum(browser_tools.Action, name) orelse return null;
|
||||
return &schemas[@intFromEnum(action)];
|
||||
}
|
||||
|
||||
pub const Split = struct {
|
||||
name: []const u8,
|
||||
rest: []const u8,
|
||||
@@ -246,21 +240,10 @@ pub fn parseValue(arena: std.mem.Allocator, schema: *const SchemaInfo, rest: []c
|
||||
|
||||
// Default-true booleans (e.g. setChecked.checked) so `/setChecked
|
||||
// selector='#a'` works without `checked=true`.
|
||||
for (schema.required) |req| {
|
||||
var found = false;
|
||||
for (list.items) |p| {
|
||||
if (std.mem.eql(u8, p.key, req)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
if (schema.isFieldDefaultTrue(req)) {
|
||||
list.appendAssumeCapacity(.{ .key = req, .value = "true" });
|
||||
} else {
|
||||
return error.MissingRequired;
|
||||
}
|
||||
}
|
||||
required: for (schema.required) |req| {
|
||||
for (list.items) |p| if (std.mem.eql(u8, p.key, req)) continue :required;
|
||||
if (!schema.isFieldDefaultTrue(req)) return error.MissingRequired;
|
||||
list.appendAssumeCapacity(.{ .key = req, .value = "true" });
|
||||
}
|
||||
|
||||
return try buildValue(arena, schema, list.items);
|
||||
@@ -348,7 +331,9 @@ fn coerce(arena: std.mem.Allocator, schema: *const SchemaInfo, key: []const u8,
|
||||
return .{ .string = try arena.dupe(u8, value) };
|
||||
}
|
||||
|
||||
// --- Global lazy schema cache (process-lifetime) ---
|
||||
// --- Global lazy schema cache ---
|
||||
//
|
||||
// `global_arena` is never deinit'd: it's process-lifetime, freed at exit.
|
||||
|
||||
var global_schemas_storage: [browser_tools.tool_defs.len]SchemaInfo = undefined;
|
||||
var global_arena: std.heap.ArenaAllocator = undefined;
|
||||
@@ -396,9 +381,6 @@ test "globalSchemas: comptime tool defs reduce cleanly" {
|
||||
if (std.mem.eql(u8, f.name, "checked")) checked_default_true = f.default_true;
|
||||
}
|
||||
try testing.expect(checked_default_true);
|
||||
|
||||
try testing.expect(findSchemaCanonical(schemas, "goto") == goto);
|
||||
try testing.expect(findSchemaCanonical(schemas, "unknown_tool") == null);
|
||||
}
|
||||
|
||||
test "parseValue: single-required positional binds" {
|
||||
|
||||
Reference in New Issue
Block a user