Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions python/packages/core/agent_framework/_feature_stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class ExperimentalFeature(str, Enum):
FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS"
HARNESS = "HARNESS"
SKILLS = "SKILLS"
TO_PROMPT_AGENT = "TO_PROMPT_AGENT"


class ReleaseCandidateFeature(str, Enum):
Expand Down
2 changes: 2 additions & 0 deletions python/packages/core/agent_framework/foundry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@
"RawFoundryAgentChatClient": ("agent_framework_foundry", "agent-framework-foundry"),
"RawFoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"),
"RawFoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"),
"deploy_as_prompt_agent": ("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"),
}


Expand Down
4 changes: 4 additions & 0 deletions python/packages/core/agent_framework/foundry/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ from agent_framework_foundry import (
RawFoundryAgentChatClient,
RawFoundryChatClient,
RawFoundryEmbeddingClient,
deploy_as_prompt_agent,
evaluate_foundry_target,
evaluate_traces,
to_prompt_agent,
)
from agent_framework_foundry_local import (
FoundryLocalChatOptions,
Expand Down Expand Up @@ -56,6 +58,8 @@ __all__ = [
"RawFoundryAgentChatClient",
"RawFoundryChatClient",
"RawFoundryEmbeddingClient",
"deploy_as_prompt_agent",
"evaluate_foundry_target",
"evaluate_traces",
"to_prompt_agent",
]
100 changes: 100 additions & 0 deletions python/packages/foundry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,103 @@ async with Agent(
result = await agent.run("What tools are available?")
print(result.text)
```

## Publishing an agent as a Foundry prompt agent

> **Experimental — `ExperimentalFeature.TO_PROMPT_AGENT`.** `to_prompt_agent`
> and `deploy_as_prompt_agent` are preview APIs 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`. 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.

For the common case of "convert and publish in one step", use
`deploy_as_prompt_agent(agent, agent_name=...)`. It reuses the bound
`FoundryChatClient`'s project client to call
`project_client.agents.create_version(...)`, so the caller does not need to
construct a separate `AIProjectClient`:

```python
import asyncio

from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient, deploy_as_prompt_agent
from azure.identity.aio import AzureCliCredential


async def main() -> None:
credential = AzureCliCredential()
project_endpoint = "https://<your-project>.services.ai.azure.com"

agent = Agent(
client=FoundryChatClient(
project_endpoint=project_endpoint,
model="gpt-4o",
credential=credential,
),
name="TravelAgent",
instructions="You are a helpful travel assistant.",
tools=[
FoundryChatClient.get_web_search_tool(),
FoundryChatClient.get_code_interpreter_tool(),
],
)

created = await deploy_as_prompt_agent(agent, agent_name="travel-agent")
print(f"Published {created.name} v{created.version}")


asyncio.run(main())
```

Reach for `to_prompt_agent(agent)` directly when you need a standalone
`PromptAgentDefinition` (e.g. to inspect, serialize, or pass to a separately
managed `AIProjectClient`).

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 examples under `samples/02-agents/providers/foundry/`:

- [`creating_prompt_agents.py`](../../samples/02-agents/providers/foundry/creating_prompt_agents.py)
\u2014 build an Agent, run it locally, and publish it via both
`deploy_as_prompt_agent` and `to_prompt_agent` + `AIProjectClient`.
- [`using_prompt_agents.py`](../../samples/02-agents/providers/foundry/using_prompt_agents.py)
\u2014 publish with `deploy_as_prompt_agent`, then connect back with
`FoundryAgent` and execute the same local `@tool` callable that the
deployed prompt agent invokes by name.
3 changes: 3 additions & 0 deletions python/packages/foundry/agent_framework_foundry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
evaluate_traces,
)
from ._memory_provider import FoundryMemoryProvider
from ._to_prompt_agent import deploy_as_prompt_agent, to_prompt_agent

try:
__version__ = importlib.metadata.version(__name__)
Expand All @@ -37,6 +38,8 @@
"RawFoundryChatClient",
"RawFoundryEmbeddingClient",
"__version__",
"deploy_as_prompt_agent",
"evaluate_foundry_target",
"evaluate_traces",
"to_prompt_agent",
]
220 changes: 220 additions & 0 deletions python/packages/foundry/agent_framework_foundry/_to_prompt_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
# 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.

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 AgentVersionDetails, PromptAgentDefinition, Tool


@experimental(feature_id=ExperimentalFeature.TO_PROMPT_AGENT)
def to_prompt_agent(agent: Agent) -> 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.

Args:
agent: An Agent Framework agent whose client is a ``FoundryChatClient``.

Returns:
A ``PromptAgentDefinition`` carrying the agent's model, instructions,
and tools. Pass it to ``AIProjectClient.agents.create_version(...)``
to publish the agent as a prompt agent.
"""
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}."
Comment thread
eavanvalkenburg marked this conversation as resolved.
)

# 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", []),
)

from azure.ai.projects.models import PromptAgentDefinition

return PromptAgentDefinition(
model=model,
instructions=instructions,
tools=tools or None,
)


@experimental(feature_id=ExperimentalFeature.TO_PROMPT_AGENT)
async def deploy_as_prompt_agent(
agent: Agent,
*,
metadata: Mapping[str, str] | None = None,
agent_name: str | None = None,
description: str | None = None,
**kwargs: Any,
) -> AgentVersionDetails:
"""Publish an ``Agent`` to Foundry as a new prompt-agent version.

Convenience wrapper around :func:`to_prompt_agent` that uses the
:class:`FoundryChatClient` already bound to ``agent`` to call
``project_client.agents.create_version(...)`` \u2014 so the caller does not
need to construct a separate :class:`AIProjectClient`.

Args:
agent: An Agent Framework agent whose client is a ``FoundryChatClient``.

Keyword Args:
metadata: Optional metadata dict (up to 16 key/value pairs) attached
to the version.
agent_name: The unique Foundry agent name. Must start and end with
alphanumeric characters, may contain hyphens in the middle, and
must not exceed 63 characters. Defaults to ``agent.name``,
this can be used to override the name set on the agent, in case it does
not adhere to the foundry naming restrictions.
description: Optional human-readable description for the version.
Defaults to ``agent.description``.
**kwargs: Forwarded to ``project_client.agents.create_version(...)``.

Returns:
The ``AgentVersionDetails`` returned by the Foundry service for the
newly created version.
"""
# to_prompt_agent enforces the FoundryChatClient requirement and model resolution.
definition = to_prompt_agent(agent)
client = cast("RawFoundryChatClient", agent.client)

resolved_name = agent_name or agent.name
if not resolved_name:
raise ValueError("Foundry agent_name is required. Pass agent_name= or set name= on the Agent.")

resolved_description = description if description is not None else agent.description

create_kwargs: dict[str, Any] = dict(kwargs)
if metadata is not None:
create_kwargs["metadata"] = dict(metadata)
if resolved_description is not None:
create_kwargs["description"] = resolved_description

return await client.project_client.agents.create_version(
agent_name=resolved_name,
definition=definition,
**create_kwargs,
)


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=False,
)


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 left to the SDK.
"""
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.")
return ProjectsTool(**tool_item)
Loading
Loading