Skip to content

Commit b80c6eb

Browse files
committed
feat(sprites): lazy-warm ephemeral sessions too
Extends the lazy wait-for-running pattern from named-attach to the created_by_us=True path. Sprites can transition between cold/warm/ running freely with auto-wake-on-traffic, and ``create_sprite`` already raises eagerly on platform rejection — so the eager poll on the ephemeral path was paying ~1.5s per session just to confirm something the platform implicitly handles. Now both paths skip the poll in ``_ensure_sprite``; ``_ensure_warm`` runs once on first I/O. ``_resolve_exposed_port`` calls ``_ensure_warm`` explicitly because it needs ``Sprite.url`` populated (which happens during the post-poll refresh). TUI startup goes from ~7.6s to ~6.0s. The ~1.5s saved is exactly the poll we now skip; the wake-up cost shifts to the agent's first exec, where it overlaps with model thinking time and is invisible to the user. Updates ``test_create_ephemeral_sprite`` to assert no eager get_sprite poll fires, mirroring the named-attach assertion.
1 parent 41313e5 commit b80c6eb

2 files changed

Lines changed: 31 additions & 22 deletions

File tree

src/agents/extensions/sandbox/sprites/sandbox.py

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,9 @@ async def _ensure_sprite(self) -> Sprite:
327327
client = self._ensure_client_sync()
328328
sprite: Sprite
329329
if self.state.created_by_us:
330+
# Provision a fresh sprite. ``create_sprite`` raises eagerly if the
331+
# platform rejects the request, so we still surface creation
332+
# failures synchronously here.
330333
config = self._build_sprite_config()
331334
try:
332335
sprite = await asyncio.to_thread(
@@ -344,29 +347,21 @@ async def _ensure_sprite(self) -> Sprite:
344347
) from exc
345348
await self._maybe_update_url_settings(sprite)
346349
self._sprite = sprite
347-
# Fresh sprite: poll until it reaches a ready status, then refresh
348-
# its info so ``url`` / ``organization_name`` are populated. We
349-
# have to poll regardless because the create call doesn't tell us
350-
# when provisioning finishes.
351-
await self._wait_for_sprite_running()
352-
self._warmth_verified = True
353-
refreshed: Sprite
354-
try:
355-
refreshed = await asyncio.to_thread(client.get_sprite, self.state.sprite_name)
356-
except SpriteError:
357-
refreshed = sprite
358-
self._sprite = refreshed
359-
return refreshed
360-
361-
# Named-attach: skip the readiness poll on attach. The sprite may be
362-
# cold (paused) and we don't want to pay wake-up latency just to hand
363-
# the agent a session handle. The first I/O operation drives the
364-
# wake-up via ``_ensure_warm`` — the platform auto-wakes a paused
365-
# sprite when traffic arrives, so this is essentially free.
350+
return sprite
351+
352+
# Named-attach: just construct the handle.
366353
sprite = await asyncio.to_thread(client.sprite, self.state.sprite_name)
367354
self._sprite = sprite
368355
return sprite
369356

357+
# Both ephemeral and named-attach paths now defer the wait-for-running poll
358+
# (and the URL/org-info refresh that comes with it) until the first I/O
359+
# operation runs ``_ensure_warm``. The platform auto-wakes paused sprites
360+
# on traffic arrival and the create POST raises eagerly on rejection, so
361+
# this purely shifts the warm-up cost from session creation to first use
362+
# without losing any safety. Callers that need ``Sprite.url`` (e.g.
363+
# ``_resolve_exposed_port``) call ``_ensure_warm`` themselves.
364+
370365
async def _ensure_warm(self) -> None:
371366
"""Block until the sprite is ready to accept I/O, but only on first use.
372367
@@ -952,7 +947,19 @@ async def _terminate_pty_entry(self, entry: _SpritePtyProcessEntry) -> None:
952947
self._release_control(entry.control)
953948

954949
async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
955-
sprite = await self._ensure_sprite()
950+
await self._ensure_sprite()
951+
# Make sure the sprite is reachable AND that ``Sprite.url`` /
952+
# ``organization_name`` are populated — these come from the post-poll
953+
# ``get_sprite`` refresh.
954+
await self._ensure_warm()
955+
sprite = self._sprite
956+
if sprite is None:
957+
raise ExposedPortUnavailableError(
958+
port=port,
959+
exposed_ports=self.state.exposed_ports,
960+
reason="backend_unavailable",
961+
context={"backend": "sprites", "sprite_name": self.state.sprite_name},
962+
)
956963
url = sprite.url
957964
if not url:
958965
raise ExposedPortUnavailableError(

tests/extensions/test_sandbox_sprites.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,8 +378,10 @@ async def test_create_ephemeral_sprite(patched_sprites: dict[str, Any]) -> None:
378378
assert inner.state.created_by_us is True
379379
assert len(fake_client.create_sprite_calls) == 1
380380
assert fake_client.create_sprite_calls[0][0].startswith("openai-agents-")
381-
# get_sprite called for status poll + the post-create refresh
382-
assert len(fake_client.get_sprite_calls) >= 1
381+
# No eager get_sprite poll — ephemeral path is lazy too. The first I/O
382+
# operation drives the wait-for-running via ``_ensure_warm``.
383+
assert fake_client.get_sprite_calls == []
384+
assert inner._warmth_verified is False
383385
# delete via client.delete deletes the ephemeral sprite
384386
await client.delete(session)
385387
assert fake_client.delete_sprite_calls == [fake_client.create_sprite_calls[0][0]]

0 commit comments

Comments
 (0)