diff --git a/python/packages/core/agent_framework/_feature_stage.py b/python/packages/core/agent_framework/_feature_stage.py index 90235b0232..609bd5d809 100644 --- a/python/packages/core/agent_framework/_feature_stage.py +++ b/python/packages/core/agent_framework/_feature_stage.py @@ -52,6 +52,7 @@ class ExperimentalFeature(str, Enum): FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS" HARNESS = "HARNESS" SKILLS = "SKILLS" + TO_PROMPT_AGENT = "TO_PROMPT_AGENT" class ReleaseCandidateFeature(str, Enum): diff --git a/python/packages/core/agent_framework/foundry/__init__.py b/python/packages/core/agent_framework/foundry/__init__.py index 82a476ddff..2603f70f13 100644 --- a/python/packages/core/agent_framework/foundry/__init__.py +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -39,8 +39,10 @@ "RawFoundryAgentChatClient": ("agent_framework_foundry", "agent-framework-foundry"), "RawFoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), "RawFoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"), + "create_prompt_agent": ("agent_framework_foundry", "agent-framework-foundry"), "evaluate_foundry_target": ("agent_framework_foundry", "agent-framework-foundry"), "evaluate_traces": ("agent_framework_foundry", "agent-framework-foundry"), + "to_prompt_agent": ("agent_framework_foundry", "agent-framework-foundry"), } diff --git a/python/packages/core/agent_framework/foundry/__init__.pyi b/python/packages/core/agent_framework/foundry/__init__.pyi index 7deb709c2a..44c8e9445f 100644 --- a/python/packages/core/agent_framework/foundry/__init__.pyi +++ b/python/packages/core/agent_framework/foundry/__init__.pyi @@ -24,8 +24,10 @@ from agent_framework_foundry import ( RawFoundryAgentChatClient, RawFoundryChatClient, RawFoundryEmbeddingClient, + create_prompt_agent, evaluate_foundry_target, evaluate_traces, + to_prompt_agent, ) from agent_framework_foundry_local import ( FoundryLocalChatOptions, @@ -56,6 +58,8 @@ __all__ = [ "RawFoundryAgentChatClient", "RawFoundryChatClient", "RawFoundryEmbeddingClient", + "create_prompt_agent", "evaluate_foundry_target", "evaluate_traces", + "to_prompt_agent", ] diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index 188535cd17..142feb8a37 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -39,3 +39,103 @@ async with Agent( result = await agent.run("What tools are available?") print(result.text) ``` + +## Publishing an agent as a Foundry prompt agent + +> **Experimental — `ExperimentalFeature.TO_PROMPT_AGENT`.** `to_prompt_agent` +> and `create_prompt_agent` are preview APIs and may change before reaching +> GA. The warning fires the first time the `TO_PROMPT_AGENT` feature is +> exercised in a process and is then deduplicated. + +`to_prompt_agent(agent)` converts an `Agent` whose chat client is a +`FoundryChatClient` into a Foundry `PromptAgentDefinition`. The model is read +from `default_options["model"]` first and falls back to the bound +`FoundryChatClient.model` (matching `Agent.__init__`'s resolution order), so +the same agent definition you run locally can be published as a hosted prompt +agent without restating the model deployment name. + +For the common case of "convert and publish in one step", use +`create_prompt_agent(agent, agent_name=...)`. It reuses the bound +`FoundryChatClient`'s project client to call +`project_client.agents.create_version(...)`, so the caller does not need to +construct a separate `AIProjectClient`: + +```python +import asyncio + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient, create_prompt_agent +from azure.identity.aio import AzureCliCredential + + +async def main() -> None: + credential = AzureCliCredential() + project_endpoint = "https://.services.ai.azure.com" + + agent = Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model="gpt-4o", + credential=credential, + ), + name="TravelAgent", + instructions="You are a helpful travel assistant.", + tools=[ + FoundryChatClient.get_web_search_tool(), + FoundryChatClient.get_code_interpreter_tool(), + ], + ) + + created = await create_prompt_agent(agent, agent_name="travel-agent") + print(f"Published {created.name} v{created.version}") + + +asyncio.run(main()) +``` + +Reach for `to_prompt_agent(agent)` directly when you need a standalone +`PromptAgentDefinition` (e.g. to inspect, serialize, or pass to a separately +managed `AIProjectClient`). + +Behaviour: + +- `agent.client` must be a `FoundryChatClient` (or subclass) — otherwise the + converter raises `TypeError`. +- The bound client must have a `model` set — otherwise the converter raises + `ValueError`. +- Foundry SDK tool instances returned by `FoundryChatClient.get_*_tool()` are + passed through unchanged. +- AF `FunctionTool` instances (and `@tool`-decorated callables) are emitted as + Foundry `FunctionTool` **declarations** — the prompt agent receives the + schema only, not the Python implementation. To execute the function when + invoking the deployed prompt agent, connect with `FoundryAgent` and pass the + same callable via `tools=`: + + ```python + from agent_framework.foundry import FoundryAgent + + deployed = FoundryAgent( + project_endpoint=project_endpoint, + agent_name="travel-agent", + credential=credential, + tools=[book_hotel], # same @tool-decorated callable used at publish time + ) + result = await deployed.run("Book me a hotel in Seattle for 3 nights.") + ``` + + `FoundryAgent` runs the function locally when the prompt agent calls it, so + the declaration on the server and the implementation on the client stay in + sync via the shared `@tool` definition. +- Local Agent Framework MCP tools cannot be published as prompt-agent tools — + the converter raises `ValueError` and points at + `FoundryChatClient.get_mcp_tool(...)` for hosted MCP servers. + +See the runnable examples under `samples/02-agents/providers/foundry/`: + +- [`creating_prompt_agents.py`](../../samples/02-agents/providers/foundry/creating_prompt_agents.py) + \u2014 build an Agent, run it locally, and publish it via both + `create_prompt_agent` and `to_prompt_agent` + `AIProjectClient`. +- [`using_prompt_agents.py`](../../samples/02-agents/providers/foundry/using_prompt_agents.py) + \u2014 publish with `create_prompt_agent`, then connect back with + `FoundryAgent` and execute the same local `@tool` callable that the + deployed prompt agent invokes by name. diff --git a/python/packages/foundry/agent_framework_foundry/__init__.py b/python/packages/foundry/agent_framework_foundry/__init__.py index 002e63f8a6..d5fbc6b5fa 100644 --- a/python/packages/foundry/agent_framework_foundry/__init__.py +++ b/python/packages/foundry/agent_framework_foundry/__init__.py @@ -16,6 +16,7 @@ evaluate_traces, ) from ._memory_provider import FoundryMemoryProvider +from ._to_prompt_agent import create_prompt_agent, to_prompt_agent try: __version__ = importlib.metadata.version(__name__) @@ -37,6 +38,8 @@ "RawFoundryChatClient", "RawFoundryEmbeddingClient", "__version__", + "create_prompt_agent", "evaluate_foundry_target", "evaluate_traces", + "to_prompt_agent", ] diff --git a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py new file mode 100644 index 0000000000..99a04d7f07 --- /dev/null +++ b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py @@ -0,0 +1,220 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Convert an Agent Framework agent into a Foundry ``PromptAgentDefinition``. + +The converter accepts an :class:`agent_framework.Agent` whose chat client is a +:class:`agent_framework_foundry.FoundryChatClient` (or a subclass) and returns a +``PromptAgentDefinition`` ready to publish via +``AIProjectClient.agents.create_version(...)``. + +The model is lifted from the bound ``FoundryChatClient`` so the same ``Agent`` +definition used for local execution can be published as a hosted prompt agent +without restating the model deployment name. + +Function tools derived from local Python callables are translated to Foundry +``FunctionTool`` *declarations* only. Prompt agents are server-side, so the +deployed agent will receive the schema for these tools but cannot execute the +underlying Python; wiring server-side execution is the caller's responsibility. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, Any, cast + +from agent_framework import FunctionTool +from agent_framework._feature_stage import ExperimentalFeature, experimental +from agent_framework._mcp import MCPTool + +from ._chat_client import RawFoundryChatClient + +if TYPE_CHECKING: + from agent_framework import Agent + from azure.ai.projects.models import AgentVersionDetails, PromptAgentDefinition, Tool + + +@experimental(feature_id=ExperimentalFeature.TO_PROMPT_AGENT) +def to_prompt_agent(agent: Agent) -> PromptAgentDefinition: + """Convert an ``Agent`` into a Foundry ``PromptAgentDefinition``. + + The agent's chat client must be a :class:`FoundryChatClient` (or any + subclass). The model deployment name is lifted from the bound client. + + Args: + agent: An Agent Framework agent whose client is a ``FoundryChatClient``. + + Returns: + A ``PromptAgentDefinition`` carrying the agent's model, instructions, + and tools. Pass it to ``AIProjectClient.agents.create_version(...)`` + to publish the agent as a prompt agent. + """ + if not isinstance(agent.client, RawFoundryChatClient): + raise TypeError( + "Creating a Foundry Prompt Agent requires an Agent whose client is a FoundryChatClient; " + f"got {type(agent.client).__name__!r}." + ) + + # Match the resolution order Agent.__init__ uses when building default_options: + # an agent-level model override in default_options wins over the bound client's model. + model = agent.default_options.get("model") or agent.client.model + if not model: + raise ValueError( + "Agent has no model. Set 'model' on the FoundryChatClient (via the FOUNDRY_MODEL " + "environment variable or the model= argument), or pass default_options={'model': ...} " + "to the Agent before converting." + ) + + instructions = agent.default_options.get("instructions") + tools = _convert_tools( + agent.default_options.get("tools", []), + getattr(agent, "mcp_tools", []), + ) + + from azure.ai.projects.models import PromptAgentDefinition + + return PromptAgentDefinition( + model=model, + instructions=instructions, + tools=tools or None, + ) + + +@experimental(feature_id=ExperimentalFeature.TO_PROMPT_AGENT) +async def create_prompt_agent( + agent: Agent, + *, + metadata: Mapping[str, str] | None = None, + agent_name: str | None = None, + description: str | None = None, + **kwargs: Any, +) -> AgentVersionDetails: + """Publish an ``Agent`` to Foundry as a new prompt-agent version. + + Convenience wrapper around :func:`to_prompt_agent` that uses the + :class:`FoundryChatClient` already bound to ``agent`` to call + ``project_client.agents.create_version(...)`` \u2014 so the caller does not + need to construct a separate :class:`AIProjectClient`. + + Args: + agent: An Agent Framework agent whose client is a ``FoundryChatClient``. + + Keyword Args: + metadata: Optional metadata dict (up to 16 key/value pairs) attached + to the version. + agent_name: The unique Foundry agent name. Must start and end with + alphanumeric characters, may contain hyphens in the middle, and + must not exceed 63 characters. Defaults to ``agent.name``, + this can be used to override the name set on the agent, in case it does + not adhere to the foundry naming restrictions. + description: Optional human-readable description for the version. + Defaults to ``agent.description``. + **kwargs: Forwarded to ``project_client.agents.create_version(...)``. + + Returns: + The ``AgentVersionDetails`` returned by the Foundry service for the + newly created version. + """ + # to_prompt_agent enforces the FoundryChatClient requirement and model resolution. + definition = to_prompt_agent(agent) + client = cast("RawFoundryChatClient", agent.client) + + resolved_name = agent_name or agent.name + if not resolved_name: + raise ValueError("Foundry agent_name is required. Pass agent_name= or set name= on the Agent.") + + resolved_description = description if description is not None else agent.description + + create_kwargs: dict[str, Any] = dict(kwargs) + if metadata is not None: + create_kwargs["metadata"] = dict(metadata) + if resolved_description is not None: + create_kwargs["description"] = resolved_description + + return await client.project_client.agents.create_version( + agent_name=resolved_name, + definition=definition, + **create_kwargs, + ) + + +def _convert_tools( + tools: Iterable[Any] | None, + mcp_tools: Iterable[MCPTool] | None, +) -> list[Tool]: + """Map AF agent tools to Foundry ``PromptAgentDefinition`` tool entries. + + Tool sources walked, in order: + + * ``agent.default_options["tools"]`` — function tools and hosted Foundry SDK + tool instances (returned by ``FoundryChatClient.get_*_tool()``). + * ``agent.mcp_tools`` — local Agent Framework MCP servers (split off from + the tools list by ``normalize_tools()``). These cannot be published as + prompt-agent tools; the caller must use the hosted MCP factory instead. + + Hosted SDK tool instances are passed through unchanged. Mapping/dict tools + are passed through after light validation. Anything else raises + ``ValueError`` with a message that names the offending type. + """ + from azure.ai.projects.models import Tool as ProjectsTool + + converted: list[Tool] = [] + + for tool_item in tools or (): + if isinstance(tool_item, ProjectsTool): + converted.append(tool_item) + continue + if isinstance(tool_item, FunctionTool): + converted.append(_function_tool_to_foundry(tool_item)) + continue + if isinstance(tool_item, Mapping): + converted.append(_validate_mapping_tool(cast("Mapping[str, Any]", tool_item))) + continue + raise ValueError( + f"Unsupported tool type for PromptAgentDefinition: {type(tool_item).__name__}. " + "Use FoundryChatClient.get_*_tool() helpers, a callable / FunctionTool, " + "or a dict matching the Foundry tool schema." + ) + + for mcp_tool in mcp_tools or (): + raise ValueError( + f"Local MCP tool {mcp_tool.name!r} cannot be published as a prompt-agent tool. " + "Use FoundryChatClient.get_mcp_tool(...) to register a hosted MCP server instead." + ) + + return converted + + +def _function_tool_to_foundry(tool_item: FunctionTool) -> Tool: + """Build a Foundry ``FunctionTool`` declaration from an AF ``FunctionTool``. + + The result carries only the schema (name, description, parameters). It is a + declaration of the tool the prompt agent may call; server-side execution + must be wired separately by the caller. + """ + try: + from azure.ai.projects.models import FunctionTool as ProjectsFunctionTool + except ImportError as exc: # pragma: no cover - sanity guard + raise ImportError( + "FunctionTool is not available in the installed azure-ai-projects. Upgrade azure-ai-projects." + ) from exc + + return ProjectsFunctionTool( + name=tool_item.name, + description=tool_item.description or "", + parameters=tool_item.parameters(), + strict=False, + ) + + +def _validate_mapping_tool(tool_item: Mapping[str, Any]) -> Tool: + """Validate a dict-shaped tool and instantiate a Foundry ``Tool``. + + The Foundry SDK can rehydrate a tool model from its raw JSON mapping via + the discriminator on ``type``. We require the ``type`` field so the + failure mode is obvious; everything else is left to the SDK. + """ + from azure.ai.projects.models import Tool as ProjectsTool + + if "type" not in tool_item: + raise ValueError("Dict-shaped tools must include a 'type' field matching a Foundry tool discriminator.") + return ProjectsTool(**tool_item) diff --git a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py new file mode 100644 index 0000000000..c69c019011 --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py @@ -0,0 +1,401 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from typing import Annotated, Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from agent_framework import Agent, MCPStdioTool, tool +from agent_framework._feature_stage import ExperimentalFeature +from azure.ai.projects.models import ( + CodeInterpreterTool, + PromptAgentDefinition, + WebSearchTool, +) +from azure.ai.projects.models import ( + FunctionTool as ProjectsFunctionTool, +) +from azure.ai.projects.models import ( + MCPTool as FoundryMCPTool, +) +from azure.ai.projects.models import ( + Tool as ProjectsTool, +) + +from agent_framework_foundry import ( + FoundryChatClient, + RawFoundryChatClient, + create_prompt_agent, + to_prompt_agent, +) + + +@tool +def get_weather(location: Annotated[str, "City name"]) -> str: + """Get the weather for a location.""" + return f"sunny in {location}" + + +def _make_foundry_chat_client(model: str | None = "gpt-4o-mini") -> FoundryChatClient: + """Build a FoundryChatClient backed by a mocked project client.""" + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + return FoundryChatClient(project_client=mock_project, model=model or "placeholder") + + +def _make_agent(client: Any, **agent_kwargs: Any) -> Agent: + """Build an Agent without entering the async context manager.""" + return Agent(client=client, **agent_kwargs) + + +def test_to_prompt_agent_minimal() -> None: + """An agent with only model + instructions produces a valid PromptAgentDefinition.""" + agent = _make_agent(_make_foundry_chat_client(), instructions="Be helpful.") + + definition = to_prompt_agent(agent) + + assert isinstance(definition, PromptAgentDefinition) + assert definition.model == "gpt-4o-mini" + assert definition.instructions == "Be helpful." + assert definition.tools is None + + +def test_to_prompt_agent_serializes_cleanly() -> None: + """The PromptAgentDefinition serializes to a dict that includes ``kind: prompt``.""" + agent = _make_agent(_make_foundry_chat_client(), instructions="Hi.") + + payload = to_prompt_agent(agent).as_dict() + + assert payload["model"] == "gpt-4o-mini" + assert payload["instructions"] == "Hi." + assert payload["kind"] == "prompt" + + +def test_to_prompt_agent_rejects_non_foundry_client() -> None: + """A non-FoundryChatClient client raises TypeError.""" + + class NotFoundryChatClient: + """Stand-in for a different chat client implementation.""" + + agent = _make_agent(NotFoundryChatClient()) + + with pytest.raises(TypeError, match="FoundryChatClient"): + to_prompt_agent(agent) + + +def test_to_prompt_agent_rejects_missing_model() -> None: + """When neither default_options nor the client has a model, ValueError is raised.""" + client = _make_foundry_chat_client() + client.model = "" # simulate unset model on the client + agent = _make_agent(client) + agent.default_options.pop("model", None) # and on the agent + + with pytest.raises(ValueError, match="Agent has no model"): + to_prompt_agent(agent) + + +def test_to_prompt_agent_no_instructions() -> None: + """A tool-only agent (no instructions) produces a definition with instructions=None. + + Agent.__init__ strips None values from default_options, so reading + default_options.get("instructions") returns None as expected. + """ + agent = _make_agent( + _make_foundry_chat_client(), + tools=[WebSearchTool()], + ) + + definition = to_prompt_agent(agent) + + assert definition.model == "gpt-4o-mini" + assert definition.instructions is None + payload = definition.as_dict() + # The optional ``instructions`` field is omitted from the serialized output when unset. + assert "instructions" not in payload + + +def test_to_prompt_agent_prefers_default_options_model() -> None: + """default_options['model'] wins over the bound client's model. + + Matches Agent.__init__'s resolution order (_agents.py:740), so the value + the agent actually runs with is the same value the converter publishes. + """ + client = _make_foundry_chat_client(model="client-model") + agent = _make_agent(client, instructions="x", default_options={"model": "agent-override"}) + + definition = to_prompt_agent(agent) + + assert definition.model == "agent-override" + + +def test_to_prompt_agent_falls_back_to_client_model() -> None: + """When the agent has no model override, the bound client's model is used.""" + agent = _make_agent(_make_foundry_chat_client(model="client-model"), instructions="x") + + definition = to_prompt_agent(agent) + + assert definition.model == "client-model" + + +def test_to_prompt_agent_passes_through_sdk_tool_instances() -> None: + """Foundry SDK tool instances (e.g. WebSearchTool) are passed through unchanged.""" + ws = WebSearchTool() + ci = CodeInterpreterTool(container={"type": "auto"}) + agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[ws, ci]) + + definition = to_prompt_agent(agent) + + assert definition.tools is not None + assert len(definition.tools) == 2 + # Pass-through: same object identity + assert definition.tools[0] is ws + assert definition.tools[1] is ci + + +def test_to_prompt_agent_converts_function_tool() -> None: + """An AF FunctionTool from @tool emerges as a Foundry FunctionTool declaration.""" + agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[get_weather]) + + definition = to_prompt_agent(agent) + + assert definition.tools is not None + assert len(definition.tools) == 1 + fn = definition.tools[0] + assert isinstance(fn, ProjectsFunctionTool) + assert fn.name == "get_weather" + assert fn.description == "Get the weather for a location." + assert fn.strict is False + parameters = fn.parameters + assert parameters["type"] == "object" + assert "location" in parameters["properties"] + assert parameters["required"] == ["location"] + + +def test_to_prompt_agent_preserves_mixed_tool_order() -> None: + """A mix of hosted SDK tools and function tools is preserved in definition order.""" + ws = WebSearchTool() + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + tools=[ws, get_weather], + ) + + definition = to_prompt_agent(agent) + + assert definition.tools is not None + assert definition.tools[0] is ws + assert isinstance(definition.tools[1], ProjectsFunctionTool) + assert definition.tools[1].name == "get_weather" + + +def test_to_prompt_agent_passes_through_hosted_mcp_tool() -> None: + """A hosted MCP tool from FoundryChatClient.get_mcp_tool() is passed through.""" + hosted_mcp = FoundryChatClient.get_mcp_tool( + name="github", + url="https://mcp.example.com", + ) + agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[hosted_mcp]) + + definition = to_prompt_agent(agent) + + assert definition.tools is not None + assert len(definition.tools) == 1 + assert isinstance(definition.tools[0], FoundryMCPTool) + + +def test_to_prompt_agent_rejects_local_mcp_tool() -> None: + """A local MCP tool in agent.mcp_tools raises a ValueError pointing at get_mcp_tool.""" + local_mcp = MCPStdioTool(name="local_fs", command="echo") + agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[local_mcp]) + + with pytest.raises(ValueError, match="get_mcp_tool"): + to_prompt_agent(agent) + + +def test_to_prompt_agent_rejects_unknown_tool_type() -> None: + """An arbitrary object in tools that isn't a known shape raises ValueError.""" + + class NotATool: + pass + + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + tools=[NotATool()], + ) + + with pytest.raises(ValueError, match="NotATool"): + to_prompt_agent(agent) + + +def test_to_prompt_agent_accepts_dict_tool() -> None: + """A dict with a 'type' discriminator is rehydrated through the SDK Tool model.""" + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + tools=[{"type": "web_search"}], + ) + + definition = to_prompt_agent(agent) + + assert definition.tools is not None + assert len(definition.tools) == 1 + tool_obj = definition.tools[0] + assert isinstance(tool_obj, ProjectsTool) + assert tool_obj.type == "web_search" + + +def test_to_prompt_agent_rejects_dict_tool_without_type() -> None: + """A dict missing the 'type' field raises ValueError.""" + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + tools=[{"name": "missing_type"}], + ) + + with pytest.raises(ValueError, match="type"): + to_prompt_agent(agent) + + +def test_to_prompt_agent_works_with_raw_foundry_chat_client() -> None: + """to_prompt_agent accepts subclasses too — RawFoundryChatClient works.""" + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + raw_client = RawFoundryChatClient(project_client=mock_project, model="gpt-4o") + agent = _make_agent(raw_client, instructions="x") + + definition = to_prompt_agent(agent) + + assert definition.model == "gpt-4o" + + +def test_to_prompt_agent_is_marked_experimental() -> None: + """to_prompt_agent carries the TO_PROMPT_AGENT experimental metadata.""" + assert getattr(to_prompt_agent, "__feature_stage__", None) == "experimental" + assert getattr(to_prompt_agent, "__feature_id__", None) == ExperimentalFeature.TO_PROMPT_AGENT.value + + +def _make_foundry_chat_client_with_async_agents_ops( + model: str | None = "gpt-4o-mini", +) -> tuple[FoundryChatClient, AsyncMock]: + """Build a FoundryChatClient backed by a mocked project client whose ``agents.create_version`` is awaitable.""" + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + create_version = AsyncMock(return_value=MagicMock(name="travel-agent", version="1")) + mock_project.agents = MagicMock(create_version=create_version) + client = FoundryChatClient(project_client=mock_project, model=model or "placeholder") + return client, create_version + + +async def test_create_prompt_agent_publishes_definition() -> None: + """create_prompt_agent calls project_client.agents.create_version with the converted definition.""" + client, create_version = _make_foundry_chat_client_with_async_agents_ops() + agent = _make_agent(client, instructions="x", tools=[WebSearchTool()]) + + result = await create_prompt_agent(agent, agent_name="travel-agent") + + create_version.assert_awaited_once() + call_kwargs = create_version.await_args.kwargs + assert call_kwargs["agent_name"] == "travel-agent" + definition = call_kwargs["definition"] + assert isinstance(definition, PromptAgentDefinition) + assert definition.model == "gpt-4o-mini" + assert definition.tools is not None and len(definition.tools) == 1 + assert "metadata" not in call_kwargs + assert "description" not in call_kwargs + assert result is create_version.return_value + + +async def test_create_prompt_agent_defaults_name_and_description_from_agent() -> None: + """When the Agent has name/description, the helper lifts them so the call site stays minimal.""" + client, create_version = _make_foundry_chat_client_with_async_agents_ops() + agent = _make_agent( + client, + instructions="x", + name="travel-agent", + description="Helps Contoso employees book travel.", + ) + + await create_prompt_agent(agent) + + call_kwargs = create_version.await_args.kwargs + assert call_kwargs["agent_name"] == "travel-agent" + assert call_kwargs["description"] == "Helps Contoso employees book travel." + + +async def test_create_prompt_agent_explicit_overrides_win() -> None: + """Explicit agent_name and description kwargs override the values from the Agent.""" + client, create_version = _make_foundry_chat_client_with_async_agents_ops() + agent = _make_agent( + client, + instructions="x", + name="travel-agent", + description="Agent-level description", + ) + + await create_prompt_agent( + agent, + agent_name="travel-agent-v2", + description="Override description", + ) + + call_kwargs = create_version.await_args.kwargs + assert call_kwargs["agent_name"] == "travel-agent-v2" + assert call_kwargs["description"] == "Override description" + + +async def test_create_prompt_agent_requires_an_agent_name() -> None: + """If neither agent_name nor agent.name is set, a ValueError is raised before any service call.""" + client, create_version = _make_foundry_chat_client_with_async_agents_ops() + agent = _make_agent(client, instructions="x") + agent.name = None # mirror an Agent constructed without a name + + with pytest.raises(ValueError, match="agent_name"): + await create_prompt_agent(agent) + create_version.assert_not_awaited() + + +async def test_create_prompt_agent_forwards_metadata_and_description() -> None: + """Optional metadata + description land on the create_version call.""" + client, create_version = _make_foundry_chat_client_with_async_agents_ops() + agent = _make_agent(client, instructions="x") + + await create_prompt_agent( + agent, + agent_name="travel-agent", + metadata={"env": "prod"}, + description="Production travel agent", + ) + + call_kwargs = create_version.await_args.kwargs + assert call_kwargs["metadata"] == {"env": "prod"} + assert call_kwargs["description"] == "Production travel agent" + + +async def test_create_prompt_agent_forwards_extra_kwargs() -> None: + """Extra keyword args fall through to project_client.agents.create_version.""" + client, create_version = _make_foundry_chat_client_with_async_agents_ops() + agent = _make_agent(client, instructions="x") + + await create_prompt_agent(agent, agent_name="travel-agent", headers={"x-trace": "abc"}) + + assert create_version.await_args.kwargs["headers"] == {"x-trace": "abc"} + + +async def test_create_prompt_agent_rejects_non_foundry_client() -> None: + """A non-FoundryChatClient client raises TypeError before any service call.""" + + class NotFoundryChatClient: + """Stand-in for a different chat client implementation.""" + + agent = _make_agent(NotFoundryChatClient()) + + with pytest.raises(TypeError, match="FoundryChatClient"): + await create_prompt_agent(agent, agent_name="travel-agent") + + +def test_create_prompt_agent_is_marked_experimental() -> None: + """create_prompt_agent carries the TO_PROMPT_AGENT experimental metadata.""" + assert getattr(create_prompt_agent, "__feature_stage__", None) == "experimental" + assert getattr(create_prompt_agent, "__feature_id__", None) == ExperimentalFeature.TO_PROMPT_AGENT.value diff --git a/python/samples/02-agents/providers/foundry/creating_prompt_agents.py b/python/samples/02-agents/providers/foundry/creating_prompt_agents.py new file mode 100644 index 0000000000..268856efab --- /dev/null +++ b/python/samples/02-agents/providers/foundry/creating_prompt_agents.py @@ -0,0 +1,109 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient, create_prompt_agent, to_prompt_agent +from azure.ai.projects.aio import AIProjectClient +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +load_dotenv() + +""" +Foundry Prompt Agent Example + +This sample demonstrates how a single Agent definition can be both: + +1. Run locally via the Foundry Responses API (``agent.run(...)``). +2. Published in one step via ``create_prompt_agent(agent)``, which reuses + the FoundryChatClient's project client and lifts ``agent_name`` / + ``description`` from the Agent itself, the recommended path. +3. Published in two steps via ``to_prompt_agent(agent)`` plus + ``AIProjectClient.agents.create_version(...)`` for when you need a + standalone definition you can inspect, serialize, or pass to a separately + managed ``AIProjectClient``. + +The model is lifted from the bound ``FoundryChatClient`` so the agent's +``model``/``instructions``/``tools`` stay as the single source of truth. + +``to_prompt_agent`` and ``create_prompt_agent`` are experimental +(``ExperimentalFeature.TO_PROMPT_AGENT``) and may change before reaching GA. + +Function tools defined in this file are exposed to the prompt agent as +*declarations only*; the deployed agent receives the schema but cannot execute +the local Python. Wire server-side execution separately if you need it. +""" + + +@tool +def book_hotel( + city: Annotated[str, Field(description="The city to book the hotel in.")], + nights: Annotated[int, Field(description="Number of nights to stay.")], +) -> str: + """Book a hotel room for the given city and number of nights.""" + return f"Booked a hotel in {city} for {nights} nights. Confirmation #CTX-{randint(1000, 9999)}." + + +async def main() -> None: + print("=== Foundry Prompt Agent Example ===\n") + + credential = AzureCliCredential() + + agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ), + # The Agent is the single source of truth for the agent that gets published: + # `name` becomes the Foundry agent name and `description` becomes the version description. + # Neither needs to be restated below. + name="travel-agent", + description="Helps Contoso employees book travel.", + instructions="You are a helpful travel assistant. Use the booking tool when asked.", + tools=[ + FoundryChatClient.get_web_search_tool(), + FoundryChatClient.get_code_interpreter_tool(), + book_hotel, + ], + ) + + # 1) Run locally via the Foundry Responses API + local_query = "Book me a hotel in Seattle for 3 nights." + print(f"User (local run): {local_query}") + response = await agent.run(local_query) + print(f"Agent: {response}\n") + + # 2) Recommended: one-step deploy. `create_prompt_agent` reuses the FoundryChatClient's + # project client AND lifts `agent_name` / `description` from the Agent itself, so the call + # site stays minimal. `metadata` and any extra kwargs fall through to + # AIProjectClient.agents.create_version. + created = await create_prompt_agent(agent) + print(f"Prompt agent published via create_prompt_agent: {created.name} v{created.version}") + + # 3) Two-step alternative: use `to_prompt_agent` when you want a standalone definition you + # can inspect, serialize, or pass to a separately managed AIProjectClient. + definition = to_prompt_agent(agent) + project_client = AIProjectClient( + endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + credential=credential, + ) + created_v2 = await project_client.agents.create_version( + agent_name=agent.name, + definition=definition, + description=agent.description, + ) + print(f"Prompt agent published via to_prompt_agent: {created_v2.name} v{created_v2.version}") + + # 4) Cleanup: delete the agent (and all its versions) so re-running the sample stays idempotent. + await project_client.agents.delete(agent_name=agent.name) + print(f"Deleted prompt agent {agent.name!r} and all its versions.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/foundry/using_prompt_agents.py b/python/samples/02-agents/providers/foundry/using_prompt_agents.py new file mode 100644 index 0000000000..c39dfa6c5e --- /dev/null +++ b/python/samples/02-agents/providers/foundry/using_prompt_agents.py @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryAgent, FoundryChatClient, create_prompt_agent +from azure.ai.projects.aio import AIProjectClient +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +load_dotenv() + +""" +Foundry Prompt Agent: Deploy, then Connect and Run + +This sample shows the end-to-end loop: + +1. Build an ``Agent`` backed by ``FoundryChatClient`` with a local ``@tool`` + function and Foundry-hosted tools. +2. Publish it to Foundry as a prompt agent via ``create_prompt_agent``. +3. Connect to the deployed prompt agent with ``FoundryAgent`` and pass the + *same* ``book_hotel`` callable through ``tools=`` so the server-side + prompt agent and the client share a single tool definition. + +The Foundry prompt agent only receives the ``book_hotel`` *declaration* (its +JSON schema). When the deployed agent decides to call the tool, ``FoundryAgent`` +executes the local Python implementation by matching tool names \u2014 keeping the +schema on the server and the implementation on the client in sync. + +``create_prompt_agent`` is experimental +(``ExperimentalFeature.TO_PROMPT_AGENT``) and may change before reaching GA. +""" + + +@tool +def book_hotel( + city: Annotated[str, Field(description="The city to book the hotel in.")], + nights: Annotated[int, Field(description="Number of nights to stay.")], +) -> str: + """Book a hotel room for the given city and number of nights.""" + return f"Booked a hotel in {city} for {nights} nights. Confirmation #CTX-{randint(1000, 9999)}." + + +async def main() -> None: + print("=== Foundry Prompt Agent: Deploy and Run ===\n") + + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ["FOUNDRY_MODEL"] + credential = AzureCliCredential() + + # 1) Define the Agent. `name` / `description` set here become the Foundry agent identity + # on publish; `book_hotel` is the local implementation that backs the published declaration. + agent = Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=credential, + ), + name="travel-agent", + description="Helps Contoso employees book travel.", + instructions="You are a helpful travel assistant. Use the booking tool when asked.", + tools=[ + FoundryChatClient.get_web_search_tool(), + book_hotel, + ], + ) + + # 2) Publish as a prompt agent. The version returned by Foundry includes the version label + # we need when connecting back to that specific deployment. + created = await create_prompt_agent(agent) + print(f"Published prompt agent: {created.name} v{created.version}\n") + + # 3) Connect to the deployed prompt agent with FoundryAgent and pass the *same* callable. + # FoundryAgent runs the local function when the server-side agent invokes the tool, + # matching by name. + deployed = FoundryAgent( + project_endpoint=project_endpoint, + agent_name=created.name, + agent_version=created.version, + credential=credential, + tools=[book_hotel], + ) + + query = "Book me a hotel in Seattle for 3 nights." + print(f"User: {query}") + result = await deployed.run(query) + print(f"Agent: {result}") + + # 4) Cleanup: delete the deployed prompt agent (and all its versions) so re-running the + # sample stays idempotent. + project_client = AIProjectClient(endpoint=project_endpoint, credential=credential) + await project_client.agents.delete(agent_name=created.name) + print(f"\nDeleted prompt agent {created.name!r} and all its versions.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/uv.lock b/python/uv.lock index 1b932afa8c..b6436f951b 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -603,7 +603,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" }, ] [[package]]