functions.exec silently discards un-emitted nested tool results and artifact handles
Summary
functions.exec silently discards the complete result of an awaited nested tool call when the script does not explicitly emit it with text(...). The outer call still reports Script completed, which can make an agent believe a producer returned empty stdout and can permanently lose exact artifact handles.
This is especially risky for custom subagents and artifact-first workflows where the producer returns a unique run_id or exact artifact path only in its stdout envelope.
Minimal reproduction
Result silently lost
await tools.exec_command({
cmd: "Write-Output 'NESTED_OUTPUT_NOT_FORWARDED_MARKER'",
yield_time_ms: 10000,
max_output_tokens: 1000
});
Observed:
Script completed
Output:
The nested command did execute successfully, but stdout and the rest of the returned object are silently discarded.
Explicit forwarding works
const result = await tools.exec_command({
cmd: "Write-Output 'NESTED_OUTPUT_FORWARDED_MARKER'",
yield_time_ms: 10000,
max_output_tokens: 1000
});
text(JSON.stringify({
exit_code: result.exit_code,
output: result.output,
session_id: result.session_id ?? null
}));
Observed:
{"exit_code":0,"output":"NESTED_OUTPUT_FORWARDED_MARKER\r\n","session_id":null}
Impact
In the motivating case, a custom subagent ran a one-shot producer that returns a JSON envelope containing a unique run ID and exact artifact path. The producer apparently completed, but the subagent did not emit the nested tool result. The isolate then ended and the following were all lost:
- stdout/stderr
- exit code
- session ID
- JSON envelope
- unique run ID and exact artifact path
The workflow prohibited scanning output directories or rerunning the one-shot producer, so the result became irrecoverable even if artifacts had been generated successfully.
Expected behavior
At least one of the following guardrails would prevent silent evidence loss:
- Warn or fail when a
functions.execscript awaits a nested tool result but emits no output. - Automatically surface a compact representation of the final awaited tool result when nothing was explicitly emitted.
- Provide a lint/runtime warning such as: “Nested tool result was not emitted; call text(), image(), generatedImage(), or store() before the isolate exits.”
- Make the successful completion status distinguish “script completed with no emitted output” from “nested command returned empty stdout.”
Actual behavior
The outer call reports successful script completion with an empty Output section, indistinguishable from a nested producer that genuinely returned no output.
Environment
- Codex Windows desktop app
- Windows 11
- Reproduced with PowerShell
Write-Output - Reproduction does not depend on the original producer, repository, or Unreal Engine
2 Comments
Traced this through the code-mode runtime source at
main(9e552e9d15ba, 2026-07-11). The behavior is structural — an awaited nested tool result lives only in the V8 heap after resolution, and the completion path has no channel that could carry it out. Pinning each hop, since it shows exactly where the guardrails you propose would attach:Why the result is unrecoverable once the isolate exits
await tools.exec_command(...)→tool_callback(codex-rs/code-mode/src/runtime/callbacks.rsL13-72): stashes aPromiseResolverinRuntimeState.pending_tool_callsand emitsRuntimeEvent::ToolCall. Nothing about the call is retained for output purposes.resolve_tool_response(runtime/module_loader.rsL66-101) removes the resolver and resolves the JS promise with the JSON result. No copy is kept and no flag is set — from this point the result exists only as a JS value in your script.text_callback/image_callback/generated_image_callback(callbacks.rsL74-162) sendRuntimeEvent::ContentItem;store_callback(L184-215) writes tostored_value_writes(recoverable viaload()in a laterexecin the same session — worth knowing as the one existing escape hatch).RuntimeEvent::Result { stored_value_writes, error_text }(runtime/mod.rsL66-69) has no content field, andcompletion_state(module_loader.rsL103-139) can't capture a value anyway — the script is evaluated as an ES module, whose evaluation promise fulfills with nothing useful.ContentItems (cell_actor/mod.rsL370) and flushes them onResult(L418+); core'shandle_runtime_response(core/src/tools/code_mode/mod.rsL204-223) then prependsScript completed\nWall time …\nOutput:\n(format_script_statusL237-243,prepend_script_statusL247-255) withsuccess = true. With zero emitted items theOutput:section is empty — byte-identical to a producer that genuinely printed nothing, which is your guardrail 4 complaint.A contributing factor: the exec tool's own description (
code-mode-protocol/src/description.rsL22) warns only that unawaited promises are silently discarded. Your repro awaited the call — the awaited-but-unemitted case is undocumented, so the model has no textual reason to believeScript completed+ empty output means it lost data.Minimal fix outline (covers guardrails 1, 3, 4 with zero protocol changes)
The runtime already has both facts a warning needs; it just doesn't track them:
RuntimeState(runtime/mod.rsL146-158):content_emitted: bool(set in the three emit callbacks) andresolved_tool_calls: u64(increment inresolve_tool_responseon theOkarm).run_runtime(runtime/mod.rsL217-226 initial checkpoint, L260-269 command loop), beforesend_result: ifresolved_tool_calls > 0 && !content_emitted && stored_value_writes.is_empty(), emit one syntheticRuntimeEvent::ContentItemfirst, e.g.Warning: script awaited N nested tool call(s) but exited without emitting output; those results were discarded. Pass them to text(...), image(...), or store(...) before the script ends.It rides the existing ContentItem → cell-actor → core path, so the rendered result becomes distinguishable and self-explaining with no changes to the protocol crates or the cell actor.Guardrail 2 (auto-surfacing the lost result) is separable and costlier: it requires retaining a (truncated) JSON snapshot of the last resolved tool response in
RuntimeState, plus a truncation policy for potentially huge results. The warning above at least names the exact recovery tools; auto-surfacing could be layered on later if the maintainers want it.Independent of the runtime change, a one-line doc fix in
EXEC_DESCRIPTION_TEMPLATE(description.rs, next to L22) would prevent the model-side misinterpretation: awaited tool results are returned to the script only, and are never included in exec output unless explicitly emitted.Caveats, stated up front: this is static analysis of the Rust source at today's
main— I don't have a Windows machine to reproduce the desktop-app flow live. But every hop above is platform-independent, and none of the code paths involved could carry the nested result out of the isolate, which matches your observedScript completed+ emptyOutput:exactly.Thank you for tracing this all the way through the runtime — this is extremely helpful.
Your analysis matches our Windows desktop reproduction exactly: the nested command completed, but because the resolved result was never emitted, the outer cell reported a successful script with an empty Output: section and the unique artifact handle became unrecoverable.
The distinction you identified between “unawaited” and “awaited but un-emitted” results is especially important. We also weren’t aware that store() is the one existing recovery path.
The proposed warning based on resolved_tool_calls > 0 && !content_emitted && stored_value_writes.is_empty() sounds like the right minimal guardrail, and the documentation update would independently prevent the model-side misinterpretation.
Since Codex currently accepts external PRs by invitation only, we’ll leave the implementation decision with the maintainers. We’d be happy to reproduce and validate any candidate fix on the Windows desktop app if that would help.
Thanks again for the detailed investigation.