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