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