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
7 changes: 6 additions & 1 deletion python/packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,18 @@ agent_framework/
display a mask. String-only APIs such as `str.join()` and JSON encoding reject it unless callers explicitly
convert it. Use `get_secret_value()` when passing credentials to provider SDKs; `str(secret)` returns the mask.
- **`load_settings`** accepts plain string overrides for `SecretString` fields and wraps them, as it does for
environment and `.env` values. Existing `SecretString` overrides are preserved.
environment and `.env` values. Existing `SecretString` overrides are preserved. Invalid supplied numeric and
boolean environment or `.env` values raise `ValueError` identifying the field and source instead of falling back
to raw strings.

### Agents (`_agents.py`)

- **`SupportsAgentRun`** - Protocol defining the agent interface
- **`BaseAgent`** - Abstract base class for agents
- **`Agent`** - Main agent class wrapping a chat client with tools, instructions, and middleware
- **`RawAgent.open()` / `close()`** (inherited by `Agent`) - Explicitly enter the client's and configured MCP tools'
async contexts, then release them along with lazily connected MCP tools. `async with agent` delegates to these
methods; a partially failed `open()` closes resources already entered.

### Chat Clients (`_clients.py`)

Expand Down
72 changes: 43 additions & 29 deletions python/packages/core/agent_framework/_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -892,8 +892,7 @@ def get_weather(location: str) -> str:
name="weather-agent",
instructions="You are a weather assistant.",
tools=get_weather,
temperature=0.7,
max_tokens=500,
default_options={"temperature": 0.7, "max_tokens": 500},
)

# Use streaming responses
Expand All @@ -912,20 +911,30 @@ def get_weather(location: str) -> str:
client = OpenAIChatClient(model="gpt-4o")
agent: Agent[OpenAIChatOptions] = Agent(
client=client,
name="reasoning-agent",
instructions="You are a reasoning assistant.",
options={
name="typed-agent",
instructions="You are a helpful assistant.",
default_options={
"temperature": 0.7,
"max_tokens": 500,
"reasoning_effort": "high", # OpenAI-specific, IDE will autocomplete!
"include": ["message.output_text.logprobs"], # OpenAI-specific option
},
)

# Or pass options at runtime
# Or override default options at runtime
response = await agent.run(
"What is 25 * 47?",
options={"temperature": 0.0, "logprobs": True},
options={"temperature": 0.0},
)

Explicit resource management (equivalent to ``async with agent``):

.. code-block:: python

await agent.open()
try:
response = await agent.run("Hello")
finally:
await agent.close()
"""

AGENT_PROVIDER_NAME: ClassVar[str] = "microsoft.agent_framework"
Expand Down Expand Up @@ -1049,23 +1058,29 @@ def __init__(
self._async_exit_stack = AsyncExitStack()
self._update_agent_name_and_description()

async def __aenter__(self) -> Self:
"""Enter the async context manager.
async def open(self) -> Self:
"""Open the client's and configured MCP tools' async contexts.

If any of the client or local_mcp_tools are context managers,
they will be entered into the async exit stack to ensure proper cleanup.

Note:
This list might be extended in the future.
Call ``await agent.close()`` when finished, or use ``async with agent``
to open and close automatically. Already-entered contexts are closed if
a later context fails to open.

Returns:
The Agent instance.
The agent instance.
"""
for context_manager in chain([self.client], self.mcp_tools):
if isinstance(context_manager, AbstractAsyncContextManager):
await self._async_exit_stack.enter_async_context(context_manager)
try:
for context_manager in chain([self.client], self.mcp_tools):
if isinstance(context_manager, AbstractAsyncContextManager):
await self._async_exit_stack.enter_async_context(context_manager)
except BaseException:
await self.close()
raise
return self

async def __aenter__(self) -> Self:
"""Enter the async context manager by opening managed resources."""
return await self.open()

def _get_history_providers(self) -> list[HistoryProvider]:
return [provider for provider in self.context_providers if isinstance(provider, HistoryProvider)]

Expand Down Expand Up @@ -1095,22 +1110,18 @@ def _resolve_per_service_call_history_providers(
)
return history_providers

async def close(self) -> None:
"""Close managed client and MCP contexts, including lazily opened MCP tools."""
await self._async_exit_stack.aclose()

async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
"""Exit the async context manager.

Close the async exit stack to ensure all context managers are exited properly.

Args:
exc_type: The exception type if an exception was raised, None otherwise.
exc_val: The exception value if an exception was raised, None otherwise.
exc_tb: The exception traceback if an exception was raised, None otherwise.
"""
await self._async_exit_stack.aclose()
"""Exit the async context manager by closing managed resources."""
await self.close()

def _update_agent_name_and_description(self) -> None:
"""Update the agent name in the chat client.
Expand Down Expand Up @@ -1944,6 +1955,9 @@ class Agent(
- OpenTelemetry-based telemetry for observability

For a minimal implementation without these features, use :class:`RawAgent`.

Use ``await agent.open()`` and ``await agent.close()`` for explicit resource
management, or ``async with agent`` to close managed resources automatically.
"""

@overload
Expand Down
60 changes: 46 additions & 14 deletions python/packages/core/agent_framework/_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class MySettings(TypedDict, total=False):
import sys
from collections.abc import Callable, Sequence
from contextlib import suppress
from typing import Any, Union, get_args, get_origin, get_type_hints
from typing import Any, Literal, Union, cast, get_args, get_origin, get_type_hints

from dotenv import dotenv_values

Expand Down Expand Up @@ -156,9 +156,25 @@ def _coerce_value(value: str, target_type: type) -> Any:
if args and type(None) in args:
for arg in args:
if arg is not type(None):
if arg is Any:
return value
runtime_type = _runtime_class(arg)
if runtime_type is None and get_origin(arg) is not Literal:
continue
with suppress(ValueError, TypeError):
return _coerce_value(value, arg)
return value
coerced = _coerce_value(value, arg)
if runtime_type is None or isinstance(coerced, runtime_type):
return coerced
raise ValueError("Value cannot be converted to any allowed type.")

if origin is Literal:
for choice in args:
choice_type = cast(type[Any], type(choice))
with suppress(ValueError, TypeError):
coerced = _coerce_value(value, choice_type)
if type(coerced) is choice_type and coerced == choice:
return coerced
raise ValueError("Value is not an allowed literal.")

# Handle SecretString
if target_type is SecretString or (isinstance(target_type, type) and issubclass(target_type, SecretString)):
Expand All @@ -172,11 +188,25 @@ def _coerce_value(value: str, target_type: type) -> Any:
if target_type is float:
return float(value)
if target_type is bool:
return value.lower() in ("true", "1", "yes", "on")
normalized = value.strip().lower()
if normalized in ("true", "1", "yes", "on"):
return True
if normalized in ("false", "0", "no", "off"):
return False
raise ValueError("Value is not a recognized boolean.")

return value


def _coerce_from_source(value: str, target_type: type, *, field_name: str, source: str) -> Any:
"""Coerce a supplied value or identify the setting and source on failure."""
try:
return _coerce_value(value, target_type)
except (ValueError, TypeError):
pass
raise ValueError(f"Invalid value for setting '{field_name}' from {source}.")


def _runtime_class(annotation: Any) -> type | None:
"""Return the class ``isinstance`` can test *annotation* against, or ``None``.

Expand All @@ -185,6 +215,8 @@ def _runtime_class(annotation: Any) -> type | None:
is always preferred. Annotations without a runtime class, such as ``Literal[...]``,
return ``None`` so callers can skip validation instead of guessing.
"""
if annotation is Any:
return None
origin = get_origin(annotation)
candidate = annotation if origin is None else origin
return candidate if isinstance(candidate, type) else None
Expand Down Expand Up @@ -245,7 +277,8 @@ def _check_override_type(value: Any, field_type: type, field_name: str) -> None:

allowed_names = ", ".join(t.__name__ for t in allowed)
raise ValueError(
f"Invalid type for setting '{field_name}': expected {allowed_names}, got {type(value).__name__}."
f"Invalid type for setting '{field_name}' from explicit override: "
f"expected {allowed_names}, got {type(value).__name__}."
)


Expand Down Expand Up @@ -293,7 +326,8 @@ def load_settings(
FileNotFoundError: If *env_file_path* was provided but the file does not exist.
SettingNotFoundError: If a required field could not be resolved from any
source, or if a mutually exclusive constraint is violated.
ValueError: If an override value has an incompatible type.
ValueError: If an override value has an incompatible type or an environment
or ``.env`` value cannot be coerced to the setting's type.
"""
encoding = env_file_encoding or "utf-8"

Expand Down Expand Up @@ -332,19 +366,17 @@ def load_settings(
if loaded_dotenv_values:
dotenv_value = loaded_dotenv_values.get(env_var_name)
if dotenv_value is not None:
try:
result[field_name] = _coerce_value(dotenv_value, field_type)
except (ValueError, TypeError):
result[field_name] = dotenv_value
result[field_name] = _coerce_from_source(
dotenv_value, field_type, field_name=field_name, source=f".env file '{env_file_path}'"
)
continue

# 3. Environment variable
env_value = os.getenv(env_var_name)
if env_value is not None:
try:
result[field_name] = _coerce_value(env_value, field_type)
except (ValueError, TypeError):
result[field_name] = env_value
result[field_name] = _coerce_from_source(
env_value, field_type, field_name=field_name, source=f"environment variable '{env_var_name}'"
)
continue

# 4. Default from TypedDict class-level defaults, or None for optional fields
Expand Down
Loading
Loading