Repository navigation
ShellTool: empty ShellResult(output=[]) fabricates stdout "(no output)" in the model-visible shell_call_output and persisted shell_output #5348
Description
Describe the bug
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_outputpayload 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 onmain@5f9899d584c5cfc879d3579352eb929fd4b34756, and the relevant code is unchanged on currentmain@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):
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())Actual output
MODEL-VISIBLE structured output: [{"stdout": "(no output)", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
PERSISTED shell_output: [{"stdout": "(no output)", "stderr": "", "status": "completed", "outcome": "success"}]
final_output: 'done'
The item the next model turn receives (ToolCallOutputItem.to_input_item()):
{"type": "shell_call_output", "call_id": "call_shell", "output": [{"stdout": "(no output)", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]}Expected behavior
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_outputabsent (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 theoutputstring, not executor data, yetShellAction.executelaunders 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 persistedshell_outputentry 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, andserialize_shell_outputover[]atsrc/agents/run_internal/tool_actions.py:578already produces the faithful payload[].- The repo's own test
test_shell_tool_structured_output_is_rendered(tests/test_shell_tool.py:226) asserts thatraw_item["output"][0]["stdout"]mirrors the executor's stdout, andToolCallOutputItem.to_input_item(src/agents/items.py:471-489) confirmsraw_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.
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: