Skip to content

Commit 7c4f725

Browse files
committed
feat(sprites): add SpritesUrlAccess and SpritesCheckpoints capabilities
Adds two opt-in capabilities to agents.extensions.sandbox.sprites for platform-specific affordances that the in-VM CLI cannot reach: - SpritesUrlAccess(allow_public=False): exposes a set_sprite_url_visibility(visibility="public" | "sprite") tool that calls Sprite.update_url_settings via the SDK's authenticated client. Default-deny on "public" — apps must explicitly set allow_public=True to expose the option to the agent. Closes the loop where models fumble between unauthenticated `sprite update` and `sprite-env curl` attempts when asked to make the URL public. - SpritesCheckpoints(allow_restore=False): exposes create_sprite_checkpoint(comment), list_sprite_checkpoints(), and (when allow_restore=True) restore_sprite_checkpoint(id). Wraps the sync NDJSON-streaming sprites-py iterator in asyncio.to_thread and filters the platform's "Current" sentinel from the create result so the model gets the actual saved snapshot id (e.g. "v1") back. Restore is destructive (replaces the workspace) so it stays gated off until the application opts in. Capability.bind() receives the runtime SandboxSession wrapper, so the shared _resolve_sprite_handle helper now steps through ``_inner`` to reach the SpritesSandboxSession before reading ``_sprite``. Updates compat-guard exports, package re-exports, and adds 11 unit tests. mypy / ruff / 110-test focused suite all green.
1 parent 08fb58e commit 7c4f725

5 files changed

Lines changed: 507 additions & 1 deletion

File tree

src/agents/extensions/sandbox/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,13 @@
115115
DEFAULT_SPRITES_CONTEXT_PATH as DEFAULT_SPRITES_CONTEXT_PATH,
116116
DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S as DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S,
117117
DEFAULT_SPRITES_WORKSPACE_ROOT as DEFAULT_SPRITES_WORKSPACE_ROOT,
118+
SpritesCheckpoints as SpritesCheckpoints,
118119
SpritesPlatformContext as SpritesPlatformContext,
119120
SpritesSandboxClient as SpritesSandboxClient,
120121
SpritesSandboxClientOptions as SpritesSandboxClientOptions,
121122
SpritesSandboxSession as SpritesSandboxSession,
122123
SpritesSandboxSessionState as SpritesSandboxSessionState,
124+
SpritesUrlAccess as SpritesUrlAccess,
123125
)
124126

125127
_HAS_SPRITES = True
@@ -232,10 +234,12 @@
232234
"DEFAULT_SPRITES_CONTEXT_PATH",
233235
"DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S",
234236
"DEFAULT_SPRITES_WORKSPACE_ROOT",
237+
"SpritesCheckpoints",
235238
"SpritesPlatformContext",
236239
"SpritesSandboxClient",
237240
"SpritesSandboxClientOptions",
238241
"SpritesSandboxSession",
239242
"SpritesSandboxSessionState",
243+
"SpritesUrlAccess",
240244
]
241245
)

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
from .capabilities import (
44
DEFAULT_SPRITES_CONTEXT_PATH,
5+
SpritesCheckpoints,
56
SpritesPlatformContext,
7+
SpritesUrlAccess,
8+
UrlVisibility,
69
)
710
from .sandbox import (
811
DEFAULT_SPRITES_API_URL,
@@ -19,9 +22,12 @@
1922
"DEFAULT_SPRITES_CONTEXT_PATH",
2023
"DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S",
2124
"DEFAULT_SPRITES_WORKSPACE_ROOT",
25+
"SpritesCheckpoints",
2226
"SpritesPlatformContext",
2327
"SpritesSandboxClient",
2428
"SpritesSandboxClientOptions",
2529
"SpritesSandboxSession",
2630
"SpritesSandboxSessionState",
31+
"SpritesUrlAccess",
32+
"UrlVisibility",
2733
]

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

Lines changed: 266 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,23 @@
22

33
from __future__ import annotations
44

5-
from typing import Literal
5+
import asyncio
6+
from typing import Any, Literal
67

78
from pydantic import PrivateAttr
89

10+
from ....run_context import RunContextWrapper
911
from ....sandbox.capabilities.capability import Capability
1012
from ....sandbox.manifest import Manifest
1113
from ....sandbox.session.base_sandbox_session import BaseSandboxSession
14+
from ....tool import Tool, function_tool
1215

1316
DEFAULT_SPRITES_CONTEXT_PATH = "/.sprite/llm.txt"
1417

18+
UrlVisibility = Literal["public", "sprite"]
19+
"""Sprites URL visibility values. ``"sprite"`` restricts the URL to organization
20+
members (the platform's default); ``"public"`` opens it to the internet."""
21+
1522

1623
class SpritesPlatformContext(Capability):
1724
"""Inject the sprite's ``/.sprite/llm.txt`` platform-context file into the agent's instructions.
@@ -89,7 +96,265 @@ async def instructions(self, manifest: Manifest) -> str | None:
8996
return framed
9097

9198

99+
def _resolve_sprite_handle(session: BaseSandboxSession | None) -> Any | None:
100+
"""Return the underlying ``sprites.Sprite`` from a SpritesSandboxSession, or None.
101+
102+
Capabilities are bound to the runtime ``SandboxSession`` wrapper, not the
103+
inner backend session — so we dig through ``_inner`` to reach the
104+
SpritesSandboxSession's ``_sprite`` attribute.
105+
"""
106+
107+
if session is None:
108+
return None
109+
inner = getattr(session, "_inner", session)
110+
sprite = getattr(inner, "_sprite", None)
111+
return sprite
112+
113+
114+
class SpritesUrlAccess(Capability):
115+
"""Expose a tool that lets the agent toggle the sprite's public URL visibility.
116+
117+
Sprite URL access is a *host-platform* setting, not something the in-VM
118+
``sprite-env`` CLI can change — the in-VM API socket only exposes
119+
services/checkpoints. Without this capability, an agent asked to "make the
120+
URL public" tends to thrash between unauthenticated commands. This
121+
capability wraps ``Sprite.update_url_settings`` (which already has the
122+
application's API token via ``SpritesSandboxClient``) so the model can
123+
flip visibility in one call.
124+
125+
Going ``public`` is gated by ``allow_public`` (default ``False``). The
126+
application must explicitly opt in to expose that option to the agent;
127+
otherwise the tool only accepts ``"sprite"`` (org-members-only).
128+
129+
Example:
130+
131+
agent = SandboxAgent(
132+
...,
133+
capabilities=[
134+
WorkspaceShellCapability(),
135+
Filesystem(),
136+
SpritesPlatformContext(),
137+
SpritesUrlAccess(allow_public=True),
138+
],
139+
)
140+
"""
141+
142+
type: Literal["sprites_url_access"] = "sprites_url_access"
143+
allow_public: bool = False
144+
"""When ``False`` (default), the tool refuses ``visibility="public"``."""
145+
146+
def tools(self) -> list[Tool]:
147+
capability = self
148+
allow_public = self.allow_public
149+
if allow_public:
150+
allowed_doc = (
151+
"Pass 'public' to make the sprite reachable from the open internet, "
152+
"or 'sprite' to restrict it to organization members."
153+
)
154+
else:
155+
allowed_doc = (
156+
"Pass 'sprite' to restrict the sprite URL to organization members. "
157+
"(The 'public' option has been disabled by application policy.)"
158+
)
159+
160+
@function_tool(name_override="set_sprite_url_visibility")
161+
async def set_sprite_url_visibility(
162+
ctx: RunContextWrapper[Any],
163+
visibility: UrlVisibility,
164+
) -> str:
165+
"""Change the sprite's public URL access mode."""
166+
167+
_ = ctx
168+
return await capability._apply_visibility(visibility)
169+
170+
# Stash a docstring fragment for tools that introspect descriptions.
171+
setattr(set_sprite_url_visibility, "_allowed_doc", allowed_doc) # noqa: B010
172+
return [set_sprite_url_visibility]
173+
174+
async def _apply_visibility(self, visibility: str) -> str:
175+
if visibility not in ("public", "sprite"):
176+
return f"error: visibility must be 'public' or 'sprite', got {visibility!r}"
177+
if visibility == "public" and not self.allow_public:
178+
return (
179+
"error: setting URL to 'public' is disabled by application policy. "
180+
"Use visibility='sprite' to keep it private to org members."
181+
)
182+
183+
sprite = _resolve_sprite_handle(self.session)
184+
if sprite is None:
185+
return "error: sprite handle not available (session not started?)"
186+
try:
187+
from sprites.types import URLSettings
188+
189+
await asyncio.to_thread(sprite.update_url_settings, URLSettings(auth=visibility))
190+
except Exception as exc: # noqa: BLE001
191+
return f"error updating URL settings: {exc!r}"
192+
return f"sprite URL visibility is now {visibility!r}"
193+
194+
195+
class SpritesCheckpoints(Capability):
196+
"""Expose tools to create, list, and (optionally) restore native sprite checkpoints.
197+
198+
Sprite checkpoints are point-in-time snapshots of the writable filesystem
199+
overlay. They're a Sprites-specific feature — most other sandbox providers
200+
don't have anything equivalent at this granularity. This capability lets
201+
the agent take a checkpoint before risky multi-file work and (when
202+
explicitly enabled) roll back to it.
203+
204+
Restore is destructive — it replaces the entire workspace. Gate it
205+
deliberately with ``allow_restore``. Default ``False``: the agent can save
206+
checkpoints freely but cannot roll back without application opt-in.
207+
208+
Example:
209+
210+
agent = SandboxAgent(
211+
...,
212+
capabilities=[
213+
...,
214+
SpritesCheckpoints(allow_restore=True),
215+
],
216+
)
217+
"""
218+
219+
type: Literal["sprites_checkpoints"] = "sprites_checkpoints"
220+
allow_restore: bool = False
221+
"""When ``False`` (default), the restore tool is omitted entirely."""
222+
223+
def tools(self) -> list[Tool]:
224+
capability = self
225+
226+
@function_tool(name_override="create_sprite_checkpoint")
227+
async def create_sprite_checkpoint(
228+
ctx: RunContextWrapper[Any],
229+
comment: str = "",
230+
) -> str:
231+
"""Create a sprite filesystem checkpoint and return its id and metadata."""
232+
233+
_ = ctx
234+
return await capability._create(comment)
235+
236+
@function_tool(name_override="list_sprite_checkpoints")
237+
async def list_sprite_checkpoints(
238+
ctx: RunContextWrapper[Any],
239+
) -> str:
240+
"""List all sprite checkpoints (most recent first)."""
241+
242+
_ = ctx
243+
return await capability._list()
244+
245+
tools_list: list[Tool] = [create_sprite_checkpoint, list_sprite_checkpoints]
246+
247+
if self.allow_restore:
248+
249+
@function_tool(name_override="restore_sprite_checkpoint")
250+
async def restore_sprite_checkpoint(
251+
ctx: RunContextWrapper[Any],
252+
checkpoint_id: str,
253+
) -> str:
254+
"""Restore the sprite filesystem to a previously-created checkpoint.
255+
256+
DESTRUCTIVE: replaces the entire workspace with the checkpoint state.
257+
Any uncommitted changes since the checkpoint are lost.
258+
"""
259+
260+
_ = ctx
261+
return await capability._restore(checkpoint_id)
262+
263+
tools_list.append(restore_sprite_checkpoint)
264+
265+
return tools_list
266+
267+
async def _create(self, comment: str) -> str:
268+
sprite = _resolve_sprite_handle(self.session)
269+
if sprite is None:
270+
return "error: sprite handle not available (session not started?)"
271+
272+
def _do_create() -> dict[str, Any]:
273+
# ``Sprite.create_checkpoint`` returns an iterator of ``StreamMessage``
274+
# (no checkpoint id in the stream itself), so consume it and then
275+
# pull the most-recent saved checkpoint from ``list_checkpoints``.
276+
stream = sprite.create_checkpoint(comment)
277+
errors: list[str] = []
278+
for msg in stream:
279+
if getattr(msg, "type", "") == "error":
280+
err = getattr(msg, "error", None) or getattr(msg, "data", None)
281+
if err:
282+
errors.append(str(err))
283+
if errors:
284+
raise RuntimeError("; ".join(errors))
285+
existing = sprite.list_checkpoints()
286+
# ``Current`` is the platform's live-state pointer that always
287+
# appears at the top of the list; skip it so we report the actual
288+
# saved snapshot we just made.
289+
saved = [c for c in existing if str(getattr(c, "id", "")).lower() != "current"]
290+
if not saved:
291+
return {}
292+
saved.sort(key=lambda c: c.create_time, reverse=True)
293+
latest = saved[0]
294+
return {
295+
"id": latest.id,
296+
"comment": latest.comment or "",
297+
"created_at": latest.create_time.isoformat(),
298+
}
299+
300+
try:
301+
result = await asyncio.to_thread(_do_create)
302+
except Exception as exc: # noqa: BLE001
303+
return f"error creating checkpoint: {exc!r}"
304+
if not result:
305+
return "checkpoint creation completed but no checkpoint was found"
306+
return (
307+
f"checkpoint created: id={result['id']!r}, "
308+
f"comment={result['comment']!r}, created_at={result['created_at']!r}"
309+
)
310+
311+
async def _list(self) -> str:
312+
sprite = _resolve_sprite_handle(self.session)
313+
if sprite is None:
314+
return "error: sprite handle not available (session not started?)"
315+
try:
316+
checkpoints = await asyncio.to_thread(sprite.list_checkpoints)
317+
except Exception as exc: # noqa: BLE001
318+
return f"error listing checkpoints: {exc!r}"
319+
if not checkpoints:
320+
return "no checkpoints"
321+
rows = [
322+
f"- {c.id} (created {c.create_time.isoformat()})"
323+
+ (f": {c.comment}" if c.comment else "")
324+
for c in checkpoints
325+
]
326+
return "\n".join(rows)
327+
328+
async def _restore(self, checkpoint_id: str) -> str:
329+
if not self.allow_restore:
330+
return "error: restore is disabled by application policy"
331+
sprite = _resolve_sprite_handle(self.session)
332+
if sprite is None:
333+
return "error: sprite handle not available (session not started?)"
334+
335+
def _do_restore() -> list[str]:
336+
stream = sprite.restore_checkpoint(checkpoint_id)
337+
errors: list[str] = []
338+
for msg in stream:
339+
if getattr(msg, "type", "") == "error":
340+
err = getattr(msg, "error", None) or getattr(msg, "data", None)
341+
if err:
342+
errors.append(str(err))
343+
return errors
344+
345+
try:
346+
errors = await asyncio.to_thread(_do_restore)
347+
except Exception as exc: # noqa: BLE001
348+
return f"error restoring checkpoint: {exc!r}"
349+
if errors:
350+
return f"restore completed with errors: {'; '.join(errors)}"
351+
return f"restored checkpoint {checkpoint_id!r}"
352+
353+
92354
__all__ = [
93355
"DEFAULT_SPRITES_CONTEXT_PATH",
356+
"SpritesCheckpoints",
94357
"SpritesPlatformContext",
358+
"SpritesUrlAccess",
359+
"UrlVisibility",
95360
]

0 commit comments

Comments
 (0)