|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | | -from typing import Literal |
| 5 | +import asyncio |
| 6 | +from typing import Any, Literal |
6 | 7 |
|
7 | 8 | from pydantic import PrivateAttr |
8 | 9 |
|
| 10 | +from ....run_context import RunContextWrapper |
9 | 11 | from ....sandbox.capabilities.capability import Capability |
10 | 12 | from ....sandbox.manifest import Manifest |
11 | 13 | from ....sandbox.session.base_sandbox_session import BaseSandboxSession |
| 14 | +from ....tool import Tool, function_tool |
12 | 15 |
|
13 | 16 | DEFAULT_SPRITES_CONTEXT_PATH = "/.sprite/llm.txt" |
14 | 17 |
|
| 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 | + |
15 | 22 |
|
16 | 23 | class SpritesPlatformContext(Capability): |
17 | 24 | """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: |
89 | 96 | return framed |
90 | 97 |
|
91 | 98 |
|
| 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 | + |
92 | 354 | __all__ = [ |
93 | 355 | "DEFAULT_SPRITES_CONTEXT_PATH", |
| 356 | + "SpritesCheckpoints", |
94 | 357 | "SpritesPlatformContext", |
| 358 | + "SpritesUrlAccess", |
| 359 | + "UrlVisibility", |
95 | 360 | ] |
0 commit comments