Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/explicit-browser-executors.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion PHILOSOPHY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 40 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";

Expand Down Expand Up @@ -73,7 +77,10 @@ const tools = defineTools({
},
});

const gruntend = createGruntendClient({ tools });
const gruntend = createGruntendClient({
tools,
executor: createJailJsCodePlanExecutor(),
});
```

A tool contract is just:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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({
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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");'
```

Expand Down
3 changes: 2 additions & 1 deletion examples/sveltekit/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion examples/sveltekit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions examples/sveltekit/src/lib/agent/client.ts
Original file line number Diff line number Diff line change
@@ -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<CodePlanExecutor> | undefined;

export function getQuickJsBrowserExecutor(): Promise<CodePlanExecutor> {
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,
});
1 change: 1 addition & 0 deletions examples/sveltekit/src/lib/agent/planner-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 21 additions & 10 deletions examples/sveltekit/src/lib/remote/agent.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
});

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -154,6 +164,8 @@ export const suggestAgentTask = command(
apiKey,
reasoning: model.reasoning ? "low" : undefined,
maxTokens: 800,
cacheRetention: "long",
sessionId: suggestionCacheSessionId,
},
);

Expand Down Expand Up @@ -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()}`,
};
}

Expand All @@ -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),
Expand All @@ -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}`,
Expand All @@ -247,6 +257,8 @@ export const generateAgentPlan = command(
apiKey,
reasoning: model.reasoning ? "low" : undefined,
maxTokens: 5000,
cacheRetention: "long",
sessionId: plannerCacheSessionId,
},
}),
);
Expand All @@ -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,
};
},
);
Expand Down
Loading
Loading