agentenv-framework-protocol 0.1.286__tar.gz → 0.1.288__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/PKG-INFO +30 -1
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/README.md +29 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/pyproject.toml +1 -1
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/__init__.py +6 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/agent_env_environment.py +37 -2
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/client.py +8 -2
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/types.py +33 -1
- agentenv_framework_protocol-0.1.288/tests/test_data_objects.py +249 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/.gitignore +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/LICENSE +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/NOTICE +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/THIRD_PARTY_NOTICES.md +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/examples/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/examples/advanced_agent.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/examples/basic_agent.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/examples/custom_extensions_agent.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/examples/multimodal_agent.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/examples/reference_model.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/examples/streaming_agent.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/manifest.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/preflight.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/src/agentenv_protocol/transfers.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/tests/test_a2a_agent.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/tests/test_a2a_agent_examples.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/tests/test_a2a_object_transfers.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/tests/test_a2a_staging.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/tests/test_agent_env_environment.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/tests/test_client.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/tests/test_intake.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/tests/test_manifest.py +0 -0
- {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/tests/test_preflight.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentenv-framework-protocol
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.288
|
|
4
4
|
Summary: Open data-plane protocol and server SDK for agent environments.
|
|
5
5
|
Project-URL: Homepage, https://www.agentenvframework.com
|
|
6
6
|
Project-URL: Documentation, https://www.agentenvframework.com/docs
|
|
@@ -177,6 +177,35 @@ extension's, and `client.invoke_extension(base_url, card, uri, params, method=..
|
|
|
177
177
|
its HTTP verb. Without `method`, `invoke_extension` calls the first method listed; an extension
|
|
178
178
|
with several methods, such as a gateway's `urn:agentenv:clock/v1`, should always be called by name.
|
|
179
179
|
|
|
180
|
+
### Uploading an export through a grant
|
|
181
|
+
|
|
182
|
+
An export too large to return from `data/get` can go straight to the caller's object store. A
|
|
183
|
+
`@get_data` handler that takes a `write_namespace` parameter is advertised under
|
|
184
|
+
`urn:agentenv:data-objects/v1` (`DATA_OBJECTS_EXTENSION_URI`), and a caller may then send a
|
|
185
|
+
`transfers.WriteNamespaceGrant` with the call: `client.get_data(base_url,
|
|
186
|
+
write_namespace=grant)`. The handler is called with the grant, or with None when the caller
|
|
187
|
+
sent none. It uploads under the grant with `transfers.NamespaceUploader` and answers with
|
|
188
|
+
`uploaded_file_part(path, name=..., mime_type=...)`, where `path` is relative to the grant's
|
|
189
|
+
`root_path`; the caller reads it back with `uploaded_object_path(part)`, which refuses a path
|
|
190
|
+
outside the root. A handler that cannot use the grant (its export outgrows the grant's limits,
|
|
191
|
+
say) answers as it would without one.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from agentenv_protocol import AgentEnvEnvironment, DataPart, environment_card, get_data, uploaded_file_part
|
|
195
|
+
from agentenv_protocol.transfers import NamespaceUploader, WriteNamespaceGrant
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@environment_card(name="slack")
|
|
199
|
+
class SlackEnv(AgentEnvEnvironment):
|
|
200
|
+
@get_data
|
|
201
|
+
async def _state(self, write_namespace: WriteNamespaceGrant | None = None):
|
|
202
|
+
bundle = self.write_bundle() # a Path
|
|
203
|
+
if write_namespace is not None and bundle.stat().st_size <= write_namespace.max_object_bytes:
|
|
204
|
+
await NamespaceUploader(write_namespace).upload("slack.zip", bundle)
|
|
205
|
+
return [uploaded_file_part("slack.zip", name="slack.zip", mime_type="application/zip")]
|
|
206
|
+
return [DataPart(data=self.state())]
|
|
207
|
+
```
|
|
208
|
+
|
|
180
209
|
Dependencies are intentionally light (`pydantic`, `starlette`) 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.
|
|
181
210
|
|
|
182
211
|
## A2A agent framework
|
|
@@ -133,6 +133,35 @@ extension's, and `client.invoke_extension(base_url, card, uri, params, method=..
|
|
|
133
133
|
its HTTP verb. Without `method`, `invoke_extension` calls the first method listed; an extension
|
|
134
134
|
with several methods, such as a gateway's `urn:agentenv:clock/v1`, should always be called by name.
|
|
135
135
|
|
|
136
|
+
### Uploading an export through a grant
|
|
137
|
+
|
|
138
|
+
An export too large to return from `data/get` can go straight to the caller's object store. A
|
|
139
|
+
`@get_data` handler that takes a `write_namespace` parameter is advertised under
|
|
140
|
+
`urn:agentenv:data-objects/v1` (`DATA_OBJECTS_EXTENSION_URI`), and a caller may then send a
|
|
141
|
+
`transfers.WriteNamespaceGrant` with the call: `client.get_data(base_url,
|
|
142
|
+
write_namespace=grant)`. The handler is called with the grant, or with None when the caller
|
|
143
|
+
sent none. It uploads under the grant with `transfers.NamespaceUploader` and answers with
|
|
144
|
+
`uploaded_file_part(path, name=..., mime_type=...)`, where `path` is relative to the grant's
|
|
145
|
+
`root_path`; the caller reads it back with `uploaded_object_path(part)`, which refuses a path
|
|
146
|
+
outside the root. A handler that cannot use the grant (its export outgrows the grant's limits,
|
|
147
|
+
say) answers as it would without one.
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from agentenv_protocol import AgentEnvEnvironment, DataPart, environment_card, get_data, uploaded_file_part
|
|
151
|
+
from agentenv_protocol.transfers import NamespaceUploader, WriteNamespaceGrant
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@environment_card(name="slack")
|
|
155
|
+
class SlackEnv(AgentEnvEnvironment):
|
|
156
|
+
@get_data
|
|
157
|
+
async def _state(self, write_namespace: WriteNamespaceGrant | None = None):
|
|
158
|
+
bundle = self.write_bundle() # a Path
|
|
159
|
+
if write_namespace is not None and bundle.stat().st_size <= write_namespace.max_object_bytes:
|
|
160
|
+
await NamespaceUploader(write_namespace).upload("slack.zip", bundle)
|
|
161
|
+
return [uploaded_file_part("slack.zip", name="slack.zip", mime_type="application/zip")]
|
|
162
|
+
return [DataPart(data=self.state())]
|
|
163
|
+
```
|
|
164
|
+
|
|
136
165
|
Dependencies are intentionally light (`pydantic`, `starlette`) 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.
|
|
137
166
|
|
|
138
167
|
## A2A agent framework
|
|
@@ -34,6 +34,7 @@ from .manifest import (
|
|
|
34
34
|
manifest_compatible,
|
|
35
35
|
)
|
|
36
36
|
from .types import (
|
|
37
|
+
DATA_OBJECTS_EXTENSION_URI,
|
|
37
38
|
INTAKE_EXTENSION_URI,
|
|
38
39
|
MCP_PATH,
|
|
39
40
|
MCP_TRANSPORT,
|
|
@@ -60,6 +61,8 @@ from .types import (
|
|
|
60
61
|
TextPart,
|
|
61
62
|
error_body,
|
|
62
63
|
intake_extension,
|
|
64
|
+
uploaded_file_part,
|
|
65
|
+
uploaded_object_path,
|
|
63
66
|
)
|
|
64
67
|
|
|
65
68
|
__all__ = [
|
|
@@ -88,6 +91,9 @@ __all__ = [
|
|
|
88
91
|
"intake_extension",
|
|
89
92
|
"intake_fit_check",
|
|
90
93
|
"INTAKE_EXTENSION_URI",
|
|
94
|
+
"DATA_OBJECTS_EXTENSION_URI",
|
|
95
|
+
"uploaded_file_part",
|
|
96
|
+
"uploaded_object_path",
|
|
91
97
|
"MCP_PATH",
|
|
92
98
|
"MCP_TRANSPORT",
|
|
93
99
|
"MANIFEST_VERSION",
|
|
@@ -13,12 +13,14 @@ import uuid
|
|
|
13
13
|
from abc import ABC, abstractmethod
|
|
14
14
|
from typing import Annotated, Any, Callable, ClassVar, Literal, NamedTuple, Union, get_args, get_origin, get_type_hints
|
|
15
15
|
|
|
16
|
+
from pydantic import ValidationError
|
|
16
17
|
from pydantic.fields import FieldInfo
|
|
17
18
|
from starlette.requests import Request
|
|
18
19
|
from starlette.responses import Response
|
|
19
20
|
from starlette.routing import Route
|
|
20
21
|
|
|
21
|
-
from .
|
|
22
|
+
from .transfers import WriteNamespaceGrant
|
|
23
|
+
from .types import DATA_OBJECTS_EXTENSION_URI, MCP_TRANSPORT, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH, AddDataRequest, AddDataResponse, EnvironmentCapabilities, EnvironmentCard, EnvironmentExtension, EnvironmentInterface, EnvironmentTool, GetDataResponse, ResetDataResponse, error_body
|
|
22
24
|
|
|
23
25
|
logger = logging.getLogger(__name__)
|
|
24
26
|
|
|
@@ -50,6 +52,9 @@ def add_data(fn: Callable) -> Callable:
|
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
def get_data(fn: Callable) -> Callable:
|
|
55
|
+
"""Mark the ``data/get`` handler. One that takes a ``write_namespace`` parameter is advertised under
|
|
56
|
+
``DATA_OBJECTS_EXTENSION_URI`` and called with the grant a caller sends, else None; it may upload its
|
|
57
|
+
export under the grant and return ``uploaded_file_part``."""
|
|
53
58
|
setattr(fn, _OP_ATTR, OP_GET_DATA)
|
|
54
59
|
return fn
|
|
55
60
|
|
|
@@ -157,6 +162,7 @@ class AgentEnvApplication(ABC):
|
|
|
157
162
|
environment_card = _merge_tools(environment_card, tool_handlers)
|
|
158
163
|
methods = _discover_methods(handler)
|
|
159
164
|
environment_card = _merge_operations(environment_card, methods)
|
|
165
|
+
environment_card = _merge_data_objects(environment_card, methods)
|
|
160
166
|
self.environment_card = environment_card
|
|
161
167
|
self._dispatch = _jsonrpc_handler(methods)
|
|
162
168
|
self._ext_routes = [(path, method, _extension_handler(fn, method)) for _descriptor, fn, path, method in ext_handlers]
|
|
@@ -426,6 +432,24 @@ def _merge_operations(card: EnvironmentCard, methods: dict) -> EnvironmentCard:
|
|
|
426
432
|
return card.model_copy(update={"capabilities": caps.model_copy(update={"operations": list(methods)})})
|
|
427
433
|
|
|
428
434
|
|
|
435
|
+
_DATA_OBJECTS_EXTENSION = EnvironmentExtension(
|
|
436
|
+
uri=DATA_OBJECTS_EXTENSION_URI,
|
|
437
|
+
description="data/get takes a write_namespace grant and may upload its export under it.",
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _takes_write_namespace(fn: Callable) -> bool:
|
|
442
|
+
return "write_namespace" in inspect.signature(fn).parameters
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _merge_data_objects(card: EnvironmentCard, methods: dict) -> EnvironmentCard:
|
|
446
|
+
"""Advertise the data-objects extension when the ``data/get`` handler takes a ``write_namespace``."""
|
|
447
|
+
entry = methods.get(METHOD_GET)
|
|
448
|
+
if entry is None or not _takes_write_namespace(entry[1]):
|
|
449
|
+
return card
|
|
450
|
+
return _merge_extensions(card, [(_DATA_OBJECTS_EXTENSION,)])
|
|
451
|
+
|
|
452
|
+
|
|
429
453
|
def _merge_mcp_interface(card: EnvironmentCard, path: str) -> EnvironmentCard:
|
|
430
454
|
"""Declare the MCP endpoint at the path the app serves it on; a card-declared `MCP_TRANSPORT` interface wins.
|
|
431
455
|
|
|
@@ -471,8 +495,19 @@ async def _invoke_add(fn: Callable, params: dict) -> dict:
|
|
|
471
495
|
|
|
472
496
|
|
|
473
497
|
async def _invoke_get(fn: Callable, params: dict) -> dict:
|
|
498
|
+
kwargs = {}
|
|
499
|
+
if _takes_write_namespace(fn):
|
|
500
|
+
if not isinstance(params, dict):
|
|
501
|
+
raise _InvalidParams("data/get takes its params by name")
|
|
502
|
+
raw = params.get("write_namespace")
|
|
503
|
+
try:
|
|
504
|
+
kwargs["write_namespace"] = None if raw is None else WriteNamespaceGrant.model_validate(raw)
|
|
505
|
+
except ValidationError as e:
|
|
506
|
+
# Name the fields only: the values carry the grant's signature.
|
|
507
|
+
fields = ", ".join(".".join(map(str, error["loc"])) or "write_namespace" for error in e.errors())
|
|
508
|
+
raise _InvalidParams(f"write_namespace is not a valid namespace grant ({fields})")
|
|
474
509
|
try:
|
|
475
|
-
parts = await _maybe_await(fn())
|
|
510
|
+
parts = await _maybe_await(fn(**kwargs))
|
|
476
511
|
return GetDataResponse(parts=parts).model_dump(exclude_none=True)
|
|
477
512
|
except Exception as e:
|
|
478
513
|
logger.exception("data/get failed")
|
|
@@ -6,6 +6,7 @@ import logging
|
|
|
6
6
|
import httpx
|
|
7
7
|
|
|
8
8
|
from .manifest import INTERFACE_MANIFEST_PATH
|
|
9
|
+
from .transfers import WriteNamespaceGrant
|
|
9
10
|
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
|
|
10
11
|
|
|
11
12
|
logger = logging.getLogger(__name__)
|
|
@@ -32,8 +33,13 @@ async def add_data(base_url: str, parts: list[Part], timeout: int = 120, verify:
|
|
|
32
33
|
return AddDataResponse.model_validate(await _rpc(base_url, METHOD_ADD, params, timeout, verify))
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
async def get_data(
|
|
36
|
-
|
|
36
|
+
async def get_data(
|
|
37
|
+
base_url: str, timeout: int = 30, verify: bool = True, *, write_namespace: WriteNamespaceGrant | None = None
|
|
38
|
+
) -> GetDataResponse:
|
|
39
|
+
"""`data/get`. A server advertising `DATA_OBJECTS_EXTENSION_URI` may be handed `write_namespace` to upload
|
|
40
|
+
its export under; `uploaded_object_path` reads from the answer where it did."""
|
|
41
|
+
params = {} if write_namespace is None else {"write_namespace": write_namespace.model_dump(mode="json", exclude_none=True)}
|
|
42
|
+
return GetDataResponse.model_validate(await _rpc(base_url, METHOD_GET, params, timeout, verify))
|
|
37
43
|
|
|
38
44
|
|
|
39
45
|
async def get_card(base_url: str, timeout: int = 10, verify: bool = True) -> dict:
|
|
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
from typing import Annotated, Literal, Optional, Union
|
|
5
5
|
|
|
6
|
-
from pydantic import BaseModel, Field
|
|
6
|
+
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
|
|
7
|
+
|
|
8
|
+
from .transfers import RelativePath
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
def error_body(code: str, message: str) -> dict:
|
|
@@ -30,6 +32,13 @@ METHOD_GET = "data/get"
|
|
|
30
32
|
# extension means "no claim" (skip fit-checks), matching the `supports_v1` 404->legacy precedent.
|
|
31
33
|
INTAKE_EXTENSION_URI = "urn:agentenv:intake/v1"
|
|
32
34
|
|
|
35
|
+
# A declaration-only extension: `data/get` takes a `write_namespace` param (a
|
|
36
|
+
# `transfers.WriteNamespaceGrant`) and may upload its export under the grant instead of returning
|
|
37
|
+
# it, answering with the part `uploaded_file_part` builds. A server that does not advertise it
|
|
38
|
+
# is never sent one.
|
|
39
|
+
DATA_OBJECTS_EXTENSION_URI = "urn:agentenv:data-objects/v1"
|
|
40
|
+
_RELATIVE_PATH = TypeAdapter(RelativePath)
|
|
41
|
+
|
|
33
42
|
|
|
34
43
|
class FileWithBytes(BaseModel):
|
|
35
44
|
bytes: str
|
|
@@ -64,6 +73,29 @@ class DataPart(BaseModel):
|
|
|
64
73
|
Part = Annotated[Union[TextPart, FilePart, DataPart], Field(discriminator="kind")]
|
|
65
74
|
|
|
66
75
|
|
|
76
|
+
def uploaded_file_part(path: str, *, name: Optional[str] = None, mime_type: Optional[str] = None) -> FilePart:
|
|
77
|
+
"""The `data/get` answer naming an export uploaded through its `write_namespace` grant, at
|
|
78
|
+
`path` relative to the grant's `root_path`."""
|
|
79
|
+
path = _RELATIVE_PATH.validate_python(path)
|
|
80
|
+
return FilePart(
|
|
81
|
+
file=FileWithUri(uri=path, name=name, mimeType=mime_type),
|
|
82
|
+
metadata={DATA_OBJECTS_EXTENSION_URI: {"path": path}},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def uploaded_object_path(part: Part) -> Optional[str]:
|
|
87
|
+
"""Where, relative to the `write_namespace` grant's `root_path`, `part` says the export was
|
|
88
|
+
uploaded; None when it names no upload. Raises ValueError for a path outside the root."""
|
|
89
|
+
marker = (part.metadata or {}).get(DATA_OBJECTS_EXTENSION_URI) if isinstance(part, FilePart) else None
|
|
90
|
+
if marker is None:
|
|
91
|
+
return None
|
|
92
|
+
path = marker.get("path") if isinstance(marker, dict) else None
|
|
93
|
+
try:
|
|
94
|
+
return _RELATIVE_PATH.validate_python(path)
|
|
95
|
+
except ValidationError:
|
|
96
|
+
raise ValueError(f"the uploaded export's path {path!r} is not a normalized relative path") from None
|
|
97
|
+
|
|
98
|
+
|
|
67
99
|
class AddDataRequest(BaseModel):
|
|
68
100
|
parts: list[Part] = Field(min_length=1)
|
|
69
101
|
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""data/get through a write_namespace grant: the card advertisement, the handler binding, the client
|
|
2
|
+
call, and the answer that names where the export was uploaded."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
from starlette.applications import Starlette
|
|
13
|
+
from starlette.routing import Route
|
|
14
|
+
|
|
15
|
+
from agentenv_protocol import (
|
|
16
|
+
DATA_OBJECTS_EXTENSION_URI,
|
|
17
|
+
AgentEnvFastMCPApplication,
|
|
18
|
+
DataPart,
|
|
19
|
+
EnvironmentCapabilities,
|
|
20
|
+
EnvironmentCard,
|
|
21
|
+
EnvironmentExtension,
|
|
22
|
+
FilePart,
|
|
23
|
+
GetDataResponse,
|
|
24
|
+
get_data,
|
|
25
|
+
uploaded_file_part,
|
|
26
|
+
uploaded_object_path,
|
|
27
|
+
)
|
|
28
|
+
from agentenv_protocol import client as protocol_v1
|
|
29
|
+
from agentenv_protocol.transfers import HttpPostPolicyGrant, NamespaceUploader, WriteNamespaceGrant
|
|
30
|
+
|
|
31
|
+
_SIGNATURE = "policy-signature-secret"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _grant(*, max_object_bytes: int = 1024) -> WriteNamespaceGrant:
|
|
35
|
+
return WriteNamespaceGrant(
|
|
36
|
+
root_path="snapshots/run-1",
|
|
37
|
+
expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
|
|
38
|
+
max_objects=1,
|
|
39
|
+
max_object_bytes=max_object_bytes,
|
|
40
|
+
max_total_bytes=max_object_bytes,
|
|
41
|
+
write=HttpPostPolicyGrant(
|
|
42
|
+
kind="http-post-policy",
|
|
43
|
+
url="https://objects.example.test/upload",
|
|
44
|
+
fields={"policy": _SIGNATURE},
|
|
45
|
+
path_field="key",
|
|
46
|
+
file_field="file",
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _FakeMCP:
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
self.routes: list[Route] = []
|
|
54
|
+
self.settings = SimpleNamespace(streamable_http_path="/mcp")
|
|
55
|
+
|
|
56
|
+
def custom_route(self, path: str, methods: list[str]):
|
|
57
|
+
def deco(fn):
|
|
58
|
+
self.routes.append(Route(path, fn, methods=methods))
|
|
59
|
+
return fn
|
|
60
|
+
return deco
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _InlineHandler:
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
self.calls = 0
|
|
66
|
+
|
|
67
|
+
@get_data
|
|
68
|
+
async def _state(self) -> list:
|
|
69
|
+
self.calls += 1
|
|
70
|
+
return [DataPart(data={"rows": 1})]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _UploadingHandler:
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
self.grants: list[WriteNamespaceGrant | None] = []
|
|
76
|
+
|
|
77
|
+
@get_data
|
|
78
|
+
async def _state(self, write_namespace: WriteNamespaceGrant | None = None) -> list:
|
|
79
|
+
self.grants.append(write_namespace)
|
|
80
|
+
if write_namespace is None:
|
|
81
|
+
return [DataPart(data={"rows": 1})]
|
|
82
|
+
await NamespaceUploader(write_namespace).upload("slack.zip", b"PK\x03\x04bundle")
|
|
83
|
+
return [uploaded_file_part("slack.zip", name="slack.zip", mime_type="application/zip")]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _app(handler, card: EnvironmentCard | None = None) -> tuple[AgentEnvFastMCPApplication, Starlette]:
|
|
87
|
+
mcp = _FakeMCP()
|
|
88
|
+
application = AgentEnvFastMCPApplication(environment_card=card or EnvironmentCard(name="slack"), handler=handler)
|
|
89
|
+
application.add_routes_to_app(mcp)
|
|
90
|
+
return application, Starlette(routes=mcp.routes)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _rpc(params: dict | None = None) -> dict:
|
|
94
|
+
return {"jsonrpc": "2.0", "id": 1, "method": "data/get", "params": params or {}}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _post(app: Starlette, body: dict) -> dict:
|
|
98
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
|
|
99
|
+
return (await client.post("/agentenv", json=body)).json()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _uris(card: EnvironmentCard) -> list[str]:
|
|
103
|
+
return [e.uri for e in card.capabilities.extensions or []]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_a_handler_that_takes_a_write_namespace_is_advertised():
|
|
107
|
+
application, _ = _app(_UploadingHandler())
|
|
108
|
+
(extension,) = application.environment_card.capabilities.extensions
|
|
109
|
+
assert extension.uri == DATA_OBJECTS_EXTENSION_URI
|
|
110
|
+
assert extension.params is None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_a_handler_that_takes_none_is_not():
|
|
114
|
+
application, _ = _app(_InlineHandler())
|
|
115
|
+
assert _uris(application.environment_card) == []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_a_card_declared_data_objects_extension_wins():
|
|
119
|
+
declared = EnvironmentExtension(uri=DATA_OBJECTS_EXTENSION_URI, description="declared")
|
|
120
|
+
card = EnvironmentCard(name="slack", capabilities=EnvironmentCapabilities(extensions=[declared]))
|
|
121
|
+
application, _ = _app(_UploadingHandler(), card)
|
|
122
|
+
assert application.environment_card.capabilities.extensions == [declared]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.asyncio
|
|
126
|
+
async def test_the_handler_gets_the_grant_sent_or_none():
|
|
127
|
+
handler = _UploadingHandler()
|
|
128
|
+
_, app = _app(handler)
|
|
129
|
+
grant = _grant()
|
|
130
|
+
|
|
131
|
+
await _post(app, _rpc())
|
|
132
|
+
await _post(app, _rpc({"write_namespace": grant.model_dump(mode="json")}))
|
|
133
|
+
|
|
134
|
+
assert handler.grants == [None, grant]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pytest.mark.asyncio
|
|
138
|
+
async def test_a_handler_that_takes_no_grant_ignores_one_sent():
|
|
139
|
+
handler = _InlineHandler()
|
|
140
|
+
_, app = _app(handler)
|
|
141
|
+
|
|
142
|
+
body = await _post(app, _rpc({"write_namespace": _grant().model_dump(mode="json")}))
|
|
143
|
+
|
|
144
|
+
assert body["result"]["parts"] == [{"kind": "data", "data": {"rows": 1}}]
|
|
145
|
+
assert handler.calls == 1
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.mark.asyncio
|
|
149
|
+
async def test_an_invalid_grant_is_refused_without_echoing_it():
|
|
150
|
+
handler = _UploadingHandler()
|
|
151
|
+
_, app = _app(handler)
|
|
152
|
+
sent = _grant().model_dump(mode="json")
|
|
153
|
+
sent["root_path"] = "../elsewhere"
|
|
154
|
+
|
|
155
|
+
body = await _post(app, _rpc({"write_namespace": sent}))
|
|
156
|
+
|
|
157
|
+
assert body["error"]["code"] == -32602
|
|
158
|
+
assert "root_path" in body["error"]["data"]["detail"]
|
|
159
|
+
assert _SIGNATURE not in json.dumps(body)
|
|
160
|
+
assert handler.grants == []
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@pytest.mark.asyncio
|
|
164
|
+
async def test_params_given_by_position_are_refused():
|
|
165
|
+
handler = _UploadingHandler()
|
|
166
|
+
_, app = _app(handler)
|
|
167
|
+
|
|
168
|
+
body = await _post(app, {"jsonrpc": "2.0", "id": 1, "method": "data/get", "params": [_grant().model_dump(mode="json")]})
|
|
169
|
+
|
|
170
|
+
assert body["error"]["code"] == -32602
|
|
171
|
+
assert _SIGNATURE not in json.dumps(body)
|
|
172
|
+
assert handler.grants == []
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@pytest.mark.asyncio
|
|
176
|
+
async def test_get_data_sends_no_params_without_a_grant_and_the_grant_with_one(monkeypatch):
|
|
177
|
+
sent: list[dict] = []
|
|
178
|
+
real = httpx.AsyncClient
|
|
179
|
+
|
|
180
|
+
def handle(request: httpx.Request) -> httpx.Response:
|
|
181
|
+
sent.append(json.loads(request.content))
|
|
182
|
+
return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": {"parts": []}})
|
|
183
|
+
|
|
184
|
+
monkeypatch.setattr(protocol_v1.httpx, "AsyncClient", lambda **kw: real(transport=httpx.MockTransport(handle)))
|
|
185
|
+
|
|
186
|
+
grant = _grant()
|
|
187
|
+
await protocol_v1.get_data("http://env")
|
|
188
|
+
await protocol_v1.get_data("http://env", write_namespace=grant)
|
|
189
|
+
|
|
190
|
+
assert sent[0]["params"] == {}
|
|
191
|
+
assert WriteNamespaceGrant.model_validate(sent[1]["params"]["write_namespace"]) == grant
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@pytest.mark.asyncio
|
|
195
|
+
async def test_an_export_uploads_through_the_grant_and_the_answer_names_where(monkeypatch):
|
|
196
|
+
"""The real handler, uploader, client and reader together: the bundle is POSTed under the grant's root, and
|
|
197
|
+
the answer names its path relative to it."""
|
|
198
|
+
handler = _UploadingHandler()
|
|
199
|
+
_, app = _app(handler)
|
|
200
|
+
uploads: list[httpx.Request] = []
|
|
201
|
+
|
|
202
|
+
def store(request: httpx.Request) -> httpx.Response:
|
|
203
|
+
uploads.append(request)
|
|
204
|
+
return httpx.Response(204)
|
|
205
|
+
|
|
206
|
+
real_sync, real_async = httpx.Client, httpx.AsyncClient
|
|
207
|
+
monkeypatch.setattr(httpx, "Client", lambda **kw: real_sync(**{**kw, "transport": httpx.MockTransport(store)}))
|
|
208
|
+
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: real_async(transport=httpx.ASGITransport(app=app)))
|
|
209
|
+
|
|
210
|
+
response = await protocol_v1.get_data("http://t", write_namespace=_grant())
|
|
211
|
+
|
|
212
|
+
(part,) = response.parts
|
|
213
|
+
assert uploaded_object_path(part) == "slack.zip"
|
|
214
|
+
assert (part.file.name, part.file.mimeType) == ("slack.zip", "application/zip")
|
|
215
|
+
(upload,) = uploads
|
|
216
|
+
assert str(upload.url) == "https://objects.example.test/upload"
|
|
217
|
+
assert b'name="key"\r\n\r\nsnapshots/run-1/slack.zip' in upload.content
|
|
218
|
+
assert b"PK\x03\x04bundle" in upload.content
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_the_uploaded_part_survives_the_wire():
|
|
222
|
+
wire = GetDataResponse(parts=[uploaded_file_part("dir/gdrive.zip", name="gdrive.zip")]).model_dump(mode="json", exclude_none=True)
|
|
223
|
+
(part,) = GetDataResponse.model_validate(json.loads(json.dumps(wire))).parts
|
|
224
|
+
assert uploaded_object_path(part) == "dir/gdrive.zip"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@pytest.mark.parametrize("part", [
|
|
228
|
+
FilePart(file={"uri": "export-snapshot", "name": "gdrive.zip"}),
|
|
229
|
+
FilePart(file={"uri": "export-snapshot"}, metadata={"other": {"path": "x"}}),
|
|
230
|
+
DataPart(data={"path": "x"}, metadata={DATA_OBJECTS_EXTENSION_URI: {"path": "x"}}),
|
|
231
|
+
], ids=["no-metadata", "other-metadata", "data-part"])
|
|
232
|
+
def test_a_part_that_names_no_upload_reads_as_none(part):
|
|
233
|
+
assert uploaded_object_path(part) is None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@pytest.mark.parametrize("path", ["../escape.zip", "/abs.zip", "a//b.zip", "a/./b.zip", "a/", "", "a\\b.zip", None, 3])
|
|
237
|
+
def test_a_path_outside_the_root_is_refused(path):
|
|
238
|
+
part = FilePart(file={"uri": "x"}, metadata={DATA_OBJECTS_EXTENSION_URI: {"path": path}})
|
|
239
|
+
with pytest.raises(ValueError, match="not a normalized relative path"):
|
|
240
|
+
uploaded_object_path(part)
|
|
241
|
+
if isinstance(path, str):
|
|
242
|
+
with pytest.raises(ValueError):
|
|
243
|
+
uploaded_file_part(path)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_a_marker_that_is_not_an_object_is_refused():
|
|
247
|
+
part = FilePart(file={"uri": "x"}, metadata={DATA_OBJECTS_EXTENSION_URI: "x.zip"})
|
|
248
|
+
with pytest.raises(ValueError, match="not a normalized relative path"):
|
|
249
|
+
uploaded_object_path(part)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/THIRD_PARTY_NOTICES.md
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/examples/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/examples/basic_agent.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/tests/test_a2a_agent.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/tests/test_client.py
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/tests/test_intake.py
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/tests/test_manifest.py
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.288}/tests/test_preflight.py
RENAMED
|
File without changes
|