diff --git a/python/packages/main/agent_framework/_clients.py b/python/packages/main/agent_framework/_clients.py index 4f37e0f516..69131fbffd 100644 --- a/python/packages/main/agent_framework/_clients.py +++ b/python/packages/main/agent_framework/_clients.py @@ -4,7 +4,7 @@ import asyncio from abc import ABC, abstractmethod from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, MutableSequence, Sequence from functools import wraps -from typing import Any, Generic, Literal, Protocol, TypeVar, runtime_checkable +from typing import TYPE_CHECKING, Any, Generic, Literal, Protocol, TypeVar, runtime_checkable from pydantic import BaseModel @@ -23,6 +23,9 @@ from ._types import ( GeneratedEmbeddings, ) +if TYPE_CHECKING: + from ._agents import ChatClientAgent + TInput = TypeVar("TInput", contravariant=True) TEmbedding = TypeVar("TEmbedding") TChatClientBase = TypeVar("TChatClientBase", bound="ChatClientBase") @@ -641,6 +644,36 @@ class ChatClientBase(AFBaseModel, ABC): """ return None + def create_agent( + self, + *, + name: str, + instructions: str, + tools: AITool + | list[AITool] + | Callable[..., Any] + | list[Callable[..., Any]] + | MutableMapping[str, Any] + | list[MutableMapping[str, Any]] + | None = None, + **kwargs: Any, + ) -> "ChatClientAgent": + """Create an agent with the given name and instructions. + + Args: + name: The name of the agent. + instructions: The instructions for the agent. + tools: Optional list of tools to associate with the agent. + **kwargs: Additional keyword arguments to pass to the agent. + See ChatClientAgent for all the available options. + + Returns: + An instance of ChatClientAgent. + """ + from ._agents import ChatClientAgent + + return ChatClientAgent(chat_client=self, name=name, instructions=instructions, tools=tools, **kwargs) + # region: Embedding Client diff --git a/python/packages/main/agent_framework/_tools.py b/python/packages/main/agent_framework/_tools.py index a24b48d2f7..5bea36015d 100644 --- a/python/packages/main/agent_framework/_tools.py +++ b/python/packages/main/agent_framework/_tools.py @@ -4,10 +4,10 @@ import inspect from collections.abc import Awaitable, Callable from functools import wraps from time import perf_counter -from typing import Any, Generic, Protocol, TypeVar, runtime_checkable +from typing import Annotated, Any, Generic, Protocol, TypeVar, get_args, get_origin, runtime_checkable from opentelemetry import metrics, trace -from pydantic import BaseModel, create_model +from pydantic import BaseModel, Field, create_model from ._logging import get_logger from .telemetry import GenAIAttributes, start_as_current_span @@ -137,6 +137,25 @@ class AIFunction(AITool, Generic[ArgsT, ReturnT]): logger.info("Function completed. Duration: %fs", duration) +def _parse_annotation(annotation: Any) -> Any: + """Parse a type annotation and return the corresponding type. + + If the second annotation (after the type) is a string, then we convert that to a pydantic Field description. + The rest are returned as-is, allowing for multiple annotations. + """ + origin = get_origin(annotation) + if origin is not None: + args = get_args(annotation) + # For other generics, return the origin type (e.g., list for List[int]) + if len(args) > 1 and isinstance(args[1], str): + # Create a new Annotated type with the updated Field + args_list = list(args) + if len(args_list) == 2: + return Annotated[args_list[0], Field(description=args_list[1])] + return Annotated[args_list[0], Field(description=args_list[1]), tuple(args_list[2:])] + return annotation + + def ai_function( func: Callable[..., ReturnT | Awaitable[ReturnT]] | None = None, *, @@ -174,7 +193,7 @@ def ai_function( sig = inspect.signature(f) fields = { pname: ( - param.annotation if param.annotation is not inspect.Parameter.empty else str, + _parse_annotation(param.annotation) if param.annotation is not inspect.Parameter.empty else str, param.default if param.default is not inspect.Parameter.empty else ..., ) for pname, param in sig.parameters.items() diff --git a/python/samples/getting_started/agents/foundry/foundry_basic.py b/python/samples/getting_started/agents/foundry/foundry_basic.py index 3e58d7d617..dd91b736c5 100644 --- a/python/samples/getting_started/agents/foundry/foundry_basic.py +++ b/python/samples/getting_started/agents/foundry/foundry_basic.py @@ -4,7 +4,6 @@ import asyncio from random import randint from typing import Annotated -from agent_framework import ChatClientAgent from agent_framework.foundry import FoundryChatClient from pydantic import Field @@ -23,8 +22,8 @@ async def non_streaming_example() -> None: # Since no Agent ID is provided, the agent will be automatically created # and deleted after getting a response - async with ChatClientAgent( - chat_client=FoundryChatClient(), + async with FoundryChatClient().create_agent( + name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) as agent: @@ -40,8 +39,8 @@ async def streaming_example() -> None: # Since no Agent ID is provided, the agent will be automatically created # and deleted after getting a response - async with ChatClientAgent( - chat_client=FoundryChatClient(), + async with FoundryChatClient().create_agent( + name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) as agent: diff --git a/python/samples/getting_started/agents/openai_chat_client/openai_chat_client_basic.py b/python/samples/getting_started/agents/openai_chat_client/openai_chat_client_basic.py index 41ed787aa5..d7b7e2fea1 100644 --- a/python/samples/getting_started/agents/openai_chat_client/openai_chat_client_basic.py +++ b/python/samples/getting_started/agents/openai_chat_client/openai_chat_client_basic.py @@ -4,13 +4,11 @@ import asyncio from random import randint from typing import Annotated -from agent_framework import ChatClientAgent from agent_framework.openai import OpenAIChatClient -from pydantic import Field def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], + location: Annotated[str, "The location to get the weather for."], ) -> str: """Get the weather for a given location.""" conditions = ["sunny", "cloudy", "rainy", "stormy"] @@ -21,8 +19,8 @@ async def non_streaming_example() -> None: """Example of non-streaming response (get the complete result at once).""" print("=== Non-streaming Response Example ===") - agent = ChatClientAgent( - chat_client=OpenAIChatClient(), + agent = OpenAIChatClient().create_agent( + name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -37,8 +35,8 @@ async def streaming_example() -> None: """Example of streaming response (get results as they are generated).""" print("=== Streaming Response Example ===") - agent = ChatClientAgent( - chat_client=OpenAIChatClient(), + agent = OpenAIChatClient().create_agent( + name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) diff --git a/python/samples/getting_started/minimal_sample.py b/python/samples/getting_started/minimal_sample.py new file mode 100644 index 0000000000..0bec88570a --- /dev/null +++ b/python/samples/getting_started/minimal_sample.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from random import randint +from typing import Annotated + +from agent_framework.openai import OpenAIChatClient + + +def get_weather( + location: Annotated[str, "The location to get the weather for."], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +agent = OpenAIChatClient().create_agent( + name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather +) +print(asyncio.run(agent.run("What's the weather like in Seattle?")))