### Summary
This PR optimizes memory usage by allowing us to use a single WASM
module. To do this we needed to fix two failure modes that occur when
multiple sessions share a single WASM module. Both are caused by the
async module loader creating asyncify suspensions (unwind/rewind cycles
on a module-global stack buffer) during import resolution.
__Failure 1__: Concurrent eval crash. The asyncify variant allows only
one async operation per module at a time. If two sessions eval
simultaneously and both hit an import, the second eval tries to suspend
asyncify while the first is already suspended. This crashes with
QuickJSAsyncifySuspended: Already suspended.
Fix: AsyncEvalQueue queues evalCodeAsync calls so only one runs at a
time.
__Failure 2__: Silent disposal corruption. When a session imports a
multi-file skill (e.g. an entry point that imports greet.js and
shout.js), each import causes an asyncify unwind/rewind cycle. The eval
succeeds. But when the session's runtime is disposed, the disposal does
not fully reset the module's asyncify stack buffer and the unwind/rewind
cycles left residual state (saved stack frames, rewind pointers) that
emscripten manages at the module level, not the runtime level. The next
session creates a fresh runtime on the same module, but the corrupted
asyncify state causes module loader callbacks to silently never fire.
Fix: synchronous module loader. Skill files are preloaded into a Map
before eval starts. The loader callback does a plain map.get(). No
await, no asyncify suspension, no residual state on disposal.
Note: The eval queue alone does not fix failure 2. The corruption
happens at runtime disposal, which runs outside the queue. Both fixes
are required.
#### What This PR Adds
- Replaces per-session WASM module instantiation with a process-global
singleton shared by all sessions. Each session still gets its own
runtime and context (full isolation); only the underlying WASM module is
shared. Reduces WASM memory from ~16 MB per session to ~16 MB total.
- Adds AsyncEvalQueue to serialize evalCodeAsync calls across sessions,
enforcing the asyncify one-concurrent-async-call-per-module constraint.
- Makes the module loader synchronous by preloading skill files into an
in-memory cache before eval (preloadReferencedSkills). Why is this
needed? An async module loader causes asyncify suspensions on each
import. Disposing a runtime after multi-file skill imports corrupts the
shared module's asyncify state and silently breaks the loader for all
subsequent sessions. The sync loader eliminates asyncify suspensions
from imports entirely.
#### Synchronous Module Loading
As stated above, synchronous module loading is a necessary change due to
async module loading causing asyncify suspensions on each import which
corrupts the singleton WASM module's asyncify state which silently
breaks the loader for all subsequent sessions. The following
demonstrates the before and after regarding this change:
__Before__:
User code: `import { run } from "@/skills/demo"`
1. eval() is called
2. evalCodeAsync() runs the code
3. QuickJS hits the import, calls the module loader callback
4. The callback is async: await ensureSkillLoaded("demo")
→ fetches /skills/demo/index.js from the backend (network I/O)
→ this await causes the first asyncify suspension in the WASM module
5. index.js has: import { greet } from "./greet.js"
6. QuickJS calls the loader again for greet.js
→ await ensureSkillLoaded() → network fetch
→ The second asyncify suspension in the WASM module is hit
7. index.js has: import { shout } from "./shout.js"
8. QuickJS calls the loader again for shout.js
→ await ensureSkillLoaded() → network fetch
→ third asyncify suspension in the WASM module is hit
9. eval completes, runtime is disposed
10. runtime.dispose() is called to clean up the session
11. The runtime disposal does NOT fully reset the module's asyncify
stack buffer. The 3 unwind/rewind cycles left residual state (saved
stack frames, rewind pointers, etc.) that the dispose path doesn't know
to clean up, because asyncify bookkeeping is managed by emscripten at
the module level, not by QuickJS at the runtime level.
12. Next session creates a fresh runtime on the same module, but the
module's asyncify machinery is corrupted. When QuickJS tries to call the
new session's module loader, the stale asyncify state causes the
callback to silently not fire. No crash, no error. Imports just resolve
to nothing.
__After__:
User code: `import { run } from "@/skills/demo"`
1. eval() is called
2. preloadReferencedSkills() runs BEFORE evalCodeAsync():
→ scans source code for "@/skills/*" specifiers
→ finds "demo"
→ fetches all 3 files from the backend into a Map:
"@/skills/demo" → source code
"@/skills/demo/greet.js" → source code
"@/skills/demo/shout.js" → source code
→ (this is async but happens OUTSIDE the WASM module so no asyncify)
3. evalCodeAsync() runs the code
4. QuickJS hits the import, calls the module loader callback
5. The callback is sync: map.get("@/skills/demo") → returns source
immediately
→ NO asyncify suspension
6. QuickJS resolves greet.js → map.get("@/skills/demo/greet.js") →
immediate
→ NO asyncify suspension
7. QuickJS resolves shout.js → map.get("@/skills/demo/shout.js") →
immediate
→ NO asyncify suspension
8. eval completes, runtime is disposed
9. Zero asyncify suspensions occurred → no residual state → module is
clean
10. Next session works perfectly
### Tests
- 4 new AsyncEvalQueue unit tests: serialization (no interleaving of
concurrent ops), return values, error isolation (rejected op doesn't
break queue), insertion order preservation
- All 160 existing tests pass (session, skills, middleware, transform,
utils, eval queue)
### Memory Benchmarking
#### Per-session module (before)
| Sessions | RSS Delta (MB) | Per-Session RSS (MB) | WASM Memory (MB) |
Module Init (ms) | Setup (ms) |
|----------|----------------|----------------------|------------------|------------------|------------|
| 1 | 0.53 | 0.53 | 16 | 11.4 | 15.9 |
| 5 | 2.13 | 0.43 | 80 | 30.2 | 37.9 |
| 10 | 5.28 | 0.53 | 160 | 31.1 | 42.8 |
| 15 | 5.53 | 0.37 | 240 | 57.4 | 72.1 |
| 20 | 6.80 | 0.34 | 320 | 73.6 | 91.1 |
---
#### Singleton (after)
| Sessions | RSS Delta (MB) | Per-Session RSS (MB) | WASM Memory (MB) |
Module Init (ms) | Setup (ms) |
|----------|----------------|----------------------|------------------|------------------|------------|
| 1 | 0.61 | 0.61 | 16 | 6.7 | 9.1 |
| 5 | 0.59 | 0.12 | 16 | 12.5 | 28.5 |
| 10 | 0.91 | 0.09 | 16 | 12.2 | 37.0 |
| 15 | 1.81 | 0.12 | 16 | 12.0 | 45.1 |
| 20 | 1.70 | 0.09 | 16 | 10.4 | 50.4 |
---
#### Comparison at 20 sessions (realistic workload)
| Metric | Before | After | Improvement |
|------------------------|---------|--------|-----------------|
| WASM virtual memory | 320 MB | 16 MB | 20x reduction |
| RSS delta | 6.80 MB | 1.70 MB| 4x reduction |
| Module instantiation | 73.6 ms | 10.4 ms| 7x reduction |
| Setup time | 91.1 ms | 50.4 ms| 1.8x reduction |
### Summary
- Rename `createQuickJSMiddleware` → `createREPLMiddleware` and
`QuickJSMiddlewareOptions` → `REPLMiddlewareOptions` to match the Python
`REPLMiddleware` naming
- Rename middleware registration from `"QuickJSMiddleware"` →
`"REPLMiddleware"`
- Rename default tool name from `"js_eval"` → `"eval"`, now configurable
via `toolName` option
- Add `captureConsole` option (default `true`) to toggle console output
capture
- Align default memory limit to 64 MiB (was 50 MiB) and timeout to 5s
(was 30s) to match Python
### Tests
- All 156 existing unit tests pass with updated references
- Updated `middleware.test.ts`, `middleware.int.test.ts`, and example
files to use new names
---------
Co-authored-by: Hunter Lovell <40191806+hntrl@users.noreply.github.com>
## Problem
When using `@langchain/google` as a model provider in the examples
workspace, the server crashes immediately with:
1. Runtime crash on startup:
```bash
ERR_PACKAGE_PATH_NOT_EXPORTED: Package subpath './utils/uuid' is not
defined by "exports" in @langchain/core/package.json
```
`@langchain/google` imports `@langchain/core/utils/uuid`, a subpath
added in `@langchain/core@1.1.42`. Both the examples workspace and
`libs/deepagents` were pinned to `^1.1.40` which does not export this
path.
2. TypeScript type error after fixing the runtime crash:
```bash
Type 'ChatGoogle' is not assignable to type 'BaseLanguageModel<any,
BaseLanguageModelCallOptions>'
```
Even after bumping `@langchain/core` in `examples/` only, pnpm kept two
separate copies in its store - `1.1.40` for `deepagents` and `1.1.42`
for `@langchain/google`. TypeScript treats them as structurally
incompatible types because the type import paths differ.
## Fix
- Bump `@langchain/core` from `^1.1.40` → `^1.1.42` in all
`../package.json` files
## Testing
```bash
cd examples/research
pnpm dlx @langchain/langgraph-cli dev
# -> No ERR_PACKAGE_PATH_NOT_EXPORTED and agent starts correctly
---------
Co-authored-by: Hunter Lovell <40191806+hntrl@users.noreply.github.com>
## Summary
Adds LangSmith code-language metadata to the QuickJS REPL evaluation
tool so tool inputs are correctly interpreted as JavaScript in traces.
## Changes
### @langchain/quickjs
- Added `metadata: { ls_code_input_language: "javascript" }` to the
`js_eval` tool definition in `createQuickJSMiddleware`.
- Extended middleware tool-registration tests to assert that `js_eval`
exposes `ls_code_input_language` metadata.
- Verified behavior with quickjs unit tests (`src/middleware.test.ts`).
### Summary
Adds functional skill modules to the QuickJS REPL. Skills can now ship
JS/TS entrypoints via a module: frontmatter key on SKILL.md, which the
model dynamically imports at eval time with await
import("@/skills/<name>").
When skillsBackend is passed to createQuickJSMiddleware, the middleware
reads skillsMetadata from the current task state before each eval, scans
the source for skill references, and pushes the metadata + backend into
the session. The session wires a QuickJS module loader that lazily
fetches skill files from the backend, strips TypeScript syntax, and
caches the result for the lifetime of the session. Skills that are
referenced but not available on the agent short-circuit with a clear
error before evaluation starts.
On the deepagents side, SkillMetadata gains an optional module field
(validated and normalized from frontmatter), and formatSkillsList now
advertises importable skills in the system prompt with an await
import(...) hint.
```ts
const backend = new FilesystemBackend({ rootDir: "/project", virtualMode: true });
const agent = createDeepAgent({
model: "claude-sonnet-4-6",
backend,
skills: ["/skills/"],
middleware: [
createQuickJSMiddleware({ skillsBackend: backend }),
],
});
```
### Tests
- Unit tests cover module path validation, TS syntax stripping, skill
loading (single/multi-file, error cases, bundle size cap), skill
reference scanning, module loader resolution and caching, and system
prompt formatting
- Ran an E2E eval that exercised the full pipeline from SKILL.md parse
through dynamic import and function call in the REPL, with a LangSmith
trace
### Benchmarks
#### memory: console_log concurrent
- Total time: 15820ms
| name | hz | min | max | mean | p75 | p99 | p995 | p999 | rme | samples
|
|--------------------------|---------|--------|--------|--------|--------|--------|--------|--------|--------|---------|
| 1 concurrent sessions | 145.91 | 1.8263 | 27.7702| 6.8536 | 9.1753 |
21.1492| 25.0420| 27.7702| ±5.30% | 730 |
| 8 concurrent sessions | 39.1773 | 15.5854| 61.5015| 25.5250| 27.8461|
49.0536| 61.5015| 61.5015| ±3.58% | 196 |
| 32 concurrent sessions | 13.9753 | 48.5504| 110.40 | 71.5547| 76.6394|
110.40 | 110.40 | 110.40 | ±3.72% | 70 |
---
#### memory: ptc_tools concurrent
- Total time: 16755ms
| name | hz | min | max | mean | p75 | p99 | p995 | p999 | rme | samples
|
|--------------------------|---------|--------|--------|--------|--------|--------|--------|--------|--------|---------|
| 1 concurrent sessions | 86.9205 | 4.1809 | 32.1833| 11.5048| 15.3222|
25.2676| 28.2697| 32.1833| ±4.76% | 435 |
| 8 concurrent sessions | 21.8310 | 33.5598| 69.0041| 45.8065| 50.8740|
60.2001| 69.0041| 69.0041| ±2.77% | 110 |
| 32 concurrent sessions | 7.0269 | 137.88 | 148.90 | 142.31 | 144.05 |
148.90 | 148.90 | 148.90 | ±0.70% | 36 |
---
#### throughput: single thread many iterations
- Total time: 31656ms
| name | hz | min | max | mean | p75 | p99 | p995 | p999 | rme | samples
|
|-------------------------------------------|--------|--------|--------|--------|--------|--------|--------|--------|--------|---------|
| 200 sequential evals (ptc + console.log) | 3.5897 | 240.92 | 347.19 |
278.58 | 289.81 | 341.02 | 347.19 | 347.19 | ±1.44% | 108 |
### Summary
ReplSession uses a static map to preserve QuickJS WASM runtime state
across js_eval calls within a run. Previously sessions were never
cleaned up, causing unbounded memory growth as each thread_id
accumulated a live WASM runtime for the lifetime of the process.
- Added ReplSession.deleteSession(key) to dispose of the QuickJS runtime
and removes the entry from the session map
- Added an afterAgent hook in createQuickJSMiddleware that calls
deleteSession for the current thread's session key at the end of each
agent run
Sessions still persist across all js_eval calls within a single run. The
map remains for correct concurrent thread isolation. Without it,
simultaneous runs from different threads would share a runtime.
### Tests
Unit tests covering the following scenarios:
- ReplSession.deleteSession disposes and removes an existing session
- ReplSession.deleteSession is a no-op for a key that does not exist
- afterAgent disposes the session for the finished thread
- afterAgent on a thread with no session does not throw
- afterAgent only removes the session for the finished thread, leaving
other active threads intact
### Summary
- Removed built-in readFile/writeFile globals from the QuickJS REPL.
File I/O is now exclusively through PTC tools, keeping the REPL a pure
JS sandbox with no implicit side effects
- ptc now accepts StructuredToolInterface instances directly alongside
tool name strings. Custom tools can be injected into the REPL without
being registered on the agent
- StateBackend now provides read-your-writes semantics within a single
js_eval superstep by reading via __pregel_read with fresh=true. Pregel
already tracks pending sends in task.writes; asking for a fresh read
applies them through the reducer, removing the need for a manual write
buffer
- Removed ptc: boolean shorthand from QuickJSMiddlewareOptions; use the
array or object forms instead
### Tests
- Added 12 unit tests to state.test.ts covering write-then-read, chained
edits, ls/grep/glob/readRaw visibility, upload/download, duplicate write
error, edit-nonexistent, and superstep boundary behavior
(commitSends/clearPending)
- Expanded middleware.test.ts with PTC instance injection coverage
- Verified end-to-end with createDeepAgent + createQuickJSMiddleware
across 7 scenarios (write-read, write-edit-read, write-ls, write-grep,
write-glob, cross-eval, upload-download); traces confirmed in LangSmith
---------
Co-authored-by: Hunter Lovell <40191806+hntrl@users.noreply.github.com>
### Summary
- Adds a FilesystemPermission class and a permissions parameter to
createDeepAgent and SubAgent that controls which filesystem operations
each agent is allowed to perform
- Rules are evaluated first-match-wins with a permissive default; mode:
"deny" blocks the operation before any backend call
- Permissions are closed over at tool-creation time — no runtime config
threading, no ToolPolicy in langchain-core
- ls, glob, and grep enforce permissions in two passes: the base path
check throws early; results are post-filtered to strip entries the
caller can't read
- Subagents receive the parent agent's permissions by default; setting
permissions on a SubAgent is a full replacement, not a merge
- FilesystemPermission, FilesystemPermissionOptions,
FilesystemOperation, and PermissionMode are exported from the top-level
package entry point
### Tests
- permissions/types.test.ts — FilesystemPermission construction,
validation (rejects relative paths, .., ~), defaults
- permissions/enforce.test.ts — validatePath, globMatch (wildcards,
brace expansion, dotfiles), decidePathAccess (first-match-wins,
operation mismatch, multiple ops/paths)
- middleware/fs.permissions.test.ts — each fs tool (read, write, edit,
ls, glob, grep) with allow/deny rules and mock backends; verifies
backend is never called on a denied path; verifies execute is unaffected
- permissions/agent.permissions.test.ts —
CreateDeepAgentParams.permissions type and default; wiring to
createFilesystemMiddleware
- middleware/subagents.permissions.test.ts — SubAgent.permissions type;
?? resolution (inherit vs override vs own deny);
createFilesystemMiddleware behavior for each resolved-permissions case
---------
Co-authored-by: Hunter Lovell <40191806+hntrl@users.noreply.github.com>
## Fix: `ERR_MODULE_NOT_FOUND` on Windows when building `deepagents`
### Problem
Building `libs/deepagents` on Windows produces a `dist/index.js` that
imports
local modules with `.ts` extensions instead of bundling them:
```js
import { createDeepAgent } from "./agent.ts";
import { ConfigurationError } from "./errors.ts";
```
This causes an immediate crash at runtime for any consumer of the
library:
```
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'C:\...\deepagentsjs\libs\deepagents\dist\agent.ts'
imported from C:\...\deepagentsjs\libs\deepagents\dist\index.js
```
**Root Cause**
The `external` filter in `libs/deepagents/tsdown.config.ts:` used a regex intended to match
only npm package names (which don't start with `.` or `/`):
```js
const external = [/^[^./]/];
```
On Unix absolute paths start with `/` so the regex correctly excludes them. On Windows absolute paths start with a drive letter (e.g. C:\...), which also doesn't start with `.` or `/` - so rolldown matches them as external, excludes them from the bundle, and leaves raw `.ts` references in the output.
**Fix**
Replace the regex with a function that explicitly excludes Windows-style
absolute paths:
```js
const external = (id: string) =>
!id.startsWith(".") && !id.startsWith("/") &&
!/^[A-Za-z]:[\\/]/.test(id);
```
After this change `pnpm -w run build` in `libs/deepagents` produces a fully
bundled `[dist/index.js]` with no `.ts` imports and examples run correctly on Windows.
Notes
- `deps.neverBundle` (the tsdown-recommended replacement for `[external]` is not yet functional in tsdown 0.21.7 / rolldown 1.0.0-rc.12 and causes a build error. This fix is the correct workaround until that API stabilises.
---------
Co-authored-by: Hunter Lovell <hunter@hntrl.io>
* feat(node-vfs): enhance VfsSandbox to support absolute paths in commands and add related integration tests
- Implemented a method to rewrite absolute paths in commands referencing VFS entries to point to the temporary directory.
- Added integration tests for executing multi-file Node.js projects with both absolute and relative paths.
- Updated existing tests to clarify path usage in commands.
* fix(node-vfs): add changeset for rewriting absolute VFS paths in execute() commands
* fix: ci
---------
Co-authored-by: wangjiangjian.0224 <wangjiangjian.0224@bytedance.com>
* update backend protocol interface, types, and utils
* refactor state backend
* refactor store backend
* clean up
* unit tests
* refactor filesystem
* unit tests
* skip binary files in literal search for filesystem backend
* refactor base sandbox
* base sandbox unit tests
* refactor local shell backend
* refactor composite backend
* composite and local shell backend changes
* refactor fs middleware
* unit tests and fixed issue where createFileData was removed - this would be breaking
* refactor acp filesystem backend
* backend protocol v2
* simplify createFileData
* docstrings
* adapt backend protocol tests
* sandbox protocol v2
* format
* standard tests
* fix tests
* fix tests
* fix tests
* fix node vfs
* lint fix
* empty commit
* fix download files to handle binary
* any backend protocol
* lint
* make backend protocol v2 extend backend protocol
* make backend unknown type for is sandbox backend check
* add changeset
* add max binary file size
* add svg
* separate v1 and v2
* format
* lint
* format
* update glob, ls, read raw return types
* unit test fixes
* fix tests
* restore standard-tests package
* fix integ tests - make standard-tests backward compatible
* remove deleted sandbox adapter
* standard-test refactor for backwards compat
* linting
* fix tests
* type guards and improved guard checks
* store mime type with v2 file data
* make explicit v1 types
* fix locall shell int types
* fix bug
* edge case
* update providers
* lint
* fix repl
* read raw tests
* support string or unint8arrays
* clean comments, docstrings, and fix issue where we were throwing a string
* don't re-wrap uint8arrays
* clean up test names
* fix quickjs
* poison pill and uint8array issues
* add back video and audio support
* bump langgraph-checkpoint version for json plus serializer changes
* fix lock after merge with main
* remove explicit cast
* use instance of Uint8Array for FileDataV2 schema
* update changeset
* update backend protocol interface, types, and utils
* refactor state backend
* refactor store backend
* clean up
* unit tests
* refactor filesystem
* unit tests
* skip binary files in literal search for filesystem backend
* refactor base sandbox
* base sandbox unit tests
* refactor local shell backend
* refactor composite backend
* composite and local shell backend changes
* refactor fs middleware
* unit tests and fixed issue where createFileData was removed - this would be breaking
* refactor acp filesystem backend
* backend protocol v2
* simplify createFileData
* docstrings
* adapt backend protocol tests
* sandbox protocol v2
* format
* standard tests
* fix tests
* fix tests
* fix tests
* fix node vfs
* lint fix
* empty commit
* fix download files to handle binary
* any backend protocol
* lint
* make backend protocol v2 extend backend protocol
* make backend unknown type for is sandbox backend check
* add changeset
* add max binary file size
* add svg
* separate v1 and v2
* format
* lint
* format
* update glob, ls, read raw return types
* unit test fixes
* fix tests
* restore standard-tests package
* fix integ tests - make standard-tests backward compatible
* remove deleted sandbox adapter
* standard-test refactor for backwards compat
* linting
* fix tests
* type guards and improved guard checks
* store mime type with v2 file data
* make explicit v1 types
* fix locall shell int types
* fix bug
* edge case
* update providers
* lint
* fix repl
* read raw tests
* support string or unint8arrays
* clean comments, docstrings, and fix issue where we were throwing a string
* don't re-wrap uint8arrays
* clean up test names
* fix quickjs
* poison pill and uint8array issues
* add back video and audio support
* bump langgraph-checkpoint version for json plus serializer changes
* fix lock after merge with main
* remove explicit cast
* use instance of Uint8Array for FileDataV2 schema
* refactor backend methods
* add changeset
* changesets
* regen lock
* update backend protocol interface, types, and utils
* refactor state backend
* refactor store backend
* clean up
* unit tests
* refactor filesystem
* unit tests
* skip binary files in literal search for filesystem backend
* refactor base sandbox
* base sandbox unit tests
* refactor local shell backend
* refactor composite backend
* composite and local shell backend changes
* refactor fs middleware
* unit tests and fixed issue where createFileData was removed - this would be breaking
* refactor acp filesystem backend
* backend protocol v2
* simplify createFileData
* docstrings
* adapt backend protocol tests
* sandbox protocol v2
* format
* standard tests
* fix tests
* fix tests
* fix tests
* fix node vfs
* lint fix
* empty commit
* fix download files to handle binary
* any backend protocol
* lint
* make backend protocol v2 extend backend protocol
* make backend unknown type for is sandbox backend check
* add changeset
* add max binary file size
* add svg
* separate v1 and v2
* format
* lint
* format
* update glob, ls, read raw return types
* unit test fixes
* fix tests
* restore standard-tests package
* fix integ tests - make standard-tests backward compatible
* remove deleted sandbox adapter
* standard-test refactor for backwards compat
* linting
* fix tests
* fix integ test
* type guards and improved guard checks
* store mime type with v2 file data
* make explicit v1 types
* fix locall shell int types
* fix bug
* edge case
* update providers
* lint
* fix repl
* read raw tests
* support string or unint8arrays
* clean comments, docstrings, and fix issue where we were throwing a string
* don't re-wrap uint8arrays
* clean up test names
* fix quickjs
* poison pill and uint8array issues
* add back video and audio support
* bump langgraph-checkpoint version for json plus serializer changes
* fix lock after merge with main
* remove explicit cast
* use instance of Uint8Array for FileDataV2 schema
* update changeset
* regen lock