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
+1 -1
View File
@@ -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"
+15
View File
@@ -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:
@@ -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):
@@ -41,6 +41,7 @@ _IMPORTS: dict[str, tuple[str, str]] = {
"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"),
}
@@ -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",
]
+126
View File
@@ -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.
@@ -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]
@@ -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
@@ -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())
+1 -1
View File
@@ -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]]