Skip to content
Open
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
26 changes: 26 additions & 0 deletions packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,32 @@ class SlackEnv(AgentEnvEnvironment):
return [DataPart(data=self.state())]
```

A handler that takes a `write_object` parameter (a `transfers.WriteObject`) is handed one object to
upload its export to, named by the caller; it uploads with `transfers.upload(write_object, path)`,
which sends a parts grant's ranges in parallel, and answers with `uploaded_object_part(uploaded,
name=..., mime_type=...)`, read back with `uploaded_object(part)`. Such a handler is declared under
`urn:agentenv:transfers/v1` (`TRANSFERS_EXTENSION_URI`) with the write grant kinds it uploads
through, `{"write": [...]}`: `http-put` and `http-put-parts`, after `http-post-policy` when it also
takes `write_namespace`. `client.write_kinds(card)` reads them, taking a card without the declaration
to accept `http-post-policy` alone when it advertises `DATA_OBJECTS_EXTENSION_URI`, and nothing
otherwise; a caller sends only a grant whose kind the card lists.

```python
from agentenv_protocol import AgentEnvEnvironment, DataPart, environment_card, get_data, uploaded_object_part
from agentenv_protocol.transfers import WriteObject, upload


@environment_card(name="github")
class GithubEnv(AgentEnvEnvironment):
@get_data
async def _state(self, write_object: WriteObject | None = None):
bundle = self.write_bundle()
if write_object is not None and bundle.stat().st_size <= write_object.max_bytes:
uploaded = await upload(write_object, bundle)
return [uploaded_object_part(uploaded, name="github.zip", mime_type="application/zip")]
return [DataPart(data=self.state())]
```

Dependencies are intentionally light (`pydantic`, `starlette`, `httpx`) so the package can be added to environment server images without pulling a heavier framework — `mcp` is imported lazily inside `create_fastmcp_app()` and is deliberately not a dependency.

## Calling an env's tools
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
)
from .types import (
DATA_OBJECTS_EXTENSION_URI,
TRANSFERS_EXTENSION_URI,
INTAKE_EXTENSION_URI,
MCP_PATH,
MCP_TRANSPORT,
Expand Down Expand Up @@ -79,6 +80,8 @@
error_body,
intake_extension,
uploaded_file_part,
uploaded_object,
uploaded_object_part,
uploaded_object_path,
)

Expand Down Expand Up @@ -121,7 +124,10 @@
"INTAKE_EXTENSION_URI",
"DATA_OBJECTS_EXTENSION_URI",
"SKILL_EXTENSION_URI",
"TRANSFERS_EXTENSION_URI",
"uploaded_file_part",
"uploaded_object",
"uploaded_object_part",
"uploaded_object_path",
"MCP_PATH",
"MCP_TRANSPORT",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
from .tool_access import DISABLE as TOOL_DISABLE, ENABLE as TOOL_ENABLE, PATHS as TOOL_ACCESS_PATHS, ToolAccess, caller_role
from .tool_access import extensions as tool_access_extensions
from .tool_context import EMPTY, ToolContext, bound, context_from_http, context_from_mcp
from .transfers import WriteNamespaceGrant
from .types import DATA_OBJECTS_EXTENSION_URI, MCP_TRANSPORT, SKILL_EXTENSION_URI, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH, AddDataRequest, AddDataResponse, EnvironmentCapabilities, EnvironmentCard, EnvironmentExtension, EnvironmentInterface, EnvironmentTool, GetDataResponse, ResetDataResponse, error_body
from .transfers import WriteNamespaceGrant, WriteObject
from .types import DATA_OBJECTS_EXTENSION_URI, MCP_TRANSPORT, SKILL_EXTENSION_URI, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, TRANSFERS_EXTENSION_URI, WELL_KNOWN_PATH, AddDataRequest, AddDataResponse, EnvironmentCapabilities, EnvironmentCard, EnvironmentExtension, EnvironmentInterface, EnvironmentTool, GetDataResponse, ResetDataResponse, error_body

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -70,7 +70,9 @@ def add_data(fn: Callable) -> Callable:
def get_data(fn: Callable) -> Callable:
"""Mark the ``data/get`` handler. One that takes a ``write_namespace`` parameter is advertised under
``DATA_OBJECTS_EXTENSION_URI`` and called with the grant a caller sends, else None; it may upload its
export under the grant and return ``uploaded_file_part``."""
export under the grant and return ``uploaded_file_part``. One that takes ``write_object`` is advertised
under ``TRANSFERS_EXTENSION_URI``, and may upload its export to that object and return
``uploaded_object_part``."""
setattr(fn, _OP_ATTR, OP_GET_DATA)
return fn

Expand Down Expand Up @@ -1127,12 +1129,31 @@ def _takes_write_namespace(fn: Callable) -> bool:
return "write_namespace" in inspect.signature(fn).parameters


def _takes_write_object(fn: Callable) -> bool:
return "write_object" in inspect.signature(fn).parameters


def _merge_data_objects(card: EnvironmentCard, methods: dict) -> EnvironmentCard:
"""Advertise the data-objects extension when the ``data/get`` handler takes a ``write_namespace``."""
"""Advertise the data-objects extension when the ``data/get`` handler takes a ``write_namespace``, and the
transfers extension with the write grant kinds it uploads through when it takes a ``write_object``. A card
without the transfers extension reads as taking the namespace grant alone, so a handler that takes only that
keeps the card it had."""
entry = methods.get(METHOD_GET)
if entry is None or not _takes_write_namespace(entry[1]):
if entry is None:
return card
return _merge_extensions(card, [(_DATA_OBJECTS_EXTENSION,)])
declared = []
kinds = []
if _takes_write_namespace(entry[1]):
declared.append((_DATA_OBJECTS_EXTENSION,))
kinds.append("http-post-policy")
if _takes_write_object(entry[1]):
kinds.extend(["http-put", "http-put-parts"])
declared.append((EnvironmentExtension(
uri=TRANSFERS_EXTENSION_URI,
description="The write grant kinds this server uploads through.",
params={"write": kinds},
),))
return _merge_extensions(card, declared)


def _merge_mcp_interface(card: EnvironmentCard, path: str) -> EnvironmentCard:
Expand Down Expand Up @@ -1199,6 +1220,15 @@ async def _invoke_get(fn: Callable, params: dict) -> dict:
# Name the fields only: the values carry the grant's signature.
fields = ", ".join(".".join(map(str, error["loc"])) or "write_namespace" for error in e.errors())
raise _InvalidParams(f"write_namespace is not a valid namespace grant ({fields})")
if _takes_write_object(fn):
if not isinstance(params, dict):
raise _InvalidParams("data/get takes its params by name")
raw = params.get("write_object")
try:
kwargs["write_object"] = None if raw is None else WriteObject.model_validate(raw)
except ValidationError as e:
fields = ", ".join(".".join(map(str, error["loc"])) or "write_object" for error in e.errors())
raise _InvalidParams(f"write_object is not a valid object grant ({fields})")
try:
parts = await _maybe_await(fn(**kwargs))
return GetDataResponse(parts=parts).model_dump(exclude_none=True)
Expand Down
34 changes: 28 additions & 6 deletions packages/agentenv-protocol/src/agentenv_protocol/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import httpx

from .manifest import INTERFACE_MANIFEST_PATH
from .transfers import WriteNamespaceGrant
from .types import INTAKE_EXTENSION_URI, MCP_PATH, MCP_TRANSPORT, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH, AddDataResponse, GetDataResponse, Part, ResetDataResponse
from .transfers import WriteNamespaceGrant, WriteObject
from .types import DATA_OBJECTS_EXTENSION_URI, INTAKE_EXTENSION_URI, MCP_PATH, MCP_TRANSPORT, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, TRANSFERS_EXTENSION_URI, WELL_KNOWN_PATH, AddDataResponse, GetDataResponse, Part, ResetDataResponse

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -57,18 +57,40 @@ async def add_data(base_url: str, parts: list[Part], timeout: int = 120, verify:


async def get_data(
base_url: str, timeout: int = 30, verify: bool = True, *, write_namespace: WriteNamespaceGrant | None = None,
base_url: str,
timeout: int = 30,
verify: bool = True,
*,
write_namespace: WriteNamespaceGrant | None = None,
write_object: WriteObject | None = None,
paths: list[str] | None = None,
) -> GetDataResponse:
"""`data/get`. A server advertising `DATA_OBJECTS_EXTENSION_URI` may be handed `write_namespace` to upload
its export under; `uploaded_object_path` reads from the answer where it did. `paths` names what a computer
returns, relative to its user's home; an env that returns all of its data refuses them."""
params = {} if write_namespace is None else {"write_namespace": write_namespace.model_dump(mode="json", exclude_none=True)}
its export under, and one whose `TRANSFERS_EXTENSION_URI` lists the grant's kind `write_object` to upload
it to; `uploaded_object_path` and `uploaded_object` read from the answer what it uploaded. `paths` names what
a computer returns, relative to its user's home; an env that returns all of its data refuses them."""
params = {}
if write_namespace is not None:
params["write_namespace"] = write_namespace.model_dump(mode="json", exclude_none=True)
if write_object is not None:
params["write_object"] = write_object.model_dump(mode="json", exclude_none=True)
if paths is not None:
params["paths"] = paths
return GetDataResponse.model_validate(await _rpc(base_url, METHOD_GET, params, timeout, verify))


def write_kinds(card: dict) -> frozenset[str]:
"""The write grant kinds a server's `data/get` takes: the names its `TRANSFERS_EXTENSION_URI` lists, else
`http-post-policy` alone for a server advertising `DATA_OBJECTS_EXTENSION_URI`, else none."""
if find_extension(card, TRANSFERS_EXTENSION_URI) is not None:
params = extension_params(card, TRANSFERS_EXTENSION_URI)
kinds = params.get("write") if isinstance(params, dict) else None
return frozenset(kind for kind in kinds if isinstance(kind, str)) if isinstance(kinds, list) else frozenset()
if find_extension(card, DATA_OBJECTS_EXTENSION_URI) is not None:
return frozenset({"http-post-policy"})
return frozenset()


async def get_card(base_url: str, timeout: int = 10, verify: bool = True) -> dict:
async with httpx.AsyncClient(verify=verify) as client:
response = await client.get(f"{base_url}{WELL_KNOWN_PATH}", timeout=timeout)
Expand Down
27 changes: 26 additions & 1 deletion packages/agentenv-protocol/src/agentenv_protocol/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from pydantic import BaseModel, Field, TypeAdapter, ValidationError

from .transfers import RelativePath
from .transfers import RelativePath, Uploaded


def error_body(code: str, message: str, **fields: Any) -> dict:
Expand Down Expand Up @@ -42,6 +42,13 @@ def error_body(code: str, message: str, **fields: Any) -> dict:
# skill-config/v1 takes one: a SKILL.md an agent reads before it uses the env, written under the
# name the caller gives. A server serves it through an `@agent_skill` handler.
SKILL_EXTENSION_URI = "urn:agentenv:skill/v1"

# A declaration-only extension: the write grant kinds this server's library uploads through, as
# `params = {"write": [kind, ...]}`. A `data/get` handler that takes a `write_object` param (a
# `transfers.WriteObject`) lists `http-put` and `http-put-parts`, after `http-post-policy` when it also takes
# `write_namespace`. A card without it takes `http-post-policy` alone when it advertises
# `DATA_OBJECTS_EXTENSION_URI`, and no write grant otherwise.
TRANSFERS_EXTENSION_URI = "urn:agentenv:transfers/v1"
_RELATIVE_PATH = TypeAdapter(RelativePath)


Expand Down Expand Up @@ -88,6 +95,24 @@ def uploaded_file_part(path: str, *, name: Optional[str] = None, mime_type: Opti
)


def uploaded_object_part(uploaded: Uploaded, *, name: str, mime_type: Optional[str] = None) -> FilePart:
"""The `data/get` answer saying the export was uploaded through its `write_object` grant: `uploaded` as
the upload reported it, and `name` (a relative file name) and `mime_type` for the record of it."""
name = _RELATIVE_PATH.validate_python(name)
return FilePart(
file=FileWithUri(uri=name, name=name, mimeType=mime_type),
metadata={TRANSFERS_EXTENSION_URI: {"uploaded": uploaded.model_dump(mode="json", exclude_none=True)}},
)


def uploaded_object(part: Part) -> Optional[Uploaded]:
"""What `part` says was uploaded through the `write_object` grant; None when it names no upload."""
marker = (part.metadata or {}).get(TRANSFERS_EXTENSION_URI) if isinstance(part, FilePart) else None
if not isinstance(marker, dict) or "uploaded" not in marker:
return None
return Uploaded.model_validate(marker["uploaded"])


def uploaded_object_path(part: Part) -> Optional[str]:
"""Where, relative to the `write_namespace` grant's `root_path`, `part` says the export was
uploaded; None when it names no upload. Raises ValueError for a path outside the root."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,14 @@ def test_transfer_models_are_closed_and_validate_contract_constraints() -> None:
)


def test_the_object_write_wire_shape_is_frozen() -> None:
"""Receivers refuse fields they do not know, so a field added to these breaks every receiver on an earlier
release: a new capability is a new grant kind."""
assert set(HttpPartsPutGrant.model_fields) == {"kind", "part_bytes", "urls", "expires_at", "headers"}
assert set(HttpPutGrant.model_fields) == {"kind", "url", "expires_at", "headers"}
assert set(WriteObject.model_fields) == {"media_type", "max_bytes", "write"}
assert set(Uploaded.model_fields) == {"size_bytes", "sha256"}

def test_transfer_errors_derive_status_and_retryability_from_their_code() -> None:
unavailable = TransferError("transfer_unavailable", "Try again.")
assert (unavailable.status_code, unavailable.retryable) == (502, True)
Expand Down
Loading
Loading