agentenv-framework-protocol 0.1.300__tar.gz → 0.1.302__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.300 → agentenv_framework_protocol-0.1.302}/PKG-INFO +13 -3
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/README.md +12 -2
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/pyproject.toml +1 -1
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/framework.py +21 -2
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/transfers.py +115 -40
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_a2a_object_transfers.py +178 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/.gitignore +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/LICENSE +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/NOTICE +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/THIRD_PARTY_NOTICES.md +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/advanced_agent.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/basic_agent.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/custom_extensions_agent.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/multimodal_agent.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/reference_model.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/streaming_agent.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/agent_env_environment.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/client.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/manifest.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/preflight.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/types.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_a2a_agent.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_a2a_agent_examples.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_a2a_staging.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_agent_env_environment.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_client.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_data_objects.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_intake.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_manifest.py +0 -0
- {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/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.302
|
|
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
|
|
@@ -666,8 +666,18 @@ itself. Every SDK agent serves the staging extension, `urn:agentenv:staging/v1`,
|
|
|
666
666
|
a small object store at `/ext/staging` on its own server. Before a call
|
|
667
667
|
AgentEnv pushes what the agent will read into it; after the call it pulls what
|
|
668
668
|
the agent wrote. The grants it sends are the ordinary ones above, with URLs
|
|
669
|
-
naming staged paths on the agent's own URL, so handlers
|
|
670
|
-
|
|
669
|
+
naming staged paths on the agent's own URL, so handlers are unchanged.
|
|
670
|
+
|
|
671
|
+
A sandbox can't always call its own public URL, so each of those grants also
|
|
672
|
+
carries an `AgentEnv-Staging-Path` header: the path the agent's own server
|
|
673
|
+
serves the URL at, such as `/ext/staging/{path}`. The helpers send such a
|
|
674
|
+
request to that server over loopback, `http://127.0.0.1:{port}/ext/staging/{path}`,
|
|
675
|
+
and to the grant's URL only when nothing listens there. The port is the one the
|
|
676
|
+
SDK's server took the request being served on, or else `A2A_PORT` (set by AgentEnv
|
|
677
|
+
on agents it deploys from an image), as in a separate process such as a hook.
|
|
678
|
+
An agent with transfer code of its own gets the same URL from
|
|
679
|
+
`transfers.loopback_url(url, headers)`, and an agent with a server of its own
|
|
680
|
+
marks each request with `transfers.serving_on(port)`.
|
|
671
681
|
|
|
672
682
|
| Route | Does |
|
|
673
683
|
| --- | --- |
|
|
@@ -622,8 +622,18 @@ itself. Every SDK agent serves the staging extension, `urn:agentenv:staging/v1`,
|
|
|
622
622
|
a small object store at `/ext/staging` on its own server. Before a call
|
|
623
623
|
AgentEnv pushes what the agent will read into it; after the call it pulls what
|
|
624
624
|
the agent wrote. The grants it sends are the ordinary ones above, with URLs
|
|
625
|
-
naming staged paths on the agent's own URL, so handlers
|
|
626
|
-
|
|
625
|
+
naming staged paths on the agent's own URL, so handlers are unchanged.
|
|
626
|
+
|
|
627
|
+
A sandbox can't always call its own public URL, so each of those grants also
|
|
628
|
+
carries an `AgentEnv-Staging-Path` header: the path the agent's own server
|
|
629
|
+
serves the URL at, such as `/ext/staging/{path}`. The helpers send such a
|
|
630
|
+
request to that server over loopback, `http://127.0.0.1:{port}/ext/staging/{path}`,
|
|
631
|
+
and to the grant's URL only when nothing listens there. The port is the one the
|
|
632
|
+
SDK's server took the request being served on, or else `A2A_PORT` (set by AgentEnv
|
|
633
|
+
on agents it deploys from an image), as in a separate process such as a hook.
|
|
634
|
+
An agent with transfer code of its own gets the same URL from
|
|
635
|
+
`transfers.loopback_url(url, headers)`, and an agent with a server of its own
|
|
636
|
+
marks each request with `transfers.serving_on(port)`.
|
|
627
637
|
|
|
628
638
|
| Route | Does |
|
|
629
639
|
| --- | --- |
|
|
@@ -22,9 +22,11 @@ from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
|
|
|
22
22
|
from pydantic_core import core_schema
|
|
23
23
|
from starlette.applications import Starlette
|
|
24
24
|
from starlette.exceptions import HTTPException
|
|
25
|
+
from starlette.middleware import Middleware
|
|
25
26
|
from starlette.requests import Request
|
|
26
27
|
from starlette.responses import JSONResponse
|
|
27
28
|
from starlette.routing import Route
|
|
29
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
28
30
|
|
|
29
31
|
from ._triggers import TriggerEngine, TriggerError
|
|
30
32
|
from .extensions import (
|
|
@@ -63,10 +65,27 @@ from .tasks.v1 import (
|
|
|
63
65
|
from .tasks.v1 import (
|
|
64
66
|
TextPart as TaskTextPart,
|
|
65
67
|
)
|
|
66
|
-
from ..transfers import TransferError, upload
|
|
68
|
+
from ..transfers import TransferError, serving_on, upload
|
|
67
69
|
|
|
68
70
|
logger = logging.getLogger(__name__)
|
|
69
71
|
|
|
72
|
+
|
|
73
|
+
class _ServingOnItsPort:
|
|
74
|
+
"""Each request is served ``serving_on`` the port this server took it on, so the transfers it makes
|
|
75
|
+
reach the server's own staging over loopback there, whatever ``A2A_PORT`` says."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
78
|
+
self.app = app
|
|
79
|
+
|
|
80
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
81
|
+
server = scope.get("server") if scope["type"] == "http" else None
|
|
82
|
+
port = server[1] if server else None
|
|
83
|
+
if not isinstance(port, int):
|
|
84
|
+
await self.app(scope, receive, send)
|
|
85
|
+
return
|
|
86
|
+
with serving_on(port):
|
|
87
|
+
await self.app(scope, receive, send)
|
|
88
|
+
|
|
70
89
|
_AGENT_DEFINITION = "_agentenv_a2a_definition"
|
|
71
90
|
_AgentT = TypeVar("_AgentT", bound="AgentEnvAgent")
|
|
72
91
|
_MAX_CACHED_CONTEXTS = 1_024
|
|
@@ -918,7 +937,7 @@ class A2AAgentApplication:
|
|
|
918
937
|
)
|
|
919
938
|
if self.staging.max_bytes:
|
|
920
939
|
routes.extend(staging_routes(self.staging))
|
|
921
|
-
kwargs = {"routes": routes}
|
|
940
|
+
kwargs = {"routes": routes, "middleware": [Middleware(_ServingOnItsPort)]}
|
|
922
941
|
if definition.lifespan is not None:
|
|
923
942
|
kwargs["lifespan"] = definition.lifespan
|
|
924
943
|
self.app = Starlette(**kwargs)
|
|
@@ -10,14 +10,14 @@ import random
|
|
|
10
10
|
import re
|
|
11
11
|
import io
|
|
12
12
|
import tempfile
|
|
13
|
-
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
|
13
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
|
|
14
14
|
from contextlib import contextmanager
|
|
15
15
|
from contextvars import ContextVar
|
|
16
16
|
from datetime import datetime, timezone
|
|
17
17
|
from functools import partial
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Annotated, BinaryIO, Literal, TypeVar
|
|
20
|
-
from urllib.parse import urlsplit
|
|
20
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
21
21
|
|
|
22
22
|
import httpx
|
|
23
23
|
from pydantic import (
|
|
@@ -51,6 +51,12 @@ TRANSFER_STALL_BUDGET_SECONDS = TRANSFER_ATTEMPTS * (
|
|
|
51
51
|
_CONNECT_TIMEOUT_SECONDS + TRANSFER_IDLE_TIMEOUT_SECONDS
|
|
52
52
|
) + sum(_RETRY_BACKOFF_SECONDS * 2**attempt for attempt in range(TRANSFER_ATTEMPTS - 1))
|
|
53
53
|
_T = TypeVar("_T")
|
|
54
|
+
# A grant naming a path on its holder's own staging carries this header, whose value is that path on
|
|
55
|
+
# the holder's server: a sandbox can't always call its own public URL, so the helpers reach the server
|
|
56
|
+
# over loopback.
|
|
57
|
+
STAGING_PATH_HEADER = "AgentEnv-Staging-Path"
|
|
58
|
+
_SERVER_PORT_ENV = "A2A_PORT"
|
|
59
|
+
_SERVER_PORT: ContextVar[int | None] = ContextVar("agentenv_protocol_server_port", default=None)
|
|
54
60
|
|
|
55
61
|
|
|
56
62
|
def _https_url(value: str) -> str:
|
|
@@ -281,6 +287,63 @@ def _is_s3_request_timeout(response: httpx.Response) -> bool:
|
|
|
281
287
|
return False
|
|
282
288
|
|
|
283
289
|
|
|
290
|
+
@contextmanager
|
|
291
|
+
def serving_on(port: int) -> Iterator[None]:
|
|
292
|
+
"""Transfers made in this scope serve a request the holder's own server took on ``port``, so they
|
|
293
|
+
reach its staging over loopback there. The SDK's server sets it for every request it handles."""
|
|
294
|
+
|
|
295
|
+
token = _SERVER_PORT.set(port)
|
|
296
|
+
try:
|
|
297
|
+
yield
|
|
298
|
+
finally:
|
|
299
|
+
_SERVER_PORT.reset(token)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def loopback_url(url: str, headers: Mapping[str, str] | None) -> str | None:
|
|
303
|
+
"""Where the holder's own server answers a grant's ``url`` over loopback: when the grant names a path
|
|
304
|
+
on the holder's staging (an ``AgentEnv-Staging-Path`` header ending the URL's path) and the server's
|
|
305
|
+
port is known, from the request being served (``serving_on``) or else ``A2A_PORT``. None otherwise."""
|
|
306
|
+
path = httpx.Headers(headers or {}).get(STAGING_PATH_HEADER)
|
|
307
|
+
port = str(_SERVER_PORT.get() or os.environ.get(_SERVER_PORT_ENV, ""))
|
|
308
|
+
if not path or not path.startswith("/") or not port.isdigit() or not 0 < int(port) < 65536:
|
|
309
|
+
return None
|
|
310
|
+
parsed = urlsplit(url)
|
|
311
|
+
if not parsed.path.endswith(path):
|
|
312
|
+
return None
|
|
313
|
+
return urlunsplit(("http", f"127.0.0.1:{int(port)}", path, parsed.query, ""))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _destinations(url: str, headers: Mapping[str, str] | None) -> tuple[str, ...]:
|
|
317
|
+
"""Where a grant's request goes: the holder's own server over loopback first when the grant names a
|
|
318
|
+
path on its staging, then the grant's URL, for a server that isn't listening there."""
|
|
319
|
+
loopback = loopback_url(url, headers)
|
|
320
|
+
return (url,) if loopback is None else (loopback, url)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def _send_to_first_listening(
|
|
324
|
+
urls: tuple[str, ...], send: Callable[[str], Awaitable[_T]]
|
|
325
|
+
) -> _T:
|
|
326
|
+
"""``send`` to the first of ``urls`` whose server takes the connection."""
|
|
327
|
+
*earlier, last = urls
|
|
328
|
+
for url in earlier:
|
|
329
|
+
try:
|
|
330
|
+
return await send(url)
|
|
331
|
+
except httpx.ConnectError:
|
|
332
|
+
pass
|
|
333
|
+
return await send(last)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _send_to_first_listening_sync(urls: tuple[str, ...], send: Callable[[str], _T]) -> _T:
|
|
337
|
+
"""``_send_to_first_listening`` for a sync client."""
|
|
338
|
+
*earlier, last = urls
|
|
339
|
+
for url in earlier:
|
|
340
|
+
try:
|
|
341
|
+
return send(url)
|
|
342
|
+
except httpx.ConnectError:
|
|
343
|
+
pass
|
|
344
|
+
return send(last)
|
|
345
|
+
|
|
346
|
+
|
|
284
347
|
def _check_unexpired(expires_at: datetime) -> None:
|
|
285
348
|
if expires_at <= datetime.now(_UTC):
|
|
286
349
|
raise TransferError("grant_expired", "The object-transfer grant has expired.")
|
|
@@ -401,20 +464,21 @@ async def upload(target: WriteObject, source: Path | bytes) -> Uploaded:
|
|
|
401
464
|
headers = httpx.Headers(target.write.headers or {})
|
|
402
465
|
headers.setdefault("content-type", target.media_type)
|
|
403
466
|
headers.setdefault("content-length", str(size_bytes))
|
|
467
|
+
urls = _destinations(target.write.url, target.write.headers)
|
|
404
468
|
|
|
405
469
|
async def put() -> Uploaded:
|
|
406
|
-
with (
|
|
407
|
-
_SourceStream(source, size_bytes) as stream,
|
|
408
|
-
_transfer_request(target.write.expires_at),
|
|
409
|
-
):
|
|
470
|
+
with _transfer_request(target.write.expires_at):
|
|
410
471
|
async with httpx.AsyncClient(
|
|
411
472
|
follow_redirects=False, timeout=_TRANSFER_TIMEOUT
|
|
412
473
|
) as client:
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
474
|
+
|
|
475
|
+
async def to(url: str) -> Uploaded:
|
|
476
|
+
with _SourceStream(source, size_bytes) as stream:
|
|
477
|
+
response = await client.put(url, headers=headers, content=stream)
|
|
478
|
+
_raise_for_transfer_status(response)
|
|
479
|
+
return stream.uploaded()
|
|
480
|
+
|
|
481
|
+
return await _send_to_first_listening(urls, to)
|
|
418
482
|
|
|
419
483
|
return await _retrying(target.write.expires_at, put)
|
|
420
484
|
|
|
@@ -434,28 +498,35 @@ async def download(source: ReadObject, destination: Path) -> None:
|
|
|
434
498
|
temporary = Path(filename)
|
|
435
499
|
headers = httpx.Headers(source.read.headers or {})
|
|
436
500
|
headers["accept-encoding"] = "identity"
|
|
501
|
+
urls = _destinations(source.read.url, source.read.headers)
|
|
437
502
|
|
|
438
503
|
async def get() -> tuple[int, str]:
|
|
439
504
|
size_bytes = 0
|
|
440
505
|
digest = hashlib.sha256()
|
|
441
506
|
with _transfer_request(source.read.expires_at):
|
|
442
|
-
async with (
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
507
|
+
async with httpx.AsyncClient(
|
|
508
|
+
follow_redirects=False, timeout=_TRANSFER_TIMEOUT
|
|
509
|
+
) as client:
|
|
510
|
+
response = await _send_to_first_listening(
|
|
511
|
+
urls,
|
|
512
|
+
lambda url: client.send(
|
|
513
|
+
client.build_request("GET", url, headers=headers), stream=True
|
|
514
|
+
),
|
|
515
|
+
)
|
|
450
516
|
try:
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
517
|
+
_raise_for_transfer_status(response)
|
|
518
|
+
stream = await asyncio.to_thread(temporary.open, "wb")
|
|
519
|
+
try:
|
|
520
|
+
async for chunk in response.aiter_raw(_CHUNK_BYTES):
|
|
521
|
+
size_bytes += len(chunk)
|
|
522
|
+
if size_bytes > source.max_bytes:
|
|
523
|
+
raise TransferError("transfer_too_large", _TOO_LARGE)
|
|
524
|
+
digest.update(chunk)
|
|
525
|
+
await asyncio.to_thread(stream.write, chunk)
|
|
526
|
+
finally:
|
|
527
|
+
await asyncio.to_thread(stream.close)
|
|
457
528
|
finally:
|
|
458
|
-
await
|
|
529
|
+
await response.aclose()
|
|
459
530
|
return size_bytes, digest.hexdigest()
|
|
460
531
|
|
|
461
532
|
try:
|
|
@@ -485,24 +556,28 @@ def _post_to_namespace(
|
|
|
485
556
|
fields = {**grant.fields, grant.path_field: object_path}
|
|
486
557
|
fields.setdefault("Content-Type", "application/octet-stream")
|
|
487
558
|
with (
|
|
488
|
-
_SourceStream(source, size_bytes) as stream,
|
|
489
559
|
_transfer_request(target.expires_at),
|
|
490
560
|
httpx.Client(follow_redirects=False, timeout=_TRANSFER_TIMEOUT) as client,
|
|
491
561
|
):
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
562
|
+
|
|
563
|
+
def to(url: str) -> Uploaded:
|
|
564
|
+
with _SourceStream(source, size_bytes) as stream:
|
|
565
|
+
response = client.post(
|
|
566
|
+
url,
|
|
567
|
+
data=fields,
|
|
568
|
+
files={
|
|
569
|
+
grant.file_field: (
|
|
570
|
+
object_path.rsplit("/", 1)[-1],
|
|
571
|
+
stream,
|
|
572
|
+
"application/octet-stream",
|
|
573
|
+
)
|
|
574
|
+
},
|
|
575
|
+
headers=grant.headers,
|
|
500
576
|
)
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
return stream.uploaded()
|
|
577
|
+
_raise_for_transfer_status(response)
|
|
578
|
+
return stream.uploaded()
|
|
579
|
+
|
|
580
|
+
return _send_to_first_listening_sync(_destinations(grant.url, grant.headers), to)
|
|
506
581
|
|
|
507
582
|
|
|
508
583
|
class NamespaceUploader:
|
|
@@ -31,6 +31,7 @@ from agentenv_protocol.a2a_agent import (
|
|
|
31
31
|
TaskObjectTrajectoryRequest,
|
|
32
32
|
TaskRequest,
|
|
33
33
|
TaskResult,
|
|
34
|
+
TaskTrajectoryRequest,
|
|
34
35
|
TrajectoryWriteObjects,
|
|
35
36
|
a2a_agent,
|
|
36
37
|
card_request_accepts,
|
|
@@ -964,3 +965,180 @@ async def test_namespace_upload_of_bytes_declares_its_length(
|
|
|
964
965
|
assert "transfer-encoding" not in request.headers
|
|
965
966
|
assert int(request.headers["content-length"]) == len(request.content)
|
|
966
967
|
assert from_bytes == from_file
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
_PUBLIC = "https://agent.example.test/sandbox/vm-8000" # the provider routes this prefix to the agent
|
|
971
|
+
_STAGED = "/ext/staging/" + "c" * 32 + "/0"
|
|
972
|
+
_NAMESPACE = "/ext/staging/" + "n" * 32
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _on_own_staging(content: bytes) -> tuple[WriteObject, ReadObject, WriteNamespaceGrant]:
|
|
976
|
+
"""Grants naming paths on their holder's own staging, as agent-env stages them."""
|
|
977
|
+
headers = {transfers.STAGING_PATH_HEADER: _STAGED}
|
|
978
|
+
write = WriteObject(
|
|
979
|
+
media_type="application/json",
|
|
980
|
+
max_bytes=1024,
|
|
981
|
+
write=HttpPutGrant(kind="http-put", url=_PUBLIC + _STAGED, expires_at=_expiry(), headers=headers),
|
|
982
|
+
)
|
|
983
|
+
read = ReadObject(
|
|
984
|
+
media_type="application/json",
|
|
985
|
+
max_bytes=1024,
|
|
986
|
+
size_bytes=len(content),
|
|
987
|
+
sha256=hashlib.sha256(content).hexdigest(),
|
|
988
|
+
read=HttpGetGrant(kind="http-get", url=_PUBLIC + _STAGED, expires_at=_expiry(), headers=headers),
|
|
989
|
+
)
|
|
990
|
+
namespace = _namespace_grant().model_copy(
|
|
991
|
+
update={
|
|
992
|
+
"write": HttpPostPolicyGrant(
|
|
993
|
+
kind="http-post-policy",
|
|
994
|
+
url=_PUBLIC + _NAMESPACE,
|
|
995
|
+
fields={},
|
|
996
|
+
path_field="key",
|
|
997
|
+
file_field="file",
|
|
998
|
+
headers={transfers.STAGING_PATH_HEADER: _NAMESPACE},
|
|
999
|
+
)
|
|
1000
|
+
}
|
|
1001
|
+
)
|
|
1002
|
+
return write, read, namespace
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
@pytest.mark.asyncio
|
|
1006
|
+
async def test_a_grant_on_its_holders_own_staging_goes_to_its_server_over_loopback(
|
|
1007
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1008
|
+
) -> None:
|
|
1009
|
+
monkeypatch.setenv("A2A_PORT", "8123")
|
|
1010
|
+
content = b'{"trajectory":[]}'
|
|
1011
|
+
sent: list[str] = []
|
|
1012
|
+
|
|
1013
|
+
def handle(request: httpx.Request) -> httpx.Response:
|
|
1014
|
+
sent.append(f"{request.method} {request.url}")
|
|
1015
|
+
if request.url.host != "127.0.0.1":
|
|
1016
|
+
raise httpx.ConnectError("the sandbox can't call its own public URL", request=request)
|
|
1017
|
+
request.read()
|
|
1018
|
+
return _served(content) if request.method == "GET" else httpx.Response(201)
|
|
1019
|
+
|
|
1020
|
+
_route(monkeypatch, handle)
|
|
1021
|
+
write, read, namespace = _on_own_staging(content)
|
|
1022
|
+
|
|
1023
|
+
await upload(write, content)
|
|
1024
|
+
await download(read, tmp_path / "trajectory.json")
|
|
1025
|
+
await NamespaceUploader(namespace).upload("000000.tar", b"increment")
|
|
1026
|
+
|
|
1027
|
+
assert sent == [
|
|
1028
|
+
f"PUT http://127.0.0.1:8123{_STAGED}",
|
|
1029
|
+
f"GET http://127.0.0.1:8123{_STAGED}",
|
|
1030
|
+
f"POST http://127.0.0.1:8123{_NAMESPACE}",
|
|
1031
|
+
]
|
|
1032
|
+
assert (tmp_path / "trajectory.json").read_bytes() == content
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
@pytest.mark.asyncio
|
|
1036
|
+
async def test_a_holder_whose_server_isnt_listening_on_loopback_uses_the_grants_url(
|
|
1037
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1038
|
+
) -> None:
|
|
1039
|
+
monkeypatch.setenv("A2A_PORT", "8123")
|
|
1040
|
+
content = b'{"trajectory":[]}'
|
|
1041
|
+
sent: list[str] = []
|
|
1042
|
+
bodies: list[bytes] = []
|
|
1043
|
+
|
|
1044
|
+
def handle(request: httpx.Request) -> httpx.Response:
|
|
1045
|
+
sent.append(request.url.host)
|
|
1046
|
+
if request.url.host == "127.0.0.1":
|
|
1047
|
+
raise httpx.ConnectError("refused", request=request)
|
|
1048
|
+
bodies.append(request.read())
|
|
1049
|
+
return _served(content) if request.method == "GET" else httpx.Response(201)
|
|
1050
|
+
|
|
1051
|
+
_route(monkeypatch, handle)
|
|
1052
|
+
write, read, namespace = _on_own_staging(content)
|
|
1053
|
+
|
|
1054
|
+
uploaded = await upload(write, content)
|
|
1055
|
+
await download(read, tmp_path / "trajectory.json")
|
|
1056
|
+
increment = await NamespaceUploader(namespace).upload("000000.tar", b"increment")
|
|
1057
|
+
|
|
1058
|
+
assert sent == ["127.0.0.1", "agent.example.test"] * 3
|
|
1059
|
+
assert bodies[0] == content and uploaded.size_bytes == len(content)
|
|
1060
|
+
assert b"increment" in bodies[2] and increment.size_bytes == len(b"increment")
|
|
1061
|
+
assert (tmp_path / "trajectory.json").read_bytes() == content
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
@pytest.mark.asyncio
|
|
1065
|
+
async def test_an_answer_from_the_holders_own_server_is_final(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
1066
|
+
monkeypatch.setenv("A2A_PORT", "8123")
|
|
1067
|
+
requests: list[httpx.Request] = []
|
|
1068
|
+
_route(monkeypatch, _respond(requests, 404))
|
|
1069
|
+
write, _, _ = _on_own_staging(b"")
|
|
1070
|
+
|
|
1071
|
+
with pytest.raises(TransferError) as exc_info:
|
|
1072
|
+
await upload(write, b"{}")
|
|
1073
|
+
|
|
1074
|
+
assert exc_info.value.code == "transfer_rejected"
|
|
1075
|
+
assert [request.url.host for request in requests] == ["127.0.0.1"]
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
@pytest.mark.parametrize(
|
|
1079
|
+
("port", "path"),
|
|
1080
|
+
[
|
|
1081
|
+
(None, _STAGED), # agent-env didn't deploy the holder
|
|
1082
|
+
("http", _STAGED),
|
|
1083
|
+
("0", _STAGED),
|
|
1084
|
+
("65536", _STAGED),
|
|
1085
|
+
("8123", None), # not staged
|
|
1086
|
+
("8123", "/ext/staging/" + "d" * 32 + "/0"), # not this URL's path
|
|
1087
|
+
("8123", _STAGED.lstrip("/")),
|
|
1088
|
+
],
|
|
1089
|
+
)
|
|
1090
|
+
def test_a_grant_goes_to_its_url_unless_it_names_a_path_its_holders_known_port_serves(
|
|
1091
|
+
monkeypatch: pytest.MonkeyPatch, port: str | None, path: str | None
|
|
1092
|
+
) -> None:
|
|
1093
|
+
if port is None:
|
|
1094
|
+
monkeypatch.delenv("A2A_PORT", raising=False)
|
|
1095
|
+
else:
|
|
1096
|
+
monkeypatch.setenv("A2A_PORT", port)
|
|
1097
|
+
headers = None if path is None else {transfers.STAGING_PATH_HEADER: path}
|
|
1098
|
+
|
|
1099
|
+
assert transfers.loopback_url(_PUBLIC + _STAGED, headers) is None
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def test_the_loopback_url_keeps_the_grants_query_and_reads_the_header_in_any_case(
|
|
1103
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1104
|
+
) -> None:
|
|
1105
|
+
monkeypatch.setenv("A2A_PORT", "8123")
|
|
1106
|
+
headers = {transfers.STAGING_PATH_HEADER.lower(): _STAGED}
|
|
1107
|
+
|
|
1108
|
+
assert (
|
|
1109
|
+
transfers.loopback_url(f"{_PUBLIC}{_STAGED}?part=1", headers)
|
|
1110
|
+
== f"http://127.0.0.1:8123{_STAGED}?part=1"
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
@a2a_agent(identity=AgentIdentity(name="own-port", description="test", version="1"))
|
|
1115
|
+
class _UploadsItsTrajectory(AgentEnvAgent):
|
|
1116
|
+
async def run(self, request: TaskRequest) -> TaskResult:
|
|
1117
|
+
return TaskResult.text("ok")
|
|
1118
|
+
|
|
1119
|
+
@extension(TRAJECTORY_V1.get)
|
|
1120
|
+
async def trajectory(self, request: TaskTrajectoryRequest | TaskObjectTrajectoryRequest):
|
|
1121
|
+
uploaded = await upload(request.objects.trajectory, b"[]")
|
|
1122
|
+
return {"objects": {"trajectory": uploaded.model_dump(exclude_none=True)}}
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def test_the_sdk_server_reaches_its_staging_on_the_port_it_took_the_request_on(
|
|
1126
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1127
|
+
) -> None:
|
|
1128
|
+
monkeypatch.setenv("A2A_PORT", "9999") # not where this server listens
|
|
1129
|
+
sent: list[str] = []
|
|
1130
|
+
|
|
1131
|
+
def handle(request: httpx.Request) -> httpx.Response:
|
|
1132
|
+
sent.append(str(request.url))
|
|
1133
|
+
request.read()
|
|
1134
|
+
return httpx.Response(201)
|
|
1135
|
+
|
|
1136
|
+
_route(monkeypatch, handle)
|
|
1137
|
+
write, _, _ = _on_own_staging(b"")
|
|
1138
|
+
payload = {"task_id": "t", "objects": {"trajectory": write.model_dump(mode="json")}}
|
|
1139
|
+
|
|
1140
|
+
with TestClient(_UploadsItsTrajectory().create_app(), base_url="http://127.0.0.1:8456") as client:
|
|
1141
|
+
assert client.post("/ext/trajectory", json=payload).status_code == 200
|
|
1142
|
+
|
|
1143
|
+
assert sent == [f"http://127.0.0.1:8456{_STAGED}"]
|
|
1144
|
+
assert transfers.loopback_url(write.write.url, write.write.headers) == f"http://127.0.0.1:9999{_STAGED}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/THIRD_PARTY_NOTICES.md
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/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
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_a2a_agent.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_client.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_intake.py
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_manifest.py
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_preflight.py
RENAMED
|
File without changes
|