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" 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/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..b253d2d000 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -106,3 +106,129 @@ 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. 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` 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. + +Every generation parameter that has an Agent Framework equivalent is sourced +from `agent.default_options` and translated into the matching Foundry shape by +`_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 | +|---|---| +| `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 +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.identity.aio import AzureCliCredential + + +async def main() -> None: + credential = AzureCliCredential() + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + + agent = Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model="gpt-4o", + credential=credential, + ), + 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, + "reasoning": {"effort": "medium"}, + }, + ) + + definition = to_prompt_agent(agent) + + 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()) +``` + +Behaviour: + +- `agent.client` must be a `FoundryChatClient` (or subclass) — otherwise the + converter raises `TypeError`. +- The bound client must have a `model` set — otherwise the converter raises + `ValueError`. +- Foundry SDK tool instances returned by `FoundryChatClient.get_*_tool()` are + passed through unchanged. +- AF `FunctionTool` instances (and `@tool`-decorated callables) are emitted as + Foundry `FunctionTool` **declarations** — the prompt agent receives the + schema only, not the Python implementation. To execute the function when + invoking the deployed prompt agent, connect with `FoundryAgent` and pass the + same callable via `tools=`: + + ```python + from agent_framework.foundry import FoundryAgent + + deployed = FoundryAgent( + project_endpoint=project_endpoint, + agent_name="travel-agent", + credential=credential, + tools=[book_hotel], # same @tool-decorated callable used at publish time + ) + result = await deployed.run("Book me a hotel in Seattle for 3 nights.") + ``` + + `FoundryAgent` runs the function locally when the prompt agent calls it, so + the declaration on the server and the implementation on the client stay in + sync via the shared `@tool` definition. +- Local Agent Framework MCP tools cannot be published as prompt-agent tools — + the converter raises `ValueError` and points at + `FoundryChatClient.get_mcp_tool(...)` for hosted MCP servers. + +See the runnable 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/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..bb835ff63c --- /dev/null +++ b/python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py @@ -0,0 +1,323 @@ +# 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. Generation parameters +(``temperature``, ``top_p``, ``tool_choice``, ``reasoning``, +``response_format`` / ``text`` / ``verbosity``) are translated from +``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. + +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, + RaiConfig, + StructuredInputDefinition, + Tool, + ) + + +@experimental(feature_id=ExperimentalFeature.TO_PROMPT_AGENT) +def to_prompt_agent( + agent: Agent, + *, + 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. + + 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 ``_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: + structured_inputs: Mapping of structured input names to + ``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, + tools, and generation parameters. Pass it to + ``AIProjectClient.agents.create_version(...)`` to publish. + """ + if not isinstance(agent.client, RawFoundryChatClient): + raise TypeError( + "Creating a Foundry Prompt Agent requires an Agent whose client is a FoundryChatClient; " + f"got {type(agent.client).__name__!r}." + ) + + # Match the resolution order Agent.__init__ uses when building default_options: + # an agent-level model override in default_options wins over the bound client's model. + model = agent.default_options.get("model") or agent.client.model + if not model: + raise ValueError( + "Agent has no model. Set 'model' on the FoundryChatClient (via the FOUNDRY_MODEL " + "environment variable or the model= argument), or pass default_options={'model': ...} " + "to the Agent before converting." + ) + + instructions = agent.default_options.get("instructions") + tools = _convert_tools( + agent.default_options.get("tools", []), + getattr(agent, "mcp_tools", []), + ) + + translated = _prepare_prompt_agent_options( + agent.client, + agent.default_options, + has_tools=bool(tools), + ) + + from azure.ai.projects.models import PromptAgentDefinition + + 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: + kwargs["rai_config"] = rai_config + + 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, +) -> list[Tool]: + """Map AF agent tools to Foundry ``PromptAgentDefinition`` tool entries. + + Tool sources walked, in order: + + * ``agent.default_options["tools"]`` — function tools and hosted Foundry SDK + tool instances (returned by ``FoundryChatClient.get_*_tool()``). + * ``agent.mcp_tools`` — local Agent Framework MCP servers (split off from + the tools list by ``normalize_tools()``). These cannot be published as + prompt-agent tools; the caller must use the hosted MCP factory instead. + + Hosted SDK tool instances are passed through unchanged. Mapping/dict tools + are passed through after light validation. Anything else raises + ``ValueError`` with a message that names the offending type. + """ + from azure.ai.projects.models import Tool as ProjectsTool + + converted: list[Tool] = [] + + for tool_item in tools or (): + if isinstance(tool_item, ProjectsTool): + converted.append(tool_item) + continue + if isinstance(tool_item, FunctionTool): + converted.append(_function_tool_to_foundry(tool_item)) + continue + if isinstance(tool_item, Mapping): + converted.append(_validate_mapping_tool(cast("Mapping[str, Any]", tool_item))) + continue + raise ValueError( + f"Unsupported tool type for PromptAgentDefinition: {type(tool_item).__name__}. " + "Use FoundryChatClient.get_*_tool() helpers, a callable / FunctionTool, " + "or a dict matching the Foundry tool schema." + ) + + for mcp_tool in mcp_tools or (): + raise ValueError( + f"Local MCP tool {mcp_tool.name!r} cannot be published as a prompt-agent tool. " + "Use FoundryChatClient.get_mcp_tool(...) to register a hosted MCP server instead." + ) + + return converted + + +def _function_tool_to_foundry(tool_item: FunctionTool) -> Tool: + """Build a Foundry ``FunctionTool`` declaration from an AF ``FunctionTool``. + + The result carries only the schema (name, description, parameters). It is a + declaration of the tool the prompt agent may call; server-side execution + must be wired separately by the caller. + """ + try: + from azure.ai.projects.models import FunctionTool as ProjectsFunctionTool + except ImportError as exc: # pragma: no cover - sanity guard + raise ImportError( + "FunctionTool is not available in the installed azure-ai-projects. Upgrade azure-ai-projects." + ) from exc + + return ProjectsFunctionTool( + name=tool_item.name, + description=tool_item.description or "", + parameters=tool_item.parameters(), + strict=True, + ) + + +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 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.") + # ``_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 new file mode 100644 index 0000000000..599aa73d2d --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_to_prompt_agent.py @@ -0,0 +1,664 @@ +# 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, + PromptAgentDefinitionTextOptions, + RaiConfig, + Reasoning, + StructuredInputDefinition, + ToolChoiceAllowed, + ToolChoiceFunction, + 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 pydantic import BaseModel + +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) + + +# --------------------------------------------------------------------------- +# 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.") + + definition = to_prompt_agent(agent) + + assert isinstance(definition, PromptAgentDefinition) + assert definition.model == "gpt-4o-mini" + assert definition.instructions == "Be helpful." + assert definition.tools is None + + +def test_to_prompt_agent_serializes_cleanly() -> None: + """The PromptAgentDefinition serializes to a dict that includes ``kind: prompt``.""" + agent = _make_agent(_make_foundry_chat_client(), instructions="Hi.") + + payload = to_prompt_agent(agent).as_dict() + + assert payload["model"] == "gpt-4o-mini" + assert payload["instructions"] == "Hi." + assert payload["kind"] == "prompt" + + +def test_to_prompt_agent_rejects_non_foundry_client() -> None: + """A non-FoundryChatClient client raises TypeError.""" + + class NotFoundryChatClient: + """Stand-in for a different chat client implementation.""" + + agent = _make_agent(NotFoundryChatClient()) + + with pytest.raises(TypeError, match="FoundryChatClient"): + to_prompt_agent(agent) + + +def test_to_prompt_agent_rejects_missing_model() -> None: + """When neither default_options nor the client has a model, ValueError is raised.""" + client = _make_foundry_chat_client() + client.model = "" + agent = _make_agent(client) + 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 = _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() + 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.""" + 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_works_with_raw_foundry_chat_client() -> None: + """to_prompt_agent accepts subclasses too — RawFoundryChatClient works.""" + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + raw_client = RawFoundryChatClient(project_client=mock_project, model="gpt-4o") + agent = _make_agent(raw_client, instructions="x") + + definition = to_prompt_agent(agent) + + assert definition.model == "gpt-4o" + + +def test_to_prompt_agent_is_marked_experimental() -> None: + """to_prompt_agent carries the TO_PROMPT_AGENT experimental metadata.""" + assert getattr(to_prompt_agent, "__feature_stage__", None) == "experimental" + assert getattr(to_prompt_agent, "__feature_id__", None) == ExperimentalFeature.TO_PROMPT_AGENT.value + + +def 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 +# --------------------------------------------------------------------------- + + +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 + 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 True + 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] + # 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( + _make_foundry_chat_client(), + instructions="x", + tools=[{"name": "missing_type"}], + ) + + with pytest.raises(ValueError, match="type"): + to_prompt_agent(agent) + + +# --------------------------------------------------------------------------- +# Generation parameters sourced from default_options +# (translated by _prepare_prompt_agent_options in _to_prompt_agent) +# --------------------------------------------------------------------------- + + +def test_to_prompt_agent_temperature_top_p_unset_by_default() -> None: + """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) + + 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_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) + + assert definition.temperature == 0.42 + assert definition.top_p == 0.8 + + +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.0, "top_p": 0.0}, + ) + + definition = to_prompt_agent(agent) + + assert definition.temperature == 0.0 + assert definition.top_p == 0.0 + + +def test_to_prompt_agent_tool_choice_omitted_when_no_tools() -> None: + """``tool_choice`` is dropped when the definition has no tools. + + 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", + tools=[get_weather], + default_options={"tool_choice": "required"}, + ) + + definition = to_prompt_agent(agent) + + assert definition.tool_choice == "required" + + +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 isinstance(definition.tool_choice, ToolChoiceFunction) + assert definition.tool_choice.name == "get_weather" + + +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", + tools=[get_weather], + default_options={ + "tool_choice": {"mode": "auto", "allowed_tools": ["get_weather"]}, + }, + ) + + definition = to_prompt_agent(agent) + + 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_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 isinstance(definition.reasoning, Reasoning) + assert definition.reasoning.effort == "high" + assert definition.reasoning.summary == "concise" + + +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={"reasoning": reasoning}, + ) + + definition = to_prompt_agent(agent) + + assert definition.reasoning is reasoning + + +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) + + 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"}}} + + +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 + + agent = _make_agent( + _make_foundry_chat_client(), + instructions="x", + default_options={"response_format": WeatherReply}, + ) + + definition = to_prompt_agent(agent) + + 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_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) + + 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_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( + _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_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") + + payload = to_prompt_agent(agent).as_dict() + + assert "structured_inputs" not in payload + assert "rai_config" not in payload + + +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"] + 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 + + +# --------------------------------------------------------------------------- +# 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", + "reasoning": {"effort": "medium"}, + "verbosity": "low", + }, + tools=[get_weather], + ) + + definition = to_prompt_agent( + agent, + structured_inputs=structured, + rai_config=rai_config, + ) + + assert definition.temperature == 0.3 + assert definition.top_p == 0.95 + assert definition.tool_choice == "auto" + 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/foundry_prompt_agents.py b/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py new file mode 100644 index 0000000000..6d53891aaa --- /dev/null +++ b/python/samples/02-agents/providers/foundry/foundry_prompt_agents.py @@ -0,0 +1,150 @@ +# 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, 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: 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. 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(...)``. +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 — 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 being released. +""" + + +@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: Convert, Publish, Connect, and Run ===\n") + + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ["FOUNDRY_MODEL"] + + # 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__": + 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]]