Agent Sandbox Registry

python-sdk

Decision: SDK Capability Surface (Capability-Driven, Type-Safe Dynamic)

无中文版

.agents/notes/implemented/architecture/2026-09-03-sdk-capability-surface.md

Decision: SDK Capability Surface (Capability-Driven, Type-Safe Dynamic)

Status: implemented

Problem

The SDK must expose two kinds of commands: (a) standard capabilities with well-known signatures (commands.run, files.upload, ...) and (b) template-declared custom commands whose names are only known at runtime. We need dynamic dispatch for (b) without sacrificing the static type safety promised by py.typed + mypy.

Decision

Keep standard capabilities as typed methods, and route custom commands through explicit dynamic dispatch — no __getattr__ magic.

  1. Standard capabilities keep their typed methods (sandbox.commands.run(...), sandbox.files.upload(...), etc.). Each is subject to capability gating (raises CapabilityNotSupportedError when the capability is absent — see capability-model ADR).
  2. Custom commands are invoked via one explicit method: sandbox.run("name", **args). This is a normal, typed method — arguments are passed through a **kwargs mapping and validated against the template's custom_commands schema at call time. We deliberately do not use __getattr__/magic attributes (e.g. sandbox.serve(...)), which would be invisible to mypy and break py.typed.
  3. Discovery API: sandbox.capabilities returns the effective capability set (frozenset[str]); sandbox.list_commands() returns the template's declared custom commands as a list of plain dicts (not objects) — each dict carries name + description + args. These let users and Agents introspect what a sandbox can do.

API Design

class Sandbox:
    @property
    def capabilities(self) -> frozenset[Capability]:
        """Effective standard-capability set for this sandbox."""
        ...

    def list_commands(self) -> list[dict[str, Any]]:
        """Template-declared custom commands as a list of plain dicts:
        {"name": str, "description": str,
         "args": [{"name": str, "required": bool,
                   "default": str | None, "description": str}]}
        """
        ...

    async def run(self, name: str, **args: str) -> ProcessResult:
        """Dispatch a template-declared custom command by name.
        Raises if `name` is unknown or a required arg is missing.
        Values are shlex.quote()-escaped before substitution.
        """
        ...

# Standard, typed, gated as before:
#   await sandbox.commands.run("ls -la")     # requires "shell"
#   await sandbox.files.upload(local, remote) # requires "files"

MCP tools filtered by capabilities = deferred to Phase 2: the MCP Server currently exposes 7 P0 tools that rely only on the default capabilities (shell / files / code) and does not dynamically filter the tool list by capabilities. When a sandbox has restricted capabilities, dispatch_tool catches CapabilityNotSupportedError and returns a structured error (rather than crashing or silently omitting the tool). Dynamically filtering the MCP tool set by capabilities is left for Phase 2.

Alternatives considered

Dependencies

Test Strategy

Acceptance criteria