Describe the bug
With create_or_reuse(..., defer=True), shell behavior depends on whether sandbox provisioning has finished before the first tool call. While pending, DeferredSandboxSession.supports_pty() returns False, even when the eventual backend supports PTY execution.
The pinned Agents SDK checks that synchronous method in two places:
Shell.tools()omitswrite_stdinfrom the pending session's tool list.ExecCommandTool.run()chooses one-shot execution before its first await. The subsequentDeferredSandboxSession.exec()waits for provisioning, but the execution mode has already been chosen.yield_time_msbecomes a hard one-shot timeout instead of a wait before returning a process/session ID.
Consequently, the first long-running shell command can report a timeout and expose no session to poll. The same command, arguments, and backend work through PTY after the deferred session resolves. This affects callers opting into background provisioning; the default defer=False path is outside this reproduction.
To Reproduce
Reproduced on current main, 6b7ae2ebe4d47037a927aade23265ff4e4f27e84, using the pinned SDK's real UnixLocalSandboxClient, shell capability, and shell tool. The example only runs sleep and printf in a temporary local workspace. It uses no Docker daemon, LLM, Caido service, credentials, or scan target.
The 5 ms yield value keeps the example short. The SDK's default is 10 seconds; a first command that runs longer than that also takes the one-shot timeout path while provisioning is pending.
- Run
uv sync --frozen --python 3.12in a development checkout. - Save this as
repro_deferred_pty.py:
import asyncio
import tempfile
from agents.sandbox.capabilities import Shell
from agents.sandbox.capabilities.tools.shell_tool import ExecCommandArgs, ExecCommandTool
from agents.sandbox.manifest import Manifest
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
from strix.runtime.deferred_session import DeferredSandboxSession
async def main():
with tempfile.TemporaryDirectory() as directory:
manifest = Manifest(root=directory)
client = UnixLocalSandboxClient()
actual = await client.create(manifest=manifest)
await actual.start()
gate = asyncio.Event()
async def bring_up():
await gate.wait()
return client, actual
deferred = DeferredSandboxSession(
asyncio.create_task(bring_up()), manifest=manifest
)
shell = Shell(session=deferred)
args = ExecCommandArgs(
cmd="sleep 2; printf finished",
yield_time_ms=5,
shell="/bin/sh",
login=False,
)
try:
print("pending tools:", [tool.name for tool in shell.tools()])
first = asyncio.create_task(ExecCommandTool(session=deferred).run(args))
await asyncio.sleep(0) # Let the tool choose its mode while pending.
gate.set()
print("first exec:", await first)
print("ready tools:", [tool.name for tool in shell.tools()])
print("ready exec:", await ExecCommandTool(session=deferred).run(args))
finally:
await actual.pty_terminate_all()
await actual.aclose()
await client.delete(actual)
asyncio.run(main())- Run with an isolated home and environment:
test_home=$(mktemp -d)
env -i PATH="$PATH" HOME="$test_home" \
LITELLM_MODE=PRODUCTION LITELLM_LOCAL_MODEL_COST_MAP=True \
STRIX_TELEMETRY=0 STRIX_NO_UPDATE_CHECK=1 \
uv run --frozen python repro_deferred_pty.py
rm -rf "$test_home"Observed output, omitting variable chunk IDs and timings:
pending tools: ['exec_command']
first exec:
Output:
Command timed out after 0.005 seconds.
ready tools: ['exec_command', 'write_stdin']
ready exec:
Process running with session ID 78841
Output:Expected behavior
For a PTY-capable backend, background provisioning should not change the shell tool contract: a command still running after yield_time_ms should produce a process/session ID that write_stdin can poll. Non-PTY backends should retain their existing supported behavior.
System Information:
- OS: macOS, arm64
- Strix commit:
6b7ae2ebe4d47037a927aade23265ff4e4f27e84 - Python: 3.12.14
- Agents SDK: 0.19.0 from
uv.lock - LLM used: none
Additional context
The pending capability result comes from deferred_session.py:128-129. The deferred startup path constructs this wrapper in session_manager.py. Introduced with the opt-in deferred startup in #1518; this issue concerns initial PTY capability selection, rather than polling an already-dead PTY session as in #867/#868.
Two possible approaches need a capability-contract decision:
- Make backend capabilities available before provisioning and pass the expected PTY capability to the deferred wrapper. This preserves early tool construction and the first model request, while avoiding an assumption that every custom backend supports PTYs.
- Wait for resolution before selecting shell execution mode, and arrange for the tool list to expose the corresponding polling tool consistently. Waiting only at the first shell operation would preserve the startup overlap; waiting during tool construction would reduce the benefit of deferred startup.
I have not tested a live Docker daemon or provider-backed scan. The reproduction exercises the real SDK's shell selection and local execution paths; no production patch is proposed until the pending-capability contract is chosen.
Activity
0No activity yet.