diff --git a/.changeset/explicit-browser-executors.md b/.changeset/explicit-browser-executors.md new file mode 100644 index 0000000..6bdcafd --- /dev/null +++ b/.changeset/explicit-browser-executors.md @@ -0,0 +1,36 @@ +--- +"gruntend-sdk": minor +--- + +Make code-plan execution an explicit, first-class application choice. + +### Migration + +- Pass an object-based `CodePlanExecutor` to every direct `runCodePlan()` call and every `createGruntendClient()` call. +- Preserve current behavior by importing `createJailJsCodePlanExecutor()` from `gruntend-sdk/executor/jailjs` and passing its result as `executor`. +- Override the client executor for an individual plan with the existing per-run `executor` option. + +### First-class browser executors + +- Add stable executor profiles with an ID, a `controlled` or `isolated` trust label, and a generated-UI capability declaration. +- Export the executor contract from `gruntend-sdk/executor`. +- Export explicit JailJS and QuickJS strategies from `gruntend-sdk/executor/jailjs` and `gruntend-sdk/executor/quickjs-browser`. +- Keep JailJS as the lightweight `controlled` executor with its ES5 transformation, dotted tools, generated UI, and `maxOps` operation budget. JailJS is not presented as a hostile-code isolation boundary. +- Add an asynchronously initialized `quickjs-browser` executor backed by the original QuickJS engine compiled to WebAssembly. It creates a fresh runtime and context for every plan, copies supported values rather than exposing host objects, bridges asynchronous tools through guest-owned promises, forwards console events, and enforces memory, stack, and deadline limits. + +### Strict selection and failure behavior + +- Pin the initial plan, Promise continuations, render closure, and event closures to exactly one selected executor. +- Normalize initialization, unsupported-UI, abort, and execution failures. +- Never automatically fall back, downgrade, or replay a complete plan through a second executor. This prevents completed effects from being repeated after a later failure. + +### Generated UI lifecycle + +- Represent QuickJS templates and retained closures as guest-owned values behind the existing synchronous generated-UI contract. +- Add idempotent generated-UI cleanup so replacement and unmount release retained QuickJS functions, context handles, and runtime memory. + +### Demo and verification + +- Add shared JailJS and QuickJS conformance coverage for data, asynchronous tools, `Promise.all`, dotted tools, validation, expected failures, unexpected faults, console events, aborts, templates, closure state, cleanup, and limits. +- Add strict executor-selection, no-replay, lifecycle, and host-isolation tests. +- Let the SvelteKit restaurant demo choose JailJS or lazily initialized QuickJS/WASM for each complete plan. diff --git a/PHILOSOPHY.md b/PHILOSOPHY.md index f0918fb..f1ff52a 100644 --- a/PHILOSOPHY.md +++ b/PHILOSOPHY.md @@ -107,7 +107,7 @@ Applications own model choice, prompts, examples, evaluation, and whether genera A code plan is not passed to `eval` or `Function`. Gruntend executes it through a `CodePlanExecutor` with only explicit globals: `input`, registered `tools`, safe `console`, and optional `html`. -JailJS is the pinned dependency and default executor. It provides the current AST interpreter and scope boundary. It is not the product's authority boundary, and Gruntend does not describe it as a security sandbox. Applications may supply another `CodePlanExecutor`; the tool registry, validation, handlers, results, and lifecycle events remain unchanged. +Executor selection is explicit. JailJS is the pinned lightweight `controlled` executor and is not a security sandbox. The QuickJS/WASM browser executor runs plans in a separate JavaScript realm and heap and reports `isolated`. Neither executor is the product's authority boundary: the tool registry, validation, and application-owned handlers remain responsible for effects. Each plan and its closures stay pinned to one selected executor, with no fallback or whole-plan replay. ## Core belief diff --git a/README.md b/README.md index b10a110..9991fdb 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ Typed tool namespaces and generated UI for app-owned capabilities. Gruntend lets an app expose a small capability surface to an LLM, receive a JavaScript code plan, and execute that plan through app-owned handlers. In UI mode, the plan can return safe `html` tagged-template UI with local component state. +> [!CAUTION] +> Gruntend is a public beta under active development and is not production-ready. Do not expose sensitive production capabilities or rely on an executor as the only security boundary. + ```text defineTools() app capability surface generateCodePlan LLM → JavaScript plan @@ -23,6 +26,7 @@ pnpm add gruntend-sdk valibot ```ts import { createGruntendClient } from "gruntend-sdk/client"; +import { createJailJsCodePlanExecutor } from "gruntend-sdk/executor/jailjs"; import { defineTools } from "gruntend-sdk/tool"; import * as v from "valibot"; @@ -73,7 +77,10 @@ const tools = defineTools({ }, }); -const gruntend = createGruntendClient({ tools }); +const gruntend = createGruntendClient({ + tools, + executor: createJailJsCodePlanExecutor(), +}); ``` A tool contract is just: @@ -193,8 +200,8 @@ console html when UI mode is provided at runtime ``` -With the default JailJS executor, standard built-ins such as `Promise` are still available for `async` code and `Promise.all(...)`. -The default executor applies an operation budget with `maxOps`; use a custom executor when you need a worker, process, isolate, or remote sandbox as the outer execution boundary. +With the JailJS executor selected, standard built-ins such as `Promise` are still available for `async` code and `Promise.all(...)`. +JailJS applies an operation budget with `maxOps`. The QuickJS browser executor instead enforces WASM runtime memory, stack, and deadline limits. The built-in budget is an interpreter operation budget, not a wall-clock or memory isolation boundary. Use normal JavaScript for orchestration: @@ -223,22 +230,24 @@ return err({ ## Bring your own executor -JailJS is the default code-plan executor. Applications can replace it with their own interpreter, worker, isolate, or remote execution service without changing tool contracts or handlers. +Executor selection is explicit. JailJS is a lightweight `controlled` executor; the QuickJS browser executor uses a separate WASM realm and reports `isolated`. A run uses exactly one selected executor and Gruntend never falls back to or replays the plan with another executor. ```ts -import type { CodePlanExecutor } from "gruntend-sdk/code-plan"; - -const executor: CodePlanExecutor = async ({ - code, - globals, - maxOps, - signal, -}) => { - return myInterpreter.evaluate(code, { - globals, - maxOps, - signal, - }); +import type { CodePlanExecutor } from "gruntend-sdk/executor"; + +const executor: CodePlanExecutor = { + profile: { + id: "my-browser-runtime", + trust: "controlled", + supportsGeneratedUi: false, + }, + execute({ code, globals, maxOps, signal }) { + return myInterpreter.evaluate(code, { + globals, + maxOps, + signal, + }); + }, }; const gruntend = createGruntendClient({ @@ -247,8 +256,18 @@ const gruntend = createGruntendClient({ }); ``` -The executor receives Gruntend-owned globals: `input`, `tools`, a safe `console`, and `html` when UI mode is enabled. It also receives `maxOps` and the run `signal` so custom backends can enforce budgets and cancellation. If the signal is already aborted, Gruntend fails the run before invoking the executor. -The `code` value is the generated async function body; custom executors own any wrapping, parsing, or remote execution protocol they need. +The executor receives Gruntend-owned globals: `input`, `tools`, a safe `console`, and `html` when UI mode is enabled. It also receives `maxOps` and the run `signal` so custom backends can enforce budgets and cancellation. If the signal is already aborted, Gruntend fails the run before invoking the executor. The `code` value is the generated async function body. + +Use QuickJS in a browser by creating it asynchronously before client construction: + +```ts +import { createQuickJsBrowserCodePlanExecutor } from "gruntend-sdk/executor/quickjs-browser"; + +const executor = await createQuickJsBrowserCodePlanExecutor(); +const gruntend = createGruntendClient({ tools, executor }); +``` + +The SvelteKit demo defaults to JailJS and provides a compact per-run selector for trying QuickJS/WASM in the browser. Its temporary `500_000` operation allowance applies to JailJS runs. Node executors, Workers, transports, and asynchronous UI sessions are deferred work. ## Release process @@ -417,7 +436,8 @@ Use real LLM mode with `examples/sveltekit/.env`: ```env GRUNTEND_AGENT_MODE=openai OPENAI_API_KEY=your_key_here -OPENAI_MODEL=gpt-5.5 +OPENAI_MODEL=gpt-5.1 +OPENAI_SUGGESTION_MODEL=gpt-5-nano ``` Then open: diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 5ca8c9e..9a49a46 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -48,7 +48,7 @@ npm pack --pack-destination "$tmp_dir" --json cd "$tmp_dir" npm init -y npm install ./gruntend-sdk-0.1.0.tgz -node --input-type=module -e 'import { createGruntendClient } from "gruntend-sdk/client"; import { defineTools } from "gruntend-sdk/tool"; console.log(Boolean(createGruntendClient({ tools: defineTools({}) }).registry));' +node --input-type=module -e 'import { createGruntendClient } from "gruntend-sdk/client"; import { createJailJsCodePlanExecutor } from "gruntend-sdk/executor/jailjs"; import { defineTools } from "gruntend-sdk/tool"; console.log(Boolean(createGruntendClient({ tools: defineTools({}), executor: createJailJsCodePlanExecutor() }).registry));' node --input-type=module -e 'await Promise.all(["gruntend-sdk/client","gruntend-sdk/code-plan","gruntend-sdk/generate","gruntend-sdk/registry","gruntend-sdk/runtime","gruntend-sdk/tool","gruntend-sdk/ui","gruntend-sdk/ui/dom","gruntend-sdk/ui-runtime"].map((id) => import(id))); console.log("core subpath imports ok");' ``` diff --git a/examples/sveltekit/.env.example b/examples/sveltekit/.env.example index ab17ba7..45433a7 100644 --- a/examples/sveltekit/.env.example +++ b/examples/sveltekit/.env.example @@ -6,4 +6,5 @@ PUBLIC_GRUNTEND_AGENT_DEBUG=false # Required only when GRUNTEND_AGENT_MODE=openai. # OPENAI_API_KEY=sk-... -# OPENAI_MODEL=gpt-5.5 +# OPENAI_MODEL=gpt-5.1 +# OPENAI_SUGGESTION_MODEL=gpt-5-nano diff --git a/examples/sveltekit/README.md b/examples/sveltekit/README.md index 6b9f4d9..3ec3bca 100644 --- a/examples/sveltekit/README.md +++ b/examples/sveltekit/README.md @@ -10,6 +10,7 @@ This example has: - a Gruntend tool namespace over app-owned remote handlers - a chat-style agent route with a mocked code-plan generator - tagged-template message islands rendered through `gruntend-sdk/ui` and `gruntend-sdk/ui/svelte` +- a per-run browser executor selector: JailJS by default or lazily initialized QuickJS/WASM ## Run @@ -34,7 +35,8 @@ Then route `https://gruntend.com/example` to this app through your host or rever ```env GRUNTEND_AGENT_MODE=openai OPENAI_API_KEY=... -OPENAI_MODEL=gpt-5.5 +OPENAI_MODEL=gpt-5.1 +OPENAI_SUGGESTION_MODEL=gpt-5-nano ``` For a public demo, set an LLM provider spend cap and consider Cloudflare rate limiting or Turnstile before allowing planning requests. @@ -60,6 +62,8 @@ Summarize the restaurant data The agent route is mocked on purpose and returns deterministic Gruntend code plans without requiring `OPENAI_API_KEY`. The browser still executes those plans through the real Gruntend runtime, registered app tools, and app-owned handlers. +On the overview route, choose **JailJS · controlled** or **QuickJS · isolated** before running a task. The selection applies to the complete plan and its generated UI closures. QuickJS is loaded only when first selected. + The chat transcript renders generated UI returned from code plans as native JavaScript plus the Gruntend `html` tagged template. Event handlers use function interpolation, for example `onclick=${handler}`. The UI compiler rewrites those handlers to inert delegated attributes such as `data-gr-click="h0"`, so the browser never receives real inline JavaScript. The selectable menu prompt demonstrates local generated component state plus app tool calls for duplicated items. ## Switching back to a real LLM later diff --git a/examples/sveltekit/src/lib/agent/client.ts b/examples/sveltekit/src/lib/agent/client.ts index 1c2ee29..937470d 100644 --- a/examples/sveltekit/src/lib/agent/client.ts +++ b/examples/sveltekit/src/lib/agent/client.ts @@ -1,6 +1,25 @@ import { createGruntendClient } from "gruntend-sdk/client"; +import type { CodePlanExecutor } from "gruntend-sdk/executor"; +import { createJailJsCodePlanExecutor } from "gruntend-sdk/executor/jailjs"; import { appTools } from "./tools"; +export const jailJsExecutor = createJailJsCodePlanExecutor(); + +let quickJsExecutorPromise: Promise | undefined; + +export function getQuickJsBrowserExecutor(): Promise { + return (quickJsExecutorPromise ??= import( + "gruntend-sdk/executor/quickjs-browser" + ).then(({ createQuickJsBrowserCodePlanExecutor }) => + createQuickJsBrowserCodePlanExecutor({ + memoryLimitBytes: 32 * 1024 * 1024, + maxStackBytes: 512 * 1024, + timeoutMs: 10_000, + }), + )); +} + export const gruntend = createGruntendClient({ tools: appTools, + executor: jailJsExecutor, }); diff --git a/examples/sveltekit/src/lib/agent/planner-instructions.ts b/examples/sveltekit/src/lib/agent/planner-instructions.ts index ea32298..4c5f1e9 100644 --- a/examples/sveltekit/src/lib/agent/planner-instructions.ts +++ b/examples/sveltekit/src/lib/agent/planner-instructions.ts @@ -13,6 +13,7 @@ Interpret restaurant language according to these application rules: - A preview action may update closure state only. Application mutations must remain behind one clearly labeled confirmation action. - After confirmation, render the records returned by the mutation handlers and a concise completion message. - Orders contain a customer, assigned floor staff member, service type, party size, lifecycle timestamps, total, historical item-price snapshots, an optional physical table, and an optional payment. Use orders.list for operational analysis instead of inferring sales from menu records. +- For tasks involving today or relative dates, use the supplied currentTime value and copy it into the generated plan input. Date is not an available plan global. - Exclude cancelled orders from revenue, average-ticket, and item-popularity calculations unless the user explicitly asks about cancellations. Treat only paid payments attached to served orders as realized revenue. Tips are separate from order amount. - Customer questions may combine orders and item lines with customer loyalty tier. Derive visit count and spend from actual orders; never invent or reuse a stored aggregate. - Staff-performance questions may combine assignedUserId, users.list, and shifts.list. Do not attribute an order to staff who are not represented by the order relationship. diff --git a/examples/sveltekit/src/lib/remote/agent.remote.ts b/examples/sveltekit/src/lib/remote/agent.remote.ts index 51627ca..9e034fa 100644 --- a/examples/sveltekit/src/lib/remote/agent.remote.ts +++ b/examples/sveltekit/src/lib/remote/agent.remote.ts @@ -45,13 +45,21 @@ const suggestAgentTaskSchema = v.object({ type AgentPlannerMode = "mock" | "openai"; +const defaultPlannerModelId = "gpt-5.1"; +const defaultSuggestionModelId = "gpt-5-nano"; +const plannerCacheSessionId = "gruntend-demo-planner-v1"; +const suggestionCacheSessionId = "gruntend-demo-task-suggestion-v1"; + export const getAgentPlannerInfo = query(async () => { const mode = resolvePlannerMode(); return { generator: mode === "mock" ? ("mock" as const) : ("pi-ai" as const), mode, - model: mode === "mock" ? "mock-planner" : env.OPENAI_MODEL || "gpt-5.5", + model: + mode === "mock" + ? "mock-planner" + : env.OPENAI_MODEL || defaultPlannerModelId, }; }); @@ -85,7 +93,9 @@ export const suggestAgentTask = command( ); } - const model = resolveOpenAiModel(env.OPENAI_MODEL || "gpt-5.5"); + const model = resolveOpenAiModel( + env.OPENAI_SUGGESTION_MODEL || defaultSuggestionModelId, + ); const context = { platform: event.platform }; const [ menus, @@ -154,6 +164,8 @@ export const suggestAgentTask = command( apiKey, reasoning: model.reasoning ? "low" : undefined, maxTokens: 800, + cacheRetention: "long", + sessionId: suggestionCacheSessionId, }, ); @@ -200,9 +212,6 @@ export const generateAgentPlan = command( generator: "mock" as const, model: "mock-planner", plan: createMockPlan(prompt.trim()), - stopReason: "stop", - usage: undefined, - responseId: `mock_${Date.now()}`, }; } @@ -213,12 +222,13 @@ export const generateAgentPlan = command( ); } - const model = resolveOpenAiModel(env.OPENAI_MODEL || "gpt-5.5"); + const model = resolveOpenAiModel(env.OPENAI_MODEL || defaultPlannerModelId); const context = { platform: event.platform }; const promptRequest = { tools: appTools, task: prompt.trim(), input: { + currentTime: new Date().toISOString(), menus: await listMenus(context), orders: await listOrders(context), customers: await listCustomers(context), @@ -232,7 +242,7 @@ export const generateAgentPlan = command( }; const defaultPrompt = createCodePlanPrompt(promptRequest); const chartRequirement = requiresChart(prompt) - ? "\n\nRequired output for this task: the generated interface must include a visible, accessible chart. Use native SVG or a registered renderer; do not return only metrics, rows, or a table." + ? "\n\nRequired output for this task: the generated interface must include a visible, accessible chart. Use native SVG; do not return only metrics, rows, or a table." : ""; const codePlanPrompt = { system: `${defaultPrompt.system}\n\nApplication-owned planning policy:\n${restaurantPlannerInstructions}`, @@ -247,6 +257,8 @@ export const generateAgentPlan = command( apiKey, reasoning: model.reasoning ? "low" : undefined, maxTokens: 5000, + cacheRetention: "long", + sessionId: plannerCacheSessionId, }, }), ); @@ -258,15 +270,14 @@ export const generateAgentPlan = command( durationMs: Date.now() - startedAt, inputTokens: generated.message.usage.input, outputTokens: generated.message.usage.output, + cacheReadTokens: generated.message.usage.cacheRead, + cacheWriteTokens: generated.message.usage.cacheWrite, }); return { generator: "pi-ai" as const, model: model.id, plan: generated.plan, - stopReason: generated.message.stopReason, - usage: generated.message.usage, - responseId: generated.message.responseId, }; }, ); diff --git a/examples/sveltekit/src/routes/+page.svelte b/examples/sveltekit/src/routes/+page.svelte index b4a0de7..ca22599 100644 --- a/examples/sveltekit/src/routes/+page.svelte +++ b/examples/sveltekit/src/routes/+page.svelte @@ -15,7 +15,11 @@ Tags, TrendingUp, } from "@lucide/svelte"; - import { gruntend } from "$lib/agent/client"; + import { + getQuickJsBrowserExecutor, + gruntend, + jailJsExecutor, + } from "$lib/agent/client"; import { createBrowserHandlers } from "$lib/agent/handlers"; import { generateAgentPlan, @@ -37,6 +41,7 @@ import { toast } from "svelte-sonner"; type RunState = "idle" | "planning" | "running" | "done" | "error"; + type ExecutorChoice = "jailjs" | "quickjs-browser"; type ShowcaseTaskKind = | "price" | "menu" @@ -164,6 +169,8 @@ }, ] as const satisfies readonly ShowcaseTask[]; + const restaurantPlanMaxOps = 500_000; + const mutationTools = new Set([ "menus.create", "menu.item.create", @@ -187,12 +194,15 @@ const teamCount = $derived(usersResponse?.users.length); let prompt = $state(""); + let executorChoice = $state("jailjs"); + let activeExecutorId = $state(""); let suggestionLoading = $state(false); let state = $state("idle"); let resultUi = $state(); let resultTitle = $state(""); let errorMessage = $state(""); let debugDetails = $state(""); + let modelName = $state(""); let toolCallCount = $state(0); let completedToolCallCount = $state(0); let runtimeActivity = $state( @@ -272,11 +282,18 @@ if (state === "planning" || state === "running") return; prompt = task; + const selectedExecutorChoice = executorChoice; + const executorPromise = + selectedExecutorChoice === "quickjs-browser" + ? getQuickJsBrowserExecutor() + : Promise.resolve(jailJsExecutor); state = "planning"; resultUi = undefined; resultTitle = "Understanding your request"; errorMessage = ""; debugDetails = ""; + modelName = ""; + activeExecutorId = ""; toolCallCount = 0; completedToolCallCount = 0; runtimeActivity = "Waiting for the model to return a JavaScript plan"; @@ -288,11 +305,13 @@ prompt: task, })) as AgentGenerationEnvelope; const plan = envelope.plan; + modelName = envelope.model ?? "unknown"; resultTitle = plan.summary; debugDetails = JSON.stringify( { generator: envelope.generator, model: envelope.model, + executor: selectedExecutorChoice, summary: plan.summary, input: plan.input, code: plan.code, @@ -302,11 +321,17 @@ ); state = "running"; - runtimeActivity = "Starting the controlled interpreter"; + runtimeActivity = + selectedExecutorChoice === "quickjs-browser" + ? "Initializing the QuickJS/WASM executor" + : "Starting the JailJS executor"; + const executor = await executorPromise; const result = await gruntend.runCodePlan(plan.code, { id: "restaurant-dashboard-plan", input: plan.input, retry: { maxAttempts: 2 }, + executor, + maxOps: restaurantPlanMaxOps, handlers: createBrowserHandlers({ canMutate: () => generatedActionRunning, }), @@ -360,7 +385,8 @@ console.debug("[juniper operation]", event); if (event.type === "plan.started") { - runtimeActivity = "Interpreter started with registered application tools"; + activeExecutorId = event.executorId ?? ""; + runtimeActivity = `${event.executorId ?? "Executor"} started with registered application tools`; } else if (event.type === "tool.started") { toolCallCount += 1; runtimeActivity = `Calling ${event.tool}`; @@ -578,6 +604,23 @@
+