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
40 changes: 40 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,46 @@ async def main():
asyncio.run(main())
```

### Connection and credential lifetimes

Chat clients support async context managers for deterministic cleanup:

```python
from agent_framework import Agent, Message
from agent_framework.openai import OpenAIChatClient

async with OpenAIChatClient() as client:
response = await client.get_response([Message("user", ["Hello!"])])

# Alternatively, let the agent enter and exit its chat client's context.
async with Agent(client=OpenAIChatClient(), instructions="Be helpful.") as agent:
response = await agent.run("Hello!")
```

Calling clients without a context manager remains supported; use `await client.close()` when finished.
Context exit releases connections created by the connector, including on exceptions. Caller-supplied SDK clients,
HTTP transports, and credentials remain caller-owned. In particular, closing an Azure SDK connection does not
close the credential supplied to it. Scope credentials outside the agent or client that uses them:

```python
from azure.identity.aio import AzureCliCredential

async with AzureCliCredential() as credential:
async with OpenAIChatClient(
azure_endpoint="https://YOUR-RESOURCE.openai.azure.com",
model="YOUR-DEPLOYMENT",
credential=credential,
) as client:
response = await client.get_response([Message("user", ["Hello!"])])
```

Treat closing a resource-owning client as the end of its lifetime; reopening is SDK-dependent. Avoid overlapping
agent contexts sharing a connector-owned connection, since exiting either agent closes that connection. To share
an SDK client across agents, inject it into their chat clients and manage its lifetime outside those agent contexts.
`BaseChatClient` provides default context methods and a no-op `close()`, so custom subclasses do not need to
implement lifecycle methods unless they own resources. Protocol-only chat clients do not need context methods.
Inherited context exit also supports existing subclasses whose `close()` is synchronous.

## 4. Build an Agent with Tools and Functions

Enhance your agent with custom tools and function calling:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ def __init__(
secret_key_secret = settings.get("aws_secret_access_key")
session_token_secret = settings.get("aws_session_token")

owns_client = anthropic_client is None
if anthropic_client is None:
anthropic_client = AsyncAnthropicBedrock(
aws_secret_key=secret_key_secret.get_secret_value() if secret_key_secret is not None else None,
Expand All @@ -130,6 +131,7 @@ def __init__(
additional_beta_flags=additional_beta_flags,
additional_properties=additional_properties,
)
self._owns_client = owns_client


class AnthropicBedrockClient(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ class MyOptions(AnthropicChatOptions, total=False):
model_setting = anthropic_settings.get("chat_model")
base_url_setting = anthropic_settings.get("base_url")

owns_client = anthropic_client is None
if anthropic_client is None:
if api_key_secret is None:
raise ValueError(
Expand All @@ -438,9 +439,18 @@ class MyOptions(AnthropicChatOptions, total=False):

# Initialize instance variables
self.anthropic_client = anthropic_client
self._owns_client = owns_client
self._client_closed = False
self.additional_beta_flags = additional_beta_flags or []
self.model = model_setting

@override
async def close(self) -> None:
"""Close the internally created SDK client, leaving supplied credentials open."""
if self._owns_client and not self._client_closed:
await self.anthropic_client.close()
self._client_closed = True

# region Static factory methods for hosted tools

@staticmethod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def __init__(
base_url_setting = settings.get("anthropic_foundry_base_url")
api_key_value = api_key_secret.get_secret_value() if api_key_secret is not None else None

owns_client = anthropic_client is None
if anthropic_client is None:
if base_url_setting is None and resource_setting is None:
message = (
Expand Down Expand Up @@ -107,6 +108,7 @@ def __init__(
additional_beta_flags=additional_beta_flags,
additional_properties=additional_properties,
)
self._owns_client = owns_client


class AnthropicFoundryClient(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def __init__(
region_setting = settings.get("cloud_ml_region")
project_id_setting = settings.get("anthropic_vertex_project_id")

owns_client = anthropic_client is None
if anthropic_client is None:
resolved_region = region_setting if region_setting is not None else NOT_GIVEN
resolved_project_id = project_id_setting if project_id_setting is not None else NOT_GIVEN
Expand All @@ -102,6 +103,7 @@ def __init__(
additional_beta_flags=additional_beta_flags,
additional_properties=additional_properties,
)
self._owns_client = owns_client


class AnthropicVertexClient(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.

from __future__ import annotations

import logging
from unittest.mock import AsyncMock, MagicMock, patch

Expand All @@ -11,9 +13,11 @@

from agent_framework_anthropic import (
AnthropicBedrockClient,
AnthropicClient,
AnthropicFoundryClient,
AnthropicVertexClient,
RawAnthropicBedrockClient,
RawAnthropicClient,
RawAnthropicFoundryClient,
RawAnthropicVertexClient,
)
Expand All @@ -28,6 +32,38 @@ def _create_mock_transport(base_url: str) -> MagicMock:
return transport


@pytest.mark.parametrize(
("client_type", "factory", "kwargs"),
[
(RawAnthropicClient, "_chat_client.AsyncAnthropic", {"api_key": "test-key"}),
(AnthropicClient, "_chat_client.AsyncAnthropic", {"api_key": "test-key"}),
(RawAnthropicFoundryClient, "_foundry_client.AsyncAnthropicFoundry", {"resource": "test", "api_key": "test"}),
(AnthropicFoundryClient, "_foundry_client.AsyncAnthropicFoundry", {"resource": "test", "api_key": "test"}),
(RawAnthropicBedrockClient, "_bedrock_client.AsyncAnthropicBedrock", {"aws_region": "us-east-1"}),
(AnthropicBedrockClient, "_bedrock_client.AsyncAnthropicBedrock", {"aws_region": "us-east-1"}),
(
RawAnthropicVertexClient,
"_vertex_client.AsyncAnthropicVertex",
{"project_id": "test", "region": "us-central1"},
),
(AnthropicVertexClient, "_vertex_client.AsyncAnthropicVertex", {"project_id": "test", "region": "us-central1"}),
],
)
@pytest.mark.parametrize("injected", [False, True])
async def test_anthropic_client_context_ownership(client_type, factory: str, kwargs, injected: bool) -> None:
sdk_client = _create_mock_transport("https://test.example.com")
sdk_client.close = AsyncMock()
if injected:
kwargs = {**kwargs, "anthropic_client": sdk_client}
with patch(f"agent_framework_anthropic.{factory}", return_value=sdk_client):
client = client_type(model="test-model", **kwargs)
async with client as entered:
assert entered is client
sdk_client.close.assert_not_awaited()
await client.close()
assert sdk_client.close.await_count == (0 if injected else 1)


@pytest.mark.parametrize(
("public_client", "raw_client"),
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,9 @@ class MyOptions(BedrockChatOptions, total=False):
region = settings.get("region") or session_region or DEFAULT_REGION
chat_model = settings.get("chat_model")

if client:
self._owns_client = client is None
self._client_closed = False
if client is not None:
self._bedrock_client = client
else:
session = boto3_session or self._create_session(settings)
Expand All @@ -344,6 +346,13 @@ class MyOptions(BedrockChatOptions, total=False):
self.model = chat_model
self.region = region

@override
async def close(self) -> None:
"""Close the owned runtime connection without closing caller-owned sessions."""
if self._owns_client and not self._client_closed:
await asyncio.to_thread(self._bedrock_client.close)
self._client_closed = True

@staticmethod
def _create_session(settings: BedrockSettings) -> Boto3Session:
session_kwargs: dict[str, Any] = {"region_name": settings.get("region") or DEFAULT_REGION}
Expand Down
14 changes: 14 additions & 0 deletions python/packages/bedrock/tests/test_bedrock_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,20 @@ def test_agent_accepts_bedrock_chat_client() -> None:
assert agent.client is client


@pytest.mark.parametrize("injected", [False, True])
async def test_bedrock_client_context_ownership(injected: bool) -> None:
runtime = MagicMock(spec=BaseClient)
session = MagicMock(spec=Boto3Session)
session.region_name = "us-west-2"
session.client.return_value = runtime
client = BedrockChatClient(model="test-model", boto3_session=session, client=runtime if injected else None)
async with client as entered:
assert entered is client
runtime.close.assert_not_called()
await client.close()
assert runtime.close.call_count == (0 if injected else 1)


async def test_get_response_invokes_bedrock_runtime() -> None:
stub = _StubBedrockRuntime()
client = BedrockChatClient(
Expand Down
5 changes: 4 additions & 1 deletion python/packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ agent_framework/
### Chat Clients (`_clients.py`)

- **`SupportsChatGetResponse`** - Protocol for chat client implementations
- **`BaseChatClient`** - Abstract base class with middleware support; subclasses implement `_inner_get_response()` and `_inner_get_streaming_response()`
- **`BaseChatClient`** - Abstract base class without middleware; subclasses implement `_inner_get_response()`
for streaming and non-streaming requests. Provides `__aenter__()` returning `self`, `__aexit__()` delegating to
`close()`, and a default no-op async `close()`. Connectors override cleanup for owned connections and credentials;
injected resources remain caller-owned. `SupportsChatGetResponse` does not require lifecycle methods.

### Types (`_types.py`)

Expand Down
33 changes: 33 additions & 0 deletions python/packages/core/agent_framework/_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
Mapping,
Sequence,
)
from inspect import isawaitable
from types import TracebackType
from typing import (
TYPE_CHECKING,
Any,
Expand Down Expand Up @@ -44,6 +46,11 @@
else:
from typing_extensions import TypeVar # pragma: no cover

if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover


if TYPE_CHECKING:
from pydantic import BaseModel
Expand Down Expand Up @@ -309,6 +316,32 @@ def __init__(
self.tokenizer = tokenizer
super().__init__()

async def __aenter__(self) -> Self:
"""Enter the client context without requiring it for ordinary requests."""
return self

async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
/,
) -> None:
"""Release resources owned by the client without suppressing exceptions."""
result = self.close()
if isawaitable(result):
await result
Comment thread
eavanvalkenburg marked this conversation as resolved.
Resolved

async def close(self) -> None:
"""Release client-owned connections and credentials.

The default implementation does nothing. Resource-owning implementations
override this method and must leave caller-supplied resources open.
Context exit also accepts legacy synchronous ``close()`` overrides.
Clients remain usable without entering an async context; callers can use
``await client.close()`` to release their resources explicitly.
"""

def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
"""Convert the instance to a dictionary.

Expand Down
51 changes: 49 additions & 2 deletions python/packages/core/tests/core/test_agents.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.

from __future__ import annotations

import asyncio
import contextlib
import inspect
Expand Down Expand Up @@ -101,7 +103,7 @@ def __init__(self, events: list[str]) -> None:
self.events = events
self.exit_exception_type: type[BaseException] | None = None

async def __aenter__(self) -> "_LifecycleClient":
async def __aenter__(self) -> _LifecycleClient:
self.events.append("client.enter")
return self

Expand All @@ -117,7 +119,7 @@ def __init__(self, name: str, events: list[str], *, fail_on_enter: bool = False)
self.fail_on_enter = fail_on_enter
self.exit_exception_type: type[BaseException] | None = None

async def __aenter__(self) -> "_LifecycleMCPTool":
async def __aenter__(self) -> _LifecycleMCPTool:
self.events.append(f"{self.name}.enter")
if self.fail_on_enter:
raise RuntimeError("MCP entry failed")
Expand Down Expand Up @@ -220,6 +222,51 @@ async def test_agent_explicit_open_close(agent_type: type[RawAgent]) -> None:
assert events == ["client.enter", "mcp.enter", "mcp.exit", "client.exit"]


@pytest.mark.parametrize("agent_type", [Agent, RawAgent])
@pytest.mark.parametrize("fail_on_enter", [False, True])
async def test_agent_manages_inherited_client_context(
agent_type: type[RawAgent], fail_on_enter: bool, chat_client_base: MockBaseChatClient
) -> None:
events: list[str] = []
mcp_tool = _LifecycleMCPTool("mcp", events, fail_on_enter=fail_on_enter)
agent = agent_type(client=chat_client_base, tools=[mcp_tool])
with patch.object(chat_client_base, "close", new_callable=AsyncMock) as close:
if fail_on_enter:
with pytest.raises(RuntimeError, match="MCP entry failed"):
await agent.open()
else:
async with agent:
close.assert_not_awaited()
close.assert_awaited_once_with()
await agent.close()
close.assert_awaited_once_with()


async def test_agent_still_accepts_client_without_lifecycle(client: SupportsChatGetResponse) -> None:
assert not hasattr(client, "__aenter__")
async with RawAgent(client=client) as agent:
response = await agent.run("Hello")
assert response.text == "test response"


@pytest.mark.parametrize("agent_type", [Agent, RawAgent])
@pytest.mark.parametrize("error", [None, RuntimeError("body failed")])
async def test_agent_context_accepts_client_with_sync_close(
agent_type: type[RawAgent], chat_client_base: MockBaseChatClient, error: RuntimeError | None
) -> None:
with patch.object(chat_client_base, "close", new_callable=MagicMock, return_value=None) as close:
agent = agent_type(client=chat_client_base)
if error is None:
async with agent:
close.assert_not_called()
else:
with pytest.raises(RuntimeError) as raised:
async with agent:
raise error
assert raised.value is error
Comment thread
eavanvalkenburg marked this conversation as resolved.
Resolved
close.assert_called_once_with()


@pytest.mark.parametrize("agent_type", [Agent, RawAgent])
async def test_agent_async_with_delegates_to_open_close(agent_type: type[RawAgent]) -> None:
events: list[str] = []
Expand Down
Loading
Loading