Python: feat(foundry): add to_prompt_agent / deploy_as_prompt_agent (experimental) (#5959)

* 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>

* chore(samples): remove snippet tags from portable agent sample

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore(samples): inline FoundryChatClient and enable prompt-agent publish

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore(samples): drop async credential context manager

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(foundry): trim README to_prompt_agent example to publish-only flow

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(foundry): note FoundryAgent runs @tool callables for deployed prompt agents

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* refactor(foundry): single source of truth for prompt-agent options

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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-05-27 15:31:21 +02:00
committed by GitHub
Unverified
parent ae989b92e7
commit d5c07f2623
11 changed files with 1286 additions and 2 deletions
@@ -16,6 +16,7 @@ from ._foundry_evals import (
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 @@ __all__ = [
"__version__",
"evaluate_foundry_target",
"evaluate_traces",
"to_prompt_agent",
]
@@ -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]