Skip to content

Commit 30df3dd

Browse files
committed
fix(sprites): cache platform-context text across capability clones
``Capability.clone`` runs every agent turn and resets per-instance attribute state, so the previous ``_cached_text`` PrivateAttr never hit — every turn re-executed ``cat /.sprite/llm.txt`` to inject the platform doc, waking the sprite even when the model never made a tool call. Promotes the cache to module scope keyed by ``(sprite_name, path)``. Survives across all clones for the same sprite, so the file lands exactly once per sprite for the life of the process. Adds ``clear_platform_context_cache`` for applications that need to force a re-fetch (e.g. after a sprite image upgrade). Adds an autouse fixture in the test suite that clears the cache between tests so state doesn't leak. 2 new regression tests cover the cross-clone caching and the explicit clear path.
1 parent 35821bb commit 30df3dd

4 files changed

Lines changed: 135 additions & 16 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
SpritesPlatformContext,
77
SpritesUrlAccess,
88
UrlVisibility,
9+
clear_platform_context_cache,
910
)
1011
from .sandbox import (
1112
DEFAULT_SPRITES_API_URL,
@@ -30,4 +31,5 @@
3031
"SpritesSandboxSessionState",
3132
"SpritesUrlAccess",
3233
"UrlVisibility",
34+
"clear_platform_context_cache",
3335
]

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

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
import asyncio
66
from typing import Any, Literal
77

8-
from pydantic import PrivateAttr
9-
108
from ....run_context import RunContextWrapper
119
from ....sandbox.capabilities.capability import Capability
1210
from ....sandbox.manifest import Manifest
@@ -19,6 +17,34 @@
1917
"""Sprites URL visibility values. ``"sprite"`` restricts the URL to organization
2018
members (the platform's default); ``"public"`` opens it to the internet."""
2119

20+
# Module-level cache of the framed platform-context text keyed by sprite name.
21+
# ``Capability.clone`` runs every agent turn and resets per-instance attribute
22+
# state, so a per-instance cache would re-exec ``cat /.sprite/llm.txt`` every
23+
# turn — waking a paused sprite for nothing on turns where the model never
24+
# calls a tool. Caching at module scope by sprite name lets the file land
25+
# exactly once per sprite for the life of the process. ``clear_platform_context_cache``
26+
# below is exposed for applications that want to force a re-fetch (e.g.
27+
# after a sprite image upgrade).
28+
_PLATFORM_CONTEXT_CACHE: dict[tuple[str, str], str] = {}
29+
30+
31+
def clear_platform_context_cache(sprite_name: str | None = None, path: str | None = None) -> None:
32+
"""Forget cached platform-context text.
33+
34+
With no arguments, clears every entry. Pass ``sprite_name`` (and optionally
35+
``path``) to evict a specific entry.
36+
"""
37+
38+
if sprite_name is None:
39+
_PLATFORM_CONTEXT_CACHE.clear()
40+
return
41+
if path is None:
42+
for key in list(_PLATFORM_CONTEXT_CACHE.keys()):
43+
if key[0] == sprite_name:
44+
del _PLATFORM_CONTEXT_CACHE[key]
45+
return
46+
_PLATFORM_CONTEXT_CACHE.pop((sprite_name, path), None)
47+
2248

2349
class SpritesPlatformContext(Capability):
2450
"""Inject the sprite's ``/.sprite/llm.txt`` platform-context file into the agent's instructions.
@@ -56,24 +82,20 @@ class SpritesPlatformContext(Capability):
5682
timeout_s: float = 5.0
5783
"""Timeout for the ``cat`` exec call."""
5884

59-
_cached_text: str | None = PrivateAttr(default=None)
60-
61-
def bind(self, session: BaseSandboxSession) -> None:
62-
super().bind(session)
63-
# Reset the cache on rebind so a different session re-reads its own file.
64-
self._cached_text = None
65-
6685
async def instructions(self, manifest: Manifest) -> str | None:
6786
_ = manifest
68-
if self._cached_text is not None:
69-
return self._cached_text
70-
if self.session is None:
87+
session = self.session
88+
if session is None:
7189
return None
7290

91+
sprite_name = _resolve_sprite_name(session)
92+
cache_key = (sprite_name or "", self.path)
93+
cached = _PLATFORM_CONTEXT_CACHE.get(cache_key)
94+
if cached is not None:
95+
return cached
96+
7397
try:
74-
result = await self.session.exec(
75-
"cat", "--", self.path, shell=False, timeout=self.timeout_s
76-
)
98+
result = await session.exec("cat", "--", self.path, shell=False, timeout=self.timeout_s)
7799
except Exception:
78100
return None
79101
if not result.ok():
@@ -92,7 +114,8 @@ async def instructions(self, manifest: Manifest) -> str | None:
92114
f"{text}\n"
93115
"</sprites-platform-context>"
94116
)
95-
self._cached_text = framed
117+
if sprite_name:
118+
_PLATFORM_CONTEXT_CACHE[cache_key] = framed
96119
return framed
97120

98121

@@ -111,6 +134,17 @@ def _resolve_sprite_handle(session: BaseSandboxSession | None) -> Any | None:
111134
return sprite
112135

113136

137+
def _resolve_sprite_name(session: BaseSandboxSession | None) -> str | None:
138+
"""Return the underlying sprite's name, or None if not yet known."""
139+
140+
if session is None:
141+
return None
142+
inner = getattr(session, "_inner", session)
143+
state = getattr(inner, "state", None)
144+
name = getattr(state, "sprite_name", None) if state is not None else None
145+
return name if isinstance(name, str) and name else None
146+
147+
114148
class SpritesUrlAccess(Capability):
115149
"""Expose a tool that lets the agent toggle the sprite's public URL visibility.
116150
@@ -357,4 +391,5 @@ def _do_restore() -> list[str]:
357391
"SpritesPlatformContext",
358392
"SpritesUrlAccess",
359393
"UrlVisibility",
394+
"clear_platform_context_cache",
360395
]

tests/extensions/test_sandbox_sprites.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@
4141
SESSION_UUID = uuid.UUID("11111111-1111-1111-1111-111111111111")
4242

4343

44+
@pytest.fixture(autouse=True)
45+
def _clear_platform_context_cache() -> Any:
46+
"""Make sure cached platform-context text from one test doesn't leak."""
47+
48+
from agents.extensions.sandbox.sprites import clear_platform_context_cache
49+
50+
clear_platform_context_cache()
51+
yield
52+
clear_platform_context_cache()
53+
54+
4455
def _attach(inner: SpritesSandboxSession, *, client: Any, sprite: Any = None) -> None:
4556
"""Inject fake client/sprite into a SpritesSandboxSession.
4657
@@ -1243,3 +1254,73 @@ async def test_activity_during_idle_window_keeps_connection_open(
12431254
assert watcher is not None
12441255
await asyncio.wait_for(watcher, timeout=0.2)
12451256
assert sprite.close_control_connection_calls == 1
1257+
1258+
1259+
# ---------- 19. Platform-context cache survives cloning ----------
1260+
1261+
1262+
@pytest.mark.asyncio
1263+
async def test_sprites_platform_context_cache_survives_clone(
1264+
patched_sprites: dict[str, Any],
1265+
) -> None:
1266+
"""Each agent turn re-clones capabilities; the cache must survive that.
1267+
1268+
Without a module-level cache, a new clone wakes the sprite every turn
1269+
just to re-read the (unchanged) platform-context file. With it, only
1270+
the first turn for a given sprite-name pays the exec.
1271+
"""
1272+
1273+
fake_control = patched_sprites["control"]
1274+
fake_control.next_ops.append(_FakeOpConn(stdout=b"# Sprite\n", exit_code=0))
1275+
fake_client = patched_sprites["client"]
1276+
sprite = _FakeSprite(name=SPRITE_NAME)
1277+
fake_client._sprites_by_name[SPRITE_NAME] = sprite
1278+
state = _make_state()
1279+
inner = SpritesSandboxSession.from_state(state, token="tok")
1280+
_attach(inner, client=fake_client, sprite=sprite)
1281+
1282+
# Turn 1: a fresh clone fetches and caches.
1283+
cap1 = SpritesPlatformContext()
1284+
cap1.bind(inner)
1285+
out1 = await cap1.instructions(state.manifest)
1286+
assert out1 is not None
1287+
assert "<sprites-platform-context>" in out1
1288+
assert len(fake_control.start_op_calls) == 1
1289+
1290+
# Turn 2: a NEW clone targeting the same sprite hits the module cache.
1291+
cap2 = SpritesPlatformContext()
1292+
cap2.bind(inner)
1293+
out2 = await cap2.instructions(state.manifest)
1294+
assert out2 == out1
1295+
# Still just the one exec — turn 2 didn't touch the sprite.
1296+
assert len(fake_control.start_op_calls) == 1
1297+
1298+
1299+
@pytest.mark.asyncio
1300+
async def test_sprites_platform_context_cache_clear_forces_refetch(
1301+
patched_sprites: dict[str, Any],
1302+
) -> None:
1303+
from agents.extensions.sandbox.sprites import clear_platform_context_cache
1304+
1305+
fake_control = patched_sprites["control"]
1306+
fake_control.next_ops.extend(
1307+
[_FakeOpConn(stdout=b"v1\n", exit_code=0), _FakeOpConn(stdout=b"v2\n", exit_code=0)]
1308+
)
1309+
fake_client = patched_sprites["client"]
1310+
sprite = _FakeSprite(name=SPRITE_NAME)
1311+
fake_client._sprites_by_name[SPRITE_NAME] = sprite
1312+
state = _make_state()
1313+
inner = SpritesSandboxSession.from_state(state, token="tok")
1314+
_attach(inner, client=fake_client, sprite=sprite)
1315+
1316+
cap = SpritesPlatformContext()
1317+
cap.bind(inner)
1318+
out1 = await cap.instructions(state.manifest)
1319+
assert out1 is not None and "v1" in out1
1320+
assert len(fake_control.start_op_calls) == 1
1321+
1322+
# Cache invalidation forces a re-fetch.
1323+
clear_platform_context_cache(SPRITE_NAME)
1324+
out2 = await cap.instructions(state.manifest)
1325+
assert out2 is not None and "v2" in out2
1326+
assert len(fake_control.start_op_calls) == 2

tests/sandbox/test_compatibility_guards.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ def test_core_sandbox_public_export_surface_is_stable() -> None:
347347
"SpritesSandboxSessionState",
348348
"SpritesUrlAccess",
349349
"UrlVisibility",
350+
"clear_platform_context_cache",
350351
},
351352
),
352353
],

0 commit comments

Comments
 (0)