mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
ae989b92e7
commit
d5c07f2623
@@ -8,7 +8,7 @@ ignorePatterns:
|
||||
- pattern: "./blob"
|
||||
- pattern: "./issues"
|
||||
- pattern: "./discussions"
|
||||
- pattern: "./pulls"
|
||||
- pattern: "./pull"
|
||||
- pattern: "https:\/\/platform.openai.com"
|
||||
- pattern: "http:\/\/localhost"
|
||||
- pattern: "http:\/\/127.0.0.1"
|
||||
|
||||
@@ -21,6 +21,21 @@ When making changes to a package, check if the following need updates:
|
||||
- The package's `AGENTS.md` file (adding/removing/renaming public APIs, architecture changes, import path changes)
|
||||
- The agent skills in `.github/skills/` if conventions, commands, or workflows change
|
||||
|
||||
At the end of every run, re-read `AGENTS.md` and the relevant skill files and
|
||||
update any guidance that the conversation revealed to be out of date,
|
||||
incomplete, or misleading (renamed files, changed commands, new conventions
|
||||
the user confirmed, etc.). **Before adding a new principle or rule, ask the
|
||||
user whether they want it captured as a durable principle** — do not invent
|
||||
team norms from a single conversation without explicit confirmation.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Avoid "GA" for Agent Framework code.** Reserve *GA* for hosted services
|
||||
(e.g. "the Foundry service is GA"). For Agent Framework packages, features,
|
||||
and APIs use **"released"** or **"stable"** depending on context — these
|
||||
match the feature-lifecycle stages documented in the
|
||||
`python-feature-lifecycle` skill.
|
||||
|
||||
## Pull Request Description Guidance
|
||||
|
||||
When preparing a PR description:
|
||||
|
||||
@@ -58,6 +58,7 @@ class ExperimentalFeature(str, Enum):
|
||||
FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS"
|
||||
HARNESS = "HARNESS"
|
||||
SKILLS = "SKILLS"
|
||||
TO_PROMPT_AGENT = "TO_PROMPT_AGENT"
|
||||
|
||||
|
||||
class ReleaseCandidateFeature(str, Enum):
|
||||
|
||||
@@ -41,6 +41,7 @@ _IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"RawFoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"evaluate_foundry_target": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"evaluate_traces": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"to_prompt_agent": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from agent_framework_foundry import (
|
||||
RawFoundryEmbeddingClient,
|
||||
evaluate_foundry_target,
|
||||
evaluate_traces,
|
||||
to_prompt_agent,
|
||||
)
|
||||
from agent_framework_foundry_local import (
|
||||
FoundryLocalChatOptions,
|
||||
@@ -58,4 +59,5 @@ __all__ = [
|
||||
"RawFoundryEmbeddingClient",
|
||||
"evaluate_foundry_target",
|
||||
"evaluate_traces",
|
||||
"to_prompt_agent",
|
||||
]
|
||||
|
||||
@@ -106,3 +106,129 @@ Generally available factories: `get_code_interpreter_tool`,
|
||||
| `get_browser_automation_tool(connection_id)` | `BrowserAutomationPreviewTool` |
|
||||
| `get_bing_custom_search_tool(connection_id, instance_name, ...)` | `BingCustomSearchPreviewTool` |
|
||||
| `get_a2a_tool(base_url=..., project_connection_id=..., ...)` | `A2APreviewTool` |
|
||||
## Publishing an agent as a Foundry prompt agent
|
||||
|
||||
> **Experimental — `ExperimentalFeature.TO_PROMPT_AGENT`.** `to_prompt_agent`
|
||||
> is a preview API and may change before reaching GA. The warning fires the
|
||||
> first time the `TO_PROMPT_AGENT` feature is exercised in a process and is
|
||||
> then deduplicated.
|
||||
|
||||
`to_prompt_agent(agent)` converts an `Agent` whose chat client is a
|
||||
`FoundryChatClient` into a Foundry `PromptAgentDefinition` that can be
|
||||
published with `AIProjectClient.agents.create_version(...)`. The model is read
|
||||
from `default_options["model"]` first and falls back to the bound
|
||||
`FoundryChatClient.model` (matching `Agent.__init__`'s resolution order), so
|
||||
the same agent definition you run locally can be published as a hosted prompt
|
||||
agent without restating the model deployment name.
|
||||
|
||||
Every generation parameter that has an Agent Framework equivalent is sourced
|
||||
from `agent.default_options` and translated into the matching Foundry shape by
|
||||
`_prepare_prompt_agent_options` (a module-private helper in
|
||||
`agent_framework_foundry._to_prompt_agent` that reuses the chat client's own
|
||||
request-path helpers):
|
||||
|
||||
| `default_options` key | `PromptAgentDefinition` field |
|
||||
|---|---|
|
||||
| `temperature` | `temperature` |
|
||||
| `top_p` | `top_p` |
|
||||
| `tool_choice` (dropped when no tools) | `tool_choice` (`str` / `ToolChoiceFunction` / `ToolChoiceAllowed`) |
|
||||
| `reasoning` (dict or `Reasoning`) | `reasoning` |
|
||||
| `response_format` (dict or `BaseModel`) | `text.format` |
|
||||
| `verbosity` | `text.verbosity` |
|
||||
| `text` | merged into `text` |
|
||||
|
||||
This keeps the `Agent` as the single source of truth for everything it can
|
||||
already express. Only Foundry-specific fields with no Agent Framework
|
||||
equivalent are accepted as keyword arguments on `to_prompt_agent`:
|
||||
|
||||
- `structured_inputs` — `dict[str, StructuredInputDefinition]`
|
||||
- `rai_config` — `RaiConfig`
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient, to_prompt_agent
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
credential = AzureCliCredential()
|
||||
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
|
||||
|
||||
agent = Agent(
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=project_endpoint,
|
||||
model="gpt-4o",
|
||||
credential=credential,
|
||||
),
|
||||
name="travel-agent",
|
||||
description="Helps Contoso employees book travel.",
|
||||
instructions="You are a helpful travel assistant.",
|
||||
tools=[
|
||||
FoundryChatClient.get_web_search_tool(),
|
||||
FoundryChatClient.get_code_interpreter_tool(),
|
||||
],
|
||||
# Generation parameters set on the Agent flow through automatically.
|
||||
default_options={
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.95,
|
||||
"reasoning": {"effort": "medium"},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
project_client = AIProjectClient(endpoint=project_endpoint, credential=credential)
|
||||
created = await project_client.agents.create_version(
|
||||
agent_name=agent.name,
|
||||
definition=definition,
|
||||
description=agent.description,
|
||||
)
|
||||
print(f"Published {created.name} v{created.version}")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
|
||||
- `agent.client` must be a `FoundryChatClient` (or subclass) — otherwise the
|
||||
converter raises `TypeError`.
|
||||
- The bound client must have a `model` set — otherwise the converter raises
|
||||
`ValueError`.
|
||||
- Foundry SDK tool instances returned by `FoundryChatClient.get_*_tool()` are
|
||||
passed through unchanged.
|
||||
- AF `FunctionTool` instances (and `@tool`-decorated callables) are emitted as
|
||||
Foundry `FunctionTool` **declarations** — the prompt agent receives the
|
||||
schema only, not the Python implementation. To execute the function when
|
||||
invoking the deployed prompt agent, connect with `FoundryAgent` and pass the
|
||||
same callable via `tools=`:
|
||||
|
||||
```python
|
||||
from agent_framework.foundry import FoundryAgent
|
||||
|
||||
deployed = FoundryAgent(
|
||||
project_endpoint=project_endpoint,
|
||||
agent_name="travel-agent",
|
||||
credential=credential,
|
||||
tools=[book_hotel], # same @tool-decorated callable used at publish time
|
||||
)
|
||||
result = await deployed.run("Book me a hotel in Seattle for 3 nights.")
|
||||
```
|
||||
|
||||
`FoundryAgent` runs the function locally when the prompt agent calls it, so
|
||||
the declaration on the server and the implementation on the client stay in
|
||||
sync via the shared `@tool` definition.
|
||||
- Local Agent Framework MCP tools cannot be published as prompt-agent tools —
|
||||
the converter raises `ValueError` and points at
|
||||
`FoundryChatClient.get_mcp_tool(...)` for hosted MCP servers.
|
||||
|
||||
See the runnable example under `samples/02-agents/providers/foundry/`:
|
||||
|
||||
- [`foundry_prompt_agents.py`](../../samples/02-agents/providers/foundry/foundry_prompt_agents.py)
|
||||
— publish with `to_prompt_agent`, then connect back with `FoundryAgent` and
|
||||
execute the same local `@tool` callable that the deployed prompt agent
|
||||
invokes by name.
|
||||
|
||||
@@ -16,6 +16,7 @@ from ._foundry_evals import (
|
||||
evaluate_traces,
|
||||
)
|
||||
from ._memory_provider import FoundryMemoryProvider
|
||||
from ._to_prompt_agent import to_prompt_agent
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
@@ -39,4 +40,5 @@ __all__ = [
|
||||
"__version__",
|
||||
"evaluate_foundry_target",
|
||||
"evaluate_traces",
|
||||
"to_prompt_agent",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Convert an Agent Framework agent into a Foundry ``PromptAgentDefinition``.
|
||||
|
||||
The converter accepts an :class:`agent_framework.Agent` whose chat client is a
|
||||
:class:`agent_framework_foundry.FoundryChatClient` (or a subclass) and returns
|
||||
a ``PromptAgentDefinition`` ready to publish via
|
||||
``AIProjectClient.agents.create_version(...)``.
|
||||
|
||||
The model is lifted from the bound ``FoundryChatClient`` so the same ``Agent``
|
||||
definition used for local execution can be published as a hosted prompt agent
|
||||
without restating the model deployment name. Generation parameters
|
||||
(``temperature``, ``top_p``, ``tool_choice``, ``reasoning``,
|
||||
``response_format`` / ``text`` / ``verbosity``) are translated from
|
||||
``agent.default_options`` by the local ``_prepare_prompt_agent_options``
|
||||
helper, which reuses the chat client's own request-path helpers so they stay
|
||||
consistent with the agent's local execution.
|
||||
|
||||
Parameters with no Agent Framework equivalent (``structured_inputs``,
|
||||
``rai_config``) are accepted as keyword arguments only.
|
||||
|
||||
Function tools derived from local Python callables are translated to Foundry
|
||||
``FunctionTool`` *declarations* only. Prompt agents are server-side, so the
|
||||
deployed agent will receive the schema for these tools but cannot execute the
|
||||
underlying Python; wiring server-side execution is the caller's responsibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from agent_framework import FunctionTool
|
||||
from agent_framework._feature_stage import ExperimentalFeature, experimental
|
||||
from agent_framework._mcp import MCPTool
|
||||
|
||||
from ._chat_client import RawFoundryChatClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent
|
||||
from azure.ai.projects.models import (
|
||||
PromptAgentDefinition,
|
||||
RaiConfig,
|
||||
StructuredInputDefinition,
|
||||
Tool,
|
||||
)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.TO_PROMPT_AGENT)
|
||||
def to_prompt_agent(
|
||||
agent: Agent,
|
||||
*,
|
||||
structured_inputs: Mapping[str, StructuredInputDefinition] | None = None,
|
||||
rai_config: RaiConfig | None = None,
|
||||
) -> PromptAgentDefinition:
|
||||
"""Convert an ``Agent`` into a Foundry ``PromptAgentDefinition``.
|
||||
|
||||
The agent's chat client must be a :class:`FoundryChatClient` (or any
|
||||
subclass). The model deployment name is lifted from the bound client.
|
||||
|
||||
All generation parameters that have an Agent Framework equivalent
|
||||
(``temperature``, ``top_p``, ``tool_choice``, ``reasoning``,
|
||||
``response_format`` / ``text`` / ``verbosity``) are sourced from
|
||||
``agent.default_options`` and translated by ``_prepare_prompt_agent_options``.
|
||||
The agent is the single source of truth for these; configure them on the
|
||||
``Agent`` (or pass ``default_options={...}`` to its constructor) rather
|
||||
than here.
|
||||
|
||||
Args:
|
||||
agent: An Agent Framework agent whose client is a ``FoundryChatClient``.
|
||||
|
||||
Keyword Args:
|
||||
structured_inputs: Mapping of structured input names to
|
||||
``StructuredInputDefinition`` entries. Foundry-only; no
|
||||
``ChatOptions`` equivalent.
|
||||
rai_config: Foundry ``RaiConfig`` to attach to the definition.
|
||||
Foundry-only; no ``ChatOptions`` equivalent.
|
||||
|
||||
Returns:
|
||||
A ``PromptAgentDefinition`` carrying the agent's model, instructions,
|
||||
tools, and generation parameters. Pass it to
|
||||
``AIProjectClient.agents.create_version(...)`` to publish.
|
||||
"""
|
||||
if not isinstance(agent.client, RawFoundryChatClient):
|
||||
raise TypeError(
|
||||
"Creating a Foundry Prompt Agent requires an Agent whose client is a FoundryChatClient; "
|
||||
f"got {type(agent.client).__name__!r}."
|
||||
)
|
||||
|
||||
# Match the resolution order Agent.__init__ uses when building default_options:
|
||||
# an agent-level model override in default_options wins over the bound client's model.
|
||||
model = agent.default_options.get("model") or agent.client.model
|
||||
if not model:
|
||||
raise ValueError(
|
||||
"Agent has no model. Set 'model' on the FoundryChatClient (via the FOUNDRY_MODEL "
|
||||
"environment variable or the model= argument), or pass default_options={'model': ...} "
|
||||
"to the Agent before converting."
|
||||
)
|
||||
|
||||
instructions = agent.default_options.get("instructions")
|
||||
tools = _convert_tools(
|
||||
agent.default_options.get("tools", []),
|
||||
getattr(agent, "mcp_tools", []),
|
||||
)
|
||||
|
||||
translated = _prepare_prompt_agent_options(
|
||||
agent.client,
|
||||
agent.default_options,
|
||||
has_tools=bool(tools),
|
||||
)
|
||||
|
||||
from azure.ai.projects.models import PromptAgentDefinition
|
||||
|
||||
kwargs: dict[str, Any] = {"model": model}
|
||||
if instructions is not None:
|
||||
kwargs["instructions"] = instructions
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
kwargs.update(translated)
|
||||
if structured_inputs is not None:
|
||||
kwargs["structured_inputs"] = dict(structured_inputs)
|
||||
if rai_config is not None:
|
||||
kwargs["rai_config"] = rai_config
|
||||
|
||||
return PromptAgentDefinition(**kwargs)
|
||||
|
||||
|
||||
def _prepare_prompt_agent_options(
|
||||
client: RawFoundryChatClient[Any],
|
||||
default_options: Mapping[str, Any],
|
||||
*,
|
||||
has_tools: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Translate ``default_options`` into ``PromptAgentDefinition`` field kwargs.
|
||||
|
||||
Reuses the chat client's own request-path helpers
|
||||
(``validate_tool_mode``, ``client._prepare_response_and_text_format``,
|
||||
``type_to_text_format_param``) so a published prompt agent stays
|
||||
consistent with the agent's local execution.
|
||||
|
||||
Only fields with a direct ``PromptAgentDefinition`` counterpart are
|
||||
translated: ``temperature``, ``top_p``, ``reasoning``, ``tool_choice``,
|
||||
``response_format`` / ``text`` / ``verbosity``. Other ``OpenAIChatOptions``
|
||||
keys (``include``, ``prompt``, ``store``, etc.) have no prompt-agent
|
||||
equivalent and are intentionally ignored. The input mapping is never
|
||||
mutated.
|
||||
|
||||
Args:
|
||||
client: The bound ``FoundryChatClient`` (used to reuse its
|
||||
``_prepare_response_and_text_format`` for dict-shaped
|
||||
``response_format`` values).
|
||||
default_options: The agent's ``default_options`` mapping.
|
||||
|
||||
Keyword Args:
|
||||
has_tools: When ``False``, ``tool_choice`` is dropped (no point
|
||||
emitting a tool selection policy when the definition has no
|
||||
tools), mirroring the regular request path in
|
||||
``_prepare_options``.
|
||||
|
||||
Returns:
|
||||
A dict ready to splat into ``PromptAgentDefinition(**...)``. Unset
|
||||
fields are omitted.
|
||||
"""
|
||||
from agent_framework._types import validate_tool_mode
|
||||
from azure.ai.projects.models import (
|
||||
PromptAgentDefinitionTextOptions,
|
||||
Reasoning,
|
||||
ToolChoiceAllowed,
|
||||
ToolChoiceFunction,
|
||||
)
|
||||
from openai.lib._parsing._responses import ( # type: ignore[reportPrivateImportUsage]
|
||||
type_to_text_format_param,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
if (temperature := default_options.get("temperature")) is not None:
|
||||
result["temperature"] = temperature
|
||||
if (top_p := default_options.get("top_p")) is not None:
|
||||
result["top_p"] = top_p
|
||||
|
||||
if (reasoning := default_options.get("reasoning")) is not None:
|
||||
if isinstance(reasoning, Reasoning):
|
||||
result["reasoning"] = reasoning
|
||||
elif isinstance(reasoning, Mapping):
|
||||
result["reasoning"] = Reasoning(**dict(cast("Mapping[str, Any]", reasoning)))
|
||||
else:
|
||||
result["reasoning"] = reasoning
|
||||
|
||||
if has_tools and (tool_choice := default_options.get("tool_choice")) is not None:
|
||||
tool_mode = validate_tool_mode(tool_choice)
|
||||
if tool_mode is not None:
|
||||
mode = tool_mode.get("mode")
|
||||
func_name = tool_mode.get("required_function_name")
|
||||
allowed = tool_mode.get("allowed_tools")
|
||||
if mode == "required" and func_name is not None:
|
||||
result["tool_choice"] = ToolChoiceFunction(name=func_name)
|
||||
elif mode == "auto" and allowed is not None:
|
||||
result["tool_choice"] = ToolChoiceAllowed(
|
||||
mode="auto",
|
||||
tools=[{"type": "function", "name": name} for name in allowed],
|
||||
)
|
||||
else:
|
||||
result["tool_choice"] = mode
|
||||
|
||||
existing_text = default_options.get("text")
|
||||
text_config: dict[str, Any] | None = (
|
||||
dict(cast("Mapping[str, Any]", existing_text)) if isinstance(existing_text, Mapping) else None
|
||||
)
|
||||
response_format = default_options.get("response_format")
|
||||
if response_format is not None or text_config is not None:
|
||||
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
|
||||
format_config = dict(type_to_text_format_param(response_format))
|
||||
text_config = dict(text_config) if text_config else {}
|
||||
if "format" in text_config and text_config["format"] != format_config:
|
||||
raise ValueError("Conflicting response_format definitions detected.")
|
||||
text_config["format"] = format_config
|
||||
elif response_format is not None:
|
||||
response_format_model, text_config = client._prepare_response_and_text_format( # pyright: ignore[reportPrivateUsage]
|
||||
response_format=response_format, text_config=text_config
|
||||
)
|
||||
if response_format_model is not None:
|
||||
raise ValueError(
|
||||
"response_format must be a Pydantic BaseModel subclass or a mapping when "
|
||||
"converting to a PromptAgentDefinition."
|
||||
)
|
||||
if (verbosity := default_options.get("verbosity")) is not None:
|
||||
text_config = dict(text_config) if text_config else {}
|
||||
text_config["verbosity"] = verbosity
|
||||
if text_config:
|
||||
result["text"] = PromptAgentDefinitionTextOptions(text_config)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _convert_tools(
|
||||
tools: Iterable[Any] | None,
|
||||
mcp_tools: Iterable[MCPTool] | None,
|
||||
) -> list[Tool]:
|
||||
"""Map AF agent tools to Foundry ``PromptAgentDefinition`` tool entries.
|
||||
|
||||
Tool sources walked, in order:
|
||||
|
||||
* ``agent.default_options["tools"]`` — function tools and hosted Foundry SDK
|
||||
tool instances (returned by ``FoundryChatClient.get_*_tool()``).
|
||||
* ``agent.mcp_tools`` — local Agent Framework MCP servers (split off from
|
||||
the tools list by ``normalize_tools()``). These cannot be published as
|
||||
prompt-agent tools; the caller must use the hosted MCP factory instead.
|
||||
|
||||
Hosted SDK tool instances are passed through unchanged. Mapping/dict tools
|
||||
are passed through after light validation. Anything else raises
|
||||
``ValueError`` with a message that names the offending type.
|
||||
"""
|
||||
from azure.ai.projects.models import Tool as ProjectsTool
|
||||
|
||||
converted: list[Tool] = []
|
||||
|
||||
for tool_item in tools or ():
|
||||
if isinstance(tool_item, ProjectsTool):
|
||||
converted.append(tool_item)
|
||||
continue
|
||||
if isinstance(tool_item, FunctionTool):
|
||||
converted.append(_function_tool_to_foundry(tool_item))
|
||||
continue
|
||||
if isinstance(tool_item, Mapping):
|
||||
converted.append(_validate_mapping_tool(cast("Mapping[str, Any]", tool_item)))
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Unsupported tool type for PromptAgentDefinition: {type(tool_item).__name__}. "
|
||||
"Use FoundryChatClient.get_*_tool() helpers, a callable / FunctionTool, "
|
||||
"or a dict matching the Foundry tool schema."
|
||||
)
|
||||
|
||||
for mcp_tool in mcp_tools or ():
|
||||
raise ValueError(
|
||||
f"Local MCP tool {mcp_tool.name!r} cannot be published as a prompt-agent tool. "
|
||||
"Use FoundryChatClient.get_mcp_tool(...) to register a hosted MCP server instead."
|
||||
)
|
||||
|
||||
return converted
|
||||
|
||||
|
||||
def _function_tool_to_foundry(tool_item: FunctionTool) -> Tool:
|
||||
"""Build a Foundry ``FunctionTool`` declaration from an AF ``FunctionTool``.
|
||||
|
||||
The result carries only the schema (name, description, parameters). It is a
|
||||
declaration of the tool the prompt agent may call; server-side execution
|
||||
must be wired separately by the caller.
|
||||
"""
|
||||
try:
|
||||
from azure.ai.projects.models import FunctionTool as ProjectsFunctionTool
|
||||
except ImportError as exc: # pragma: no cover - sanity guard
|
||||
raise ImportError(
|
||||
"FunctionTool is not available in the installed azure-ai-projects. Upgrade azure-ai-projects."
|
||||
) from exc
|
||||
|
||||
return ProjectsFunctionTool(
|
||||
name=tool_item.name,
|
||||
description=tool_item.description or "",
|
||||
parameters=tool_item.parameters(),
|
||||
strict=True,
|
||||
)
|
||||
|
||||
|
||||
def _validate_mapping_tool(tool_item: Mapping[str, Any]) -> Tool:
|
||||
"""Validate a dict-shaped tool and instantiate a Foundry ``Tool``.
|
||||
|
||||
The Foundry SDK can rehydrate a tool model from its raw JSON mapping via
|
||||
the discriminator on ``type``. We require the ``type`` field so the
|
||||
failure mode is obvious; everything else is dispatched through the SDK's
|
||||
``Tool._deserialize`` entry point so the concrete subclass
|
||||
(e.g. ``FunctionTool``, ``WebSearchTool``) is materialized rather than a
|
||||
generic ``Tool`` instance.
|
||||
"""
|
||||
from azure.ai.projects.models import Tool as ProjectsTool
|
||||
|
||||
if "type" not in tool_item:
|
||||
raise ValueError("Dict-shaped tools must include a 'type' field matching a Foundry tool discriminator.")
|
||||
# ``_deserialize`` is the SDK's discriminator-aware entry point. It is marked
|
||||
# protected by convention but is the standard way to rehydrate polymorphic
|
||||
# azure-sdk-for-python models from a raw mapping.
|
||||
return cast("Tool", ProjectsTool._deserialize(dict(tool_item), [])) # type: ignore[no-untyped-call] # pyright: ignore[reportPrivateUsage, reportUnknownMemberType]
|
||||
@@ -0,0 +1,664 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from agent_framework import Agent, MCPStdioTool, tool
|
||||
from agent_framework._feature_stage import ExperimentalFeature
|
||||
from azure.ai.projects.models import (
|
||||
CodeInterpreterTool,
|
||||
PromptAgentDefinition,
|
||||
PromptAgentDefinitionTextOptions,
|
||||
RaiConfig,
|
||||
Reasoning,
|
||||
StructuredInputDefinition,
|
||||
ToolChoiceAllowed,
|
||||
ToolChoiceFunction,
|
||||
WebSearchTool,
|
||||
)
|
||||
from azure.ai.projects.models import (
|
||||
FunctionTool as ProjectsFunctionTool,
|
||||
)
|
||||
from azure.ai.projects.models import (
|
||||
MCPTool as FoundryMCPTool,
|
||||
)
|
||||
from azure.ai.projects.models import (
|
||||
Tool as ProjectsTool,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_foundry import (
|
||||
FoundryChatClient,
|
||||
RawFoundryChatClient,
|
||||
to_prompt_agent,
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: Annotated[str, "City name"]) -> str:
|
||||
"""Get the weather for a location."""
|
||||
return f"sunny in {location}"
|
||||
|
||||
|
||||
def _make_foundry_chat_client(model: str | None = "gpt-4o-mini") -> FoundryChatClient:
|
||||
"""Build a FoundryChatClient backed by a mocked project client."""
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
return FoundryChatClient(project_client=mock_project, model=model or "placeholder")
|
||||
|
||||
|
||||
def _make_agent(client: Any, **agent_kwargs: Any) -> Agent:
|
||||
"""Build an Agent without entering the async context manager."""
|
||||
return Agent(client=client, **agent_kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core conversion: model resolution and client-type guarding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_minimal() -> None:
|
||||
"""An agent with only model + instructions produces a valid PromptAgentDefinition."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="Be helpful.")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition, PromptAgentDefinition)
|
||||
assert definition.model == "gpt-4o-mini"
|
||||
assert definition.instructions == "Be helpful."
|
||||
assert definition.tools is None
|
||||
|
||||
|
||||
def test_to_prompt_agent_serializes_cleanly() -> None:
|
||||
"""The PromptAgentDefinition serializes to a dict that includes ``kind: prompt``."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="Hi.")
|
||||
|
||||
payload = to_prompt_agent(agent).as_dict()
|
||||
|
||||
assert payload["model"] == "gpt-4o-mini"
|
||||
assert payload["instructions"] == "Hi."
|
||||
assert payload["kind"] == "prompt"
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_non_foundry_client() -> None:
|
||||
"""A non-FoundryChatClient client raises TypeError."""
|
||||
|
||||
class NotFoundryChatClient:
|
||||
"""Stand-in for a different chat client implementation."""
|
||||
|
||||
agent = _make_agent(NotFoundryChatClient())
|
||||
|
||||
with pytest.raises(TypeError, match="FoundryChatClient"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_missing_model() -> None:
|
||||
"""When neither default_options nor the client has a model, ValueError is raised."""
|
||||
client = _make_foundry_chat_client()
|
||||
client.model = ""
|
||||
agent = _make_agent(client)
|
||||
agent.default_options.pop("model", None)
|
||||
|
||||
with pytest.raises(ValueError, match="Agent has no model"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_no_instructions() -> None:
|
||||
"""A tool-only agent (no instructions) produces a definition with instructions=None."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
tools=[WebSearchTool()],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "gpt-4o-mini"
|
||||
assert definition.instructions is None
|
||||
payload = definition.as_dict()
|
||||
assert "instructions" not in payload
|
||||
|
||||
|
||||
def test_to_prompt_agent_prefers_default_options_model() -> None:
|
||||
"""default_options['model'] wins over the bound client's model."""
|
||||
client = _make_foundry_chat_client(model="client-model")
|
||||
agent = _make_agent(client, instructions="x", default_options={"model": "agent-override"})
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "agent-override"
|
||||
|
||||
|
||||
def test_to_prompt_agent_falls_back_to_client_model() -> None:
|
||||
"""When the agent has no model override, the bound client's model is used."""
|
||||
agent = _make_agent(_make_foundry_chat_client(model="client-model"), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "client-model"
|
||||
|
||||
|
||||
def test_to_prompt_agent_works_with_raw_foundry_chat_client() -> None:
|
||||
"""to_prompt_agent accepts subclasses too — RawFoundryChatClient works."""
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
raw_client = RawFoundryChatClient(project_client=mock_project, model="gpt-4o")
|
||||
agent = _make_agent(raw_client, instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "gpt-4o"
|
||||
|
||||
|
||||
def test_to_prompt_agent_is_marked_experimental() -> None:
|
||||
"""to_prompt_agent carries the TO_PROMPT_AGENT experimental metadata."""
|
||||
assert getattr(to_prompt_agent, "__feature_stage__", None) == "experimental"
|
||||
assert getattr(to_prompt_agent, "__feature_id__", None) == ExperimentalFeature.TO_PROMPT_AGENT.value
|
||||
|
||||
|
||||
def test_to_prompt_agent_does_not_mutate_default_options() -> None:
|
||||
"""Conversion never mutates the translatable option values in ``agent.default_options``."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.5,
|
||||
"reasoning": {"effort": "low"},
|
||||
"response_format": {"type": "json_object"},
|
||||
"verbosity": "low",
|
||||
},
|
||||
tools=[get_weather],
|
||||
)
|
||||
reasoning_before = dict(agent.default_options["reasoning"]) # type: ignore[index]
|
||||
response_format_before = dict(agent.default_options["response_format"]) # type: ignore[index]
|
||||
tool_choice_before = agent.default_options.get("tool_choice")
|
||||
|
||||
to_prompt_agent(agent)
|
||||
|
||||
assert dict(agent.default_options["reasoning"]) == reasoning_before # type: ignore[index]
|
||||
assert dict(agent.default_options["response_format"]) == response_format_before # type: ignore[index]
|
||||
assert agent.default_options.get("tool_choice") == tool_choice_before
|
||||
assert "text" not in agent.default_options
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_passes_through_sdk_tool_instances() -> None:
|
||||
"""Foundry SDK tool instances (e.g. WebSearchTool) are passed through unchanged."""
|
||||
ws = WebSearchTool()
|
||||
ci = CodeInterpreterTool(container={"type": "auto"})
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[ws, ci])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 2
|
||||
assert definition.tools[0] is ws
|
||||
assert definition.tools[1] is ci
|
||||
|
||||
|
||||
def test_to_prompt_agent_converts_function_tool() -> None:
|
||||
"""An AF FunctionTool from @tool emerges as a Foundry FunctionTool declaration."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[get_weather])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
fn = definition.tools[0]
|
||||
assert isinstance(fn, ProjectsFunctionTool)
|
||||
assert fn.name == "get_weather"
|
||||
assert fn.description == "Get the weather for a location."
|
||||
assert fn.strict is True
|
||||
parameters = fn.parameters
|
||||
assert parameters["type"] == "object"
|
||||
assert "location" in parameters["properties"]
|
||||
assert parameters["required"] == ["location"]
|
||||
|
||||
|
||||
def test_to_prompt_agent_preserves_mixed_tool_order() -> None:
|
||||
"""A mix of hosted SDK tools and function tools is preserved in definition order."""
|
||||
ws = WebSearchTool()
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[ws, get_weather],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert definition.tools[0] is ws
|
||||
assert isinstance(definition.tools[1], ProjectsFunctionTool)
|
||||
assert definition.tools[1].name == "get_weather"
|
||||
|
||||
|
||||
def test_to_prompt_agent_passes_through_hosted_mcp_tool() -> None:
|
||||
"""A hosted MCP tool from FoundryChatClient.get_mcp_tool() is passed through."""
|
||||
hosted_mcp = FoundryChatClient.get_mcp_tool(
|
||||
name="github",
|
||||
url="https://mcp.example.com",
|
||||
)
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[hosted_mcp])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
assert isinstance(definition.tools[0], FoundryMCPTool)
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_local_mcp_tool() -> None:
|
||||
"""A local MCP tool in agent.mcp_tools raises a ValueError pointing at get_mcp_tool."""
|
||||
local_mcp = MCPStdioTool(name="local_fs", command="echo")
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[local_mcp])
|
||||
|
||||
with pytest.raises(ValueError, match="get_mcp_tool"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_unknown_tool_type() -> None:
|
||||
"""An arbitrary object in tools that isn't a known shape raises ValueError."""
|
||||
|
||||
class NotATool:
|
||||
pass
|
||||
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[NotATool()],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="NotATool"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_accepts_dict_tool() -> None:
|
||||
"""A dict with a 'type' discriminator is rehydrated through the SDK Tool model."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[{"type": "web_search"}],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
tool_obj = definition.tools[0]
|
||||
# The SDK discriminator on ``type`` should materialize the concrete subclass
|
||||
# (here ``WebSearchTool``), not a generic ``Tool``.
|
||||
assert isinstance(tool_obj, WebSearchTool)
|
||||
assert isinstance(tool_obj, ProjectsTool)
|
||||
assert tool_obj.type == "web_search"
|
||||
|
||||
|
||||
def test_to_prompt_agent_accepts_dict_function_tool() -> None:
|
||||
"""A dict with ``type='function'`` rehydrates to a Foundry ``FunctionTool``."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"description": "Look up a value.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
tool_obj = definition.tools[0]
|
||||
assert isinstance(tool_obj, ProjectsFunctionTool)
|
||||
assert tool_obj.name == "lookup"
|
||||
assert tool_obj.description == "Look up a value."
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_dict_tool_without_type() -> None:
|
||||
"""A dict missing the 'type' field raises ValueError."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[{"name": "missing_type"}],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="type"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generation parameters sourced from default_options
|
||||
# (translated by _prepare_prompt_agent_options in _to_prompt_agent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_temperature_top_p_unset_by_default() -> None:
|
||||
"""Without default_options entries, temperature/top_p are unset on the definition."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.temperature is None
|
||||
assert definition.top_p is None
|
||||
payload = definition.as_dict()
|
||||
assert "temperature" not in payload
|
||||
assert "top_p" not in payload
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_temperature_top_p_from_default_options() -> None:
|
||||
"""temperature/top_p in default_options flow through to the definition."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"temperature": 0.42, "top_p": 0.8},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.temperature == 0.42
|
||||
assert definition.top_p == 0.8
|
||||
|
||||
|
||||
def test_to_prompt_agent_temperature_zero_is_honored() -> None:
|
||||
"""A literal ``0.0`` in default_options is treated as explicit, not as unset."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"temperature": 0.0, "top_p": 0.0},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.temperature == 0.0
|
||||
assert definition.top_p == 0.0
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_omitted_when_no_tools() -> None:
|
||||
"""``tool_choice`` is dropped when the definition has no tools.
|
||||
|
||||
Mirrors RawOpenAIChatClient._prepare_options behavior. This also keeps
|
||||
Agent.__init__'s default ``tool_choice="auto"`` from polluting tool-less
|
||||
prompt agents.
|
||||
"""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tool_choice is None
|
||||
assert "tool_choice" not in definition.as_dict()
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_auto_with_tools() -> None:
|
||||
"""When tools are present, the default ``tool_choice="auto"`` flows through."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[get_weather])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tool_choice == "auto"
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_required_string_with_tools() -> None:
|
||||
"""A string ``tool_choice="required"`` flows through when tools are present."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[get_weather],
|
||||
default_options={"tool_choice": "required"},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tool_choice == "required"
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_required_function_dict() -> None:
|
||||
"""tool_choice mode=required with a function name → ToolChoiceFunction."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[get_weather],
|
||||
default_options={
|
||||
"tool_choice": {"mode": "required", "required_function_name": "get_weather"},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.tool_choice, ToolChoiceFunction)
|
||||
assert definition.tool_choice.name == "get_weather"
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_auto_allowed_tools() -> None:
|
||||
"""tool_choice mode=auto with allowed_tools → ToolChoiceAllowed."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[get_weather],
|
||||
default_options={
|
||||
"tool_choice": {"mode": "auto", "allowed_tools": ["get_weather"]},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.tool_choice, ToolChoiceAllowed)
|
||||
assert definition.tool_choice.mode == "auto"
|
||||
assert definition.tool_choice.tools == [{"type": "function", "name": "get_weather"}]
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_reasoning_dict_from_default_options() -> None:
|
||||
"""A reasoning dict in default_options becomes a Foundry ``Reasoning`` model."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"reasoning": {"effort": "high", "summary": "concise"}},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.reasoning, Reasoning)
|
||||
assert definition.reasoning.effort == "high"
|
||||
assert definition.reasoning.summary == "concise"
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_reasoning_model_from_default_options() -> None:
|
||||
"""A pre-built ``Reasoning`` model in default_options is passed through."""
|
||||
reasoning = Reasoning(effort="medium")
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"reasoning": reasoning},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.reasoning is reasoning
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_response_format_dict_to_text() -> None:
|
||||
"""A ``response_format`` dict in default_options becomes ``text.format``."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "weather",
|
||||
"schema": {"type": "object", "properties": {"temp": {"type": "number"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
format_dict = definition.text["format"]
|
||||
assert format_dict is not None
|
||||
assert format_dict["type"] == "json_schema"
|
||||
assert format_dict["name"] == "weather"
|
||||
assert format_dict["schema"] == {"type": "object", "properties": {"temp": {"type": "number"}}}
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_response_format_pydantic_to_text() -> None:
|
||||
"""A Pydantic ``BaseModel`` response_format becomes ``text.format`` json_schema."""
|
||||
|
||||
class WeatherReply(BaseModel):
|
||||
location: str
|
||||
condition: str
|
||||
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"response_format": WeatherReply},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
format_dict = definition.text["format"]
|
||||
assert format_dict is not None
|
||||
assert format_dict["type"] == "json_schema"
|
||||
assert format_dict["name"] == "WeatherReply"
|
||||
assert "schema" in format_dict
|
||||
assert "location" in format_dict["schema"]["properties"]
|
||||
|
||||
|
||||
def test_to_prompt_agent_merges_verbosity_into_text() -> None:
|
||||
"""A ``verbosity`` entry merges into the ``text`` config."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"verbosity": "low"},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
# PromptAgentDefinitionTextOptions only declares ``format``, but its
|
||||
# mapping-init preserves extra keys for server-side use.
|
||||
assert dict(definition.text).get("verbosity") == "low"
|
||||
|
||||
|
||||
def test_to_prompt_agent_raises_on_conflicting_response_format_and_text_format() -> None:
|
||||
"""Pydantic ``response_format`` + a different ``text.format`` mapping must fail loudly."""
|
||||
|
||||
class WeatherReply(BaseModel):
|
||||
location: str
|
||||
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"response_format": WeatherReply,
|
||||
"text": {"format": {"type": "json_object"}},
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Conflicting response_format"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_passes_through_text_dict_from_default_options() -> None:
|
||||
"""A ``text`` dict in default_options flows through to the definition."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"text": {"format": {"type": "text"}, "verbosity": "high"}},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
assert definition.text["format"] == {"type": "text"}
|
||||
assert dict(definition.text).get("verbosity") == "high"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Foundry-specific kwargs (no AF ChatOptions equivalent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_kwarg_only_fields_unset_by_default() -> None:
|
||||
"""structured_inputs and rai_config are absent from the payload when unset."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
payload = to_prompt_agent(agent).as_dict()
|
||||
|
||||
assert "structured_inputs" not in payload
|
||||
assert "rai_config" not in payload
|
||||
|
||||
|
||||
def test_to_prompt_agent_forwards_structured_inputs_kwarg() -> None:
|
||||
"""A ``structured_inputs`` mapping is forwarded (and copied to a new dict)."""
|
||||
inputs = {"city": StructuredInputDefinition(description="Target city.")}
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent, structured_inputs=inputs)
|
||||
|
||||
assert definition.structured_inputs is not None
|
||||
assert set(definition.structured_inputs) == {"city"}
|
||||
assert definition.structured_inputs["city"] is inputs["city"]
|
||||
inputs["other"] = StructuredInputDefinition(description="x")
|
||||
assert "other" not in definition.structured_inputs
|
||||
|
||||
|
||||
def test_to_prompt_agent_forwards_rai_config_kwarg() -> None:
|
||||
"""A ``RaiConfig`` kwarg is forwarded to the definition."""
|
||||
rai_config = RaiConfig()
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent, rai_config=rai_config)
|
||||
|
||||
assert definition.rai_config is rai_config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Combined integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_combines_all_sources() -> None:
|
||||
"""Generation params from default_options + Foundry-only kwargs combine cleanly."""
|
||||
rai_config = RaiConfig()
|
||||
structured = {"q": StructuredInputDefinition(description="query")}
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.95,
|
||||
"tool_choice": "auto",
|
||||
"reasoning": {"effort": "medium"},
|
||||
"verbosity": "low",
|
||||
},
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(
|
||||
agent,
|
||||
structured_inputs=structured,
|
||||
rai_config=rai_config,
|
||||
)
|
||||
|
||||
assert definition.temperature == 0.3
|
||||
assert definition.top_p == 0.95
|
||||
assert definition.tool_choice == "auto"
|
||||
assert isinstance(definition.reasoning, Reasoning)
|
||||
assert definition.reasoning.effort == "medium"
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
assert dict(definition.text).get("verbosity") == "low"
|
||||
assert definition.rai_config is rai_config
|
||||
assert definition.structured_inputs is not None and "q" in definition.structured_inputs
|
||||
assert definition.tools is not None and len(definition.tools) == 1
|
||||
@@ -0,0 +1,150 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from random import randint
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework.foundry import FoundryAgent, FoundryChatClient, to_prompt_agent
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import Field
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
Foundry Prompt Agent: Convert, Publish, Connect, and Run
|
||||
|
||||
This sample shows the end-to-end loop:
|
||||
|
||||
1. Build an ``Agent`` backed by ``FoundryChatClient`` with a local ``@tool``
|
||||
function and Foundry-hosted tools.
|
||||
2. Run the local ``Agent`` directly against the Foundry Responses API.
|
||||
3. Convert it with ``to_prompt_agent(agent)`` and publish via
|
||||
``AIProjectClient.agents.create_version(...)``.
|
||||
4. Connect to the deployed prompt agent with ``FoundryAgent`` and pass the
|
||||
*same* ``book_hotel`` callable through ``tools=`` so the server-side prompt
|
||||
agent and the client share a single tool definition.
|
||||
|
||||
The Foundry prompt agent only receives the ``book_hotel`` *declaration* (its
|
||||
JSON schema). When the deployed agent decides to call the tool, ``FoundryAgent``
|
||||
executes the local Python implementation by matching tool names — keeping the
|
||||
schema on the server and the implementation on the client in sync.
|
||||
|
||||
Local ``Agent`` vs deployed prompt agent — compare & contrast when calling
|
||||
``run`` on each:
|
||||
|
||||
* **Runtime / latency.** ``Agent.run`` issues a single ``responses.create``
|
||||
call against the Foundry Responses API. ``FoundryAgent.run`` against a
|
||||
published prompt agent goes through the Foundry Agents service, which
|
||||
resolves the stored ``PromptAgentDefinition`` (instructions, tools,
|
||||
generation parameters, RAI config) on every call before forwarding to the
|
||||
model. Expect a small per-call overhead on the deployed path in exchange
|
||||
for centrally managed configuration.
|
||||
* **Configurability.** With the local ``Agent``, model, instructions, tools,
|
||||
``default_options``, etc. live in your process — change them, restart, and
|
||||
the next ``run`` picks them up. With the deployed prompt agent, those same
|
||||
fields are versioned server-side: publishing a new version updates every
|
||||
consumer at once and you keep an audit trail of previous versions, but you
|
||||
must call ``create_version`` (or pin ``agent_version``) to roll changes
|
||||
out or back.
|
||||
* **Persistence / sharing.** A local ``Agent`` instance only exists for the
|
||||
lifetime of the process that created it; tools and instructions are not
|
||||
discoverable by anything else. A published prompt agent is a first-class
|
||||
Foundry resource — other services, other languages, and the Foundry portal
|
||||
can all bind to it by ``agent_name`` (+ optional ``agent_version``) and get
|
||||
the same behaviour. Local ``@tool`` callables stay on the client; only
|
||||
their JSON schema is persisted, so the implementation must be supplied
|
||||
again at connection time via ``FoundryAgent(tools=[...])``.
|
||||
|
||||
``to_prompt_agent`` is experimental
|
||||
(``ExperimentalFeature.TO_PROMPT_AGENT``) and may change before being released.
|
||||
"""
|
||||
|
||||
|
||||
@tool
|
||||
def book_hotel(
|
||||
city: Annotated[str, Field(description="The city to book the hotel in.")],
|
||||
nights: Annotated[int, Field(description="Number of nights to stay.")],
|
||||
) -> str:
|
||||
"""Book a hotel room for the given city and number of nights."""
|
||||
return f"Booked a hotel in {city} for {nights} nights. Confirmation #CTX-{randint(1000, 9999)}."
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
print("=== Foundry Prompt Agent: Convert, Publish, Connect, and Run ===\n")
|
||||
|
||||
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
|
||||
model = os.environ["FOUNDRY_MODEL"]
|
||||
|
||||
# Use ``async with`` so the credential and project client are closed even if the
|
||||
# body below raises. The ``try/finally`` around ``delete`` further guarantees we
|
||||
# don't leave an orphaned prompt agent in the Foundry project after a failure.
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AIProjectClient(endpoint=project_endpoint, credential=credential) as project_client,
|
||||
):
|
||||
# 1) Define the Agent. `name` / `description` set here become the Foundry agent identity
|
||||
# on publish; `book_hotel` is the local implementation that backs the published declaration.
|
||||
agent = Agent(
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=project_endpoint,
|
||||
model=model,
|
||||
credential=credential,
|
||||
),
|
||||
name="travel-agent",
|
||||
description="Helps Contoso employees book travel.",
|
||||
instructions="You are a helpful travel assistant. Use the booking tool when asked.",
|
||||
tools=[
|
||||
FoundryChatClient.get_web_search_tool(),
|
||||
book_hotel,
|
||||
],
|
||||
default_options={"reasoning": {"effort": "medium"}},
|
||||
)
|
||||
|
||||
query = "Book me a hotel in Seattle for 3 nights."
|
||||
|
||||
# 2) Run the local Agent. This calls the Foundry Responses API directly — instructions,
|
||||
# tools, and generation parameters live in this process only.
|
||||
print(f"User (local Agent): {query}")
|
||||
local_result = await agent.run(query)
|
||||
print(f"Local Agent: {local_result}\n")
|
||||
|
||||
# 3) Convert and publish. The version returned by Foundry includes the version label
|
||||
# we need when connecting back to that specific deployment.
|
||||
created = await project_client.agents.create_version(
|
||||
agent_name=agent.name,
|
||||
# note this line:
|
||||
definition=to_prompt_agent(agent),
|
||||
description=agent.description,
|
||||
)
|
||||
print(f"Published prompt agent: {created.name} v{created.version}\n")
|
||||
|
||||
try:
|
||||
# 4) Connect to the deployed prompt agent with FoundryAgent and pass the *same* callable
|
||||
# tool. FoundryAgent runs the local function when the server-side agent invokes the tool,
|
||||
# matching by name. Compared to step 2, instructions/tools/generation parameters now
|
||||
# come from the stored PromptAgentDefinition rather than this process.
|
||||
deployed = FoundryAgent(
|
||||
project_endpoint=project_endpoint,
|
||||
agent_name=created.name,
|
||||
agent_version=created.version,
|
||||
credential=credential,
|
||||
tools=[book_hotel],
|
||||
)
|
||||
|
||||
print(f"User (deployed agent): {query}")
|
||||
deployed_result = await deployed.run(query)
|
||||
print(f"Deployed Agent: {deployed_result}")
|
||||
finally:
|
||||
# 5) Cleanup: delete the deployed prompt agent (and all its versions) even if step 4
|
||||
# raised, so re-running the sample stays idempotent and we don't leak resources in
|
||||
# the Foundry project.
|
||||
await project_client.agents.delete(agent_name=created.name)
|
||||
print(f"\nDeleted prompt agent {created.name!r} and all its versions.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Generated
+1
-1
@@ -604,7 +604,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" },
|
||||
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user