agent: simplify command, schema, and repl logic

- Remove `input_schema_raw` from `SchemaInfo` and use `parameters` directly
- Simplify JSON field extraction in `Verifier` to avoid `parseArgs`
- Refactor REPL command splitting and environment substitution
- Clean up and condense comments across the codebase
This commit is contained in:
Adrià Arrufat
2026-05-21 22:39:58 +02:00
parent e89fb9b485
commit 13ebb9e802
8 changed files with 100 additions and 197 deletions

View File

@@ -143,9 +143,8 @@ notification: *lp.Notification,
browser: lp.Browser,
session: *lp.Session,
node_registry: CDPNode.Registry,
/// Provider-facing tool list, built from `SlashCommand.globalSchemas()`. Slice
/// is owned by `allocator`; the `parameters` JSON `Value` each entry points at
/// lives in the schema module's process-lifetime arena.
/// 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,
@@ -445,8 +444,8 @@ fn runRepl(self: *Agent) void {
if (line.len == 0) continue;
if (line[0] == '/') {
const split = SlashCommand.splitNameRest(line[1..]) orelse continue :repl;
const slash_split: ?SlashCommand.Split = if (line[0] == '/') SlashCommand.splitNameRest(line[1..]) else null;
if (slash_split) |split| {
if (SlashCommand.findMeta(split.name) != null) {
if (self.handleMeta(split.name, split.rest)) break :repl;
continue :repl;
@@ -480,12 +479,11 @@ fn runRepl(self: *Agent) void {
continue :repl;
}
const prompt = if (cmd == .login) login_prompt else accept_cookies_prompt;
const label: []const u8 = if (cmd == .login) "LOGIN" else "ACCEPT_COOKIES";
const label: []const u8 = if (cmd == .login) "/login" else "/acceptCookies";
_ = self.runTurn(.{ .prompt = prompt, .record_comment = line, .label = label });
},
.tool_call => |tc| {
const split = SlashCommand.splitNameRest(line[1..]) orelse unreachable;
self.terminal.beginTool(tc.name, split.rest);
self.terminal.beginTool(tc.name, slash_split.?.rest);
const result = self.cmd_runner.executeWithResult(aa, cmd);
self.terminal.endTool();
self.cmd_runner.printResult(cmd, result);
@@ -561,14 +559,9 @@ fn printSlashHelp(self: *Agent, target: []const u8) void {
var arena: std.heap.ArenaAllocator = .init(self.allocator);
defer arena.deinit();
const aa = arena.allocator();
const pretty: []const u8 = blk: {
const v = std.json.parseFromSliceLeaky(std.json.Value, aa, schema.input_schema_raw, .{}) catch break :blk schema.input_schema_raw;
var aw: std.Io.Writer.Allocating = .init(aa);
std.json.Stringify.value(v, .{ .whitespace = .indent_2 }, &aw.writer) catch break :blk schema.input_schema_raw;
break :blk aw.written();
};
self.terminal.printInfoFmt("schema:\n{s}", .{pretty});
var aw: std.Io.Writer.Allocating = .init(arena.allocator());
std.json.Stringify.value(schema.parameters, .{ .whitespace = .indent_2 }, &aw.writer) catch return;
self.terminal.printInfoFmt("schema:\n{s}", .{aw.written()});
}
fn printSlashParseError(self: *Agent, err: SlashCommand.ParseError, name: []const u8) void {
@@ -620,9 +613,9 @@ fn runScript(self: *Agent, path: []const u8) bool {
}) orelse break;
switch (entry.command) {
.comment => {
// Recorded scripts prefix LLM-generated commands with the
// natural-language prompt that produced them; keep the
// last one around so self-heal can use it as context.
// `#` prefix lines preceding a recorded action are the
// natural-language prompt that produced it — kept for
// self-heal context.
if (entry.opener_line.len > 2 and entry.opener_line[0] == '#') {
last_comment = std.mem.trim(u8, entry.opener_line[1..], &std.ascii.whitespace);
}
@@ -787,8 +780,6 @@ fn ensureSystemPrompt(self: *Agent) !void {
}
}
// Drop older turns once `prune_high` is hit; survivors are deep-copied so
// the old arena (which still pins dropped strings) can be released.
const prune_high = 30;
const prune_keep = 20;
@@ -798,10 +789,8 @@ fn pruneMessages(self: *Agent) void {
const tail_start = zenai.provider.safeTruncationStart(msgs, msgs.len - prune_keep) orelse return;
// Dupe the kept tail into a scratch slice in the new arena first. Only
// mutate self.messages once every dupe has succeeded — otherwise a
// partial failure would leave self.messages.items[1..] pointing into
// the freed `new_arena`.
// Dupe into the new arena before mutating self.messages — a partial
// failure would otherwise leave items pointing into a freed arena.
var new_arena: std.heap.ArenaAllocator = .init(self.allocator);
const duped = zenai.provider.dupeMessages(new_arena.allocator(), msgs[tail_start..]) catch {
new_arena.deinit();
@@ -852,9 +841,8 @@ fn runHealTurn(self: *Agent, arena: std.mem.Allocator, prompt: []const u8) ![]Co
var cmds: std.ArrayList(Command) = .empty;
for (result.tool_calls_made) |tc| {
if (tc.is_error) continue;
// Deep-copy into the caller's arena: `result.deinit()` (deferred above)
// frees `tc.arguments`'s backing arena before the returned cmds are
// formatted by `attemptSelfHeal`.
// `result.deinit()` (deferred above) frees the args arena before the
// caller formats `cmds`; deep-copy into `arena` to outlive it.
const cmd = try Command.fromToolCallOwned(arena, tc.name, tc.arguments);
if (!cmd.canHeal()) {
self.terminal.printInfoFmt(
@@ -1036,21 +1024,17 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
}
r.record(cmd);
}
// Recorder self-disables on write failure (disk full, fd closed). Tell
// the user the recording stopped instead of silently dropping appends.
if (!r.isActive()) {
self.terminal.printError("recording disabled (write failed); see logs");
}
};
// RunToolsResult arenas are deinited at the end of this function —
// dupe into `message_arena` so the returned slice outlives them.
// Dupe into `message_arena` — RunToolsResult arenas are deinited below.
const final_text: ?[]const u8 = blk: {
if (result.text) |text| break :blk try ma.dupe(u8, text);
// Tool loop ended without a final text — force one more turn that
// forbids tools and pretraining fallback. Without this, models
// confabulate answers when the page was blocked or empty.
// Without a synthesis turn forbidding tools+pretraining, models
// confabulate when the page was blocked or empty.
log.info(.app, "synthesizing final answer", .{});
const synth_baseline = self.messages.items.len;
try self.messages.append(self.allocator, .{
@@ -1065,17 +1049,12 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
ma,
.{ .context = @ptrCast(self), .callFn = handleToolCall },
.{
// tool_choice = .none forbids tools; serializing the full
// catalog anyway just pads the request body.
.tools = &.{},
.max_turns = 1,
.max_tokens = 4096,
.tool_choice = .none,
// Cap thinking on the finalize turn. Fully disabling it (0)
// leaves reasoning-heavy tasks with no answer at all; letting
// it run unbounded lets models fill the turn with thoughts
// and emit nothing as the final text. 512 tokens is enough
// for the model to pick its answer but not to freewheel.
// .low (≈512 tokens) so reasoning models still pick an answer
// but can't burn the whole turn on thinking and emit nothing.
.thinking_level = .low,
.cancel = .{ .context = @ptrCast(self), .checkFn = checkCancel },
},

View File

@@ -62,18 +62,22 @@ fn substituteStringArgs(arena: std.mem.Allocator, tool_name: []const u8, args: ?
const is_fill = std.mem.eql(u8, tool_name, @tagName(browser_tools.Action.fill));
var needs_sub = false;
const needsSub = struct {
fn f(is_fill_: bool, key: []const u8, val: std.json.Value) bool {
if (is_fill_ and std.mem.eql(u8, key, "value")) return false;
return val == .string and std.mem.indexOf(u8, val.string, "$LP_") != null;
}
}.f;
var needs_any = false;
var it = v.object.iterator();
while (it.next()) |entry| {
const key = entry.key_ptr.*;
const val = entry.value_ptr.*;
const exclude = is_fill and std.mem.eql(u8, key, "value");
if (!exclude and val == .string and std.mem.indexOf(u8, val.string, "$LP_") != null) {
needs_sub = true;
if (needsSub(is_fill, entry.key_ptr.*, entry.value_ptr.*)) {
needs_any = true;
break;
}
}
if (!needs_sub) return v;
if (!needs_any) return v;
var new_obj: std.json.ObjectMap = .init(arena);
try new_obj.ensureTotalCapacity(v.object.count());
@@ -81,13 +85,11 @@ fn substituteStringArgs(arena: std.mem.Allocator, tool_name: []const u8, args: ?
while (it.next()) |entry| {
const key = entry.key_ptr.*;
const val = entry.value_ptr.*;
const exclude = is_fill and std.mem.eql(u8, key, "value");
if (!exclude and val == .string and std.mem.indexOf(u8, val.string, "$LP_") != null) {
const resolved = try browser_tools.substituteEnvVars(arena, val.string);
try new_obj.put(key, .{ .string = resolved });
continue;
}
try new_obj.put(key, val);
const new_val: std.json.Value = if (needsSub(is_fill, key, val))
.{ .string = try browser_tools.substituteEnvVars(arena, val.string) }
else
val;
try new_obj.put(key, new_val);
}
return .{ .object = new_obj };
}

View File

@@ -17,15 +17,12 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! REPL-only meta slash commands and re-exports of the PandaScript schema
//! primitives. The actual slash-command grammar lives in `script/schema.zig`;
//! this module keeps the agent-only meta commands (`/help`, `/quit`,
//! `/verbosity`) that aren't part of the script.
//! primitives. The actual slash-command grammar lives in `script/schema.zig`.
const std = @import("std");
const lp = @import("lightpanda");
const schema = lp.script.schema;
// Re-export so existing call sites (Agent, Terminal) keep their import path.
pub const SchemaInfo = schema.SchemaInfo;
pub const ParseError = schema.ParseError;
pub const Split = schema.Split;
@@ -37,8 +34,7 @@ pub const findSchema = schema.findSchema;
pub const findSchemaCanonical = schema.findSchemaCanonical;
pub const splitNameRest = schema.splitNameRest;
/// Meta slash commands handled directly by the agent (not by ToolExecutor).
/// Kept in sync with `handleMeta` in Agent.zig.
/// Meta slash commands handled directly by Agent.handleMeta.
pub const MetaCommand = struct {
name: [:0]const u8,
/// Ghost-text fragment shown after the name + space. Empty when the

View File

@@ -182,6 +182,11 @@ fn surfacesErrorInBand(action: browser_tools.Action) bool {
return action == .eval or action == .extract;
}
fn surfacesErrorInBandByName(name: []const u8) bool {
const action = std.meta.stringToEnum(browser_tools.Action, name) orelse return false;
return surfacesErrorInBand(action);
}
fn recordIfActive(server: *Server, name: []const u8, arguments: ?std.json.Value) void {
if (server.recorder == null) return;
const cmd = Command.fromToolCall(name, arguments);
@@ -266,13 +271,8 @@ fn handleScriptStep(server: *Server, arena: std.mem.Allocator, id: std.json.Valu
}
const tc = cmd.tool_call;
const action = std.meta.stringToEnum(browser_tools.Action, tc.name) orelse {
return sendErrorContent(server, id, "internal: unknown action");
};
const result = browser_tools.call(arena, server.session, &server.node_registry, tc.name, tc.args) catch |err| {
// eval/extract get a terse error — they're line-scoped already.
if (surfacesErrorInBand(action)) {
if (surfacesErrorInBandByName(tc.name)) {
return sendErrorContent(server, id, @errorName(err));
}
const url = browser_tools.currentUrlOrPlaceholder(server.session);

View File

@@ -129,6 +129,8 @@ fn disable(self: *Recorder, err: anyerror) void {
}
}
// --- Tests ---
fn parseLine(arena: std.mem.Allocator, line: []const u8) Command {
return Command.parse(arena, line) catch unreachable;
}

View File

@@ -60,19 +60,26 @@ pub fn verify(self: *Verifier, arena: std.mem.Allocator, cmd: Command) VerifyRes
else => return .inconclusive,
};
const action = std.meta.stringToEnum(browser_tools.Action, tc.name) orelse return .inconclusive;
const args = tc.args orelse return .inconclusive;
if (args != .object) return .inconclusive;
const selector = (args.object.get("selector") orelse return .inconclusive);
if (selector != .string) return .inconclusive;
switch (action) {
.fill => {
const a = browser_tools.parseArgs(struct { selector: []const u8, value: []const u8 }, arena, tc.args) catch return .inconclusive;
return self.verifyFill(arena, a.selector, a.value);
const value = args.object.get("value") orelse return .inconclusive;
if (value != .string) return .inconclusive;
return self.verifyFill(arena, selector.string, value.string);
},
.setChecked => {
const a = browser_tools.parseArgs(struct { selector: []const u8, checked: bool }, arena, tc.args) catch return .inconclusive;
return self.verifyCheck(arena, a.selector, a.checked);
const checked = args.object.get("checked") orelse return .inconclusive;
if (checked != .bool) return .inconclusive;
return self.verifyCheck(arena, selector.string, checked.bool);
},
.selectOption => {
const a = browser_tools.parseArgs(struct { selector: []const u8, value: []const u8 }, arena, tc.args) catch return .inconclusive;
return self.verifySelect(arena, a.selector, a.value);
const value = args.object.get("value") orelse return .inconclusive;
if (value != .string) return .inconclusive;
return self.verifySelect(arena, selector.string, value.string);
},
else => return .inconclusive,
}

View File

@@ -16,14 +16,9 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! PandaScript Command — one line of a `.lp` script: a slash command, a
//! `#`-comment, or one of two LLM triggers (`/login`, `/acceptCookies`).
//! Anything else is a parse error; bare prose → LLM only happens in the
//! live REPL and is dispatched there, not here.
//!
//! Multi-line `/eval '''…'''` / `/extract '''…'''` blocks live in
//! `ScriptIterator`, which assembles the body before calling `parse` on the
//! synthesized line.
//! PandaScript Command: a slash command, `#`-comment, or `/login` /
//! `/acceptCookies` LLM trigger. Bare prose is the REPL's job, not the parser's.
//! Multi-line `'''…'''` blocks are assembled by `ScriptIterator` before parse.
const std = @import("std");
const lp = @import("lightpanda");
@@ -40,9 +35,7 @@ pub const Command = union(enum) {
comment: void,
pub const ToolCall = struct {
/// Slice into the schema table — lives forever, no dupe required.
name: []const u8,
/// Arena-owned. `null` for tools with no args (e.g. `/getCookies`).
args: ?std.json.Value,
};
@@ -53,9 +46,8 @@ pub const Command = union(enum) {
.tool_call => |tc| blk: {
const s = schema.findSchemaCanonical(schema.globalSchemas(), tc.name) orelse break :blk false;
if (!s.recorded) break :blk false;
// backendNodeId-based calls aren't replayable (the id is
// invalidated by any DOM mutation), so keep them out of the
// recording even when the tool itself is recordable.
// backendNodeId is invalidated by any DOM mutation, so calls
// using it aren't replayable.
const args = tc.args orelse break :blk true;
if (args == .object and args.object.contains("backendNodeId")) break :blk false;
break :blk true;
@@ -77,9 +69,6 @@ pub const Command = union(enum) {
};
}
/// Self-heal must only patch the current page; navigation is excluded
/// even though `/goto` is recorded. The decision lives on the per-tool
/// `can_heal` flag in `tool_defs`; here it's just a lookup.
pub fn canHeal(self: Command) bool {
return switch (self) {
.tool_call => |tc| if (schema.findSchemaCanonical(schema.globalSchemas(), tc.name)) |s| s.can_heal else false,
@@ -87,17 +76,10 @@ pub const Command = union(enum) {
};
}
/// Parse one trimmed line. Branch order: blank/`#` → `.comment`;
/// `/login` and `/acceptCookies` short-circuit to their meta variants;
/// any other `/<name>` resolves the schema and parses args; anything
/// else returns `error.NotASlashCommand`. Bare-prose-to-LLM is the REPL's
/// job, not the parser's.
pub fn parse(arena: std.mem.Allocator, line: []const u8) ParseError!Command {
return parseWithSchemas(arena, line, schema.globalSchemas());
}
/// Same as `parse` but lets callers inject a different schema set —
/// the agent uses its own arena-backed cache to avoid double-parsing.
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 = {} };
@@ -120,10 +102,7 @@ pub const Command = union(enum) {
return .{ .tool_call = .{ .name = s.tool_name, .args = args } };
}
/// Round-trips with `parse` for the canonical recorder output. Single-
/// required-field tools emit positional + quoted (`/click '#login'`);
/// everything else emits `/name key=value ...`. Multi-line string values
/// use `'''…'''` blocks. Default-true booleans are omitted when matching.
/// Canonical recorder format. Round-trips with `parse`.
pub fn format(self: Command, writer: *std.Io.Writer) std.Io.Writer.Error!void {
switch (self) {
.login => try writer.writeAll("/login"),
@@ -133,26 +112,21 @@ pub const Command = union(enum) {
}
}
/// `name` and `arguments` must outlive the returned Command. Use
/// `fromToolCallOwned` when that guarantee doesn't hold.
/// `name` and `arguments` must outlive the returned Command — use
/// `fromToolCallOwned` to deep-copy when they don't.
pub fn fromToolCall(tool_name: []const u8, arguments: ?std.json.Value) Command {
return .{ .tool_call = .{ .name = tool_name, .args = arguments } };
}
/// Deep-copies `arguments` into `arena` so the Command can outlive the
/// caller's args buffer (e.g. the self-heal path returns Commands across
/// an arena deinit).
pub fn fromToolCallOwned(arena: std.mem.Allocator, tool_name: []const u8, arguments: ?std.json.Value) std.mem.Allocator.Error!Command {
const owned_name = if (schema.findSchemaCanonical(schema.globalSchemas(), tool_name)) |s| s.tool_name else try arena.dupe(u8, tool_name);
const owned_args = if (arguments) |v| try dupeJsonValue(arena, v) else null;
return .{ .tool_call = .{ .name = owned_name, .args = owned_args } };
}
/// Walks `.lp` content line-by-line, gluing multi-line `'''…'''` blocks
/// (today: `/eval`, `/extract`; any single-required-string-field tool
/// qualifies) into a single entry. Comments and blank lines surface as
/// `.comment` entries so the script replay can attach prefacing comments
/// to the next executable line.
/// Iterates `.lp` content, gluing multi-line `'''…'''` blocks into a
/// single entry. Comments surface as `.comment` so the replay can attach
/// the preceding comment to the next executable line.
pub const ScriptIterator = struct {
allocator: std.mem.Allocator,
lines: std.mem.SplitIterator(u8, .scalar),
@@ -168,14 +142,12 @@ pub const Command = union(enum) {
pub const Entry = struct {
line_num: u32,
/// Trimmed opener line — the only line for single-line entries,
/// the `/eval '''` / `/extract '''` opener for blocks. Display-only
/// (errors, REPL echo, heal-comment headers); use `raw_span` for
/// splices that need the full block body.
/// Trimmed opener line; use `raw_span` for splices that need the
/// full block body.
opener_line: []const u8,
/// The full slice of the original content buffer covering this entry,
/// including trailing newline(s). For multi-line blocks this spans
/// from the opener through the closing triple-quote line.
/// Slice of the original content buffer covering this entry,
/// trailing newline included. Multi-line blocks span opener
/// through closing triple-quote.
raw_span: []const u8,
command: Command,
};
@@ -225,8 +197,6 @@ pub const Command = union(enum) {
quote_type: QuoteType,
};
/// `/eval '''` or `/extract '''` (and any other single-required-string-field
/// tool followed by a bare triple-quote token).
fn tryBlockOpener(_: *ScriptIterator, line: []const u8, schemas: []const schema.SchemaInfo) ParseError!?BlockOpener {
if (line.len < 2 or line[0] != '/') return null;
const split = schema.splitNameRest(line[1..]) orelse return null;
@@ -239,7 +209,6 @@ pub const Command = union(enum) {
fn collectMultiLineBlock(self: *ScriptIterator, quote_type: QuoteType) std.mem.Allocator.Error!?[]const u8 {
const closer = quote_type.toLiteral();
var parts: std.ArrayList(u8) = .empty;
// toOwnedSlice empties `parts`, so this defer is a no-op on success.
defer parts.deinit(self.allocator);
while (self.lines.next()) |line| {
self.line_num += 1;
@@ -250,7 +219,7 @@ pub const Command = union(enum) {
if (parts.items.len > 0) {
try parts.append(self.allocator, '\n');
}
// Strip trailing CR only full trim would clobber indentation.
// Trim CR only; full trim would clobber indentation.
try parts.appendSlice(self.allocator, std.mem.trimRight(u8, line, "\r"));
}
return null;
@@ -258,8 +227,6 @@ pub const Command = union(enum) {
};
};
/// Deep-copy a `std.json.Value`, duplicating all owned strings and containers
/// into `a`. Used by `fromToolCallOwned` for the heal path.
fn dupeJsonValue(a: std.mem.Allocator, value: std.json.Value) std.mem.Allocator.Error!std.json.Value {
return switch (value) {
.null, .bool, .integer, .float => value,
@@ -296,16 +263,11 @@ fn formatToolCall(tc: Command.ToolCall, writer: *std.Io.Writer) std.Io.Writer.Er
const args = args_val.object;
if (args.count() == 0) return;
// Emit positional form only when the args reduce to the single required
// field: `/goto '<url>'`, `/click '<sel>'`, `/extract '<schema>'`. As soon
// as there are extra fields (`/selectOption selector=... value=...`,
// `/setChecked selector=... checked=false`), fall back to kv so the
// recording stays unambiguous.
// Positional form `/goto '<url>'` only when args reduce to the single
// required field; extra fields force kv so recordings stay unambiguous.
var positional_emitted: ?[]const u8 = null;
if (s_opt) |s| {
const has_one_required = s.required.len == 1;
// Count visible fields, ignoring default-true booleans that we'd skip
// in the kv pass below — they don't make the args "non-trivial".
var visible: usize = 0;
var it_v = args.iterator();
while (it_v.next()) |entry| {
@@ -322,8 +284,6 @@ fn formatToolCall(tc: Command.ToolCall, writer: *std.Io.Writer) std.Io.Writer.Er
}
}
// Emit kv for every key not already used as the positional, *and* skip
// default-true booleans so `/setChecked selector='#a'` round-trips.
var it = args.iterator();
while (it.next()) |entry| {
const key = entry.key_ptr.*;
@@ -340,8 +300,6 @@ fn isDefaultTrueBool(s: *const schema.SchemaInfo, key: []const u8, v: std.json.V
return v == .bool and v.bool and s.isFieldDefaultTrue(key);
}
/// Strings are always quoted (or triple-quoted when they contain newlines)
/// so a recorded line is unambiguous regardless of the value's content.
fn formatString(writer: *std.Io.Writer, s: []const u8) std.Io.Writer.Error!void {
if (std.mem.indexOfScalar(u8, s, '\n') != null) {
const q = QuoteType.pickFor(s).toLiteral();
@@ -355,7 +313,6 @@ fn formatString(writer: *std.Io.Writer, s: []const u8) std.Io.Writer.Error!void
try writeQuoted(writer, s);
}
/// Kv-value emission: strings via `formatString`; numbers/bools bare.
fn formatKvValue(writer: *std.Io.Writer, v: std.json.Value) std.Io.Writer.Error!void {
switch (v) {
.string => |s| try formatString(writer, s),
@@ -363,10 +320,7 @@ fn formatKvValue(writer: *std.Io.Writer, v: std.json.Value) std.Io.Writer.Error!
.float => |n| try writer.print("{d}", .{n}),
.bool => |b| try writer.writeAll(if (b) "true" else "false"),
.null => try writer.writeAll("null"),
else => {
// Arrays/objects emit as compact JSON.
std.json.Stringify.value(v, .{}, writer) catch return error.WriteFailed;
},
else => std.json.Stringify.value(v, .{}, writer) catch return error.WriteFailed,
}
}
@@ -410,9 +364,7 @@ pub const QuoteType = enum {
};
}
/// Pick the triple-quote delimiter that does not collide with `body`.
/// Defaults to `triple_single`; swaps to `triple_double` only when the
/// body already contains `'''`.
/// Default `'''`; swaps to `"""` only when the body already contains `'''`.
pub fn pickFor(body: []const u8) QuoteType {
if (std.mem.indexOf(u8, body, "'''") != null) return .triple_double;
return .triple_single;
@@ -454,12 +406,10 @@ test "parse: /goto positional" {
try testing.expectEqualStrings("https://example.com", cmd.tool_call.args.?.object.get("url").?.string);
}
test "parse: /click positional" {
test "parse: /click rejects positional (zero required fields)" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// click has zero required fields — `/click 'Login'` would be PositionalNotAllowed.
try testing.expectError(error.PositionalNotAllowed, Command.parse(arena.allocator(), "/click 'Login'"));
// The valid form is kv.
const cmd = try Command.parse(arena.allocator(), "/click selector='Login'");
try testing.expectEqualStrings("Login", cmd.tool_call.args.?.object.get("selector").?.string);
}
@@ -492,18 +442,16 @@ test "format: /goto round-trip" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
// Recorder always quotes string values for unambiguous round-trips.
try testing.expectEqualStrings("/goto 'https://example.com'", aw.written());
}
test "format: /click emits positional for single-required tools? no — click has zero required" {
test "format: /click stays kv (zero required fields)" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const cmd = try Command.parse(arena.allocator(), "/click selector='Login'");
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
// Click has zero required fields, so kv form is canonical.
try testing.expectEqualStrings("/click selector='Login'", aw.written());
}

View File

@@ -16,14 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! Slash-command schema: the parsed view of `browser_tools.tool_defs` that
//! both PandaScript (`Command.parse`/`format`) and the REPL Terminal consume.
//!
//! Each tool's JSON schema is reduced to a flat `SchemaInfo` (required names,
//! field types, hint slots, recording flags) so callers don't re-parse the
//! input_schema string. `globalSchemas()` is the lazy process-wide cache used
//! by `Command.parse`/`format` when no agent-scoped cache is plumbed (script
//! replay, recorder format, tests).
//! Flat view of `browser_tools.tool_defs` shared by PandaScript and the REPL.
//! `globalSchemas()` is the lazy process-wide cache.
const std = @import("std");
const lp = @import("lightpanda");
@@ -34,22 +28,18 @@ pub const FieldType = enum { string, integer, number, boolean, other };
pub const FieldEntry = struct {
name: []const u8,
field_type: FieldType,
/// Default for booleans declared with `"default": true` in the JSON schema.
/// Used by `Command.format` to omit `checked=true` when emitting `/setChecked`.
default_true: bool = false,
};
/// One slot of the REPL's argument-syntax hint, in display order: required
/// fields first, then optionals. `fragment` is pre-rendered as `<name>` for
/// required and `[name=…]` for optional so the renderer can hand it directly
/// to the shared writer.
/// REPL argument-syntax hint slot. `fragment` is pre-rendered as `<name>` for
/// required and `[name=…]` for optional.
pub const HintSlot = struct {
name: []const u8,
required: bool,
fragment: []const u8,
};
/// Upper bound on per-schema hint slots; lets the renderer use a stack array.
/// Asserted at schema build time so adding a tool with more fields fails loud.
pub const max_hint_slots: usize = 16;
@@ -57,20 +47,14 @@ pub const max_hint_slots: usize = 16;
pub const SchemaInfo = struct {
tool_name: []const u8,
description: []const u8,
input_schema_raw: []const u8,
required: []const []const u8,
fields: []const FieldEntry,
hints: []const HintSlot,
/// Mirrors `ToolDef.recorded` — kept on SchemaInfo so the script layer
/// doesn't have to re-resolve via `tool_defs` for every command.
recorded: bool,
can_heal: bool,
produces_data: bool,
parameters: std.json.Value,
/// True when this tool's args fit a multi-line `/<name> '''…'''` opener:
/// exactly one required field, and that field is a string. Used by
/// `Command.ScriptIterator` to detect block openers.
pub fn isMultiLineCapable(self: *const SchemaInfo) bool {
return self.required.len == 1 and self.fieldType(self.required[0]) == .string;
}
@@ -107,7 +91,6 @@ fn buildOne(arena: std.mem.Allocator, td: browser_tools.ToolDef, parsed: std.jso
var info: SchemaInfo = .{
.tool_name = td.name,
.description = td.description,
.input_schema_raw = td.input_schema,
.required = &.{},
.fields = &.{},
.hints = &.{},
@@ -225,19 +208,14 @@ pub fn splitNameRest(input: []const u8) ?Split {
};
}
/// Parse `rest` (the args portion of a slash command) into a `std.json.Value`
/// shaped for the tool. Returns null when the schema takes no args and `rest`
/// is empty; that lets the caller pass `null` straight to `tool_executor.call`
/// without allocating an empty object.
/// Parse `rest` (args portion of a slash command) into a `std.json.Value`.
/// Returns null when the schema takes no args and `rest` is empty.
///
/// Argument-binding rules:
/// - Bare `{json}` payload returned as-is after JSON parse. Pass-through
/// avoids re-stringifying the blob the LLM emitted.
/// - A single leading positional token binds to the schema's sole required
/// field when `schema.required.len == 1`. Multiple positionals (or one
/// positional with `required.len != 1`) error.
/// - Everything else is `key=value`. Coercion: integer/number/boolean
/// fields parse their respective types; anything else stays a string.
/// - Bare `{json}` payload returned as-is.
/// - Single leading positional binds to `schema.required[0]` when
/// `schema.required.len == 1`. Otherwise positionals error.
/// - Everything else is `key=value` with type coercion via `coerce`.
pub fn parseValue(arena: std.mem.Allocator, schema: *const SchemaInfo, rest: []const u8) ParseError!?std.json.Value {
if (rest.len == 0) {
if (schema.required.len > 0) return error.MissingRequired;
@@ -264,8 +242,8 @@ pub fn parseValue(arena: std.mem.Allocator, schema: *const SchemaInfo, rest: []c
list.appendAssumeCapacity(.{ .key = tok[0..eq], .value = stripQuotes(tok[eq + 1 ..]) });
}
// Default-true required booleans (e.g. setChecked.checked) are filled in
// when omitted, so `/setChecked selector='#a'` works without `checked=true`.
// 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| {
@@ -291,9 +269,8 @@ const KvPair = struct {
value: []const u8,
};
/// Split `input` into tokens, treating `"…"` and `'…'` as a single token (the
/// surrounding quotes are stripped at value-extraction time, not here).
/// Tokens may contain `=`.
/// Tokenize on whitespace. `"…"` and `'…'` (single or triple) are kept whole;
/// quote stripping happens later. Tokens may contain `=`.
fn tokenize(arena: std.mem.Allocator, input: []const u8) ParseError![][]const u8 {
var out: std.ArrayList([]const u8) = .empty;
@@ -369,17 +346,14 @@ fn coerce(arena: std.mem.Allocator, schema: *const SchemaInfo, key: []const u8,
return .{ .string = try arena.dupe(u8, value) };
}
// --- Global lazy schema cache ---
//
// Single-threaded REPL only — if multi-threaded usage emerges, swap the guard
// for `std.Once` semantics.
// --- Global lazy schema cache (process-lifetime) ---
var global_schemas_storage: [browser_tools.tool_defs.len]SchemaInfo = undefined;
var global_arena: std.heap.ArenaAllocator = undefined;
var global_once = std.once(initGlobal);
/// Process-lifetime schema cache. Panics on init failure — `tool_defs` is
/// compile-time constant, so a parse/build error is a build-time bug.
/// Panics on init failure — `tool_defs` is compile-time constant, so any
/// parse/build error is a build-time bug.
pub fn globalSchemas() []const SchemaInfo {
global_once.call();
return global_schemas_storage[0..browser_tools.tool_defs.len];
@@ -405,19 +379,15 @@ const testing = std.testing;
test "globalSchemas: comptime tool defs reduce cleanly" {
const schemas = globalSchemas();
try testing.expect(schemas.len == browser_tools.tool_defs.len);
// /goto has one required string field — multi-line capable.
const goto = findSchema(schemas, "goto").?;
try testing.expect(goto.isMultiLineCapable());
try testing.expect(goto.recorded);
// /scroll has zero required fields — not multi-line capable.
const scroll = findSchema(schemas, "scroll").?;
try testing.expect(!scroll.isMultiLineCapable());
try testing.expect(scroll.recorded);
// /tree is read-only; should not be recorded.
const tree = findSchema(schemas, "tree").?;
try testing.expect(!tree.recorded);
try testing.expect(tree.produces_data);
// /setChecked's `checked` field carries default=true.
const set_checked = findSchema(schemas, "setChecked").?;
var checked_default_true = false;
for (set_checked.fields) |f| {
@@ -425,7 +395,6 @@ test "globalSchemas: comptime tool defs reduce cleanly" {
}
try testing.expect(checked_default_true);
// canonical lookup matches search lookup
try testing.expect(findSchemaCanonical(schemas, "goto") == goto);
try testing.expect(findSchemaCanonical(schemas, "unknown_tool") == null);
}