From 27c2fe00c791a85993bea79decf53392000ec63e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 31 May 2026 16:10:42 +0200 Subject: [PATCH] eval: support top-level await and return Falls back to compiling the script inside an async IIFE if the initial block-scoped compilation fails. This enables top-level await and return statements directly in the eval tool. --- docs/agent.md | 31 +++++++++++++++---------------- src/browser/tools.zig | 42 +++++++++++++++++++++--------------------- src/mcp/tools.zig | 26 +++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 38 deletions(-) diff --git a/docs/agent.md b/docs/agent.md index 2ee97269..39a95eb2 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -192,22 +192,21 @@ the end of the call. Adding a key (`lp.x = …`), updating a nested value update — even after a navigation, because the store lives Session-side, not on the page. -**Async eval.** If your `/eval` body returns a Promise, `runEval` -pumps the event loop until it settles, then surfaces the resolved value -(or the rejection as an error). Combined with the bridge this lets a -single `/eval` do an async `fetch` loop over `lp.*` data: +**Async eval.** Top-level `await` works directly — the body runs as an +async function, so use `return` to produce a value. `runEval` pumps the +event loop until it settles, then surfaces the resolved value (or the +rejection as an error). Combined with the bridge this lets a single +`/eval` do an async `fetch` loop over `lp.*` data: ```pandascript /eval ''' -(async () => { - for (const s of lp.front.stories) { - const html = await fetch('/item?id=' + s.id).then(r => r.text()); - const doc = new DOMParser().parseFromString(html, 'text/html'); - s.comments = [...doc.querySelectorAll('tr.athing.comtr')].slice(0, 3) - .map(r => r.querySelector('.commtext')?.textContent.trim()) - .filter(Boolean); - } -})() +for (const s of lp.front.stories) { + const html = await fetch('/item?id=' + s.id).then(r => r.text()); + const doc = new DOMParser().parseFromString(html, 'text/html'); + s.comments = [...doc.querySelectorAll('tr.athing.comtr')].slice(0, 3) + .map(r => r.querySelector('.commtext')?.textContent.trim()) + .filter(Boolean); +} ''' /eval ''' @@ -215,9 +214,9 @@ JSON.stringify(lp.front.stories) ''' ``` -An async IIFE with no explicit `return` resolves to `undefined`, which -the eval treats as silent — so the loop above prints nothing, and only -the final `JSON.stringify` lands on stdout. +A body with no explicit `return` resolves to `undefined`, which the eval +treats as silent — so the loop above prints nothing, and only the final +`JSON.stringify` lands on stdout. The store is **script-run scoped**: it's bound to the Session that runs the script, and goes away when that Session does. There is no diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 0474bda2..c7dd917b 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -192,7 +192,7 @@ pub const Tool = enum { .input_schema = url_params_schema, }, .eval => .{ - .description = "Evaluate JavaScript in the current page context. If a url is provided, it navigates to that url first. The `globalThis.lp` object exposes a Session-scoped bridge store: values written via `lp.foo = ...` auto-sync at end of eval, surviving navigation; values previously set via `/extract save=` or `/eval save=` appear as `lp.`.", + .description = "Evaluate JavaScript in the current page context. A bare trailing expression yields its value; top-level `await` and `return` are supported (the body then runs as an async function, so use `return` to produce a value). If a url is provided, it navigates to that url first. The `globalThis.lp` object exposes a Session-scoped bridge store: values written via `lp.foo = ...` auto-sync at end of eval, surviving navigation; values previously set via `/extract save=` or `/eval save=` appear as `lp.`.", .summary = "Run JavaScript in the page", .input_schema = minify( \\{ @@ -659,7 +659,7 @@ pub fn evalScript( ) ToolError!ToolResult { const z = try arena.dupeZ(u8, script); const page = try ensurePage(session, registry, null, null, null); - return runEval(arena, page, z); + return runEval(arena, page, z, null); } /// Schema-driven extraction. The schema is parsed in Zig so a syntax error @@ -678,7 +678,7 @@ pub fn extract( const script = try std.mem.concatWithSentinel(arena, u8, &.{ schema_walker_prefix, schema_json, schema_walker_suffix }, 0); const page = try ensurePage(session, registry, null, null, null); - return runEval(arena, page, script); + return runEval(arena, page, script, null); } // The schema literal is spliced between prefix and suffix verbatim — a format @@ -960,31 +960,27 @@ fn execEval(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.R const app_allocator = session.browser.app.allocator; const prelude = bridgePrelude(arena, &session.bridge_store) catch return ToolError.OutOfMemory; - _ = try runEval(arena, page, prelude); + _ = try runEval(arena, page, prelude, null); - // Block-scope so top-level `let`/`const` don't leak across calls. + // Block scope preserves a trailing expression's value and keeps top-level + // `let`/`const` from leaking; top-level `await`/`return` need the async IIFE. const block_script = std.fmt.allocPrintSentinel( arena, "{{\n{s}\n}}", .{args.script}, 0, ) catch return ToolError.OutOfMemory; - var result = try runEval(arena, page, block_script); - - // Recover from top-level `return` by retrying inside an IIFE. - if (result.is_error == true and std.mem.indexOf(u8, result.text, "Illegal return statement") != null) { - const iife_script = std.fmt.allocPrintSentinel( - arena, - "(function(){{ \"use strict\"; {s} }})()", - .{args.script}, - 0, - ) catch return ToolError.OutOfMemory; - result = try runEval(arena, page, iife_script); - } - if (result.is_error == true) return result; + const iife_script = std.fmt.allocPrintSentinel( + arena, + "(async function(){{ \"use strict\"; {s} }})()", + .{args.script}, + 0, + ) catch return ToolError.OutOfMemory; + var result = try runEval(arena, page, block_script, iife_script); + if (result.is_error) return result; // Sync lp.* before any queued navigation tears down this JS context. - const postlude_result: ?ToolResult = runEval(arena, page, bridge_postlude) catch |err| switch (err) { + const postlude_result: ?ToolResult = runEval(arena, page, bridge_postlude, null) catch |err| switch (err) { error.OutOfMemory => return ToolError.OutOfMemory, else => null, }; @@ -1043,7 +1039,9 @@ fn execExtract(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNod const eval_promise_timeout_ms: u32 = 30_000; -fn runEval(arena: std.mem.Allocator, page: *lp.Frame, script: [:0]const u8) ToolError!ToolResult { +/// Runs `fallback` only if `script` fails to *compile* — a compile failure ran +/// nothing, so retrying is safe; a runtime throw keeps `script`'s error. +fn runEval(arena: std.mem.Allocator, page: *lp.Frame, script: [:0]const u8, fallback: ?[:0]const u8) ToolError!ToolResult { var ls: lp.js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); @@ -1052,8 +1050,10 @@ fn runEval(arena: std.mem.Allocator, page: *lp.Frame, script: [:0]const u8) Tool try_catch.init(&ls.local); defer try_catch.deinit(); - const js_result = ls.local.compileAndRun(script, null) catch |err| + const js_result = ls.local.compileAndRun(script, null) catch |err| { + if (err == error.CompilationError) if (fallback) |fb| return runEval(arena, page, fb, null); return .{ .text = try formatJsError(arena, &try_catch, err), .is_error = true }; + }; if (js_result.isPromise()) { const promise = js_result.toPromise(); diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 98bdb878..6c3c836e 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -477,7 +477,7 @@ test "MCP - eval error reporting" { } }, out.written()); } -test "MCP - eval: top-level return retried inside IIFE" { +test "MCP - eval: top-level return runs in an async wrapper" { defer testing.reset(); var out: std.io.Writer.Allocating = .init(testing.arena_allocator); const server = try testLoadPage("about:blank", &out.writer); @@ -501,6 +501,30 @@ test "MCP - eval: top-level return retried inside IIFE" { } }, out.written()); } +test "MCP - eval: top-level await runs in an async wrapper" { + defer testing.reset(); + var out: std.io.Writer.Allocating = .init(testing.arena_allocator); + const server = try testLoadPage("about:blank", &out.writer); + defer server.deinit(); + + const msg = + \\{ + \\ "jsonrpc": "2.0", + \\ "id": 1, + \\ "method": "tools/call", + \\ "params": { + \\ "name": "eval", + \\ "arguments": { "script": "const v = await Promise.resolve(41); return v + 1;" } + \\ } + \\} + ; + try router.handleMessage(server, testing.arena_allocator, msg); + + try testing.expectJson(.{ .id = 1, .result = .{ + .content = &.{.{ .type = "text", .text = "42" }}, + } }, out.written()); +} + test "MCP - eval: let declaration does not leak across calls" { defer testing.reset(); var out: std.io.Writer.Allocating = .init(testing.arena_allocator);