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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions python/packages/a2a/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ a2a_agent = A2AAgent(url="http://remote-agent/a2a")
response = await a2a_agent.run("Hello!")
```

**Breaking change:** HTTP clients created by `A2AAgent` no longer persist response cookies. This prevents
one run's cookies from being reused by another run through the same agent.
Connection pooling and cleanup of internally created clients are unchanged.

If the remote service requires cookies for authentication, sessions, or load-balancer affinity, explicitly supply an
`httpx.AsyncClient` using `http_client=`. Its cookie behavior is preserved.
Scope that client to the intended authenticated user or security context and
manage its lifetime; do not share a user-specific cookie jar across users.

Supplied HTTP clients remain caller-owned even when `client=` is also provided.
Applications that previously relied on the agent closing a supplied HTTP client
in that combination must now close it explicitly.

### A2AExecutor (Hosting)

The `A2AExecutor` class bridges local AI agents built with the `agent_framework` library to the A2A protocol, allowing them to be hosted and accessed by other A2A-compliant clients.
Expand Down
15 changes: 10 additions & 5 deletions python/packages/a2a/agent_framework_a2a/_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import uuid
import warnings
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
from http.cookiejar import CookieJar, DefaultCookiePolicy
from typing import Any, Final, Literal, TypeAlias, cast, overload

import httpx
Expand Down Expand Up @@ -244,7 +245,9 @@ def __init__(
agent_card: The agent card for the agent.
url: The URL for the A2A server.
client: The A2A client for the agent.
http_client: Optional httpx.AsyncClient to use.
http_client: Optional httpx.AsyncClient to use. Agent-created clients do not persist
response cookies; supplied clients retain their configured cookie behavior and
remain caller-owned, including when ``client`` is also supplied.
auth_interceptor: Optional authentication interceptor for secured endpoints.
timeout: Request timeout configuration. Can be a float (applied to all timeout components),
httpx.Timeout object (for full control), or None (uses 10.0s connect, 60.0s read,
Expand All @@ -263,15 +266,13 @@ def __init__(

super().__init__(id=id, name=name, description=description, **kwargs)
self._http_client: httpx.AsyncClient | None = http_client
# By default, leave a caller-supplied HTTP client open; specific paths may override this below.
# every construction path must set this before __aexit__ can run
# Only HTTP clients created by this agent are closed on exit.
self._close_http_client = False
self._timeout_config = self._create_timeout_config(timeout)
bindings = supported_protocol_bindings if supported_protocol_bindings is not None else ["JSONRPC"]
if client is not None:
self.client = client
self._non_streaming_client: Client | None = None
self._close_http_client = True
return
if agent_card is None:
if url is None:
Expand All @@ -282,7 +283,11 @@ def __init__(
# Create or use provided httpx client
if http_client is None:
headers = prepend_agent_framework_to_user_agent()
http_client = httpx.AsyncClient(timeout=self._timeout_config, headers=headers)
http_client = httpx.AsyncClient(
timeout=self._timeout_config,
headers=headers,
cookies=CookieJar(policy=DefaultCookiePolicy(allowed_domains=[])),
)
self._http_client = http_client # Store for cleanup
self._close_http_client = True

Expand Down
110 changes: 86 additions & 24 deletions python/packages/a2a/tests/test_a2a_agent.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.

import json
from collections.abc import AsyncIterator
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
Expand Down Expand Up @@ -30,6 +31,7 @@
)
from agent_framework.a2a import A2AAgent
from agent_framework.exceptions import AgentInvalidRequestException
from google.protobuf.json_format import MessageToDict
from pytest import fixture, mark, raises, warns

from agent_framework_a2a import A2AAgentSession, A2AContinuationToken, A2AServiceSessionId
Expand Down Expand Up @@ -130,6 +132,70 @@ def a2a_agent(mock_a2a_client: MockA2AClient) -> A2AAgent:
return A2AAgent(name="Test Agent", id="test-agent", client=cast(Any, mock_a2a_client), http_client=None)


@mark.parametrize("set_cookie", [True, False], ids=["set-cookie", "no-set-cookie-control"])
async def test_run_does_not_replay_upstream_cookies_between_sessions(set_cookie: bool) -> None:
"""A later session must not receive private data through an earlier session's response cookie."""
private_record = "First caller's private record"
upstream_requests: list[httpx.Request] = []
upstream_responses: list[httpx.Response] = []

def handle_upstream_request(request: httpx.Request) -> httpx.Response:
upstream_requests.append(request)
payload = json.loads(request.content)
assert request.method == "POST"
assert payload["method"] == "SendMessage"
prompt = payload["params"]["message"]["parts"][-1]["text"]
headers = {}
if prompt == "sign-in":
text = "Signed in"
if set_cookie:
headers["set-cookie"] = "remote_session=first-session; Path=/; HttpOnly; Secure"
else:
assert prompt == "read-record"
text = (
private_record
if "remote_session=first-session" in request.headers.get("cookie", "")
else "Access denied"
)
response = httpx.Response(
200,
headers=headers,
json={
"jsonrpc": "2.0",
"id": payload["id"],
"result": {
"message": MessageToDict(
A2AMessage(
message_id=str(uuid4()),
role=A2ARole.ROLE_AGENT,
parts=[Part(text=text)],
)
)
},
},
)
upstream_responses.append(response)
return response

async_client_type = httpx.AsyncClient

def create_http_client(**kwargs: Any) -> httpx.AsyncClient:
# Keep the agent-owned HTTPX client and its cookie handling; replace only network I/O.
return async_client_type(transport=httpx.MockTransport(handle_upstream_request), **kwargs)

with patch("agent_framework_a2a._agent.httpx.AsyncClient", side_effect=create_http_client):
async with A2AAgent(url="https://a2a.example.test/") as agent:
first_response = await agent.run("sign-in", session=agent.create_session())
second_response = await agent.run("read-record", session=agent.create_session())

assert len(upstream_requests) == 2
assert "cookie" not in upstream_requests[0].headers
assert ("set-cookie" in upstream_responses[0].headers) is set_cookie
assert first_response.text == "Signed in"
assert second_response.text == "Access denied"
assert "cookie" not in upstream_requests[1].headers


def test_a2a_agent_initialization_with_client(mock_a2a_client: MockA2AClient) -> None:
"""Test A2AAgent initialization with provided client."""
# Use model_construct to bypass Pydantic validation for mock objects
Expand Down Expand Up @@ -435,24 +501,6 @@ async def test_run_streaming_with_message_response(a2a_agent: A2AAgent, mock_a2a
assert mock_a2a_client.call_count == 1


async def test_context_manager_cleanup() -> None:
"""Test context manager cleanup of http client."""

# Create mock http client that tracks aclose calls
mock_http_client = AsyncMock()
mock_a2a_client = MagicMock()

agent = A2AAgent(client=cast(Any, mock_a2a_client))
agent._http_client = mock_http_client

# Test context manager cleanup
async with agent:
pass

# Verify aclose was called
mock_http_client.aclose.assert_called_once()


async def test_context_manager_no_cleanup_when_no_http_client() -> None:
"""Test context manager when _http_client is None."""

Expand All @@ -465,14 +513,28 @@ async def test_context_manager_no_cleanup_when_no_http_client() -> None:
pass


async def test_context_manager_does_not_close_caller_supplied_http_client() -> None:
"""A caller-supplied http_client (without client=) must survive __aexit__. See issue #7950."""
mock_http_client = AsyncMock()
@mark.parametrize("supply_a2a_client", [False, True])
async def test_context_manager_does_not_close_caller_supplied_http_client(supply_a2a_client: bool) -> None:
"""A caller-supplied HTTP client must stay open and retain its cookie behavior after __aexit__."""

async with A2AAgent(url="http://localhost:9999/", http_client=cast(Any, mock_http_client)):
pass
def handle_request(request: httpx.Request) -> httpx.Response:
assert request.headers["cookie"] == "caller_cookie=kept"
return httpx.Response(200, headers={"set-cookie": "server_cookie=retained; Path=/"})

mock_http_client.aclose.assert_not_called()
async with httpx.AsyncClient(
transport=httpx.MockTransport(handle_request), cookies={"caller_cookie": "kept"}
) as http_client:
async with A2AAgent(
url="https://a2a.example.test/",
client=MagicMock() if supply_a2a_client else None,
http_client=http_client,
):
pass

assert not http_client.is_closed
await http_client.get("https://a2a.example.test/")
assert http_client.cookies["caller_cookie"] == "kept"
assert http_client.cookies["server_cookie"] == "retained"


def test_prepare_message_for_a2a_with_multiple_contents() -> None:
Expand Down
14 changes: 14 additions & 0 deletions python/packages/ag-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,20 @@ The `AGUIChatClient` supports:
- Integration with `Agent` for client-side history management
- Canonical interrupt/resume passthrough (`availableInterrupts` and `resume`)

#### HTTP client ownership and cookies

**Breaking change:** `AGUIChatClient` and `AGUIHttpService` reuse an internally owned HTTP client
for connection pooling but no longer persist response cookies. This applies to all runs,
including repeated requests with the same thread ID. Closing the AG-UI client or leaving
its async context manager closes its internally owned HTTP client.

Applications that need cookies for upstream authentication, sessions, or load-balancer affinity, including those migrating from
the previous default of retaining response cookies, must supply an `httpx.AsyncClient`
through `http_client`. Supplied clients retain their headers, cookies, timeout, transport,
and response-cookie handling, and must be closed by the caller. Scope a cookie-bearing
client to a single authenticated principal; do not share it across users. AG-UI thread IDs
are correlation identifiers, not authentication boundaries.

## Tool Return Helpers

Use `state_update` when a backend tool needs to send different payloads to the model, the UI, and shared state. The `text` value remains the LLM-bound tool result, `tool_result` becomes the AG-UI `ToolCallResultEvent.content` for frontend rendering, and `state` is merged into durable shared state.
Expand Down
7 changes: 5 additions & 2 deletions python/packages/ag-ui/agent_framework_ag_ui/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,10 @@ def __init__(

Args:
endpoint: The AG-UI server endpoint URL (e.g., "http://localhost:8888/")
http_client: Optional httpx.AsyncClient instance. If None, one will be created.
http_client: Optional httpx.AsyncClient instance. If None, creates a client that does not
persist response cookies. Supplied clients retain their cookie behavior and remain
caller-owned; scope cookie-based authentication to a single authenticated principal
rather than sharing it across users.
timeout: Request timeout in seconds (default: 60.0)
additional_properties: Additional properties to store
middleware: Optional middleware to apply to the client.
Expand All @@ -297,7 +300,7 @@ def __init__(
)

async def close(self) -> None:
"""Close the HTTP client."""
"""Close the internally created HTTP client, leaving supplied clients open."""
await self._http_service.close()

async def __aenter__(self) -> Self:
Expand Down
15 changes: 13 additions & 2 deletions python/packages/ag-ui/agent_framework_ag_ui/_http_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import json
import logging
from collections.abc import AsyncIterable, Mapping, Sequence
from http.cookiejar import CookieJar, DefaultCookiePolicy
from typing import Any, cast

import httpx
Expand Down Expand Up @@ -151,12 +152,22 @@ def __init__(

Args:
endpoint: AG-UI server endpoint URL (e.g., "http://localhost:8888/")
http_client: Optional httpx AsyncClient. If None, creates a new one.
http_client: Optional httpx AsyncClient. If None, creates a client that does not
persist response cookies. Supplied clients retain their cookie behavior and
remain caller-owned; scope cookie-based authentication to a single authenticated
principal rather than sharing it across users.
timeout: Request timeout in seconds (default: 60.0)
"""
self.endpoint = endpoint.rstrip("/")
self._owns_client = http_client is None
self.http_client = http_client or httpx.AsyncClient(timeout=timeout)
if http_client is not None:
self.http_client = http_client
else:
# An empty domain allowlist prevents response cookies from persisting across runs.
self.http_client = httpx.AsyncClient(
timeout=timeout,
cookies=CookieJar(policy=DefaultCookiePolicy(allowed_domains=[])),
)

async def post_run(
self,
Expand Down
Loading
Loading