Run JavaScript that an LLM wrote as a tool call: check it and apply safe syntax repairs, run it as an async function body under limits, and hand the model back either the result or a compact report it can act on. Four attempts show the loop.
using System.Text.Json; using RecursiveAI.OneEcma; using RecursiveAI.OneEcma.Diagnostics; // What a tool-calling host does with code a model wrote: check it, run it as an async function body // under limits, and hand back either the result or a compact report the model can act on. static string RunModelCode(string code) { // Every syntax error at once, before anything runs. Repairs are syntax-only and never change meaning. RepairResult repaired = ScriptEngine.CheckAndRepairAsyncBody(code, "tool.js"); if (repaired.Diagnostics.Count > 0) return DiagnosticRenderer.RenderAll(repaired.Diagnostics, new RenderOptions { Compact = true }); string notes = repaired.Changed ? DiagnosticRenderer.RenderRepairs(repaired.Repairs, [], new RenderOptions { Compact = true }) + "\n" : ""; var engine = new ScriptEngine(new ScriptEngineOptions { Limits = new EngineLimits { Fuel = 10_000_000, WallClockMs = 2_000, MaxAllocatedBytes = 64 << 20 }, Configure = e => e.MainRealm.DefineData(e.Global, "orders", JsValue.FromObject(JsonSchemaTools.DefineAsync(e.MainRealm, "orders", """{ "type": "object", "required": ["customer"], "properties": { "customer": { "type": "string" } } }""", _ => Task.FromResult(JsonSerializer.SerializeToElement(new[] { new { id = 1, total = 12.5, shipped = true }, new { id = 2, total = 40.0, shipped = false }, })))), JsAttributes.Method), }); try { JsValue result = engine.Await(engine.EvaluateAsyncBody(repaired.RepairedSource, "tool.js")); return notes + "result: " + engine.ToDisplayString(result); } catch (JsException e) { return notes + DiagnosticRenderer.Render(e.Report!, new RenderOptions { Compact = true }); } catch (JsLimitException e) { return notes + DiagnosticRenderer.Render(e.Report!, new RenderOptions { Compact = true }); } } string[] attempts = [ // First try: the model guessed the result's shape. """ const res = await orders({ customer: "harbor" }); const open = res.orders.filter(o => !o.shipped); return open.map(o => o.id).join(", "); """, // Second try, after reading the report. """ const list = await orders({ customer: "harbor" }); const open = list.filter(o => !o.shipped); return open.map(o => o.id).join(", "); """, // A typo the repair table fixes. """ const list = await orders({ customer: "harbor" }); return list.map(o => o.total).reduce((a, b) => a + b, 0)); """, // A call the tool's schema rejects. """ return (await orders({ customer: 7 })).length; """, ]; foreach (string code in attempts) { Console.WriteLine(RunModelCode(code)); Console.WriteLine(); }
error[TypeError]: Cannot read properties of undefined (reading 'filter') --> tool.js:2:18 1 | const res = await orders({ customer: "harbor" }); | ------------------------------------ `res` comes from here: Array (2 items) 2 | const open = res.orders.filter(o => !o.shipped); | ^^^^^^ `res.orders` is undefined = help: guard it: `res.orders?.filter` result: 2 repair[R001]: removed an extra closing delimiter --> tool.js:2:57 2 | return list.map(o => o.total).reduce((a, b) => a + b, 0)); | ^ removed result: 52.5 error[TypeError]: orders: argument 'args.customer' must be a string, got number 7 --> tool.js:1:15 1 | return (await orders({ customer: 7 })).length; | ^^^^^^ `args.customer` is number 7, expected a string = help: convert it first: String(value) = stack: orders [.NET] ← <top-level> (tool.js:1)
The first report is enough for the model to fix its code: res came from orders(…) and is an array of two items, so there is no res.orders. The second attempt is that fix. The third had an extra ), which the repair table removed before the run and listed in the report. The fourth passed a number where the tool’s schema wants a string.
The pieces
EvaluateAsyncBodyruns the text as the body of anasyncfunction, the goalnew AsyncFunction(body)uses.awaitandreturnwork at the top level, every declaration belongs to the call, and line and column in reports are those of the text you were given, so no wrapper shifts them. It returns the promise of the body;AwaitorAwaitAsyncwaits for it and throwsJsExceptionwith the explained report if the body failed.CheckAndRepairAsyncBodyparses with the same goal, returns every syntax and early error, and applies a repair only when it is the single plausible fix and the result parses. Fixes that could change meaning stay suggestions in the report. Repair is off unless you call it.RenderOptions.Compactkeeps the headline, the location, the underlined line with its label, where the value came from, the most useful help and a one-line stack, so a report costs a few lines of context.- Limits make a runaway answer end in a report like any other error: see Run untrusted JavaScript with limits.
Good to know
- Create a fresh engine per tool call, as above, so nothing one call declares leaks into the next. Within one engine,
EvaluateAsyncBodyalready gives each call its own scope. CheckAsyncBody(source, fileName)reports every syntax error without repairing anything.- Each repair rule, with examples, is in the auto-repair table. The wording of every syntax error is in syntax error wording.