Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -583,3 +583,33 @@ SDK. A snapshot or changelog is restored in the form it was captured in: one
captured as objects only through the object variants, an older one only
through `s3_prefix`. Do not roll agent-env back once portable snapshots or
changelogs exist, because older releases cannot load them.

#### Staging

When the object store's grants cannot reach the agent, as with a local store
and an agent on a remote sandbox, AgentEnv moves the objects through the agent
itself. Every SDK agent serves the staging extension, `urn:agentenv:staging/v1`,
a small object store at `/ext/staging` on its own server. Before a call
AgentEnv pushes what the agent will read into it; after the call it pulls what
the agent wrote. The grants it sends are the ordinary ones above, with URLs
naming staged paths on the agent's own URL, so handlers and helpers are
unchanged.

| Route | Does |
| --- | --- |
| `PUT /ext/staging/{path}` | stores the body |
| `GET /ext/staging/{path}` | returns it, with an `ETag` |
| `POST /ext/staging/{prefix}` | stores a multipart upload's `file` at `{prefix}/{key}`, `key` being a form field sent before it |
| `GET /ext/staging/{prefix}/` | lists `{objects: [{path, size_bytes, etag}]}` below the prefix |
| `DELETE /ext/staging/{path}` | removes the object, only while it still matches an `If-Match` tag when one is given |
| `DELETE /ext/staging/{prefix}/` | removes everything below the prefix |

A path's first segment is an id AgentEnv generates, at least 22 characters, and
never shares, which is what keeps staged objects private on a public agent URL.
Everything staged counts against `AGENTENV_STAGING_MAX_BYTES` (16 GiB unless
set; `0` turns staging off) and lives until AgentEnv removes it or the server
process ends, in a directory of the process's own (or under
`AGENTENV_STAGING_DIR` when set) that only the server's user can read. One
server process owns a staging directory. An agent that doesn't use the SDK can serve the same routes
and add `{uri: "urn:agentenv:staging/v1", params: {endpoint: "/ext/staging"}}`
to its card.
7 changes: 7 additions & 0 deletions packages/agentenv-protocol/THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,13 @@ The runtime dependencies of `agentenv-protocol` and its `agent` extra, at the ve
- Source: <https://github.com/pydantic/pydantic-core>
- [License text 2](#license-text-2)

### python-multipart 0.0.32

- License: Apache-2.0
- Author: Andrew Dunham <andrew@du.nham.ca>
- Source: <https://github.com/Kludex/python-multipart>
- [License text 1](#license-text-1)

### regex 2026.6.28

- License: Apache-2.0 AND CNRI-Python
Expand Down
2 changes: 2 additions & 0 deletions packages/agentenv-protocol/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ dependencies = [
[project.optional-dependencies]
agent = [
"a2a-sdk[http-server]>=0.3.26,<0.4",
"python-multipart>=0.0.13", # the staging extension's upload forms
"regex>=2023.12.25",
"uvicorn>=0.30",
]
dev = [
"a2a-sdk[http-server]>=0.3.26,<0.4",
"python-multipart>=0.0.13",
"regex>=2023.12.25",
"pytest>=9.0",
"pytest-asyncio>=0.23.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
RegisteredOperation,
build_registry,
)
from .staging import STAGING_ENDPOINT, STAGING_V1_URI, StagingStore, staging_routes
from .tasks.v1 import (
AgentConfig,
AgentRunResult,
Expand Down Expand Up @@ -116,6 +117,8 @@
"PEER_AGENTS_V1",
"SKILL_CONFIG_V1",
"SNAPSHOT_V1",
"STAGING_ENDPOINT",
"STAGING_V1_URI",
"STANDARD_EXTENSIONS",
"TRAJECTORY_V1",
"TRIGGERS_V1",
Expand Down Expand Up @@ -172,6 +175,7 @@
"SnapshotReadObjects",
"SnapshotUploadedObjects",
"SnapshotWriteObjects",
"StagingStore",
"TaskError",
"TaskObjectTrajectoryRequest",
"TaskOutcome",
Expand Down Expand Up @@ -204,5 +208,6 @@
"extension",
"request_fields",
"serve",
"staging_routes",
"upload",
]
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
enable,
)
from .registry import RegisteredOperation, build_registry
from .staging import STAGING_ENDPOINT, StagingStore, staging_routes
from .tasks.v1 import (
AgentConfig,
AgentRunResult,
Expand Down Expand Up @@ -873,6 +874,15 @@ def __init__(self, agent: Any, definition: _AgentDefinition | None = None) -> No
("/.well-known/agent-card.json", "GET"): "Agent Card",
}
)
# Objects agent-env moves through the agent when its object store's grants cannot reach it.
self.staging = StagingStore()
for extension in self.registry.extensions if self.staging.max_bytes else ():
for operation in extension.operations.values():
if operation.definition.path.startswith(STAGING_ENDPOINT + "/"):
raise ValueError(
f"extension operation {extension.definition.uri}.{operation.definition.name} "
f"conflicts with the staging routes below {STAGING_ENDPOINT}"
)
conformance = self.registry.conformance()
for override in conformance["standard_operation_overrides"]:
logger.warning(
Expand Down Expand Up @@ -906,6 +916,8 @@ def __init__(self, agent: Any, definition: _AgentDefinition | None = None) -> No
methods=[operation.definition.method],
)
)
if self.staging.max_bytes:
routes.extend(staging_routes(self.staging))
kwargs = {"routes": routes}
if definition.lifespan is not None:
kwargs["lifespan"] = definition.lifespan
Expand Down Expand Up @@ -953,6 +965,8 @@ def _build_card(self) -> Any:
AgentExtension.model_validate(item)
for item in self.registry.card_extensions()
]
if self.staging.max_bytes:
extensions.append(AgentExtension.model_validate(self.staging.card_extension()))
resolved_capabilities = AgentCapabilities(
streaming=self.streaming,
push_notifications=False,
Expand Down
Loading
Loading