Cloudflare Shell code tool: Promise nested in sandbox result fails as raw DataCloneError instead of being resolved or corrected
Describe the Bug
With @flue/[email protected] + @cloudflare/[email protected] + @cloudflare/[email protected], model-authored code whose return value contains an unresolved Promise fails the entire code tool call with a raw structured-clone error:
DataCloneError: #<Promise> could not be cloned.
at async DynamicWorkerExecutor.execute (...)The executor awaits the top-level result inside the sandbox (await Promise.race([(fn)(), timeout])), but the { result, logs } object then crosses the Workers RPC boundary from the dynamic Worker's evaluate() back to the host via structured clone. Any Promise nested inside the result — a shape models produce constantly, e.g.
async () => ({ files: paths.map((p) => state.readFile(p)) }) // missing Promise.all— throws DataCloneError at the RPC boundary, and the tool call fails terminally.
Two problems compound here:
- The failure is avoidable by construction. The sandbox already ships a codec (
__stringifyForCodemode/__parseForCodemode) used for tool-call arguments and results between sandbox and host, butevaluate()'s own return value rides raw structured clone. The result channel is the one channel that skips the codec. - The error is unactionable for the model.
#<Promise> could not be cloned.names the transport mechanism (structured clone) three abstraction layers below the model's actual mistake (a missingawait). In our production traces this class of thrown code-tool error produces blind identical retries rather than a corrected call; errors returned to an LLM need to state the API contract that was violated, not the transport symptom.
Observed in production (Sentry, support-seal): the model batched state.readFile calls into a returned container without awaiting them; the whole read failed and the draft degraded to other tool calls.
Expected Behavior
In preference order:
- Deep-resolve the result before it crosses the boundary. After awaiting the top-level value, resolve nested thenables (or JSON-roundtrip the result inside the sandbox with the existing codemode codec, which both flushes non-serializable values deterministically and reuses the binary-tagging path tool results already use). "Return what you computed" should always work; this deletes the error class for every consumer of the executor.
- If a non-cloneable value must remain an error, make it corrective. Return an execution error stating the contract — e.g.
Your function's return value must be plain JSON-serializable data. It contained a Promise: await every async call (use await Promise.all(...) for arrays) before returning.— instead of the rawDataCloneError, so the model self-corrects in one turn. - Document the result contract in the generated tool description ("the returned value must be plain JSON data; await all promises").
Steps to Reproduce
- Create a
DynamicWorkerExecutorwith anyWorkerLoaderbinding. - Execute:
async () => ({ files: [Promise.resolve("a"), Promise.resolve("b")] })- The sandbox
evaluate()returns successfully inside the isolate, but the host-side RPC deserialization throwsDataCloneError: #<Promise> could not be cloned., which surfaces as a terminalcodetool failure.
Related observation (same affordance-mismatch family)
The same day we also saw entry.isDirectory is not a function from model code iterating state.readdir(...) output — the model's Node.js prior expects fs.Dirent entries while the state API returns name strings. Follow-on data point for the 2026-07-15 code-tool feedback: where the sandbox API diverges from the strongest model prior (Node fs), either aligning the API shape or stating the return shape in the tool schema prevents the misuse class at the source.
Source: withastro/flue