diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 13051f3f..bebbd328 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -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 — 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 — 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, diff --git a/src/agent/CommandRunner.zig b/src/agent/CommandRunner.zig index af659eab..3b7a04b9 100644 --- a/src/agent/CommandRunner.zig +++ b/src/agent/CommandRunner.zig @@ -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; diff --git a/src/agent/SlashCommand.zig b/src/agent/SlashCommand.zig index 0ecacb05..c12a1866 100644 --- a/src/agent/SlashCommand.zig +++ b/src/agent/SlashCommand.zig @@ -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 = "", .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 = "", .values = &.{ "low", "medium", "high" } }, }; pub fn findMeta(name: []const u8) ?*const MetaCommand { diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index 0456d00f..0aa0d5e2 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -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; } diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 09acc978..730e2ba3 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -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: \\ "" → first match's textContent.trim() (string|null) diff --git a/src/script/command.zig b/src/script/command.zig index d9839e07..64063f2e 100644 --- a/src/script/command.zig +++ b/src/script/command.zig @@ -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 }; diff --git a/src/script/schema.zig b/src/script/schema.zig index 459f2437..e9d5d53d 100644 --- a/src/script/schema.zig +++ b/src/script/schema.zig @@ -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" {