langchain-ai/deepagents · error · ConcurrentEvalError

PTC bridge called outside active eval

Error message

PTC bridge called outside active eval

What it means

Raised by the PTC (prompt-to-code) tool bridge in the QuickJS REPL when a tool bridged into the sandbox is invoked but no eval session is currently active (`self._ptc_state` is None). The library requires every bridged tool call to be attributed to a running eval so it can consume the per-eval PTC call budget; a call outside that window is a state-machine violation.

Solutions

  1. Ensure every bridged tool call happens synchronously within an active eval run driven by the middleware's eval tool.
  2. Do not share one REPL/middleware instance across concurrent evals; create one per run.
  3. Check that JS code does not schedule tool calls in promises/timers that outlive the eval.
  4. If concurrency is intentional, serialize tool calls or upgrade to a version that guards/retries the race.

Example fix

// before: calling a captured bridge tool after the eval
const tool = getBridgedTool();
await runEval(js); // eval finishes, clears state
await tool.call({...}); // ConcurrentEvalError

// after: invoke within the eval
await runEval(`await tools.readFile('x.txt')`);
Defensive patterns

Strategy: validation

Validate before calling

def assert_repl_active(repl):
    if getattr(repl, "_ptc_state", None) is None:
        raise RuntimeError("PTC bridge used outside an active eval")

Type guard

def has_active_ptc(repl) -> bool:
    return getattr(repl, "_ptc_state", None) is not None

Try / catch

try:
    await run_eval(js_code)
except ConcurrentEvalError:
    # bridge used outside an active eval; restart the eval or re-bind the tool
    restart_eval_session()

Prevention

When it happens

Trigger: Calling a bridged tool (e.g. `tools.<camel>` from inside the sandbox) after the eval finished or before it started, invoking the bridge from a stale/reused REPL instance, or a race where the eval teardown cleared `_ptc_state` while a queued tool call was still executing.

Common situations: Running multiple QuickJS evals concurrently against a shared REPL/middleware instance; holding a reference to a bridged tool across `await` boundaries and calling it after the eval completes; fire-and-forget JS promises resolving after the eval returns.

Related errors


AI-assisted analysis of langchain-ai/deepagents@a1af029e6e (2026-08-29). Data as JSON: /api/errors/9dc4a94bb758a9ef. Report an issue: GitHub.

Appendix: source

Thrown at libs/partners/quickjs/langchain_quickjs/_repl.py:713

        `tool.ainvoke` without blocking the event loop. We look the
        tool up through `self._registered_tools` on every call so a
        later `install_tools` that swaps the underlying object (same
        name, different instance) is picked up without re-registration.
        """
        ctx = self._require_ctx()
        registered = self._registered_tools

        async def _bridge(raw_input: Any = None) -> Any:
            tool = registered.get(camel)
            if tool is None:
                # Shouldn't happen — we only rewrite `globalThis.tools`
                # with names currently in the map — but if a race causes
                # it, fail loud.
                msg = f"tool '{camel}' not registered"
                raise RuntimeError(msg)
            if self._ptc_state is None:
                msg = "PTC bridge called outside active eval"
                raise ConcurrentEvalError(msg)
            state = self._ptc_state.consume_call_budget(
                function_name=f"tools.{camel}",
                max_ptc_calls=self._max_ptc_calls,
            )
            self._ptc_state = state
            payload = _normalize_tool_input(raw_input)
            call_id = _synth_tool_call_id(tool.name)
            # Inject runtime/state/store ourselves; `InjectedToolCallId`
            # is handled inside `_ainvoke_tool_on_outer_loop` via
            # `tool.arun(..., tool_call_id=...)`. The bridge intentionally
            # avoids the tool-call envelope path because it wraps the
            # result in a `ToolMessage` and string-coerces `.content`,
            # destroying native return types (lists, dicts, numbers).
            args = _inject_tool_args_for_ptc(
                tool, payload, state.outer_runtime, call_id
            )
            result = await self._ainvoke_tool_on_outer_loop(
                tool,

View on GitHub (pinned to a1af029e6e)