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.
Files changed (39) hide show
  1. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/PKG-INFO +13 -3
  2. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/README.md +12 -2
  3. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/pyproject.toml +1 -1
  4. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/framework.py +21 -2
  5. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/transfers.py +115 -40
  6. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_a2a_object_transfers.py +178 -0
  7. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/.gitignore +0 -0
  8. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/LICENSE +0 -0
  9. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/NOTICE +0 -0
  10. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/THIRD_PARTY_NOTICES.md +0 -0
  11. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/__init__.py +0 -0
  12. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/advanced_agent.py +0 -0
  13. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/basic_agent.py +0 -0
  14. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/custom_extensions_agent.py +0 -0
  15. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/multimodal_agent.py +0 -0
  16. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/reference_model.py +0 -0
  17. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/examples/streaming_agent.py +0 -0
  18. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/__init__.py +0 -0
  19. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
  20. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
  21. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
  22. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
  23. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
  24. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
  25. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
  26. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/agent_env_environment.py +0 -0
  27. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/client.py +0 -0
  28. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/manifest.py +0 -0
  29. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/preflight.py +0 -0
  30. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/src/agentenv_protocol/types.py +0 -0
  31. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_a2a_agent.py +0 -0
  32. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_a2a_agent_examples.py +0 -0
  33. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_a2a_staging.py +0 -0
  34. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_agent_env_environment.py +0 -0
  35. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_client.py +0 -0
  36. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_data_objects.py +0 -0
  37. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_intake.py +0 -0
  38. {agentenv_framework_protocol-0.1.300 → agentenv_framework_protocol-0.1.302}/tests/test_manifest.py +0 -0
  39. {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.300
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 and helpers are
670
- unchanged.
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 and helpers are
626
- unchanged.
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
  | --- | --- |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentenv-framework-protocol"
3
- version = "0.1.300"
3
+ version = "0.1.302"
4
4
  description = "Open data-plane protocol and server SDK for agent environments."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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
- response = await client.put(
414
- target.write.url, headers=headers, content=stream
415
- )
416
- _raise_for_transfer_status(response)
417
- return stream.uploaded()
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
- httpx.AsyncClient(
444
- follow_redirects=False, timeout=_TRANSFER_TIMEOUT
445
- ) as client,
446
- client.stream("GET", source.read.url, headers=headers) as response,
447
- ):
448
- _raise_for_transfer_status(response)
449
- stream = await asyncio.to_thread(temporary.open, "wb")
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
- async for chunk in response.aiter_raw(_CHUNK_BYTES):
452
- size_bytes += len(chunk)
453
- if size_bytes > source.max_bytes:
454
- raise TransferError("transfer_too_large", _TOO_LARGE)
455
- digest.update(chunk)
456
- await asyncio.to_thread(stream.write, chunk)
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 asyncio.to_thread(stream.close)
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
- response = client.post(
493
- grant.url,
494
- data=fields,
495
- files={
496
- grant.file_field: (
497
- object_path.rsplit("/", 1)[-1],
498
- stream,
499
- "application/octet-stream",
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
- headers=grant.headers,
503
- )
504
- _raise_for_transfer_status(response)
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}"