From fda73b7c2a315c6f11dedca207bce5b102653e6c Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 19 May 2026 20:30:41 +0200 Subject: [PATCH 01/20] feat(foundry): add experimental to_prompt_agent converter Adds `to_prompt_agent(agent)`, an experimental converter (`ExperimentalFeature.TO_PROMPT_AGENT`) that turns an Agent Framework `Agent` into a Foundry `PromptAgentDefinition` ready to publish via `AIProjectClient.agents.create_version(...)`. Behaviour: * `agent.client` must be a `FoundryChatClient` (or subclass); otherwise `TypeError` is raised. The model deployment name is lifted from the bound client so the same Agent definition used for local runs can be published as a hosted prompt agent without restating the model. * Foundry SDK tool instances (from `FoundryChatClient.get_*_tool()`) are passed through unchanged. AF `FunctionTool`s (and `@tool`-decorated callables) are emitted as Foundry `FunctionTool` declarations. * Local AF MCP tools cannot be expressed in a `PromptAgentDefinition`; the converter raises `ValueError` and points at `FoundryChatClient.get_mcp_tool()` for hosted MCP servers. * The converter walks both `agent.default_options["tools"]` and `agent.mcp_tools` because `normalize_tools()` splits local MCP off into its own list. Re-exported through the `agent_framework.foundry` lazy-loading namespace (updates both `__init__.py` and the `__init__.pyi` type stub). Adds a portable-agent sample showing the same `Agent` driven through both `agent.run(...)` and `to_prompt_agent(agent)`, and a README section covering the new converter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/_feature_stage.py | 1 + .../core/agent_framework/foundry/__init__.py | 1 + .../core/agent_framework/foundry/__init__.pyi | 2 + python/packages/foundry/README.md | 74 ++++++ .../agent_framework_foundry/__init__.py | 2 + .../_to_prompt_agent.py | 179 ++++++++++++++ .../tests/foundry/test_to_prompt_agent.py | 227 ++++++++++++++++++ .../foundry/foundry_portable_agent.py | 96 ++++++++ 8 files changed, 582 insertions(+) create mode 100644 python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py create mode 100644 python/packages/foundry/tests/foundry/test_to_prompt_agent.py create mode 100644 python/samples/02-agents/providers/foundry/foundry_portable_agent.py diff --git a/python/packages/core/agent_framework/_feature_stage.py b/python/packages/core/agent_framework/_feature_stage.py index b80f5d54ed..55d4ac1096 100644 --- a/python/packages/core/agent_framework/_feature_stage.py +++ b/python/packages/core/agent_framework/_feature_stage.py @@ -58,6 +58,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..103bdca8f8 100644 --- a/python/packages/core/agent_framework/foundry/__init__.py +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -41,6 +41,7 @@ "RawFoundryEmbeddingClient": ("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..73c3ffe589 100644 --- a/python/packages/core/agent_framework/foundry/__init__.pyi +++ b/python/packages/core/agent_framework/foundry/__init__.pyi @@ -26,6 +26,7 @@ from agent_framework_foundry import ( RawFoundryEmbeddingClient, evaluate_foundry_target, evaluate_traces, + to_prompt_agent, ) from agent_framework_foundry_local import ( FoundryLocalChatOptions, @@ -58,4 +59,5 @@ __all__ = [ "RawFoundryEmbeddingClient", "evaluate_foundry_target", "evaluate_traces", + "to_prompt_agent", ] diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index bbc139a1ae..96a6a611b9 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -106,3 +106,77 @@ Generally available factories: `get_code_interpreter_tool`, | `get_browser_automation_tool(connection_id)` | `BrowserAutomationPreviewTool` | | `get_bing_custom_search_tool(connection_id, instance_name, ...)` | `BingCustomSearchPreviewTool` | | `get_a2a_tool(base_url=..., project_connection_id=..., ...)` | `A2APreviewTool` | +## Publishing an agent as a Foundry prompt agent + +> **Experimental — `ExperimentalFeature.TO_PROMPT_AGENT`.** `to_prompt_agent` +> is a preview API and may change before reaching GA. It emits an +> `ExperimentalWarning` on first use. + +`to_prompt_agent(agent)` converts an `Agent` whose chat client is a +`FoundryChatClient` into a Foundry `PromptAgentDefinition`. The model is lifted +from the bound `FoundryChatClient`, so the same agent definition you run +locally can be published as a hosted prompt agent without restating the model +deployment name. + +```python +import asyncio + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient, to_prompt_agent +from azure.ai.projects.aio import AIProjectClient +from azure.identity.aio import AzureCliCredential + + +async def main() -> None: + async with AzureCliCredential() as credential: + client = FoundryChatClient( + project_endpoint="https://.services.ai.azure.com", + model="gpt-4o", + credential=credential, + ) + + agent = Agent( + client=client, + name="TravelAgent", + instructions="You are a helpful travel assistant.", + tools=[ + FoundryChatClient.get_web_search_tool(), + FoundryChatClient.get_code_interpreter_tool(), + ], + ) + + # Either run locally... + await agent.run("Book a hotel in Seattle for 3 nights.") + + # ...or publish the same definition as a prompt agent. + async with AIProjectClient( + endpoint="https://.services.ai.azure.com", + credential=credential, + ) as project_client: + await project_client.agents.create_version( + name="travel-agent", + definition=to_prompt_agent(agent), + ) + + +asyncio.run(main()) +``` + +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**. Prompt agents are server-side, so + the deployed agent receives the schema but cannot execute the local Python. + Wire server-side execution separately if needed. +- 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 [`samples/02-agents/providers/foundry/foundry_portable_agent.py`](../../samples/02-agents/providers/foundry/foundry_portable_agent.py) +for an end-to-end runnable example. diff --git a/python/packages/foundry/agent_framework_foundry/__init__.py b/python/packages/foundry/agent_framework_foundry/__init__.py index 002e63f8a6..e6422e72c8 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 to_prompt_agent try: __version__ = importlib.metadata.version(__name__) @@ -39,4 +40,5 @@ "__version__", "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..c1323f8597 --- /dev/null +++ b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py @@ -0,0 +1,179 @@ +# 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 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. + + Raises: + TypeError: If ``agent.client`` is not a ``FoundryChatClient`` + (or subclass). + ValueError: If the bound ``FoundryChatClient`` has no ``model``, or if + the agent has a local MCP tool or an unsupported tool type that + cannot be expressed in a ``PromptAgentDefinition``. + ImportError: If the installed ``azure-ai-projects`` does not expose + ``PromptAgentDefinition`` or ``FunctionTool``. + """ + if not isinstance(agent.client, RawFoundryChatClient): + raise TypeError( + "to_prompt_agent requires an Agent whose client is a FoundryChatClient; " + f"got {type(agent.client).__name__!r}." + ) + + model = agent.client.model + if not model: + raise ValueError( + "FoundryChatClient has no model set. Set 'model' on the FoundryChatClient " + "(or via the FOUNDRY_MODEL environment variable) before converting." + ) + + instructions = agent.default_options.get("instructions") + tools = _convert_tools( + agent.default_options.get("tools", []), + getattr(agent, "mcp_tools", []), + ) + + try: + from azure.ai.projects.models import PromptAgentDefinition + except ImportError as exc: # pragma: no cover - sanity guard + raise ImportError( + "PromptAgentDefinition is not available in the installed azure-ai-projects. " + "Upgrade azure-ai-projects to use to_prompt_agent." + ) from exc + + return PromptAgentDefinition( + model=model, + instructions=instructions, + tools=tools or None, + ) + + +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 (): + if isinstance(mcp_tool, MCPTool): + 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." + ) + raise ValueError( # pragma: no cover - defensive + f"Unsupported mcp_tools entry: {type(mcp_tool).__name__}." + ) + + 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 to use to_prompt_agent." + ) 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(dict(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..7e8fa558c7 --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py @@ -0,0 +1,227 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from typing import Annotated, Any +from unittest.mock import 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, 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: + """A FoundryChatClient with no model raises ValueError.""" + client = _make_foundry_chat_client() + client.model = "" # simulate unset model + agent = _make_agent(client) + + with pytest.raises(ValueError, match="model"): + to_prompt_agent(agent) + + +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 diff --git a/python/samples/02-agents/providers/foundry/foundry_portable_agent.py b/python/samples/02-agents/providers/foundry/foundry_portable_agent.py new file mode 100644 index 0000000000..0993e6a024 --- /dev/null +++ b/python/samples/02-agents/providers/foundry/foundry_portable_agent.py @@ -0,0 +1,96 @@ +# 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, to_prompt_agent +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +load_dotenv() + +""" +Foundry Portable Agent Example + +This sample demonstrates how a single Agent definition can be both: + +1. Run locally via the Foundry Responses API (``agent.run(...)``), and +2. Published as a Foundry prompt agent (``to_prompt_agent(agent)`` + ``AIProjectClient.agents.create_version(...)``) + +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`` is 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. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py. +@tool(approval_mode="never_require") +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 Portable Agent Example ===\n") + + async with AzureCliCredential() as credential: + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ) + + # + agent = Agent( + client=client, + name="TravelAgent", + 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) Publish the same definition as a Foundry prompt agent + definition = to_prompt_agent(agent) + print("PromptAgentDefinition (would be sent to AIProjectClient.agents.create_version):") + print(definition.as_dict()) + # + + # Uncomment to actually publish the prompt agent to your Foundry project: + # from azure.ai.projects.aio import AIProjectClient + # + # async with AIProjectClient( + # endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + # credential=credential, + # ) as project_client: + # created = await project_client.agents.create_version( + # name="travel-agent", + # definition=definition, + # ) + # print(f"Prompt agent published: {created.name} v{created.version}") + + +if __name__ == "__main__": + asyncio.run(main()) From 6e0c047a1eee0a5894fb6b78af58d176a080b741 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 19 May 2026 20:36:09 +0200 Subject: [PATCH 02/20] chore(samples): remove snippet tags from portable agent sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../02-agents/providers/foundry/foundry_portable_agent.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/python/samples/02-agents/providers/foundry/foundry_portable_agent.py b/python/samples/02-agents/providers/foundry/foundry_portable_agent.py index 0993e6a024..2bfee99245 100644 --- a/python/samples/02-agents/providers/foundry/foundry_portable_agent.py +++ b/python/samples/02-agents/providers/foundry/foundry_portable_agent.py @@ -54,7 +54,6 @@ async def main() -> None: credential=credential, ) - # agent = Agent( client=client, name="TravelAgent", @@ -76,7 +75,6 @@ async def main() -> None: definition = to_prompt_agent(agent) print("PromptAgentDefinition (would be sent to AIProjectClient.agents.create_version):") print(definition.as_dict()) - # # Uncomment to actually publish the prompt agent to your Foundry project: # from azure.ai.projects.aio import AIProjectClient From cdca03310ca6dd8251fff44e8d372e6b53e7d64b Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 19 May 2026 20:37:17 +0200 Subject: [PATCH 03/20] chore(samples): inline FoundryChatClient and enable prompt-agent publish Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../foundry/foundry_portable_agent.py | 37 ++++++++----------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/python/samples/02-agents/providers/foundry/foundry_portable_agent.py b/python/samples/02-agents/providers/foundry/foundry_portable_agent.py index 2bfee99245..4301838e60 100644 --- a/python/samples/02-agents/providers/foundry/foundry_portable_agent.py +++ b/python/samples/02-agents/providers/foundry/foundry_portable_agent.py @@ -7,6 +7,7 @@ from agent_framework import Agent, tool from agent_framework.foundry import FoundryChatClient, 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 @@ -48,14 +49,12 @@ async def main() -> None: print("=== Foundry Portable Agent Example ===\n") async with AzureCliCredential() as credential: - client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=credential, - ) - agent = Agent( - client=client, + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ), name="TravelAgent", instructions="You are a helpful travel assistant. Use the booking tool when asked.", tools=[ @@ -73,21 +72,15 @@ async def main() -> None: # 2) Publish the same definition as a Foundry prompt agent definition = to_prompt_agent(agent) - print("PromptAgentDefinition (would be sent to AIProjectClient.agents.create_version):") - print(definition.as_dict()) - - # Uncomment to actually publish the prompt agent to your Foundry project: - # from azure.ai.projects.aio import AIProjectClient - # - # async with AIProjectClient( - # endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - # credential=credential, - # ) as project_client: - # created = await project_client.agents.create_version( - # name="travel-agent", - # definition=definition, - # ) - # print(f"Prompt agent published: {created.name} v{created.version}") + async with AIProjectClient( + endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + credential=credential, + ) as project_client: + created = await project_client.agents.create_version( + name="travel-agent", + definition=definition, + ) + print(f"Prompt agent published: {created.name} v{created.version}") if __name__ == "__main__": From 01c16340abebc58c48c94c196fd668bbbc8c9feb Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 19 May 2026 20:38:31 +0200 Subject: [PATCH 04/20] chore(samples): drop async credential context manager Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../foundry/foundry_portable_agent.py | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/python/samples/02-agents/providers/foundry/foundry_portable_agent.py b/python/samples/02-agents/providers/foundry/foundry_portable_agent.py index 4301838e60..c4c21a2a2c 100644 --- a/python/samples/02-agents/providers/foundry/foundry_portable_agent.py +++ b/python/samples/02-agents/providers/foundry/foundry_portable_agent.py @@ -48,39 +48,40 @@ def book_hotel( async def main() -> None: print("=== Foundry Portable Agent Example ===\n") - async with AzureCliCredential() as credential: - agent = Agent( - client=FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=credential, - ), - name="TravelAgent", - 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) Publish the same definition as a Foundry prompt agent - definition = to_prompt_agent(agent) - async with AIProjectClient( - endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + credential = AzureCliCredential() + + agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], credential=credential, - ) as project_client: - created = await project_client.agents.create_version( - name="travel-agent", - definition=definition, - ) - print(f"Prompt agent published: {created.name} v{created.version}") + ), + name="TravelAgent", + 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) Publish the same definition as a Foundry prompt agent + definition = to_prompt_agent(agent) + project_client = AIProjectClient( + endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + credential=credential, + ) + created = await project_client.agents.create_version( + name="travel-agent", + definition=definition, + ) + print(f"Prompt agent published: {created.name} v{created.version}") if __name__ == "__main__": From 2a1416599cf59bdb14398c1c797df7ff1fbfb614 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 19 May 2026 20:40:25 +0200 Subject: [PATCH 05/20] docs(foundry): trim README to_prompt_agent example to publish-only flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/foundry/README.md | 47 +++++++++++++------------------ 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index 96a6a611b9..8e2847abd4 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -128,35 +128,28 @@ from azure.identity.aio import AzureCliCredential async def main() -> None: - async with AzureCliCredential() as credential: - client = FoundryChatClient( - project_endpoint="https://.services.ai.azure.com", + credential = AzureCliCredential() + project_endpoint = "https://.services.ai.azure.com" + + agent = Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, model="gpt-4o", credential=credential, - ) - - agent = Agent( - client=client, - name="TravelAgent", - instructions="You are a helpful travel assistant.", - tools=[ - FoundryChatClient.get_web_search_tool(), - FoundryChatClient.get_code_interpreter_tool(), - ], - ) - - # Either run locally... - await agent.run("Book a hotel in Seattle for 3 nights.") - - # ...or publish the same definition as a prompt agent. - async with AIProjectClient( - endpoint="https://.services.ai.azure.com", - credential=credential, - ) as project_client: - await project_client.agents.create_version( - name="travel-agent", - definition=to_prompt_agent(agent), - ) + ), + name="TravelAgent", + instructions="You are a helpful travel assistant.", + tools=[ + FoundryChatClient.get_web_search_tool(), + FoundryChatClient.get_code_interpreter_tool(), + ], + ) + + project_client = AIProjectClient(endpoint=project_endpoint, credential=credential) + await project_client.agents.create_version( + name="travel-agent", + definition=to_prompt_agent(agent), + ) asyncio.run(main()) From 55e79de23cfc006571c10818a36b1b4bf553e21d Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 19 May 2026 20:42:33 +0200 Subject: [PATCH 06/20] docs(foundry): note FoundryAgent runs @tool callables for deployed prompt agents Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/foundry/README.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index 8e2847abd4..6cacac0bd9 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -164,9 +164,26 @@ Behaviour: - 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**. Prompt agents are server-side, so - the deployed agent receives the schema but cannot execute the local Python. - Wire server-side execution separately if needed. + 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. From 4551081774a805fd6ea2aa7159b75d60e0999f33 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 19 May 2026 20:51:49 +0200 Subject: [PATCH 07/20] fix(foundry): address review comments on to_prompt_agent converter * Construct `PromptAgentDefinition` `Tool` from a dict via `**tool_item` unpacking rather than the positional Mapping constructor \u2014 cleaner and matches the typical Pydantic / Azure SDK pattern. * Drop the redundant `isinstance(mcp_tool, MCPTool)` guard in `_convert_tools`; the parameter is already typed `Iterable[MCPTool]` so the second `raise` was unreachable. The remaining single `raise` fires for every entry as intended. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_foundry/_to_prompt_agent.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py index c1323f8597..dd199c07bb 100644 --- a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py @@ -130,13 +130,9 @@ def _convert_tools( ) for mcp_tool in mcp_tools or (): - if isinstance(mcp_tool, MCPTool): - 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." - ) - raise ValueError( # pragma: no cover - defensive - f"Unsupported mcp_tools entry: {type(mcp_tool).__name__}." + 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 @@ -176,4 +172,4 @@ def _validate_mapping_tool(tool_item: Mapping[str, Any]) -> Tool: if "type" not in tool_item: raise ValueError("Dict-shaped tools must include a 'type' field matching a Foundry tool discriminator.") - return ProjectsTool(dict(tool_item)) + return ProjectsTool(**tool_item) From c9edd1ce2f1d9608dd6d7df7aea7e9c64f657092 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 19 May 2026 21:09:04 +0200 Subject: [PATCH 08/20] fix(foundry): match Agent.__init__ model resolution in to_prompt_agent * Read the model from `agent.default_options.get("model")` first, falling back to `agent.client.model`. This mirrors the order `Agent.__init__` uses (`_agents.py:740`) when assembling default_options, so the model the agent runs with is the same model the converter publishes \u2014 e.g. when the caller passes `default_options={"model": "..."}` to override the bound client. * Updated the missing-model error message to point at both the client and the default_options paths. * Added tests: * tool-only agent with no `instructions` produces a definition where `instructions` is `None` and is omitted from the dict payload (`Agent.__init__` strips None values from default_options before storing them). * `default_options['model']` wins over the bound client's model. * Fallback to client.model when default_options has no model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_to_prompt_agent.py | 9 ++-- .../tests/foundry/test_to_prompt_agent.py | 50 +++++++++++++++++-- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py index dd199c07bb..1a36bf8b21 100644 --- a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py @@ -63,11 +63,14 @@ def to_prompt_agent(agent: Agent) -> PromptAgentDefinition: f"got {type(agent.client).__name__!r}." ) - model = agent.client.model + # 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( - "FoundryChatClient has no model set. Set 'model' on the FoundryChatClient " - "(or via the FOUNDRY_MODEL environment variable) before converting." + "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") diff --git a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py index 7e8fa558c7..6df61f8214 100644 --- a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py +++ b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py @@ -80,15 +80,59 @@ class NotFoundryChatClient: def test_to_prompt_agent_rejects_missing_model() -> None: - """A FoundryChatClient with no model raises ValueError.""" + """When neither default_options nor the client has a model, ValueError is raised.""" client = _make_foundry_chat_client() - client.model = "" # simulate unset model + 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="model"): + 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() From e5e6438ae580bf5581f94939a3284a685eb81c3e Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 20 May 2026 13:41:50 +0200 Subject: [PATCH 09/20] feat(foundry): add deploy_as_prompt_agent helper + samples Adds `deploy_as_prompt_agent(agent)`, a convenience wrapper around `to_prompt_agent` that reuses the bound FoundryChatClient's project client to call `project_client.agents.create_version(...)`. Defaults `agent_name` / `description` from `agent.name` / `agent.description` so the Agent stays the single source of truth. * Exposed from `agent_framework_foundry` and the lazy-loading `agent_framework.foundry` namespace (including the .pyi stub). * Marked experimental with the existing `ExperimentalFeature.TO_PROMPT_AGENT` tag. * Tests cover the happy path, name/description defaulting, explicit override, no-name error, metadata + description forwarding, extra kwargs passthrough, and the experimental metadata. Samples: * Renamed the existing sample to `creating_prompt_agents.py`, drops 'portable' wording, presents `deploy_as_prompt_agent` first as the recommended path and `to_prompt_agent` + `AIProjectClient` as the two-step alternative, and adds a cleanup step that deletes the published agent so re-runs stay idempotent. * New `using_prompt_agents.py` shows the end-to-end loop: deploy the agent, connect to it with `FoundryAgent` passing the same local `@tool` callable, run a query against the deployed prompt agent, then clean up. README updated to introduce `deploy_as_prompt_agent` as the recommended path and link to both runnable samples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/foundry/__init__.py | 1 + .../core/agent_framework/foundry/__init__.pyi | 2 + python/packages/foundry/README.md | 45 ++++-- .../agent_framework_foundry/__init__.py | 3 +- .../_to_prompt_agent.py | 89 ++++++++---- .../tests/foundry/test_to_prompt_agent.py | 134 +++++++++++++++++- ...ble_agent.py => creating_prompt_agents.py} | 51 +++++-- .../providers/foundry/using_prompt_agents.py | 101 +++++++++++++ python/uv.lock | 2 +- 9 files changed, 367 insertions(+), 61 deletions(-) rename python/samples/02-agents/providers/foundry/{foundry_portable_agent.py => creating_prompt_agents.py} (51%) create mode 100644 python/samples/02-agents/providers/foundry/using_prompt_agents.py diff --git a/python/packages/core/agent_framework/foundry/__init__.py b/python/packages/core/agent_framework/foundry/__init__.py index 103bdca8f8..8f5741bfb8 100644 --- a/python/packages/core/agent_framework/foundry/__init__.py +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -39,6 +39,7 @@ "RawFoundryAgentChatClient": ("agent_framework_foundry", "agent-framework-foundry"), "RawFoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), "RawFoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"), + "deploy_as_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 73c3ffe589..38ca3ba913 100644 --- a/python/packages/core/agent_framework/foundry/__init__.pyi +++ b/python/packages/core/agent_framework/foundry/__init__.pyi @@ -24,6 +24,7 @@ from agent_framework_foundry import ( RawFoundryAgentChatClient, RawFoundryChatClient, RawFoundryEmbeddingClient, + deploy_as_prompt_agent, evaluate_foundry_target, evaluate_traces, to_prompt_agent, @@ -57,6 +58,7 @@ __all__ = [ "RawFoundryAgentChatClient", "RawFoundryChatClient", "RawFoundryEmbeddingClient", + "deploy_as_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 6cacac0bd9..e21a3d5c5e 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -109,21 +109,28 @@ Generally available factories: `get_code_interpreter_tool`, ## Publishing an agent as a Foundry prompt agent > **Experimental — `ExperimentalFeature.TO_PROMPT_AGENT`.** `to_prompt_agent` -> is a preview API and may change before reaching GA. It emits an -> `ExperimentalWarning` on first use. +> and `deploy_as_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 lifted -from the bound `FoundryChatClient`, so the same agent definition you run -locally can be published as a hosted prompt agent without restating the model -deployment name. +`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 +`deploy_as_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, to_prompt_agent -from azure.ai.projects.aio import AIProjectClient +from agent_framework.foundry import FoundryChatClient, deploy_as_prompt_agent from azure.identity.aio import AzureCliCredential @@ -145,16 +152,17 @@ async def main() -> None: ], ) - project_client = AIProjectClient(endpoint=project_endpoint, credential=credential) - await project_client.agents.create_version( - name="travel-agent", - definition=to_prompt_agent(agent), - ) + created = await deploy_as_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 @@ -188,5 +196,12 @@ Behaviour: the converter raises `ValueError` and points at `FoundryChatClient.get_mcp_tool(...)` for hosted MCP servers. -See [`samples/02-agents/providers/foundry/foundry_portable_agent.py`](../../samples/02-agents/providers/foundry/foundry_portable_agent.py) -for an end-to-end runnable example. +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 + `deploy_as_prompt_agent` and `to_prompt_agent` + `AIProjectClient`. +- [`using_prompt_agents.py`](../../samples/02-agents/providers/foundry/using_prompt_agents.py) + \u2014 publish with `deploy_as_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 e6422e72c8..f43317f763 100644 --- a/python/packages/foundry/agent_framework_foundry/__init__.py +++ b/python/packages/foundry/agent_framework_foundry/__init__.py @@ -16,7 +16,7 @@ evaluate_traces, ) from ._memory_provider import FoundryMemoryProvider -from ._to_prompt_agent import to_prompt_agent +from ._to_prompt_agent import deploy_as_prompt_agent, to_prompt_agent try: __version__ = importlib.metadata.version(__name__) @@ -38,6 +38,7 @@ "RawFoundryChatClient", "RawFoundryEmbeddingClient", "__version__", + "deploy_as_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 index 1a36bf8b21..52ac337815 100644 --- a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: from agent_framework import Agent - from azure.ai.projects.models import PromptAgentDefinition, Tool + from azure.ai.projects.models import AgentVersionDetails, PromptAgentDefinition, Tool @experimental(feature_id=ExperimentalFeature.TO_PROMPT_AGENT) @@ -47,45 +47,23 @@ def to_prompt_agent(agent: Agent) -> PromptAgentDefinition: 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. - - Raises: - TypeError: If ``agent.client`` is not a ``FoundryChatClient`` - (or subclass). - ValueError: If the bound ``FoundryChatClient`` has no ``model``, or if - the agent has a local MCP tool or an unsupported tool type that - cannot be expressed in a ``PromptAgentDefinition``. - ImportError: If the installed ``azure-ai-projects`` does not expose - ``PromptAgentDefinition`` or ``FunctionTool``. """ if not isinstance(agent.client, RawFoundryChatClient): raise TypeError( - "to_prompt_agent requires an Agent whose client is a FoundryChatClient; " + "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", []), ) - try: - from azure.ai.projects.models import PromptAgentDefinition - except ImportError as exc: # pragma: no cover - sanity guard - raise ImportError( - "PromptAgentDefinition is not available in the installed azure-ai-projects. " - "Upgrade azure-ai-projects to use to_prompt_agent." - ) from exc + from azure.ai.projects.models import PromptAgentDefinition return PromptAgentDefinition( model=model, @@ -94,6 +72,64 @@ def to_prompt_agent(agent: Agent) -> PromptAgentDefinition: ) +@experimental(feature_id=ExperimentalFeature.TO_PROMPT_AGENT) +async def deploy_as_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, @@ -152,8 +188,7 @@ def _function_tool_to_foundry(tool_item: FunctionTool) -> Tool: 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 to use to_prompt_agent." + "FunctionTool is not available in the installed azure-ai-projects. Upgrade azure-ai-projects." ) from exc return ProjectsFunctionTool( diff --git a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py index 6df61f8214..2fa4690bc7 100644 --- a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py +++ b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Annotated, Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest from agent_framework import Agent, MCPStdioTool, tool @@ -23,7 +23,12 @@ Tool as ProjectsTool, ) -from agent_framework_foundry import FoundryChatClient, RawFoundryChatClient, to_prompt_agent +from agent_framework_foundry import ( + FoundryChatClient, + RawFoundryChatClient, + deploy_as_prompt_agent, + to_prompt_agent, +) @tool @@ -269,3 +274,128 @@ 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_deploy_as_prompt_agent_publishes_definition() -> None: + """deploy_as_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 deploy_as_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_deploy_as_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 deploy_as_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_deploy_as_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 deploy_as_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_deploy_as_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 deploy_as_prompt_agent(agent) + create_version.assert_not_awaited() + + +async def test_deploy_as_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 deploy_as_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_deploy_as_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 deploy_as_prompt_agent(agent, agent_name="travel-agent", headers={"x-trace": "abc"}) + + assert create_version.await_args.kwargs["headers"] == {"x-trace": "abc"} + + +async def test_deploy_as_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 deploy_as_prompt_agent(agent, agent_name="travel-agent") + + +def test_deploy_as_prompt_agent_is_marked_experimental() -> None: + """deploy_as_prompt_agent carries the TO_PROMPT_AGENT experimental metadata.""" + assert getattr(deploy_as_prompt_agent, "__feature_stage__", None) == "experimental" + assert getattr(deploy_as_prompt_agent, "__feature_id__", None) == ExperimentalFeature.TO_PROMPT_AGENT.value diff --git a/python/samples/02-agents/providers/foundry/foundry_portable_agent.py b/python/samples/02-agents/providers/foundry/creating_prompt_agents.py similarity index 51% rename from python/samples/02-agents/providers/foundry/foundry_portable_agent.py rename to python/samples/02-agents/providers/foundry/creating_prompt_agents.py index c4c21a2a2c..ba313049d3 100644 --- a/python/samples/02-agents/providers/foundry/foundry_portable_agent.py +++ b/python/samples/02-agents/providers/foundry/creating_prompt_agents.py @@ -6,7 +6,7 @@ from typing import Annotated from agent_framework import Agent, tool -from agent_framework.foundry import FoundryChatClient, to_prompt_agent +from agent_framework.foundry import FoundryChatClient, deploy_as_prompt_agent, to_prompt_agent from azure.ai.projects.aio import AIProjectClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -15,18 +15,24 @@ load_dotenv() """ -Foundry Portable Agent Example +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(...)``), and -2. Published as a Foundry prompt agent (``to_prompt_agent(agent)`` + ``AIProjectClient.agents.create_version(...)``) +1. Run locally via the Foundry Responses API (``agent.run(...)``). +2. Published in one step via ``deploy_as_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`` is experimental (``ExperimentalFeature.TO_PROMPT_AGENT``) -and may change before reaching GA. +``to_prompt_agent`` and ``deploy_as_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 @@ -34,9 +40,7 @@ """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py. -@tool(approval_mode="never_require") +@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.")], @@ -46,7 +50,7 @@ def book_hotel( async def main() -> None: - print("=== Foundry Portable Agent Example ===\n") + print("=== Foundry Prompt Agent Example ===\n") credential = AzureCliCredential() @@ -56,7 +60,11 @@ async def main() -> None: model=os.environ["FOUNDRY_MODEL"], credential=credential, ), - name="TravelAgent", + # 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(), @@ -71,17 +79,30 @@ async def main() -> None: response = await agent.run(local_query) print(f"Agent: {response}\n") - # 2) Publish the same definition as a Foundry prompt agent + # 2) Recommended: one-step deploy. `deploy_as_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 deploy_as_prompt_agent(agent) + print(f"Prompt agent published via deploy_as_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 = await project_client.agents.create_version( - name="travel-agent", + created_v2 = await project_client.agents.create_version( + agent_name=agent.name, definition=definition, + description=agent.description, ) - print(f"Prompt agent published: {created.name} v{created.version}") + 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__": 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..e478eece14 --- /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, deploy_as_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 ``deploy_as_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. + +``deploy_as_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 deploy_as_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 58c0ed50ee..dee89c9f0a 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -604,7 +604,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]] From 881b42155605a7f46da8286f9e5dd812f1f89f92 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 20 May 2026 13:46:42 +0200 Subject: [PATCH 10/20] fix(foundry): restore missing-model ValueError in to_prompt_agent The check was accidentally dropped while reworking docstrings in the previous commit. Test `test_to_prompt_agent_rejects_missing_model` exercises this path and was failing on CI as a result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../foundry/agent_framework_foundry/_to_prompt_agent.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py index 52ac337815..d14def9691 100644 --- a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py @@ -57,6 +57,13 @@ def to_prompt_agent(agent: Agent) -> PromptAgentDefinition: # 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", []), From c628c2a6525c60ae327ee0c5ed45a6f66b999986 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 21 May 2026 12:32:43 +0200 Subject: [PATCH 11/20] refactor(foundry): rename deploy_as_prompt_agent -> create_prompt_agent Renames the helper across the foundry package, core lazy-loader stubs, tests, README and samples. The new name better matches the action performed (a prompt-agent definition is created in Foundry) and is consistent with the surrounding ''create_*'' API surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/foundry/__init__.py | 2 +- .../core/agent_framework/foundry/__init__.pyi | 4 +- python/packages/foundry/README.md | 12 +++--- .../agent_framework_foundry/__init__.py | 4 +- .../_to_prompt_agent.py | 2 +- .../tests/foundry/test_to_prompt_agent.py | 40 +++++++++---------- .../foundry/creating_prompt_agents.py | 12 +++--- .../providers/foundry/using_prompt_agents.py | 8 ++-- 8 files changed, 42 insertions(+), 42 deletions(-) diff --git a/python/packages/core/agent_framework/foundry/__init__.py b/python/packages/core/agent_framework/foundry/__init__.py index 8f5741bfb8..2603f70f13 100644 --- a/python/packages/core/agent_framework/foundry/__init__.py +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -39,7 +39,7 @@ "RawFoundryAgentChatClient": ("agent_framework_foundry", "agent-framework-foundry"), "RawFoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), "RawFoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"), - "deploy_as_prompt_agent": ("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 38ca3ba913..44c8e9445f 100644 --- a/python/packages/core/agent_framework/foundry/__init__.pyi +++ b/python/packages/core/agent_framework/foundry/__init__.pyi @@ -24,7 +24,7 @@ from agent_framework_foundry import ( RawFoundryAgentChatClient, RawFoundryChatClient, RawFoundryEmbeddingClient, - deploy_as_prompt_agent, + create_prompt_agent, evaluate_foundry_target, evaluate_traces, to_prompt_agent, @@ -58,7 +58,7 @@ __all__ = [ "RawFoundryAgentChatClient", "RawFoundryChatClient", "RawFoundryEmbeddingClient", - "deploy_as_prompt_agent", + "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 e21a3d5c5e..5ebbed270a 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -109,7 +109,7 @@ Generally available factories: `get_code_interpreter_tool`, ## Publishing an agent as a Foundry prompt agent > **Experimental — `ExperimentalFeature.TO_PROMPT_AGENT`.** `to_prompt_agent` -> and `deploy_as_prompt_agent` are preview APIs and may change before reaching +> 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. @@ -121,7 +121,7 @@ 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 -`deploy_as_prompt_agent(agent, agent_name=...)`. It reuses the bound +`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`: @@ -130,7 +130,7 @@ construct a separate `AIProjectClient`: import asyncio from agent_framework import Agent -from agent_framework.foundry import FoundryChatClient, deploy_as_prompt_agent +from agent_framework.foundry import FoundryChatClient, create_prompt_agent from azure.identity.aio import AzureCliCredential @@ -152,7 +152,7 @@ async def main() -> None: ], ) - created = await deploy_as_prompt_agent(agent, agent_name="travel-agent") + created = await create_prompt_agent(agent, agent_name="travel-agent") print(f"Published {created.name} v{created.version}") @@ -200,8 +200,8 @@ 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 - `deploy_as_prompt_agent` and `to_prompt_agent` + `AIProjectClient`. + `create_prompt_agent` and `to_prompt_agent` + `AIProjectClient`. - [`using_prompt_agents.py`](../../samples/02-agents/providers/foundry/using_prompt_agents.py) - \u2014 publish with `deploy_as_prompt_agent`, then connect back with + \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 f43317f763..d5fbc6b5fa 100644 --- a/python/packages/foundry/agent_framework_foundry/__init__.py +++ b/python/packages/foundry/agent_framework_foundry/__init__.py @@ -16,7 +16,7 @@ evaluate_traces, ) from ._memory_provider import FoundryMemoryProvider -from ._to_prompt_agent import deploy_as_prompt_agent, to_prompt_agent +from ._to_prompt_agent import create_prompt_agent, to_prompt_agent try: __version__ = importlib.metadata.version(__name__) @@ -38,7 +38,7 @@ "RawFoundryChatClient", "RawFoundryEmbeddingClient", "__version__", - "deploy_as_prompt_agent", + "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 index d14def9691..99a04d7f07 100644 --- a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py @@ -80,7 +80,7 @@ def to_prompt_agent(agent: Agent) -> PromptAgentDefinition: @experimental(feature_id=ExperimentalFeature.TO_PROMPT_AGENT) -async def deploy_as_prompt_agent( +async def create_prompt_agent( agent: Agent, *, metadata: Mapping[str, str] | None = None, diff --git a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py index 2fa4690bc7..c69c019011 100644 --- a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py +++ b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py @@ -26,7 +26,7 @@ from agent_framework_foundry import ( FoundryChatClient, RawFoundryChatClient, - deploy_as_prompt_agent, + create_prompt_agent, to_prompt_agent, ) @@ -288,12 +288,12 @@ def _make_foundry_chat_client_with_async_agents_ops( return client, create_version -async def test_deploy_as_prompt_agent_publishes_definition() -> None: - """deploy_as_prompt_agent calls project_client.agents.create_version with the converted definition.""" +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 deploy_as_prompt_agent(agent, agent_name="travel-agent") + result = await create_prompt_agent(agent, agent_name="travel-agent") create_version.assert_awaited_once() call_kwargs = create_version.await_args.kwargs @@ -307,7 +307,7 @@ async def test_deploy_as_prompt_agent_publishes_definition() -> None: assert result is create_version.return_value -async def test_deploy_as_prompt_agent_defaults_name_and_description_from_agent() -> None: +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( @@ -317,14 +317,14 @@ async def test_deploy_as_prompt_agent_defaults_name_and_description_from_agent() description="Helps Contoso employees book travel.", ) - await deploy_as_prompt_agent(agent) + 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_deploy_as_prompt_agent_explicit_overrides_win() -> None: +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( @@ -334,7 +334,7 @@ async def test_deploy_as_prompt_agent_explicit_overrides_win() -> None: description="Agent-level description", ) - await deploy_as_prompt_agent( + await create_prompt_agent( agent, agent_name="travel-agent-v2", description="Override description", @@ -345,23 +345,23 @@ async def test_deploy_as_prompt_agent_explicit_overrides_win() -> None: assert call_kwargs["description"] == "Override description" -async def test_deploy_as_prompt_agent_requires_an_agent_name() -> None: +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 deploy_as_prompt_agent(agent) + await create_prompt_agent(agent) create_version.assert_not_awaited() -async def test_deploy_as_prompt_agent_forwards_metadata_and_description() -> None: +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 deploy_as_prompt_agent( + await create_prompt_agent( agent, agent_name="travel-agent", metadata={"env": "prod"}, @@ -373,17 +373,17 @@ async def test_deploy_as_prompt_agent_forwards_metadata_and_description() -> Non assert call_kwargs["description"] == "Production travel agent" -async def test_deploy_as_prompt_agent_forwards_extra_kwargs() -> None: +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 deploy_as_prompt_agent(agent, agent_name="travel-agent", headers={"x-trace": "abc"}) + 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_deploy_as_prompt_agent_rejects_non_foundry_client() -> None: +async def test_create_prompt_agent_rejects_non_foundry_client() -> None: """A non-FoundryChatClient client raises TypeError before any service call.""" class NotFoundryChatClient: @@ -392,10 +392,10 @@ class NotFoundryChatClient: agent = _make_agent(NotFoundryChatClient()) with pytest.raises(TypeError, match="FoundryChatClient"): - await deploy_as_prompt_agent(agent, agent_name="travel-agent") + await create_prompt_agent(agent, agent_name="travel-agent") -def test_deploy_as_prompt_agent_is_marked_experimental() -> None: - """deploy_as_prompt_agent carries the TO_PROMPT_AGENT experimental metadata.""" - assert getattr(deploy_as_prompt_agent, "__feature_stage__", None) == "experimental" - assert getattr(deploy_as_prompt_agent, "__feature_id__", None) == ExperimentalFeature.TO_PROMPT_AGENT.value +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 index ba313049d3..268856efab 100644 --- a/python/samples/02-agents/providers/foundry/creating_prompt_agents.py +++ b/python/samples/02-agents/providers/foundry/creating_prompt_agents.py @@ -6,7 +6,7 @@ from typing import Annotated from agent_framework import Agent, tool -from agent_framework.foundry import FoundryChatClient, deploy_as_prompt_agent, to_prompt_agent +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 @@ -20,7 +20,7 @@ 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 ``deploy_as_prompt_agent(agent)``, which reuses +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 @@ -31,7 +31,7 @@ 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 ``deploy_as_prompt_agent`` are experimental +``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 @@ -79,12 +79,12 @@ async def main() -> None: response = await agent.run(local_query) print(f"Agent: {response}\n") - # 2) Recommended: one-step deploy. `deploy_as_prompt_agent` reuses the FoundryChatClient's + # 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 deploy_as_prompt_agent(agent) - print(f"Prompt agent published via deploy_as_prompt_agent: {created.name} v{created.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. diff --git a/python/samples/02-agents/providers/foundry/using_prompt_agents.py b/python/samples/02-agents/providers/foundry/using_prompt_agents.py index e478eece14..c39dfa6c5e 100644 --- a/python/samples/02-agents/providers/foundry/using_prompt_agents.py +++ b/python/samples/02-agents/providers/foundry/using_prompt_agents.py @@ -6,7 +6,7 @@ from typing import Annotated from agent_framework import Agent, tool -from agent_framework.foundry import FoundryAgent, FoundryChatClient, deploy_as_prompt_agent +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 @@ -21,7 +21,7 @@ 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 ``deploy_as_prompt_agent``. +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. @@ -31,7 +31,7 @@ executes the local Python implementation by matching tool names \u2014 keeping the schema on the server and the implementation on the client in sync. -``deploy_as_prompt_agent`` is experimental +``create_prompt_agent`` is experimental (``ExperimentalFeature.TO_PROMPT_AGENT``) and may change before reaching GA. """ @@ -71,7 +71,7 @@ async def main() -> None: # 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 deploy_as_prompt_agent(agent) + 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. From 7c3f41ab35c512afc71b88c1c419abf185a76dd6 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 26 May 2026 21:07:04 +0200 Subject: [PATCH 12/20] refactor(foundry): drop create_prompt_agent, enrich to_prompt_agent params Remove the create_prompt_agent helper and consolidate on to_prompt_agent. Expose every PromptAgentDefinition parameter that has either an Agent Framework equivalent (sourced from default_options) or no equivalent (accepted as a keyword argument). * default_options-sourced (with kwarg overrides): temperature, top_p, string tool_choice * kwarg-only Foundry knobs: reasoning, text, structured_inputs, rai_config, ToolChoiceParam tool_choice Precedence is always: explicit keyword > default_options entry > unset. Tests cover every path (defaults, default_options, kwargs, kwarg override). Samples and README rewritten around the enriched to_prompt_agent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/foundry/__init__.py | 1 - .../core/agent_framework/foundry/__init__.pyi | 2 - python/packages/foundry/README.md | 65 ++-- .../agent_framework_foundry/__init__.py | 3 +- .../_to_prompt_agent.py | 154 +++++---- .../tests/foundry/test_to_prompt_agent.py | 301 ++++++++++++------ .../foundry/creating_prompt_agents.py | 51 ++- .../providers/foundry/using_prompt_agents.py | 27 +- 8 files changed, 372 insertions(+), 232 deletions(-) diff --git a/python/packages/core/agent_framework/foundry/__init__.py b/python/packages/core/agent_framework/foundry/__init__.py index 2603f70f13..103bdca8f8 100644 --- a/python/packages/core/agent_framework/foundry/__init__.py +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -39,7 +39,6 @@ "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 44c8e9445f..73c3ffe589 100644 --- a/python/packages/core/agent_framework/foundry/__init__.pyi +++ b/python/packages/core/agent_framework/foundry/__init__.pyi @@ -24,7 +24,6 @@ from agent_framework_foundry import ( RawFoundryAgentChatClient, RawFoundryChatClient, RawFoundryEmbeddingClient, - create_prompt_agent, evaluate_foundry_target, evaluate_traces, to_prompt_agent, @@ -58,7 +57,6 @@ __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 5ebbed270a..5f4ddb1bcb 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -109,34 +109,48 @@ Generally available factories: `get_code_interpreter_tool`, ## 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. +> is a preview API 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 +`FoundryChatClient` into a Foundry `PromptAgentDefinition` that can be +published with `AIProjectClient.agents.create_version(...)`. 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`: +Generation parameters with an Agent Framework equivalent are sourced from +`agent.default_options` when not passed explicitly to `to_prompt_agent`: + +- `temperature` — from `default_options["temperature"]` +- `top_p` — from `default_options["top_p"]` +- `tool_choice` — from `default_options["tool_choice"]` *when it is a string* + (e.g. `"auto"`, `"required"`, `"none"`). Non-string Agent Framework + tool-choice values are ignored — pass an explicit `ToolChoiceParam` via the + keyword argument when you need one. + +Foundry-specific knobs are keyword-only on `to_prompt_agent`: `reasoning`, +`text`, `structured_inputs`, and `rai_config`. + +Precedence is always: **explicit keyword argument > `default_options` entry > +unset on the resulting definition.** ```python import asyncio +import os from agent_framework import Agent -from agent_framework.foundry import FoundryChatClient, create_prompt_agent +from agent_framework.foundry import FoundryChatClient, to_prompt_agent +from azure.ai.projects.aio import AIProjectClient +from azure.ai.projects.models import Reasoning from azure.identity.aio import AzureCliCredential async def main() -> None: credential = AzureCliCredential() - project_endpoint = "https://.services.ai.azure.com" + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] agent = Agent( client=FoundryChatClient( @@ -144,25 +158,32 @@ async def main() -> None: model="gpt-4o", credential=credential, ), - name="TravelAgent", + name="travel-agent", + description="Helps Contoso employees book travel.", instructions="You are a helpful travel assistant.", tools=[ FoundryChatClient.get_web_search_tool(), FoundryChatClient.get_code_interpreter_tool(), ], + # Generation parameters set on the Agent flow through automatically. + default_options={"temperature": 0.3, "top_p": 0.95}, ) - created = await create_prompt_agent(agent, agent_name="travel-agent") + # `reasoning` has no AF equivalent; pass it as a keyword argument. + definition = to_prompt_agent(agent, reasoning=Reasoning(effort="medium")) + + project_client = AIProjectClient(endpoint=project_endpoint, credential=credential) + created = await project_client.agents.create_version( + agent_name=agent.name, + definition=definition, + description=agent.description, + ) 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 @@ -199,9 +220,9 @@ Behaviour: 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`. + \u2014 build an Agent, run it locally, and publish it via `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. + \u2014 publish with `to_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 d5fbc6b5fa..e6422e72c8 100644 --- a/python/packages/foundry/agent_framework_foundry/__init__.py +++ b/python/packages/foundry/agent_framework_foundry/__init__.py @@ -16,7 +16,7 @@ evaluate_traces, ) from ._memory_provider import FoundryMemoryProvider -from ._to_prompt_agent import create_prompt_agent, to_prompt_agent +from ._to_prompt_agent import to_prompt_agent try: __version__ = importlib.metadata.version(__name__) @@ -38,7 +38,6 @@ "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 index 99a04d7f07..a601465e56 100644 --- a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py @@ -11,6 +11,12 @@ definition used for local execution can be published as a hosted prompt agent without restating the model deployment name. +Generation parameters (``temperature``, ``top_p``, ``tool_choice``) are sourced +from ``agent.default_options`` when not overridden by an explicit keyword +argument. Foundry-specific parameters that have no Agent Framework equivalent +(``reasoning``, ``text``, ``structured_inputs``, ``rai_config``) are accepted +as keyword arguments only. + 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 @@ -30,23 +36,62 @@ if TYPE_CHECKING: from agent_framework import Agent - from azure.ai.projects.models import AgentVersionDetails, PromptAgentDefinition, Tool + from azure.ai.projects.models import ( + PromptAgentDefinition, + PromptAgentDefinitionTextOptions, + RaiConfig, + Reasoning, + StructuredInputDefinition, + Tool, + ToolChoiceParam, + ) @experimental(feature_id=ExperimentalFeature.TO_PROMPT_AGENT) -def to_prompt_agent(agent: Agent) -> PromptAgentDefinition: +def to_prompt_agent( + agent: Agent, + *, + temperature: float | None = None, + top_p: float | None = None, + tool_choice: str | ToolChoiceParam | None = None, + reasoning: Reasoning | None = None, + text: PromptAgentDefinitionTextOptions | None = None, + structured_inputs: Mapping[str, StructuredInputDefinition] | None = None, + rai_config: RaiConfig | None = None, +) -> 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. + Generation parameters that have an Agent Framework ``ChatOptions`` + equivalent are sourced from ``agent.default_options`` when not supplied as + a keyword argument here. Precedence is: explicit keyword > default_options + entry > unset on the resulting definition. Parameters specific to Foundry + prompt agents are accepted as keyword arguments only. + Args: agent: An Agent Framework agent whose client is a ``FoundryChatClient``. + Keyword Args: + temperature: Sampling temperature. Falls back to + ``agent.default_options['temperature']`` if unset. + top_p: Nucleus sampling parameter. Falls back to + ``agent.default_options['top_p']`` if unset. + tool_choice: How the model should pick tools. When unset, a *string* + ``agent.default_options['tool_choice']`` (e.g. ``"auto"``, + ``"required"``, ``"none"``) is propagated; non-string Agent + Framework tool-choice values are ignored. + reasoning: Foundry ``Reasoning`` configuration. + text: Foundry ``PromptAgentDefinitionTextOptions`` configuration. + structured_inputs: Mapping of structured input names to + ``StructuredInputDefinition`` entries. + rai_config: Foundry ``RaiConfig`` to attach to the definition. + 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. + tools, and generation parameters. Pass it to + ``AIProjectClient.agents.create_version(...)`` to publish. """ if not isinstance(agent.client, RawFoundryChatClient): raise TypeError( @@ -70,71 +115,48 @@ def to_prompt_agent(agent: Agent) -> PromptAgentDefinition: getattr(agent, "mcp_tools", []), ) - from azure.ai.projects.models import PromptAgentDefinition - - return PromptAgentDefinition( - model=model, - instructions=instructions, - tools=tools or None, - ) - + resolved_temperature = temperature if temperature is not None else agent.default_options.get("temperature") + resolved_top_p = top_p if top_p is not None else agent.default_options.get("top_p") + resolved_tool_choice = tool_choice if tool_choice is not None else _default_options_tool_choice(agent) -@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(...)``. + from azure.ai.projects.models import PromptAgentDefinition - Returns: - The ``AgentVersionDetails`` returned by the Foundry service for the - newly created version. + kwargs: dict[str, Any] = { + "model": model, + "instructions": instructions, + "tools": tools or None, + } + if resolved_temperature is not None: + kwargs["temperature"] = resolved_temperature + if resolved_top_p is not None: + kwargs["top_p"] = resolved_top_p + if resolved_tool_choice is not None: + kwargs["tool_choice"] = resolved_tool_choice + if reasoning is not None: + kwargs["reasoning"] = reasoning + if text is not None: + kwargs["text"] = text + if structured_inputs is not None: + kwargs["structured_inputs"] = dict(structured_inputs) + if rai_config is not None: + kwargs["rai_config"] = rai_config + + return PromptAgentDefinition(**kwargs) + + +def _default_options_tool_choice(agent: Agent) -> str | None: + """Return ``agent.default_options['tool_choice']`` only when it is a string. + + Agent Framework's ``tool_choice`` is ``ToolMode | Literal["auto", "required", "none"]``. + Foundry's prompt-agent ``tool_choice`` accepts either a string or a + ``ToolChoiceParam`` model; the simple string values overlap cleanly, while + AF ``ToolMode`` instances have no canonical Foundry mapping. Anything that + is not already a string is left to the explicit keyword argument. """ - # 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, - ) + value = agent.default_options.get("tool_choice") + if isinstance(value, str): + return value + return None def _convert_tools( diff --git a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py index c69c019011..c15cf8cb88 100644 --- a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py +++ b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Annotated, Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest from agent_framework import Agent, MCPStdioTool, tool @@ -11,6 +11,11 @@ from azure.ai.projects.models import ( CodeInterpreterTool, PromptAgentDefinition, + PromptAgentDefinitionTextOptions, + RaiConfig, + Reasoning, + StructuredInputDefinition, + ToolChoiceFunction, WebSearchTool, ) from azure.ai.projects.models import ( @@ -26,7 +31,6 @@ from agent_framework_foundry import ( FoundryChatClient, RawFoundryChatClient, - create_prompt_agent, to_prompt_agent, ) @@ -49,6 +53,11 @@ def _make_agent(client: Any, **agent_kwargs: Any) -> Agent: return Agent(client=client, **agent_kwargs) +# --------------------------------------------------------------------------- +# Core conversion: model resolution and client-type guarding +# --------------------------------------------------------------------------- + + 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.") @@ -138,6 +147,29 @@ def test_to_prompt_agent_falls_back_to_client_model() -> None: assert definition.model == "client-model" +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 + + +# --------------------------------------------------------------------------- +# Tool conversion +# --------------------------------------------------------------------------- + + def test_to_prompt_agent_passes_through_sdk_tool_instances() -> None: """Foundry SDK tool instances (e.g. WebSearchTool) are passed through unchanged.""" ws = WebSearchTool() @@ -258,144 +290,215 @@ def test_to_prompt_agent_rejects_dict_tool_without_type() -> None: 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") +# --------------------------------------------------------------------------- +# Generation parameters sourced from default_options (with kwarg overrides) +# --------------------------------------------------------------------------- + + +def test_to_prompt_agent_temperature_top_p_unset_by_default() -> None: + """Without default_options or kwargs, temperature/top_p are unset on the definition.""" + agent = _make_agent(_make_foundry_chat_client(), instructions="x") definition = to_prompt_agent(agent) - assert definition.model == "gpt-4o" + assert definition.temperature is None + assert definition.top_p is None + payload = definition.as_dict() + assert "temperature" not in payload + assert "top_p" not in payload -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 test_to_prompt_agent_lifts_temperature_top_p_from_default_options() -> None: + """temperature/top_p in default_options flow through to the definition.""" + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + default_options={"temperature": 0.42, "top_p": 0.8}, + ) + definition = to_prompt_agent(agent) -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 + assert definition.temperature == 0.42 + assert definition.top_p == 0.8 -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()]) +def test_to_prompt_agent_temperature_top_p_kwargs_win_over_default_options() -> None: + """Explicit kwargs override values present in default_options.""" + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + default_options={"temperature": 0.42, "top_p": 0.8}, + ) - result = await create_prompt_agent(agent, agent_name="travel-agent") + definition = to_prompt_agent(agent, temperature=0.1, top_p=0.2) - 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 + assert definition.temperature == 0.1 + assert definition.top_p == 0.2 -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() +def test_to_prompt_agent_temperature_zero_kwarg_is_honored() -> None: + """A literal ``0.0`` kwarg is treated as explicit, not as "fall back to default_options". + + Guards against an ``if temperature:`` truthiness check that would silently drop the value. + """ agent = _make_agent( - client, + _make_foundry_chat_client(), instructions="x", - name="travel-agent", - description="Helps Contoso employees book travel.", + default_options={"temperature": 0.7}, ) - await create_prompt_agent(agent) + definition = to_prompt_agent(agent, temperature=0.0, top_p=0.0) + + assert definition.temperature == 0.0 + assert definition.top_p == 0.0 - call_kwargs = create_version.await_args.kwargs - assert call_kwargs["agent_name"] == "travel-agent" - assert call_kwargs["description"] == "Helps Contoso employees book travel." +def test_to_prompt_agent_defaults_tool_choice_to_auto() -> None: + """Agent.__init__ inserts tool_choice='auto' by default; the converter propagates it.""" + agent = _make_agent(_make_foundry_chat_client(), instructions="x") + + definition = to_prompt_agent(agent) + + assert definition.tool_choice == "auto" -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() + +def test_to_prompt_agent_lifts_string_tool_choice_from_default_options() -> None: + """A string ``tool_choice`` in default_options propagates to the definition.""" agent = _make_agent( - client, + _make_foundry_chat_client(), instructions="x", - name="travel-agent", - description="Agent-level description", + default_options={"tool_choice": "required"}, ) - await create_prompt_agent( - agent, - agent_name="travel-agent-v2", - description="Override description", - ) + definition = to_prompt_agent(agent) - call_kwargs = create_version.await_args.kwargs - assert call_kwargs["agent_name"] == "travel-agent-v2" - assert call_kwargs["description"] == "Override description" + assert definition.tool_choice == "required" -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 +def test_to_prompt_agent_ignores_non_string_tool_choice_from_default_options() -> None: + """Non-string ``tool_choice`` values (e.g. AF ToolMode) are not auto-propagated.""" + agent = _make_agent(_make_foundry_chat_client(), instructions="x") + # Replace the str default with a non-str sentinel to mimic an AF ToolMode value. + agent.default_options["tool_choice"] = object() # type: ignore[typeddict-item] - with pytest.raises(ValueError, match="agent_name"): - await create_prompt_agent(agent) - create_version.assert_not_awaited() + definition = to_prompt_agent(agent) + assert definition.tool_choice is None -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", +def test_to_prompt_agent_tool_choice_kwarg_wins_over_default_options() -> None: + """An explicit ``tool_choice`` kwarg wins over a default_options entry.""" + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + default_options={"tool_choice": "auto"}, ) - call_kwargs = create_version.await_args.kwargs - assert call_kwargs["metadata"] == {"env": "prod"} - assert call_kwargs["description"] == "Production travel agent" + definition = to_prompt_agent(agent, tool_choice="none") + assert definition.tool_choice == "none" -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"}) +def test_to_prompt_agent_tool_choice_accepts_param_model() -> None: + """A ``ToolChoiceParam`` instance passed as kwarg is forwarded to the definition.""" + choice = ToolChoiceFunction(name="get_weather") + agent = _make_agent(_make_foundry_chat_client(), instructions="x") - assert create_version.await_args.kwargs["headers"] == {"x-trace": "abc"} + definition = to_prompt_agent(agent, tool_choice=choice) + assert definition.tool_choice is choice -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.""" +# --------------------------------------------------------------------------- +# Foundry-specific kwargs (no AF ChatOptions equivalent) +# --------------------------------------------------------------------------- - agent = _make_agent(NotFoundryChatClient()) - with pytest.raises(TypeError, match="FoundryChatClient"): - await create_prompt_agent(agent, agent_name="travel-agent") +def test_to_prompt_agent_kwarg_only_fields_unset_by_default() -> None: + """reasoning, text, structured_inputs, rai_config are absent from the payload when unset.""" + agent = _make_agent(_make_foundry_chat_client(), instructions="x") + + payload = to_prompt_agent(agent).as_dict() + + assert "reasoning" not in payload + assert "text" not in payload + assert "structured_inputs" not in payload + assert "rai_config" not in payload + +def test_to_prompt_agent_forwards_reasoning_kwarg() -> None: + """A ``Reasoning`` kwarg is forwarded to the definition.""" + reasoning = Reasoning(effort="high") + agent = _make_agent(_make_foundry_chat_client(), instructions="x") -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 + definition = to_prompt_agent(agent, reasoning=reasoning) + + assert definition.reasoning is reasoning + + +def test_to_prompt_agent_forwards_text_kwarg() -> None: + """A ``PromptAgentDefinitionTextOptions`` kwarg is forwarded to the definition.""" + text = PromptAgentDefinitionTextOptions() + agent = _make_agent(_make_foundry_chat_client(), instructions="x") + + definition = to_prompt_agent(agent, text=text) + + assert definition.text is text + + +def test_to_prompt_agent_forwards_structured_inputs_kwarg() -> None: + """A ``structured_inputs`` mapping is forwarded (and copied to a new dict).""" + inputs = {"city": StructuredInputDefinition(description="Target city.")} + agent = _make_agent(_make_foundry_chat_client(), instructions="x") + + definition = to_prompt_agent(agent, structured_inputs=inputs) + + assert definition.structured_inputs is not None + assert set(definition.structured_inputs) == {"city"} + assert definition.structured_inputs["city"] is inputs["city"] + # Defensive copy: mutating the caller's mapping after the call does not leak in. + inputs["other"] = StructuredInputDefinition(description="x") + assert "other" not in definition.structured_inputs + + +def test_to_prompt_agent_forwards_rai_config_kwarg() -> None: + """A ``RaiConfig`` kwarg is forwarded to the definition.""" + rai_config = RaiConfig() + agent = _make_agent(_make_foundry_chat_client(), instructions="x") + + definition = to_prompt_agent(agent, rai_config=rai_config) + + assert definition.rai_config is rai_config + + +def test_to_prompt_agent_combines_all_parameters() -> None: + """Every parameter routes through to a single definition simultaneously.""" + reasoning = Reasoning(effort="medium") + text = PromptAgentDefinitionTextOptions() + rai_config = RaiConfig() + structured = {"q": StructuredInputDefinition(description="query")} + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + default_options={"temperature": 0.3, "top_p": 0.95, "tool_choice": "auto"}, + tools=[get_weather], + ) + + definition = to_prompt_agent( + agent, + temperature=0.5, + reasoning=reasoning, + text=text, + structured_inputs=structured, + rai_config=rai_config, + ) + + # Kwargs overrode default_options for temperature; top_p and tool_choice came from default_options. + assert definition.temperature == 0.5 + assert definition.top_p == 0.95 + assert definition.tool_choice == "auto" + assert definition.reasoning is reasoning + assert definition.text is text + assert definition.rai_config is rai_config + assert definition.structured_inputs is not None and "q" in definition.structured_inputs + assert definition.tools is not None and len(definition.tools) == 1 diff --git a/python/samples/02-agents/providers/foundry/creating_prompt_agents.py b/python/samples/02-agents/providers/foundry/creating_prompt_agents.py index 268856efab..45c65d518c 100644 --- a/python/samples/02-agents/providers/foundry/creating_prompt_agents.py +++ b/python/samples/02-agents/providers/foundry/creating_prompt_agents.py @@ -6,7 +6,7 @@ from typing import Annotated from agent_framework import Agent, tool -from agent_framework.foundry import FoundryChatClient, create_prompt_agent, to_prompt_agent +from agent_framework.foundry import FoundryChatClient, to_prompt_agent from azure.ai.projects.aio import AIProjectClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -20,18 +20,17 @@ 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 +2. Converted to a ``PromptAgentDefinition`` with ``to_prompt_agent(agent)`` and + published via ``AIProjectClient.agents.create_version(...)``. + +The model is lifted from the bound ``FoundryChatClient`` and generation +parameters (``temperature``, ``top_p``, ``tool_choice``) are sourced from the +agent's ``default_options`` so the ``model``/``instructions``/``tools`` stay as +the single source of truth. Foundry-specific knobs such as ``reasoning``, +``rai_config``, ``text``, and ``structured_inputs`` are exposed as explicit +keyword arguments on ``to_prompt_agent``. + +``to_prompt_agent`` is experimental (``ExperimentalFeature.TO_PROMPT_AGENT``) and may change before reaching GA. Function tools defined in this file are exposed to the prompt agent as @@ -53,6 +52,7 @@ async def main() -> None: print("=== Foundry Prompt Agent Example ===\n") credential = AzureCliCredential() + project_client = AIProjectClient(endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], credential=credential) agent = Agent( client=FoundryChatClient( @@ -62,7 +62,6 @@ async def main() -> None: ), # 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.", @@ -71,6 +70,8 @@ async def main() -> None: FoundryChatClient.get_code_interpreter_tool(), book_hotel, ], + # Generation parameters set on the Agent flow through to the published prompt agent. + default_options={"temperature": 0.3, "top_p": 0.95}, ) # 1) Run locally via the Foundry Responses API @@ -79,28 +80,18 @@ async def main() -> None: 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. + # 2) Convert and publish. `to_prompt_agent` lifts `model`, `instructions`, `tools`, and the + # generation parameters from `default_options`. Use kwargs to set Foundry-specific fields + # (e.g. `reasoning`, `rai_config`) or to override a value from `default_options`. definition = to_prompt_agent(agent) - project_client = AIProjectClient( - endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - credential=credential, - ) - created_v2 = await project_client.agents.create_version( + created = 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}") + print(f"Prompt agent published: {created.name} v{created.version}") - # 4) Cleanup: delete the agent (and all its versions) so re-running the sample stays idempotent. + # 3) 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.") diff --git a/python/samples/02-agents/providers/foundry/using_prompt_agents.py b/python/samples/02-agents/providers/foundry/using_prompt_agents.py index c39dfa6c5e..783732ba44 100644 --- a/python/samples/02-agents/providers/foundry/using_prompt_agents.py +++ b/python/samples/02-agents/providers/foundry/using_prompt_agents.py @@ -6,7 +6,7 @@ from typing import Annotated from agent_framework import Agent, tool -from agent_framework.foundry import FoundryAgent, FoundryChatClient, create_prompt_agent +from agent_framework.foundry import FoundryAgent, FoundryChatClient, to_prompt_agent from azure.ai.projects.aio import AIProjectClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -15,23 +15,24 @@ load_dotenv() """ -Foundry Prompt Agent: Deploy, then Connect and Run +Foundry Prompt Agent: Convert, Publish, 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``. +2. Convert it with ``to_prompt_agent(agent)`` and publish via + ``AIProjectClient.agents.create_version(...)``. 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. + *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 +``to_prompt_agent`` is experimental (``ExperimentalFeature.TO_PROMPT_AGENT``) and may change before reaching GA. """ @@ -46,11 +47,12 @@ def book_hotel( async def main() -> None: - print("=== Foundry Prompt Agent: Deploy and Run ===\n") + print("=== Foundry Prompt Agent: Convert, Publish, Connect, and Run ===\n") project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] model = os.environ["FOUNDRY_MODEL"] credential = AzureCliCredential() + project_client = AIProjectClient(endpoint=project_endpoint, credential=credential) # 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. @@ -67,11 +69,17 @@ async def main() -> None: FoundryChatClient.get_web_search_tool(), book_hotel, ], + default_options={"temperature": 0.3}, ) - # 2) Publish as a prompt agent. The version returned by Foundry includes the version label + # 2) Convert and publish. The version returned by Foundry includes the version label # we need when connecting back to that specific deployment. - created = await create_prompt_agent(agent) + definition = to_prompt_agent(agent) + created = await project_client.agents.create_version( + agent_name=agent.name, + definition=definition, + description=agent.description, + ) 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. @@ -92,7 +100,6 @@ async def main() -> None: # 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.") From 5e46fd74fe20960815b8a9bbe755400fc0d08213 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 09:13:37 +0200 Subject: [PATCH 13/20] refactor(foundry): single source of truth for prompt-agent options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop duplicating the generation-parameter surface between FoundryChatOptions and to_prompt_agent. Translate every field with an Agent Framework equivalent (temperature, top_p, tool_choice, reasoning, response_format/text/verbosity) from agent.default_options via a new RawFoundryChatClient helper _prepare_prompt_agent_options. Only Foundry-specific fields with no AF equivalent — structured_inputs and rai_config — remain as keyword arguments on to_prompt_agent. - tool_choice is dropped when there are no tools (mirrors _prepare_options semantics and avoids polluting tool-less prompt agents with Agent.__init__'s 'auto' default). - response_format Pydantic models route through openai.lib._parsing._responses.type_to_text_format_param; dict shapes go through the existing _prepare_response_and_text_format helper. - default_options is not mutated; text dict is defensively copied. Tests, README, and creating_prompt_agents.py sample updated to reflect the new single-source model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/foundry/README.md | 45 +-- .../agent_framework_foundry/_chat_client.py | 107 ++++++- .../_to_prompt_agent.py | 94 ++---- .../tests/foundry/test_to_prompt_agent.py | 291 ++++++++++++------ .../foundry/creating_prompt_agents.py | 26 +- 5 files changed, 379 insertions(+), 184 deletions(-) diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index 5f4ddb1bcb..ace56ff57e 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -121,21 +121,26 @@ from `default_options["model"]` first and falls back to the bound the same agent definition you run locally can be published as a hosted prompt agent without restating the model deployment name. -Generation parameters with an Agent Framework equivalent are sourced from -`agent.default_options` when not passed explicitly to `to_prompt_agent`: - -- `temperature` — from `default_options["temperature"]` -- `top_p` — from `default_options["top_p"]` -- `tool_choice` — from `default_options["tool_choice"]` *when it is a string* - (e.g. `"auto"`, `"required"`, `"none"`). Non-string Agent Framework - tool-choice values are ignored — pass an explicit `ToolChoiceParam` via the - keyword argument when you need one. - -Foundry-specific knobs are keyword-only on `to_prompt_agent`: `reasoning`, -`text`, `structured_inputs`, and `rai_config`. - -Precedence is always: **explicit keyword argument > `default_options` entry > -unset on the resulting definition.** +Every generation parameter that has an Agent Framework equivalent is sourced +from `agent.default_options` and translated into the matching Foundry shape by +`FoundryChatClient._prepare_prompt_agent_options`: + +| `default_options` key | `PromptAgentDefinition` field | +|---|---| +| `temperature` | `temperature` | +| `top_p` | `top_p` | +| `tool_choice` (dropped when no tools) | `tool_choice` (`str` / `ToolChoiceFunction` / `ToolChoiceAllowed`) | +| `reasoning` (dict or `Reasoning`) | `reasoning` | +| `response_format` (dict or `BaseModel`) | `text.format` | +| `verbosity` | `text.verbosity` | +| `text` | merged into `text` | + +This keeps the `Agent` as the single source of truth for everything it can +already express. Only Foundry-specific fields with no Agent Framework +equivalent are accepted as keyword arguments on `to_prompt_agent`: + +- `structured_inputs` — `dict[str, StructuredInputDefinition]` +- `rai_config` — `RaiConfig` ```python import asyncio @@ -144,7 +149,6 @@ import os from agent_framework import Agent from agent_framework.foundry import FoundryChatClient, to_prompt_agent from azure.ai.projects.aio import AIProjectClient -from azure.ai.projects.models import Reasoning from azure.identity.aio import AzureCliCredential @@ -166,11 +170,14 @@ async def main() -> None: FoundryChatClient.get_code_interpreter_tool(), ], # Generation parameters set on the Agent flow through automatically. - default_options={"temperature": 0.3, "top_p": 0.95}, + default_options={ + "temperature": 0.3, + "top_p": 0.95, + "reasoning": {"effort": "medium"}, + }, ) - # `reasoning` has no AF equivalent; pass it as a keyword argument. - definition = to_prompt_agent(agent, reasoning=Reasoning(effort="medium")) + definition = to_prompt_agent(agent) project_client = AIProjectClient(endpoint=project_endpoint, credential=credential) created = await project_client.agents.create_version( diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 6d7dc878ff..a0fb5f4fee 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -5,7 +5,7 @@ import logging import sys from collections.abc import Awaitable, Callable, Mapping, Sequence -from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast from agent_framework import ( ChatMiddlewareLayer, @@ -270,6 +270,111 @@ def _parse_chunk_from_openai( return update return super()._parse_chunk_from_openai(event, options, function_call_ids, seen_reasoning_delta_item_ids) + def _prepare_prompt_agent_options( + self, + default_options: Mapping[str, Any], + *, + has_tools: bool = False, + ) -> dict[str, Any]: + """Translate ``default_options`` into ``PromptAgentDefinition`` field kwargs. + + Reuses the same helpers as the regular request path + (``validate_tool_mode``, ``_prepare_response_and_text_format``, + ``type_to_text_format_param``) so a published prompt agent stays + consistent with the agent's local execution. + + Only fields with a direct ``PromptAgentDefinition`` counterpart are + translated: ``temperature``, ``top_p``, ``reasoning``, ``tool_choice``, + ``response_format`` / ``text`` / ``verbosity``. Other + ``OpenAIChatOptions`` keys (``include``, ``prompt``, ``store``, etc.) + have no prompt-agent equivalent and are intentionally ignored. The + input mapping is never mutated. + + Args: + default_options: The agent's ``default_options`` mapping. + + Keyword Args: + has_tools: When ``False``, ``tool_choice`` is dropped (no point + emitting a tool selection policy when the definition has no + tools), mirroring the regular request path in + ``_prepare_options``. + + Returns: + A dict ready to splat into ``PromptAgentDefinition(**...)``. + Unset fields are omitted. + """ + from agent_framework._types import validate_tool_mode + from azure.ai.projects.models import ( + PromptAgentDefinitionTextOptions, + Reasoning, + ToolChoiceAllowed, + ToolChoiceFunction, + ) + from openai.lib._parsing._responses import ( # type: ignore[reportPrivateImportUsage] + type_to_text_format_param, + ) + from pydantic import BaseModel + + result: dict[str, Any] = {} + + if (temperature := default_options.get("temperature")) is not None: + result["temperature"] = temperature + if (top_p := default_options.get("top_p")) is not None: + result["top_p"] = top_p + + if (reasoning := default_options.get("reasoning")) is not None: + if isinstance(reasoning, Reasoning): + result["reasoning"] = reasoning + elif isinstance(reasoning, Mapping): + result["reasoning"] = Reasoning(**dict(cast("Mapping[str, Any]", reasoning))) + else: + result["reasoning"] = reasoning + + if has_tools and (tool_choice := default_options.get("tool_choice")) is not None: + tool_mode = validate_tool_mode(tool_choice) + if tool_mode is not None: + mode = tool_mode.get("mode") + func_name = tool_mode.get("required_function_name") + allowed = tool_mode.get("allowed_tools") + if mode == "required" and func_name is not None: + result["tool_choice"] = ToolChoiceFunction(name=func_name) + elif mode == "auto" and allowed is not None: + result["tool_choice"] = ToolChoiceAllowed( + mode="auto", + tools=[{"type": "function", "name": name} for name in allowed], + ) + else: + result["tool_choice"] = mode + + existing_text = default_options.get("text") + text_config: dict[str, Any] | None = ( + dict(cast("Mapping[str, Any]", existing_text)) if isinstance(existing_text, Mapping) else None + ) + response_format = default_options.get("response_format") + if response_format is not None or text_config is not None: + if isinstance(response_format, type) and issubclass(response_format, BaseModel): + format_config = dict(type_to_text_format_param(response_format)) + text_config = dict(text_config) if text_config else {} + if "format" in text_config and text_config["format"] != format_config: + raise ValueError("Conflicting response_format definitions detected.") + text_config["format"] = format_config + elif response_format is not None: + response_format_model, text_config = self._prepare_response_and_text_format( + response_format=response_format, text_config=text_config + ) + if response_format_model is not None: + raise ValueError( + "response_format must be a Pydantic BaseModel subclass or a mapping when " + "converting to a PromptAgentDefinition." + ) + if (verbosity := default_options.get("verbosity")) is not None: + text_config = dict(text_config) if text_config else {} + text_config["verbosity"] = verbosity + if text_config: + result["text"] = PromptAgentDefinitionTextOptions(text_config) + + return result + async def configure_azure_monitor( self, enable_sensitive_data: bool = False, diff --git a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py index a601465e56..e87d10099f 100644 --- a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py @@ -3,19 +3,20 @@ """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 +: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. +without restating the model deployment name. Generation parameters +(``temperature``, ``top_p``, ``tool_choice``, ``reasoning``, +``response_format`` / ``text`` / ``verbosity``) are translated from +``agent.default_options`` by ``FoundryChatClient._prepare_prompt_agent_options`` +so they stay consistent with the agent's local request path. -Generation parameters (``temperature``, ``top_p``, ``tool_choice``) are sourced -from ``agent.default_options`` when not overridden by an explicit keyword -argument. Foundry-specific parameters that have no Agent Framework equivalent -(``reasoning``, ``text``, ``structured_inputs``, ``rai_config``) are accepted -as keyword arguments only. +Parameters with no Agent Framework equivalent (``structured_inputs``, +``rai_config``) are accepted as keyword arguments only. Function tools derived from local Python callables are translated to Foundry ``FunctionTool`` *declarations* only. Prompt agents are server-side, so the @@ -38,12 +39,9 @@ from agent_framework import Agent from azure.ai.projects.models import ( PromptAgentDefinition, - PromptAgentDefinitionTextOptions, RaiConfig, - Reasoning, StructuredInputDefinition, Tool, - ToolChoiceParam, ) @@ -51,11 +49,6 @@ def to_prompt_agent( agent: Agent, *, - temperature: float | None = None, - top_p: float | None = None, - tool_choice: str | ToolChoiceParam | None = None, - reasoning: Reasoning | None = None, - text: PromptAgentDefinitionTextOptions | None = None, structured_inputs: Mapping[str, StructuredInputDefinition] | None = None, rai_config: RaiConfig | None = None, ) -> PromptAgentDefinition: @@ -64,29 +57,23 @@ def to_prompt_agent( The agent's chat client must be a :class:`FoundryChatClient` (or any subclass). The model deployment name is lifted from the bound client. - Generation parameters that have an Agent Framework ``ChatOptions`` - equivalent are sourced from ``agent.default_options`` when not supplied as - a keyword argument here. Precedence is: explicit keyword > default_options - entry > unset on the resulting definition. Parameters specific to Foundry - prompt agents are accepted as keyword arguments only. + All generation parameters that have an Agent Framework equivalent + (``temperature``, ``top_p``, ``tool_choice``, ``reasoning``, + ``response_format`` / ``text`` / ``verbosity``) are sourced from + ``agent.default_options`` and translated by + ``FoundryChatClient._prepare_prompt_agent_options``. The agent is the + single source of truth for these; configure them on the ``Agent`` (or + pass ``default_options={...}`` to its constructor) rather than here. Args: agent: An Agent Framework agent whose client is a ``FoundryChatClient``. Keyword Args: - temperature: Sampling temperature. Falls back to - ``agent.default_options['temperature']`` if unset. - top_p: Nucleus sampling parameter. Falls back to - ``agent.default_options['top_p']`` if unset. - tool_choice: How the model should pick tools. When unset, a *string* - ``agent.default_options['tool_choice']`` (e.g. ``"auto"``, - ``"required"``, ``"none"``) is propagated; non-string Agent - Framework tool-choice values are ignored. - reasoning: Foundry ``Reasoning`` configuration. - text: Foundry ``PromptAgentDefinitionTextOptions`` configuration. structured_inputs: Mapping of structured input names to - ``StructuredInputDefinition`` entries. + ``StructuredInputDefinition`` entries. Foundry-only; no + ``ChatOptions`` equivalent. rai_config: Foundry ``RaiConfig`` to attach to the definition. + Foundry-only; no ``ChatOptions`` equivalent. Returns: A ``PromptAgentDefinition`` carrying the agent's model, instructions, @@ -115,27 +102,19 @@ def to_prompt_agent( getattr(agent, "mcp_tools", []), ) - resolved_temperature = temperature if temperature is not None else agent.default_options.get("temperature") - resolved_top_p = top_p if top_p is not None else agent.default_options.get("top_p") - resolved_tool_choice = tool_choice if tool_choice is not None else _default_options_tool_choice(agent) + translated = agent.client._prepare_prompt_agent_options( # pyright: ignore[reportPrivateUsage] + agent.default_options, + has_tools=bool(tools), + ) from azure.ai.projects.models import PromptAgentDefinition - kwargs: dict[str, Any] = { - "model": model, - "instructions": instructions, - "tools": tools or None, - } - if resolved_temperature is not None: - kwargs["temperature"] = resolved_temperature - if resolved_top_p is not None: - kwargs["top_p"] = resolved_top_p - if resolved_tool_choice is not None: - kwargs["tool_choice"] = resolved_tool_choice - if reasoning is not None: - kwargs["reasoning"] = reasoning - if text is not None: - kwargs["text"] = text + kwargs: dict[str, Any] = {"model": model} + if instructions is not None: + kwargs["instructions"] = instructions + if tools: + kwargs["tools"] = tools + kwargs.update(translated) if structured_inputs is not None: kwargs["structured_inputs"] = dict(structured_inputs) if rai_config is not None: @@ -144,21 +123,6 @@ def to_prompt_agent( return PromptAgentDefinition(**kwargs) -def _default_options_tool_choice(agent: Agent) -> str | None: - """Return ``agent.default_options['tool_choice']`` only when it is a string. - - Agent Framework's ``tool_choice`` is ``ToolMode | Literal["auto", "required", "none"]``. - Foundry's prompt-agent ``tool_choice`` accepts either a string or a - ``ToolChoiceParam`` model; the simple string values overlap cleanly, while - AF ``ToolMode`` instances have no canonical Foundry mapping. Anything that - is not already a string is left to the explicit keyword argument. - """ - value = agent.default_options.get("tool_choice") - if isinstance(value, str): - return value - return None - - def _convert_tools( tools: Iterable[Any] | None, mcp_tools: Iterable[MCPTool] | None, diff --git a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py index c15cf8cb88..1d505f2dc1 100644 --- a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py +++ b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py @@ -15,6 +15,7 @@ RaiConfig, Reasoning, StructuredInputDefinition, + ToolChoiceAllowed, ToolChoiceFunction, WebSearchTool, ) @@ -27,6 +28,7 @@ from azure.ai.projects.models import ( Tool as ProjectsTool, ) +from pydantic import BaseModel from agent_framework_foundry import ( FoundryChatClient, @@ -96,20 +98,16 @@ class NotFoundryChatClient: 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 + client.model = "" agent = _make_agent(client) - agent.default_options.pop("model", None) # and on the agent + agent.default_options.pop("model", None) 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. - """ + """A tool-only agent (no instructions) produces a definition with instructions=None.""" agent = _make_agent( _make_foundry_chat_client(), tools=[WebSearchTool()], @@ -120,16 +118,11 @@ def test_to_prompt_agent_no_instructions() -> None: 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. - """ + """default_options['model'] wins over the bound client's model.""" client = _make_foundry_chat_client(model="client-model") agent = _make_agent(client, instructions="x", default_options={"model": "agent-override"}) @@ -165,6 +158,32 @@ def test_to_prompt_agent_is_marked_experimental() -> None: assert getattr(to_prompt_agent, "__feature_id__", None) == ExperimentalFeature.TO_PROMPT_AGENT.value +def test_to_prompt_agent_does_not_mutate_default_options() -> None: + """Conversion never mutates the translatable option values in ``agent.default_options``.""" + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + default_options={ + "temperature": 0.3, + "top_p": 0.5, + "reasoning": {"effort": "low"}, + "response_format": {"type": "json_object"}, + "verbosity": "low", + }, + tools=[get_weather], + ) + reasoning_before = dict(agent.default_options["reasoning"]) # type: ignore[index] + response_format_before = dict(agent.default_options["response_format"]) # type: ignore[index] + tool_choice_before = agent.default_options.get("tool_choice") + + to_prompt_agent(agent) + + assert dict(agent.default_options["reasoning"]) == reasoning_before # type: ignore[index] + assert dict(agent.default_options["response_format"]) == response_format_before # type: ignore[index] + assert agent.default_options.get("tool_choice") == tool_choice_before + assert "text" not in agent.default_options + + # --------------------------------------------------------------------------- # Tool conversion # --------------------------------------------------------------------------- @@ -180,7 +199,6 @@ def test_to_prompt_agent_passes_through_sdk_tool_instances() -> None: 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 @@ -291,12 +309,13 @@ def test_to_prompt_agent_rejects_dict_tool_without_type() -> None: # --------------------------------------------------------------------------- -# Generation parameters sourced from default_options (with kwarg overrides) +# Generation parameters sourced from default_options +# (translated by FoundryChatClient._prepare_prompt_agent_options) # --------------------------------------------------------------------------- def test_to_prompt_agent_temperature_top_p_unset_by_default() -> None: - """Without default_options or kwargs, temperature/top_p are unset on the definition.""" + """Without default_options entries, temperature/top_p are unset on the definition.""" agent = _make_agent(_make_foundry_chat_client(), instructions="x") definition = to_prompt_agent(agent) @@ -322,128 +341,216 @@ def test_to_prompt_agent_lifts_temperature_top_p_from_default_options() -> None: assert definition.top_p == 0.8 -def test_to_prompt_agent_temperature_top_p_kwargs_win_over_default_options() -> None: - """Explicit kwargs override values present in default_options.""" +def test_to_prompt_agent_temperature_zero_is_honored() -> None: + """A literal ``0.0`` in default_options is treated as explicit, not as unset.""" agent = _make_agent( _make_foundry_chat_client(), instructions="x", - default_options={"temperature": 0.42, "top_p": 0.8}, + default_options={"temperature": 0.0, "top_p": 0.0}, ) - definition = to_prompt_agent(agent, temperature=0.1, top_p=0.2) + definition = to_prompt_agent(agent) - assert definition.temperature == 0.1 - assert definition.top_p == 0.2 + assert definition.temperature == 0.0 + assert definition.top_p == 0.0 -def test_to_prompt_agent_temperature_zero_kwarg_is_honored() -> None: - """A literal ``0.0`` kwarg is treated as explicit, not as "fall back to default_options". +def test_to_prompt_agent_tool_choice_omitted_when_no_tools() -> None: + """``tool_choice`` is dropped when the definition has no tools. - Guards against an ``if temperature:`` truthiness check that would silently drop the value. + Mirrors RawOpenAIChatClient._prepare_options behavior. This also keeps + Agent.__init__'s default ``tool_choice="auto"`` from polluting tool-less + prompt agents. """ + agent = _make_agent(_make_foundry_chat_client(), instructions="x") + + definition = to_prompt_agent(agent) + + assert definition.tool_choice is None + assert "tool_choice" not in definition.as_dict() + + +def test_to_prompt_agent_tool_choice_auto_with_tools() -> None: + """When tools are present, the default ``tool_choice="auto"`` flows through.""" + agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[get_weather]) + + definition = to_prompt_agent(agent) + + assert definition.tool_choice == "auto" + + +def test_to_prompt_agent_tool_choice_required_string_with_tools() -> None: + """A string ``tool_choice="required"`` flows through when tools are present.""" agent = _make_agent( _make_foundry_chat_client(), instructions="x", - default_options={"temperature": 0.7}, + tools=[get_weather], + default_options={"tool_choice": "required"}, ) - definition = to_prompt_agent(agent, temperature=0.0, top_p=0.0) + definition = to_prompt_agent(agent) - assert definition.temperature == 0.0 - assert definition.top_p == 0.0 + assert definition.tool_choice == "required" -def test_to_prompt_agent_defaults_tool_choice_to_auto() -> None: - """Agent.__init__ inserts tool_choice='auto' by default; the converter propagates it.""" - agent = _make_agent(_make_foundry_chat_client(), instructions="x") +def test_to_prompt_agent_tool_choice_required_function_dict() -> None: + """tool_choice mode=required with a function name → ToolChoiceFunction.""" + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + tools=[get_weather], + default_options={ + "tool_choice": {"mode": "required", "required_function_name": "get_weather"}, + }, + ) definition = to_prompt_agent(agent) - assert definition.tool_choice == "auto" + assert isinstance(definition.tool_choice, ToolChoiceFunction) + assert definition.tool_choice.name == "get_weather" -def test_to_prompt_agent_lifts_string_tool_choice_from_default_options() -> None: - """A string ``tool_choice`` in default_options propagates to the definition.""" +def test_to_prompt_agent_tool_choice_auto_allowed_tools() -> None: + """tool_choice mode=auto with allowed_tools → ToolChoiceAllowed.""" agent = _make_agent( _make_foundry_chat_client(), instructions="x", - default_options={"tool_choice": "required"}, + tools=[get_weather], + default_options={ + "tool_choice": {"mode": "auto", "allowed_tools": ["get_weather"]}, + }, ) definition = to_prompt_agent(agent) - assert definition.tool_choice == "required" + assert isinstance(definition.tool_choice, ToolChoiceAllowed) + assert definition.tool_choice.mode == "auto" + assert definition.tool_choice.tools == [{"type": "function", "name": "get_weather"}] -def test_to_prompt_agent_ignores_non_string_tool_choice_from_default_options() -> None: - """Non-string ``tool_choice`` values (e.g. AF ToolMode) are not auto-propagated.""" - agent = _make_agent(_make_foundry_chat_client(), instructions="x") - # Replace the str default with a non-str sentinel to mimic an AF ToolMode value. - agent.default_options["tool_choice"] = object() # type: ignore[typeddict-item] +def test_to_prompt_agent_lifts_reasoning_dict_from_default_options() -> None: + """A reasoning dict in default_options becomes a Foundry ``Reasoning`` model.""" + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + default_options={"reasoning": {"effort": "high", "summary": "concise"}}, + ) definition = to_prompt_agent(agent) - assert definition.tool_choice is None + assert isinstance(definition.reasoning, Reasoning) + assert definition.reasoning.effort == "high" + assert definition.reasoning.summary == "concise" -def test_to_prompt_agent_tool_choice_kwarg_wins_over_default_options() -> None: - """An explicit ``tool_choice`` kwarg wins over a default_options entry.""" +def test_to_prompt_agent_lifts_reasoning_model_from_default_options() -> None: + """A pre-built ``Reasoning`` model in default_options is passed through.""" + reasoning = Reasoning(effort="medium") agent = _make_agent( _make_foundry_chat_client(), instructions="x", - default_options={"tool_choice": "auto"}, + default_options={"reasoning": reasoning}, ) - definition = to_prompt_agent(agent, tool_choice="none") + definition = to_prompt_agent(agent) - assert definition.tool_choice == "none" + assert definition.reasoning is reasoning -def test_to_prompt_agent_tool_choice_accepts_param_model() -> None: - """A ``ToolChoiceParam`` instance passed as kwarg is forwarded to the definition.""" - choice = ToolChoiceFunction(name="get_weather") - agent = _make_agent(_make_foundry_chat_client(), instructions="x") +def test_to_prompt_agent_lifts_response_format_dict_to_text() -> None: + """A ``response_format`` dict in default_options becomes ``text.format``.""" + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + default_options={ + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "weather", + "schema": {"type": "object", "properties": {"temp": {"type": "number"}}}, + }, + }, + }, + ) - definition = to_prompt_agent(agent, tool_choice=choice) + definition = to_prompt_agent(agent) - assert definition.tool_choice is choice + assert isinstance(definition.text, PromptAgentDefinitionTextOptions) + format_dict = definition.text["format"] + assert format_dict is not None + assert format_dict["type"] == "json_schema" + assert format_dict["name"] == "weather" + assert format_dict["schema"] == {"type": "object", "properties": {"temp": {"type": "number"}}} -# --------------------------------------------------------------------------- -# Foundry-specific kwargs (no AF ChatOptions equivalent) -# --------------------------------------------------------------------------- +def test_to_prompt_agent_lifts_response_format_pydantic_to_text() -> None: + """A Pydantic ``BaseModel`` response_format becomes ``text.format`` json_schema.""" + class WeatherReply(BaseModel): + location: str + condition: str -def test_to_prompt_agent_kwarg_only_fields_unset_by_default() -> None: - """reasoning, text, structured_inputs, rai_config are absent from the payload when unset.""" - agent = _make_agent(_make_foundry_chat_client(), instructions="x") + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + default_options={"response_format": WeatherReply}, + ) - payload = to_prompt_agent(agent).as_dict() + definition = to_prompt_agent(agent) - assert "reasoning" not in payload - assert "text" not in payload - assert "structured_inputs" not in payload - assert "rai_config" not in payload + assert isinstance(definition.text, PromptAgentDefinitionTextOptions) + format_dict = definition.text["format"] + assert format_dict is not None + assert format_dict["type"] == "json_schema" + assert format_dict["name"] == "WeatherReply" + assert "schema" in format_dict + assert "location" in format_dict["schema"]["properties"] -def test_to_prompt_agent_forwards_reasoning_kwarg() -> None: - """A ``Reasoning`` kwarg is forwarded to the definition.""" - reasoning = Reasoning(effort="high") - agent = _make_agent(_make_foundry_chat_client(), instructions="x") +def test_to_prompt_agent_merges_verbosity_into_text() -> None: + """A ``verbosity`` entry merges into the ``text`` config.""" + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + default_options={"verbosity": "low"}, + ) - definition = to_prompt_agent(agent, reasoning=reasoning) + definition = to_prompt_agent(agent) - assert definition.reasoning is reasoning + assert isinstance(definition.text, PromptAgentDefinitionTextOptions) + # PromptAgentDefinitionTextOptions only declares ``format``, but its + # mapping-init preserves extra keys for server-side use. + assert dict(definition.text).get("verbosity") == "low" + + +def test_to_prompt_agent_passes_through_text_dict_from_default_options() -> None: + """A ``text`` dict in default_options flows through to the definition.""" + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + default_options={"text": {"format": {"type": "text"}, "verbosity": "high"}}, + ) + + definition = to_prompt_agent(agent) + + assert isinstance(definition.text, PromptAgentDefinitionTextOptions) + assert definition.text["format"] == {"type": "text"} + assert dict(definition.text).get("verbosity") == "high" + + +# --------------------------------------------------------------------------- +# Foundry-specific kwargs (no AF ChatOptions equivalent) +# --------------------------------------------------------------------------- -def test_to_prompt_agent_forwards_text_kwarg() -> None: - """A ``PromptAgentDefinitionTextOptions`` kwarg is forwarded to the definition.""" - text = PromptAgentDefinitionTextOptions() +def test_to_prompt_agent_kwarg_only_fields_unset_by_default() -> None: + """structured_inputs and rai_config are absent from the payload when unset.""" agent = _make_agent(_make_foundry_chat_client(), instructions="x") - definition = to_prompt_agent(agent, text=text) + payload = to_prompt_agent(agent).as_dict() - assert definition.text is text + assert "structured_inputs" not in payload + assert "rai_config" not in payload def test_to_prompt_agent_forwards_structured_inputs_kwarg() -> None: @@ -456,7 +563,6 @@ def test_to_prompt_agent_forwards_structured_inputs_kwarg() -> None: assert definition.structured_inputs is not None assert set(definition.structured_inputs) == {"city"} assert definition.structured_inputs["city"] is inputs["city"] - # Defensive copy: mutating the caller's mapping after the call does not leak in. inputs["other"] = StructuredInputDefinition(description="x") assert "other" not in definition.structured_inputs @@ -471,34 +577,41 @@ def test_to_prompt_agent_forwards_rai_config_kwarg() -> None: assert definition.rai_config is rai_config -def test_to_prompt_agent_combines_all_parameters() -> None: - """Every parameter routes through to a single definition simultaneously.""" - reasoning = Reasoning(effort="medium") - text = PromptAgentDefinitionTextOptions() +# --------------------------------------------------------------------------- +# Combined integration +# --------------------------------------------------------------------------- + + +def test_to_prompt_agent_combines_all_sources() -> None: + """Generation params from default_options + Foundry-only kwargs combine cleanly.""" rai_config = RaiConfig() structured = {"q": StructuredInputDefinition(description="query")} agent = _make_agent( _make_foundry_chat_client(), instructions="x", - default_options={"temperature": 0.3, "top_p": 0.95, "tool_choice": "auto"}, + default_options={ + "temperature": 0.3, + "top_p": 0.95, + "tool_choice": "auto", + "reasoning": {"effort": "medium"}, + "verbosity": "low", + }, tools=[get_weather], ) definition = to_prompt_agent( agent, - temperature=0.5, - reasoning=reasoning, - text=text, structured_inputs=structured, rai_config=rai_config, ) - # Kwargs overrode default_options for temperature; top_p and tool_choice came from default_options. - assert definition.temperature == 0.5 + assert definition.temperature == 0.3 assert definition.top_p == 0.95 assert definition.tool_choice == "auto" - assert definition.reasoning is reasoning - assert definition.text is text + assert isinstance(definition.reasoning, Reasoning) + assert definition.reasoning.effort == "medium" + assert isinstance(definition.text, PromptAgentDefinitionTextOptions) + assert dict(definition.text).get("verbosity") == "low" assert definition.rai_config is rai_config assert definition.structured_inputs is not None and "q" in definition.structured_inputs assert definition.tools is not None and len(definition.tools) == 1 diff --git a/python/samples/02-agents/providers/foundry/creating_prompt_agents.py b/python/samples/02-agents/providers/foundry/creating_prompt_agents.py index 45c65d518c..f2f9794616 100644 --- a/python/samples/02-agents/providers/foundry/creating_prompt_agents.py +++ b/python/samples/02-agents/providers/foundry/creating_prompt_agents.py @@ -23,12 +23,14 @@ 2. Converted to a ``PromptAgentDefinition`` with ``to_prompt_agent(agent)`` and published via ``AIProjectClient.agents.create_version(...)``. -The model is lifted from the bound ``FoundryChatClient`` and generation -parameters (``temperature``, ``top_p``, ``tool_choice``) are sourced from the -agent's ``default_options`` so the ``model``/``instructions``/``tools`` stay as -the single source of truth. Foundry-specific knobs such as ``reasoning``, -``rai_config``, ``text``, and ``structured_inputs`` are exposed as explicit -keyword arguments on ``to_prompt_agent``. +The model is lifted from the bound ``FoundryChatClient`` and every generation +parameter that has an Agent Framework equivalent (``temperature``, ``top_p``, +``tool_choice``, ``reasoning``, ``response_format`` / ``text`` / ``verbosity``) +is sourced from the agent's ``default_options``, so the ``Agent`` is the +single source of truth for them. + +Only Foundry-only fields (``structured_inputs``, ``rai_config``) are exposed +as keyword arguments on ``to_prompt_agent``. ``to_prompt_agent`` is experimental (``ExperimentalFeature.TO_PROMPT_AGENT``) and may change before reaching GA. @@ -71,7 +73,11 @@ async def main() -> None: book_hotel, ], # Generation parameters set on the Agent flow through to the published prompt agent. - default_options={"temperature": 0.3, "top_p": 0.95}, + default_options={ + "temperature": 0.3, + "top_p": 0.95, + "reasoning": {"effort": "medium"}, + }, ) # 1) Run locally via the Foundry Responses API @@ -80,9 +86,9 @@ async def main() -> None: response = await agent.run(local_query) print(f"Agent: {response}\n") - # 2) Convert and publish. `to_prompt_agent` lifts `model`, `instructions`, `tools`, and the - # generation parameters from `default_options`. Use kwargs to set Foundry-specific fields - # (e.g. `reasoning`, `rai_config`) or to override a value from `default_options`. + # 2) Convert and publish. `to_prompt_agent` lifts `model`, `instructions`, `tools`, and every + # generation parameter from `default_options`. Use kwargs only for Foundry-only fields + # (`structured_inputs`, `rai_config`) that have no Agent Framework equivalent. definition = to_prompt_agent(agent) created = await project_client.agents.create_version( agent_name=agent.name, From 31216def85774c0e28a8f4188547675bed3827b1 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 09:15:36 +0200 Subject: [PATCH 14/20] docs(foundry): consolidate prompt-agent sample Drop creating_prompt_agents.py (the publish-only variant) and rename using_prompt_agents.py to foundry_prompt_agents.py so the single sample covers the full convert -> publish -> connect -> run loop. Update the README link list accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/foundry/README.md | 13 +-- .../foundry/creating_prompt_agents.py | 106 ------------------ ...mpt_agents.py => foundry_prompt_agents.py} | 0 3 files changed, 5 insertions(+), 114 deletions(-) delete mode 100644 python/samples/02-agents/providers/foundry/creating_prompt_agents.py rename python/samples/02-agents/providers/foundry/{using_prompt_agents.py => foundry_prompt_agents.py} (100%) diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index ace56ff57e..294b47bed1 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -224,12 +224,9 @@ Behaviour: 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 `to_prompt_agent` + - `AIProjectClient`. -- [`using_prompt_agents.py`](../../samples/02-agents/providers/foundry/using_prompt_agents.py) - \u2014 publish with `to_prompt_agent`, then connect back with `FoundryAgent` - and execute the same local `@tool` callable that the deployed prompt agent +See the runnable example under `samples/02-agents/providers/foundry/`: + +- [`foundry_prompt_agents.py`](../../samples/02-agents/providers/foundry/foundry_prompt_agents.py) + — publish with `to_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/samples/02-agents/providers/foundry/creating_prompt_agents.py b/python/samples/02-agents/providers/foundry/creating_prompt_agents.py deleted file mode 100644 index f2f9794616..0000000000 --- a/python/samples/02-agents/providers/foundry/creating_prompt_agents.py +++ /dev/null @@ -1,106 +0,0 @@ -# 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, 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. Converted to a ``PromptAgentDefinition`` with ``to_prompt_agent(agent)`` and - published via ``AIProjectClient.agents.create_version(...)``. - -The model is lifted from the bound ``FoundryChatClient`` and every generation -parameter that has an Agent Framework equivalent (``temperature``, ``top_p``, -``tool_choice``, ``reasoning``, ``response_format`` / ``text`` / ``verbosity``) -is sourced from the agent's ``default_options``, so the ``Agent`` is the -single source of truth for them. - -Only Foundry-only fields (``structured_inputs``, ``rai_config``) are exposed -as keyword arguments on ``to_prompt_agent``. - -``to_prompt_agent`` is 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() - project_client = AIProjectClient(endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], credential=credential) - - 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. - 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, - ], - # Generation parameters set on the Agent flow through to the published prompt agent. - default_options={ - "temperature": 0.3, - "top_p": 0.95, - "reasoning": {"effort": "medium"}, - }, - ) - - # 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) Convert and publish. `to_prompt_agent` lifts `model`, `instructions`, `tools`, and every - # generation parameter from `default_options`. Use kwargs only for Foundry-only fields - # (`structured_inputs`, `rai_config`) that have no Agent Framework equivalent. - definition = to_prompt_agent(agent) - created = await project_client.agents.create_version( - agent_name=agent.name, - definition=definition, - description=agent.description, - ) - print(f"Prompt agent published: {created.name} v{created.version}") - - # 3) 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/foundry_prompt_agents.py similarity index 100% rename from python/samples/02-agents/providers/foundry/using_prompt_agents.py rename to python/samples/02-agents/providers/foundry/foundry_prompt_agents.py From 7bbd8a9f83ede7a19ee4b7451cf2ca348cb1b836 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 09:17:51 +0200 Subject: [PATCH 15/20] docs(foundry): run local Agent + deployed agent in same sample Add an agent.run() call against the local Agent before publishing, then run the deployed prompt agent on the same query. Expand the docstring with a compare-and-contrast covering runtime/latency, configurability, and persistence/sharing differences between the two execution paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../foundry/foundry_prompt_agents.py | 57 +++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py b/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py index 783732ba44..6f1593c0f5 100644 --- a/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py +++ b/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py @@ -21,17 +21,44 @@ 1. Build an ``Agent`` backed by ``FoundryChatClient`` with a local ``@tool`` function and Foundry-hosted tools. -2. Convert it with ``to_prompt_agent(agent)`` and publish via +2. Run the local ``Agent`` directly against the Foundry Responses API. +3. Convert it with ``to_prompt_agent(agent)`` and publish via ``AIProjectClient.agents.create_version(...)``. -3. Connect to the deployed prompt agent with ``FoundryAgent`` and pass the +4. 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 +executes the local Python implementation by matching tool names — keeping the schema on the server and the implementation on the client in sync. +Local ``Agent`` vs deployed prompt agent — compare & contrast when calling +``run`` on each: + +* **Runtime / latency.** ``Agent.run`` issues a single ``responses.create`` + call against the Foundry Responses API. ``FoundryAgent.run`` against a + published prompt agent goes through the Foundry Agents service, which + resolves the stored ``PromptAgentDefinition`` (instructions, tools, + generation parameters, RAI config) on every call before forwarding to the + model. Expect a small per-call overhead on the deployed path in exchange + for centrally managed configuration. +* **Configurability.** With the local ``Agent``, model, instructions, tools, + ``default_options``, etc. live in your process — change them, restart, and + the next ``run`` picks them up. With the deployed prompt agent, those same + fields are versioned server-side: publishing a new version updates every + consumer at once and you keep an audit trail of previous versions, but you + must call ``create_version`` (or pin ``agent_version``) to roll changes + out or back. +* **Persistence / sharing.** A local ``Agent`` instance only exists for the + lifetime of the process that created it; tools and instructions are not + discoverable by anything else. A published prompt agent is a first-class + Foundry resource — other services, other languages, and the Foundry portal + can all bind to it by ``agent_name`` (+ optional ``agent_version``) and get + the same behaviour. Local ``@tool`` callables stay on the client; only + their JSON schema is persisted, so the implementation must be supplied + again at connection time via ``FoundryAgent(tools=[...])``. + ``to_prompt_agent`` is experimental (``ExperimentalFeature.TO_PROMPT_AGENT``) and may change before reaching GA. """ @@ -72,7 +99,15 @@ async def main() -> None: default_options={"temperature": 0.3}, ) - # 2) Convert and publish. The version returned by Foundry includes the version label + query = "Book me a hotel in Seattle for 3 nights." + + # 2) Run the local Agent. This calls the Foundry Responses API directly — instructions, + # tools, and generation parameters live in this process only. + print(f"User (local Agent): {query}") + local_result = await agent.run(query) + print(f"Local Agent: {local_result}\n") + + # 3) Convert and publish. The version returned by Foundry includes the version label # we need when connecting back to that specific deployment. definition = to_prompt_agent(agent) created = await project_client.agents.create_version( @@ -82,9 +117,10 @@ async def main() -> None: ) 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. + # 4) 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. + # matching by name. Compared to step 2, instructions/tools/generation parameters now + # come from the stored PromptAgentDefinition rather than this process. deployed = FoundryAgent( project_endpoint=project_endpoint, agent_name=created.name, @@ -93,12 +129,11 @@ async def main() -> None: 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}") + print(f"User (deployed agent): {query}") + deployed_result = await deployed.run(query) + print(f"Deployed Agent: {deployed_result}") - # 4) Cleanup: delete the deployed prompt agent (and all its versions) so re-running the + # 5) Cleanup: delete the deployed prompt agent (and all its versions) so re-running the # sample stays idempotent. await project_client.agents.delete(agent_name=created.name) print(f"\nDeleted prompt agent {created.name!r} and all its versions.") From ec9cf26ee1d45a21e8bbe7a1fa55cfab33049567 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 09:24:43 +0200 Subject: [PATCH 16/20] test(foundry): cover conflicting response_format + text.format in to_prompt_agent Exercises the ValueError path when a Pydantic response_format would overwrite an explicit text.format mapping with a different shape. Lifts _chat_client.py coverage from 89% to 90%. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/foundry/test_to_prompt_agent.py | 19 +++++++++++++++++++ .../foundry/foundry_prompt_agents.py | 8 ++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py index 1d505f2dc1..3b9a07b2d6 100644 --- a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py +++ b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py @@ -523,6 +523,25 @@ def test_to_prompt_agent_merges_verbosity_into_text() -> None: assert dict(definition.text).get("verbosity") == "low" +def test_to_prompt_agent_raises_on_conflicting_response_format_and_text_format() -> None: + """Pydantic ``response_format`` + a different ``text.format`` mapping must fail loudly.""" + + class WeatherReply(BaseModel): + location: str + + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + default_options={ + "response_format": WeatherReply, + "text": {"format": {"type": "json_object"}}, + }, + ) + + with pytest.raises(ValueError, match="Conflicting response_format"): + to_prompt_agent(agent) + + def test_to_prompt_agent_passes_through_text_dict_from_default_options() -> None: """A ``text`` dict in default_options flows through to the definition.""" agent = _make_agent( diff --git a/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py b/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py index 6f1593c0f5..89effa482b 100644 --- a/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py +++ b/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py @@ -96,7 +96,7 @@ async def main() -> None: FoundryChatClient.get_web_search_tool(), book_hotel, ], - default_options={"temperature": 0.3}, + default_options={"reasoning": {"effort": "medium"}}, ) query = "Book me a hotel in Seattle for 3 nights." @@ -109,15 +109,15 @@ async def main() -> None: # 3) Convert and publish. The version returned by Foundry includes the version label # we need when connecting back to that specific deployment. - definition = to_prompt_agent(agent) created = await project_client.agents.create_version( agent_name=agent.name, - definition=definition, + # note this line: + definition=to_prompt_agent(agent), description=agent.description, ) print(f"Published prompt agent: {created.name} v{created.version}\n") - # 4) Connect to the deployed prompt agent with FoundryAgent and pass the *same* callable. + # 4) Connect to the deployed prompt agent with FoundryAgent and pass the *same* callable tool. # FoundryAgent runs the local function when the server-side agent invokes the tool, # matching by name. Compared to step 2, instructions/tools/generation parameters now # come from the stored PromptAgentDefinition rather than this process. From dc5a740659de18a12d5fde0dfeebe0fd44b3c6ac Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 09:35:01 +0200 Subject: [PATCH 17/20] refactor(foundry): move _prepare_prompt_agent_options into _to_prompt_agent Lift the translation helper off RawFoundryChatClient and into the _to_prompt_agent module as a module-private function that takes the client as its first argument. The chat client no longer needs to carry a method whose only consumer is the prompt-agent converter, while still serving as the source of the request-path helper (_prepare_response_and_text_format) that the converter reuses for dict-shaped response_format values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/foundry/README.md | 4 +- .../agent_framework_foundry/_chat_client.py | 107 +-------------- .../_to_prompt_agent.py | 125 +++++++++++++++++- .../tests/foundry/test_to_prompt_agent.py | 2 +- 4 files changed, 123 insertions(+), 115 deletions(-) diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index 294b47bed1..b253d2d000 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -123,7 +123,9 @@ agent without restating the model deployment name. Every generation parameter that has an Agent Framework equivalent is sourced from `agent.default_options` and translated into the matching Foundry shape by -`FoundryChatClient._prepare_prompt_agent_options`: +`_prepare_prompt_agent_options` (a module-private helper in +`agent_framework_foundry._to_prompt_agent` that reuses the chat client's own +request-path helpers): | `default_options` key | `PromptAgentDefinition` field | |---|---| diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index a0fb5f4fee..6d7dc878ff 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -5,7 +5,7 @@ import logging import sys from collections.abc import Awaitable, Callable, Mapping, Sequence -from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal from agent_framework import ( ChatMiddlewareLayer, @@ -270,111 +270,6 @@ def _parse_chunk_from_openai( return update return super()._parse_chunk_from_openai(event, options, function_call_ids, seen_reasoning_delta_item_ids) - def _prepare_prompt_agent_options( - self, - default_options: Mapping[str, Any], - *, - has_tools: bool = False, - ) -> dict[str, Any]: - """Translate ``default_options`` into ``PromptAgentDefinition`` field kwargs. - - Reuses the same helpers as the regular request path - (``validate_tool_mode``, ``_prepare_response_and_text_format``, - ``type_to_text_format_param``) so a published prompt agent stays - consistent with the agent's local execution. - - Only fields with a direct ``PromptAgentDefinition`` counterpart are - translated: ``temperature``, ``top_p``, ``reasoning``, ``tool_choice``, - ``response_format`` / ``text`` / ``verbosity``. Other - ``OpenAIChatOptions`` keys (``include``, ``prompt``, ``store``, etc.) - have no prompt-agent equivalent and are intentionally ignored. The - input mapping is never mutated. - - Args: - default_options: The agent's ``default_options`` mapping. - - Keyword Args: - has_tools: When ``False``, ``tool_choice`` is dropped (no point - emitting a tool selection policy when the definition has no - tools), mirroring the regular request path in - ``_prepare_options``. - - Returns: - A dict ready to splat into ``PromptAgentDefinition(**...)``. - Unset fields are omitted. - """ - from agent_framework._types import validate_tool_mode - from azure.ai.projects.models import ( - PromptAgentDefinitionTextOptions, - Reasoning, - ToolChoiceAllowed, - ToolChoiceFunction, - ) - from openai.lib._parsing._responses import ( # type: ignore[reportPrivateImportUsage] - type_to_text_format_param, - ) - from pydantic import BaseModel - - result: dict[str, Any] = {} - - if (temperature := default_options.get("temperature")) is not None: - result["temperature"] = temperature - if (top_p := default_options.get("top_p")) is not None: - result["top_p"] = top_p - - if (reasoning := default_options.get("reasoning")) is not None: - if isinstance(reasoning, Reasoning): - result["reasoning"] = reasoning - elif isinstance(reasoning, Mapping): - result["reasoning"] = Reasoning(**dict(cast("Mapping[str, Any]", reasoning))) - else: - result["reasoning"] = reasoning - - if has_tools and (tool_choice := default_options.get("tool_choice")) is not None: - tool_mode = validate_tool_mode(tool_choice) - if tool_mode is not None: - mode = tool_mode.get("mode") - func_name = tool_mode.get("required_function_name") - allowed = tool_mode.get("allowed_tools") - if mode == "required" and func_name is not None: - result["tool_choice"] = ToolChoiceFunction(name=func_name) - elif mode == "auto" and allowed is not None: - result["tool_choice"] = ToolChoiceAllowed( - mode="auto", - tools=[{"type": "function", "name": name} for name in allowed], - ) - else: - result["tool_choice"] = mode - - existing_text = default_options.get("text") - text_config: dict[str, Any] | None = ( - dict(cast("Mapping[str, Any]", existing_text)) if isinstance(existing_text, Mapping) else None - ) - response_format = default_options.get("response_format") - if response_format is not None or text_config is not None: - if isinstance(response_format, type) and issubclass(response_format, BaseModel): - format_config = dict(type_to_text_format_param(response_format)) - text_config = dict(text_config) if text_config else {} - if "format" in text_config and text_config["format"] != format_config: - raise ValueError("Conflicting response_format definitions detected.") - text_config["format"] = format_config - elif response_format is not None: - response_format_model, text_config = self._prepare_response_and_text_format( - response_format=response_format, text_config=text_config - ) - if response_format_model is not None: - raise ValueError( - "response_format must be a Pydantic BaseModel subclass or a mapping when " - "converting to a PromptAgentDefinition." - ) - if (verbosity := default_options.get("verbosity")) is not None: - text_config = dict(text_config) if text_config else {} - text_config["verbosity"] = verbosity - if text_config: - result["text"] = PromptAgentDefinitionTextOptions(text_config) - - return result - async def configure_azure_monitor( self, enable_sensitive_data: bool = False, diff --git a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py index e87d10099f..6200910401 100644 --- a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py @@ -12,8 +12,9 @@ without restating the model deployment name. Generation parameters (``temperature``, ``top_p``, ``tool_choice``, ``reasoning``, ``response_format`` / ``text`` / ``verbosity``) are translated from -``agent.default_options`` by ``FoundryChatClient._prepare_prompt_agent_options`` -so they stay consistent with the agent's local request path. +``agent.default_options`` by the local ``_prepare_prompt_agent_options`` +helper, which reuses the chat client's own request-path helpers so they stay +consistent with the agent's local execution. Parameters with no Agent Framework equivalent (``structured_inputs``, ``rai_config``) are accepted as keyword arguments only. @@ -60,10 +61,10 @@ def to_prompt_agent( All generation parameters that have an Agent Framework equivalent (``temperature``, ``top_p``, ``tool_choice``, ``reasoning``, ``response_format`` / ``text`` / ``verbosity``) are sourced from - ``agent.default_options`` and translated by - ``FoundryChatClient._prepare_prompt_agent_options``. The agent is the - single source of truth for these; configure them on the ``Agent`` (or - pass ``default_options={...}`` to its constructor) rather than here. + ``agent.default_options`` and translated by ``_prepare_prompt_agent_options``. + The agent is the single source of truth for these; configure them on the + ``Agent`` (or pass ``default_options={...}`` to its constructor) rather + than here. Args: agent: An Agent Framework agent whose client is a ``FoundryChatClient``. @@ -102,7 +103,8 @@ def to_prompt_agent( getattr(agent, "mcp_tools", []), ) - translated = agent.client._prepare_prompt_agent_options( # pyright: ignore[reportPrivateUsage] + translated = _prepare_prompt_agent_options( + agent.client, agent.default_options, has_tools=bool(tools), ) @@ -123,6 +125,115 @@ def to_prompt_agent( return PromptAgentDefinition(**kwargs) +def _prepare_prompt_agent_options( + client: RawFoundryChatClient[Any], + default_options: Mapping[str, Any], + *, + has_tools: bool = False, +) -> dict[str, Any]: + """Translate ``default_options`` into ``PromptAgentDefinition`` field kwargs. + + Reuses the chat client's own request-path helpers + (``validate_tool_mode``, ``client._prepare_response_and_text_format``, + ``type_to_text_format_param``) so a published prompt agent stays + consistent with the agent's local execution. + + Only fields with a direct ``PromptAgentDefinition`` counterpart are + translated: ``temperature``, ``top_p``, ``reasoning``, ``tool_choice``, + ``response_format`` / ``text`` / ``verbosity``. Other ``OpenAIChatOptions`` + keys (``include``, ``prompt``, ``store``, etc.) have no prompt-agent + equivalent and are intentionally ignored. The input mapping is never + mutated. + + Args: + client: The bound ``FoundryChatClient`` (used to reuse its + ``_prepare_response_and_text_format`` for dict-shaped + ``response_format`` values). + default_options: The agent's ``default_options`` mapping. + + Keyword Args: + has_tools: When ``False``, ``tool_choice`` is dropped (no point + emitting a tool selection policy when the definition has no + tools), mirroring the regular request path in + ``_prepare_options``. + + Returns: + A dict ready to splat into ``PromptAgentDefinition(**...)``. Unset + fields are omitted. + """ + from agent_framework._types import validate_tool_mode + from azure.ai.projects.models import ( + PromptAgentDefinitionTextOptions, + Reasoning, + ToolChoiceAllowed, + ToolChoiceFunction, + ) + from openai.lib._parsing._responses import ( # type: ignore[reportPrivateImportUsage] + type_to_text_format_param, + ) + from pydantic import BaseModel + + result: dict[str, Any] = {} + + if (temperature := default_options.get("temperature")) is not None: + result["temperature"] = temperature + if (top_p := default_options.get("top_p")) is not None: + result["top_p"] = top_p + + if (reasoning := default_options.get("reasoning")) is not None: + if isinstance(reasoning, Reasoning): + result["reasoning"] = reasoning + elif isinstance(reasoning, Mapping): + result["reasoning"] = Reasoning(**dict(cast("Mapping[str, Any]", reasoning))) + else: + result["reasoning"] = reasoning + + if has_tools and (tool_choice := default_options.get("tool_choice")) is not None: + tool_mode = validate_tool_mode(tool_choice) + if tool_mode is not None: + mode = tool_mode.get("mode") + func_name = tool_mode.get("required_function_name") + allowed = tool_mode.get("allowed_tools") + if mode == "required" and func_name is not None: + result["tool_choice"] = ToolChoiceFunction(name=func_name) + elif mode == "auto" and allowed is not None: + result["tool_choice"] = ToolChoiceAllowed( + mode="auto", + tools=[{"type": "function", "name": name} for name in allowed], + ) + else: + result["tool_choice"] = mode + + existing_text = default_options.get("text") + text_config: dict[str, Any] | None = ( + dict(cast("Mapping[str, Any]", existing_text)) if isinstance(existing_text, Mapping) else None + ) + response_format = default_options.get("response_format") + if response_format is not None or text_config is not None: + if isinstance(response_format, type) and issubclass(response_format, BaseModel): + format_config = dict(type_to_text_format_param(response_format)) + text_config = dict(text_config) if text_config else {} + if "format" in text_config and text_config["format"] != format_config: + raise ValueError("Conflicting response_format definitions detected.") + text_config["format"] = format_config + elif response_format is not None: + response_format_model, text_config = client._prepare_response_and_text_format( # pyright: ignore[reportPrivateUsage] + response_format=response_format, text_config=text_config + ) + if response_format_model is not None: + raise ValueError( + "response_format must be a Pydantic BaseModel subclass or a mapping when " + "converting to a PromptAgentDefinition." + ) + if (verbosity := default_options.get("verbosity")) is not None: + text_config = dict(text_config) if text_config else {} + text_config["verbosity"] = verbosity + if text_config: + result["text"] = PromptAgentDefinitionTextOptions(text_config) + + return result + + def _convert_tools( tools: Iterable[Any] | None, mcp_tools: Iterable[MCPTool] | None, diff --git a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py index 3b9a07b2d6..624418466f 100644 --- a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py +++ b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py @@ -310,7 +310,7 @@ def test_to_prompt_agent_rejects_dict_tool_without_type() -> None: # --------------------------------------------------------------------------- # Generation parameters sourced from default_options -# (translated by FoundryChatClient._prepare_prompt_agent_options) +# (translated by _prepare_prompt_agent_options in _to_prompt_agent) # --------------------------------------------------------------------------- From 4dc670a8ec463b5c152c450aaf9f0e1282952c57 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 09:40:44 +0200 Subject: [PATCH 18/20] docs(python): codify GA terminology + post-run docs review Add two pieces of guidance to python/AGENTS.md: * Terminology - reserve 'GA' for hosted services; use 'released' or 'stable' for Agent Framework code/features to match the feature-lifecycle stages. * Maintaining Documentation - review AGENTS.md and skills at the end of every run and update any guidance the conversation made stale; before adding a new principle, ask the user to confirm it should be captured. Also pulls in a docstring fix in foundry_prompt_agents.py that swaps the stray 'GA' for 'released', applying the new terminology rule. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/AGENTS.md | 15 +++++++++++++++ .../providers/foundry/foundry_prompt_agents.py | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/python/AGENTS.md b/python/AGENTS.md index 80173560f7..8dc259c42b 100644 --- a/python/AGENTS.md +++ b/python/AGENTS.md @@ -21,6 +21,21 @@ When making changes to a package, check if the following need updates: - The package's `AGENTS.md` file (adding/removing/renaming public APIs, architecture changes, import path changes) - The agent skills in `.github/skills/` if conventions, commands, or workflows change +At the end of every run, re-read `AGENTS.md` and the relevant skill files and +update any guidance that the conversation revealed to be out of date, +incomplete, or misleading (renamed files, changed commands, new conventions +the user confirmed, etc.). **Before adding a new principle or rule, ask the +user whether they want it captured as a durable principle** — do not invent +team norms from a single conversation without explicit confirmation. + +## Terminology + +- **Avoid "GA" for Agent Framework code.** Reserve *GA* for hosted services + (e.g. "the Foundry service is GA"). For Agent Framework packages, features, + and APIs use **"released"** or **"stable"** depending on context — these + match the feature-lifecycle stages documented in the + `python-feature-lifecycle` skill. + ## Pull Request Description Guidance When preparing a PR description: diff --git a/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py b/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py index 89effa482b..e4082c52c2 100644 --- a/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py +++ b/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py @@ -60,7 +60,7 @@ again at connection time via ``FoundryAgent(tools=[...])``. ``to_prompt_agent`` is experimental -(``ExperimentalFeature.TO_PROMPT_AGENT``) and may change before reaching GA. +(``ExperimentalFeature.TO_PROMPT_AGENT``) and may change before being released. """ From d494ac19b0a8c47761c1ee2a8e620522010403e0 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 10:26:55 +0200 Subject: [PATCH 19/20] address PR review: strict=True default, Tool._deserialize dispatch, sample cleanup safety - FunctionTool published as strict=True so the server-side schema validation matches what the local FoundryAgent(tools=[same_callable]) dispatcher enforces. AF FunctionTool has no 'strict' attribute, so the safer default is used uniformly instead of silently downgrading to a permissive contract. - _validate_mapping_tool now dispatches through ProjectsTool._deserialize so dict-shaped tools rehydrate to the concrete subclass (FunctionTool, WebSearchTool, ...) via the 'type' discriminator instead of returning a generic Tool. Added a test that asserts isinstance(WebSearchTool) and a new test for the function-typed dict path. - foundry_prompt_agents.py sample now wraps credential + project client in async with and the create_version / run flow in try/finally so a failure on connect or run still deletes the published prompt agent rather than leaving an orphaned, billable resource in the user's Foundry project. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_to_prompt_agent.py | 12 +- .../tests/foundry/test_to_prompt_agent.py | 30 ++++- .../foundry/foundry_prompt_agents.py | 125 +++++++++--------- 3 files changed, 104 insertions(+), 63 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py index 6200910401..bb835ff63c 100644 --- a/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py @@ -299,7 +299,7 @@ def _function_tool_to_foundry(tool_item: FunctionTool) -> Tool: name=tool_item.name, description=tool_item.description or "", parameters=tool_item.parameters(), - strict=False, + strict=True, ) @@ -308,10 +308,16 @@ def _validate_mapping_tool(tool_item: Mapping[str, Any]) -> 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. + failure mode is obvious; everything else is dispatched through the SDK's + ``Tool._deserialize`` entry point so the concrete subclass + (e.g. ``FunctionTool``, ``WebSearchTool``) is materialized rather than a + generic ``Tool`` instance. """ 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) + # ``_deserialize`` is the SDK's discriminator-aware entry point. It is marked + # protected by convention but is the standard way to rehydrate polymorphic + # azure-sdk-for-python models from a raw mapping. + return cast("Tool", ProjectsTool._deserialize(dict(tool_item), [])) # type: ignore[no-untyped-call] # pyright: ignore[reportPrivateUsage, reportUnknownMemberType] diff --git a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py index 624418466f..599aa73d2d 100644 --- a/python/packages/foundry/tests/foundry/test_to_prompt_agent.py +++ b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py @@ -215,7 +215,7 @@ def test_to_prompt_agent_converts_function_tool() -> None: assert isinstance(fn, ProjectsFunctionTool) assert fn.name == "get_weather" assert fn.description == "Get the weather for a location." - assert fn.strict is False + assert fn.strict is True parameters = fn.parameters assert parameters["type"] == "object" assert "location" in parameters["properties"] @@ -292,10 +292,38 @@ def test_to_prompt_agent_accepts_dict_tool() -> None: assert definition.tools is not None assert len(definition.tools) == 1 tool_obj = definition.tools[0] + # The SDK discriminator on ``type`` should materialize the concrete subclass + # (here ``WebSearchTool``), not a generic ``Tool``. + assert isinstance(tool_obj, WebSearchTool) assert isinstance(tool_obj, ProjectsTool) assert tool_obj.type == "web_search" +def test_to_prompt_agent_accepts_dict_function_tool() -> None: + """A dict with ``type='function'`` rehydrates to a Foundry ``FunctionTool``.""" + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + tools=[ + { + "type": "function", + "name": "lookup", + "description": "Look up a value.", + "parameters": {"type": "object", "properties": {}}, + } + ], + ) + + 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, ProjectsFunctionTool) + assert tool_obj.name == "lookup" + assert tool_obj.description == "Look up a value." + + def test_to_prompt_agent_rejects_dict_tool_without_type() -> None: """A dict missing the 'type' field raises ValueError.""" agent = _make_agent( diff --git a/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py b/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py index e4082c52c2..6d53891aaa 100644 --- a/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py +++ b/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py @@ -78,65 +78,72 @@ async def main() -> None: project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] model = os.environ["FOUNDRY_MODEL"] - credential = AzureCliCredential() - project_client = AIProjectClient(endpoint=project_endpoint, credential=credential) - - # 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, - ], - default_options={"reasoning": {"effort": "medium"}}, - ) - - query = "Book me a hotel in Seattle for 3 nights." - - # 2) Run the local Agent. This calls the Foundry Responses API directly — instructions, - # tools, and generation parameters live in this process only. - print(f"User (local Agent): {query}") - local_result = await agent.run(query) - print(f"Local Agent: {local_result}\n") - - # 3) Convert and publish. The version returned by Foundry includes the version label - # we need when connecting back to that specific deployment. - created = await project_client.agents.create_version( - agent_name=agent.name, - # note this line: - definition=to_prompt_agent(agent), - description=agent.description, - ) - print(f"Published prompt agent: {created.name} v{created.version}\n") - - # 4) Connect to the deployed prompt agent with FoundryAgent and pass the *same* callable tool. - # FoundryAgent runs the local function when the server-side agent invokes the tool, - # matching by name. Compared to step 2, instructions/tools/generation parameters now - # come from the stored PromptAgentDefinition rather than this process. - deployed = FoundryAgent( - project_endpoint=project_endpoint, - agent_name=created.name, - agent_version=created.version, - credential=credential, - tools=[book_hotel], - ) - - print(f"User (deployed agent): {query}") - deployed_result = await deployed.run(query) - print(f"Deployed Agent: {deployed_result}") - - # 5) Cleanup: delete the deployed prompt agent (and all its versions) so re-running the - # sample stays idempotent. - await project_client.agents.delete(agent_name=created.name) - print(f"\nDeleted prompt agent {created.name!r} and all its versions.") + + # Use ``async with`` so the credential and project client are closed even if the + # body below raises. The ``try/finally`` around ``delete`` further guarantees we + # don't leave an orphaned prompt agent in the Foundry project after a failure. + async with ( + AzureCliCredential() as credential, + AIProjectClient(endpoint=project_endpoint, credential=credential) as project_client, + ): + # 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, + ], + default_options={"reasoning": {"effort": "medium"}}, + ) + + query = "Book me a hotel in Seattle for 3 nights." + + # 2) Run the local Agent. This calls the Foundry Responses API directly — instructions, + # tools, and generation parameters live in this process only. + print(f"User (local Agent): {query}") + local_result = await agent.run(query) + print(f"Local Agent: {local_result}\n") + + # 3) Convert and publish. The version returned by Foundry includes the version label + # we need when connecting back to that specific deployment. + created = await project_client.agents.create_version( + agent_name=agent.name, + # note this line: + definition=to_prompt_agent(agent), + description=agent.description, + ) + print(f"Published prompt agent: {created.name} v{created.version}\n") + + try: + # 4) Connect to the deployed prompt agent with FoundryAgent and pass the *same* callable + # tool. FoundryAgent runs the local function when the server-side agent invokes the tool, + # matching by name. Compared to step 2, instructions/tools/generation parameters now + # come from the stored PromptAgentDefinition rather than this process. + deployed = FoundryAgent( + project_endpoint=project_endpoint, + agent_name=created.name, + agent_version=created.version, + credential=credential, + tools=[book_hotel], + ) + + print(f"User (deployed agent): {query}") + deployed_result = await deployed.run(query) + print(f"Deployed Agent: {deployed_result}") + finally: + # 5) Cleanup: delete the deployed prompt agent (and all its versions) even if step 4 + # raised, so re-running the sample stays idempotent and we don't leak resources in + # the Foundry project. + await project_client.agents.delete(agent_name=created.name) + print(f"\nDeleted prompt agent {created.name!r} and all its versions.") if __name__ == "__main__": From f26afd4075d6efe742c28e919fbff6c54d8040e7 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 27 May 2026 10:48:22 +0200 Subject: [PATCH 20/20] fix(ci): correct linkspector ignorePattern typo (./pulls -> ./pull) GitHub PR URLs use the singular segment /pull/N (compare to /issues/N for issues). The existing './pulls' ignore pattern never matched anything as a result, so legitimately stale PR links (e.g. PRs deleted from forks) surface as linkspector failures on unrelated PRs. This is the same convention the './issues' rule above already follows. Fixes the markdown-link-check failure on a dangling link in dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/.linkspector.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/.linkspector.yml b/.github/.linkspector.yml index 270f659bc3..22fe804a09 100644 --- a/.github/.linkspector.yml +++ b/.github/.linkspector.yml @@ -8,7 +8,7 @@ ignorePatterns: - pattern: "./blob" - pattern: "./issues" - pattern: "./discussions" - - pattern: "./pulls" + - pattern: "./pull" - pattern: "https:\/\/platform.openai.com" - pattern: "http:\/\/localhost" - pattern: "http:\/\/127.0.0.1"