You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
ShellTool: empty ShellResult(output=[]) fabricates stdout "(no output)" in the model-visible shell_call_output and persisted shell_output #5348
When a ShellTool executor returns a structured ShellResult with an empty output list — i.e. ShellResult(output=[]), the legitimate result of a command that ran successfully and printed nothing (e.g. true, mkdir foo) — the SDK fabricates stdout that the executor never produced:
the model-visible shell_call_output payload for the next turn contains [{"stdout": "(no output)", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
the same fabricated entry is persisted into raw_item["shell_output"] with "outcome": "success"
"(no output)" is the human-readable placeholder that render_shell_outputs() returns for an empty output list, not command output. The model can quote it back to the user as real stdout, and any consumer parsing the structured shell_call_output entries (or the persisted shell_output) sees fake command output for a command that produced none.
Debug information
Agents SDK version: 0.23.1 (latest release; also reproduced on main @ 5f9899d584c5cfc879d3579352eb929fd4b34756, and the relevant code is unchanged on current main @ 125efa029b4bfd84238bd2c4fd69c3406f802663)
Python version: 3.13.15
Operating system: macOS 26.6
Model and model provider: reproduced with agents.testing.ScriptedModel (no network, no API keys) — the payload is built in the runner before any provider serialization, so this is provider-agnostic
Does the issue reproduce with the latest Agents SDK release? Yes, 0.23.1
Does the issue occur consistently or intermittently? Consistently
Repro steps
Minimal, self-contained, no network or API keys needed (agents.testing.ScriptedModel drives the run):
The executor returned a structured result with no output entries, so the model-visible output should be [] and no shell_output entry should be persisted — the same faithful pass-through the sibling branches of this code path already produce:
ShellResult(output=[ShellCommandOutput(stdout="", stderr="")]) → model-visible {"output": [{"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": null}}]} — the empty stdout is passed through verbatim, nothing fabricated (verified by running the repro with this executor)
executor returning the plain string "" → model-visible {"output": []}, shell_output absent (verified the same way)
Three executor shapes that all mean "no output" are handled inconsistently inside one function, and only ShellResult(output=[]) gets a fabricated stdout.
Concrete basis:
render_shell_outputs (src/agents/run_internal/tool_execution.py:969-972) documents "(no output)" as its placeholder: "Render shell outputs into human-readable text for tool responses. / if not outputs: return "(no output)"". It is rendering text for the output string, not executor data, yet ShellAction.execute launders it into the structured channel as real stdout.
serialize_shell_output (src/agents/run_internal/tool_execution.py:936-937) is documented as "Serialize ShellCommandOutput for persistence or cross-run transmission" — the persisted shell_output entry in the bug case serializes nothing the executor produced.
ShellExecutor (src/agents/tool.py:1432-1433) is "Executes a shell command sequence and returns either text or structured output" — ShellResult(output=[]) is within the documented public contract, and serialize_shell_output over [] at src/agents/run_internal/tool_actions.py:578 already produces the faithful payload [].
The repo's own test test_shell_tool_structured_output_is_rendered (tests/test_shell_tool.py:226) asserts that raw_item["output"][0]["stdout"] mirrors the executor's stdout, and ToolCallOutputItem.to_input_item (src/agents/items.py:471-489) confirms raw_item["output"] is what the model receives.
Root cause
In ShellAction.execute (src/agents/run_internal/tool_actions.py:608), the truthiness check if shell_output_payload: treats the valid empty payload computed at tool_actions.py:578 as absent, so control falls into elif output_text: (tool_actions.py:610-618), which wraps the human-readable placeholder output_text (from render_shell_outputs, tool_execution.py:969-972) into a structured entry as if it were executor stdout, and that entry is persisted at tool_actions.py:632. The two branches do not distinguish "executor returned a structured ShellResult (possibly empty)" from "executor returned a plain string".
Happy to be assigned. For a fix direction: gate the elif output_text: fallback on whether the executor returned a structured ShellResult (e.g. a flag set in the isinstance(result, ShellResult) branch at tool_actions.py:562), so an empty-but-valid structured result yields output: [] exactly like the plain-empty-string sibling branch — I verified locally that this change makes the repro produce output: [] with no shell_output entry, leaves the two sibling cases unchanged, and passes all 22 tests in tests/test_shell_tool.py.
Related issues / PRs
I searched and found no existing report of this ("no output" shell, ShellResult, shell_output, ShellTool issues/PRs). The only open PR touching src/agents/run_internal/tool_actions.py is #5332 ("fix: preserve completed tool outputs when a sibling fails"), which is about sibling tool failures and unrelated to this fabrication.
Reproduced this. Root cause: src/agents/run_internal/tool_actions.py:608 — the truthiness check if shell_output_payload: treats the valid empty payload computed at tool_actions.py:578 as absent, so control falls into the elif output_text: branch (tool_actions.py:610-618), which wraps render_shell_outputs()'s "(no output)" placeholder into the structured channel as if it were executor stdout and persists it at tool_actions.py:632.
Minimal reproduction:
python3 - <<'EOF'
import asyncio
import json
from openai.types.responses import ResponseFunctionShellToolCall, ResponseOutputMessage, ResponseOutputText
from agents import Agent, Runner
from agents.testing import ScriptedModel
from agents.tool import ShellResult, ShellTool
def empty_output_executor(request):
# Executor for a command that succeeded and printed nothing.
return ShellResult(output=[])
def shell_call(call_id="call_shell"):
return ResponseFunctionShellToolCall(
id="sc1",
call_id=call_id,
type="shell_call",
action={"type": "shell_command", "commands": ["true"]},
status="completed",
)
async def main():
model = ScriptedModel(
[
[shell_call()],
[
ResponseOutputMessage(
id="m",
type="message",
role="assistant",
content=[
ResponseOutputText(text="done", type="output_text", annotations=[], logprobs=[])
],
status="completed",
)
],
]
)
agent = Agent(name="t", model=model, tools=[ShellTool(executor=empty_output_executor)])
result = await Runner.run(agent, input="run true", max_turns=5)
for item in result.new_items:
if type(item).__name__ == "ToolCallOutputItem":
print("MODEL-VISIBLE structured output:", json.dumps(item.raw_item["output"]))
print("PERSISTED shell_output:", json.dumps(item.raw_item.get("shell_output")))
print("final_output:", repr(result.final_output))
asyncio.run(main())
EOF
Thanks for the detailed report and for checking the neighboring cases! The code path does promote the display placeholder into structured stdout when ShellResult.output is empty.
For a command that succeeds without printing anything, you can currently return a ShellCommandOutput with empty stdout/stderr and ShellCallOutcome(type="exit", exit_code=0) inside the result list. That retains the command's outcome without adding placeholder text to its stdout.
Could you share a little more about the workflow where your executor returns no entries, and whether the placeholder has affected a downstream consumer or a model response? A small, sanitized example would help us understand the practical impact and prioritize a focused fix. Thanks again for taking the time to investigate this.
Thanks for taking a look, and for confirming the workaround. To answer both questions honestly:
Where an executor returns no entries. Our evidence is code reading plus the scripted repro in the report — we don't have a production incident to cite, so please treat this as a design sharp edge rather than a live failure we hit. The scenario we're concerned about is user-supplied executors for local environments: ShellExecutor returns str | ShellResult, and nothing requires ShellResult.output to be non-empty, so two natural executor shapes land on output=[] for a successful quiet command (mkdir, cp, git commit -q, …):
chunk-per-entry executors that emit one ShellCommandOutput per captured output segment — a command that printed nothing produces zero segments;
result-filtering executors that only record commands which printed something or failed — a fully quiet batch returns an empty list.
Both read as a faithful "the command ran and printed nothing", and the type system gives no hint that this is unsupported.
Practical impact (sanitized). The fabricated entry is presented as real stdout in two places, and nothing marks the string as a placeholder in either:
the model-visible shell_call_output for the next turn ({"stdout": "(no output)", ...}), so the model can reasonably quote it back as the command's output;
the persisted raw_item["shell_output"], so a trace or log consumer that parses these entries records stdout the command never produced.
The plain-text channel carries the same placeholder (ToolCallOutputItem.output), which is fine for human display but is exactly what leaks into the structured channel here.
The explicit-ShellCommandOutput workaround works for us, and we're happy to leave the fix itself to you. If a focused change is on the table, the seam we'd point at: when the executor returned a structured ShellResult, an empty output list means "structured result: nothing to report", so skipping the elif output_text: fallback in that case (keeping it for plain str returns) would stop the fabrication without changing what render_shell_outputs() shows humans. Happy to send that as a PR if you'd like — otherwise we'll defer to whatever you prefer.
The branch has two commits ("Fix ShellTool fabricating stdout for empty structured results") which:
In ShellAction.execute (src/agents/run_internal/tool_actions.py), change elif output_text: to elif output_text and shell_output_payload is None: so the "(no output)" placeholder path from render_shell_outputs() only applies when there is genuinely no structured result (plain string or exception). A structured ShellResult(output=[]) now keeps output == [] with no fabricated stdout and no persisted shell_output entry.
Add test_shell_tool_empty_structured_result_does_not_fabricate_stdout in tests/test_shell_tool.py, asserting output == [] and no shell_output key for ShellResult(output=[]).
However, this repository restricts pull request creation to collaborators, so I'm unable to open the PR myself. Could a maintainer please open a PR from the fix/issue-5348 branch on my fork (zhgao/openai-agents-python), or grant me collaborator access so I can submit it directly? I'm happy to address any review feedback. Thank you!
Complementary offline coverage for #5348: on 9abc9182d06dbd28330af888306031269d57a175 (openai-agents 0.24.0, Python 3.12.13, openai 3.34.0), Runner + ScriptedModel reproduces (no output) as stdout in both the actual second model call and persisted raw_item["shell_output"] when the executor returns ShellResult(output=[]).
Two fixed-behavior assertions fail; eight checks pass, consistently across two runs. Controls cover empty/nonempty strings, quiet structured entries with unspecified or successful exit outcomes, real stdout/stderr with newlines and ordering, and literal executor-provided (no output) that must not be stripped. The human-readable display placeholder is allowed to remain. A temporary one-line fallback guard made all ten checks pass twice; it was then reverted. No source fix is included, and this is synthetic evidence, not a production incident or live-provider test.
I see a fix branch has already been offered. If complementary coverage would help, I can share these cases for a collaborator-led test-only PR, or open a test-only PR if permitted under the collaborator-only contribution policy. Otherwise, no duplicate implementation or PR is intended.
AI disclosure: this repro, test coverage, and summary were prepared with Astra Coder (OpenAI GPT-6 Astra, via OpenCode). The reported checks were executed locally.
Hi, I’d like to contribute to the validation of this fix. Since an implementation and complementary test coverage have already been shared, would it be okay if I review the proposed changes and check for any remaining edge cases around empty ShellResult outputs, plain-string returns, and persisted shell_output? I’ll coordinate with the existing contributors to avoid duplicating work and can help strengthen the regression tests if needed.
Executable regression coverage for the empty-structured-result fix
Building on BlueX888's report, jbeckwith-oai's response, and zhgao's existing
proposal, here is a tested one-line correction plus a self-contained regression
suite. The additional contribution is coverage of both runner modes, preservation
of metadata and display behavior, and the actual next model input. This is
corroboration and regression coverage, not a claim of original discovery.
On October 20 I checked src/agents/run_internal/tool_actions.py at main 64cd5dfc5a1579870dcbed0ea42b27aa84033fee: it is byte-identical to the openai-agents==0.24.0 file tested below. The presence/truthiness distinction is
still relevant. Synthetic tests on Windows, Python 3.12.14:
Baseline: 32 tests, 28 pass and four fail. The failing cases are empty structured
results, with/without metadata, in ordinary and streamed runs.
With the single predicate correction: all 32 pass, no errors or skips.
The eight approval/state controls already pass on the baseline; no approval
bypass is claimed. The other 24 cases test shell-result behavior.
The correction keeps the human-readable display placeholder while preserving output=[] in the retained item, its input conversion, and the next scripted
model request. It does not invent a successful exit code for an absent entry.
Explicit quiet success, unknown exit code, failure, timeout, multiple entries,
plain strings (including the literal placeholder), rejection, output limits,
executor exceptions, and provider metadata are checked.
No shell command, live model, credential, billing experiment, or production incident
is involved. These are bounded synthetic checks, not full SDK or security validation.
The installed SDK was not modified during evaluation.
Patch
--- a/src/agents/run_internal/tool_actions.py+++ b/src/agents/run_internal/tool_actions.py@@ -624,7 +624,7 @@
log_tool_action_error("Shell executor failed", exc)
raw_entries: list[dict[str, Any]] | None = None
- if shell_output_payload:+ if shell_output_payload is not None:
raw_entries = shell_output_payload
elif output_text:
raw_entries = [
Regression file
Save the following as test_integrity.py. With openai-agents==0.24.0 installed,
run python -m unittest -v test_integrity. The unpatched package should fail the
four cases above. Apply the one-line correction in a separate SDK development
checkout to test the corrected path; do not patch an installed production package.
Our evaluation loaded that corrected class only inside the test process.
Prepared with Codex assistance at Sherman G. Laing's direction. The code supplied
in this comment is offered under the repository's MIT terms, including permission
for commercial use, modification, and redistribution. This grant covers these
contribution copies; it does not relicense unrelated work or change attribution
for the original issue and earlier proposals.
MIT license for the contributed code
MIT License
Copyright (c) 2026 Sherman G. Laing
Copyright (c) 2025 OpenAI (upstream-derived patch)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Thanks for the detailed correction and regression coverage. My independent offline packet observed the same empty ShellResult(output=[]) case: (no output) was included in both the next model input and persisted shell_output. Your is not None check targets that distinction. I haven't independently run your patch, so I won't claim it passes from my side; I won't duplicate the suite. Appreciate you taking it forward. As noted, my reproduction was AI-assisted and verified offline.
Thanks, @EngineeringWithJay, for the independent observation and for being precise about what you verified. Preserving the explicit empty list in the retained item and next model input, while keeping the display placeholder, is exactly the distinction covered by the correction above. I appreciate your corroboration of the original behavior; the patch results remain the bounded synthetic results reported in my submission.
Activity
BlueX888 commentedon Oct 11, 2026
Reproduced this. Root cause: src/agents/run_internal/tool_actions.py:608 — the truthiness check
if shell_output_payload:treats the valid empty payload computed at tool_actions.py:578 as absent, so control falls into theelif output_text:branch (tool_actions.py:610-618), which wrapsrender_shell_outputs()'s "(no output)" placeholder into the structured channel as if it were executor stdout and persists it at tool_actions.py:632.Minimal reproduction:
Observed output:
Happy to open a PR with this fix, or to be assigned if you prefer to take it.
jbeckwith-oai commentedon Oct 13, 2026
Thanks for the detailed report and for checking the neighboring cases! The code path does promote the display placeholder into structured stdout when
ShellResult.outputis empty.For a command that succeeds without printing anything, you can currently return a
ShellCommandOutputwith empty stdout/stderr andShellCallOutcome(type="exit", exit_code=0)inside the result list. That retains the command's outcome without adding placeholder text to its stdout.Could you share a little more about the workflow where your executor returns no entries, and whether the placeholder has affected a downstream consumer or a model response? A small, sanitized example would help us understand the practical impact and prioritize a focused fix. Thanks again for taking the time to investigate this.
BlueX888 commentedon Oct 14, 2026
Thanks for taking a look, and for confirming the workaround. To answer both questions honestly:
Where an executor returns no entries. Our evidence is code reading plus the scripted repro in the report — we don't have a production incident to cite, so please treat this as a design sharp edge rather than a live failure we hit. The scenario we're concerned about is user-supplied executors for local environments:
ShellExecutorreturnsstr | ShellResult, and nothing requiresShellResult.outputto be non-empty, so two natural executor shapes land onoutput=[]for a successful quiet command (mkdir,cp,git commit -q, …):ShellCommandOutputper captured output segment — a command that printed nothing produces zero segments;Both read as a faithful "the command ran and printed nothing", and the type system gives no hint that this is unsupported.
Practical impact (sanitized). The fabricated entry is presented as real stdout in two places, and nothing marks the string as a placeholder in either:
shell_call_outputfor the next turn ({"stdout": "(no output)", ...}), so the model can reasonably quote it back as the command's output;raw_item["shell_output"], so a trace or log consumer that parses these entries records stdout the command never produced.The plain-text channel carries the same placeholder (
ToolCallOutputItem.output), which is fine for human display but is exactly what leaks into the structured channel here.The explicit-
ShellCommandOutputworkaround works for us, and we're happy to leave the fix itself to you. If a focused change is on the table, the seam we'd point at: when the executor returned a structuredShellResult, an emptyoutputlist means "structured result: nothing to report", so skipping theelif output_text:fallback in that case (keeping it for plainstrreturns) would stop the fabrication without changing whatrender_shell_outputs()shows humans. Happy to send that as a PR if you'd like — otherwise we'll defer to whatever you prefer.zhgao commentedon Oct 17, 2026
Hi, I've implemented a fix for this issue and it's ready on my fork:
zhgao/openai-agents-pythonbranchfix/issue-5348.Compare view: main...zhgao:fix/issue-5348
The branch has two commits ("Fix ShellTool fabricating stdout for empty structured results") which:
ShellAction.execute(src/agents/run_internal/tool_actions.py), changeelif output_text:toelif output_text and shell_output_payload is None:so the"(no output)"placeholder path fromrender_shell_outputs()only applies when there is genuinely no structured result (plain string or exception). A structuredShellResult(output=[])now keepsoutput == []with no fabricated stdout and no persistedshell_outputentry.test_shell_tool_empty_structured_result_does_not_fabricate_stdoutintests/test_shell_tool.py, assertingoutput == []and noshell_outputkey forShellResult(output=[]).However, this repository restricts pull request creation to collaborators, so I'm unable to open the PR myself. Could a maintainer please open a PR from the
fix/issue-5348branch on my fork (zhgao/openai-agents-python), or grant me collaborator access so I can submit it directly? I'm happy to address any review feedback. Thank you!EngineeringWithJay commentedon Oct 17, 2026
Complementary offline coverage for #5348: on
9abc9182d06dbd28330af888306031269d57a175(openai-agents0.24.0, Python 3.12.13,openai3.34.0),Runner+ScriptedModelreproduces(no output)as stdout in both the actual second model call and persistedraw_item["shell_output"]when the executor returnsShellResult(output=[]).Two fixed-behavior assertions fail; eight checks pass, consistently across two runs. Controls cover empty/nonempty strings, quiet structured entries with unspecified or successful exit outcomes, real stdout/stderr with newlines and ordering, and literal executor-provided
(no output)that must not be stripped. The human-readable display placeholder is allowed to remain. A temporary one-line fallback guard made all ten checks pass twice; it was then reverted. No source fix is included, and this is synthetic evidence, not a production incident or live-provider test.I see a fix branch has already been offered. If complementary coverage would help, I can share these cases for a collaborator-led test-only PR, or open a test-only PR if permitted under the collaborator-only contribution policy. Otherwise, no duplicate implementation or PR is intended.
AI disclosure: this repro, test coverage, and summary were prepared with Astra Coder (OpenAI GPT-6 Astra, via OpenCode). The reported checks were executed locally.
AbarnaaSree commentedon Oct 18, 2026
Hi, I’d like to contribute to the validation of this fix. Since an implementation and complementary test coverage have already been shared, would it be okay if I review the proposed changes and check for any remaining edge cases around empty
ShellResultoutputs, plain-string returns, and persistedshell_output? I’ll coordinate with the existing contributors to avoid duplicating work and can help strengthen the regression tests if needed.MuneebMalik244535 commentedon Oct 18, 2026
I have investigated this issue and confirmed the root cause in
src/agents/run_internal/tool_actions.py.Root Cause
In
ShellAction.execute:0-Actual commentedon Oct 20, 2026
Executable regression coverage for the empty-structured-result fix
Building on BlueX888's report, jbeckwith-oai's response, and zhgao's existing
proposal, here is a tested one-line correction plus a self-contained regression
suite. The additional contribution is coverage of both runner modes, preservation
of metadata and display behavior, and the actual next model input. This is
corroboration and regression coverage, not a claim of original discovery.
On October 20 I checked
src/agents/run_internal/tool_actions.pyat main64cd5dfc5a1579870dcbed0ea42b27aa84033fee: it is byte-identical to theopenai-agents==0.24.0file tested below. The presence/truthiness distinction isstill relevant. Synthetic tests on Windows, Python 3.12.14:
results, with/without metadata, in ordinary and streamed runs.
bypass is claimed. The other 24 cases test shell-result behavior.
The correction keeps the human-readable display placeholder while preserving
output=[]in the retained item, its input conversion, and the next scriptedmodel request. It does not invent a successful exit code for an absent entry.
Explicit quiet success, unknown exit code, failure, timeout, multiple entries,
plain strings (including the literal placeholder), rejection, output limits,
executor exceptions, and provider metadata are checked.
No shell command, live model, credential, billing experiment, or production incident
is involved. These are bounded synthetic checks, not full SDK or security validation.
The installed SDK was not modified during evaluation.
Patch
Regression file
Save the following as
test_integrity.py. Withopenai-agents==0.24.0installed,run
python -m unittest -v test_integrity. The unpatched package should fail thefour cases above. Apply the one-line correction in a separate SDK development
checkout to test the corrected path; do not patch an installed production package.
Our evaluation loaded that corrected class only inside the test process.
Complete runnable regression suite
Contribution license and attribution
Prepared with Codex assistance at Sherman G. Laing's direction. The code supplied
in this comment is offered under the repository's MIT terms, including permission
for commercial use, modification, and redistribution. This grant covers these
contribution copies; it does not relicense unrelated work or change attribution
for the original issue and earlier proposals.
MIT license for the contributed code
EngineeringWithJay commentedon Oct 20, 2026
Thanks for the detailed correction and regression coverage. My independent offline packet observed the same empty
ShellResult(output=[])case:(no output)was included in both the next model input and persistedshell_output. Youris not Nonecheck targets that distinction. I haven't independently run your patch, so I won't claim it passes from my side; I won't duplicate the suite. Appreciate you taking it forward. As noted, my reproduction was AI-assisted and verified offline.0-Actual commentedon Oct 21, 2026
Thanks, @EngineeringWithJay, for the independent observation and for being precise about what you verified. Preserving the explicit empty list in the retained item and next model input, while keeping the display placeholder, is exactly the distinction covered by the correction above. I appreciate your corroboration of the original behavior; the patch results remain the bounded synthetic results reported in my submission.