[PR #261] [MERGED] feat: add quickjs middleware #310

Closed
opened 2026-06-05 17:22:36 -04:00 by yindo · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/langchain-ai/deepagentsjs/pull/261
Author: @hntrl
Created: 3/1/2026
Status: Merged
Merged: 3/6/2026
Merged by: @hntrl

Base: mainHead: hunter/repl


📝 Commits (8)

  • 819eaa3 feat(quickjs): add @langchain/quickjs — sandboxed JS/TS REPL with PTC, env isolation, and RLM support
  • 82771d6 chore: split changesets into quickjs minor + deepagents patch
  • d7f76cf refactor(quickjs): remove env/secrets, lazy runtime, buffered writes, fix thread_id propagation
  • 4c9c93f cr
  • 1d17979 cr
  • ece8a92 docs(quickjs): add README and LICENSE, fix lint errors
  • 9cfed2e cr
  • 78dca5a cr

📊 Changes

26 files changed (+2834 additions, -18 deletions)

View changed files

.changeset/deepagents-ptc-task.md (+7 -0)
.changeset/quickjs-repl.md (+10 -0)
📝 examples/package.json (+3 -0)
examples/repl/data-analysis-agent.ts (+50 -0)
examples/repl/rlm-agent.ts (+105 -0)
📝 libs/deepagents/src/index.ts (+1 -0)
📝 libs/deepagents/src/middleware/subagents.ts (+18 -2)
libs/providers/quickjs/.npmignore (+13 -0)
libs/providers/quickjs/LICENSE (+21 -0)
libs/providers/quickjs/README.md (+166 -0)
libs/providers/quickjs/package.json (+87 -0)
libs/providers/quickjs/src/index.ts (+41 -0)
libs/providers/quickjs/src/middleware.int.test.ts (+59 -0)
libs/providers/quickjs/src/middleware.test.ts (+125 -0)
libs/providers/quickjs/src/middleware.ts (+281 -0)
libs/providers/quickjs/src/session.test.ts (+458 -0)
libs/providers/quickjs/src/session.ts (+413 -0)
libs/providers/quickjs/src/transform.test.ts (+115 -0)
libs/providers/quickjs/src/transform.ts (+276 -0)
libs/providers/quickjs/src/types.ts (+73 -0)

...and 6 more files

📄 Description

Summary

Adds @langchain/quickjs — a sandboxed JavaScript/TypeScript REPL tool for deepagents, powered by QuickJS-NG compiled to WASM via quickjs-emscripten.

Motivation

LLMs are unreliable at arithmetic, data transformation, and multi-step logic. They hallucinate calculations, lose track of intermediate state, and can't natively express patterns like parallel fan-out or programmatic aggregation. The standard workaround — giving agents a code execution tool — traditionally requires either an unsandboxed Node.js eval (a security nightmare) or a heavyweight container/VM.

This PR introduces a third option: a WASM-sandboxed QuickJS interpreter that runs in-process with no network access, no filesystem access, and no Node.js APIs. The sandbox boundary is the WebAssembly memory model itself — guest code physically cannot reach host resources unless we explicitly bridge them in.

On top of this sandbox, there are two capabilities that make the REPL useful for deepagents:

1. Programmatic Tool Calling (PTC)

Any tool passed to a deepagents agent can be exposed inside the REPL as a typed async function. Instead of the LLM emitting tool calls one at a time through the standard request/response cycle, it writes code that calls tools directly — loops, conditionals, error handling, parallel execution, and result transformation all happen in code rather than in the LLM's attention.

const urls = ["/users", "/orders", "/products"];
const responses = await Promise.all(
  urls.map(u => tools.httpRequest({ url: "https://api.example.com" + u }))
);
const parsed = responses.map(r => JSON.parse(r));
const summary = { users: parsed[0].length, orders: parsed[1].length, products: parsed[2].length };
await writeFile("/summary.json", JSON.stringify(summary, null, 2));

As a specific carve-out of this pattern, PTC enables the Recursive Language Model (RLM) architecture: when the task tool is exposed inside the REPL, the LLM can spawn sub-agents in parallel via Promise.all + tools.task(), collect their results, and aggregate programmatically — all within a single js_eval call. This turns code into a control plane for multi-agent orchestration.

PTC configuration is progressive: false (off), true (all tools minus VFS defaults), string[] (allowlist), { include } / { exclude } (fine-grained control).

2. TypeScript Support & Cross-Eval State Persistence

LLMs naturally generate TypeScript (type annotations, interfaces, as casts). Rather than requiring the model to write pure JavaScript, we run an AST-based transform pipeline (acorn + estree-walker + magic-string) that strips TypeScript syntax, hoists top-level declarations to globalThis for persistence across evaluations, auto-returns the last expression, and wraps everything in an async IIFE.

This means:

  • const data = await readFile("/data.json") in eval #1data is available in eval #2
  • Type annotations, interfaces, and generics are silently stripped
  • Top-level await works naturally
  • Parse failures gracefully degrade (the raw code is wrapped and QuickJS reports the syntax error)

Changes

New package: @langchain/quickjs (libs/providers/quickjs/)

File Purpose
types.ts QuickJSMiddlewareOptions, ReplSessionOptions, ReplResult
session.ts ReplSession — lazy WASM runtime lifecycle, VFS bridge, console capture, PTC host functions, buffered writes, timeout management, serialization (toJSON/fromJSON)
transform.ts transformForEval() — AST pipeline: strip TS → remove imports/exports → hoist declarations to globalThis → auto-return → async IIFE wrap
middleware.ts createQuickJSMiddleware() — agent integration: injects js_eval tool, assembles dynamic system prompt (base + auto-generated PTC type signatures), resolves backend per-thread
utils.ts toCamelCase, formatReplResult, safeToJsonSchema, toolToTypeSignature
index.ts Barrel exports

68 unit tests across 4 test files + 1 integration test (session: 33, middleware: 9, transform: 16, utils: 10, integration: 1).

Key dependencies: quickjs-emscripten, @jitl/quickjs-ng-wasmfile-release-asyncify (asyncify variant needed for async host functions), acorn, @sveltejs/acorn-typescript, estree-walker, magic-string, json-schema-to-typescript, dedent.

deepagents core changes

libs/deepagents/src/middleware/subagents.ts — The task tool previously threw if invoked without a toolCall.id (which is always absent in PTC context). Now it gracefully returns the sub-agent's last message as a plain string when called via PTC, and returns the normal Command with state update when called as a standard tool. This enables the RLM pattern — tools.task() inside the REPL returns the sub-agent's response directly to the calling code.

libs/deepagents/src/index.ts — Exports StateAndStore type (needed by the middleware for backend resolution).

Examples (examples/repl/)

  • data-analysis-agent.ts — Agent with QuickJS REPL for data analysis tasks
  • rlm-agent.ts — Recursive Language Model example: PTC-enabled agent that spawns sub-agents inside the REPL via tools.task() + Promise.all

Architecture

Agent (with middleware)
  │
  ├── wrapModelCall (runs before each LLM call)
  │     ├── Filter PTC tools from agent's toolset
  │     ├── Generate system prompt (base + PTC type signatures)
  │     └── Append to system messages
  │
  └── js_eval tool (injected into agent's tools)
        ├── Get threadId from config.configurable.thread_id
        ├── Resolve backend from state via getCurrentTaskInput()
        ├── ReplSession.getOrCreate(threadId, { backend, tools })
        ├── transformForEval(code) → AST pipeline
        ├── session.eval(transformed, timeout)
        │     ├── ensureStarted() — lazy WASM boot on first call
        │     ├── Set interrupt handler (CPU timeout)
        │     ├── evalCodeAsync in QuickJS WASM
        │     ├── Poll promise state (async timeout)
        │     └── Return { ok, value/error, logs }
        ├── session.flushWrites(backend) — persist buffered writes
        └── formatReplResult → string for LLM

Inside QuickJS WASM sandbox:
  ├── console.{log,warn,error,info,debug} → captured to logs[]
  ├── readFile(path)  → backend.readRaw()
  ├── writeFile(path, content) → buffered to pendingWrites[]
  └── tools.name(input) → tool.invoke() via PTC bridge

Design Decisions

  • WASM variant: @jitl/quickjs-ng-wasmfile-release-asyncify — asyncify is required for evalCodeAsync which enables async host functions (VFS reads, PTC tool calls). The -ng variant uses QuickJS-NG (actively maintained fork).
  • Session singleton per thread: Sessions are deduped by threadId in a static Map. This ensures REPL state persists across multiple js_eval calls within an agent thread while preventing cross-thread contamination.
  • Lazy runtime: The QuickJS WASM runtime is not loaded until the first .eval() call. ReplSession.getOrCreate() is synchronous — it returns a lightweight handle immediately. This makes sessions safe to create during wrapModelCall and survive graph interrupts.
  • Buffered writes: writeFile() inside the sandbox pushes to a pendingWrites array synchronously. After eval, the caller flushes writes to the backend. This prevents partial writes from failed scripts and keeps the sandbox deterministic.
  • Session creation in tool handler, not wrapModelCall: LangGraph's wrapModelCall receives a runtime object that is intentionally frozen to {context, writer, interrupt, signal}configurable (with thread_id) is not exposed. Tool handlers receive the full RunnableConfig including config.configurable.thread_id. Therefore session lookup/creation happens in the js_eval tool handler.
  • No env/secrets system: The original design included environment variable isolation with secret management (plain strings, tool-restricted values, opaque secret placeholders). This was removed to reduce complexity — secrets management is better handled at the application/deployment layer rather than inside the REPL sandbox.
  • No heap persistence: quickjs-emscripten doesn't expose a way to create a runtime from existing WASM memory, so sessions are ephemeral to the process lifetime (not serializable to LangGraph checkpoints). The toJSON()/fromJSON() methods serialize only the session id — on restore, fromJSON() reconnects to the live runtime if it still exists, or creates a fresh session.
  • Negative timeout = no timeout: Setting executionTimeoutMs to a negative value disables both the CPU interrupt handler and the promise polling deadline, allowing unbounded execution for long-running PTC workflows.
  • Graceful transform fallback: If the AST parser fails (malformed code), the raw code is wrapped in an async IIFE anyway. QuickJS then reports the actual syntax error, which is more useful than an opaque parse failure.

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/langchain-ai/deepagentsjs/pull/261 **Author:** [@hntrl](https://github.com/hntrl) **Created:** 3/1/2026 **Status:** ✅ Merged **Merged:** 3/6/2026 **Merged by:** [@hntrl](https://github.com/hntrl) **Base:** `main` ← **Head:** `hunter/repl` --- ### 📝 Commits (8) - [`819eaa3`](https://github.com/langchain-ai/deepagentsjs/commit/819eaa3f90d681bf39a5547fa1139c6155e044bf) feat(quickjs): add @langchain/quickjs — sandboxed JS/TS REPL with PTC, env isolation, and RLM support - [`82771d6`](https://github.com/langchain-ai/deepagentsjs/commit/82771d638ba062dd952ae7b06c7be6c08de00f9b) chore: split changesets into quickjs minor + deepagents patch - [`d7f76cf`](https://github.com/langchain-ai/deepagentsjs/commit/d7f76cf76f8fe55e5cbfd935d71d0dd83ccfe711) refactor(quickjs): remove env/secrets, lazy runtime, buffered writes, fix thread_id propagation - [`4c9c93f`](https://github.com/langchain-ai/deepagentsjs/commit/4c9c93f32d2a4d1609ff8aac30a24d408c7e9033) cr - [`1d17979`](https://github.com/langchain-ai/deepagentsjs/commit/1d1797930ffee3784679b864de2311abae7fccba) cr - [`ece8a92`](https://github.com/langchain-ai/deepagentsjs/commit/ece8a92862862a7c97e9c1d39deccba57733caa0) docs(quickjs): add README and LICENSE, fix lint errors - [`9cfed2e`](https://github.com/langchain-ai/deepagentsjs/commit/9cfed2e3aeb5763690cdbe8aa680de2d38f87b86) cr - [`78dca5a`](https://github.com/langchain-ai/deepagentsjs/commit/78dca5ae0b2d299dffdb5e3bd839c937937f2436) cr ### 📊 Changes **26 files changed** (+2834 additions, -18 deletions) <details> <summary>View changed files</summary> ➕ `.changeset/deepagents-ptc-task.md` (+7 -0) ➕ `.changeset/quickjs-repl.md` (+10 -0) 📝 `examples/package.json` (+3 -0) ➕ `examples/repl/data-analysis-agent.ts` (+50 -0) ➕ `examples/repl/rlm-agent.ts` (+105 -0) 📝 `libs/deepagents/src/index.ts` (+1 -0) 📝 `libs/deepagents/src/middleware/subagents.ts` (+18 -2) ➕ `libs/providers/quickjs/.npmignore` (+13 -0) ➕ `libs/providers/quickjs/LICENSE` (+21 -0) ➕ `libs/providers/quickjs/README.md` (+166 -0) ➕ `libs/providers/quickjs/package.json` (+87 -0) ➕ `libs/providers/quickjs/src/index.ts` (+41 -0) ➕ `libs/providers/quickjs/src/middleware.int.test.ts` (+59 -0) ➕ `libs/providers/quickjs/src/middleware.test.ts` (+125 -0) ➕ `libs/providers/quickjs/src/middleware.ts` (+281 -0) ➕ `libs/providers/quickjs/src/session.test.ts` (+458 -0) ➕ `libs/providers/quickjs/src/session.ts` (+413 -0) ➕ `libs/providers/quickjs/src/transform.test.ts` (+115 -0) ➕ `libs/providers/quickjs/src/transform.ts` (+276 -0) ➕ `libs/providers/quickjs/src/types.ts` (+73 -0) _...and 6 more files_ </details> ### 📄 Description ## Summary Adds `@langchain/quickjs` — a sandboxed JavaScript/TypeScript REPL tool for deepagents, powered by QuickJS-NG compiled to WASM via quickjs-emscripten. ## Motivation LLMs are unreliable at arithmetic, data transformation, and multi-step logic. They hallucinate calculations, lose track of intermediate state, and can't natively express patterns like parallel fan-out or programmatic aggregation. The standard workaround — giving agents a code execution tool — traditionally requires either an unsandboxed Node.js `eval` (a security nightmare) or a heavyweight container/VM. This PR introduces a third option: a WASM-sandboxed QuickJS interpreter that runs in-process with **no network access, no filesystem access, and no Node.js APIs**. The sandbox boundary is the WebAssembly memory model itself — guest code physically cannot reach host resources unless we explicitly bridge them in. On top of this sandbox, there are two capabilities that make the REPL useful for deepagents: ### 1. Programmatic Tool Calling (PTC) Any tool passed to a deepagents agent can be exposed inside the REPL as a typed async function. Instead of the LLM emitting tool calls one at a time through the standard request/response cycle, it **writes code that calls tools directly** — loops, conditionals, error handling, parallel execution, and result transformation all happen in code rather than in the LLM's attention. ```typescript const urls = ["/users", "/orders", "/products"]; const responses = await Promise.all( urls.map(u => tools.httpRequest({ url: "https://api.example.com" + u })) ); const parsed = responses.map(r => JSON.parse(r)); const summary = { users: parsed[0].length, orders: parsed[1].length, products: parsed[2].length }; await writeFile("/summary.json", JSON.stringify(summary, null, 2)); ``` As a specific carve-out of this pattern, PTC enables the **Recursive Language Model (RLM)** architecture: when the `task` tool is exposed inside the REPL, the LLM can spawn sub-agents in parallel via `Promise.all` + `tools.task()`, collect their results, and aggregate programmatically — all within a single `js_eval` call. This turns code into a control plane for multi-agent orchestration. PTC configuration is progressive: `false` (off), `true` (all tools minus VFS defaults), `string[]` (allowlist), `{ include }` / `{ exclude }` (fine-grained control). ### 2. TypeScript Support & Cross-Eval State Persistence LLMs naturally generate TypeScript (type annotations, interfaces, `as` casts). Rather than requiring the model to write pure JavaScript, we run an AST-based transform pipeline (acorn + estree-walker + magic-string) that strips TypeScript syntax, hoists top-level declarations to `globalThis` for persistence across evaluations, auto-returns the last expression, and wraps everything in an async IIFE. This means: - `const data = await readFile("/data.json")` in eval #1 → `data` is available in eval #2 - Type annotations, interfaces, and generics are silently stripped - Top-level `await` works naturally - Parse failures gracefully degrade (the raw code is wrapped and QuickJS reports the syntax error) ## Changes ### New package: `@langchain/quickjs` (`libs/providers/quickjs/`) | File | Purpose | |---|---| | `types.ts` | `QuickJSMiddlewareOptions`, `ReplSessionOptions`, `ReplResult` | | `session.ts` | `ReplSession` — lazy WASM runtime lifecycle, VFS bridge, console capture, PTC host functions, buffered writes, timeout management, serialization (`toJSON`/`fromJSON`) | | `transform.ts` | `transformForEval()` — AST pipeline: strip TS → remove imports/exports → hoist declarations to `globalThis` → auto-return → async IIFE wrap | | `middleware.ts` | `createQuickJSMiddleware()` — agent integration: injects `js_eval` tool, assembles dynamic system prompt (base + auto-generated PTC type signatures), resolves backend per-thread | | `utils.ts` | `toCamelCase`, `formatReplResult`, `safeToJsonSchema`, `toolToTypeSignature` | | `index.ts` | Barrel exports | **68 unit tests** across 4 test files + **1 integration test** (session: 33, middleware: 9, transform: 16, utils: 10, integration: 1). Key dependencies: `quickjs-emscripten`, `@jitl/quickjs-ng-wasmfile-release-asyncify` (asyncify variant needed for async host functions), `acorn`, `@sveltejs/acorn-typescript`, `estree-walker`, `magic-string`, `json-schema-to-typescript`, `dedent`. ### `deepagents` core changes **`libs/deepagents/src/middleware/subagents.ts`** — The `task` tool previously threw if invoked without a `toolCall.id` (which is always absent in PTC context). Now it gracefully returns the sub-agent's last message as a plain string when called via PTC, and returns the normal `Command` with state update when called as a standard tool. This enables the RLM pattern — `tools.task()` inside the REPL returns the sub-agent's response directly to the calling code. **`libs/deepagents/src/index.ts`** — Exports `StateAndStore` type (needed by the middleware for backend resolution). ### Examples (`examples/repl/`) - **`data-analysis-agent.ts`** — Agent with QuickJS REPL for data analysis tasks - **`rlm-agent.ts`** — Recursive Language Model example: PTC-enabled agent that spawns sub-agents inside the REPL via `tools.task()` + `Promise.all` ## Architecture ``` Agent (with middleware) │ ├── wrapModelCall (runs before each LLM call) │ ├── Filter PTC tools from agent's toolset │ ├── Generate system prompt (base + PTC type signatures) │ └── Append to system messages │ └── js_eval tool (injected into agent's tools) ├── Get threadId from config.configurable.thread_id ├── Resolve backend from state via getCurrentTaskInput() ├── ReplSession.getOrCreate(threadId, { backend, tools }) ├── transformForEval(code) → AST pipeline ├── session.eval(transformed, timeout) │ ├── ensureStarted() — lazy WASM boot on first call │ ├── Set interrupt handler (CPU timeout) │ ├── evalCodeAsync in QuickJS WASM │ ├── Poll promise state (async timeout) │ └── Return { ok, value/error, logs } ├── session.flushWrites(backend) — persist buffered writes └── formatReplResult → string for LLM Inside QuickJS WASM sandbox: ├── console.{log,warn,error,info,debug} → captured to logs[] ├── readFile(path) → backend.readRaw() ├── writeFile(path, content) → buffered to pendingWrites[] └── tools.name(input) → tool.invoke() via PTC bridge ``` ## Design Decisions - **WASM variant**: `@jitl/quickjs-ng-wasmfile-release-asyncify` — asyncify is required for `evalCodeAsync` which enables async host functions (VFS reads, PTC tool calls). The `-ng` variant uses QuickJS-NG (actively maintained fork). - **Session singleton per thread**: Sessions are deduped by `threadId` in a static `Map`. This ensures REPL state persists across multiple `js_eval` calls within an agent thread while preventing cross-thread contamination. - **Lazy runtime**: The QuickJS WASM runtime is not loaded until the first `.eval()` call. `ReplSession.getOrCreate()` is synchronous — it returns a lightweight handle immediately. This makes sessions safe to create during `wrapModelCall` and survive graph interrupts. - **Buffered writes**: `writeFile()` inside the sandbox pushes to a `pendingWrites` array synchronously. After eval, the caller flushes writes to the backend. This prevents partial writes from failed scripts and keeps the sandbox deterministic. - **Session creation in tool handler, not wrapModelCall**: LangGraph's `wrapModelCall` receives a `runtime` object that is intentionally frozen to `{context, writer, interrupt, signal}` — `configurable` (with `thread_id`) is not exposed. Tool handlers receive the full `RunnableConfig` including `config.configurable.thread_id`. Therefore session lookup/creation happens in the `js_eval` tool handler. - **No env/secrets system**: The original design included environment variable isolation with secret management (plain strings, tool-restricted values, opaque secret placeholders). This was removed to reduce complexity — secrets management is better handled at the application/deployment layer rather than inside the REPL sandbox. - **No heap persistence**: quickjs-emscripten doesn't expose a way to create a runtime from existing WASM memory, so sessions are ephemeral to the process lifetime (not serializable to LangGraph checkpoints). The `toJSON()`/`fromJSON()` methods serialize only the session id — on restore, `fromJSON()` reconnects to the live runtime if it still exists, or creates a fresh session. - **Negative timeout = no timeout**: Setting `executionTimeoutMs` to a negative value disables both the CPU interrupt handler and the promise polling deadline, allowing unbounded execution for long-running PTC workflows. - **Graceful transform fallback**: If the AST parser fails (malformed code), the raw code is wrapped in an async IIFE anyway. QuickJS then reports the actual syntax error, which is more useful than an opaque parse failure. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
yindo added the pull-request label 2026-06-05 17:22:36 -04:00
yindo closed this issue 2026-06-05 17:22:36 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langchain-ai/deepagentsjs#310