mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5777bc546 | ||
|
|
b6b191ad9c | ||
|
|
2c8036779c | ||
|
|
ce8b6305d8 | ||
|
|
07f4c8a8d6 | ||
|
|
04aaf0c1fe | ||
|
|
3e54a689fc | ||
|
|
60af59ba8b | ||
|
|
69894eded8 | ||
|
|
495e1dad6b | ||
|
|
5777ed26e6 |
@@ -203,6 +203,8 @@ temp*/
|
||||
|
||||
# AI
|
||||
.claude/
|
||||
.omc/
|
||||
.omx/
|
||||
WARP.md
|
||||
**/memory-bank/
|
||||
**/projectBrief.md
|
||||
@@ -235,3 +237,4 @@ python/dotnet-ref
|
||||
|
||||
# Generated filtered solution files (created by eng/scripts/New-FilteredSolution.ps1)
|
||||
dotnet/filtered-*.slnx
|
||||
**/*.lscache
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: evmattso
|
||||
date: 2026-04-10
|
||||
deciders: evmattso
|
||||
---
|
||||
|
||||
# Foundry Toolbox Support in FoundryChatClient
|
||||
|
||||
## What is the goal of this feature?
|
||||
|
||||
Enable Agent Framework users to consume Foundry **toolboxes** — named, versioned bundles of tool definitions stored server-side in an Azure AI Foundry project — directly from `FoundryChatClient`, without dropping to the raw `azure-ai-projects` SDK.
|
||||
|
||||
A user who has configured a toolbox in the Foundry portal (or via the raw SDK) should be able to load it into an agent with a single call:
|
||||
|
||||
```python
|
||||
toolbox = await client.get_toolbox("research_tools")
|
||||
agent = Agent(client=client, instructions="...", tools=toolbox)
|
||||
```
|
||||
|
||||
**Success metric:** an agent can consume a toolbox with no manual handling of version-resolution logic on the user's side.
|
||||
|
||||
## What is the problem being solved?
|
||||
|
||||
`azure-ai-projects==2.1.0a20260409002` ships a new `BetaToolboxesOperations` surface, reachable as `AIProjectClient.beta.toolboxes` on the raw SDK client (and therefore as `FoundryChatClient.project_client.beta.toolboxes` through our wrapper), that lets teams:
|
||||
- Group related hosted tools (code interpreter, file search, MCP, web search, etc.) under a named toolbox
|
||||
- Version toolboxes immutably, so agents can pin to a specific configuration for production stability
|
||||
- Share toolboxes across multiple agents in a project
|
||||
|
||||
However, consuming a toolbox from the framework today requires:
|
||||
1. Knowing the raw SDK accessor path (`client.project_client.beta.toolboxes`)
|
||||
2. Making two calls for the common case — `.get(name)` to find the default version, then `.get_version(name, version)` to actually retrieve tools
|
||||
3. Manually unpacking `toolbox.tools` before passing them to `Agent(tools=...)`
|
||||
|
||||
None of this is hard, but it's the kind of boilerplate that should live in the client. Every other hosted tool in `FoundryChatClient` (code interpreter, file search, web search, image generation, MCP) already has a factory method (`get_code_interpreter_tool()`, etc.). Toolbox support should fit the same shape on the chat-client composition surface.
|
||||
|
||||
## API Changes
|
||||
|
||||
### One new method on the FoundryChatClient surface
|
||||
|
||||
The public toolbox-consumption surface lands on:
|
||||
|
||||
- `RawFoundryChatClient` (inherited by `FoundryChatClient`) in `_chat_client.py`
|
||||
|
||||
The implementation delegates to shared helper functions in `_tools.py` so there is a single source of truth for the SDK calls.
|
||||
|
||||
**Scope note:** `FoundryAgent` is intentionally not part of this design. `FoundryAgent` is the runtime surface for invoking an already-configured server-side Foundry agent; if that agent should use a toolbox, the toolbox/tools should already be configured on the Foundry side (UI or `azure-ai-projects` authoring flow) before MAF connects to it.
|
||||
|
||||
**Scope note:** Authoring a server-side agent whose definition references a toolbox (via `PromptAgentDefinition(tools=toolbox.tools, ...)` + `client.agents.create_version(...)`) is deliberately outside MAF scope. That is an `azure-ai-projects` / service-resource authoring concern, not a future MAF feature. Users who need it should use the raw Azure SDK directly.
|
||||
|
||||
```python
|
||||
async def get_toolbox(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
) -> ToolboxVersionObject:
|
||||
"""Fetch a Foundry toolbox by name.
|
||||
|
||||
If ``version`` is ``None``, resolves the toolbox's current default version
|
||||
(two requests). If ``version`` is specified, fetches that version directly
|
||||
(single request).
|
||||
|
||||
:param name: The name of the toolbox.
|
||||
:param version: Optional immutable version identifier to pin to.
|
||||
:return: A ``ToolboxVersionObject``. Pass its ``tools`` attribute to
|
||||
``Agent(tools=toolbox.tools)``.
|
||||
:raises azure.core.exceptions.ResourceNotFoundError: If the toolbox or
|
||||
version does not exist.
|
||||
"""
|
||||
|
||||
```
|
||||
|
||||
### Return types: raw SDK models, no custom wrappers
|
||||
|
||||
Methods return the `azure.ai.projects.models` types directly:
|
||||
|
||||
- `get_toolbox()` → `ToolboxVersionObject` (has `.name`, `.version`, `.tools`, `.id`, `.created_at`, `.description`, `.metadata`, `.policies`)
|
||||
|
||||
No custom wrapper classes are defined. Returning the SDK types directly:
|
||||
- Eliminates maintenance overhead of keeping a custom wrapper aligned with SDK changes
|
||||
- Matches the existing convention — `get_code_interpreter_tool()` returns the raw `CodeInterpreterTool` SDK type
|
||||
- Means any new fields the SDK adds to these types flow through automatically
|
||||
|
||||
`Agent(..., tools=...)` will accept the fetched toolbox object directly by flattening to `toolbox.tools` internally.
|
||||
|
||||
### Design decisions
|
||||
|
||||
**Instance methods, not `@staticmethod` factories.** Existing `get_code_interpreter_tool()` / `get_mcp_tool()` / etc. are `@staticmethod` because they're pure factories with no network I/O. Toolbox fetching requires the project client, so these new methods must be instance methods. This is a deliberate departure from the existing-factory pattern, justified by the async-with-I/O nature of the operation.
|
||||
|
||||
**Raw SDK type passthrough (no custom wrappers).** There is only one toolbox type in the Foundry SDK and maintaining a shadow wrapper would create alignment risk as the SDK evolves. The raw `ToolboxVersionObject` and `ToolboxObject` carry all the fields users need. Individual tools inside `toolbox.tools` are the same `azure.ai.projects.models.Tool` subclasses returned by other factory methods.
|
||||
|
||||
**Two-request default-version path.** When `version=None`, implementation calls `.get(name)` to find `default_version`, then `.get_version(name, default_version)` for the tools. Caching the default-version mapping was considered and rejected — default versions can change server-side via `update(default_version=...)`, and a stale cache would silently give callers the wrong tools. Two requests at agent setup is acceptable.
|
||||
|
||||
**No discovery/listing surface in MAF.** Discovery is intentionally left to the raw `azure-ai-projects` client. MAF does not currently expose project-resource listing surfaces for many other Foundry resources (deployments, vector stores, agents, etc.), so the toolbox design stays narrowly focused on explicit retrieval by name/version.
|
||||
|
||||
**Shared helpers in `_tools.py`.** The SDK-call helper function (`fetch_toolbox`) lives in a shared module so the chat-client surface stays thin and the request logic remains centralized.
|
||||
|
||||
**`tools=toolbox` convenience, not a new wrapper type.** Although `get_toolbox()` returns the raw `ToolboxVersionObject`, Agent Framework can still support `tools=toolbox` / `tools=[toolbox]` by flattening the toolbox's `.tools` internally. That matches existing SDK ergonomics where some higher-level objects can be placed directly in `tools=` and unpacked underneath, without introducing a public `FoundryToolbox` wrapper.
|
||||
|
||||
**Errors pass through unchanged.** `ResourceNotFoundError`, `HttpResponseError`, etc. from the SDK propagate as-is. No framework-specific exception hierarchy.
|
||||
|
||||
## E2E Code Samples
|
||||
|
||||
### Primary sample
|
||||
|
||||
New file: `samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py`
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
|
||||
toolbox = await client.get_toolbox("research_tools")
|
||||
print(f"Loaded toolbox {toolbox.name}@{toolbox.version} ({len(toolbox.tools)} tools)")
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="You are a research assistant.",
|
||||
tools=toolbox,
|
||||
)
|
||||
|
||||
result = await agent.run("What are the latest developments in quantum error correction?")
|
||||
print(f"Result: {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Version pinning
|
||||
|
||||
```python
|
||||
toolbox = await client.get_toolbox("research_tools", version="v3")
|
||||
```
|
||||
|
||||
### Combining multiple toolboxes
|
||||
|
||||
```python
|
||||
toolbox_a = await client.get_toolbox("research_tools")
|
||||
toolbox_b = await client.get_toolbox("some_other_tools", version="v3")
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="...",
|
||||
tools=[toolbox_a, toolbox_b],
|
||||
)
|
||||
```
|
||||
|
||||
### Combining toolbox tools with locally defined tools
|
||||
|
||||
```python
|
||||
toolbox = await client.get_toolbox("research_tools")
|
||||
|
||||
def get_internal_metrics(metric_name: str) -> dict:
|
||||
"""Custom tool that reads from an internal dashboard."""
|
||||
...
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="...",
|
||||
tools=[get_internal_metrics, toolbox],
|
||||
)
|
||||
```
|
||||
|
||||
### Selecting only some tools from a toolbox
|
||||
|
||||
Developers will not always want to pass the entire toolbox through unchanged. A
|
||||
small helper in the Foundry package provides local post-fetch selection without
|
||||
changing the raw return type of `get_toolbox()`.
|
||||
|
||||
```python
|
||||
from agent_framework.foundry import select_toolbox_tools
|
||||
|
||||
toolbox = await client.get_toolbox("research_tools")
|
||||
|
||||
selected_tools = select_toolbox_tools(
|
||||
toolbox,
|
||||
include_names=["githubmcp", "code_interpreter"],
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="Use only the selected toolbox tools.",
|
||||
tools=selected_tools,
|
||||
)
|
||||
```
|
||||
|
||||
Supported filters:
|
||||
|
||||
```python
|
||||
from agent_framework.foundry import FoundryHostedToolType, select_toolbox_tools
|
||||
|
||||
selected_tools = select_toolbox_tools(
|
||||
toolbox,
|
||||
include_types=["mcp", "code_interpreter"], # type: Collection[FoundryHostedToolType]
|
||||
exclude_names=["internal_admin_tool"],
|
||||
)
|
||||
```
|
||||
|
||||
Helper signature:
|
||||
|
||||
```python
|
||||
type FoundryHostedToolType = Literal[
|
||||
"code_interpreter",
|
||||
"file_search",
|
||||
"image_generation",
|
||||
"mcp",
|
||||
"web_search",
|
||||
] | str
|
||||
|
||||
def select_toolbox_tools(
|
||||
tools: ToolboxVersionObject | Sequence[Tool | dict[str, Any]],
|
||||
*,
|
||||
include_names: Collection[str] | None = None,
|
||||
exclude_names: Collection[str] | None = None,
|
||||
include_types: Collection[FoundryHostedToolType] | None = None,
|
||||
exclude_types: Collection[FoundryHostedToolType] | None = None,
|
||||
predicate: Callable[[Tool | dict[str, Any]], bool] | None = None,
|
||||
) -> list[Tool | dict[str, Any]]:
|
||||
...
|
||||
```
|
||||
|
||||
Normalized name precedence for `include_names` / `exclude_names`:
|
||||
|
||||
1. MCP `server_label`
|
||||
2. generic tool `name`
|
||||
3. fallback tool `type`
|
||||
|
||||
This keeps `get_toolbox()` as a thin fetch API and makes selection an explicit,
|
||||
local post-processing step, while still allowing the ergonomic
|
||||
`select_toolbox_tools(toolbox, ...)` call shape.
|
||||
|
||||
## Native vs MCP consumption of a Foundry toolbox
|
||||
|
||||
A Foundry toolbox can be consumed two ways. This design adds new implementation work only for the first:
|
||||
|
||||
1. **Native consumption (in scope).** Tools execute inside Foundry's agent runtime. `get_toolbox()` returns the `ToolboxVersionObject` whose `.tools` attribute carries typed tool configs that the runtime interprets server-side. This design is specifically for `FoundryChatClient`-backed local agent composition.
|
||||
|
||||
2. **MCP consumption (already supported through existing MCP abstractions).** A Foundry toolbox can also be exposed as an MCP server. In that case, use the existing `MCPStreamableHTTPTool(name=..., url=...)` — it already handles this path with any chat client (Foundry, OpenAI, Anthropic, etc.). No new Foundry-specific API is needed for MCP-exposed toolboxes in this design.
|
||||
|
||||
### MCPStreamableHTTPTool example for a Foundry toolbox endpoint
|
||||
|
||||
If Foundry gives you an MCP endpoint for the toolbox (for example from the
|
||||
toolbox details UI / endpoint surface), the existing MCP client path is:
|
||||
|
||||
```python
|
||||
from agent_framework import Agent, MCPStreamableHTTPTool
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
toolbox_mcp = MCPStreamableHTTPTool(
|
||||
name="research_tools",
|
||||
url="https://<foundry-toolbox-mcp-endpoint>",
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
instructions="You are a research assistant.",
|
||||
tools=[toolbox_mcp],
|
||||
)
|
||||
```
|
||||
|
||||
This is a different integration shape than `get_toolbox(...).tools`:
|
||||
|
||||
- `get_toolbox(...).tools` = **native Foundry hosted-tool configs** interpreted by the
|
||||
Foundry runtime
|
||||
- `MCPStreamableHTTPTool(name=..., url=...)` = **live MCP server connection** to a
|
||||
toolbox endpoint
|
||||
|
||||
The design in this spec adds first-class support only for the native hosted-tool
|
||||
path. The MCP path is already served by the framework's existing MCP abstractions.
|
||||
|
||||
These paths are not unified because they have fundamentally different execution models. Native toolbox tools are declarative configs the Foundry runtime executes; MCP consumption is a live wire protocol to a running server.
|
||||
|
||||
**MCP authentication inside a toolbox** is handled server-side via `project_connection_id` on individual `MCPTool` entries (OAuth connection objects configured in the Foundry project). The client never holds bearer tokens. Consent flow handling (`CONSENT_REQUIRED` → user-visible consent URL) happens during `agent.run()`, not during toolbox fetching — see Non-goals.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Unit tests in `packages/foundry/tests/test_toolbox.py` with mocked `project_client.beta.toolboxes`. A single opt-in live round-trip, `test_integration_get_toolbox_round_trip_against_real_project`, is marked `@pytest.mark.integration`; it is skipped by default and only runs when the required Foundry credentials are available.
|
||||
|
||||
Coverage:
|
||||
|
||||
- `get_toolbox(name, version="v3")` — explicit version, single request. Assert `.get` not called, `.get_version` awaited once, returns `ToolboxVersionObject`.
|
||||
- `get_toolbox(name)` — default-version resolution. Assert `.get` then `.get_version` called in order with correct args.
|
||||
- Error propagation — `ResourceNotFoundError` from `.get` propagates unchanged.
|
||||
- Tool passthrough — heterogeneous tool list (`CodeInterpreterTool`, `MCPTool(project_connection_id=...)`) passes through unchanged. Asserts `project_connection_id` survives.
|
||||
- Agent integration smoke — `tools=toolbox` / `tools=[toolbox]` flatten to the underlying toolbox tools.
|
||||
- Multiple toolbox composition smoke — `tools=[toolbox_a, toolbox_b]` flattens into a single agent tool list.
|
||||
- `get_toolbox_tool_name()` — selection-name precedence is MCP `server_label`, then `name`, then `type`.
|
||||
- `select_toolbox_tools(toolbox, include_names=...)` — selects by normalized tool names directly from a fetched toolbox object.
|
||||
- `select_toolbox_tools(toolbox, include_types=...)` — selects by tool types with `Literal`-guided IDE completion.
|
||||
- `select_toolbox_tools(..., exclude_names=..., predicate=...)` — supports exclusion + custom predicates.
|
||||
|
||||
Deliberately **not** covered:
|
||||
- Runtime consent-flow handling for OAuth MCP tools (see Non-goals).
|
||||
- Toolbox discovery/listing (`list_toolboxes`, `list_toolbox_versions`) — deliberately left to the raw Azure SDK.
|
||||
- Full CRUD (`create_version`, `update`, `delete`) and server-side agent authoring — see Non-goals.
|
||||
|
||||
Live Foundry API integration is exercised only through the opt-in `@pytest.mark.integration` round-trip noted above; it is not part of the default test run.
|
||||
|
||||
## Framework dependency: `normalize_tools` flattening
|
||||
|
||||
The core `normalize_tools` function in `packages/core/agent_framework/_tools.py` already supports flattening composite tool inputs. Toolbox support extends that behavior so a fetched `ToolboxVersionObject` is treated as a composite tool source and flattened to its `.tools`.
|
||||
|
||||
That enables:
|
||||
|
||||
- `tools=toolbox`
|
||||
- `tools=[toolbox]`
|
||||
- `tools=[local_tool, toolbox]`
|
||||
- `tools=[toolbox_a, toolbox_b]`
|
||||
|
||||
while still keeping `select_toolbox_tools(toolbox.tools, ...)` available for partial selection before the final agent construction step.
|
||||
|
||||
## Telemetry
|
||||
|
||||
Telemetry for toolbox support has two separate goals:
|
||||
|
||||
1. **Observe toolbox API access** — `get_toolbox()`
|
||||
2. **Observe toolbox usage during agent runs** — when users pass toolbox-derived tools into `Agent(..., tools=...)`
|
||||
|
||||
### Request telemetry for toolbox API access
|
||||
|
||||
When Agent Framework constructs the `AIProjectClient` internally for `FoundryChatClient`, it already sets:
|
||||
|
||||
```python
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT
|
||||
```
|
||||
|
||||
That means toolbox API requests made through:
|
||||
|
||||
- `project_client.beta.toolboxes.get(...)`
|
||||
- `project_client.beta.toolboxes.get_version(...)`
|
||||
|
||||
carry the standard MAF user-agent marker and can be queried in backend request logs the same way as other Foundry SDK calls made through framework-owned clients.
|
||||
|
||||
Important constraint: if the caller passes an already-constructed `project_client`, Agent Framework does **not** mutate it to inject the MAF user-agent. In that case, toolbox API request telemetry reflects whatever user-agent behavior that external client was configured with.
|
||||
|
||||
### Runtime telemetry for toolbox usage on agent runs
|
||||
|
||||
Tool-level telemetry already captures which hosted Foundry tools are available / invoked during agent execution. The remaining gap is **toolbox provenance**: once the user writes `tools=toolbox` (or otherwise flattens the toolbox into tool configs), the framework sees only raw tool configs and no longer knows which toolbox name/version supplied them.
|
||||
|
||||
The design for closing the **client-side** observability gap is **internal provenance tracking**, not user-supplied metadata and not a new public wrapper type.
|
||||
|
||||
#### Provenance model
|
||||
|
||||
Note: this section is still under investigation.
|
||||
|
||||
When `get_toolbox()` or `list_toolbox_versions()` returns a `ToolboxVersionObject`, Agent Framework will attach private provenance metadata to:
|
||||
|
||||
- the returned toolbox object
|
||||
- each tool inside `toolbox.tools`
|
||||
|
||||
Recommended shape (private, internal-only):
|
||||
|
||||
```python
|
||||
tool._maf_toolbox_sources = [
|
||||
{
|
||||
"id": toolbox.id,
|
||||
"name": toolbox.name,
|
||||
"version": toolbox.version,
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Key properties of this approach:
|
||||
|
||||
- **No new public API surface** — users still work with raw `ToolboxVersionObject` / `ToolboxObject`
|
||||
- **No user burden** — callers do not need to stamp metadata manually
|
||||
- **Provenance follows the tool objects** — works with:
|
||||
- `tools=toolbox.tools`
|
||||
- `tools=[toolbox_a.tools, toolbox_b.tools]`
|
||||
- `tools=[*toolbox_a.tools, *toolbox_b.tools]`
|
||||
- **Private attributes are not serialized** into the actual request payload sent to the model/service, so this metadata does not leak into the tool definition body
|
||||
|
||||
This is intentionally preferred over introducing a new public `FoundryToolbox` wrapper purely for telemetry, and preferred over a separate global provenance registry. The provenance lives on the existing tool objects so list-copying and chat-option merging naturally preserve it.
|
||||
|
||||
#### Span enrichment
|
||||
|
||||
When Agent / chat telemetry computes span attributes for a run, it should inspect the final tool list and aggregate the private toolbox provenance from any tool objects that carry it. The aggregated values are then emitted as attributes on the existing run/chat spans.
|
||||
|
||||
Suggested custom attributes:
|
||||
|
||||
- `agent_framework.foundry.toolbox.ids`
|
||||
- `agent_framework.foundry.toolbox.names`
|
||||
- `agent_framework.foundry.toolbox.versions`
|
||||
- or a single compact attribute such as `agent_framework.foundry.toolbox.sources=["research_tools@1","some_other_tools@3"]`
|
||||
|
||||
The single compact `toolbox.sources` form is preferred for initial implementation because it is easy to query and easy to render from combined tool lists.
|
||||
|
||||
#### Scope of telemetry changes
|
||||
|
||||
This design does **not** require new spans. It enriches existing telemetry:
|
||||
|
||||
- toolbox API access continues to rely on request logs + Azure SDK distributed tracing + MAF user-agent
|
||||
- agent/chat execution spans gain toolbox provenance attributes when toolbox-derived tools are present
|
||||
|
||||
Implementation-wise, this design most likely touches:
|
||||
|
||||
- `packages/foundry/agent_framework_foundry/_tools.py` — to stamp provenance on fetched toolbox objects / tools
|
||||
- `packages/core/agent_framework/observability.py` — to aggregate provenance into span attributes
|
||||
|
||||
#### Important limitation: no server-side toolbox telemetry solution yet
|
||||
|
||||
Private provenance attached to tool objects is only useful on the client side. It
|
||||
does **not** go over the wire to the Foundry service because those private fields
|
||||
are intentionally not serialized into the request payload.
|
||||
|
||||
That means this design can support:
|
||||
|
||||
- local OpenTelemetry / exporter spans emitted by Agent Framework
|
||||
- local attribution of a run to one or more fetched toolboxes
|
||||
|
||||
but it does **not** solve:
|
||||
|
||||
- server-side request-log attribution of a model/tool run back to a toolbox
|
||||
- backend/database queries that need the service itself to know "this tool came from toolbox X"
|
||||
|
||||
At the moment, we do not have a satisfactory design for server-side toolbox
|
||||
telemetry. The service would require additional structured information on the
|
||||
request, and there is no accepted mechanism in this design yet for projecting
|
||||
toolbox provenance into a server-visible field/header/metadata shape.
|
||||
|
||||
So the telemetry story in this spec is explicitly limited to **client-side
|
||||
toolbox telemetry**. Server-side toolbox attribution remains an open question and
|
||||
requires either:
|
||||
|
||||
- new service/API support, or
|
||||
- a later framework design for emitting additional server-visible request metadata.
|
||||
|
||||
#### Deliberate non-goals for telemetry
|
||||
|
||||
- No requirement for users to pass explicit toolbox metadata in `default_options["metadata"]` or `run(..., options=...)`
|
||||
- No new public `FoundryToolbox` wrapper type just to preserve attribution
|
||||
- No attempted server-side attribution mechanism in this design (for example a custom request header or request metadata field) until there is a validated end-to-end contract for it
|
||||
|
||||
## Non-goals / Future Work
|
||||
|
||||
Explicitly out of scope for this design. Each is a separate design and PR when needed.
|
||||
|
||||
1. **Create/update/delete toolboxes from code.** CRUD is rare in agent consumption flows. Users who need it drop to `client.project_client.beta.toolboxes.create_version(...)`, `.update(...)`, `.delete(...)` directly.
|
||||
|
||||
2. **Server-side agent authoring from toolbox.** Creating a `PromptAgentDefinition(tools=toolbox.tools)` + `client.agents.create_version(...)` is a future feature covering agent authoring from code. The toolbox read API provides the building blocks; the authoring helpers are a separate design.
|
||||
|
||||
3. **OAuth consent-flow runtime handling.** When a toolbox contains MCP tools with `project_connection_id` pointing to an OAuth connection, the runtime may return `CONSENT_REQUIRED` mid-run. This is a runtime concern separate from toolbox fetching.
|
||||
|
||||
4. **Live integration tests.** This PR ships unit tests only.
|
||||
|
||||
5. **Toolbox caching or refresh APIs.** Each `get_toolbox()` call hits the network. Users who want caching wrap the call themselves.
|
||||
@@ -7,13 +7,16 @@
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<!-- Aspire -->
|
||||
<AspireAppHostSdkVersion>13.0.2</AspireAppHostSdkVersion>
|
||||
<AspireAppHostSdkVersion>13.1.0</AspireAppHostSdkVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Aspire.* -->
|
||||
<PackageVersion Include="Anthropic" Version="12.13.0" />
|
||||
<PackageVersion Include="Anthropic.Foundry" Version="0.5.0" />
|
||||
<PackageVersion Include="Aspire.Hosting" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
|
||||
<PackageVersion Include="Aspire.Azure.AI.Inference" Version="13.1.0-preview.1.25616.3" />
|
||||
<PackageVersion Include="Aspire.Hosting.Azure.AIFoundry" Version="13.1.0-preview.1.25616.3" />
|
||||
<PackageVersion Include="Aspire.Hosting.AppHost" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Hosting.Azure.CognitiveServices" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
|
||||
@@ -48,12 +51,12 @@
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
<!-- OpenTelemetry -->
|
||||
<PackageVersion Include="OpenTelemetry" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Api" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Api" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
|
||||
@@ -71,18 +74,18 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.5.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
|
||||
<!-- Vector Stores -->
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<BuildType Name="Publish" />
|
||||
<BuildType Name="Release" />
|
||||
</Configurations>
|
||||
<Folder Name="/src/Aspire.Hosting.AgentFramework.DevUI/">
|
||||
<Project Path="src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/">
|
||||
<File Path="samples/AGENTS.md" />
|
||||
<File Path="samples/README.md" />
|
||||
@@ -37,6 +40,12 @@
|
||||
<Project Path="samples/02-agents/AgentProviders/Agent_With_OpenAIChatCompletion/Agent_With_OpenAIChatCompletion.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/Agent_With_OpenAIResponses.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/DevUIAspireIntegration/">
|
||||
<Project Path="samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj" />
|
||||
<Project Path="samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj" />
|
||||
<Project Path="samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/EditorAgent.csproj" />
|
||||
<Project Path="samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/WriterAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Agents/">
|
||||
<File Path="samples/02-agents/Agents/README.md" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals/Agent_Step01_UsingFunctionToolsWithApprovals.csproj" />
|
||||
@@ -542,6 +551,7 @@
|
||||
<Project Path="tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Tests/UnitTests/">
|
||||
<Project Path="tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj" />
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
|
||||
"src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj"
|
||||
"src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj",
|
||||
"src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"appHostPath": "../DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
**/**/*.Development.json
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Sdk Name="Aspire.AppHost.Sdk" Version="$(AspireAppHostSdkVersion)" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.Azure.AIFoundry" />
|
||||
<PackageReference Include="OpenAI" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" IsAspireProjectResource="false" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DevUI\Microsoft.Agents.AI.DevUI.csproj" IsAspireProjectResource="false" />
|
||||
<ProjectReference Include="..\..\..\..\src\Aspire.Hosting.AgentFramework.DevUI\Aspire.Hosting.AgentFramework.DevUI.csproj" IsAspireProjectResource="false" />
|
||||
<ProjectReference Include="..\WriterAgent\WriterAgent.csproj" />
|
||||
<ProjectReference Include="..\EditorAgent\EditorAgent.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var foundry = builder.AddAzureAIFoundry("foundry");
|
||||
|
||||
// Comment the following lines to create a new Foundry instance instead of connecting to an existing one. If creating a new instance, the DevUI resource will wait for the Foundry to be ready before starting, ensuring the DevUI frontend is available as soon as the app starts.
|
||||
var existingFoundryName = builder.AddParameter("existingFoundryName")
|
||||
.WithDescription("The name of the existing Azure Foundry resource.");
|
||||
var existingFoundryResourceGroup = builder.AddParameter("existingFoundryResourceGroup")
|
||||
.WithDescription("The resource group of the existing Azure Foundry resource.");
|
||||
foundry.AsExisting(existingFoundryName, existingFoundryResourceGroup);
|
||||
|
||||
// Add the writer agent service
|
||||
var writerAgent = builder.AddProject<Projects.WriterAgent>("writer-agent")
|
||||
.WithHttpHealthCheck("/health")
|
||||
.WithReference(foundry).WaitFor(foundry);
|
||||
|
||||
// Add the editor agent service
|
||||
var editorAgent = builder.AddProject<Projects.EditorAgent>("editor-agent")
|
||||
.WithHttpHealthCheck("/health")
|
||||
.WithReference(foundry).WaitFor(foundry);
|
||||
|
||||
// Add DevUI integration that aggregates agents from all agent services.
|
||||
// Agent metadata is declared here so backends don't need a /v1/entities endpoint.
|
||||
_ = builder.AddDevUI("devui")
|
||||
.WithAgentService(writerAgent, agents: [new("writer")]) // the name of the agent should match the agent declaration in WriterAgent/Program.cs
|
||||
.WithAgentService(editorAgent, agents: [new("editor")]) // the name of the agent should match the agent declaration in EditorAgent/Program.cs
|
||||
.WaitFor(writerAgent)
|
||||
.WaitFor(editorAgent);
|
||||
|
||||
builder.Build().Run();
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:16500;http://localhost:16501",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17250",
|
||||
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:18100",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17250",
|
||||
"ASPIRE_SHOW_DASHBOARD_RESOURCES": "true"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:16501",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17251",
|
||||
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18101",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17251",
|
||||
"ASPIRE_SHOW_DASHBOARD_RESOURCES": "true",
|
||||
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"Azure": {
|
||||
"TenantId": "",
|
||||
"SubscriptionId": "",
|
||||
"AllowResourceGroupCreation": true,
|
||||
"ResourceGroup": "",
|
||||
"Location": "",
|
||||
"CredentialSource": "AzureCli"
|
||||
},
|
||||
"Parameters": {
|
||||
"existingFoundryName": "",
|
||||
"existingFoundryResourceGroup": ""
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Microsoft.Extensions.Hosting;
|
||||
|
||||
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
|
||||
// This project should be referenced by each service project in your solution.
|
||||
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
|
||||
#pragma warning disable CA1724 // Type name 'Extensions' conflicts with namespace - acceptable for Aspire pattern
|
||||
public static class Extensions
|
||||
#pragma warning restore CA1724
|
||||
{
|
||||
private const string HealthEndpointPath = "/health";
|
||||
private const string AlivenessEndpointPath = "/alive";
|
||||
|
||||
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.ConfigureOpenTelemetry();
|
||||
|
||||
builder.AddDefaultHealthChecks();
|
||||
|
||||
builder.Services.AddServiceDiscovery();
|
||||
|
||||
builder.Services.ConfigureHttpClientDefaults(http =>
|
||||
{
|
||||
// Turn on resilience by default
|
||||
http.AddStandardResilienceHandler();
|
||||
|
||||
// Turn on service discovery by default
|
||||
http.AddServiceDiscovery();
|
||||
});
|
||||
|
||||
// Uncomment the following to restrict the allowed schemes for service discovery.
|
||||
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
|
||||
// {
|
||||
// options.AllowedSchemes = ["https"];
|
||||
// });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
{
|
||||
logging.IncludeFormattedMessage = true;
|
||||
logging.IncludeScopes = true;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
})
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing.AddSource(builder.Environment.ApplicationName)
|
||||
.AddAspNetCoreInstrumentation(tracing =>
|
||||
// Exclude health check requests from tracing
|
||||
tracing.Filter = context =>
|
||||
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
|
||||
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
|
||||
)
|
||||
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
|
||||
//.AddGrpcClientInstrumentation()
|
||||
.AddHttpClientInstrumentation();
|
||||
});
|
||||
|
||||
builder.AddOpenTelemetryExporters();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
|
||||
|
||||
if (useOtlpExporter)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||
}
|
||||
|
||||
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
|
||||
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
|
||||
//{
|
||||
// builder.Services.AddOpenTelemetry()
|
||||
// .UseAzureMonitor();
|
||||
//}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Services.AddHealthChecks()
|
||||
// Add a default liveness check to ensure app is responsive
|
||||
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||
{
|
||||
// Adding health checks endpoints to applications in non-development environments has security implications.
|
||||
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
// All health checks must pass for app to be considered ready to accept traffic after starting
|
||||
app.MapHealthChecks(HealthEndpointPath);
|
||||
|
||||
// Only health checks tagged with the "live" tag must pass for app to be considered alive
|
||||
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>b2c3d4e5-f6a7-8901-bcde-f12345678901</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Azure.AI.Inference" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\DevUIIntegration.ServiceDefaults\DevUIIntegration.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
builder.AddAzureChatCompletionsClient(connectionName: "foundry",
|
||||
configureSettings: settings =>
|
||||
{
|
||||
settings.TokenCredential = new DefaultAzureCredential();
|
||||
settings.EnableSensitiveTelemetryData = builder.Environment.IsDevelopment();
|
||||
})
|
||||
.AddChatClient("gpt41");
|
||||
|
||||
builder.AddAIAgent("editor", (sp, key) =>
|
||||
{
|
||||
var chatClient = sp.GetRequiredService<IChatClient>();
|
||||
return new ChatClientAgent(
|
||||
chatClient,
|
||||
name: key,
|
||||
instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.",
|
||||
tools: [AIFunctionFactory.Create(FormatStory)]
|
||||
);
|
||||
});
|
||||
|
||||
// Register services for OpenAI responses and conversations
|
||||
builder.Services.AddOpenAIResponses();
|
||||
builder.Services.AddOpenAIConversations();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map OpenAI API endpoints — DevUI aggregator routes requests here
|
||||
app.MapOpenAIResponses();
|
||||
app.MapOpenAIConversations();
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
[Description("Formats the story for publication, revealing its title.")]
|
||||
static string FormatStory(string title, string story) => $"""
|
||||
**Title**: {title}
|
||||
|
||||
{story}
|
||||
""";
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5281",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# DevUI Integration Sample
|
||||
|
||||
This sample demonstrates how to use the **Aspire.Hosting.AgentFramework.DevUI** library to test and debug multiple AI agents through a unified DevUI web interface, orchestrated by an Aspire AppHost.
|
||||
|
||||
The solution contains two agent services:
|
||||
|
||||
- **WriterAgent** — a simple agent that writes short stories (≤ 300 words) about a given topic.
|
||||
- **EditorAgent** — an agent that edits stories for grammar and style, selects a title, and formats the result for publishing. It also demonstrates tool use via `AIFunctionFactory`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/10.0)
|
||||
- [Aspire CLI](https://learn.microsoft.com/dotnet/aspire/fundamentals/setup-tooling)
|
||||
- An Azure subscription with access to [Azure AI Foundry](https://learn.microsoft.com/azure/ai-studio/)
|
||||
- Azure CLI authenticated (`az login`)
|
||||
|
||||
## Azure AI Foundry configuration
|
||||
|
||||
The sample requires an Azure AI Foundry resource with a deployed `gpt-4.1` model. You have two options:
|
||||
|
||||
### Option 1: Connect to an existing Foundry resource
|
||||
|
||||
Fill in the parameters in `DevUIIntegration.AppHost/appsettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Azure": {
|
||||
"TenantId": "<your-tenant-id>",
|
||||
"SubscriptionId": "<your-subscription-id>",
|
||||
"AllowResourceGroupCreation": true,
|
||||
"ResourceGroup": "<your-resource-group>",
|
||||
"Location": "<your-azure-region>",
|
||||
"CredentialSource": "AzureCli"
|
||||
},
|
||||
"Parameters": {
|
||||
"existingFoundryName": "<your-foundry-resource-name>",
|
||||
"existingFoundryResourceGroup": "<resource-group-containing-your-foundry>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The AppHost calls `foundry.AsExisting(...)` with these parameters, so Aspire connects to the existing resource instead of provisioning a new one.
|
||||
|
||||
### Option 2: Let Aspire provision a new Foundry resource
|
||||
|
||||
Remove or comment out the `AsExisting` block in `DevUIIntegration.AppHost/Program.cs`:
|
||||
|
||||
```csharp
|
||||
// Comment the following lines to create a new Foundry instance
|
||||
// _ = builder.AddParameterFromConfiguration("tenant", "Azure:TenantId");
|
||||
// var existingFoundryName = builder.AddParameter("existingFoundryName") ...
|
||||
// foundry.AsExisting(existingFoundryName, existingFoundryResourceGroup);
|
||||
```
|
||||
|
||||
Aspire will provision a new Azure AI Foundry resource on startup. The DevUI resource uses `.WaitFor(foundry)` transitively through the agent services, so the frontend won't become available until provisioning completes. This can take several minutes on first run.
|
||||
|
||||
You still need to fill in the `Azure` section of `appsettings.json` (subscription, location, etc.) so Aspire knows where to create the resource.
|
||||
|
||||
## Agent name matching with `WithAgentService`
|
||||
|
||||
When connecting agent services to DevUI in the AppHost, you must pass the correct agent name via the `agents:` parameter. **This name must match the name used in `AddAIAgent(...)` inside each agent service's `Program.cs` — not the Aspire resource name.**
|
||||
|
||||
For example, the WriterAgent Aspire resource is named `"writer-agent"`, but the agent is registered as `"writer"`:
|
||||
|
||||
```csharp
|
||||
// WriterAgent/Program.cs
|
||||
builder.AddAIAgent("writer", "You write short stories ...");
|
||||
// ^^^^^^^^ this is the agent name
|
||||
```
|
||||
|
||||
```csharp
|
||||
// EditorAgent/Program.cs
|
||||
builder.AddAIAgent("editor", (sp, key) => { ... });
|
||||
// ^^^^^^^^ this is the agent name
|
||||
```
|
||||
|
||||
The AppHost must use these exact names:
|
||||
|
||||
```csharp
|
||||
// DevUIIntegration.AppHost/Program.cs
|
||||
builder.AddDevUI("devui")
|
||||
.WithAgentService(writerAgent, agents: [new("writer")]) // âś… matches AddAIAgent("writer", ...)
|
||||
.WithAgentService(editorAgent, agents: [new("editor")]) // âś… matches AddAIAgent("editor", ...)
|
||||
.WaitFor(writerAgent)
|
||||
.WaitFor(editorAgent);
|
||||
```
|
||||
|
||||
Using the wrong name (e.g., `new("writer-agent")` instead of `new("writer")`) will cause the aggregator to send an entity ID the backend doesn't recognize, resulting in 404 errors when interacting with the agent.
|
||||
|
||||
If you omit the `agents:` parameter entirely, the aggregator defaults to a single agent named after the Aspire resource (e.g., `"writer-agent"`). Since agent services don't expose a `/v1/entities` discovery endpoint, **the Aspire resource name must exactly match the agent name registered via `AddAIAgent(...)` in the service's `Program.cs`**.
|
||||
|
||||
## Running the sample
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/05-end-to-end/DevUIAspireIntegration
|
||||
aspire run
|
||||
```
|
||||
|
||||
Once all services are running, open the **DevUI** URL shown in the Aspire dashboard. You should see both the writer and editor agents listed — select one and start a conversation.
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
builder.AddAzureChatCompletionsClient(connectionName: "foundry",
|
||||
configureSettings: settings =>
|
||||
{
|
||||
settings.TokenCredential = new DefaultAzureCredential();
|
||||
settings.EnableSensitiveTelemetryData = builder.Environment.IsDevelopment();
|
||||
})
|
||||
.AddChatClient("gpt41");
|
||||
|
||||
builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic.");
|
||||
|
||||
// Register services for OpenAI responses and conversations
|
||||
builder.Services.AddOpenAIResponses();
|
||||
builder.Services.AddOpenAIConversations();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map OpenAI API endpoints — DevUI aggregator routes requests here
|
||||
app.MapOpenAIResponses();
|
||||
app.MapOpenAIConversations();
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
app.Run();
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5280",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>a1b2c3d4-e5f6-7890-abcd-ef1234567890</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Azure.AI.Inference" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\DevUIIntegration.ServiceDefaults\DevUIIntegration.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"appHost": {
|
||||
"path": "DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Aspire.Hosting.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Describes an AI agent exposed by an agent service backend, used for entity discovery in DevUI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When added via <see cref="AgentFrameworkBuilderExtensions.WithAgentService{TSource}"/>,
|
||||
/// agent metadata is declared at the AppHost level so that the DevUI aggregator can build the
|
||||
/// entity listing without querying each backend's <c>/v1/entities</c> endpoint.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Agent services only need to expose the standard OpenAI Responses and Conversations API endpoints
|
||||
/// (<c>MapOpenAIResponses</c> and <c>MapOpenAIConversations</c>), not a custom discovery endpoint.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Id">The unique identifier for the agent, typically matching the name passed to <c>AddAIAgent</c>.</param>
|
||||
/// <param name="Description">A short description of the agent's capabilities.</param>
|
||||
public record AgentEntityInfo(string Id, string? Description = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the display name for the agent. Defaults to <see cref="Id"/> if not specified.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = Id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entity type. Defaults to <c>"agent"</c>.
|
||||
/// </summary>
|
||||
public string Type { get; init; } = "agent";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the framework identifier. Defaults to <c>"agent_framework"</c>.
|
||||
/// </summary>
|
||||
public string Framework { get; init; } = "agent_framework";
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Aspire.Hosting.AgentFramework;
|
||||
using Aspire.Hosting.ApplicationModel;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Aspire.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for adding Agent Framework DevUI resources to the application model.
|
||||
/// </summary>
|
||||
public static class AgentFrameworkBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a DevUI resource for testing AI agents in a distributed application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// DevUI is a web-based interface for testing and debugging AI agents using the OpenAI Responses protocol.
|
||||
/// When configured with <see cref="WithAgentService{TSource}"/>, it aggregates agents from multiple backend services
|
||||
/// and provides a unified testing interface.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The aggregator runs as an in-process reverse proxy within the AppHost, requiring no external container image.
|
||||
/// It serves the DevUI frontend from embedded resources in Microsoft.Agents.AI.DevUI when available, and
|
||||
/// falls back to proxying from the first configured backend. It aggregates entity listings from all backends.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This resource is excluded from the deployment manifest as it is intended for development use only.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
|
||||
/// <param name="name">The name to give the resource.</param>
|
||||
/// <param name="port">The host port for the DevUI web interface. If not specified, a random port will be assigned.</param>
|
||||
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var devui = builder.AddDevUI("devui")
|
||||
/// .WithAgentService(dotnetAgent)
|
||||
/// .WithAgentService(pythonAgent);
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static IResourceBuilder<DevUIResource> AddDevUI(
|
||||
this IDistributedApplicationBuilder builder,
|
||||
string name,
|
||||
int? port = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
var resource = new DevUIResource(name, port);
|
||||
|
||||
var resourceBuilder = builder.AddResource(resource)
|
||||
.ExcludeFromManifest(); // DevUI is a dev-only tool
|
||||
|
||||
// Initialize the in-process aggregator when the resource is initialized by the orchestrator
|
||||
builder.Eventing.Subscribe<InitializeResourceEvent>(resource, async (e, ct) =>
|
||||
{
|
||||
var logger = e.Logger;
|
||||
var aggregator = new DevUIAggregatorHostedService(resource, e.Services.GetRequiredService<ILoggerFactory>().CreateLogger<DevUIAggregatorHostedService>());
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for dependencies (e.g. agent service backends) before starting.
|
||||
// Custom resources must manually publish BeforeResourceStartedEvent to trigger
|
||||
// the orchestrator's WaitFor mechanism.
|
||||
await e.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, e.Services), ct).ConfigureAwait(false);
|
||||
|
||||
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
|
||||
{
|
||||
State = KnownResourceStates.Starting
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
await aggregator.StartAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Allocate the endpoint so the URL appears in the Aspire dashboard
|
||||
var endpointAnnotation = resource.Annotations
|
||||
.OfType<EndpointAnnotation>()
|
||||
.First(ea => ea.Name == DevUIResource.PrimaryEndpointName);
|
||||
|
||||
endpointAnnotation.AllocatedEndpoint = new AllocatedEndpoint(
|
||||
endpointAnnotation, "localhost", aggregator.AllocatedPort);
|
||||
|
||||
var devuiUrl = $"http://localhost:{aggregator.AllocatedPort}/devui/";
|
||||
|
||||
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
|
||||
{
|
||||
State = KnownResourceStates.Running,
|
||||
Urls = [new UrlSnapshot("DevUI", devuiUrl, IsInternal: false)]
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
// Shut down the aggregator when the app stops
|
||||
var lifetime = e.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||
lifetime.ApplicationStopping.Register(() =>
|
||||
{
|
||||
e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
|
||||
{
|
||||
State = KnownResourceStates.Finished
|
||||
}).GetAwaiter().GetResult();
|
||||
|
||||
aggregator.StopAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
aggregator.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to start DevUI aggregator");
|
||||
|
||||
await aggregator.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
|
||||
{
|
||||
State = KnownResourceStates.FailedToStart
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
|
||||
return resourceBuilder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures DevUI to connect to an agent service backend.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each agent service should expose the OpenAI Responses and Conversations API endpoints
|
||||
/// (via <c>MapOpenAIResponses</c> and <c>MapOpenAIConversations</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <paramref name="agents"/> is provided, the aggregator builds the entity listing from
|
||||
/// these declarations without querying the backend. When not provided, a single agent named
|
||||
/// after the service resource is assumed. Agent services don't need a <c>/v1/entities</c> endpoint.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <typeparam name="TSource">The type of the agent service resource.</typeparam>
|
||||
/// <param name="builder">The DevUI resource builder.</param>
|
||||
/// <param name="agentService">The agent service resource to connect to.</param>
|
||||
/// <param name="agents">
|
||||
/// Optional list of agents declared by this backend. When provided, the aggregator uses these
|
||||
/// declarations directly. When not provided, defaults to a single agent named after the
|
||||
/// <paramref name="agentService"/> resource. The backend doesn't need to expose a
|
||||
/// <c>/v1/entities</c> endpoint in either case.
|
||||
/// </param>
|
||||
/// <param name="entityIdPrefix">
|
||||
/// An optional prefix to add to entity IDs from this backend.
|
||||
/// If not specified, the resource name will be used as the prefix.
|
||||
/// </param>
|
||||
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var writerAgent = builder.AddProject<Projects.WriterAgent>("writer-agent");
|
||||
/// var editorAgent = builder.AddProject<Projects.EditorAgent>("editor-agent");
|
||||
///
|
||||
/// builder.AddDevUI("devui")
|
||||
/// .WithAgentService(writerAgent, agents: [new("writer", "Writes short stories")])
|
||||
/// .WithAgentService(editorAgent, agents: [new("editor", "Edits and formats stories")])
|
||||
/// .WaitFor(writerAgent)
|
||||
/// .WaitFor(editorAgent);
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static IResourceBuilder<DevUIResource> WithAgentService<TSource>(
|
||||
this IResourceBuilder<DevUIResource> builder,
|
||||
IResourceBuilder<TSource> agentService,
|
||||
IReadOnlyList<AgentEntityInfo>? agents = null,
|
||||
string? entityIdPrefix = null)
|
||||
where TSource : IResourceWithEndpoints
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(agentService);
|
||||
|
||||
// Default to a single agent named after the service resource
|
||||
agents ??= [new AgentEntityInfo(agentService.Resource.Name)];
|
||||
|
||||
builder.WithAnnotation(new AgentServiceAnnotation(agentService.Resource, entityIdPrefix, agents));
|
||||
builder.WithRelationship(agentService.Resource, "agent-backend");
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Aspire.Hosting.AgentFramework;
|
||||
|
||||
namespace Aspire.Hosting.ApplicationModel;
|
||||
|
||||
/// <summary>
|
||||
/// An annotation that tracks an agent service backend referenced by a DevUI resource.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This annotation is used to configure DevUI to aggregate entities from multiple
|
||||
/// agent service backends. Each annotation represents one backend that DevUI should
|
||||
/// connect to for entity discovery and request routing.
|
||||
/// </remarks>
|
||||
public class AgentServiceAnnotation : IResourceAnnotation
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentServiceAnnotation"/> class.
|
||||
/// </summary>
|
||||
/// <param name="agentService">The agent service resource.</param>
|
||||
/// <param name="entityIdPrefix">
|
||||
/// An optional prefix to add to entity IDs from this backend to avoid conflicts.
|
||||
/// If not specified, the resource name will be used as the prefix.
|
||||
/// </param>
|
||||
/// <param name="agents">
|
||||
/// Optional list of agents declared by this backend. When provided, the aggregator builds the entity
|
||||
/// listing directly from these declarations instead of querying the backend's <c>/v1/entities</c> endpoint.
|
||||
/// </param>
|
||||
public AgentServiceAnnotation(IResource agentService, string? entityIdPrefix = null, IReadOnlyList<AgentEntityInfo>? agents = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentService);
|
||||
|
||||
this.AgentService = agentService;
|
||||
this.EntityIdPrefix = entityIdPrefix;
|
||||
this.Agents = agents ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent service resource that exposes AI agents.
|
||||
/// </summary>
|
||||
public IResource AgentService { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the prefix to use for entity IDs from this backend.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <c>null</c>, the resource name will be used as the prefix.
|
||||
/// Entity IDs will be formatted as "{prefix}/{entityId}" to ensure uniqueness
|
||||
/// across multiple agent backends.
|
||||
/// </remarks>
|
||||
public string? EntityIdPrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of agents declared by this backend.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When non-empty, the DevUI aggregator uses these declarations to build the entity listing
|
||||
/// without querying the backend. When empty, the aggregator falls back to calling
|
||||
/// <c>GET /v1/entities</c> on the backend for discovery.
|
||||
/// </remarks>
|
||||
public IReadOnlyList<AgentEntityInfo> Agents { get; }
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageTags>aspire integration hosting agent-framework devui ai agents</PackageTags>
|
||||
<Description>Microsoft Agent Framework DevUI support for Aspire.</Description>
|
||||
<!-- Suppress analyzer warnings for Aspire integration code -->
|
||||
<!-- IL2026/IL3050: Suppress trimming/AOT warnings - DevUI is a dev-only tool not intended for AOT -->
|
||||
<NoWarn>$(NoWarn);CA1873;RCS1061;VSTHRD002;IL2026;IL3050</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Aspire.Hosting.AgentFramework.DevUI.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,779 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Aspire.Hosting.ApplicationModel;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Aspire.Hosting.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Hosts an in-process reverse proxy that aggregates DevUI entities from multiple agent backends.
|
||||
/// Serves the DevUI frontend directly from the <c>Microsoft.Agents.AI.DevUI</c> assembly's embedded
|
||||
/// resources and intercepts API calls to provide multi-backend entity aggregation and request routing.
|
||||
/// </summary>
|
||||
internal sealed class DevUIAggregatorHostedService : IAsyncDisposable
|
||||
{
|
||||
private static readonly FileExtensionContentTypeProvider s_contentTypeProvider = new();
|
||||
|
||||
private WebApplication? _app;
|
||||
private readonly DevUIResource _resource;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
// Frontend resources loaded from the Microsoft.Agents.AI.DevUI assembly (null if unavailable)
|
||||
private readonly Dictionary<string, (string ResourceName, string ContentType)>? _frontendResources;
|
||||
|
||||
// Maps conversation IDs to backend URLs for routing GET requests that lack agent_id context.
|
||||
// Populated when the aggregator routes conversation requests to a positively-resolved backend.
|
||||
private readonly ConcurrentDictionary<string, string> _conversationBackendMap = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public DevUIAggregatorHostedService(
|
||||
DevUIResource resource,
|
||||
ILogger logger)
|
||||
{
|
||||
this._resource = resource;
|
||||
this._logger = logger;
|
||||
this._frontendResources = LoadFrontendResources(logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the port the aggregator is listening on, available after <see cref="StartAsync"/>.
|
||||
/// </summary>
|
||||
internal int AllocatedPort { get; private set; }
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.Logging.ClearProviders();
|
||||
|
||||
builder.Services.AddHttpClient("devui-proxy")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
this._app = builder.Build();
|
||||
|
||||
// Bind to a fixed port if one was specified on the DevUI resource; otherwise use 0 for dynamic allocation.
|
||||
var port = this._resource.Port ?? 0;
|
||||
this._app.Urls.Add($"http://127.0.0.1:{port}");
|
||||
this.MapRoutes(this._app);
|
||||
|
||||
await this._app.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serverAddresses = this._app.Services.GetRequiredService<IServer>()
|
||||
.Features.Get<IServerAddressesFeature>();
|
||||
|
||||
if (serverAddresses is not null)
|
||||
{
|
||||
var address = serverAddresses.Addresses.First();
|
||||
var uri = new Uri(address);
|
||||
this.AllocatedPort = uri.Port;
|
||||
this._logger.LogInformation("DevUI aggregator started on port {Port}", this.AllocatedPort);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (this._app is not null)
|
||||
{
|
||||
await this._app.StopAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (this._app is not null)
|
||||
{
|
||||
await this._app.DisposeAsync().ConfigureAwait(false);
|
||||
this._app = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the DevUI frontend resources from the <c>Microsoft.Agents.AI.DevUI</c> assembly.
|
||||
/// The assembly embeds the Vite SPA build output as manifest resources.
|
||||
/// Returns null if the assembly is not available.
|
||||
/// </summary>
|
||||
private static Dictionary<string, (string ResourceName, string ContentType)>? LoadFrontendResources(ILogger logger)
|
||||
{
|
||||
Assembly assembly;
|
||||
try
|
||||
{
|
||||
assembly = Assembly.Load("Microsoft.Agents.AI.DevUI");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Microsoft.Agents.AI.DevUI assembly not found. Frontend will be proxied from backends.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var prefix = $"{assembly.GetName().Name}.resources.";
|
||||
var resources = new Dictionary<string, (string, string)>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var name in assembly.GetManifestResourceNames())
|
||||
{
|
||||
if (!name.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// The DevUI middleware maps resource names by replacing dots with slashes.
|
||||
// Both the key and lookup use the same transform, so they match.
|
||||
var key = name[prefix.Length..].Replace('.', '/');
|
||||
s_contentTypeProvider.TryGetContentType(name, out var contentType);
|
||||
resources[key] = (name, contentType ?? "application/octet-stream");
|
||||
}
|
||||
|
||||
if (resources.Count == 0)
|
||||
{
|
||||
logger.LogWarning("Microsoft.Agents.AI.DevUI assembly loaded but contains no frontend resources");
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.LogDebug("Loaded {Count} DevUI frontend resources from assembly", resources.Count);
|
||||
return resources;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the DevUI frontend. Uses embedded assembly resources if available,
|
||||
/// otherwise falls back to proxying from the first backend agent service.
|
||||
/// </summary>
|
||||
private async Task ServeDevUIFrontendAsync(HttpContext context, string? path)
|
||||
{
|
||||
// Redirect /devui to /devui/ so relative URLs in the SPA resolve correctly
|
||||
if (string.IsNullOrEmpty(path) && context.Request.Path.Value is { } reqPath && !reqPath.EndsWith('/'))
|
||||
{
|
||||
var redirect = reqPath + "/";
|
||||
if (context.Request.QueryString.HasValue)
|
||||
{
|
||||
redirect += context.Request.QueryString.Value;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
|
||||
context.Response.Headers.Location = redirect;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try embedded resources first
|
||||
if (this._frontendResources is not null)
|
||||
{
|
||||
var resourcePath = string.IsNullOrEmpty(path) ? "index.html" : path;
|
||||
|
||||
if (await this.TryServeResourceAsync(context, resourcePath).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html for paths without a file extension (client-side routing)
|
||||
if (!resourcePath.Contains('.', StringComparison.Ordinal) &&
|
||||
await this.TryServeResourceAsync(context, "index.html").ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: proxy from the first backend that serves /devui
|
||||
var backends = this.ResolveBackends();
|
||||
var firstBackendUrl = backends.Values.FirstOrDefault();
|
||||
|
||||
if (firstBackendUrl is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
context.Response.ContentType = "text/plain";
|
||||
await context.Response.WriteAsync(
|
||||
"DevUI: No agent service backends are available yet.", context.RequestAborted).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var targetPath = string.IsNullOrEmpty(path) ? "/devui/" : $"/devui/{path}";
|
||||
await ProxyRequestAsync(
|
||||
context, firstBackendUrl, targetPath + context.Request.QueryString, bodyBytes: null).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<bool> TryServeResourceAsync(HttpContext context, string resourcePath)
|
||||
{
|
||||
if (this._frontendResources is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = resourcePath.Replace('.', '/');
|
||||
|
||||
if (!this._frontendResources.TryGetValue(key, out var entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Assembly assembly;
|
||||
try
|
||||
{
|
||||
assembly = Assembly.Load("Microsoft.Agents.AI.DevUI");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var stream = assembly.GetManifestResourceStream(entry.ResourceName);
|
||||
|
||||
if (stream is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
context.Response.ContentType = entry.ContentType;
|
||||
context.Response.Headers.CacheControl = "no-cache, no-store";
|
||||
await stream.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IResult GetMeta()
|
||||
{
|
||||
return Results.Json(new
|
||||
{
|
||||
ui_mode = "developer",
|
||||
version = "0.1.0",
|
||||
framework = "agent_framework",
|
||||
runtime = "dotnet",
|
||||
capabilities = new Dictionary<string, bool>
|
||||
{
|
||||
["tracing"] = false,
|
||||
["openai_proxy"] = false,
|
||||
["deployment"] = false
|
||||
},
|
||||
auth_required = false
|
||||
});
|
||||
}
|
||||
|
||||
private void MapRoutes(WebApplication app)
|
||||
{
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
|
||||
|
||||
// Intercept API calls for multi-backend aggregation and routing
|
||||
app.MapGet("/v1/entities", (Delegate)this.AggregateEntitiesAsync);
|
||||
app.MapGet("/v1/entities/{**entityPath}", this.RouteEntityInfoAsync);
|
||||
app.MapPost("/v1/responses", this.RouteResponsesAsync);
|
||||
app.Map("/v1/conversations/{**path}", this.ProxyConversationsAsync);
|
||||
app.MapGet("/meta", GetMeta);
|
||||
|
||||
// Serve the DevUI frontend from embedded assembly resources
|
||||
app.Map("/devui/{**path}", this.ServeDevUIFrontendAsync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves backend URLs from the resource's <see cref="AgentServiceAnnotation"/> annotations.
|
||||
/// This method does not cache results to ensure late-allocated backends are always discovered.
|
||||
/// </summary>
|
||||
private Dictionary<string, string> ResolveBackends()
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var annotation in this._resource.Annotations.OfType<AgentServiceAnnotation>())
|
||||
{
|
||||
if (annotation.AgentService is not IResourceWithEndpoints rwe)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var prefix = annotation.EntityIdPrefix ?? annotation.AgentService.Name;
|
||||
|
||||
try
|
||||
{
|
||||
var endpoint = rwe.GetEndpoint("http");
|
||||
if (endpoint.IsAllocated)
|
||||
{
|
||||
result[prefix] = endpoint.Url;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogDebug(ex, "Backend '{Prefix}' endpoint not yet available", prefix);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IResult> AggregateEntitiesAsync(HttpContext context)
|
||||
{
|
||||
var backends = this.ResolveBackends();
|
||||
var allEntities = new JsonArray();
|
||||
|
||||
foreach (var annotation in this._resource.Annotations.OfType<AgentServiceAnnotation>())
|
||||
{
|
||||
var prefix = annotation.EntityIdPrefix ?? annotation.AgentService.Name;
|
||||
|
||||
if (annotation.Agents.Count > 0)
|
||||
{
|
||||
// Build entities from AppHost-declared metadata — no backend call needed
|
||||
foreach (var agent in annotation.Agents)
|
||||
{
|
||||
allEntities.Add(new JsonObject
|
||||
{
|
||||
["id"] = $"{prefix}/{agent.Id}",
|
||||
["type"] = agent.Type,
|
||||
["name"] = agent.Name,
|
||||
["description"] = agent.Description,
|
||||
["framework"] = agent.Framework,
|
||||
["_original_id"] = agent.Id,
|
||||
["_backend"] = prefix
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback: query backend /v1/entities for discovery
|
||||
if (!backends.TryGetValue(prefix, out var baseUrl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var httpClientFactory = context.RequestServices.GetRequiredService<IHttpClientFactory>();
|
||||
using var client = httpClientFactory.CreateClient("devui-proxy");
|
||||
var response = await client.GetAsync(
|
||||
new Uri(new Uri(baseUrl), "/v1/entities"),
|
||||
context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
this._logger.LogWarning(
|
||||
"Failed to fetch entities from backend '{Prefix}' at {Url}: {Status}",
|
||||
prefix, baseUrl, response.StatusCode);
|
||||
continue;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(context.RequestAborted).ConfigureAwait(false);
|
||||
var doc = JsonNode.Parse(json);
|
||||
var entities = doc?["entities"]?.AsArray();
|
||||
|
||||
if (entities is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
if (entity is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cloned = entity.DeepClone();
|
||||
var id = cloned["id"]?.GetValue<string>() ?? cloned["name"]?.GetValue<string>();
|
||||
|
||||
if (id is not null)
|
||||
{
|
||||
cloned["id"] = $"{prefix}/{id}";
|
||||
cloned["_original_id"] = id;
|
||||
cloned["_backend"] = prefix;
|
||||
}
|
||||
|
||||
allEntities.Add(cloned);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
this._logger.LogWarning(ex, "Error fetching entities from backend '{Prefix}' at {Url}", prefix, baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Json(new { entities = allEntities });
|
||||
}
|
||||
|
||||
private async Task RouteEntityInfoAsync(HttpContext context, string entityPath)
|
||||
{
|
||||
var (backendUrl, actualPath) = this.ResolveBackend(entityPath);
|
||||
|
||||
if (backendUrl is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
var httpClientFactory = context.RequestServices.GetRequiredService<IHttpClientFactory>();
|
||||
using var client = httpClientFactory.CreateClient("devui-proxy");
|
||||
var targetUrl = new Uri(new Uri(backendUrl), $"/v1/entities/{actualPath}");
|
||||
|
||||
using var response = await client.GetAsync(targetUrl, context.RequestAborted).ConfigureAwait(false);
|
||||
await CopyResponseAsync(response, context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RouteResponsesAsync(HttpContext context)
|
||||
{
|
||||
var bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false);
|
||||
var json = JsonNode.Parse(bodyBytes);
|
||||
var entityId = json?["metadata"]?["entity_id"]?.GetValue<string>();
|
||||
|
||||
if (entityId is null)
|
||||
{
|
||||
var firstBackend = this.ResolveBackends().Values.FirstOrDefault();
|
||||
if (firstBackend is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
return;
|
||||
}
|
||||
|
||||
await ProxyRequestAsync(context, firstBackend, "/v1/responses", bodyBytes).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var (backendUrl, actualEntityId) = this.ResolveBackend(entityId);
|
||||
|
||||
if (backendUrl is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
await context.Response.WriteAsJsonAsync(
|
||||
new { error = $"No backend found for entity '{entityId}'" },
|
||||
context.RequestAborted).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Rewrite entity_id to the un-prefixed original value
|
||||
json!["metadata"]!["entity_id"] = actualEntityId;
|
||||
var rewrittenBody = JsonSerializer.SerializeToUtf8Bytes(json);
|
||||
|
||||
await ProxyRequestAsync(context, backendUrl, "/v1/responses", rewrittenBody, streaming: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ProxyConversationsAsync(HttpContext context, string? path)
|
||||
{
|
||||
// Try to determine the backend from agent_id query param or request body
|
||||
string? backendUrl = null;
|
||||
string? actualAgentId = null;
|
||||
|
||||
var agentId = context.Request.Query["agent_id"].FirstOrDefault();
|
||||
if (agentId is not null)
|
||||
{
|
||||
(backendUrl, actualAgentId) = this.ResolveBackend(agentId);
|
||||
}
|
||||
|
||||
// Build query string with rewritten agent_id if we resolved from query param
|
||||
var queryString = (agentId is not null && actualAgentId is not null)
|
||||
? RewriteAgentIdInQueryString(context.Request.QueryString, actualAgentId)
|
||||
: context.Request.QueryString.ToString();
|
||||
|
||||
// Try conversation→backend map for previously-seen conversations
|
||||
if (backendUrl is null)
|
||||
{
|
||||
var conversationId = ExtractConversationId(path);
|
||||
if (conversationId is not null && this._conversationBackendMap.TryGetValue(conversationId, out var mappedUrl))
|
||||
{
|
||||
backendUrl = mappedUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Always read the request body when present so it isn't dropped during proxying
|
||||
byte[]? bodyBytes = null;
|
||||
if (context.Request.ContentLength > 0)
|
||||
{
|
||||
bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Try to resolve backend from request body metadata when not yet determined
|
||||
if (backendUrl is null && bodyBytes is not null)
|
||||
{
|
||||
var json = JsonNode.Parse(bodyBytes);
|
||||
var entityId = json?["metadata"]?["entity_id"]?.GetValue<string>()
|
||||
?? json?["metadata"]?["agent_id"]?.GetValue<string>();
|
||||
|
||||
if (entityId is not null)
|
||||
{
|
||||
string actualId;
|
||||
(backendUrl, actualId) = this.ResolveBackend(entityId);
|
||||
|
||||
if (backendUrl is not null)
|
||||
{
|
||||
// Rewrite the entity/agent id to the un-prefixed value
|
||||
if (json?["metadata"]?["entity_id"] is not null)
|
||||
{
|
||||
json!["metadata"]!["entity_id"] = actualId;
|
||||
}
|
||||
|
||||
if (json?["metadata"]?["agent_id"] is not null)
|
||||
{
|
||||
json!["metadata"]!["agent_id"] = actualId;
|
||||
}
|
||||
|
||||
bodyBytes = JsonSerializer.SerializeToUtf8Bytes(json);
|
||||
var targetPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}";
|
||||
|
||||
// Also rewrite query string agent_id if present
|
||||
var bodyQueryString = (agentId is not null)
|
||||
? RewriteAgentIdInQueryString(context.Request.QueryString, actualId)
|
||||
: context.Request.QueryString.ToString();
|
||||
|
||||
await this.ProxyAndRecordConversationAsync(
|
||||
context, backendUrl, path, targetPath + bodyQueryString, bodyBytes).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Couldn't determine backend from body; proxy raw bytes to first backend
|
||||
backendUrl = this.ResolveBackends().Values.FirstOrDefault();
|
||||
if (backendUrl is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
return;
|
||||
}
|
||||
|
||||
var targetPathFallback = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}";
|
||||
await ProxyRequestAsync(
|
||||
context, backendUrl, targetPathFallback + queryString, bodyBytes).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route to resolved backend (from query or conversation map), or fall back to first backend
|
||||
var backendKnown = backendUrl is not null;
|
||||
backendUrl ??= this.ResolveBackends().Values.FirstOrDefault();
|
||||
if (backendUrl is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
return;
|
||||
}
|
||||
|
||||
var convPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}";
|
||||
if (backendKnown)
|
||||
{
|
||||
await this.ProxyAndRecordConversationAsync(
|
||||
context, backendUrl, path, convPath + queryString, bodyBytes).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ProxyRequestAsync(
|
||||
context, backendUrl, convPath + queryString, bodyBytes).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites the agent_id query parameter to the un-prefixed value for backend routing.
|
||||
/// </summary>
|
||||
internal static string RewriteAgentIdInQueryString(QueryString queryString, string actualAgentId)
|
||||
{
|
||||
if (!queryString.HasValue)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString.Value);
|
||||
query["agent_id"] = actualAgentId;
|
||||
|
||||
return QueryString.Create(query).ToString();
|
||||
}
|
||||
|
||||
private static string? ExtractConversationId(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var slashIndex = path.IndexOf('/');
|
||||
return slashIndex > 0 ? path[..slashIndex] : path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the conversation→backend mapping and proxies the request.
|
||||
/// For creation POSTs (no conversation ID in path), intercepts the response to capture the new ID.
|
||||
/// </summary>
|
||||
private async Task ProxyAndRecordConversationAsync(
|
||||
HttpContext context,
|
||||
string backendUrl,
|
||||
string? conversationPath,
|
||||
string targetUrl,
|
||||
byte[]? bodyBytes)
|
||||
{
|
||||
var conversationId = ExtractConversationId(conversationPath);
|
||||
if (conversationId is not null)
|
||||
{
|
||||
// We already know the conversation ID — record and proxy normally
|
||||
this._conversationBackendMap[conversationId] = backendUrl;
|
||||
await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Creation POST: intercept response to capture the new conversation ID
|
||||
if (!context.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var originalBody = context.Response.Body;
|
||||
using var buffer = new MemoryStream();
|
||||
context.Response.Body = buffer;
|
||||
|
||||
try
|
||||
{
|
||||
await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false);
|
||||
|
||||
if (context.Response.StatusCode is >= 200 and < 300)
|
||||
{
|
||||
buffer.Position = 0;
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(
|
||||
buffer, cancellationToken: context.RequestAborted).ConfigureAwait(false);
|
||||
if (doc.RootElement.TryGetProperty("id", out var idProp) &&
|
||||
idProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var createdId = idProp.GetString();
|
||||
if (createdId is not null)
|
||||
{
|
||||
this._conversationBackendMap[createdId] = backendUrl;
|
||||
this._logger.LogDebug(
|
||||
"Recorded conversation '{ConversationId}' → backend '{BackendUrl}'",
|
||||
createdId, backendUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: response may not be parseable JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
context.Response.Body = originalBody;
|
||||
buffer.Position = 0;
|
||||
await buffer.CopyToAsync(originalBody, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ProxyRequestAsync(
|
||||
HttpContext context,
|
||||
string backendUrl,
|
||||
string path,
|
||||
byte[]? bodyBytes,
|
||||
bool streaming = false)
|
||||
{
|
||||
var httpClientFactory = context.RequestServices.GetRequiredService<IHttpClientFactory>();
|
||||
using var client = httpClientFactory.CreateClient("devui-proxy");
|
||||
|
||||
var targetUri = new Uri(new Uri(backendUrl), path);
|
||||
using var request = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetUri);
|
||||
|
||||
foreach (var header in context.Request.Headers)
|
||||
{
|
||||
if (IsHopByHopHeader(header.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
request.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
|
||||
}
|
||||
|
||||
if (bodyBytes is not null)
|
||||
{
|
||||
request.Content = new ByteArrayContent(bodyBytes);
|
||||
if (context.Request.ContentType is not null)
|
||||
{
|
||||
request.Content.Headers.ContentType =
|
||||
System.Net.Http.Headers.MediaTypeHeaderValue.Parse(context.Request.ContentType);
|
||||
}
|
||||
}
|
||||
|
||||
var completionOption = streaming
|
||||
? HttpCompletionOption.ResponseHeadersRead
|
||||
: HttpCompletionOption.ResponseContentRead;
|
||||
|
||||
using var response = await client.SendAsync(
|
||||
request, completionOption, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
if (streaming && response.Content.Headers.ContentType?.MediaType == "text/event-stream")
|
||||
{
|
||||
context.Response.StatusCode = (int)response.StatusCode;
|
||||
context.Response.ContentType = "text/event-stream";
|
||||
context.Response.Headers.CacheControl = "no-cache";
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(context.RequestAborted).ConfigureAwait(false);
|
||||
await stream.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await CopyResponseAsync(response, context).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private (string? BackendUrl, string ActualPath) ResolveBackend(string prefixedId)
|
||||
{
|
||||
var backends = this.ResolveBackends();
|
||||
var slashIndex = prefixedId.IndexOf('/');
|
||||
|
||||
if (slashIndex > 0)
|
||||
{
|
||||
var prefix = prefixedId[..slashIndex];
|
||||
var rest = prefixedId[(slashIndex + 1)..];
|
||||
|
||||
if (backends.TryGetValue(prefix, out var url))
|
||||
{
|
||||
return (url, rest);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check all prefixes
|
||||
foreach (var (prefix, url) in backends)
|
||||
{
|
||||
if (prefixedId.StartsWith(prefix + "/", StringComparison.Ordinal))
|
||||
{
|
||||
return (url, prefixedId[(prefix.Length + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
return (null, prefixedId);
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadRequestBodyAsync(HttpRequest request)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await request.Body.CopyToAsync(ms).ConfigureAwait(false);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static async Task CopyResponseAsync(HttpResponseMessage response, HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = (int)response.StatusCode;
|
||||
|
||||
foreach (var header in response.Headers.Where(h => !IsHopByHopHeader(h.Key)))
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
|
||||
foreach (var header in response.Content.Headers)
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
|
||||
await response.Content.CopyToAsync(context.Response.Body).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsHopByHopHeader(string headerName)
|
||||
{
|
||||
return headerName.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase)
|
||||
|| headerName.Equals("Connection", StringComparison.OrdinalIgnoreCase)
|
||||
|| headerName.Equals("Keep-Alive", StringComparison.OrdinalIgnoreCase)
|
||||
|| headerName.Equals("Host", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace Aspire.Hosting.ApplicationModel;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a DevUI resource for testing AI agents in a distributed application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DevUI aggregates agents from multiple backend services and provides a unified
|
||||
/// web interface for testing and debugging AI agents using the OpenAI Responses protocol.
|
||||
/// The aggregator runs as an in-process reverse proxy within the AppHost, requiring no
|
||||
/// external container image.
|
||||
/// </remarks>
|
||||
/// <param name="name">The name of the DevUI resource.</param>
|
||||
public class DevUIResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithWaitSupport
|
||||
{
|
||||
internal const string PrimaryEndpointName = "http";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DevUIResource"/> class with endpoint annotations.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the resource.</param>
|
||||
/// <param name="port">An optional fixed port. If <c>null</c>, a dynamic port is assigned.</param>
|
||||
internal DevUIResource(string name, int? port) : this(name)
|
||||
{
|
||||
this.Port = port;
|
||||
this.Annotations.Add(new EndpointAnnotation(
|
||||
ProtocolType.Tcp,
|
||||
uriScheme: "http",
|
||||
name: PrimaryEndpointName,
|
||||
port: port,
|
||||
isProxied: false)
|
||||
{
|
||||
TargetHost = "localhost"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional fixed port for the DevUI web interface.
|
||||
/// </summary>
|
||||
internal int? Port { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary HTTP endpoint for the DevUI web interface.
|
||||
/// </summary>
|
||||
public EndpointReference PrimaryEndpoint => field ??= new(this, PrimaryEndpointName);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# Aspire.Hosting.AgentFramework.DevUI library
|
||||
|
||||
Provides extension methods and resource definitions for an Aspire AppHost to configure a DevUI resource for testing and debugging AI agents built with [Microsoft Agent Framework](https://github.com/microsoft/agent-framework).
|
||||
|
||||
## Getting started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Agent services must expose the OpenAI Responses and Conversations API endpoints. This is compatible with services using [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) with `MapOpenAIResponses()` and `MapOpenAIConversations()` mapped.
|
||||
|
||||
### Install the package
|
||||
|
||||
In your AppHost project, install the Aspire Agent Framework DevUI Hosting library with [NuGet](https://www.nuget.org):
|
||||
|
||||
```dotnetcli
|
||||
dotnet add package Aspire.Hosting.AgentFramework.DevUI
|
||||
```
|
||||
|
||||
## Usage example
|
||||
|
||||
Then, in the _AppHost.cs_ file of `AppHost`, add a DevUI resource and connect it to your agent services using the following methods:
|
||||
|
||||
```csharp
|
||||
var writerAgent = builder.AddProject<Projects.WriterAgent>("writer-agent")
|
||||
.WithHttpHealthCheck("/health");
|
||||
|
||||
var editorAgent = builder.AddProject<Projects.EditorAgent>("editor-agent")
|
||||
.WithHttpHealthCheck("/health");
|
||||
|
||||
var devui = builder.AddDevUI("devui")
|
||||
.WithAgentService(writerAgent)
|
||||
.WithAgentService(editorAgent)
|
||||
.WaitFor(writerAgent)
|
||||
.WaitFor(editorAgent);
|
||||
```
|
||||
|
||||
Each agent service only needs to map the standard OpenAI API endpoints — no custom discovery endpoints are required:
|
||||
|
||||
```csharp
|
||||
// In the agent service's Program.cs
|
||||
builder.AddAIAgent("writer", "You write short stories.");
|
||||
builder.Services.AddOpenAIResponses();
|
||||
builder.Services.AddOpenAIConversations();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapOpenAIResponses();
|
||||
app.MapOpenAIConversations();
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
`AddDevUI` starts an **in-process aggregator** inside the AppHost — no external container image is needed. The aggregator is a lightweight Kestrel server that:
|
||||
|
||||
1. **Serves the DevUI frontend** from the `Microsoft.Agents.AI.DevUI` assembly's embedded resources (loaded at runtime). If the assembly is not available, it falls back to proxying the frontend from the first backend.
|
||||
2. **Aggregates entities** from all configured agent service backends into a single `/v1/entities` listing. Each entity ID is prefixed with the backend name to ensure uniqueness across services (e.g., `writer-agent/writer`, `editor-agent/editor`).
|
||||
3. **Routes requests** to the correct backend based on the entity ID prefix. When DevUI sends a `POST /v1/responses` or `/v1/conversations` request, the aggregator strips the prefix and forwards it to the appropriate service.
|
||||
4. **Streams SSE responses** for the `/v1/responses` endpoint, so agent responses stream back to the DevUI frontend in real time.
|
||||
|
||||
The aggregator publishes its URL to the Aspire dashboard, where it appears as a clickable link.
|
||||
|
||||
## Agent discovery
|
||||
|
||||
By default, `WithAgentService` declares a single agent named after the Aspire resource. You can provide explicit agent metadata when the agent name differs from the resource name, or when a service hosts multiple agents:
|
||||
|
||||
```csharp
|
||||
builder.AddDevUI("devui")
|
||||
.WithAgentService(writerAgent, agents: [new("writer", "Writes short stories")])
|
||||
.WithAgentService(editorAgent, agents: [new("editor", "Edits and formats stories")]);
|
||||
```
|
||||
|
||||
Agent metadata is declared at the AppHost level so the aggregator builds the entity listing directly — agent services don't need a `/v1/entities` endpoint.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Custom entity ID prefix
|
||||
|
||||
By default, entity IDs are prefixed with the Aspire resource name. You can specify a custom prefix:
|
||||
|
||||
```csharp
|
||||
builder.AddDevUI("devui")
|
||||
.WithAgentService(myService, entityIdPrefix: "custom-prefix");
|
||||
```
|
||||
|
||||
### Custom port
|
||||
|
||||
You can specify a fixed host port for the DevUI web interface:
|
||||
|
||||
```csharp
|
||||
builder.AddDevUI("devui", port: 8090);
|
||||
```
|
||||
|
||||
### DevUI frontend assembly
|
||||
|
||||
To serve the DevUI frontend directly from the aggregator (instead of proxying from a backend), add the `Microsoft.Agents.AI.DevUI` NuGet package to your AppHost project. The aggregator loads its embedded resources at runtime via `Assembly.Load`.
|
||||
|
||||
## Additional documentation
|
||||
|
||||
* https://github.com/microsoft/agent-framework
|
||||
* https://github.com/microsoft/agent-framework/tree/main/dotnet/src/Microsoft.Agents.AI.DevUI
|
||||
|
||||
## Feedback & contributing
|
||||
|
||||
https://github.com/dotnet/aspire
|
||||
@@ -426,7 +426,7 @@ internal sealed class HandoffAgentExecutor :
|
||||
{
|
||||
AgentId = this._agent.Id,
|
||||
AuthorName = this._agent.Name ?? this._agent.Id,
|
||||
Contents = [new FunctionResultContent(handoffRequest.CallId, "Transferred.")],
|
||||
Contents = [CreateHandoffResult(handoffRequest.CallId)],
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
Role = ChatRole.Tool,
|
||||
@@ -459,4 +459,6 @@ internal sealed class HandoffAgentExecutor :
|
||||
? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null
|
||||
: null;
|
||||
}
|
||||
|
||||
internal static FunctionResultContent CreateHandoffResult(string requestCallId) => new(requestCallId, "Transferred.");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
@@ -31,113 +30,78 @@ internal sealed class HandoffMessagesFilter
|
||||
return messages;
|
||||
}
|
||||
|
||||
Dictionary<string, FilterCandidateState> filteringCandidates = new();
|
||||
List<ChatMessage> filteredMessages = [];
|
||||
HashSet<int> messagesToRemove = [];
|
||||
HashSet<string> filteredCallsWithoutResponses = new();
|
||||
List<ChatMessage> retainedMessages = [];
|
||||
|
||||
bool filterAllToolCalls = this._filteringBehavior == HandoffToolCallFilteringBehavior.All;
|
||||
|
||||
// The logic of filtering is fairly straightforward: We are only interested in FunctionCallContent and FunctionResponseContent.
|
||||
// We are going to assume that Handoff operates as follows:
|
||||
// * Each agent is only taking one turn at a time
|
||||
// * Each agent is taking a turn alone
|
||||
//
|
||||
// In the case of certain providers, like Gemini (see microsoft/agent-framework #5244), we will see the function call name as the
|
||||
// call id as well, so we may see multiple calls with the same call id, and assume that the call is terminated before another
|
||||
// "CallId-less" FCC is issued. We also need to rely on the idea that FRC follows their corresponding FCC in the message stream.
|
||||
// (This changes the previous behaviour where FRC could arrive earlier, and relies on strict ordering).
|
||||
//
|
||||
// The benefit of expecting all the AIContent to be strictly ordered is that we never need to reach back into a post-filtered
|
||||
// content to retroactively remove it, or to try to inject it back into the middle of a Message that has already been processed.
|
||||
|
||||
bool filterHandoffOnly = this._filteringBehavior == HandoffToolCallFilteringBehavior.HandoffOnly;
|
||||
foreach (ChatMessage unfilteredMessage in messages)
|
||||
{
|
||||
ChatMessage filteredMessage = unfilteredMessage.Clone();
|
||||
|
||||
// .Clone() is shallow, so we cannot modify the contents of the cloned message in place.
|
||||
List<AIContent> contents = [];
|
||||
contents.Capacity = unfilteredMessage.Contents?.Count ?? 0;
|
||||
filteredMessage.Contents = contents;
|
||||
|
||||
// Because this runs after the role changes from assistant to user for the target agent, we cannot rely on tool calls
|
||||
// originating only from messages with the Assistant role. Instead, we need to inspect the contents of all non-Tool (result)
|
||||
// FunctionCallContent.
|
||||
if (unfilteredMessage.Role != ChatRole.Tool)
|
||||
if (unfilteredMessage.Contents is null || unfilteredMessage.Contents.Count == 0)
|
||||
{
|
||||
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
|
||||
{
|
||||
AIContent content = unfilteredMessage.Contents[i];
|
||||
if (content is not FunctionCallContent fcc || (filterHandoffOnly && !IsHandoffFunctionName(fcc.Name)))
|
||||
{
|
||||
filteredMessage.Contents.Add(content);
|
||||
|
||||
// Track non-handoff function calls so their tool results are preserved in HandoffOnly mode
|
||||
if (filterHandoffOnly && content is FunctionCallContent nonHandoffFcc)
|
||||
{
|
||||
filteringCandidates[nonHandoffFcc.CallId] = new FilterCandidateState(nonHandoffFcc.CallId)
|
||||
{
|
||||
IsHandoffFunction = false,
|
||||
};
|
||||
}
|
||||
}
|
||||
else if (filterHandoffOnly)
|
||||
{
|
||||
if (!filteringCandidates.TryGetValue(fcc.CallId, out FilterCandidateState? candidateState))
|
||||
{
|
||||
filteringCandidates[fcc.CallId] = new FilterCandidateState(fcc.CallId)
|
||||
{
|
||||
IsHandoffFunction = true,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
candidateState.IsHandoffFunction = true;
|
||||
(int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value;
|
||||
ChatMessage messageToFilter = filteredMessages[messageIndex];
|
||||
messageToFilter.Contents.RemoveAt(contentIndex);
|
||||
if (messageToFilter.Contents.Count == 0)
|
||||
{
|
||||
messagesToRemove.Add(messageIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// All mode: strip all FunctionCallContent
|
||||
}
|
||||
}
|
||||
retainedMessages.Add(unfilteredMessage);
|
||||
continue;
|
||||
}
|
||||
else
|
||||
|
||||
// We may need to filter out a subset of the message's content, but we won't know until we iterate through it. Create a new list
|
||||
// of AIContent which we will stuff into a clone of the message if we need to filter out any content.
|
||||
List<AIContent> retainedContents = new(capacity: unfilteredMessage.Contents.Count);
|
||||
|
||||
foreach (AIContent content in unfilteredMessage.Contents)
|
||||
{
|
||||
if (!filterHandoffOnly)
|
||||
if (content is FunctionCallContent fcc
|
||||
&& (filterAllToolCalls || IsHandoffFunctionName(fcc.Name)))
|
||||
{
|
||||
// If we already have an unmatched candidate with the same CallId, that means we have two FCCs in a row without an FRC,
|
||||
// which violates our assumption of strict ordering.
|
||||
if (!filteredCallsWithoutResponses.Add(fcc.CallId))
|
||||
{
|
||||
throw new InvalidOperationException($"Duplicate FunctionCallContent with CallId '{fcc.CallId}' without corresponding FunctionResultContent.");
|
||||
}
|
||||
|
||||
// If we are filtering all tool calls, or this is a handoff call (and we are not filtering None, already checked), then
|
||||
// filter this FCC
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
|
||||
else if (content is FunctionResultContent frc)
|
||||
{
|
||||
AIContent content = unfilteredMessage.Contents[i];
|
||||
if (content is not FunctionResultContent frc
|
||||
|| (filteringCandidates.TryGetValue(frc.CallId, out FilterCandidateState? candidateState)
|
||||
&& candidateState.IsHandoffFunction is false))
|
||||
// We rely on the corresponding FCC to have already been processed, so check if it is in the candidate dictionary.
|
||||
// If it is, we can filter out the FRC, but we need to remove the candidate from the dictionary, since a future FCC can
|
||||
// come in with the same CallId, and should be considered a new call that may need to be filtered.
|
||||
if (filteredCallsWithoutResponses.Remove(frc.CallId))
|
||||
{
|
||||
// Either this is not a function result content, so we should let it through, or it is a FRC that
|
||||
// we know is not related to a handoff call. In either case, we should include it.
|
||||
filteredMessage.Contents.Add(content);
|
||||
continue;
|
||||
}
|
||||
else if (candidateState is null)
|
||||
{
|
||||
// We haven't seen the corresponding function call yet, so add it as a candidate to be filtered later
|
||||
filteringCandidates[frc.CallId] = new FilterCandidateState(frc.CallId)
|
||||
{
|
||||
FunctionCallResultLocation = (filteredMessages.Count, filteredMessage.Contents.Count),
|
||||
};
|
||||
}
|
||||
// else we have seen the corresponding function call and it is a handoff, so we should filter it out.
|
||||
}
|
||||
|
||||
// FCC/FRC, but not filtered, or neither FCC nor FRC: this should not be filtered out
|
||||
retainedContents.Add(content);
|
||||
}
|
||||
|
||||
if (filteredMessage.Contents.Count > 0)
|
||||
if (retainedContents.Count == 0)
|
||||
{
|
||||
filteredMessages.Add(filteredMessage);
|
||||
// message was fully filtered, skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
ChatMessage filteredMessage = unfilteredMessage.Clone();
|
||||
filteredMessage.Contents = retainedContents;
|
||||
retainedMessages.Add(filteredMessage);
|
||||
}
|
||||
|
||||
return filteredMessages.Where((_, index) => !messagesToRemove.Contains(index));
|
||||
}
|
||||
|
||||
private class FilterCandidateState(string callId)
|
||||
{
|
||||
public (int MessageIndex, int ContentIndex)? FunctionCallResultLocation { get; set; }
|
||||
|
||||
public string CallId => callId;
|
||||
|
||||
public bool? IsHandoffFunction { get; set; }
|
||||
return retainedMessages;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AgentEntityInfo"/> record.
|
||||
/// </summary>
|
||||
public class AgentEntityInfoTests
|
||||
{
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the Id property is set from the constructor parameter.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithId_SetsIdProperty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-agent", info.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the Description property is set when provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithDescription_SetsDescriptionProperty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent", "A test agent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("A test agent", info.Description);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the Description property is null when not provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithoutDescription_DescriptionIsNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(info.Description);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Default Value Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Name defaults to the Id value when not explicitly set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Name_NotSet_DefaultsToId()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-agent", info.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Name can be overridden with a custom value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Name_Set_ReturnsCustomValue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent") { Name = "Custom Name" };
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Custom Name", info.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Type defaults to "agent".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Type_NotSet_DefaultsToAgent()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("agent", info.Type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Type can be overridden with a custom value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Type_Set_ReturnsCustomValue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent") { Type = "workflow" };
|
||||
|
||||
// Assert
|
||||
Assert.Equal("workflow", info.Type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Framework defaults to "agent_framework".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Framework_NotSet_DefaultsToAgentFramework()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("agent_framework", info.Framework);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Framework can be overridden with a custom value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Framework_Set_ReturnsCustomValue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent") { Framework = "custom_framework" };
|
||||
|
||||
// Assert
|
||||
Assert.Equal("custom_framework", info.Framework);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Record Equality Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that two AgentEntityInfo records with identical values are equal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equality_SameValues_AreEqual()
|
||||
{
|
||||
// Arrange
|
||||
var info1 = new AgentEntityInfo("agent", "description");
|
||||
var info2 = new AgentEntityInfo("agent", "description");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(info1, info2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that two AgentEntityInfo records with different Ids are not equal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equality_DifferentIds_AreNotEqual()
|
||||
{
|
||||
// Arrange
|
||||
var info1 = new AgentEntityInfo("agent1");
|
||||
var info2 = new AgentEntityInfo("agent2");
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(info1, info2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with-expression creates a modified copy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithExpression_ModifiesProperty_CreatesNewInstance()
|
||||
{
|
||||
// Arrange
|
||||
var original = new AgentEntityInfo("agent", "Original description");
|
||||
|
||||
// Act
|
||||
var modified = original with { Description = "Modified description" };
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Original description", original.Description);
|
||||
Assert.Equal("Modified description", modified.Description);
|
||||
Assert.Equal(original.Id, modified.Id);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Aspire.Hosting.ApplicationModel;
|
||||
using Moq;
|
||||
|
||||
namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AgentFrameworkBuilderExtensions"/> class.
|
||||
/// </summary>
|
||||
public class AgentFrameworkBuilderExtensionsTests
|
||||
{
|
||||
#region AddDevUI Validation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI throws ArgumentNullException when builder is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_NullBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(
|
||||
() => AgentFrameworkBuilderExtensions.AddDevUI(null!, "devui"));
|
||||
Assert.Equal("builder", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI throws ArgumentNullException when name is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = DistributedApplication.CreateBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(
|
||||
() => builder.AddDevUI(null!));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI creates a resource with the specified name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_ValidName_CreatesResourceWithName()
|
||||
{
|
||||
// Arrange
|
||||
var builder = DistributedApplication.CreateBuilder();
|
||||
|
||||
// Act
|
||||
var resourceBuilder = builder.AddDevUI("my-devui");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("my-devui", resourceBuilder.Resource.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI creates a DevUIResource.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_ReturnsDevUIResourceBuilder()
|
||||
{
|
||||
// Arrange
|
||||
var builder = DistributedApplication.CreateBuilder();
|
||||
|
||||
// Act
|
||||
var resourceBuilder = builder.AddDevUI("devui");
|
||||
|
||||
// Assert
|
||||
Assert.IsType<DevUIResource>(resourceBuilder.Resource);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI with port configures the endpoint.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_WithPort_ConfiguresEndpointWithPort()
|
||||
{
|
||||
// Arrange
|
||||
var builder = DistributedApplication.CreateBuilder();
|
||||
|
||||
// Act
|
||||
var resourceBuilder = builder.AddDevUI("devui", port: 8090);
|
||||
|
||||
// Assert
|
||||
var endpoint = resourceBuilder.Resource.Annotations
|
||||
.OfType<EndpointAnnotation>()
|
||||
.FirstOrDefault(e => e.Name == "http");
|
||||
Assert.NotNull(endpoint);
|
||||
Assert.Equal(8090, endpoint.Port);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI without port leaves port as null for dynamic allocation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_WithoutPort_EndpointHasDynamicPort()
|
||||
{
|
||||
// Arrange
|
||||
var builder = DistributedApplication.CreateBuilder();
|
||||
|
||||
// Act
|
||||
var resourceBuilder = builder.AddDevUI("devui");
|
||||
|
||||
// Assert
|
||||
var endpoint = resourceBuilder.Resource.Annotations
|
||||
.OfType<EndpointAnnotation>()
|
||||
.FirstOrDefault(e => e.Name == "http");
|
||||
Assert.NotNull(endpoint);
|
||||
Assert.Null(endpoint.Port);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region WithAgentService Validation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService throws ArgumentNullException when builder is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_NullBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var mockAgentService = CreateMockAgentServiceBuilder(appBuilder, "agent-service");
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(
|
||||
() => AgentFrameworkBuilderExtensions.WithAgentService(null!, mockAgentService));
|
||||
Assert.Equal("builder", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService throws ArgumentNullException when agentService is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_NullAgentService_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(
|
||||
() => devuiBuilder.WithAgentService<IResourceWithEndpoints>(null!));
|
||||
Assert.Equal("agentService", exception.ParamName);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region WithAgentService Annotation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService adds an AgentServiceAnnotation to the resource.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_ValidService_AddsAnnotation()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.FirstOrDefault();
|
||||
Assert.NotNull(annotation);
|
||||
Assert.Same(agentService.Resource, annotation.AgentService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService defaults to agent name being the resource name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_NoAgents_DefaultsToResourceNameAsAgent()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
Assert.Single(annotation.Agents);
|
||||
Assert.Equal("writer-agent", annotation.Agents[0].Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService with explicit agents uses those agents.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_WithAgents_UsesProvidedAgents()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "multi-agent-service");
|
||||
var agents = new[]
|
||||
{
|
||||
new AgentEntityInfo("agent1", "First agent"),
|
||||
new AgentEntityInfo("agent2", "Second agent")
|
||||
};
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService, agents: agents);
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
Assert.Equal(2, annotation.Agents.Count);
|
||||
Assert.Equal("agent1", annotation.Agents[0].Id);
|
||||
Assert.Equal("agent2", annotation.Agents[1].Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService with custom prefix uses that prefix.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_WithEntityIdPrefix_UsesProvidedPrefix()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService, entityIdPrefix: "custom-prefix");
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
Assert.Equal("custom-prefix", annotation.EntityIdPrefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService without prefix leaves EntityIdPrefix null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_NoEntityIdPrefix_PrefixIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
Assert.Null(annotation.EntityIdPrefix);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Chaining Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService returns the builder for chaining.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_ReturnsSameBuilder_ForChaining()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act
|
||||
var result = devuiBuilder.WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
Assert.Same(devuiBuilder, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple WithAgentService calls can be chained.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_MultipleCalls_AddsMultipleAnnotations()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var writerService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
var editorService = CreateMockAgentServiceBuilder(appBuilder, "editor-agent");
|
||||
|
||||
// Act
|
||||
devuiBuilder
|
||||
.WithAgentService(writerService)
|
||||
.WithAgentService(editorService);
|
||||
|
||||
// Assert
|
||||
var annotations = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.ToList();
|
||||
Assert.Equal(2, annotations.Count);
|
||||
Assert.Contains(annotations, a => a.AgentService.Name == "writer-agent");
|
||||
Assert.Contains(annotations, a => a.AgentService.Name == "editor-agent");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI returns a builder that can be chained with WithAgentService.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_CanChainWithAgentService()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act - Chain AddDevUI with WithAgentService
|
||||
var result = appBuilder.AddDevUI("devui").WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
var annotation = result.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.FirstOrDefault();
|
||||
Assert.NotNull(annotation);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Relationship Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService creates a relationship annotation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_CreatesRelationshipAnnotation()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
var relationship = devuiBuilder.Resource.Annotations
|
||||
.OfType<ResourceRelationshipAnnotation>()
|
||||
.FirstOrDefault();
|
||||
Assert.NotNull(relationship);
|
||||
Assert.Equal("agent-backend", relationship.Type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple WithAgentService calls create multiple relationship annotations.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_MultipleCalls_CreatesMultipleRelationships()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var writerService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
var editorService = CreateMockAgentServiceBuilder(appBuilder, "editor-agent");
|
||||
|
||||
// Act
|
||||
devuiBuilder
|
||||
.WithAgentService(writerService)
|
||||
.WithAgentService(editorService);
|
||||
|
||||
// Assert
|
||||
var relationships = devuiBuilder.Resource.Annotations
|
||||
.OfType<ResourceRelationshipAnnotation>()
|
||||
.ToList();
|
||||
Assert.Equal(2, relationships.Count);
|
||||
Assert.All(relationships, r => Assert.Equal("agent-backend", r.Type));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Agent Metadata Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that agent description is preserved when specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_AgentWithDescription_PreservesDescription()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
var agents = new[] { new AgentEntityInfo("writer", "Writes creative stories") };
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService, agents: agents);
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
Assert.Equal("Writes creative stories", annotation.Agents[0].Description);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that custom agent properties are preserved.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_CustomAgentProperties_ArePreserved()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "custom-service");
|
||||
var agents = new[]
|
||||
{
|
||||
new AgentEntityInfo("custom-agent")
|
||||
{
|
||||
Name = "Custom Display Name",
|
||||
Type = "workflow",
|
||||
Framework = "custom_framework"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService, agents: agents);
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
var agent = annotation.Agents[0];
|
||||
Assert.Equal("custom-agent", agent.Id);
|
||||
Assert.Equal("Custom Display Name", agent.Name);
|
||||
Assert.Equal("workflow", agent.Type);
|
||||
Assert.Equal("custom_framework", agent.Framework);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that empty agents array can be explicitly provided and is respected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_EmptyAgentsArray_UsesEmptyArray()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
var emptyAgents = Array.Empty<AgentEntityInfo>();
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService, agents: emptyAgents);
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
// When explicitly passing an empty array, the extension method respects it
|
||||
// This is the expected behavior - explicit empty means "discover at runtime"
|
||||
Assert.Empty(annotation.Agents);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Case Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI can be called multiple times with different names.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_MultipleCalls_CreatesSeparateResources()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
|
||||
// Act
|
||||
var devui1 = appBuilder.AddDevUI("devui1");
|
||||
var devui2 = appBuilder.AddDevUI("devui2");
|
||||
|
||||
// Assert
|
||||
Assert.NotSame(devui1.Resource, devui2.Resource);
|
||||
Assert.Equal("devui1", devui1.Resource.Name);
|
||||
Assert.Equal("devui2", devui2.Resource.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that same agent service can be added to multiple DevUI resources.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_SameServiceToMultipleDevUI_Works()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devui1 = appBuilder.AddDevUI("devui1");
|
||||
var devui2 = appBuilder.AddDevUI("devui2");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "shared-agent");
|
||||
|
||||
// Act
|
||||
devui1.WithAgentService(agentService);
|
||||
devui2.WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
var annotation1 = devui1.Resource.Annotations.OfType<AgentServiceAnnotation>().Single();
|
||||
var annotation2 = devui2.Resource.Annotations.OfType<AgentServiceAnnotation>().Single();
|
||||
Assert.Same(annotation1.AgentService, annotation2.AgentService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService works with different entity ID prefixes for the same service.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_DifferentPrefixesToDifferentDevUI_Works()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devui1 = appBuilder.AddDevUI("devui1");
|
||||
var devui2 = appBuilder.AddDevUI("devui2");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act
|
||||
devui1.WithAgentService(agentService, entityIdPrefix: "prefix1");
|
||||
devui2.WithAgentService(agentService, entityIdPrefix: "prefix2");
|
||||
|
||||
// Assert
|
||||
var annotation1 = devui1.Resource.Annotations.OfType<AgentServiceAnnotation>().Single();
|
||||
var annotation2 = devui2.Resource.Annotations.OfType<AgentServiceAnnotation>().Single();
|
||||
Assert.Equal("prefix1", annotation1.EntityIdPrefix);
|
||||
Assert.Equal("prefix2", annotation2.EntityIdPrefix);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock agent service builder for testing.
|
||||
/// Uses a minimal resource implementation that satisfies IResourceWithEndpoints.
|
||||
/// </summary>
|
||||
private static IResourceBuilder<IResourceWithEndpoints> CreateMockAgentServiceBuilder(
|
||||
IDistributedApplicationBuilder appBuilder,
|
||||
string name)
|
||||
{
|
||||
// Create a mock resource that implements IResourceWithEndpoints
|
||||
var mockResource = new Mock<IResourceWithEndpoints>();
|
||||
mockResource.Setup(r => r.Name).Returns(name);
|
||||
mockResource.Setup(r => r.Annotations).Returns(new ResourceAnnotationCollection());
|
||||
|
||||
var mockBuilder = new Mock<IResourceBuilder<IResourceWithEndpoints>>();
|
||||
mockBuilder.Setup(b => b.Resource).Returns(mockResource.Object);
|
||||
mockBuilder.Setup(b => b.ApplicationBuilder).Returns(appBuilder);
|
||||
|
||||
return mockBuilder.Object;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Aspire.Hosting.ApplicationModel;
|
||||
using Moq;
|
||||
|
||||
namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AgentServiceAnnotation"/> class.
|
||||
/// </summary>
|
||||
public class AgentServiceAnnotationTests
|
||||
{
|
||||
#region Constructor Validation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that passing null for agentService throws ArgumentNullException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullAgentService_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AgentServiceAnnotation(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a valid agentService can be used to create the annotation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_ValidAgentService_CreatesAnnotation()
|
||||
{
|
||||
// Arrange
|
||||
var mockResource = new Mock<IResource>();
|
||||
mockResource.Setup(r => r.Name).Returns("test-service");
|
||||
|
||||
// Act
|
||||
var annotation = new AgentServiceAnnotation(mockResource.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(annotation);
|
||||
Assert.Same(mockResource.Object, annotation.AgentService);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AgentService property returns the value passed to constructor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AgentService_ReturnsConstructorValue()
|
||||
{
|
||||
// Arrange
|
||||
var mockResource = new Mock<IResource>();
|
||||
mockResource.Setup(r => r.Name).Returns("my-service");
|
||||
|
||||
// Act
|
||||
var annotation = new AgentServiceAnnotation(mockResource.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockResource.Object, annotation.AgentService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that EntityIdPrefix returns null when not specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EntityIdPrefix_NotSpecified_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var mockResource = new Mock<IResource>();
|
||||
mockResource.Setup(r => r.Name).Returns("test-service");
|
||||
|
||||
// Act
|
||||
var annotation = new AgentServiceAnnotation(mockResource.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(annotation.EntityIdPrefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that EntityIdPrefix returns the value passed to constructor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EntityIdPrefix_Specified_ReturnsValue()
|
||||
{
|
||||
// Arrange
|
||||
var mockResource = new Mock<IResource>();
|
||||
mockResource.Setup(r => r.Name).Returns("test-service");
|
||||
|
||||
// Act
|
||||
var annotation = new AgentServiceAnnotation(mockResource.Object, entityIdPrefix: "custom-prefix");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("custom-prefix", annotation.EntityIdPrefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Agents returns empty collection when not specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Agents_NotSpecified_ReturnsEmptyCollection()
|
||||
{
|
||||
// Arrange
|
||||
var mockResource = new Mock<IResource>();
|
||||
mockResource.Setup(r => r.Name).Returns("test-service");
|
||||
|
||||
// Act
|
||||
var annotation = new AgentServiceAnnotation(mockResource.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(annotation.Agents);
|
||||
Assert.Empty(annotation.Agents);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Agents returns the list passed to constructor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Agents_Specified_ReturnsValue()
|
||||
{
|
||||
// Arrange
|
||||
var mockResource = new Mock<IResource>();
|
||||
mockResource.Setup(r => r.Name).Returns("test-service");
|
||||
var agents = new[] { new AgentEntityInfo("agent1"), new AgentEntityInfo("agent2") };
|
||||
|
||||
// Act
|
||||
var annotation = new AgentServiceAnnotation(mockResource.Object, agents: agents);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, annotation.Agents.Count);
|
||||
Assert.Equal("agent1", annotation.Agents[0].Id);
|
||||
Assert.Equal("agent2", annotation.Agents[1].Id);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Full Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that all constructor parameters are correctly stored.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_AllParameters_SetsAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
var mockResource = new Mock<IResource>();
|
||||
mockResource.Setup(r => r.Name).Returns("full-service");
|
||||
var agents = new[] { new AgentEntityInfo("writer", "Writes stories") };
|
||||
|
||||
// Act
|
||||
var annotation = new AgentServiceAnnotation(
|
||||
mockResource.Object,
|
||||
entityIdPrefix: "writer-backend",
|
||||
agents: agents);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockResource.Object, annotation.AgentService);
|
||||
Assert.Equal("writer-backend", annotation.EntityIdPrefix);
|
||||
Assert.Single(annotation.Agents);
|
||||
Assert.Equal("writer", annotation.Agents[0].Id);
|
||||
Assert.Equal("Writes stories", annotation.Agents[0].Description);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Aspire.Hosting.AgentFramework.DevUI\Aspire.Hosting.AgentFramework.DevUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using Aspire.Hosting.ApplicationModel;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="DevUIAggregatorHostedService"/> class.
|
||||
/// </summary>
|
||||
public class DevUIAggregatorHostedServiceTests
|
||||
{
|
||||
#region RewriteAgentIdInQueryString Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString returns empty string when query string has no value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RewriteAgentIdInQueryString_EmptyQueryString_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var queryString = QueryString.Empty;
|
||||
|
||||
// Act
|
||||
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString rewrites agent_id to the un-prefixed value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RewriteAgentIdInQueryString_WithPrefixedAgentId_RewritesToUnprefixed()
|
||||
{
|
||||
// Arrange
|
||||
var queryString = new QueryString("?agent_id=writer-agent%2Fwriter");
|
||||
|
||||
// Act
|
||||
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
|
||||
|
||||
// Assert
|
||||
Assert.Contains("agent_id=writer", result);
|
||||
Assert.DoesNotContain("writer-agent", result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString preserves other query parameters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RewriteAgentIdInQueryString_WithOtherParams_PreservesOtherParams()
|
||||
{
|
||||
// Arrange
|
||||
var queryString = new QueryString("?agent_id=writer-agent%2Fwriter&conversation_id=123&page=5");
|
||||
|
||||
// Act
|
||||
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
|
||||
|
||||
// Assert
|
||||
Assert.Contains("agent_id=writer", result);
|
||||
Assert.Contains("conversation_id=123", result);
|
||||
Assert.Contains("page=5", result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString works when agent_id is not the first parameter.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RewriteAgentIdInQueryString_AgentIdNotFirst_StillRewrites()
|
||||
{
|
||||
// Arrange
|
||||
var queryString = new QueryString("?page=1&agent_id=editor-agent%2Feditor&limit=10");
|
||||
|
||||
// Act
|
||||
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "editor");
|
||||
|
||||
// Assert
|
||||
Assert.Contains("agent_id=editor", result);
|
||||
Assert.DoesNotContain("editor-agent", result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString handles special characters in actual agent ID.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RewriteAgentIdInQueryString_SpecialCharsInAgentId_UrlEncodesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var queryString = new QueryString("?agent_id=prefix%2Fmy-agent");
|
||||
|
||||
// Act
|
||||
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "my-agent");
|
||||
|
||||
// Assert
|
||||
// The result should contain the agent_id with the value properly encoded if needed
|
||||
Assert.Contains("agent_id=my-agent", result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString handles an agent_id with no prefix.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RewriteAgentIdInQueryString_NoPrefix_SetsDirectly()
|
||||
{
|
||||
// Arrange
|
||||
var queryString = new QueryString("?agent_id=simple");
|
||||
|
||||
// Act
|
||||
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "new-value");
|
||||
|
||||
// Assert
|
||||
Assert.Contains("agent_id=new-value", result);
|
||||
Assert.DoesNotContain("simple", result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString adds agent_id even if not originally present.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RewriteAgentIdInQueryString_NoAgentId_AddsAgentId()
|
||||
{
|
||||
// Arrange
|
||||
var queryString = new QueryString("?page=1&limit=10");
|
||||
|
||||
// Act
|
||||
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
|
||||
|
||||
// Assert
|
||||
Assert.Contains("agent_id=writer", result);
|
||||
Assert.Contains("page=1", result);
|
||||
Assert.Contains("limit=10", result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString returns proper format starting with ?.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RewriteAgentIdInQueryString_ValidQuery_ReturnsQueryStringFormat()
|
||||
{
|
||||
// Arrange
|
||||
var queryString = new QueryString("?agent_id=test");
|
||||
|
||||
// Act
|
||||
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
|
||||
|
||||
// Assert
|
||||
Assert.StartsWith("?", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Backend Resolution Behavior Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ResolveBackends returns empty dictionary when no annotations are present.
|
||||
/// These tests verify the expected behavior of the aggregator via the DevUI resource annotations.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DevUIResource_NoAnnotations_ResolveBackendsReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var builder = DistributedApplication.CreateBuilder();
|
||||
var devui = builder.AddDevUI("devui");
|
||||
|
||||
// Assert - no AgentServiceAnnotation means no backends
|
||||
var annotations = devui.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.ToList();
|
||||
|
||||
Assert.Empty(annotations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService adds proper annotations for backend resolution.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_AddsAnnotation_ForBackendResolution()
|
||||
{
|
||||
// Arrange
|
||||
var builder = DistributedApplication.CreateBuilder();
|
||||
var devui = builder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(builder, "writer-agent");
|
||||
|
||||
// Act
|
||||
devui.WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
var annotation = devui.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.FirstOrDefault();
|
||||
|
||||
Assert.NotNull(annotation);
|
||||
Assert.Equal("writer-agent", annotation.AgentService.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that custom EntityIdPrefix is properly stored in the annotation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_CustomPrefix_StoresInAnnotation()
|
||||
{
|
||||
// Arrange
|
||||
var builder = DistributedApplication.CreateBuilder();
|
||||
var devui = builder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(builder, "writer-agent");
|
||||
|
||||
// Act
|
||||
devui.WithAgentService(agentService, entityIdPrefix: "custom-writer");
|
||||
|
||||
// Assert
|
||||
var annotation = devui.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
|
||||
Assert.Equal("custom-writer", annotation.EntityIdPrefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple agent services create multiple annotations for backend resolution.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_MultipleServices_CreatesMultipleAnnotations()
|
||||
{
|
||||
// Arrange
|
||||
var builder = DistributedApplication.CreateBuilder();
|
||||
var devui = builder.AddDevUI("devui");
|
||||
var writerService = CreateMockAgentServiceBuilder(builder, "writer-agent");
|
||||
var editorService = CreateMockAgentServiceBuilder(builder, "editor-agent");
|
||||
|
||||
// Act
|
||||
devui.WithAgentService(writerService);
|
||||
devui.WithAgentService(editorService);
|
||||
|
||||
// Assert
|
||||
var annotations = devui.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(2, annotations.Count);
|
||||
Assert.Contains(annotations, a => a.AgentService.Name == "writer-agent");
|
||||
Assert.Contains(annotations, a => a.AgentService.Name == "editor-agent");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Entity ID Parsing Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the expected format for prefixed entity IDs in the aggregator.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("writer-agent/writer", "writer-agent", "writer")]
|
||||
[InlineData("editor-agent/editor", "editor-agent", "editor")]
|
||||
[InlineData("custom/my-agent", "custom", "my-agent")]
|
||||
[InlineData("prefix/sub/path", "prefix", "sub/path")]
|
||||
public void PrefixedEntityId_Format_ExtractsCorrectly(string prefixedId, string expectedPrefix, string expectedRest)
|
||||
{
|
||||
// This test documents the expected format for prefixed entity IDs
|
||||
// The aggregator uses "prefix/entityId" format where:
|
||||
// - prefix is typically the resource name or custom prefix
|
||||
// - entityId is the original entity identifier from the backend
|
||||
|
||||
var slashIndex = prefixedId.IndexOf('/');
|
||||
var prefix = prefixedId[..slashIndex];
|
||||
var rest = prefixedId[(slashIndex + 1)..];
|
||||
|
||||
Assert.Equal(expectedPrefix, prefix);
|
||||
Assert.Equal(expectedRest, rest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock agent service builder for testing.
|
||||
/// Uses a minimal resource implementation that satisfies IResourceWithEndpoints.
|
||||
/// </summary>
|
||||
private static IResourceBuilder<IResourceWithEndpoints> CreateMockAgentServiceBuilder(
|
||||
IDistributedApplicationBuilder appBuilder,
|
||||
string name)
|
||||
{
|
||||
// Create a mock resource that implements IResourceWithEndpoints
|
||||
var mockResource = new Moq.Mock<IResourceWithEndpoints>();
|
||||
mockResource.Setup(r => r.Name).Returns(name);
|
||||
mockResource.Setup(r => r.Annotations).Returns(new ResourceAnnotationCollection());
|
||||
|
||||
var mockBuilder = new Moq.Mock<IResourceBuilder<IResourceWithEndpoints>>();
|
||||
mockBuilder.Setup(b => b.Resource).Returns(mockResource.Object);
|
||||
mockBuilder.Setup(b => b.ApplicationBuilder).Returns(appBuilder);
|
||||
|
||||
return mockBuilder.Object;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Net.Sockets;
|
||||
using Aspire.Hosting.ApplicationModel;
|
||||
|
||||
namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="DevUIResource"/> class.
|
||||
/// </summary>
|
||||
public class DevUIResourceTests
|
||||
{
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the resource name is correctly set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithName_SetsName()
|
||||
{
|
||||
// Arrange & Act
|
||||
var resource = new DevUIResource("test-devui");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-devui", resource.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the resource implements IResourceWithEndpoints.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resource_ImplementsIResourceWithEndpoints()
|
||||
{
|
||||
// Arrange & Act
|
||||
var resource = new DevUIResource("test-devui");
|
||||
|
||||
// Assert
|
||||
Assert.IsAssignableFrom<IResourceWithEndpoints>(resource);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the resource implements IResourceWithWaitSupport.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resource_ImplementsIResourceWithWaitSupport()
|
||||
{
|
||||
// Arrange & Act
|
||||
var resource = new DevUIResource("test-devui");
|
||||
|
||||
// Assert
|
||||
Assert.IsAssignableFrom<IResourceWithWaitSupport>(resource);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Endpoint Annotation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the resource has an HTTP endpoint annotation when port is specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithPort_AddsEndpointAnnotation()
|
||||
{
|
||||
// Arrange & Act
|
||||
var resource = CreateResourceWithPort(8090);
|
||||
|
||||
// Assert
|
||||
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().FirstOrDefault();
|
||||
Assert.NotNull(endpoint);
|
||||
Assert.Equal("http", endpoint.Name);
|
||||
Assert.Equal(8090, endpoint.Port);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the endpoint annotation has correct protocol type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EndpointAnnotation_HasTcpProtocol()
|
||||
{
|
||||
// Arrange
|
||||
var resource = CreateResourceWithPort(8080);
|
||||
|
||||
// Act
|
||||
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().First();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ProtocolType.Tcp, endpoint.Protocol);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the endpoint annotation has HTTP URI scheme.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EndpointAnnotation_HasHttpUriScheme()
|
||||
{
|
||||
// Arrange
|
||||
var resource = CreateResourceWithPort(8080);
|
||||
|
||||
// Act
|
||||
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().First();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("http", endpoint.UriScheme);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the endpoint is not proxied.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EndpointAnnotation_IsNotProxied()
|
||||
{
|
||||
// Arrange
|
||||
var resource = CreateResourceWithPort(8080);
|
||||
|
||||
// Act
|
||||
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().First();
|
||||
|
||||
// Assert
|
||||
Assert.False(endpoint.IsProxied);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the endpoint target host is localhost.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EndpointAnnotation_TargetHostIsLocalhost()
|
||||
{
|
||||
// Arrange
|
||||
var resource = CreateResourceWithPort(8080);
|
||||
|
||||
// Act
|
||||
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().First();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("localhost", endpoint.TargetHost);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the endpoint has no fixed port when null is passed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithNullPort_EndpointHasNullPort()
|
||||
{
|
||||
// Arrange & Act
|
||||
var resource = CreateResourceWithPort(null);
|
||||
|
||||
// Assert
|
||||
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().FirstOrDefault();
|
||||
Assert.NotNull(endpoint);
|
||||
Assert.Null(endpoint.Port);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PrimaryEndpoint Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that PrimaryEndpoint returns an endpoint reference.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PrimaryEndpoint_ReturnsEndpointReference()
|
||||
{
|
||||
// Arrange
|
||||
var resource = CreateResourceWithPort(8080);
|
||||
|
||||
// Act
|
||||
var endpoint = resource.PrimaryEndpoint;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(endpoint);
|
||||
Assert.Same(resource, endpoint.Resource);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that PrimaryEndpoint returns the same instance on multiple calls.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PrimaryEndpoint_MultipleCalls_ReturnsSameInstance()
|
||||
{
|
||||
// Arrange
|
||||
var resource = CreateResourceWithPort(8080);
|
||||
|
||||
// Act
|
||||
var endpoint1 = resource.PrimaryEndpoint;
|
||||
var endpoint2 = resource.PrimaryEndpoint;
|
||||
|
||||
// Assert
|
||||
Assert.Same(endpoint1, endpoint2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static DevUIResource CreateResourceWithPort(int? port) => new("test-devui", port);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public class HandoffMessageFilterTests
|
||||
{
|
||||
private List<ChatMessage> CreateTestMessages(bool firstAgentUsesCallId, bool secondAgentUsesCallId, HandoffToolCallFilteringBehavior filter = HandoffToolCallFilteringBehavior.None)
|
||||
{
|
||||
FunctionCallContent handoffRequest1 = CreateHandoffCall(1, firstAgentUsesCallId);
|
||||
FunctionResultContent handoffResponse1 = CreateHandoffResponse(handoffRequest1);
|
||||
|
||||
FunctionCallContent toolCall = CreateToolCall(secondAgentUsesCallId);
|
||||
FunctionResultContent toolResponse = CreateToolResponse(toolCall);
|
||||
|
||||
// Approvals come from the function call middleware over ChatClient, so we can expect there to be a RequestId (not that we
|
||||
// care, because we do not filter approval content)
|
||||
ToolApprovalRequestContent toolApproval = new(Guid.NewGuid().ToString("N"), toolCall);
|
||||
ToolApprovalResponseContent toolApprovalResponse = new(toolApproval.RequestId, true, toolCall);
|
||||
|
||||
FunctionCallContent handoffRequest2 = CreateHandoffCall(1, secondAgentUsesCallId);
|
||||
FunctionResultContent handoffResponse2 = CreateHandoffResponse(handoffRequest2);
|
||||
|
||||
List<ChatMessage> result = [new(ChatRole.User, "Hello")];
|
||||
|
||||
// Agent 1 turn
|
||||
result.Add(new(ChatRole.Assistant, "Hello! What do you want help with today?"));
|
||||
result.Add(new(ChatRole.User, "Please explain temperature"));
|
||||
|
||||
// Unless we are filtering none, we expect the handoff call to be filtered out, so we add it conditionally
|
||||
if (filter == HandoffToolCallFilteringBehavior.None)
|
||||
{
|
||||
result.Add(new(ChatRole.Assistant, [handoffRequest1]));
|
||||
result.Add(new(ChatRole.Tool, [handoffResponse1]));
|
||||
}
|
||||
|
||||
// Agent 2 turn
|
||||
|
||||
// Tool approvals are never filtered, so we add them unconditionally
|
||||
result.Add(new(ChatRole.Assistant, [toolApproval]));
|
||||
result.Add(new(ChatRole.User, [toolApprovalResponse]));
|
||||
|
||||
// Unless we are filtering all, we expect the tool call to be retained, so we add it conditionally
|
||||
if (filter != HandoffToolCallFilteringBehavior.All)
|
||||
{
|
||||
result.Add(new(ChatRole.Assistant, [toolCall]));
|
||||
result.Add(new(ChatRole.Tool, [toolResponse]));
|
||||
}
|
||||
|
||||
result.Add(new(ChatRole.Assistant, "Temperature is a measure of the average kinetic energy of the particles in a substance."));
|
||||
|
||||
if (filter == HandoffToolCallFilteringBehavior.None)
|
||||
{
|
||||
result.Add(new(ChatRole.Assistant, [handoffRequest2]));
|
||||
result.Add(new(ChatRole.Tool, [handoffResponse2]));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static FunctionCallContent CreateHandoffCall(int id, bool useCallId)
|
||||
{
|
||||
string callName = $"{HandoffWorkflowBuilder.FunctionPrefix}{id}";
|
||||
string callId = useCallId ? Guid.NewGuid().ToString("N") : callName;
|
||||
|
||||
return new FunctionCallContent(callId, callName);
|
||||
}
|
||||
|
||||
private static FunctionResultContent CreateHandoffResponse(FunctionCallContent call)
|
||||
=> HandoffAgentExecutor.CreateHandoffResult(call.CallId);
|
||||
|
||||
private static FunctionCallContent CreateToolCall(bool useCallId)
|
||||
{
|
||||
const string CallName = "ToolFunction";
|
||||
string callId = useCallId ? Guid.NewGuid().ToString("N") : CallName;
|
||||
|
||||
return new FunctionCallContent(callId, CallName);
|
||||
}
|
||||
|
||||
private static FunctionResultContent CreateToolResponse(FunctionCallContent call)
|
||||
=> new(call.CallId, new object());
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true, HandoffToolCallFilteringBehavior.None)]
|
||||
[InlineData(true, false, HandoffToolCallFilteringBehavior.None)]
|
||||
[InlineData(false, true, HandoffToolCallFilteringBehavior.None)]
|
||||
[InlineData(false, false, HandoffToolCallFilteringBehavior.None)]
|
||||
[InlineData(true, true, HandoffToolCallFilteringBehavior.HandoffOnly)]
|
||||
[InlineData(true, false, HandoffToolCallFilteringBehavior.HandoffOnly)]
|
||||
[InlineData(false, true, HandoffToolCallFilteringBehavior.HandoffOnly)]
|
||||
[InlineData(false, false, HandoffToolCallFilteringBehavior.HandoffOnly)]
|
||||
[InlineData(true, true, HandoffToolCallFilteringBehavior.All)]
|
||||
[InlineData(true, false, HandoffToolCallFilteringBehavior.All)]
|
||||
[InlineData(false, true, HandoffToolCallFilteringBehavior.All)]
|
||||
[InlineData(false, false, HandoffToolCallFilteringBehavior.All)]
|
||||
public void Test_HandoffMessageFilter_FiltersOnlyExpectedMessages(bool firstAgentUsesCallId, bool secondAgentUsesCallId, HandoffToolCallFilteringBehavior behavior)
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages = this.CreateTestMessages(firstAgentUsesCallId, secondAgentUsesCallId);
|
||||
List<ChatMessage> expected = this.CreateTestMessages(firstAgentUsesCallId, secondAgentUsesCallId, behavior);
|
||||
|
||||
HandoffMessagesFilter filter = new(behavior);
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> filteredMessages = filter.FilterMessages(messages);
|
||||
|
||||
// Assert
|
||||
filteredMessages.Should().BeEquivalentTo(expected);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
],
|
||||
"words": [
|
||||
"aeiou",
|
||||
"agentserver",
|
||||
"agui",
|
||||
"aiplatform",
|
||||
"azuredocindex",
|
||||
|
||||
+38
-1
@@ -7,8 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.0] - 2026-04-21
|
||||
|
||||
### Added
|
||||
- **agent-framework-gemini**: Add `GeminiChatClient` ([#4847](https://github.com/microsoft/agent-framework/pull/4847))
|
||||
- **agent-framework-core**: Add `context_providers` and `description` to `workflow.as_agent()` ([#4651](https://github.com/microsoft/agent-framework/pull/4651))
|
||||
- **agent-framework-core**: Add experimental file history provider ([#5248](https://github.com/microsoft/agent-framework/pull/5248))
|
||||
- **agent-framework-core**: Add OpenAI types to the default checkpoint encoding allow list ([#5297](https://github.com/microsoft/agent-framework/pull/5297))
|
||||
- **agent-framework-core**: Add `AgentExecutorResponse.with_text()` to preserve conversation history through custom executors ([#5255](https://github.com/microsoft/agent-framework/pull/5255))
|
||||
- **agent-framework-a2a**: Propagate A2A metadata from `Message`, `Artifact`, `Task`, and event types ([#5256](https://github.com/microsoft/agent-framework/pull/5256))
|
||||
- **agent-framework-core**: Add `finish_reason` support to `AgentResponse` and `AgentResponseUpdate` ([#5211](https://github.com/microsoft/agent-framework/pull/5211))
|
||||
- **agent-framework-hyperlight**: Add Hyperlight CodeAct package and docs ([#5185](https://github.com/microsoft/agent-framework/pull/5185))
|
||||
- **agent-framework-openai**: Add search tool content support for OpenAI responses ([#5302](https://github.com/microsoft/agent-framework/pull/5302))
|
||||
- **agent-framework-foundry**: Add support for Foundry Toolboxes ([#5346](https://github.com/microsoft/agent-framework/pull/5346))
|
||||
- **agent-framework-ag-ui**: Expose `forwardedProps` to agents and tools via session metadata ([#5264](https://github.com/microsoft/agent-framework/pull/5264))
|
||||
- **agent-framework-foundry**: Add hosted agent V2 support ([#5379](https://github.com/microsoft/agent-framework/pull/5379))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-azure-cosmos**: [BREAKING] `CosmosCheckpointStorage` now uses restricted pickle deserialization by default, matching `FileCheckpointStorage` behavior. If your checkpoints contain application-defined types, pass them via `allowed_checkpoint_types=["my_app.models:MyState"]`. ([#5200](https://github.com/microsoft/agent-framework/issues/5200))
|
||||
- **agent-framework-core**: Improve skill name validation ([#4530](https://github.com/microsoft/agent-framework/pull/4530))
|
||||
- **agent-framework-azure-cosmos**: Add `allowed_checkpoint_types` support to `CosmosCheckpointStorage` for parity with `FileCheckpointStorage` ([#5202](https://github.com/microsoft/agent-framework/pull/5202))
|
||||
- **agent-framework-core**: Move `InMemory` history provider injection to first invocation ([#5236](https://github.com/microsoft/agent-framework/pull/5236))
|
||||
- **agent-framework-github-copilot**: Forward provider config to `SessionConfig` in `GitHubCopilotAgent` ([#5195](https://github.com/microsoft/agent-framework/pull/5195))
|
||||
- **agent-framework-hyperlight-codeact**: Flatten `execute_code` output ([#5333](https://github.com/microsoft/agent-framework/pull/5333))
|
||||
- **dependencies**: Bump `pygments` from `2.19.2` to `2.20.0` in `/python` ([#4978](https://github.com/microsoft/agent-framework/pull/4978))
|
||||
- **tests**: Bump misc integration retry delay to 30s ([#5293](https://github.com/microsoft/agent-framework/pull/5293))
|
||||
- **tests**: Improve misc integration test robustness ([#5295](https://github.com/microsoft/agent-framework/pull/5295))
|
||||
- **tests**: Skip hosted tools test on transient upstream MCP errors ([#5296](https://github.com/microsoft/agent-framework/pull/5296))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Fix `python-feature-lifecycle` skill YAML frontmatter ([#5226](https://github.com/microsoft/agent-framework/pull/5226))
|
||||
- **agent-framework-core**: Fix `HandoffBuilder` dropping function-level middleware when cloning agents ([#5220](https://github.com/microsoft/agent-framework/pull/5220))
|
||||
- **agent-framework-ag-ui**: Fix deterministic state updates from tool results ([#5201](https://github.com/microsoft/agent-framework/pull/5201))
|
||||
- **agent-framework-devui**: Fix streaming memory growth and add cross-platform regression coverage ([#5221](https://github.com/microsoft/agent-framework/pull/5221))
|
||||
- **agent-framework-core**: Skip `get_final_response` in `_finalize_stream` when the stream has errored ([#5232](https://github.com/microsoft/agent-framework/pull/5232))
|
||||
- **agent-framework-openai**: Fix reasoning replay when `store=False` ([#5250](https://github.com/microsoft/agent-framework/pull/5250))
|
||||
- **agent-framework-foundry**: Handle `url_citation` annotations in `FoundryChatClient` streaming responses ([#5071](https://github.com/microsoft/agent-framework/pull/5071))
|
||||
- **agent-framework-gemini**: Fix Gemini client support for Gemini API and Vertex AI ([#5258](https://github.com/microsoft/agent-framework/pull/5258))
|
||||
- **agent-framework-copilotstudio**: Fix `CopilotStudioAgent` to reuse conversation ID from an existing session ([#5299](https://github.com/microsoft/agent-framework/pull/5299))
|
||||
|
||||
## [devui-1.0.0b260414] - 2026-04-14
|
||||
|
||||
@@ -903,7 +939,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.1...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.1.0...HEAD
|
||||
[1.1.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.1...python-1.1.0
|
||||
[1.0.1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0...python-1.0.1
|
||||
[1.0.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc6...python-1.0.0
|
||||
[1.0.0rc6]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc5...python-1.0.0rc6
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"a2a-sdk>=0.3.5,<0.3.24",
|
||||
]
|
||||
|
||||
|
||||
@@ -69,19 +69,23 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Keys that are internal to AG-UI orchestration and should not be passed to chat clients
|
||||
AG_UI_INTERNAL_METADATA_KEYS = {"ag_ui_thread_id", "ag_ui_run_id", "current_state"}
|
||||
AG_UI_INTERNAL_METADATA_KEYS = {"ag_ui_thread_id", "ag_ui_run_id", "current_state", "forwarded_props"}
|
||||
|
||||
|
||||
def _build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Build metadata dict with truncated string values for Azure compatibility.
|
||||
"""Build metadata dict with string values for Azure compatibility.
|
||||
|
||||
Azure has a 512 character limit per metadata value.
|
||||
Azure has a 512 character limit per metadata value. String values that
|
||||
already fit are kept as-is. Non-string values are JSON-serialized. If the
|
||||
resulting string exceeds 512 characters the key is **dropped** (with a
|
||||
warning) instead of truncated, because truncation can produce invalid JSON
|
||||
that downstream consumers cannot decode.
|
||||
|
||||
Args:
|
||||
thread_metadata: Raw metadata dict
|
||||
|
||||
Returns:
|
||||
Metadata with string values truncated to 512 chars
|
||||
Metadata with safe string values (each <= 512 chars)
|
||||
"""
|
||||
if not thread_metadata:
|
||||
return {}
|
||||
@@ -89,7 +93,12 @@ def _build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, An
|
||||
for key, value in thread_metadata.items():
|
||||
value_str = value if isinstance(value, str) else json.dumps(value)
|
||||
if len(value_str) > 512:
|
||||
value_str = value_str[:512]
|
||||
logger.warning(
|
||||
"Dropping metadata key %r: serialized value is %d chars (limit 512)",
|
||||
key,
|
||||
len(value_str),
|
||||
)
|
||||
continue
|
||||
safe_metadata[key] = value_str
|
||||
return safe_metadata
|
||||
|
||||
@@ -790,6 +799,10 @@ async def run_agent_stream(
|
||||
"ag_ui_thread_id": thread_id,
|
||||
"ag_ui_run_id": run_id,
|
||||
}
|
||||
if "forwarded_props" in input_data:
|
||||
base_metadata["forwarded_props"] = input_data["forwarded_props"]
|
||||
elif "forwardedProps" in input_data:
|
||||
base_metadata["forwarded_props"] = input_data["forwardedProps"]
|
||||
if flow.current_state:
|
||||
base_metadata["current_state"] = flow.current_state
|
||||
session.metadata = _build_safe_metadata(base_metadata) # type: ignore[attr-defined]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
@@ -581,11 +582,33 @@ async def run_workflow_stream(
|
||||
flow.accumulated_text = ""
|
||||
return [TextMessageEndEvent(message_id=current_message_id)]
|
||||
|
||||
fwd_kwargs: dict[str, Any] = {}
|
||||
if "forwarded_props" in input_data:
|
||||
forwarded_props = input_data["forwarded_props"]
|
||||
fwd_kwargs["function_invocation_kwargs"] = {"forwarded_props": forwarded_props}
|
||||
elif "forwardedProps" in input_data:
|
||||
forwarded_props = input_data["forwardedProps"]
|
||||
fwd_kwargs["function_invocation_kwargs"] = {"forwarded_props": forwarded_props}
|
||||
|
||||
# Only pass function_invocation_kwargs if the workflow.run signature accepts it
|
||||
if fwd_kwargs:
|
||||
try:
|
||||
sig = inspect.signature(workflow.run)
|
||||
params = sig.parameters
|
||||
accepts_fwd = "function_invocation_kwargs" in params or any(
|
||||
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
accepts_fwd = False
|
||||
if not accepts_fwd:
|
||||
logger.debug("workflow.run() does not accept function_invocation_kwargs; dropping forwarded_props")
|
||||
fwd_kwargs = {}
|
||||
|
||||
try:
|
||||
if responses:
|
||||
event_stream = workflow.run(responses=responses, stream=True)
|
||||
event_stream = workflow.run(responses=responses, stream=True, **fwd_kwargs)
|
||||
else:
|
||||
event_stream = workflow.run(message=messages, stream=True)
|
||||
event_stream = workflow.run(message=messages, stream=True, **fwd_kwargs)
|
||||
|
||||
async for event in event_stream:
|
||||
event_type = getattr(event, "type", None)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"ag-ui-protocol==0.1.13",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for forwarded_props inclusion in AG-UI session metadata."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from agent_framework_ag_ui._agent_run import AG_UI_INTERNAL_METADATA_KEYS, _build_safe_metadata
|
||||
|
||||
|
||||
class TestForwardedPropsInSessionMetadata:
|
||||
"""Verify that forwarded_props is surfaced in session metadata and filtered from LLM metadata."""
|
||||
|
||||
def test_forwarded_props_in_internal_metadata_keys(self):
|
||||
"""forwarded_props is listed in AG_UI_INTERNAL_METADATA_KEYS to prevent LLM leakage."""
|
||||
assert "forwarded_props" in AG_UI_INTERNAL_METADATA_KEYS
|
||||
|
||||
def test_forwarded_props_filtered_from_client_metadata(self):
|
||||
"""forwarded_props is filtered out when building LLM-bound client metadata."""
|
||||
session_metadata: dict[str, Any] = {
|
||||
"ag_ui_thread_id": "t1",
|
||||
"ag_ui_run_id": "r1",
|
||||
"forwarded_props": '{"custom_flag": true}',
|
||||
}
|
||||
|
||||
client_metadata = {k: v for k, v in session_metadata.items() if k not in AG_UI_INTERNAL_METADATA_KEYS}
|
||||
|
||||
assert "forwarded_props" not in client_metadata
|
||||
assert "ag_ui_thread_id" not in client_metadata
|
||||
|
||||
|
||||
class TestBuildSafeMetadata:
|
||||
"""Verify _build_safe_metadata handles various value types correctly."""
|
||||
|
||||
def test_string_value_unchanged(self):
|
||||
result = _build_safe_metadata({"key": "hello"})
|
||||
assert result == {"key": "hello"}
|
||||
|
||||
def test_dict_value_serialized_to_json(self):
|
||||
result = _build_safe_metadata({"fp": {"flag": True, "source": "frontend"}})
|
||||
assert "fp" in result
|
||||
assert isinstance(result["fp"], str)
|
||||
# Must be valid, decodable JSON
|
||||
decoded = json.loads(result["fp"])
|
||||
assert decoded == {"flag": True, "source": "frontend"}
|
||||
|
||||
def test_empty_dict_serialized_to_json(self):
|
||||
result = _build_safe_metadata({"fp": {}})
|
||||
assert result["fp"] == "{}"
|
||||
assert json.loads(result["fp"]) == {}
|
||||
|
||||
def test_value_within_limit_kept(self):
|
||||
value = "x" * 512
|
||||
result = _build_safe_metadata({"key": value})
|
||||
assert result["key"] == value
|
||||
|
||||
def test_value_exceeding_limit_dropped(self):
|
||||
"""Values exceeding 512 chars are dropped entirely (not truncated)."""
|
||||
value = "x" * 513
|
||||
result = _build_safe_metadata({"key": value})
|
||||
assert "key" not in result
|
||||
|
||||
def test_json_value_exceeding_limit_dropped(self):
|
||||
"""JSON-serialized dict exceeding 512 chars is dropped, not truncated into invalid JSON."""
|
||||
big_dict = {f"key_{i}": "v" * 100 for i in range(50)}
|
||||
result = _build_safe_metadata({"forwarded_props": big_dict})
|
||||
assert "forwarded_props" not in result
|
||||
|
||||
def test_other_keys_preserved_when_one_dropped(self):
|
||||
"""Dropping one oversized key does not affect other keys."""
|
||||
result = _build_safe_metadata(
|
||||
{
|
||||
"small": "ok",
|
||||
"big": "x" * 600,
|
||||
}
|
||||
)
|
||||
assert result == {"small": "ok"}
|
||||
|
||||
def test_none_input_returns_empty(self):
|
||||
assert _build_safe_metadata(None) == {}
|
||||
|
||||
def test_empty_input_returns_empty(self):
|
||||
assert _build_safe_metadata({}) == {}
|
||||
@@ -63,12 +63,12 @@ class TestBuildSafeMetadata:
|
||||
result = _build_safe_metadata(metadata)
|
||||
assert result == metadata
|
||||
|
||||
def test_truncates_long_strings(self):
|
||||
"""Truncates strings over 512 chars."""
|
||||
def test_drops_long_strings(self):
|
||||
"""Drops strings over 512 chars instead of truncating."""
|
||||
long_value = "x" * 1000
|
||||
metadata = {"key": long_value}
|
||||
result = _build_safe_metadata(metadata)
|
||||
assert len(result["key"]) == 512
|
||||
assert "key" not in result
|
||||
|
||||
def test_serializes_non_strings(self):
|
||||
"""Serializes non-string values to JSON."""
|
||||
@@ -77,12 +77,12 @@ class TestBuildSafeMetadata:
|
||||
assert result["count"] == "42"
|
||||
assert result["items"] == "[1, 2, 3]"
|
||||
|
||||
def test_truncates_serialized_values(self):
|
||||
"""Truncates serialized values over 512 chars."""
|
||||
def test_drops_oversized_serialized_values(self):
|
||||
"""Drops serialized values over 512 chars instead of truncating."""
|
||||
long_list = list(range(200))
|
||||
metadata = {"data": long_list}
|
||||
result = _build_safe_metadata(metadata)
|
||||
assert len(result["data"]) == 512
|
||||
assert "data" not in result
|
||||
|
||||
|
||||
class TestHasOnlyToolCalls:
|
||||
|
||||
@@ -1672,3 +1672,210 @@ async def test_workflow_run_non_terminal_status_emits_custom():
|
||||
custom = [e for e in events if e.type == "CUSTOM" and e.name == "status"]
|
||||
assert len(custom) == 1
|
||||
assert custom[0].value == {"state": "running"}
|
||||
|
||||
|
||||
async def test_workflow_run_passes_forwarded_props_as_function_invocation_kwargs() -> None:
|
||||
"""forwarded_props from input_data is forwarded to workflow.run() via function_invocation_kwargs."""
|
||||
|
||||
class CapturingWorkflow:
|
||||
def __init__(self) -> None:
|
||||
self.captured_kwargs: dict[str, Any] = {}
|
||||
|
||||
def run(self, **kwargs: Any):
|
||||
self.captured_kwargs = dict(kwargs)
|
||||
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
|
||||
return _stream()
|
||||
|
||||
workflow = CapturingWorkflow()
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"forwarded_props": {"custom_flag": True, "source": "copilotkit"},
|
||||
},
|
||||
cast(Any, workflow),
|
||||
)
|
||||
]
|
||||
|
||||
event_types = [event.type for event in events]
|
||||
assert "RUN_STARTED" in event_types
|
||||
assert "RUN_FINISHED" in event_types
|
||||
|
||||
assert workflow.captured_kwargs["stream"] is True
|
||||
assert "function_invocation_kwargs" in workflow.captured_kwargs
|
||||
assert workflow.captured_kwargs["function_invocation_kwargs"] == {
|
||||
"forwarded_props": {"custom_flag": True, "source": "copilotkit"},
|
||||
}
|
||||
|
||||
|
||||
async def test_workflow_run_omits_function_invocation_kwargs_when_no_forwarded_props() -> None:
|
||||
"""function_invocation_kwargs is not passed when forwarded_props is absent."""
|
||||
|
||||
class CapturingWorkflow:
|
||||
def __init__(self) -> None:
|
||||
self.captured_kwargs: dict[str, Any] = {}
|
||||
|
||||
def run(self, **kwargs: Any):
|
||||
self.captured_kwargs = dict(kwargs)
|
||||
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
|
||||
return _stream()
|
||||
|
||||
workflow = CapturingWorkflow()
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{"messages": [{"role": "user", "content": "hello"}]},
|
||||
cast(Any, workflow),
|
||||
)
|
||||
]
|
||||
|
||||
event_types = [event.type for event in events]
|
||||
assert "RUN_STARTED" in event_types
|
||||
assert workflow.captured_kwargs["stream"] is True
|
||||
assert "function_invocation_kwargs" not in workflow.captured_kwargs
|
||||
|
||||
|
||||
async def test_workflow_run_accepts_camel_case_forwarded_props() -> None:
|
||||
"""forwardedProps (camelCase) is accepted as an alternative key."""
|
||||
|
||||
class CapturingWorkflow:
|
||||
def __init__(self) -> None:
|
||||
self.captured_kwargs: dict[str, Any] = {}
|
||||
|
||||
def run(self, **kwargs: Any):
|
||||
self.captured_kwargs = dict(kwargs)
|
||||
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
|
||||
return _stream()
|
||||
|
||||
workflow = CapturingWorkflow()
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"forwardedProps": {"source": "frontend"},
|
||||
},
|
||||
cast(Any, workflow),
|
||||
)
|
||||
]
|
||||
|
||||
event_types = [event.type for event in events]
|
||||
assert "RUN_STARTED" in event_types
|
||||
|
||||
assert workflow.captured_kwargs["stream"] is True
|
||||
assert "function_invocation_kwargs" in workflow.captured_kwargs
|
||||
assert workflow.captured_kwargs["function_invocation_kwargs"] == {
|
||||
"forwarded_props": {"source": "frontend"},
|
||||
}
|
||||
|
||||
|
||||
async def test_workflow_run_passes_empty_dict_forwarded_props() -> None:
|
||||
"""An empty dict forwarded_props={} should still be forwarded (not dropped by truthiness)."""
|
||||
|
||||
class CapturingWorkflow:
|
||||
def __init__(self) -> None:
|
||||
self.captured_kwargs: dict[str, Any] = {}
|
||||
|
||||
def run(self, **kwargs: Any):
|
||||
self.captured_kwargs = dict(kwargs)
|
||||
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
|
||||
return _stream()
|
||||
|
||||
workflow = CapturingWorkflow()
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"forwarded_props": {},
|
||||
},
|
||||
cast(Any, workflow),
|
||||
)
|
||||
]
|
||||
|
||||
event_types = [event.type for event in events]
|
||||
assert "RUN_STARTED" in event_types
|
||||
assert "RUN_FINISHED" in event_types
|
||||
|
||||
assert workflow.captured_kwargs["stream"] is True
|
||||
assert "function_invocation_kwargs" in workflow.captured_kwargs
|
||||
assert workflow.captured_kwargs["function_invocation_kwargs"] == {
|
||||
"forwarded_props": {},
|
||||
}
|
||||
|
||||
|
||||
async def test_workflow_run_stream_true_always_passed() -> None:
|
||||
"""stream=True is always passed to workflow.run()."""
|
||||
|
||||
class CapturingWorkflow:
|
||||
def __init__(self) -> None:
|
||||
self.captured_kwargs: dict[str, Any] = {}
|
||||
|
||||
def run(self, **kwargs: Any):
|
||||
self.captured_kwargs = dict(kwargs)
|
||||
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
|
||||
return _stream()
|
||||
|
||||
workflow = CapturingWorkflow()
|
||||
_ = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"forwarded_props": {"key": "val"},
|
||||
},
|
||||
cast(Any, workflow),
|
||||
)
|
||||
]
|
||||
|
||||
assert workflow.captured_kwargs["stream"] is True
|
||||
|
||||
|
||||
async def test_workflow_run_drops_fwd_kwargs_when_run_lacks_param() -> None:
|
||||
"""function_invocation_kwargs is silently dropped if workflow.run() does not accept it."""
|
||||
|
||||
class StrictWorkflow:
|
||||
def __init__(self) -> None:
|
||||
self.captured_kwargs: dict[str, Any] = {}
|
||||
|
||||
def run(self, *, message: Any = None, responses: Any = None, stream: bool = False):
|
||||
self.captured_kwargs = {"message": message, "responses": responses, "stream": stream}
|
||||
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
|
||||
return _stream()
|
||||
|
||||
workflow = StrictWorkflow()
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"forwarded_props": {"custom": True},
|
||||
},
|
||||
cast(Any, workflow),
|
||||
)
|
||||
]
|
||||
|
||||
event_types = [event.type for event in events]
|
||||
assert "RUN_STARTED" in event_types
|
||||
assert "RUN_FINISHED" in event_types
|
||||
# No TypeError raised, and function_invocation_kwargs was not passed
|
||||
assert "function_invocation_kwargs" not in workflow.captured_kwargs
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"azure-search-documents>=11.7.0b2,<11.7.0b3",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"azure-cosmos>=4.3.0,<5",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"agent-framework-durabletask",
|
||||
"azure-functions>=1.24.0,<2",
|
||||
"azure-functions-durable>=1.3.1,<2",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"boto3>=1.35.0,<2.0.0",
|
||||
"botocore>=1.35.0,<2.0.0",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"openai-chatkit>=1.4.1,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"claude-agent-sdk>=0.1.36,<0.1.49",
|
||||
]
|
||||
|
||||
|
||||
@@ -244,7 +244,8 @@ class CopilotStudioAgent(BaseAgent):
|
||||
"""Non-streaming implementation of run."""
|
||||
if not session:
|
||||
session = self.create_session()
|
||||
session.service_session_id = await self._start_new_conversation()
|
||||
if not session.service_session_id:
|
||||
session.service_session_id = await self._start_new_conversation()
|
||||
|
||||
input_messages = normalize_messages(messages)
|
||||
|
||||
@@ -271,7 +272,8 @@ class CopilotStudioAgent(BaseAgent):
|
||||
nonlocal session
|
||||
if not session:
|
||||
session = self.create_session()
|
||||
session.service_session_id = await self._start_new_conversation()
|
||||
if not session.service_session_id:
|
||||
session.service_session_id = await self._start_new_conversation()
|
||||
|
||||
input_messages = normalize_messages(messages)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -245,6 +245,47 @@ class TestCopilotStudioAgent:
|
||||
assert response_count == 1
|
||||
assert session.service_session_id == "test-conversation-id"
|
||||
|
||||
async def test_run_reuses_existing_conversation(
|
||||
self, mock_copilot_client: MagicMock, mock_activity: MagicMock
|
||||
) -> None:
|
||||
"""Test run method reuses an existing conversation ID from the session."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
session = AgentSession()
|
||||
session.service_session_id = "existing-conversation-id"
|
||||
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity])
|
||||
|
||||
response = await agent.run("test message", session=session)
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert session.service_session_id == "existing-conversation-id"
|
||||
mock_copilot_client.start_conversation.assert_not_called()
|
||||
mock_copilot_client.ask_question.assert_called_once_with("test message", "existing-conversation-id")
|
||||
|
||||
async def test_run_streaming_reuses_existing_conversation(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run(stream=True) method reuses an existing conversation ID from the session."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
session = AgentSession()
|
||||
session.service_session_id = "existing-conversation-id"
|
||||
|
||||
typing_activity = MagicMock()
|
||||
typing_activity.text = "Streaming response"
|
||||
typing_activity.type = "typing"
|
||||
typing_activity.id = "test-typing-id"
|
||||
typing_activity.from_property.name = "Test Bot"
|
||||
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([typing_activity])
|
||||
|
||||
response_count = 0
|
||||
async for response in agent.run("test message", session=session, stream=True):
|
||||
assert isinstance(response, AgentResponseUpdate)
|
||||
response_count += 1
|
||||
|
||||
assert response_count == 1
|
||||
assert session.service_session_id == "existing-conversation-id"
|
||||
mock_copilot_client.start_conversation.assert_not_called()
|
||||
mock_copilot_client.ask_question.assert_called_once_with("test message", "existing-conversation-id")
|
||||
|
||||
async def test_run_streaming_no_typing_activity(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run(stream=True) method with non-typing activity."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
@@ -49,6 +49,7 @@ class ExperimentalFeature(str, Enum):
|
||||
EVALS = "EVALS"
|
||||
FILE_HISTORY = "FILE_HISTORY"
|
||||
SKILLS = "SKILLS"
|
||||
TOOLBOXES = "TOOLBOXES"
|
||||
|
||||
|
||||
class ReleaseCandidateFeature(str, Enum):
|
||||
|
||||
@@ -4,6 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Final
|
||||
|
||||
from . import __version__ as version_info
|
||||
@@ -26,6 +29,35 @@ USER_AGENT_KEY: Final[str] = "User-Agent"
|
||||
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
|
||||
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore[has-type]
|
||||
|
||||
_user_agent_prefixes: ContextVar[tuple[str, ...]] = ContextVar("_user_agent_prefixes", default=())
|
||||
|
||||
|
||||
@contextmanager
|
||||
def user_agent_prefix(prefix: str) -> Generator[None]:
|
||||
"""Context manager that adds a prefix to the user agent string for the current scope.
|
||||
|
||||
This is useful for upstream layers that want to identify themselves in telemetry
|
||||
for the duration of a request without permanently mutating global state.
|
||||
|
||||
Args:
|
||||
prefix: The prefix to add (e.g. "foundry-hosting").
|
||||
"""
|
||||
current = _user_agent_prefixes.get()
|
||||
token = _user_agent_prefixes.set((*current, prefix)) if prefix and prefix not in current else None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if token is not None:
|
||||
_user_agent_prefixes.reset(token)
|
||||
|
||||
|
||||
def _get_user_agent() -> str:
|
||||
"""Return the full user agent string including any context-scoped prefixes."""
|
||||
prefixes = _user_agent_prefixes.get()
|
||||
if not prefixes:
|
||||
return AGENT_FRAMEWORK_USER_AGENT
|
||||
return f"{'/'.join(prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}"
|
||||
|
||||
|
||||
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Prepend "agent-framework" to the User-Agent in the headers.
|
||||
@@ -57,12 +89,9 @@ def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None)
|
||||
"""
|
||||
if not IS_TELEMETRY_ENABLED:
|
||||
return headers or {}
|
||||
user_agent = _get_user_agent()
|
||||
if not headers:
|
||||
return {USER_AGENT_KEY: AGENT_FRAMEWORK_USER_AGENT}
|
||||
headers[USER_AGENT_KEY] = (
|
||||
f"{AGENT_FRAMEWORK_USER_AGENT} {headers[USER_AGENT_KEY]}"
|
||||
if USER_AGENT_KEY in headers
|
||||
else AGENT_FRAMEWORK_USER_AGENT
|
||||
)
|
||||
return {USER_AGENT_KEY: user_agent}
|
||||
headers[USER_AGENT_KEY] = f"{user_agent} {headers[USER_AGENT_KEY]}" if USER_AGENT_KEY in headers else user_agent
|
||||
|
||||
return headers
|
||||
|
||||
@@ -12,6 +12,7 @@ from collections.abc import (
|
||||
AsyncIterable,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Iterable,
|
||||
Mapping,
|
||||
Sequence,
|
||||
)
|
||||
@@ -859,6 +860,15 @@ def normalize_tools(
|
||||
Returns:
|
||||
A normalized list where callable inputs are converted to ``FunctionTool``
|
||||
using :func:`tool`, and existing tool objects are passed through unchanged.
|
||||
|
||||
Tool-collection wrappers are flattened in two forms:
|
||||
|
||||
- non-tool, non-callable iterables
|
||||
- mapping-like objects that expose a ``.tools`` collection (for example
|
||||
``ToolboxVersionObject`` from azure-ai-projects)
|
||||
|
||||
This lets callers write ``tools=[toolbox, my_func]`` and have the
|
||||
toolbox's contents spread in alongside individual tools.
|
||||
"""
|
||||
if not tools:
|
||||
return []
|
||||
@@ -883,6 +893,24 @@ def normalize_tools(
|
||||
if callable(tool_item): # type: ignore[reportUnknownArgumentType]
|
||||
normalized.append(tool(tool_item))
|
||||
continue
|
||||
# Mapping-like tool collections (for example ToolboxVersionObject) are
|
||||
# not flattened by the generic Iterable branch below because they are
|
||||
# also Mapping instances. If they expose a ``tools`` collection, spread
|
||||
# that collection into the normalized list.
|
||||
collection_tools = getattr(tool_item, "tools", None) # type: ignore[reportUnknownArgumentType]
|
||||
if isinstance(collection_tools, Iterable) and not isinstance(
|
||||
collection_tools, (str, bytes, bytearray, Mapping)
|
||||
):
|
||||
normalized.extend(normalize_tools(list(collection_tools))) # type: ignore[reportUnknownArgumentType]
|
||||
continue
|
||||
# Tool-collection wrapper (e.g. FoundryToolbox): a non-tool, non-callable
|
||||
# iterable. Flatten its contents so ``tools=[toolbox, my_func]`` works.
|
||||
# Strings, mappings, and Pydantic BaseModel are excluded — BaseModel
|
||||
# instances iterate over (field, value) tuples, not tools, so they
|
||||
# should pass through as leaf tool specs (handled below).
|
||||
if isinstance(tool_item, Iterable) and not isinstance(tool_item, (str, bytes, bytearray, Mapping, BaseModel)):
|
||||
normalized.extend(normalize_tools(list(tool_item))) # type: ignore[reportUnknownArgumentType]
|
||||
continue
|
||||
normalized.append(tool_item) # type: ignore[reportUnknownArgumentType]
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -351,6 +351,8 @@ ContentType = Literal[
|
||||
"image_generation_tool_result",
|
||||
"mcp_server_tool_call",
|
||||
"mcp_server_tool_result",
|
||||
"search_tool_call",
|
||||
"search_tool_result",
|
||||
"shell_tool_call",
|
||||
"shell_tool_result",
|
||||
"shell_command_output",
|
||||
@@ -864,6 +866,56 @@ class Content:
|
||||
raw_representation=raw_representation,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_search_tool_call(
|
||||
cls: type[ContentT],
|
||||
call_id: str,
|
||||
*,
|
||||
tool_name: str,
|
||||
arguments: str | Mapping[str, Any] | None = None,
|
||||
status: str | None = None,
|
||||
annotations: Sequence[Annotation] | None = None,
|
||||
additional_properties: MutableMapping[str, Any] | None = None,
|
||||
raw_representation: Any = None,
|
||||
) -> ContentT:
|
||||
"""Create search tool call content."""
|
||||
return cls(
|
||||
"search_tool_call",
|
||||
call_id=call_id,
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
status=status,
|
||||
annotations=annotations,
|
||||
additional_properties=additional_properties,
|
||||
raw_representation=raw_representation,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_search_tool_result(
|
||||
cls: type[ContentT],
|
||||
call_id: str,
|
||||
*,
|
||||
tool_name: str,
|
||||
result: Any = None,
|
||||
items: Sequence[Content] | None = None,
|
||||
status: str | None = None,
|
||||
annotations: Sequence[Annotation] | None = None,
|
||||
additional_properties: MutableMapping[str, Any] | None = None,
|
||||
raw_representation: Any = None,
|
||||
) -> ContentT:
|
||||
"""Create search tool result content."""
|
||||
return cls(
|
||||
"search_tool_result",
|
||||
call_id=call_id,
|
||||
tool_name=tool_name,
|
||||
result=result,
|
||||
items=list(items) if items is not None else None,
|
||||
status=status,
|
||||
annotations=annotations,
|
||||
additional_properties=additional_properties,
|
||||
raw_representation=raw_representation,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_usage(
|
||||
cls: type[ContentT],
|
||||
@@ -1478,7 +1530,7 @@ class Content:
|
||||
return span.lower() == top_level_media_type.lower()
|
||||
|
||||
def parse_arguments(self) -> dict[str, Any | None] | None:
|
||||
"""Parse arguments from function_call or mcp_server_tool_call content.
|
||||
"""Parse arguments from function_call, mcp_server_tool_call, or search_tool_call content.
|
||||
|
||||
If arguments cannot be parsed as JSON or the result is not a dict,
|
||||
they are returned as a dictionary with a single key "raw".
|
||||
|
||||
@@ -20,6 +20,7 @@ _IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"FoundryEmbeddingOptions": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryEmbeddingSettings": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryEvals": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryHostedToolType": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryMemoryProvider": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"FoundryLocalChatOptions": ("agent_framework_foundry_local", "agent-framework-foundry-local"),
|
||||
"FoundryLocalClient": ("agent_framework_foundry_local", "agent-framework-foundry-local"),
|
||||
@@ -31,6 +32,9 @@ _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"),
|
||||
"get_toolbox_tool_name": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"get_toolbox_tool_type": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
"select_toolbox_tools": ("agent_framework_foundry", "agent-framework-foundry"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from agent_framework_foundry import (
|
||||
FoundryEmbeddingOptions,
|
||||
FoundryEmbeddingSettings,
|
||||
FoundryEvals,
|
||||
FoundryHostedToolType,
|
||||
FoundryMemoryProvider,
|
||||
RawFoundryAgent,
|
||||
RawFoundryAgentChatClient,
|
||||
@@ -19,6 +20,9 @@ from agent_framework_foundry import (
|
||||
RawFoundryEmbeddingClient,
|
||||
evaluate_foundry_target,
|
||||
evaluate_traces,
|
||||
get_toolbox_tool_name,
|
||||
get_toolbox_tool_type,
|
||||
select_toolbox_tools,
|
||||
)
|
||||
from agent_framework_foundry_local import (
|
||||
FoundryLocalChatOptions,
|
||||
@@ -35,6 +39,7 @@ __all__ = [
|
||||
"FoundryEmbeddingOptions",
|
||||
"FoundryEmbeddingSettings",
|
||||
"FoundryEvals",
|
||||
"FoundryHostedToolType",
|
||||
"FoundryLocalChatOptions",
|
||||
"FoundryLocalClient",
|
||||
"FoundryLocalSettings",
|
||||
@@ -46,4 +51,7 @@ __all__ = [
|
||||
"RawFoundryEmbeddingClient",
|
||||
"evaluate_foundry_target",
|
||||
"evaluate_traces",
|
||||
"get_toolbox_tool_name",
|
||||
"get_toolbox_tool_type",
|
||||
"select_toolbox_tools",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -8,6 +8,7 @@ from agent_framework import (
|
||||
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
|
||||
prepend_agent_framework_to_user_agent,
|
||||
)
|
||||
from agent_framework._telemetry import user_agent_prefix
|
||||
|
||||
# region Test constants
|
||||
|
||||
@@ -96,3 +97,56 @@ def test_modifies_original_dict():
|
||||
|
||||
assert result is headers # Same object
|
||||
assert "User-Agent" in headers
|
||||
|
||||
|
||||
# region Test user_agent_prefix context manager
|
||||
|
||||
|
||||
def test_user_agent_prefix_adds_prefix():
|
||||
"""Test that the context manager adds a prefix within its scope."""
|
||||
with user_agent_prefix("test-host"):
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert result["User-Agent"].startswith("test-host/")
|
||||
assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"]
|
||||
|
||||
# Prefix is removed after exiting the context
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
|
||||
|
||||
|
||||
def test_user_agent_prefix_ignores_duplicates():
|
||||
"""Test that duplicate prefixes are not added within nested scopes."""
|
||||
with user_agent_prefix("test-host"), user_agent_prefix("test-host"):
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert result["User-Agent"].count("test-host") == 1
|
||||
|
||||
|
||||
def test_user_agent_prefix_ignores_empty():
|
||||
"""Test that empty strings are not added as prefixes."""
|
||||
with user_agent_prefix(""):
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
|
||||
|
||||
|
||||
def test_user_agent_prefix_restores_on_exit():
|
||||
"""Test that prefixes are fully restored after the context manager exits."""
|
||||
with user_agent_prefix("test-host"):
|
||||
pass
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
|
||||
|
||||
|
||||
def test_user_agent_prefix_nesting():
|
||||
"""Test that nested context managers compose prefixes correctly."""
|
||||
with user_agent_prefix("outer"):
|
||||
with user_agent_prefix("inner"):
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert "outer" in result["User-Agent"]
|
||||
assert "inner" in result["User-Agent"]
|
||||
# Inner prefix removed
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert "outer" in result["User-Agent"]
|
||||
assert "inner" not in result["User-Agent"]
|
||||
# Both removed
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
|
||||
|
||||
@@ -1144,3 +1144,160 @@ def test_parse_annotation_with_annotated_and_literal():
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region normalize_tools flattening of tool-collection wrappers
|
||||
|
||||
|
||||
def _make_flatten_function_tool(name: str) -> FunctionTool:
|
||||
"""Build a FunctionTool for flattening tests."""
|
||||
|
||||
@tool(name=name, description=f"{name} tool")
|
||||
def _impl(x: int) -> int:
|
||||
return x
|
||||
|
||||
return _impl # type: ignore[return-value]
|
||||
|
||||
|
||||
def test_normalize_tools_flattens_tool_collection_wrapper() -> None:
|
||||
"""A non-tool, non-callable iterable inside the tools list is flattened."""
|
||||
from agent_framework._tools import normalize_tools
|
||||
|
||||
inner_a = _make_flatten_function_tool("inner_a")
|
||||
inner_b = _make_flatten_function_tool("inner_b")
|
||||
|
||||
class ToolBundle:
|
||||
"""Minimal stand-in for a tool-collection wrapper like FoundryToolbox."""
|
||||
|
||||
def __init__(self, tools: list[FunctionTool]) -> None:
|
||||
self._tools = tools
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._tools)
|
||||
|
||||
bundle = ToolBundle([inner_a, inner_b])
|
||||
|
||||
normalized = normalize_tools([bundle])
|
||||
|
||||
assert len(normalized) == 2
|
||||
assert normalized[0] is inner_a
|
||||
assert normalized[1] is inner_b
|
||||
|
||||
|
||||
def test_normalize_tools_combines_bundle_with_individual_tools() -> None:
|
||||
"""The canonical ``tools=[bundle, my_func]`` call site spreads bundle + individual."""
|
||||
from agent_framework._tools import normalize_tools
|
||||
|
||||
bundled = _make_flatten_function_tool("bundled")
|
||||
standalone = _make_flatten_function_tool("standalone")
|
||||
|
||||
class ToolBundle:
|
||||
def __init__(self, tools: list[FunctionTool]) -> None:
|
||||
self._tools = tools
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._tools)
|
||||
|
||||
normalized = normalize_tools([ToolBundle([bundled]), standalone])
|
||||
|
||||
assert len(normalized) == 2
|
||||
assert normalized[0] is bundled
|
||||
assert normalized[1] is standalone
|
||||
|
||||
|
||||
def test_normalize_tools_flattens_nested_bundles() -> None:
|
||||
"""Bundles inside bundles are flattened recursively via the recursive call."""
|
||||
from agent_framework._tools import normalize_tools
|
||||
|
||||
inner = _make_flatten_function_tool("deep")
|
||||
|
||||
class ToolBundle:
|
||||
def __init__(self, tools: list[Any]) -> None:
|
||||
self._tools = tools
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._tools)
|
||||
|
||||
nested = ToolBundle([ToolBundle([inner])])
|
||||
|
||||
normalized = normalize_tools([nested])
|
||||
|
||||
assert len(normalized) == 1
|
||||
assert normalized[0] is inner
|
||||
|
||||
|
||||
def test_normalize_tools_bundle_only_form() -> None:
|
||||
"""Passing a bundle directly (no outer list) also flattens its contents.
|
||||
|
||||
``tools=bundle`` — the outer wrap-in-list happens in the non-Sequence
|
||||
branch, then the flattening logic kicks in on the inner pass.
|
||||
"""
|
||||
from agent_framework._tools import normalize_tools
|
||||
|
||||
a = _make_flatten_function_tool("a")
|
||||
b = _make_flatten_function_tool("b")
|
||||
|
||||
class ToolBundle:
|
||||
def __init__(self, tools: list[FunctionTool]) -> None:
|
||||
self._tools = tools
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._tools)
|
||||
|
||||
normalized = normalize_tools(ToolBundle([a, b])) # type: ignore[arg-type]
|
||||
|
||||
assert len(normalized) == 2
|
||||
assert normalized[0] is a
|
||||
assert normalized[1] is b
|
||||
|
||||
|
||||
def test_normalize_tools_does_not_flatten_known_tool_types() -> None:
|
||||
"""FunctionTool / dict / callable are detected before the flatten branch."""
|
||||
from agent_framework._tools import normalize_tools
|
||||
|
||||
func_tool = _make_flatten_function_tool("ft")
|
||||
dict_tool: dict[str, Any] = {"type": "code_interpreter", "container": {"type": "auto"}}
|
||||
|
||||
def plain_callable(x: int) -> int:
|
||||
return x
|
||||
|
||||
normalized = normalize_tools([func_tool, dict_tool, plain_callable])
|
||||
|
||||
assert len(normalized) == 3
|
||||
assert normalized[0] is func_tool
|
||||
assert normalized[1] is dict_tool
|
||||
# plain_callable was wrapped in a FunctionTool via the @tool helper
|
||||
assert isinstance(normalized[2], FunctionTool)
|
||||
|
||||
|
||||
def test_normalize_tools_flattens_mapping_like_toolbox_with_tools_attr() -> None:
|
||||
"""Mapping-like toolbox objects with ``.tools`` should still flatten."""
|
||||
from collections.abc import Mapping as MappingABC
|
||||
|
||||
from agent_framework._tools import normalize_tools
|
||||
|
||||
bundled = _make_flatten_function_tool("bundled")
|
||||
standalone = _make_flatten_function_tool("standalone")
|
||||
|
||||
class ToolBundleMapping(MappingABC[str, Any]):
|
||||
def __init__(self, tools: list[FunctionTool]) -> None:
|
||||
self.tools = tools
|
||||
self._data = {"name": "research_tools", "version": "v1", "tools": tools}
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self._data[key]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._data)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._data)
|
||||
|
||||
normalized = normalize_tools([ToolBundleMapping([bundled]), standalone])
|
||||
|
||||
assert len(normalized) == 2
|
||||
assert normalized[0] is bundled
|
||||
assert normalized[1] is standalone
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -664,6 +664,21 @@ def test_function_approval_serialization_roundtrip():
|
||||
# The Content union will need to be handled differently when we fully migrate
|
||||
|
||||
|
||||
def test_function_approval_request_function_call_none_guard():
|
||||
"""Test that accessing function_call attributes is safe when function_call is None."""
|
||||
# Construct a Content with type "function_approval_request" but no function_call.
|
||||
# This verifies the None-guard pattern used in samples to prevent AttributeError.
|
||||
content = Content("function_approval_request", id="req-none")
|
||||
assert content.function_call is None
|
||||
|
||||
# A proper approval request always has function_call set
|
||||
fc = Content.from_function_call(call_id="call-1", name="do_something", arguments={"a": 1})
|
||||
req = Content.from_function_approval_request(id="req-1", function_call=fc)
|
||||
assert req.function_call is not None
|
||||
assert req.function_call.name == "do_something"
|
||||
assert req.function_call.arguments == {"a": 1}
|
||||
|
||||
|
||||
def test_function_approval_accepts_mcp_call():
|
||||
"""Ensure FunctionApprovalRequestContent supports MCP server tool calls."""
|
||||
mcp_call = Content.from_mcp_server_tool_call(
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260414"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
"opentelemetry-sdk>=1.39.0,<2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"durabletask>=1.3.0,<2",
|
||||
"durabletask-azuremanaged>=1.3.0,<2",
|
||||
"python-dateutil>=2.8.0,<3",
|
||||
|
||||
@@ -1,3 +1,66 @@
|
||||
# Agent Framework Foundry
|
||||
|
||||
This package contains the Microsoft Foundry integrations for Microsoft Agent Framework, including Foundry chat clients, preconfigured Foundry agents, Foundry embedding clients, and Foundry memory providers.
|
||||
|
||||
## Toolboxes
|
||||
|
||||
A *toolbox* is a named, versioned bundle of hosted tool configurations — code interpreter, file search, image generation, MCP, web search, and so on — stored inside a Microsoft Foundry project. Toolboxes let you manage tool configuration once and reuse it across agents.
|
||||
|
||||
### Authoring a toolbox
|
||||
|
||||
Toolboxes can be authored two ways:
|
||||
|
||||
- **Foundry portal** — create and version toolboxes through the UI without touching code.
|
||||
- **Programmatically** — use the [`azure-ai-projects`](https://pypi.org/project/azure-ai-projects/) SDK to create, update, and version toolboxes from Python.
|
||||
|
||||
> Toolbox authoring APIs (`ToolboxVersionObject`, `ToolboxObject`, `project_client.beta.toolboxes.*`) require `azure-ai-projects>=2.1.0`. Earlier versions can only consume toolboxes that already exist.
|
||||
|
||||
### Using toolboxes with `FoundryAgent`
|
||||
|
||||
For hosted `FoundryAgent`, the toolbox must already be attached to the agent in the Microsoft Foundry project. Once attached, the agent invokes its toolbox tools transparently — no client-side wiring required — and you interact with the agent the same way you would with any other tool-equipped Foundry agent.
|
||||
|
||||
### Using toolboxes with `FoundryChatClient`
|
||||
|
||||
There are two patterns for wiring a toolbox into a `FoundryChatClient`-backed agent.
|
||||
|
||||
**1. Fetch, optionally filter, and pass the tools directly**
|
||||
|
||||
Load the toolbox from the Microsoft Foundry project, optionally select a subset of its tools, and hand them to an `Agent` alongside any other tools you own:
|
||||
|
||||
```python
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient, select_toolbox_tools
|
||||
|
||||
client = FoundryChatClient(...)
|
||||
toolbox = await client.get_toolbox("my-toolbox", version="3")
|
||||
|
||||
# Pass the whole toolbox:
|
||||
agent = Agent(client=client, tools=toolbox)
|
||||
|
||||
# Or filter to a subset first:
|
||||
selected = select_toolbox_tools(toolbox, include_types=["code_interpreter", "mcp"])
|
||||
agent = Agent(client=client, tools=selected)
|
||||
```
|
||||
|
||||
See [`foundry_chat_client_with_toolbox.py`](../../samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py) for a full example, including combining multiple toolboxes.
|
||||
|
||||
**2. Connect to the toolbox's MCP endpoint with `MCPStreamableHTTPTool`**
|
||||
|
||||
Each toolbox is reachable as an MCP server. Instead of fetching and fanning out its individual tool definitions, you can point a MAF `MCPStreamableHTTPTool` at the toolbox's MCP endpoint — the agent then discovers and calls its tools over MCP at runtime:
|
||||
|
||||
```python
|
||||
from agent_framework import Agent, MCPStreamableHTTPTool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
|
||||
async with Agent(
|
||||
client=FoundryChatClient(...),
|
||||
instructions="You are a helpful assistant. Use the toolbox tools when useful.",
|
||||
tools=MCPStreamableHTTPTool(
|
||||
name="my_toolbox",
|
||||
description="Tools served by my Foundry toolbox",
|
||||
url="https://<your-toolbox-mcp-endpoint>",
|
||||
),
|
||||
) as agent:
|
||||
result = await agent.run("What tools are available?")
|
||||
print(result.text)
|
||||
```
|
||||
|
||||
@@ -16,6 +16,7 @@ from ._foundry_evals import (
|
||||
evaluate_traces,
|
||||
)
|
||||
from ._memory_provider import FoundryMemoryProvider
|
||||
from ._tools import FoundryHostedToolType, get_toolbox_tool_name, get_toolbox_tool_type, select_toolbox_tools
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
@@ -30,6 +31,7 @@ __all__ = [
|
||||
"FoundryEmbeddingOptions",
|
||||
"FoundryEmbeddingSettings",
|
||||
"FoundryEvals",
|
||||
"FoundryHostedToolType",
|
||||
"FoundryMemoryProvider",
|
||||
"RawFoundryAgent",
|
||||
"RawFoundryAgentChatClient",
|
||||
@@ -38,4 +40,7 @@ __all__ = [
|
||||
"__version__",
|
||||
"evaluate_foundry_target",
|
||||
"evaluate_traces",
|
||||
"get_toolbox_tool_name",
|
||||
"get_toolbox_tool_type",
|
||||
"select_toolbox_tools",
|
||||
]
|
||||
|
||||
@@ -34,6 +34,8 @@ from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from ._tools import sanitize_foundry_response_tool
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # type: ignore # pragma: no cover
|
||||
else:
|
||||
@@ -307,6 +309,20 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
"""Skip model check — model is configured on the Foundry agent."""
|
||||
pass
|
||||
|
||||
@override
|
||||
def _prepare_tools_for_openai(
|
||||
self,
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
|
||||
) -> list[Any]:
|
||||
"""Prepare tools for Foundry agent Responses API calls.
|
||||
|
||||
Mirrors ``RawFoundryChatClient`` sanitization so toolbox-fetched MCP
|
||||
tools with extra read-model fields continue to work through the agent
|
||||
surface.
|
||||
"""
|
||||
response_tools = super()._prepare_tools_for_openai(tools)
|
||||
return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
|
||||
|
||||
def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]:
|
||||
"""Extract system/developer messages as instructions for Azure AI.
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from agent_framework import (
|
||||
load_settings,
|
||||
)
|
||||
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from agent_framework._feature_stage import ExperimentalFeature, experimental
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
@@ -32,6 +33,8 @@ from azure.ai.projects.models import MCPTool as FoundryMCPTool
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from ._tools import fetch_toolbox, sanitize_foundry_response_tool
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # type: ignore # pragma: no cover
|
||||
else:
|
||||
@@ -46,7 +49,8 @@ else:
|
||||
from typing_extensions import TypedDict # type: ignore # pragma: no cover
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import ChatAndFunctionMiddlewareTypes
|
||||
from agent_framework import ChatAndFunctionMiddlewareTypes, ToolTypes
|
||||
from azure.ai.projects.models import ToolboxVersionObject
|
||||
|
||||
logger: logging.Logger = logging.getLogger("agent_framework.foundry")
|
||||
|
||||
@@ -218,6 +222,21 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
raise ValueError("model must be a non-empty string")
|
||||
options["model"] = self.model
|
||||
|
||||
@override
|
||||
def _prepare_tools_for_openai(
|
||||
self,
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
|
||||
) -> list[Any]:
|
||||
"""Prepare tools for Foundry Responses API calls.
|
||||
|
||||
Foundry toolbox reads can surface MCP tool objects with extra fields
|
||||
(for example ``name``) that are accepted by the toolbox API but rejected
|
||||
by the Responses API. Sanitize those hosted-tool payloads before sending
|
||||
them downstream.
|
||||
"""
|
||||
response_tools = super()._prepare_tools_for_openai(tools)
|
||||
return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
|
||||
|
||||
async def configure_azure_monitor(
|
||||
self,
|
||||
enable_sensitive_data: bool = False,
|
||||
@@ -460,6 +479,37 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
|
||||
# endregion
|
||||
|
||||
# region Toolbox methods (instance methods — these hit the network)
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
|
||||
async def get_toolbox(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
) -> ToolboxVersionObject:
|
||||
"""Fetch a Foundry toolbox by name.
|
||||
|
||||
If ``version`` is omitted, resolves the toolbox's current default version
|
||||
(two requests). If ``version`` is specified, fetches that version directly
|
||||
(single request).
|
||||
|
||||
Args:
|
||||
name: The name of the toolbox.
|
||||
|
||||
Keyword Args:
|
||||
version: Optional immutable version identifier to pin to.
|
||||
|
||||
Returns:
|
||||
A ``ToolboxVersionObject``. Pass its ``tools`` attribute to
|
||||
``Agent(tools=toolbox.tools)``.
|
||||
|
||||
Raises:
|
||||
azure.core.exceptions.ResourceNotFoundError: If the toolbox or
|
||||
the requested version does not exist.
|
||||
"""
|
||||
return await fetch_toolbox(self.project_client, name, version)
|
||||
|
||||
|
||||
class FoundryChatClient( # type: ignore[misc]
|
||||
FunctionInvocationLayer[FoundryChatOptionsT],
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Shared tool helpers for Foundry chat clients.
|
||||
|
||||
Includes:
|
||||
|
||||
* *Toolbox* helpers — a *toolbox* is a named, versioned bundle of tool
|
||||
definitions stored in an Azure AI Foundry project.
|
||||
* Responses-API payload sanitization for Foundry hosted tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Collection, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast
|
||||
|
||||
from agent_framework._feature_stage import ExperimentalFeature, experimental
|
||||
from azure.ai.projects.models import MCPTool as FoundryMCPTool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import Tool, ToolboxVersionObject
|
||||
|
||||
FoundryHostedToolType: TypeAlias = (
|
||||
Literal[
|
||||
"code_interpreter",
|
||||
"file_search",
|
||||
"image_generation",
|
||||
"mcp",
|
||||
"web_search",
|
||||
]
|
||||
| str
|
||||
)
|
||||
ToolboxToolSelectionInput: TypeAlias = "ToolboxVersionObject | Sequence[Tool | dict[str, Any]]"
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
|
||||
async def fetch_toolbox(
|
||||
project_client: AIProjectClient,
|
||||
name: str,
|
||||
version: str | None = None,
|
||||
) -> ToolboxVersionObject:
|
||||
"""Fetch a toolbox version via an ``AIProjectClient``.
|
||||
|
||||
If ``version`` is omitted, resolves the toolbox's current default
|
||||
version (two requests: one to ``.get(name)`` for the default version
|
||||
pointer, one to ``.get_version(name, version)`` for the tools). If
|
||||
``version`` is specified, fetches that version directly (single request).
|
||||
"""
|
||||
if version is None:
|
||||
handle = await project_client.beta.toolboxes.get(name)
|
||||
version = handle.default_version
|
||||
return await project_client.beta.toolboxes.get_version(name, version)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
|
||||
def get_toolbox_tool_name(tool: Tool | dict[str, Any]) -> str | None:
|
||||
"""Return the best-effort display/selection name for a toolbox tool.
|
||||
|
||||
Selection precedence:
|
||||
1. MCP ``server_label``
|
||||
2. Generic tool ``name``
|
||||
3. Tool ``type``
|
||||
"""
|
||||
if isinstance(tool, dict):
|
||||
if server_label := tool.get("server_label"):
|
||||
return str(server_label)
|
||||
if name := tool.get("name"):
|
||||
return str(name)
|
||||
if tool_type := tool.get("type"):
|
||||
return str(tool_type)
|
||||
return None
|
||||
|
||||
if server_label := getattr(tool, "server_label", None):
|
||||
return str(server_label)
|
||||
if name := getattr(tool, "name", None):
|
||||
return str(name)
|
||||
if tool_type := getattr(tool, "type", None):
|
||||
return str(tool_type)
|
||||
return None
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
|
||||
def get_toolbox_tool_type(tool: Tool | dict[str, Any]) -> str | None:
|
||||
"""Return the raw tool ``type`` if present."""
|
||||
tool_type = tool.get("type") if isinstance(tool, dict) else getattr(tool, "type", None)
|
||||
return str(tool_type) if tool_type is not None else None
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
|
||||
def select_toolbox_tools(
|
||||
tools: ToolboxToolSelectionInput,
|
||||
*,
|
||||
include_names: Collection[str] | None = None,
|
||||
exclude_names: Collection[str] | None = None,
|
||||
include_types: Collection[FoundryHostedToolType] | None = None,
|
||||
exclude_types: Collection[FoundryHostedToolType] | None = None,
|
||||
predicate: Callable[[Tool | dict[str, Any]], bool] | None = None,
|
||||
) -> list[Tool | dict[str, Any]]:
|
||||
"""Filter toolbox tools by normalized name, raw type, and/or predicate.
|
||||
|
||||
Normalized name precedence:
|
||||
1. ``server_label`` for MCP tools
|
||||
2. ``name``
|
||||
3. ``type``
|
||||
"""
|
||||
tool_items: Sequence[Tool | dict[str, Any]] = (
|
||||
tools if isinstance(tools, Sequence) else cast("Sequence[Tool | dict[str, Any]]", tools.tools)
|
||||
)
|
||||
include_name_set = {str(item) for item in include_names} if include_names is not None else None
|
||||
exclude_name_set = {str(item) for item in exclude_names} if exclude_names is not None else None
|
||||
include_type_set = {str(item) for item in include_types} if include_types is not None else None
|
||||
exclude_type_set = {str(item) for item in exclude_types} if exclude_types is not None else None
|
||||
|
||||
selected: list[Tool | dict[str, Any]] = []
|
||||
for tool in tool_items:
|
||||
tool_name = get_toolbox_tool_name(tool)
|
||||
tool_type = get_toolbox_tool_type(tool)
|
||||
|
||||
if include_name_set is not None and tool_name not in include_name_set:
|
||||
continue
|
||||
if exclude_name_set is not None and tool_name in exclude_name_set:
|
||||
continue
|
||||
if include_type_set is not None and tool_type not in include_type_set:
|
||||
continue
|
||||
if exclude_type_set is not None and tool_type in exclude_type_set:
|
||||
continue
|
||||
if predicate is not None and not predicate(tool):
|
||||
continue
|
||||
|
||||
selected.append(tool)
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
|
||||
def sanitize_foundry_response_tool(tool_item: Any) -> Any:
|
||||
"""Return a Responses-API-safe tool payload for Foundry hosted tools.
|
||||
|
||||
Azure AI Projects toolbox reads can currently return hosted tool objects with
|
||||
extra read-model decoration fields such as top-level ``name`` and
|
||||
``description``. Azure AI Foundry rejects at least ``name`` on Responses API
|
||||
requests with:
|
||||
|
||||
``Unknown parameter: 'tools[0].name'``.
|
||||
|
||||
We defensively strip these decoration fields for non-function hosted tools so
|
||||
the round-trip
|
||||
``toolbox.tools -> Agent(..., tools=...) -> run()`` works, while the Azure
|
||||
SDK/service behavior is corrected upstream.
|
||||
"""
|
||||
if isinstance(tool_item, FoundryMCPTool):
|
||||
sanitized: dict[str, Any] = dict(cast("Mapping[str, Any]", tool_item))
|
||||
sanitized.pop("name", None)
|
||||
sanitized.pop("description", None)
|
||||
return sanitized
|
||||
|
||||
if isinstance(tool_item, Mapping):
|
||||
mapping = cast("Mapping[str, Any]", tool_item)
|
||||
if "type" in mapping and mapping.get("type") not in {"function", "custom"}:
|
||||
sanitized = dict(mapping)
|
||||
sanitized.pop("name", None)
|
||||
sanitized.pop("description", None)
|
||||
return sanitized
|
||||
|
||||
return cast(Any, tool_item)
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,10 +23,10 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-openai>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"agent-framework-openai>=1.1.0,<2",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-ai-projects>=2.0.0,<3.0",
|
||||
"azure-ai-projects>=2.1.0,<3.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -15,6 +15,7 @@ from agent_framework import ChatResponse, Content, Message, SupportsChatGetRespo
|
||||
from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT
|
||||
from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException
|
||||
from agent_framework_openai import OpenAIContentFilterException
|
||||
from azure.ai.projects.models import MCPTool as FoundryMCPTool
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from azure.identity import AzureCliCredential
|
||||
from openai import BadRequestError
|
||||
@@ -608,6 +609,82 @@ def test_get_mcp_tool_with_project_connection_id() -> None:
|
||||
assert tool_config["server_label"] == "Docs_MCP"
|
||||
|
||||
|
||||
def test_prepare_tools_for_openai_strips_extraneous_name_from_foundry_mcp_tool() -> None:
|
||||
"""Toolbox-returned MCP tools may carry ``name``; Foundry Responses rejects it."""
|
||||
project_client = MagicMock()
|
||||
project_client.get_openai_client.return_value = _make_mock_openai_client()
|
||||
client = FoundryChatClient(project_client=project_client, model="test-model")
|
||||
|
||||
tool = FoundryMCPTool(
|
||||
server_label="githubmcp",
|
||||
server_url="https://api.githubcopilot.com/mcp",
|
||||
)
|
||||
tool["project_connection_id"] = "githubmcp"
|
||||
tool["name"] = "githubmcp"
|
||||
|
||||
response_tools = client._prepare_tools_for_openai([tool])
|
||||
|
||||
assert len(response_tools) == 1
|
||||
prepared = response_tools[0]
|
||||
assert prepared["type"] == "mcp"
|
||||
assert prepared["server_label"] == "githubmcp"
|
||||
assert prepared["project_connection_id"] == "githubmcp"
|
||||
assert "name" not in prepared
|
||||
|
||||
|
||||
def test_prepare_tools_for_openai_strips_read_model_fields_from_toolbox_code_interpreter() -> None:
|
||||
"""Toolbox-returned code interpreter tools may carry read-model-only name/description."""
|
||||
project_client = MagicMock()
|
||||
project_client.get_openai_client.return_value = _make_mock_openai_client()
|
||||
client = FoundryChatClient(project_client=project_client, model="test-model")
|
||||
|
||||
tool = {
|
||||
"type": "code_interpreter",
|
||||
"name": "code_interpreter_t6bbtm",
|
||||
"description": "Toolbox read model description",
|
||||
"container": {"file_ids": [], "type": "auto"},
|
||||
}
|
||||
|
||||
response_tools = client._prepare_tools_for_openai([tool])
|
||||
|
||||
assert len(response_tools) == 1
|
||||
prepared = response_tools[0]
|
||||
assert prepared["type"] == "code_interpreter"
|
||||
assert prepared["container"] == {"file_ids": [], "type": "auto"}
|
||||
assert "name" not in prepared
|
||||
assert "description" not in prepared
|
||||
|
||||
|
||||
def test_prepare_tools_for_openai_strips_name_from_non_function_hosted_tool_dicts() -> None:
|
||||
"""All non-function hosted tool payloads should drop top-level read-model names."""
|
||||
project_client = MagicMock()
|
||||
project_client.get_openai_client.return_value = _make_mock_openai_client()
|
||||
client = FoundryChatClient(project_client=project_client, model="test-model")
|
||||
|
||||
response_tools = client._prepare_tools_for_openai([
|
||||
{
|
||||
"type": "file_search",
|
||||
"name": "file_search_tool_123",
|
||||
"description": "toolbox decoration",
|
||||
"vector_store_ids": ["vs_123"],
|
||||
},
|
||||
{
|
||||
"type": "web_search",
|
||||
"name": "web_search_tool_456",
|
||||
"description": "toolbox decoration",
|
||||
},
|
||||
])
|
||||
|
||||
assert len(response_tools) == 2
|
||||
assert response_tools[0]["type"] == "file_search"
|
||||
assert response_tools[0]["vector_store_ids"] == ["vs_123"]
|
||||
assert "name" not in response_tools[0]
|
||||
assert "description" not in response_tools[0]
|
||||
assert response_tools[1]["type"] == "web_search"
|
||||
assert "name" not in response_tools[1]
|
||||
assert "description" not in response_tools[1]
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_integration_tests_disabled
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for toolbox helpers on FoundryChatClient.
|
||||
|
||||
Return types are the raw azure-ai-projects SDK models (ToolboxVersionObject,
|
||||
ToolboxObject) — no custom wrapper. Tests verify the chat-client get path and
|
||||
tool-selection ergonomics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from azure.ai.projects.models import (
|
||||
AutoCodeInterpreterToolParam,
|
||||
CodeInterpreterTool,
|
||||
Tool,
|
||||
ToolboxObject,
|
||||
ToolboxVersionObject,
|
||||
)
|
||||
except ImportError:
|
||||
pytest.skip(
|
||||
"Toolbox types require azure-ai-projects>=2.1.0 (unreleased).",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _AsyncIter:
|
||||
"""Minimal async-iterable for mocking ``AsyncItemPaged`` in tests."""
|
||||
|
||||
def __init__(self, items: list[Any]) -> None:
|
||||
self._items = items
|
||||
|
||||
def __aiter__(self) -> _AsyncIter:
|
||||
self._iter = iter(self._items)
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> Any:
|
||||
try:
|
||||
return next(self._iter)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration from None
|
||||
|
||||
|
||||
def _make_code_interpreter() -> CodeInterpreterTool:
|
||||
return CodeInterpreterTool(container=AutoCodeInterpreterToolParam())
|
||||
|
||||
|
||||
def _make_version_object(
|
||||
*,
|
||||
name: str = "research_tools",
|
||||
version: str = "v1",
|
||||
tools: list[Tool] | None = None,
|
||||
description: str | None = None,
|
||||
) -> ToolboxVersionObject:
|
||||
return ToolboxVersionObject(
|
||||
id=f"tbv_{name}_{version}",
|
||||
name=name,
|
||||
version=version,
|
||||
metadata={},
|
||||
created_at=dt.datetime(2026, 4, 10, tzinfo=dt.timezone.utc),
|
||||
tools=tools if tools is not None else [_make_code_interpreter()],
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_foundry_client(*, project_client: MagicMock) -> Any:
|
||||
"""Build a FoundryChatClient wired to a mock project_client."""
|
||||
from agent_framework_foundry import FoundryChatClient
|
||||
|
||||
project_client.get_openai_client = MagicMock(return_value=MagicMock())
|
||||
return FoundryChatClient(project_client=project_client, model="test-model")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# get_toolbox — explicit version path #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_get_toolbox_with_explicit_version_makes_single_request() -> None:
|
||||
project_client = MagicMock()
|
||||
version_obj = _make_version_object(name="research_tools", version="v3")
|
||||
project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj)
|
||||
project_client.beta.toolboxes.get = AsyncMock(
|
||||
side_effect=AssertionError("get() must not be called when version is explicit")
|
||||
)
|
||||
|
||||
client = _make_mock_foundry_client(project_client=project_client)
|
||||
|
||||
toolbox = await client.get_toolbox("research_tools", version="v3")
|
||||
|
||||
assert isinstance(toolbox, ToolboxVersionObject)
|
||||
assert toolbox.name == "research_tools"
|
||||
assert toolbox.version == "v3"
|
||||
project_client.beta.toolboxes.get_version.assert_awaited_once_with("research_tools", "v3")
|
||||
project_client.beta.toolboxes.get.assert_not_called()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# get_toolbox — default-version path + error + passthrough + smoke #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_get_toolbox_default_version_resolves_then_fetches() -> None:
|
||||
project_client = MagicMock()
|
||||
handle = ToolboxObject(id="tb_1", name="research_tools", default_version="v5")
|
||||
version_obj = _make_version_object(name="research_tools", version="v5")
|
||||
|
||||
project_client.beta.toolboxes.get = AsyncMock(return_value=handle)
|
||||
project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj)
|
||||
|
||||
client = _make_mock_foundry_client(project_client=project_client)
|
||||
|
||||
toolbox = await client.get_toolbox("research_tools")
|
||||
|
||||
assert toolbox.version == "v5"
|
||||
project_client.beta.toolboxes.get.assert_awaited_once_with("research_tools")
|
||||
project_client.beta.toolboxes.get_version.assert_awaited_once_with("research_tools", "v5")
|
||||
|
||||
|
||||
async def test_get_toolbox_propagates_resource_not_found() -> None:
|
||||
project_client = MagicMock()
|
||||
project_client.beta.toolboxes.get = AsyncMock(side_effect=ResourceNotFoundError("no such toolbox"))
|
||||
|
||||
client = _make_mock_foundry_client(project_client=project_client)
|
||||
|
||||
with pytest.raises(ResourceNotFoundError):
|
||||
await client.get_toolbox("missing_toolbox")
|
||||
|
||||
|
||||
async def test_get_toolbox_tool_passthrough_preserves_heterogeneous_types() -> None:
|
||||
"""Ensure all Tool subclasses pass through unchanged — critical for MCP tools
|
||||
with project_connection_id, which must reach the runtime untouched."""
|
||||
from azure.ai.projects.models import MCPTool as FoundryMCPTool
|
||||
|
||||
mcp_tool = FoundryMCPTool(
|
||||
server_label="github_oauth",
|
||||
server_url="https://api.githubcopilot.com/mcp",
|
||||
)
|
||||
mcp_tool["project_connection_id"] = "conn_abc"
|
||||
|
||||
project_client = MagicMock()
|
||||
version_obj = _make_version_object(
|
||||
name="mixed",
|
||||
version="v1",
|
||||
tools=[_make_code_interpreter(), mcp_tool],
|
||||
)
|
||||
project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj)
|
||||
|
||||
client = _make_mock_foundry_client(project_client=project_client)
|
||||
|
||||
toolbox = await client.get_toolbox("mixed", version="v1")
|
||||
|
||||
assert len(toolbox.tools) == 2
|
||||
assert isinstance(toolbox.tools[0], CodeInterpreterTool)
|
||||
assert isinstance(toolbox.tools[1], FoundryMCPTool)
|
||||
assert toolbox.tools[1]["project_connection_id"] == "conn_abc"
|
||||
|
||||
|
||||
async def test_toolbox_tools_can_be_passed_to_agent() -> None:
|
||||
"""Integration smoke: toolbox.tools can be passed directly to Agent(tools=...) ."""
|
||||
from agent_framework import Agent
|
||||
|
||||
project_client = MagicMock()
|
||||
version_obj = _make_version_object(name="research_tools", version="v1", tools=[_make_code_interpreter()])
|
||||
project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj)
|
||||
|
||||
client = _make_mock_foundry_client(project_client=project_client)
|
||||
|
||||
toolbox = await client.get_toolbox("research_tools", version="v1")
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="You are a test agent.",
|
||||
tools=toolbox.tools,
|
||||
)
|
||||
|
||||
agent_tools = agent.default_options["tools"]
|
||||
assert len(agent_tools) == 1
|
||||
assert agent_tools[0]["type"] == "code_interpreter"
|
||||
|
||||
|
||||
async def test_multiple_toolbox_tool_lists_can_be_combined_in_agent() -> None:
|
||||
"""Nested toolbox ``.tools`` lists flatten into one tool list on Agent construction."""
|
||||
from agent_framework import Agent
|
||||
|
||||
project_client = MagicMock()
|
||||
project_client.get_openai_client = MagicMock(return_value=MagicMock())
|
||||
client = _make_mock_foundry_client(project_client=project_client)
|
||||
|
||||
toolbox_a = _make_version_object(name="research_tools", version="v1", tools=[_make_code_interpreter()])
|
||||
toolbox_b = _make_version_object(name="some_other_tools", version="v3", tools=[_make_code_interpreter()])
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="You are a test agent.",
|
||||
tools=[toolbox_a.tools, toolbox_b.tools],
|
||||
)
|
||||
|
||||
agent_tools = agent.default_options["tools"]
|
||||
assert len(agent_tools) == 2
|
||||
assert agent_tools[0]["type"] == "code_interpreter"
|
||||
assert agent_tools[1]["type"] == "code_interpreter"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# toolbox tool selection helpers #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_get_toolbox_tool_name_prefers_server_label_then_name_then_type() -> None:
|
||||
from azure.ai.projects.models import MCPTool as FoundryMCPTool
|
||||
|
||||
from agent_framework_foundry import get_toolbox_tool_name
|
||||
|
||||
mcp_tool = FoundryMCPTool(
|
||||
server_label="githubmcp",
|
||||
server_url="https://api.githubcopilot.com/mcp",
|
||||
)
|
||||
assert get_toolbox_tool_name(mcp_tool) == "githubmcp"
|
||||
|
||||
named_tool = {"type": "code_interpreter", "name": "ci_tool"}
|
||||
assert get_toolbox_tool_name(named_tool) == "ci_tool"
|
||||
|
||||
unnamed_tool = {"type": "web_search"}
|
||||
assert get_toolbox_tool_name(unnamed_tool) == "web_search"
|
||||
|
||||
|
||||
def test_select_toolbox_tools_filters_by_names() -> None:
|
||||
from azure.ai.projects.models import MCPTool as FoundryMCPTool
|
||||
|
||||
from agent_framework_foundry import select_toolbox_tools
|
||||
|
||||
tools: list[Tool | dict[str, Any]] = [
|
||||
FoundryMCPTool(server_label="githubmcp", server_url="https://api.githubcopilot.com/mcp"),
|
||||
{"type": "code_interpreter", "name": "python_runner"},
|
||||
{"type": "web_search"},
|
||||
]
|
||||
|
||||
selected = select_toolbox_tools(tools, include_names=["githubmcp", "python_runner"])
|
||||
|
||||
assert len(selected) == 2
|
||||
assert selected[0] is tools[0]
|
||||
assert selected[1] is tools[1]
|
||||
|
||||
|
||||
def test_select_toolbox_tools_filters_by_typed_tool_types() -> None:
|
||||
from agent_framework_foundry import select_toolbox_tools
|
||||
|
||||
tools: list[Tool | dict[str, Any]] = [
|
||||
{"type": "mcp", "server_label": "githubmcp"},
|
||||
{"type": "code_interpreter", "name": "python_runner"},
|
||||
{"type": "web_search"},
|
||||
]
|
||||
|
||||
selected = select_toolbox_tools(tools, include_types=["mcp", "code_interpreter"])
|
||||
|
||||
assert len(selected) == 2
|
||||
assert selected[0]["type"] == "mcp"
|
||||
assert selected[1]["type"] == "code_interpreter"
|
||||
|
||||
|
||||
def test_select_toolbox_tools_accepts_toolbox_object_directly() -> None:
|
||||
from agent_framework_foundry import select_toolbox_tools
|
||||
|
||||
toolbox = _make_version_object(
|
||||
name="research_tools",
|
||||
version="v1",
|
||||
tools=[
|
||||
{"type": "mcp", "server_label": "githubmcp"}, # type: ignore[list-item]
|
||||
{"type": "code_interpreter", "name": "python_runner"}, # type: ignore[list-item]
|
||||
{"type": "web_search"}, # type: ignore[list-item]
|
||||
],
|
||||
)
|
||||
|
||||
selected = select_toolbox_tools(toolbox, include_types=["mcp", "code_interpreter"])
|
||||
|
||||
assert len(selected) == 2
|
||||
assert selected[0]["type"] == "mcp"
|
||||
assert selected[1]["type"] == "code_interpreter"
|
||||
|
||||
|
||||
async def test_fetched_toolbox_can_be_combined_with_function_tool() -> None:
|
||||
from agent_framework import Agent, FunctionTool, tool
|
||||
|
||||
project_client = MagicMock()
|
||||
version_obj = _make_version_object(name="research_tools", version="v1", tools=[_make_code_interpreter()])
|
||||
project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj)
|
||||
|
||||
client = _make_mock_foundry_client(project_client=project_client)
|
||||
toolbox = await client.get_toolbox("research_tools", version="v1")
|
||||
|
||||
@tool(name="local_lookup", description="A local helper tool")
|
||||
def local_lookup(query: str) -> str:
|
||||
return query
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="You are a test agent.",
|
||||
tools=[toolbox, local_lookup],
|
||||
)
|
||||
|
||||
agent_tools = agent.default_options["tools"]
|
||||
assert len(agent_tools) == 2
|
||||
assert agent_tools[0]["type"] == "code_interpreter"
|
||||
assert isinstance(agent_tools[1], FunctionTool)
|
||||
assert agent_tools[1].name == "local_lookup"
|
||||
|
||||
|
||||
def test_select_toolbox_tools_supports_excludes_and_predicate() -> None:
|
||||
from agent_framework_foundry import select_toolbox_tools
|
||||
|
||||
tools: list[Tool | dict[str, Any]] = [
|
||||
{"type": "mcp", "server_label": "githubmcp"},
|
||||
{"type": "mcp", "server_label": "learnmcp"},
|
||||
{"type": "web_search"},
|
||||
]
|
||||
|
||||
selected = select_toolbox_tools(
|
||||
tools,
|
||||
exclude_names=["learnmcp"],
|
||||
predicate=lambda tool: tool.get("type") == "mcp", # type: ignore[union-attr]
|
||||
)
|
||||
|
||||
assert len(selected) == 1
|
||||
assert selected[0]["server_label"] == "githubmcp"
|
||||
|
||||
|
||||
async def test_selected_toolbox_subset_can_be_combined_with_function_tool() -> None:
|
||||
from agent_framework import Agent, FunctionTool, tool
|
||||
|
||||
from agent_framework_foundry import select_toolbox_tools
|
||||
|
||||
project_client = MagicMock()
|
||||
version_obj = _make_version_object(
|
||||
name="research_tools",
|
||||
version="v1",
|
||||
tools=[
|
||||
{"type": "mcp", "server_label": "githubmcp"}, # type: ignore[list-item]
|
||||
{"type": "code_interpreter", "name": "python_runner"}, # type: ignore[list-item]
|
||||
{"type": "web_search"}, # type: ignore[list-item]
|
||||
],
|
||||
)
|
||||
project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj)
|
||||
|
||||
client = _make_mock_foundry_client(project_client=project_client)
|
||||
toolbox = await client.get_toolbox("research_tools", version="v1")
|
||||
selected_tools = select_toolbox_tools(toolbox, include_types=["mcp", "code_interpreter"])
|
||||
|
||||
@tool(name="local_lookup", description="A local helper tool")
|
||||
def local_lookup(query: str) -> str:
|
||||
return query
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="You are a test agent.",
|
||||
tools=[selected_tools, local_lookup],
|
||||
)
|
||||
|
||||
agent_tools = agent.default_options["tools"]
|
||||
assert len(agent_tools) == 3
|
||||
assert agent_tools[0]["type"] == "mcp"
|
||||
assert agent_tools[1]["type"] == "code_interpreter"
|
||||
assert isinstance(agent_tools[2], FunctionTool)
|
||||
assert agent_tools[2].name == "local_lookup"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Integration #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
skip_if_foundry_integration_tests_disabled = pytest.mark.skipif(
|
||||
os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/")
|
||||
or os.getenv("FOUNDRY_MODEL", "") == "",
|
||||
reason="No real FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_MODEL provided; skipping integration tests.",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_integration_tests_disabled
|
||||
async def test_integration_get_toolbox_round_trip_against_real_project() -> None:
|
||||
"""Create a toolbox via the raw SDK, fetch via FoundryChatClient, then delete.
|
||||
|
||||
Self-contained to avoid depending on toolboxes that may be cleaned up
|
||||
externally. Exercises both the default-version resolution path
|
||||
(``get`` + ``get_version``) and the explicit-version path.
|
||||
"""
|
||||
from uuid import uuid4
|
||||
|
||||
from agent_framework import Agent
|
||||
|
||||
from agent_framework_foundry import FoundryChatClient
|
||||
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
project_client = client.project_client
|
||||
|
||||
toolbox_name = f"af-int-toolbox-{uuid4().hex[:12]}"
|
||||
created = await project_client.beta.toolboxes.create_version(
|
||||
name=toolbox_name,
|
||||
tools=[CodeInterpreterTool()],
|
||||
description=f"{toolbox_name} integration test",
|
||||
)
|
||||
assert isinstance(created, ToolboxVersionObject)
|
||||
try:
|
||||
toolbox_default = await client.get_toolbox(toolbox_name)
|
||||
assert toolbox_default.name == toolbox_name
|
||||
assert toolbox_default.tools, "Default-version fetch returned no tools"
|
||||
|
||||
toolbox_pinned = await client.get_toolbox(toolbox_name, version=created.version)
|
||||
assert toolbox_pinned.version == created.version
|
||||
assert toolbox_pinned.tools
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="You are a test agent.",
|
||||
tools=toolbox_pinned.tools,
|
||||
)
|
||||
assert len(agent.default_options["tools"]) == len(toolbox_pinned.tools)
|
||||
finally:
|
||||
await project_client.beta.toolboxes.delete(toolbox_name)
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,3 @@
|
||||
# Foundry Hosting
|
||||
|
||||
This package provides the integration of Agent Framework agents and workflows with the Foundry Agent Server, which can be hosted on Foundry infrastructure.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._invocations import InvocationsHostServer
|
||||
from ._responses import ResponsesHostServer
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = ["InvocationsHostServer", "ResponsesHostServer"]
|
||||
@@ -0,0 +1,80 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework import AgentSession, BaseAgent, SupportsAgentRun
|
||||
from agent_framework._telemetry import user_agent_prefix
|
||||
from azure.ai.agentserver.invocations import InvocationAgentServerHost
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response, StreamingResponse
|
||||
from typing_extensions import Any, AsyncGenerator
|
||||
|
||||
|
||||
class InvocationsHostServer(InvocationAgentServerHost):
|
||||
"""An invocations server host for an agent."""
|
||||
|
||||
USER_AGENT_PREFIX = "foundry-hosting"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: BaseAgent,
|
||||
*,
|
||||
openapi_spec: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize an InvocationsHostServer.
|
||||
|
||||
Args:
|
||||
agent: The agent to handle responses for.
|
||||
openapi_spec: The OpenAPI specification for the server.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
This host will expect the request to be a JSON body with a "message" field.
|
||||
The response from the host will be a JSON object with a "response" field containing
|
||||
the agent's response and a "session_id" field containing the session ID.
|
||||
"""
|
||||
super().__init__(openapi_spec=openapi_spec, **kwargs)
|
||||
|
||||
if not isinstance(agent, SupportsAgentRun):
|
||||
raise TypeError("Agent must support the SupportsAgentRun interface")
|
||||
|
||||
self._agent = agent
|
||||
self._sessions: dict[str, AgentSession] = {}
|
||||
self.invoke_handler(self._handle_invoke) # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
async def _handle_invoke(self, request: Request) -> Response:
|
||||
"""Invoke the agent with the given request."""
|
||||
with user_agent_prefix(self.USER_AGENT_PREFIX):
|
||||
return await self._handle_invoke_inner(request)
|
||||
|
||||
async def _handle_invoke_inner(self, request: Request) -> Response:
|
||||
"""Core invoke handler logic."""
|
||||
data = await request.json()
|
||||
session_id: str = request.state.session_id
|
||||
|
||||
stream = data.get("stream", False)
|
||||
user_message = data.get("message", None)
|
||||
if user_message is None:
|
||||
error = "Missing 'message' in request"
|
||||
if stream:
|
||||
return StreamingResponse(content=error, status_code=400)
|
||||
return Response(content=error, status_code=400)
|
||||
|
||||
session = self._sessions.setdefault(session_id, AgentSession(session_id=session_id))
|
||||
|
||||
if stream:
|
||||
|
||||
async def stream_response() -> AsyncGenerator[str]:
|
||||
async for update in self._agent.run(user_message, session=session, stream=True):
|
||||
if update.text:
|
||||
yield update.text
|
||||
|
||||
return StreamingResponse(
|
||||
stream_response(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
|
||||
response = await self._agent.run([user_message], session=session, stream=stream)
|
||||
return JSONResponse({
|
||||
"response": response.text,
|
||||
"session_id": session_id,
|
||||
})
|
||||
@@ -0,0 +1,983 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
|
||||
from typing import cast
|
||||
|
||||
from agent_framework import (
|
||||
ChatOptions,
|
||||
Content,
|
||||
ContextProvider,
|
||||
FileCheckpointStorage,
|
||||
HistoryProvider,
|
||||
Message,
|
||||
RawAgent,
|
||||
SupportsAgentRun,
|
||||
WorkflowAgent,
|
||||
)
|
||||
from agent_framework._telemetry import user_agent_prefix
|
||||
from azure.ai.agentserver.responses import (
|
||||
ResponseContext,
|
||||
ResponseEventStream,
|
||||
ResponseProviderProtocol,
|
||||
ResponsesServerOptions,
|
||||
)
|
||||
from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost
|
||||
from azure.ai.agentserver.responses.models import (
|
||||
ComputerScreenshotContent,
|
||||
CreateResponse,
|
||||
FunctionCallOutputItemParam,
|
||||
FunctionShellAction,
|
||||
FunctionShellCallOutputContent,
|
||||
FunctionShellCallOutputExitOutcome,
|
||||
LocalEnvironmentResource,
|
||||
MessageContent,
|
||||
MessageContentInputFileContent,
|
||||
MessageContentInputImageContent,
|
||||
MessageContentInputTextContent,
|
||||
MessageContentOutputTextContent,
|
||||
MessageContentReasoningTextContent,
|
||||
MessageContentRefusalContent,
|
||||
OAuthConsentRequestOutputItem,
|
||||
OutputItem,
|
||||
OutputItemApplyPatchToolCall,
|
||||
OutputItemApplyPatchToolCallOutput,
|
||||
OutputItemCodeInterpreterToolCall,
|
||||
OutputItemComputerToolCall,
|
||||
OutputItemComputerToolCallOutputResource,
|
||||
OutputItemCustomToolCall,
|
||||
OutputItemCustomToolCallOutput,
|
||||
OutputItemFileSearchToolCall,
|
||||
OutputItemFunctionShellCall,
|
||||
OutputItemFunctionShellCallOutput,
|
||||
OutputItemFunctionToolCall,
|
||||
OutputItemImageGenToolCall,
|
||||
OutputItemLocalShellToolCall,
|
||||
OutputItemLocalShellToolCallOutput,
|
||||
OutputItemMcpApprovalRequest,
|
||||
OutputItemMcpApprovalResponseResource,
|
||||
OutputItemMcpToolCall,
|
||||
OutputItemMessage,
|
||||
OutputItemOutputMessage,
|
||||
OutputItemReasoningItem,
|
||||
OutputItemWebSearchToolCall,
|
||||
OutputMessageContent,
|
||||
OutputMessageContentOutputTextContent,
|
||||
OutputMessageContentRefusalContent,
|
||||
ResponseStreamEvent,
|
||||
StructuredOutputsOutputItem,
|
||||
SummaryTextContent,
|
||||
TextContent,
|
||||
)
|
||||
from azure.ai.agentserver.responses.streaming._builders import (
|
||||
OutputItemFunctionCallBuilder,
|
||||
OutputItemMcpCallBuilder,
|
||||
OutputItemMessageBuilder,
|
||||
OutputItemReasoningItemBuilder,
|
||||
ReasoningSummaryPartBuilder,
|
||||
TextContentBuilder,
|
||||
)
|
||||
from typing_extensions import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
"""A responses server host for an agent."""
|
||||
|
||||
USER_AGENT_PREFIX = "foundry-hosting"
|
||||
# TODO(@taochen): Allow a different checkpoint storage that stores checkpoints externally
|
||||
CHECKPOINT_STORAGE_PATH = "/.checkpoints"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: SupportsAgentRun,
|
||||
*,
|
||||
prefix: str = "",
|
||||
options: ResponsesServerOptions | None = None,
|
||||
store: ResponseProviderProtocol | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize a ResponsesHostServer.
|
||||
|
||||
Args:
|
||||
agent: The agent to handle responses for.
|
||||
prefix: The URL prefix for the server.
|
||||
options: Optional server options.
|
||||
store: Optional response store.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Note:
|
||||
1. The agent must not have a history provider with `load_messages=True`,
|
||||
because history is managed by the hosting infrastructure.
|
||||
2. The agent must not have any context providers that maintain context
|
||||
in memory, because the hosting environment may get deactivated between
|
||||
requests, and any in-memory context would be lost.
|
||||
"""
|
||||
super().__init__(prefix=prefix, options=options, store=store, **kwargs)
|
||||
|
||||
for provider in getattr(agent, "context_providers", []):
|
||||
if isinstance(provider, HistoryProvider) and provider.load_messages:
|
||||
raise RuntimeError(
|
||||
"There shouldn't be a history provider with `load_messages=True` already present. "
|
||||
"History is managed by the hosting infrastructure."
|
||||
)
|
||||
provider = cast(ContextProvider, provider)
|
||||
logger.warning(
|
||||
"Context provider %s is present. If it maintains context in memory, "
|
||||
"the context may be lost between requests. Use with caution.",
|
||||
provider.source_id,
|
||||
)
|
||||
|
||||
self._is_workflow_agent = False
|
||||
self._checkpoint_storage_path = None
|
||||
if isinstance(agent, WorkflowAgent):
|
||||
if agent.workflow._runner_context.has_checkpointing(): # pyright: ignore[reportPrivateUsage]
|
||||
raise RuntimeError(
|
||||
"There should not be a checkpoint storage already present in the workflow agent. "
|
||||
"The hosting infrastructure will manage checkpoints instead."
|
||||
)
|
||||
self._checkpoint_storage_path = (
|
||||
self.CHECKPOINT_STORAGE_PATH
|
||||
if self.config.is_hosted
|
||||
else os.path.join(os.getcwd(), self.CHECKPOINT_STORAGE_PATH.lstrip("/"))
|
||||
)
|
||||
self._is_workflow_agent = True
|
||||
|
||||
self._agent = agent
|
||||
self.response_handler(self._handler) # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
@staticmethod
|
||||
def _is_streaming_request(request: CreateResponse) -> bool:
|
||||
"""Check if the request is a streaming request."""
|
||||
return request.stream is not None and request.stream is True
|
||||
|
||||
async def _handler(
|
||||
self,
|
||||
request: CreateResponse,
|
||||
context: ResponseContext,
|
||||
cancellation_signal: asyncio.Event,
|
||||
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
|
||||
"""Handle the creation of a response."""
|
||||
with user_agent_prefix(self.USER_AGENT_PREFIX):
|
||||
async for event in self._handle_inner(request, context, cancellation_signal):
|
||||
yield event
|
||||
|
||||
async def _handle_inner(
|
||||
self,
|
||||
request: CreateResponse,
|
||||
context: ResponseContext,
|
||||
cancellation_signal: asyncio.Event,
|
||||
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
|
||||
"""Core handler logic."""
|
||||
if self._is_workflow_agent:
|
||||
# Workflow agents are handled differently because they require checkpoint restoration
|
||||
async for event in self._handle_workflow_agent(request, context, cancellation_signal):
|
||||
yield event
|
||||
return
|
||||
|
||||
input_text = await context.get_input_text()
|
||||
history = await context.get_history()
|
||||
messages: list[str | Content | Message] = [*_to_messages(history), input_text]
|
||||
|
||||
chat_options, are_options_set = _to_chat_options(request)
|
||||
|
||||
is_streaming_request = self._is_streaming_request(request)
|
||||
response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model)
|
||||
|
||||
yield response_event_stream.emit_created()
|
||||
yield response_event_stream.emit_in_progress()
|
||||
|
||||
if not is_streaming_request:
|
||||
# Run the agent in non-streaming mode
|
||||
if isinstance(self._agent, RawAgent):
|
||||
raw_agent = cast("RawAgent[Any]", self._agent) # type: ignore[redundant-cast] # pyright: ignore[reportUnknownMemberType]
|
||||
response = await raw_agent.run(messages, stream=False, options=chat_options)
|
||||
else:
|
||||
if are_options_set:
|
||||
logger.warning("Agent doesn't support runtime options. They will be ignored.")
|
||||
response = await self._agent.run(messages, stream=False)
|
||||
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
async for item in _to_outputs(response_event_stream, content):
|
||||
yield item
|
||||
|
||||
yield response_event_stream.emit_completed()
|
||||
return
|
||||
|
||||
# Run the agent in streaming mode
|
||||
if isinstance(self._agent, RawAgent):
|
||||
raw_agent = cast("RawAgent[Any]", self._agent) # type: ignore[redundant-cast] # pyright: ignore[reportUnknownMemberType]
|
||||
response_stream = raw_agent.run(messages, stream=True, options=chat_options)
|
||||
else:
|
||||
if are_options_set:
|
||||
logger.warning("Agent doesn't support runtime options. They will be ignored.")
|
||||
response_stream = self._agent.run(messages, stream=True)
|
||||
|
||||
# Track the current active output item builder for streaming;
|
||||
# lazily created on matching content, closed when a different type arrives.
|
||||
tracker = _OutputItemTracker(response_event_stream)
|
||||
|
||||
async for update in response_stream:
|
||||
for content in update.contents:
|
||||
for event in tracker.handle(content):
|
||||
yield event
|
||||
if tracker.needs_async:
|
||||
async for item in _to_outputs(response_event_stream, content):
|
||||
yield item
|
||||
tracker.needs_async = False
|
||||
|
||||
# Close any remaining active builder
|
||||
for event in tracker.close():
|
||||
yield event
|
||||
|
||||
yield response_event_stream.emit_completed()
|
||||
|
||||
async def _handle_workflow_agent(
|
||||
self,
|
||||
request: CreateResponse,
|
||||
context: ResponseContext,
|
||||
cancellation_signal: asyncio.Event,
|
||||
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
|
||||
"""Handle the creation of a response for a workflow agent.
|
||||
|
||||
Why this is required:
|
||||
The sandbox may be deactivated after some period of inactivity, and only data managed
|
||||
by the hosting infrastructure or files will be preserved upon deactivation.
|
||||
"""
|
||||
input_text = await context.get_input_text()
|
||||
is_streaming_request = self._is_streaming_request(request)
|
||||
|
||||
_, are_options_set = _to_chat_options(request)
|
||||
if are_options_set:
|
||||
logger.warning("Workflow agent doesn't support runtime options. They will be ignored.")
|
||||
|
||||
if request.previous_response_id is not None and context.conversation_id is not None:
|
||||
raise RuntimeError("Previous response ID cannot be used in conjunction with conversation ID.")
|
||||
context_id = request.previous_response_id or context.conversation_id
|
||||
|
||||
# The following should never happen due to the checks above.
|
||||
# This is for type safety and defensive programming.
|
||||
if self._checkpoint_storage_path is None:
|
||||
raise RuntimeError("Checkpoint storage path is not configured for workflow agent.")
|
||||
if not isinstance(self._agent, WorkflowAgent):
|
||||
raise RuntimeError("Agent is not a workflow agent.")
|
||||
|
||||
# Restore from the latest checkpoint if available, otherwise start with an empty history
|
||||
if context_id is not None:
|
||||
checkpoint_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
|
||||
latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=self._agent.workflow.name)
|
||||
if latest_checkpoint is not None:
|
||||
if not is_streaming_request:
|
||||
_ = await self._agent.run(
|
||||
stream=False,
|
||||
checkpoint_id=latest_checkpoint.checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
)
|
||||
else:
|
||||
# Consume the streaming or the invocation will result in a no-op
|
||||
async for _ in self._agent.run(
|
||||
stream=True,
|
||||
checkpoint_id=latest_checkpoint.checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
):
|
||||
pass
|
||||
|
||||
# Now run the agent with the latest input
|
||||
response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model)
|
||||
|
||||
# Create a new checkpoint storage for this response based on the following rules:
|
||||
# - If no previous response ID or conversation ID is provided, create a new checkpoint storage for this response
|
||||
# - If a previous response ID is provided, create a new checkpoint storage for this response
|
||||
# - If a conversation ID is provided, reuse the existing checkpoint storage for the conversation
|
||||
context_id = context.conversation_id or context.response_id
|
||||
checkpoint_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
|
||||
|
||||
yield response_event_stream.emit_created()
|
||||
yield response_event_stream.emit_in_progress()
|
||||
|
||||
if not is_streaming_request:
|
||||
# Run the agent in non-streaming mode
|
||||
response = await self._agent.run(input_text, stream=False, checkpoint_storage=checkpoint_storage)
|
||||
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
async for item in _to_outputs(response_event_stream, content):
|
||||
yield item
|
||||
|
||||
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
|
||||
yield response_event_stream.emit_completed()
|
||||
return
|
||||
|
||||
# Run the agent in streaming mode
|
||||
response_stream = self._agent.run(input_text, stream=True, checkpoint_storage=checkpoint_storage)
|
||||
|
||||
# Track the current active output item builder for streaming;
|
||||
# lazily created on matching content, closed when a different type arrives.
|
||||
tracker = _OutputItemTracker(response_event_stream)
|
||||
|
||||
async for update in response_stream:
|
||||
for content in update.contents:
|
||||
for event in tracker.handle(content):
|
||||
yield event
|
||||
if tracker.needs_async:
|
||||
async for item in _to_outputs(response_event_stream, content):
|
||||
yield item
|
||||
tracker.needs_async = False
|
||||
|
||||
# Close any remaining active builder
|
||||
for event in tracker.close():
|
||||
yield event
|
||||
|
||||
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
|
||||
yield response_event_stream.emit_completed()
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
async def _delete_not_latest_checkpoints(checkpoint_storage: FileCheckpointStorage, workflow_name: str) -> None:
|
||||
"""Delete all checkpoints except the latest one.
|
||||
|
||||
We only need the last checkpoint for each invocation.
|
||||
"""
|
||||
latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=workflow_name)
|
||||
if latest_checkpoint is not None:
|
||||
all_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow_name)
|
||||
for checkpoint in all_checkpoints:
|
||||
if checkpoint.checkpoint_id != latest_checkpoint.checkpoint_id:
|
||||
await checkpoint_storage.delete(checkpoint.checkpoint_id)
|
||||
|
||||
|
||||
# region Active Builder State
|
||||
|
||||
|
||||
class _OutputItemTracker:
|
||||
"""Tracks the current active output item builder during streaming.
|
||||
|
||||
Handles lazy creation, delta emission, and closing of streaming builders
|
||||
for text messages, reasoning, function calls, and MCP calls.
|
||||
"""
|
||||
|
||||
_DELTA_TYPES = frozenset({"text", "text_reasoning", "function_call", "mcp_server_tool_call"})
|
||||
|
||||
def __init__(self, stream: ResponseEventStream) -> None:
|
||||
self._stream = stream
|
||||
self._active_type: str | None = None
|
||||
self._active_id: str | None = None
|
||||
# Accumulated delta text for the current active builder
|
||||
self._accumulated: list[str] = []
|
||||
# Builder state — only one is active at a time
|
||||
self._message_item: OutputItemMessageBuilder | None = None
|
||||
self._text_content: TextContentBuilder | None = None
|
||||
self._reasoning_item: OutputItemReasoningItemBuilder | None = None
|
||||
self._summary_part: ReasoningSummaryPartBuilder | None = None
|
||||
self._fc_builder: OutputItemFunctionCallBuilder | None = None
|
||||
self._mcp_builder: OutputItemMcpCallBuilder | None = None
|
||||
self.needs_async = False
|
||||
|
||||
def handle(self, content: Content) -> Generator[ResponseStreamEvent]:
|
||||
"""Process a content item, yielding sync events.
|
||||
|
||||
Sets ``needs_async = True`` if the caller must also drain an
|
||||
async ``_to_outputs`` call for this content.
|
||||
"""
|
||||
if content.type == "text" and content.text is not None:
|
||||
if self._active_type != "text":
|
||||
yield from self._close()
|
||||
yield from self._open_message()
|
||||
self._accumulated.append(content.text)
|
||||
if self._text_content is not None:
|
||||
yield self._text_content.emit_delta(content.text)
|
||||
|
||||
elif content.type == "text_reasoning" and content.text is not None:
|
||||
if self._active_type != "text_reasoning":
|
||||
yield from self._close()
|
||||
yield from self._open_reasoning()
|
||||
self._accumulated.append(content.text)
|
||||
if self._summary_part is not None:
|
||||
yield self._summary_part.emit_text_delta(content.text)
|
||||
|
||||
elif content.type == "function_call" and content.call_id is not None:
|
||||
if self._active_type != "function_call" or self._active_id != content.call_id:
|
||||
yield from self._close()
|
||||
yield from self._open_function_call(content)
|
||||
args_str = _arguments_to_str(content.arguments)
|
||||
self._accumulated.append(args_str)
|
||||
if self._fc_builder is not None:
|
||||
yield self._fc_builder.emit_arguments_delta(args_str)
|
||||
|
||||
elif content.type == "mcp_server_tool_call" and content.tool_name:
|
||||
key = f"{content.server_name or 'default'}::{content.tool_name}"
|
||||
if self._active_type != "mcp_server_tool_call" or self._active_id != key:
|
||||
yield from self._close()
|
||||
yield from self._open_mcp_call(content)
|
||||
args_str = _arguments_to_str(content.arguments)
|
||||
self._accumulated.append(args_str)
|
||||
if self._mcp_builder is not None:
|
||||
yield self._mcp_builder.emit_arguments_delta(args_str)
|
||||
|
||||
else:
|
||||
yield from self._close()
|
||||
self.needs_async = True
|
||||
|
||||
def close(self) -> Generator[ResponseStreamEvent]:
|
||||
"""Close any remaining active builder."""
|
||||
yield from self._close()
|
||||
|
||||
# -- Private open/close helpers --
|
||||
|
||||
def _open_message(self) -> Generator[ResponseStreamEvent]:
|
||||
self._message_item = self._stream.add_output_item_message()
|
||||
self._text_content = self._message_item.add_text_content()
|
||||
self._active_type = "text"
|
||||
self._active_id = None
|
||||
yield self._message_item.emit_added()
|
||||
yield self._text_content.emit_added()
|
||||
|
||||
def _open_reasoning(self) -> Generator[ResponseStreamEvent]:
|
||||
self._reasoning_item = self._stream.add_output_item_reasoning_item()
|
||||
self._summary_part = self._reasoning_item.add_summary_part()
|
||||
self._active_type = "text_reasoning"
|
||||
self._active_id = None
|
||||
yield self._reasoning_item.emit_added()
|
||||
yield self._summary_part.emit_added()
|
||||
|
||||
def _open_function_call(self, content: Content) -> Generator[ResponseStreamEvent]:
|
||||
self._fc_builder = self._stream.add_output_item_function_call(
|
||||
name=content.name or "",
|
||||
call_id=content.call_id or "",
|
||||
)
|
||||
self._active_type = "function_call"
|
||||
self._active_id = content.call_id
|
||||
yield self._fc_builder.emit_added()
|
||||
|
||||
def _open_mcp_call(self, content: Content) -> Generator[ResponseStreamEvent]:
|
||||
self._mcp_builder = self._stream.add_output_item_mcp_call(
|
||||
server_label=content.server_name or "default",
|
||||
name=content.tool_name or "",
|
||||
)
|
||||
self._active_type = "mcp_server_tool_call"
|
||||
self._active_id = f"{content.server_name or 'default'}::{content.tool_name}"
|
||||
yield self._mcp_builder.emit_added()
|
||||
|
||||
def _close(self) -> Generator[ResponseStreamEvent]:
|
||||
accumulated = "".join(self._accumulated)
|
||||
|
||||
if self._active_type == "text" and self._text_content and self._message_item:
|
||||
yield self._text_content.emit_text_done(accumulated)
|
||||
yield self._text_content.emit_done()
|
||||
yield self._message_item.emit_done()
|
||||
self._text_content = None
|
||||
self._message_item = None
|
||||
|
||||
elif self._active_type == "text_reasoning" and self._summary_part and self._reasoning_item:
|
||||
yield self._summary_part.emit_text_done(accumulated)
|
||||
yield self._summary_part.emit_done()
|
||||
yield self._reasoning_item.emit_done()
|
||||
self._summary_part = None
|
||||
self._reasoning_item = None
|
||||
|
||||
elif self._active_type == "function_call" and self._fc_builder:
|
||||
yield self._fc_builder.emit_arguments_done(accumulated)
|
||||
yield self._fc_builder.emit_done()
|
||||
self._fc_builder = None
|
||||
|
||||
elif self._active_type == "mcp_server_tool_call" and self._mcp_builder:
|
||||
yield self._mcp_builder.emit_arguments_done(accumulated)
|
||||
yield self._mcp_builder.emit_completed()
|
||||
yield self._mcp_builder.emit_done()
|
||||
self._mcp_builder = None
|
||||
|
||||
self._active_type = None
|
||||
self._active_id = None
|
||||
self._accumulated.clear()
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Option Conversion
|
||||
|
||||
|
||||
def _to_chat_options(request: CreateResponse) -> tuple[ChatOptions, bool]:
|
||||
"""Converts a CreateResponse request to ChatOptions.
|
||||
|
||||
Args:
|
||||
request (CreateResponse): The request to convert.
|
||||
|
||||
Returns:
|
||||
ChatOptions: The converted ChatOptions.
|
||||
bool: Whether any options were set.
|
||||
|
||||
"""
|
||||
chat_options = ChatOptions()
|
||||
are_options_set = False
|
||||
|
||||
if request.temperature is not None:
|
||||
chat_options["temperature"] = request.temperature
|
||||
are_options_set = True
|
||||
if request.top_p is not None:
|
||||
chat_options["top_p"] = request.top_p
|
||||
are_options_set = True
|
||||
if request.max_output_tokens is not None:
|
||||
chat_options["max_tokens"] = request.max_output_tokens
|
||||
are_options_set = True
|
||||
if request.parallel_tool_calls is not None:
|
||||
chat_options["allow_multiple_tool_calls"] = request.parallel_tool_calls
|
||||
are_options_set = True
|
||||
|
||||
return chat_options, are_options_set
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Input Message Conversion
|
||||
|
||||
|
||||
def _to_messages(history: Sequence[OutputItem]) -> list[Message]:
|
||||
"""Converts a sequence of OutputItem objects to a list of Message objects.
|
||||
|
||||
Args:
|
||||
history (Sequence[OutputItem]): The sequence of OutputItem objects to convert.
|
||||
|
||||
Returns:
|
||||
list[Message]: The list of Message objects.
|
||||
"""
|
||||
messages: list[Message] = []
|
||||
for item in history:
|
||||
messages.append(_to_message(item))
|
||||
return messages
|
||||
|
||||
|
||||
def _to_message(item: OutputItem) -> Message:
|
||||
"""Converts an OutputItem to a Message.
|
||||
|
||||
Args:
|
||||
item (OutputItem): The OutputItem to convert.
|
||||
|
||||
Returns:
|
||||
Message: The converted Message.
|
||||
|
||||
Raises:
|
||||
ValueError: If the OutputItem type is not supported.
|
||||
"""
|
||||
if item.type == "output_message":
|
||||
output_msg = cast(OutputItemOutputMessage, item)
|
||||
return Message(
|
||||
role=output_msg.role, contents=[_convert_output_message_content(part) for part in output_msg.content]
|
||||
)
|
||||
|
||||
if item.type == "message":
|
||||
msg = cast(OutputItemMessage, item)
|
||||
return Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content])
|
||||
|
||||
if item.type == "function_call":
|
||||
fc = cast(OutputItemFunctionToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)],
|
||||
)
|
||||
|
||||
if item.type == "function_call_output":
|
||||
fco = cast(FunctionCallOutputItemParam, item)
|
||||
output = fco.output if isinstance(fco.output, str) else str(fco.output)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(fco.call_id, result=output)],
|
||||
)
|
||||
|
||||
if item.type == "reasoning":
|
||||
reasoning = cast(OutputItemReasoningItem, item)
|
||||
contents: list[Content] = []
|
||||
if reasoning.summary:
|
||||
for summary in reasoning.summary:
|
||||
contents.append(Content.from_text(summary.text))
|
||||
return Message(role="assistant", contents=contents)
|
||||
|
||||
if item.type == "mcp_call":
|
||||
mcp = cast(OutputItemMcpToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_call(
|
||||
mcp.id,
|
||||
mcp.name,
|
||||
server_name=mcp.server_label,
|
||||
arguments=mcp.arguments,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "mcp_approval_request":
|
||||
mcp_req = cast(OutputItemMcpApprovalRequest, item)
|
||||
mcp_call_content = Content.from_mcp_server_tool_call(
|
||||
mcp_req.id,
|
||||
mcp_req.name,
|
||||
server_name=mcp_req.server_label,
|
||||
arguments=mcp_req.arguments,
|
||||
)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_approval_request(mcp_req.id, mcp_call_content)],
|
||||
)
|
||||
|
||||
if item.type == "mcp_approval_response":
|
||||
mcp_resp = cast(OutputItemMcpApprovalResponseResource, item)
|
||||
# Build a placeholder function_call Content since the original call details are not available
|
||||
placeholder_content = Content.from_function_call(mcp_resp.approval_request_id, "mcp_approval")
|
||||
return Message(
|
||||
role="user",
|
||||
contents=[Content.from_function_approval_response(mcp_resp.approve, mcp_resp.id, placeholder_content)],
|
||||
)
|
||||
|
||||
if item.type == "code_interpreter_call":
|
||||
ci = cast(OutputItemCodeInterpreterToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_code_interpreter_tool_call(call_id=ci.id)],
|
||||
)
|
||||
|
||||
if item.type == "image_generation_call":
|
||||
ig = cast(OutputItemImageGenToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_image_generation_tool_call(image_id=ig.id)],
|
||||
)
|
||||
|
||||
if item.type == "shell_call":
|
||||
sc = cast(OutputItemFunctionShellCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_shell_tool_call(
|
||||
call_id=sc.call_id,
|
||||
commands=sc.action.commands,
|
||||
status=str(sc.status),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "shell_call_output":
|
||||
sco = cast(OutputItemFunctionShellCallOutput, item)
|
||||
outputs = [
|
||||
Content.from_shell_command_output(
|
||||
stdout=out.stdout or "",
|
||||
stderr=out.stderr or "",
|
||||
exit_code=getattr(out.outcome, "exit_code", None) if hasattr(out, "outcome") else None,
|
||||
)
|
||||
for out in (sco.output or [])
|
||||
]
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_shell_tool_result(
|
||||
call_id=sco.call_id,
|
||||
outputs=outputs,
|
||||
max_output_length=sco.max_output_length,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "local_shell_call":
|
||||
lsc = cast(OutputItemLocalShellToolCall, item)
|
||||
commands = lsc.action.command if hasattr(lsc.action, "command") and lsc.action.command else []
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_shell_tool_call(
|
||||
call_id=lsc.call_id,
|
||||
commands=commands,
|
||||
status=str(lsc.status),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "local_shell_call_output":
|
||||
lsco = cast(OutputItemLocalShellToolCallOutput, item)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_shell_tool_result(
|
||||
call_id=lsco.id,
|
||||
outputs=[Content.from_shell_command_output(stdout=lsco.output)],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "file_search_call":
|
||||
fs = cast(OutputItemFileSearchToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
fs.id,
|
||||
"file_search",
|
||||
arguments=json.dumps({"queries": fs.queries}),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "web_search_call":
|
||||
ws = cast(OutputItemWebSearchToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(ws.id, "web_search")],
|
||||
)
|
||||
|
||||
if item.type == "computer_call":
|
||||
cc = cast(OutputItemComputerToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
cc.call_id,
|
||||
"computer_use",
|
||||
arguments=str(cc.action),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "computer_call_output":
|
||||
cco = cast(OutputItemComputerToolCallOutputResource, item)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(cco.call_id, result=str(cco.output))],
|
||||
)
|
||||
|
||||
if item.type == "custom_tool_call":
|
||||
ct = cast(OutputItemCustomToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)],
|
||||
)
|
||||
|
||||
if item.type == "custom_tool_call_output":
|
||||
cto = cast(OutputItemCustomToolCallOutput, item)
|
||||
output = cto.output if isinstance(cto.output, str) else str(cto.output)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(cto.call_id, result=output)],
|
||||
)
|
||||
|
||||
if item.type == "apply_patch_call":
|
||||
ap = cast(OutputItemApplyPatchToolCall, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
ap.call_id,
|
||||
"apply_patch",
|
||||
arguments=str(ap.operation),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if item.type == "apply_patch_call_output":
|
||||
apo = cast(OutputItemApplyPatchToolCallOutput, item)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(apo.call_id, result=apo.output or "")],
|
||||
)
|
||||
|
||||
if item.type == "oauth_consent_request":
|
||||
oauth = cast(OAuthConsentRequestOutputItem, item)
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_oauth_consent_request(oauth.consent_link)],
|
||||
)
|
||||
|
||||
if item.type == "structured_outputs":
|
||||
so = cast(StructuredOutputsOutputItem, item)
|
||||
text = json.dumps(so.output) if not isinstance(so.output, str) else so.output
|
||||
return Message(role="assistant", contents=[Content.from_text(text)])
|
||||
|
||||
raise ValueError(f"Unsupported OutputItem type: {item.type}")
|
||||
|
||||
|
||||
def _convert_output_message_content(content: OutputMessageContent) -> Content:
|
||||
"""Converts an OutputMessageContent to a Content object.
|
||||
|
||||
Args:
|
||||
content (OutputMessageContent): The OutputMessageContent to convert.
|
||||
|
||||
Returns:
|
||||
Content: The converted Content object.
|
||||
|
||||
Raises:
|
||||
ValueError: If the OutputMessageContent type is not supported.
|
||||
"""
|
||||
if content.type == "output_text":
|
||||
text_content = cast(OutputMessageContentOutputTextContent, content)
|
||||
return Content.from_text(text_content.text)
|
||||
if content.type == "refusal":
|
||||
refusal_content = cast(OutputMessageContentRefusalContent, content)
|
||||
return Content.from_text(refusal_content.refusal)
|
||||
|
||||
raise ValueError(f"Unsupported OutputMessageContent type: {content.type}")
|
||||
|
||||
|
||||
def _convert_message_content(content: MessageContent) -> Content:
|
||||
"""Converts a MessageContent to a Content object.
|
||||
|
||||
Args:
|
||||
content (MessageContent): The MessageContent to convert.
|
||||
|
||||
Returns:
|
||||
Content: The converted Content object.
|
||||
|
||||
Raises:
|
||||
ValueError: If the MessageContent type is not supported.
|
||||
"""
|
||||
if content.type == "input_text":
|
||||
input_text = cast(MessageContentInputTextContent, content)
|
||||
return Content.from_text(input_text.text)
|
||||
if content.type == "output_text":
|
||||
output_text = cast(MessageContentOutputTextContent, content)
|
||||
return Content.from_text(output_text.text)
|
||||
if content.type == "text":
|
||||
text = cast(TextContent, content)
|
||||
return Content.from_text(text.text)
|
||||
if content.type == "summary_text":
|
||||
summary = cast(SummaryTextContent, content)
|
||||
return Content.from_text(summary.text)
|
||||
if content.type == "refusal":
|
||||
refusal = cast(MessageContentRefusalContent, content)
|
||||
return Content.from_text(refusal.refusal)
|
||||
if content.type == "reasoning_text":
|
||||
reasoning = cast(MessageContentReasoningTextContent, content)
|
||||
return Content.from_text_reasoning(text=reasoning.text)
|
||||
if content.type == "input_image":
|
||||
image = cast(MessageContentInputImageContent, content)
|
||||
if image.image_url:
|
||||
return Content.from_uri(image.image_url)
|
||||
if image.file_id:
|
||||
return Content.from_hosted_file(image.file_id)
|
||||
if content.type == "input_file":
|
||||
file = cast(MessageContentInputFileContent, content)
|
||||
if file.file_url:
|
||||
return Content.from_uri(file.file_url)
|
||||
if file.file_id:
|
||||
return Content.from_hosted_file(file.file_id, name=file.filename)
|
||||
if content.type == "computer_screenshot":
|
||||
screenshot = cast(ComputerScreenshotContent, content)
|
||||
return Content.from_uri(screenshot.image_url)
|
||||
|
||||
raise ValueError(f"Unsupported MessageContent type: {content.type}")
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Output Item Conversion
|
||||
|
||||
|
||||
def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
|
||||
"""Convert arguments to a JSON string.
|
||||
|
||||
Args:
|
||||
arguments: The arguments to convert, can be a string, mapping, or None.
|
||||
|
||||
Returns:
|
||||
The arguments as a JSON string.
|
||||
"""
|
||||
if arguments is None:
|
||||
return ""
|
||||
if isinstance(arguments, str):
|
||||
return arguments
|
||||
return json.dumps(arguments)
|
||||
|
||||
|
||||
async def _to_outputs(stream: ResponseEventStream, content: Content) -> AsyncIterator[ResponseStreamEvent]:
|
||||
"""Converts a Content object to an async sequence of ResponseStreamEvent objects.
|
||||
|
||||
Args:
|
||||
stream: The ResponseEventStream to use for building events.
|
||||
content: The Content to convert.
|
||||
|
||||
Yields:
|
||||
ResponseStreamEvent: The converted event objects.
|
||||
|
||||
Raises:
|
||||
ValueError: If the Content type is not supported.
|
||||
"""
|
||||
if content.type == "text" and content.text is not None:
|
||||
async for event in stream.aoutput_item_message(content.text):
|
||||
yield event
|
||||
elif content.type == "text_reasoning" and content.text is not None:
|
||||
async for event in stream.aoutput_item_reasoning_item(content.text):
|
||||
yield event
|
||||
elif content.type == "function_call":
|
||||
async for event in stream.aoutput_item_function_call(
|
||||
content.name, # type: ignore[arg-type]
|
||||
content.call_id, # type: ignore[arg-type]
|
||||
_arguments_to_str(content.arguments),
|
||||
):
|
||||
yield event
|
||||
elif content.type == "function_result":
|
||||
async for event in stream.aoutput_item_function_call_output(
|
||||
content.call_id, # type: ignore[arg-type]
|
||||
str(content.result or ""),
|
||||
):
|
||||
yield event
|
||||
elif content.type == "image_generation_tool_result" and content.outputs is not None:
|
||||
async for event in stream.aoutput_item_image_gen_call(str(content.outputs)):
|
||||
yield event
|
||||
elif content.type == "mcp_server_tool_call":
|
||||
mcp_call = stream.add_output_item_mcp_call(
|
||||
server_label=content.server_name or "default",
|
||||
name=content.tool_name or "",
|
||||
)
|
||||
yield mcp_call.emit_added()
|
||||
async for event in mcp_call.aarguments(_arguments_to_str(content.arguments)):
|
||||
yield event
|
||||
yield mcp_call.emit_completed()
|
||||
yield mcp_call.emit_done()
|
||||
elif content.type == "mcp_server_tool_result":
|
||||
output = (
|
||||
content.output
|
||||
if isinstance(content.output, str)
|
||||
else str(content.output)
|
||||
if content.output is not None
|
||||
else ""
|
||||
)
|
||||
async for event in stream.aoutput_item_custom_tool_call_output(content.call_id or "", output):
|
||||
yield event
|
||||
elif content.type == "shell_tool_call":
|
||||
action = FunctionShellAction(commands=content.commands or [], timeout_ms=0, max_output_length=0)
|
||||
async for event in stream.aoutput_item_function_shell_call(
|
||||
content.call_id or "",
|
||||
action,
|
||||
LocalEnvironmentResource(),
|
||||
status=content.status or "completed",
|
||||
):
|
||||
yield event
|
||||
elif content.type == "shell_tool_result":
|
||||
output_items: list[FunctionShellCallOutputContent] = []
|
||||
if content.outputs:
|
||||
for out in content.outputs:
|
||||
exit_code = getattr(out, "exit_code", None)
|
||||
output_items.append(
|
||||
FunctionShellCallOutputContent(
|
||||
stdout=getattr(out, "stdout", "") or "",
|
||||
stderr=getattr(out, "stderr", "") or "",
|
||||
outcome=FunctionShellCallOutputExitOutcome(exit_code=exit_code if exit_code is not None else 0),
|
||||
)
|
||||
)
|
||||
async for event in stream.aoutput_item_function_shell_call_output(
|
||||
content.call_id or "",
|
||||
output_items,
|
||||
status=content.status or "completed",
|
||||
max_output_length=content.max_output_length,
|
||||
):
|
||||
yield event
|
||||
else:
|
||||
# Log a warning for unsupported content types instead of raising an error to avoid breaking the response stream.
|
||||
logger.warning(f"Content type '{content.type}' is not supported yet. This is usually safe to ignore.")
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,99 @@
|
||||
[project]
|
||||
name = "agent-framework-foundry-hosting"
|
||||
description = "Foundry Hosting integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 4 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"azure-ai-agentserver-core==2.0.0b2",
|
||||
"azure-ai-agentserver-responses==1.0.0b4",
|
||||
"azure-ai-agentserver-invocations==1.0.0b2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = []
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests that require external services",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
include = ["agent_framework_foundry_hosting"]
|
||||
exclude = ['tests']
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_foundry_hosting"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry_hosting"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_foundry_hosting --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,917 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""HTTP round-trip tests for ResponsesHostServer.
|
||||
|
||||
These tests exercise the full HTTP pipeline using httpx.AsyncClient with
|
||||
ASGITransport — no real server process is started. Requests go through
|
||||
the Starlette routing stack, the Responses API middleware, and arrive at
|
||||
the registered _handle_create handler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
Content,
|
||||
HistoryProvider,
|
||||
Message,
|
||||
RawAgent,
|
||||
ResponseStream,
|
||||
)
|
||||
from azure.ai.agentserver.responses import InMemoryResponseProvider
|
||||
from typing_extensions import Any
|
||||
|
||||
from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
from agent_framework_foundry_hosting._responses import _to_message # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# region Helpers
|
||||
|
||||
|
||||
def _make_agent(
|
||||
*,
|
||||
response: AgentResponse | None = None,
|
||||
stream_updates: list[AgentResponseUpdate] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Create a mock agent implementing SupportsAgentRun."""
|
||||
agent = MagicMock(spec=RawAgent)
|
||||
agent.id = "test-agent"
|
||||
agent.name = "Test Agent"
|
||||
agent.description = "A mock agent for testing"
|
||||
agent.context_providers = []
|
||||
|
||||
if response is not None:
|
||||
|
||||
async def run_non_streaming(*args: Any, **kwargs: Any) -> AgentResponse:
|
||||
return response
|
||||
|
||||
agent.run = AsyncMock(side_effect=run_non_streaming)
|
||||
|
||||
if stream_updates is not None:
|
||||
|
||||
async def _stream_gen() -> AsyncIterator[AgentResponseUpdate]:
|
||||
for update in stream_updates:
|
||||
yield update
|
||||
|
||||
def run_streaming(*args: Any, **kwargs: Any) -> Any:
|
||||
if kwargs.get("stream"):
|
||||
return ResponseStream(_stream_gen()) # type: ignore
|
||||
raise NotImplementedError("Only streaming is configured on this mock")
|
||||
|
||||
agent.run = MagicMock(side_effect=run_streaming)
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
def _make_server(agent: MagicMock, **kwargs: Any) -> ResponsesHostServer:
|
||||
"""Create a ResponsesHostServer with an in-memory store."""
|
||||
return ResponsesHostServer(agent, store=InMemoryResponseProvider(), **kwargs)
|
||||
|
||||
|
||||
async def _post(
|
||||
server: ResponsesHostServer,
|
||||
*,
|
||||
input_text: str = "Hello",
|
||||
model: str = "test-model",
|
||||
stream: bool = False,
|
||||
temperature: float | None = None,
|
||||
top_p: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
parallel_tool_calls: bool | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Send a POST /responses request through the ASGI transport."""
|
||||
payload: dict[str, Any] = {"model": model, "input": input_text, "stream": stream}
|
||||
if temperature is not None:
|
||||
payload["temperature"] = temperature
|
||||
if top_p is not None:
|
||||
payload["top_p"] = top_p
|
||||
if max_output_tokens is not None:
|
||||
payload["max_output_tokens"] = max_output_tokens
|
||||
if parallel_tool_calls is not None:
|
||||
payload["parallel_tool_calls"] = parallel_tool_calls
|
||||
|
||||
transport = httpx.ASGITransport(app=server)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
return await client.post("/responses", json=payload)
|
||||
|
||||
|
||||
def _parse_sse_events(body: str) -> list[dict[str, Any]]:
|
||||
"""Parse SSE text into a list of event dicts with 'event' and 'data' keys."""
|
||||
events: list[dict[str, Any]] = []
|
||||
current_event: str | None = None
|
||||
current_data_lines: list[str] = []
|
||||
|
||||
for line in body.split("\n"):
|
||||
if line.startswith("event: "):
|
||||
current_event = line[len("event: ") :]
|
||||
elif line.startswith("data: "):
|
||||
current_data_lines.append(line[len("data: ") :])
|
||||
elif line.strip() == "" and current_event is not None:
|
||||
data_str = "\n".join(current_data_lines)
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
data = data_str
|
||||
events.append({"event": current_event, "data": data})
|
||||
current_event = None
|
||||
current_data_lines = []
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _sse_event_types(events: list[dict[str, Any]]) -> list[str]:
|
||||
"""Extract event type strings from parsed SSE events."""
|
||||
return [e["event"] for e in events]
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Initialization
|
||||
|
||||
|
||||
class TestResponsesHostServerInit:
|
||||
def test_init_basic(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
server = _make_server(agent)
|
||||
assert server is not None
|
||||
|
||||
def test_init_rejects_history_provider_with_load_messages(self) -> None:
|
||||
hp = HistoryProvider(source_id="test", load_messages=True)
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
agent.context_providers = [hp]
|
||||
with pytest.raises(RuntimeError, match="history provider"):
|
||||
ResponsesHostServer(agent)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Health Check
|
||||
|
||||
|
||||
class TestHealthCheck:
|
||||
async def test_readiness(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
|
||||
)
|
||||
server = _make_server(agent)
|
||||
transport = httpx.ASGITransport(app=server)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/readiness")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Non-streaming
|
||||
|
||||
|
||||
class TestNonStreaming:
|
||||
async def test_basic_text_response(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Hello!")])])
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, input_text="Hi", stream=False)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "application/json" in resp.headers["content-type"]
|
||||
|
||||
body = resp.json()
|
||||
assert body["object"] == "response"
|
||||
assert body["status"] == "completed"
|
||||
assert len(body["output"]) > 0
|
||||
|
||||
# Find the message output item with our text
|
||||
text_found = False
|
||||
for item in body["output"]:
|
||||
assert item["type"] == "message"
|
||||
for part in item.get("content", []):
|
||||
if part.get("type") == "output_text" and part.get("text") == "Hello!":
|
||||
text_found = True
|
||||
assert text_found, f"Expected 'Hello!' in output, got: {body['output']}"
|
||||
|
||||
async def test_function_call_and_result(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(
|
||||
messages=[
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call("call_1", "get_weather", arguments='{"loc": "NYC"}')],
|
||||
),
|
||||
Message(role="tool", contents=[Content.from_function_result("call_1", result="sunny")]),
|
||||
Message(role="assistant", contents=[Content.from_text("The weather is sunny!")]),
|
||||
]
|
||||
)
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=False)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
types = [item["type"] for item in body["output"]]
|
||||
assert "function_call" in types
|
||||
assert "function_call_output" in types
|
||||
assert "message" in types
|
||||
|
||||
async def test_reasoning_content(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(
|
||||
messages=[
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_text_reasoning(text="Let me think..."),
|
||||
Content.from_text("The answer is 42"),
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=False)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
types = [item["type"] for item in body["output"]]
|
||||
assert "reasoning" in types
|
||||
assert "message" in types
|
||||
|
||||
async def test_empty_response(self) -> None:
|
||||
agent = _make_agent(response=AgentResponse(messages=[]))
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=False)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
async def test_chat_options_forwarded(self) -> None:
|
||||
agent = _make_agent(
|
||||
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=False, temperature=0.5, top_p=0.9, max_output_tokens=1024)
|
||||
|
||||
assert resp.status_code == 200
|
||||
agent.run.assert_awaited_once()
|
||||
call_kwargs = agent.run.call_args.kwargs
|
||||
assert call_kwargs["stream"] is False
|
||||
options = call_kwargs["options"]
|
||||
assert options["temperature"] == 0.5
|
||||
assert options["top_p"] == 0.9
|
||||
assert options["max_tokens"] == 1024
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Streaming
|
||||
|
||||
|
||||
class TestStreaming:
|
||||
async def test_basic_text_streaming(self) -> None:
|
||||
agent = _make_agent(
|
||||
stream_updates=[
|
||||
AgentResponseUpdate(contents=[Content.from_text("Hello ")], role="assistant"),
|
||||
AgentResponseUpdate(contents=[Content.from_text("world!")], role="assistant"),
|
||||
]
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=True)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "text/event-stream" in resp.headers["content-type"]
|
||||
|
||||
events = _parse_sse_events(resp.text)
|
||||
types = _sse_event_types(events)
|
||||
|
||||
assert types[0] == "response.created"
|
||||
assert types[1] == "response.in_progress"
|
||||
assert types[-1] == "response.completed"
|
||||
assert "response.output_text.delta" in types
|
||||
assert types.count("response.output_text.delta") == 2
|
||||
assert "response.output_text.done" in types
|
||||
|
||||
# Verify the accumulated text in the done event
|
||||
done_events = [e for e in events if e["event"] == "response.output_text.done"]
|
||||
assert len(done_events) == 1
|
||||
assert done_events[0]["data"]["text"] == "Hello world!"
|
||||
|
||||
async def test_function_call_streaming(self) -> None:
|
||||
agent = _make_agent(
|
||||
stream_updates=[
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call("call_1", "search", arguments='{"q":')],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call("call_1", "search", arguments=' "hello"}')],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=True)
|
||||
|
||||
assert resp.status_code == 200
|
||||
events = _parse_sse_events(resp.text)
|
||||
types = _sse_event_types(events)
|
||||
|
||||
assert types[0] == "response.created"
|
||||
assert types[-1] == "response.completed"
|
||||
assert types.count("response.function_call_arguments.delta") == 2
|
||||
assert "response.function_call_arguments.done" in types
|
||||
|
||||
# Verify accumulated arguments
|
||||
args_done = [e for e in events if e["event"] == "response.function_call_arguments.done"]
|
||||
assert len(args_done) == 1
|
||||
assert args_done[0]["data"]["arguments"] == '{"q": "hello"}'
|
||||
|
||||
async def test_alternating_text_and_function_call(self) -> None:
|
||||
agent = _make_agent(
|
||||
stream_updates=[
|
||||
# Text deltas
|
||||
AgentResponseUpdate(contents=[Content.from_text("Let me ")], role="assistant"),
|
||||
AgentResponseUpdate(contents=[Content.from_text("search...")], role="assistant"),
|
||||
# Function call argument deltas
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call("call_1", "search", arguments='{"q":')],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call("call_1", "search", arguments=' "x"}')],
|
||||
role="assistant",
|
||||
),
|
||||
# More text deltas
|
||||
AgentResponseUpdate(contents=[Content.from_text("Found ")], role="assistant"),
|
||||
AgentResponseUpdate(contents=[Content.from_text("it!")], role="assistant"),
|
||||
]
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=True)
|
||||
|
||||
assert resp.status_code == 200
|
||||
events = _parse_sse_events(resp.text)
|
||||
types = _sse_event_types(events)
|
||||
|
||||
assert types[0] == "response.created"
|
||||
assert types[-1] == "response.completed"
|
||||
|
||||
# 4 text deltas + 2 function call argument deltas
|
||||
assert types.count("response.output_text.delta") == 4
|
||||
assert types.count("response.function_call_arguments.delta") == 2
|
||||
|
||||
# 3 distinct output items (text, fc, text)
|
||||
assert types.count("response.output_item.added") == 3
|
||||
assert types.count("response.output_item.done") == 3
|
||||
|
||||
# Verify accumulated content
|
||||
text_done = [e for e in events if e["event"] == "response.output_text.done"]
|
||||
assert len(text_done) == 2
|
||||
assert text_done[0]["data"]["text"] == "Let me search..."
|
||||
assert text_done[1]["data"]["text"] == "Found it!"
|
||||
|
||||
args_done = [e for e in events if e["event"] == "response.function_call_arguments.done"]
|
||||
assert len(args_done) == 1
|
||||
assert args_done[0]["data"]["arguments"] == '{"q": "x"}'
|
||||
|
||||
async def test_reasoning_then_text_streaming(self) -> None:
|
||||
agent = _make_agent(
|
||||
stream_updates=[
|
||||
# Reasoning deltas
|
||||
AgentResponseUpdate(contents=[Content.from_text_reasoning(text="Let me ")], role="assistant"),
|
||||
AgentResponseUpdate(contents=[Content.from_text_reasoning(text="think...")], role="assistant"),
|
||||
# Text deltas
|
||||
AgentResponseUpdate(contents=[Content.from_text("The answer ")], role="assistant"),
|
||||
AgentResponseUpdate(contents=[Content.from_text("is 42")], role="assistant"),
|
||||
]
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=True)
|
||||
|
||||
assert resp.status_code == 200
|
||||
events = _parse_sse_events(resp.text)
|
||||
types = _sse_event_types(events)
|
||||
|
||||
assert types[0] == "response.created"
|
||||
assert types[-1] == "response.completed"
|
||||
# Reasoning + text = 2 output items
|
||||
assert types.count("response.output_item.added") == 2
|
||||
assert types.count("response.output_item.done") == 2
|
||||
assert types.count("response.output_text.delta") == 2
|
||||
|
||||
# Verify accumulated text
|
||||
text_done = [e for e in events if e["event"] == "response.output_text.done"]
|
||||
assert len(text_done) == 1
|
||||
assert text_done[0]["data"]["text"] == "The answer is 42"
|
||||
|
||||
async def test_empty_streaming(self) -> None:
|
||||
agent = _make_agent(stream_updates=[])
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=True)
|
||||
|
||||
assert resp.status_code == 200
|
||||
events = _parse_sse_events(resp.text)
|
||||
types = _sse_event_types(events)
|
||||
|
||||
assert types == ["response.created", "response.in_progress", "response.completed"]
|
||||
|
||||
async def test_mixed_contents_in_single_update(self) -> None:
|
||||
"""Text and function call in one update switches builder mid-update."""
|
||||
agent = _make_agent(
|
||||
stream_updates=[
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_text("Let me search"),
|
||||
Content.from_function_call("call_1", "search", arguments='{"q": "test"}'),
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=True)
|
||||
|
||||
assert resp.status_code == 200
|
||||
events = _parse_sse_events(resp.text)
|
||||
types = _sse_event_types(events)
|
||||
|
||||
assert "response.output_text.delta" in types
|
||||
assert "response.output_text.done" in types
|
||||
assert "response.function_call_arguments.delta" in types
|
||||
assert "response.function_call_arguments.done" in types
|
||||
|
||||
async def test_different_function_call_ids_produce_separate_items(self) -> None:
|
||||
agent = _make_agent(
|
||||
stream_updates=[
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call("call_1", "func_a", arguments='{"x":1}')],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call("call_2", "func_b", arguments='{"y":2}')],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=True)
|
||||
|
||||
assert resp.status_code == 200
|
||||
events = _parse_sse_events(resp.text)
|
||||
types = _sse_event_types(events)
|
||||
|
||||
# Two separate function call items
|
||||
assert types.count("response.output_item.added") == 2
|
||||
assert types.count("response.function_call_arguments.done") == 2
|
||||
|
||||
async def test_mcp_tool_call_streaming(self) -> None:
|
||||
agent = _make_agent(
|
||||
stream_updates=[
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content(
|
||||
type="mcp_server_tool_call",
|
||||
server_name="my_server",
|
||||
tool_name="search",
|
||||
arguments='{"query":',
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content(
|
||||
type="mcp_server_tool_call",
|
||||
server_name="my_server",
|
||||
tool_name="search",
|
||||
arguments=' "test"}',
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=True)
|
||||
|
||||
assert resp.status_code == 200
|
||||
events = _parse_sse_events(resp.text)
|
||||
types = _sse_event_types(events)
|
||||
|
||||
assert types[0] == "response.created"
|
||||
assert types[-1] == "response.completed"
|
||||
assert "response.output_item.added" in types
|
||||
assert "response.output_item.done" in types
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region _to_message conversion
|
||||
|
||||
|
||||
class TestToMessage:
|
||||
"""Tests for _to_message covering all supported OutputItem types."""
|
||||
|
||||
def test_output_message(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemOutputMessage, OutputMessageContentOutputTextContent
|
||||
|
||||
item = OutputItemOutputMessage({
|
||||
"type": "output_message",
|
||||
"role": "assistant",
|
||||
"content": [OutputMessageContentOutputTextContent({"type": "output_text", "text": "hello"})],
|
||||
"status": "completed",
|
||||
"id": "msg-1",
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert len(msg.contents) == 1
|
||||
assert msg.contents[0].type == "text"
|
||||
assert msg.contents[0].text == "hello"
|
||||
|
||||
def test_message(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import MessageContentInputTextContent, OutputItemMessage
|
||||
|
||||
item = OutputItemMessage({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [MessageContentInputTextContent({"type": "input_text", "text": "hi"})],
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "user"
|
||||
assert len(msg.contents) == 1
|
||||
assert msg.contents[0].text == "hi"
|
||||
|
||||
def test_function_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemFunctionToolCall
|
||||
|
||||
item = OutputItemFunctionToolCall({
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "NYC"}',
|
||||
"status": "completed",
|
||||
"id": "fc-1",
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].type == "function_call"
|
||||
assert msg.contents[0].call_id == "call_1"
|
||||
assert msg.contents[0].name == "get_weather"
|
||||
|
||||
def test_function_call_output(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam
|
||||
|
||||
item = FunctionCallOutputItemParam({"type": "function_call_output", "call_id": "call_1", "output": "sunny"})
|
||||
msg = _to_message(item) # type: ignore[arg-type]
|
||||
assert msg.role == "tool"
|
||||
assert msg.contents[0].type == "function_result"
|
||||
assert msg.contents[0].call_id == "call_1"
|
||||
assert msg.contents[0].result == "sunny"
|
||||
|
||||
def test_reasoning(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemReasoningItem, SummaryTextContent
|
||||
|
||||
item = OutputItemReasoningItem({
|
||||
"type": "reasoning",
|
||||
"id": "r-1",
|
||||
"summary": [SummaryTextContent({"type": "summary_text", "text": "thinking hard"})],
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert len(msg.contents) == 1
|
||||
assert msg.contents[0].text == "thinking hard"
|
||||
|
||||
def test_reasoning_no_summary(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemReasoningItem
|
||||
|
||||
item = OutputItemReasoningItem({"type": "reasoning", "id": "r-2"})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents == []
|
||||
|
||||
def test_mcp_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemMcpToolCall
|
||||
|
||||
item = OutputItemMcpToolCall({
|
||||
"type": "mcp_call",
|
||||
"id": "mcp-1",
|
||||
"server_label": "my_server",
|
||||
"name": "search",
|
||||
"arguments": '{"q": "test"}',
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].type == "mcp_server_tool_call"
|
||||
assert msg.contents[0].server_name == "my_server"
|
||||
assert msg.contents[0].tool_name == "search"
|
||||
|
||||
def test_mcp_approval_request(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemMcpApprovalRequest
|
||||
|
||||
item = OutputItemMcpApprovalRequest({
|
||||
"type": "mcp_approval_request",
|
||||
"id": "apr-1",
|
||||
"server_label": "srv",
|
||||
"name": "dangerous_tool",
|
||||
"arguments": "{}",
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].type == "function_approval_request"
|
||||
|
||||
def test_mcp_approval_response(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemMcpApprovalResponseResource
|
||||
|
||||
item = OutputItemMcpApprovalResponseResource({
|
||||
"type": "mcp_approval_response",
|
||||
"id": "resp-1",
|
||||
"approval_request_id": "apr-1",
|
||||
"approve": True,
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "user"
|
||||
assert msg.contents[0].type == "function_approval_response"
|
||||
assert msg.contents[0].approved is True
|
||||
|
||||
def test_code_interpreter_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemCodeInterpreterToolCall
|
||||
|
||||
item = OutputItemCodeInterpreterToolCall({
|
||||
"type": "code_interpreter_call",
|
||||
"id": "ci-1",
|
||||
"status": "completed",
|
||||
"container_id": "c-1",
|
||||
"code": "print('hi')",
|
||||
"outputs": [],
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].type == "code_interpreter_tool_call"
|
||||
|
||||
def test_image_generation_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemImageGenToolCall
|
||||
|
||||
item = OutputItemImageGenToolCall({"type": "image_generation_call", "id": "ig-1", "status": "completed"})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].type == "image_generation_tool_call"
|
||||
|
||||
def test_shell_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import (
|
||||
FunctionShellAction,
|
||||
FunctionShellCallEnvironment,
|
||||
OutputItemFunctionShellCall,
|
||||
)
|
||||
|
||||
item = OutputItemFunctionShellCall({
|
||||
"type": "shell_call",
|
||||
"id": "sc-1",
|
||||
"call_id": "call_sc",
|
||||
"action": FunctionShellAction({"commands": ["ls", "-la"], "timeout_ms": 5000, "max_output_length": 1024}),
|
||||
"status": "completed",
|
||||
"environment": FunctionShellCallEnvironment({"type": "local"}),
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].type == "shell_tool_call"
|
||||
assert msg.contents[0].commands == ["ls", "-la"]
|
||||
assert msg.contents[0].call_id == "call_sc"
|
||||
|
||||
def test_shell_call_output(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import (
|
||||
FunctionShellCallOutputContent,
|
||||
FunctionShellCallOutputExitOutcome,
|
||||
OutputItemFunctionShellCallOutput,
|
||||
)
|
||||
|
||||
item = OutputItemFunctionShellCallOutput({
|
||||
"type": "shell_call_output",
|
||||
"id": "sco-1",
|
||||
"call_id": "call_sc",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
FunctionShellCallOutputContent({
|
||||
"stdout": "file.txt",
|
||||
"stderr": "",
|
||||
"outcome": FunctionShellCallOutputExitOutcome({"exit_code": 0}),
|
||||
})
|
||||
],
|
||||
"max_output_length": 1024,
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "tool"
|
||||
assert msg.contents[0].type == "shell_tool_result"
|
||||
assert msg.contents[0].call_id == "call_sc"
|
||||
|
||||
def test_local_shell_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import LocalShellExecAction, OutputItemLocalShellToolCall
|
||||
|
||||
item = OutputItemLocalShellToolCall({
|
||||
"type": "local_shell_call",
|
||||
"id": "lsc-1",
|
||||
"call_id": "call_lsc",
|
||||
"action": LocalShellExecAction({"type": "exec", "command": ["echo", "hello"], "env": {}}),
|
||||
"status": "completed",
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].type == "shell_tool_call"
|
||||
assert msg.contents[0].commands == ["echo", "hello"]
|
||||
|
||||
def test_local_shell_call_output(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemLocalShellToolCallOutput
|
||||
|
||||
item = OutputItemLocalShellToolCallOutput({
|
||||
"type": "local_shell_call_output",
|
||||
"id": "lsco-1",
|
||||
"output": "hello\n",
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "tool"
|
||||
assert msg.contents[0].type == "shell_tool_result"
|
||||
|
||||
def test_file_search_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemFileSearchToolCall
|
||||
|
||||
item = OutputItemFileSearchToolCall({
|
||||
"type": "file_search_call",
|
||||
"id": "fs-1",
|
||||
"status": "completed",
|
||||
"queries": ["what is AI"],
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].type == "function_call"
|
||||
assert msg.contents[0].name == "file_search"
|
||||
assert '"what is AI"' in (msg.contents[0].arguments or "")
|
||||
|
||||
def test_web_search_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemWebSearchToolCall, WebSearchActionSearch
|
||||
|
||||
item = OutputItemWebSearchToolCall({
|
||||
"type": "web_search_call",
|
||||
"id": "ws-1",
|
||||
"status": "completed",
|
||||
"action": WebSearchActionSearch({"type": "search", "query": "test"}),
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].type == "function_call"
|
||||
assert msg.contents[0].name == "web_search"
|
||||
|
||||
def test_computer_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import ComputerAction, OutputItemComputerToolCall
|
||||
|
||||
item = OutputItemComputerToolCall({
|
||||
"type": "computer_call",
|
||||
"id": "cc-1",
|
||||
"call_id": "call_cc",
|
||||
"action": ComputerAction({"type": "click"}),
|
||||
"pending_safety_checks": [],
|
||||
"status": "completed",
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].type == "function_call"
|
||||
assert msg.contents[0].name == "computer_use"
|
||||
|
||||
def test_computer_call_output(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import (
|
||||
ComputerScreenshotImage,
|
||||
OutputItemComputerToolCallOutputResource,
|
||||
)
|
||||
|
||||
item = OutputItemComputerToolCallOutputResource({
|
||||
"type": "computer_call_output",
|
||||
"call_id": "call_cc",
|
||||
"output": ComputerScreenshotImage({
|
||||
"type": "computer_screenshot",
|
||||
"image_url": "data:image/png;base64,abc",
|
||||
}),
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "tool"
|
||||
assert msg.contents[0].type == "function_result"
|
||||
assert msg.contents[0].call_id == "call_cc"
|
||||
|
||||
def test_custom_tool_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemCustomToolCall
|
||||
|
||||
item = OutputItemCustomToolCall({
|
||||
"type": "custom_tool_call",
|
||||
"call_id": "call_ct",
|
||||
"name": "my_tool",
|
||||
"input": '{"key": "value"}',
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].type == "function_call"
|
||||
assert msg.contents[0].name == "my_tool"
|
||||
assert msg.contents[0].arguments == '{"key": "value"}'
|
||||
|
||||
def test_custom_tool_call_output(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemCustomToolCallOutput
|
||||
|
||||
item = OutputItemCustomToolCallOutput({
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call_ct",
|
||||
"output": "result text",
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "tool"
|
||||
assert msg.contents[0].type == "function_result"
|
||||
assert msg.contents[0].result == "result text"
|
||||
|
||||
def test_apply_patch_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import ApplyPatchUpdateFileOperation, OutputItemApplyPatchToolCall
|
||||
|
||||
item = OutputItemApplyPatchToolCall({
|
||||
"type": "apply_patch_call",
|
||||
"id": "ap-1",
|
||||
"call_id": "call_ap",
|
||||
"status": "completed",
|
||||
"operation": ApplyPatchUpdateFileOperation({
|
||||
"type": "update_file",
|
||||
"path": "file.py",
|
||||
"diff": "+ new line",
|
||||
}),
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].type == "function_call"
|
||||
assert msg.contents[0].name == "apply_patch"
|
||||
|
||||
def test_apply_patch_call_output(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItemApplyPatchToolCallOutput
|
||||
|
||||
item = OutputItemApplyPatchToolCallOutput({
|
||||
"type": "apply_patch_call_output",
|
||||
"id": "apo-1",
|
||||
"call_id": "call_ap",
|
||||
"status": "completed",
|
||||
"output": "patch applied",
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "tool"
|
||||
assert msg.contents[0].type == "function_result"
|
||||
assert msg.contents[0].result == "patch applied"
|
||||
|
||||
def test_oauth_consent_request(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OAuthConsentRequestOutputItem
|
||||
|
||||
item = OAuthConsentRequestOutputItem({
|
||||
"type": "oauth_consent_request",
|
||||
"id": "oauth-1",
|
||||
"consent_link": "https://example.com/consent",
|
||||
"server_label": "my_server",
|
||||
})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].type == "oauth_consent_request"
|
||||
assert msg.contents[0].consent_link == "https://example.com/consent"
|
||||
|
||||
def test_structured_outputs_dict(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import StructuredOutputsOutputItem
|
||||
|
||||
item = StructuredOutputsOutputItem({"type": "structured_outputs", "id": "so-1", "output": {"answer": 42}})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].type == "text"
|
||||
assert json.loads(msg.contents[0].text or "") == {"answer": 42}
|
||||
|
||||
def test_structured_outputs_string(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import StructuredOutputsOutputItem
|
||||
|
||||
item = StructuredOutputsOutputItem({"type": "structured_outputs", "id": "so-2", "output": "plain text"})
|
||||
msg = _to_message(item)
|
||||
assert msg.role == "assistant"
|
||||
assert msg.contents[0].text == "plain text"
|
||||
|
||||
def test_unsupported_type_raises(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import OutputItem
|
||||
|
||||
item = OutputItem({"type": "some_unknown_type"})
|
||||
with pytest.raises(ValueError, match="Unsupported OutputItem type: some_unknown_type"):
|
||||
_to_message(item)
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-openai>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"agent-framework-openai>=1.1.0,<2",
|
||||
"foundry-local-sdk>=0.5.1,<0.5.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260410"
|
||||
version = "1.0.0a260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2.0",
|
||||
"agent-framework-core>=1.1.0,<2.0",
|
||||
"google-genai>=1.0.0,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -431,7 +431,7 @@ def _build_execution_contents(
|
||||
outputs.append(Content.from_text(stderr, raw_representation=result))
|
||||
if not outputs:
|
||||
outputs.append(Content.from_text("Code executed successfully without output."))
|
||||
return [Content.from_code_interpreter_tool_result(outputs=outputs, raw_representation=result)]
|
||||
return outputs
|
||||
|
||||
error_details = stderr or "Unknown sandbox error"
|
||||
outputs.append(
|
||||
@@ -441,12 +441,16 @@ def _build_execution_contents(
|
||||
raw_representation=result,
|
||||
)
|
||||
)
|
||||
return [Content.from_code_interpreter_tool_result(outputs=outputs, raw_representation=result)]
|
||||
return outputs
|
||||
|
||||
|
||||
def _make_sandbox_callback(tool_obj: FunctionTool) -> Callable[..., Any]:
|
||||
sandbox_tool = copy.copy(tool_obj)
|
||||
sandbox_tool.result_parser = _passthrough_result_parser
|
||||
# Auto-assign a passthrough parser so the raw return value round-trips through
|
||||
# `ast.literal_eval` in the sandbox callback below. User-supplied parsers are
|
||||
# left in place so callers can customize how results are exposed to the guest.
|
||||
if sandbox_tool.result_parser is None:
|
||||
sandbox_tool.result_parser = _passthrough_result_parser
|
||||
|
||||
def _callback(**kwargs: Any) -> Any:
|
||||
async def _invoke() -> list[Content]:
|
||||
@@ -765,6 +769,7 @@ class HyperlightExecuteCodeTool(FunctionTool):
|
||||
return build_codeact_instructions(
|
||||
tools=config.tools,
|
||||
tools_visible_to_model=tools_visible_to_model,
|
||||
filesystem_enabled=config.filesystem_enabled,
|
||||
)
|
||||
|
||||
def create_run_tool(self) -> HyperlightExecuteCodeTool:
|
||||
|
||||
@@ -68,6 +68,7 @@ def build_codeact_instructions(
|
||||
*,
|
||||
tools: Sequence[FunctionTool],
|
||||
tools_visible_to_model: bool,
|
||||
filesystem_enabled: bool = False,
|
||||
) -> str:
|
||||
"""Build dynamic CodeAct instructions for the effective sandbox state."""
|
||||
usage_note = (
|
||||
@@ -77,12 +78,24 @@ def build_codeact_instructions(
|
||||
else "Provider-owned sandbox tools are not exposed separately; use `execute_code` when you need them."
|
||||
)
|
||||
|
||||
output_note = (
|
||||
"To surface results from `execute_code`, end the code with `print(...)`; the sandbox does not "
|
||||
"return the value of the last expression."
|
||||
)
|
||||
if filesystem_enabled:
|
||||
output_note += (
|
||||
" For larger artifacts, write them to `/output/<filename>` instead — returned files will be "
|
||||
"attached to the tool result."
|
||||
)
|
||||
|
||||
return f"""You have one primary tool: execute_code.
|
||||
|
||||
Prefer one execute_code call per request when possible.
|
||||
Its tool description contains the current `call_tool(...)` guidance, sandbox
|
||||
tool registry, and capability limits.
|
||||
|
||||
{output_note}
|
||||
|
||||
{usage_note}
|
||||
"""
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Hyperlight CodeAct integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260409"
|
||||
version = "1.0.0a260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,9 +22,9 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"hyperlight-sandbox>=0.3.0,<0.4",
|
||||
"hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; (sys_platform == 'linux' or sys_platform == 'win32') and python_version < '3.14'",
|
||||
"hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'",
|
||||
"hyperlight-sandbox-python-guest>=0.3.0,<0.4",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Benchmark CodeAct vs. traditional tool-calling for a multi-tool-call task.
|
||||
|
||||
This sample runs the same prompt against the same FoundryChatClient twice:
|
||||
|
||||
1. **Traditional tool-calling**: the five business tools are passed directly to
|
||||
the agent, so the model calls each tool individually via the LLM tool-call
|
||||
interface.
|
||||
2. **CodeAct**: the same tools are registered on a HyperlightCodeActProvider
|
||||
and the model sees a single ``execute_code`` tool that calls them from
|
||||
inside the Hyperlight sandbox via ``call_tool(...)``.
|
||||
|
||||
The task (computing grand totals per user) naturally requires many tool calls
|
||||
to complete. At the end, the sample prints elapsed time and token usage for
|
||||
each run so the two approaches can be compared.
|
||||
|
||||
Run with:
|
||||
cd python
|
||||
uv run --directory packages/hyperlight python samples/codeact_benchmark.py
|
||||
|
||||
Required environment variables (loaded from ``.env`` if present):
|
||||
FOUNDRY_PROJECT_ENDPOINT
|
||||
FOUNDRY_MODEL
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from agent_framework import Agent, AgentResponse, UsageDetails
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent_framework_hyperlight import HyperlightCodeActProvider
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# 1. Deterministic "business" data and tools.
|
||||
|
||||
_USERS: list[dict[str, Any]] = [
|
||||
{"id": 1, "name": "Alice", "region": "EU", "tier": "gold"},
|
||||
{"id": 2, "name": "Bob", "region": "US", "tier": "silver"},
|
||||
{"id": 3, "name": "Charlie", "region": "US", "tier": "gold"},
|
||||
{"id": 4, "name": "Diana", "region": "APAC", "tier": "bronze"},
|
||||
{"id": 5, "name": "Evan", "region": "EU", "tier": "silver"},
|
||||
{"id": 6, "name": "Fiona", "region": "US", "tier": "gold"},
|
||||
{"id": 7, "name": "George", "region": "APAC", "tier": "gold"},
|
||||
{"id": 8, "name": "Hana", "region": "EU", "tier": "bronze"},
|
||||
]
|
||||
|
||||
_ORDERS: dict[int, list[dict[str, Any]]] = {
|
||||
1: [{"product": "Widget", "qty": 3, "unit_price": 9.99}, {"product": "Gadget", "qty": 1, "unit_price": 19.99}],
|
||||
2: [{"product": "Widget", "qty": 1, "unit_price": 9.99}],
|
||||
3: [{"product": "Gadget", "qty": 2, "unit_price": 19.99}, {"product": "Thingamajig", "qty": 4, "unit_price": 4.50}],
|
||||
4: [{"product": "Widget", "qty": 10, "unit_price": 9.99}],
|
||||
5: [{"product": "Gadget", "qty": 1, "unit_price": 19.99}],
|
||||
6: [{"product": "Widget", "qty": 2, "unit_price": 9.99}, {"product": "Thingamajig", "qty": 5, "unit_price": 4.50}],
|
||||
7: [{"product": "Gadget", "qty": 3, "unit_price": 19.99}],
|
||||
8: [{"product": "Thingamajig", "qty": 2, "unit_price": 4.50}],
|
||||
}
|
||||
|
||||
_DISCOUNTS: dict[str, float] = {"gold": 0.20, "silver": 0.10, "bronze": 0.05}
|
||||
_TAX_RATES: dict[str, float] = {"EU": 0.21, "US": 0.08, "APAC": 0.10}
|
||||
|
||||
|
||||
def list_users() -> list[dict[str, Any]]:
|
||||
"""Return all users as a list of dictionaries.
|
||||
|
||||
Each entry has keys: id (int), name (str), region (str), tier (str).
|
||||
"""
|
||||
return _USERS
|
||||
|
||||
|
||||
def get_orders_for_user(
|
||||
user_id: Annotated[int, "The user id whose orders to retrieve."],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the user's orders as a list of dictionaries.
|
||||
|
||||
Each entry has keys: product (str), qty (int), unit_price (float).
|
||||
"""
|
||||
return _ORDERS.get(user_id, [])
|
||||
|
||||
|
||||
def get_discount_rate(
|
||||
tier: Annotated[Literal["gold", "silver", "bronze"], "The customer tier."],
|
||||
) -> float:
|
||||
"""Return the discount rate as a float fraction (e.g. 0.2 for 20%)."""
|
||||
return _DISCOUNTS[tier]
|
||||
|
||||
|
||||
def get_tax_rate(
|
||||
region: Annotated[Literal["EU", "US", "APAC"], "The region code."],
|
||||
) -> float:
|
||||
"""Return the tax rate as a float fraction (e.g. 0.21 for 21%)."""
|
||||
return _TAX_RATES[region]
|
||||
|
||||
|
||||
def compute_line_total(
|
||||
qty: Annotated[int, "Line item quantity."],
|
||||
unit_price: Annotated[float, "Line item unit price."],
|
||||
discount_rate: Annotated[float, "Discount rate as a fraction (e.g. 0.2 for 20%)."],
|
||||
tax_rate: Annotated[float, "Tax rate as a fraction (e.g. 0.21 for 21%)."],
|
||||
) -> float:
|
||||
"""Compute a single order line total.
|
||||
|
||||
Formula: qty * unit_price * (1 - discount_rate) * (1 + tax_rate), rounded to 2 decimals.
|
||||
"""
|
||||
subtotal = qty * unit_price
|
||||
discounted = subtotal * (1.0 - discount_rate)
|
||||
return round(discounted * (1.0 + tax_rate), 2)
|
||||
|
||||
|
||||
TOOLS = [list_users, get_orders_for_user, get_discount_rate, get_tax_rate, compute_line_total]
|
||||
|
||||
|
||||
# 2. Structured output schema shared between both runs.
|
||||
|
||||
|
||||
class UserTotal(BaseModel):
|
||||
"""A user's grand total of all their orders."""
|
||||
|
||||
user_id: int = Field(description="The user's id.")
|
||||
name: str = Field(description="The user's display name.")
|
||||
grand_total: float = Field(description="Sum of all line totals, rounded to 2 decimals.")
|
||||
|
||||
|
||||
class UserGrandTotals(BaseModel):
|
||||
"""Structured output schema for both runs."""
|
||||
|
||||
results: list[UserTotal] = Field(description="One entry per user, sorted by grand_total descending.")
|
||||
|
||||
|
||||
INSTRUCTIONS = "You are a careful assistant. Use the provided tools for every lookup and computation."
|
||||
|
||||
BENCHMARK_PROMPT = (
|
||||
"For every user in our system (there are 8 of them), compute the grand total of all their orders. "
|
||||
"Use the compute_line_total tool for each user's orders, after looking up the relevant discount and "
|
||||
"tax rates for that user. "
|
||||
"Use the provided tools for EVERY data lookup (users, orders, discount rates, tax rates) and for EVERY "
|
||||
"line-total computation via compute_line_total — do not invent values or hardcode any numbers. "
|
||||
"The total per order item should apply the discount first and then the tax "
|
||||
"(e.g. total = qty * unit_price * (1-discount) * (1+tax)). "
|
||||
"Return one entry per user, sorted by grand_total descending."
|
||||
)
|
||||
|
||||
|
||||
def get_client() -> FoundryChatClient:
|
||||
"""Create a FoundryChatClient from environment variables."""
|
||||
return FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
|
||||
# 3. Two runners that share the same tools, prompt, and structured output schema.
|
||||
|
||||
|
||||
async def _run_traditional() -> tuple[float, AgentResponse]:
|
||||
agent = Agent(
|
||||
client=get_client(),
|
||||
name="TraditionalAgent",
|
||||
instructions=INSTRUCTIONS,
|
||||
tools=TOOLS,
|
||||
default_options={"response_format": UserGrandTotals},
|
||||
)
|
||||
start = time.perf_counter()
|
||||
result = await agent.run(BENCHMARK_PROMPT)
|
||||
elapsed = time.perf_counter() - start
|
||||
return elapsed, result
|
||||
|
||||
|
||||
async def _run_codeact() -> tuple[float, AgentResponse]:
|
||||
codeact = HyperlightCodeActProvider(
|
||||
tools=TOOLS,
|
||||
approval_mode="never_require",
|
||||
)
|
||||
agent = Agent(
|
||||
client=get_client(),
|
||||
name="CodeActAgent",
|
||||
instructions=INSTRUCTIONS,
|
||||
context_providers=[codeact],
|
||||
default_options={"response_format": UserGrandTotals},
|
||||
)
|
||||
start = time.perf_counter()
|
||||
result = await agent.run(BENCHMARK_PROMPT)
|
||||
elapsed = time.perf_counter() - start
|
||||
return elapsed, result
|
||||
|
||||
|
||||
# 4. Report results side by side.
|
||||
|
||||
|
||||
def _print_section(title: str) -> None:
|
||||
bar = "=" * 70
|
||||
print(f"\n{bar}\n{title}\n{bar}")
|
||||
|
||||
|
||||
def _format_usage(usage: UsageDetails | None) -> str:
|
||||
if usage is None:
|
||||
return "usage=<none>"
|
||||
return (
|
||||
f"input={usage.get('input_token_count') or 0:>6} "
|
||||
f"output={usage.get('output_token_count') or 0:>6} "
|
||||
f"total={usage.get('total_token_count') or 0:>6}"
|
||||
)
|
||||
|
||||
|
||||
def _print_results(result: AgentResponse) -> None:
|
||||
if result.value is not None:
|
||||
for row in result.value.results:
|
||||
print(f" user_id={row.user_id:>2} name={row.name:<8} grand_total={row.grand_total:>8.2f}")
|
||||
else:
|
||||
print(result.text)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the benchmark and print a comparison."""
|
||||
trad_time, trad_result = await _run_traditional()
|
||||
code_time, code_result = await _run_codeact()
|
||||
|
||||
_print_section("Traditional tool-calling")
|
||||
print(f"time={trad_time:7.2f}s {_format_usage(trad_result.usage_details)}")
|
||||
_print_results(trad_result)
|
||||
|
||||
_print_section("CodeAct (HyperlightCodeActProvider)")
|
||||
print(f"time={code_time:7.2f}s {_format_usage(code_result.usage_details)}")
|
||||
_print_results(code_result)
|
||||
|
||||
_print_section("Comparison")
|
||||
trad_total = (trad_result.usage_details or {}).get("total_token_count") or 0
|
||||
code_total = (code_result.usage_details or {}).get("total_token_count") or 0
|
||||
|
||||
def pct(new: float, old: float) -> str:
|
||||
if old == 0:
|
||||
return "n/a"
|
||||
delta = (new - old) / old * 100
|
||||
sign = "+" if delta >= 0 else ""
|
||||
return f"{sign}{delta:.1f}%"
|
||||
|
||||
print(f"time : traditional={trad_time:7.2f}s codeact={code_time:7.2f}s delta={pct(code_time, trad_time)}")
|
||||
print(f"tokens : traditional={trad_total:7d} codeact={code_total:7d} delta={pct(code_total, trad_total)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -72,15 +72,11 @@ async def log_function_calls(
|
||||
|
||||
result = context.result
|
||||
if function_name == "execute_code" and isinstance(result, list):
|
||||
for item in result:
|
||||
if item.type != "code_interpreter_tool_result":
|
||||
continue
|
||||
|
||||
for output in item.outputs or []:
|
||||
if output.type == "text" and output.text:
|
||||
print(f"{_GREEN}stdout:\n{output.text}{_RESET}")
|
||||
if output.type == "error" and output.error_details:
|
||||
print(f"{_YELLOW}stderr:\n{output.error_details}{_RESET}")
|
||||
for output in result:
|
||||
if output.type == "text" and output.text:
|
||||
print(f"{_GREEN}stdout:\n{output.text}{_RESET}")
|
||||
elif output.type == "error" and output.error_details:
|
||||
print(f"{_YELLOW}stderr:\n{output.error_details}{_RESET}")
|
||||
else:
|
||||
print(f"{_YELLOW}◀ {function_name} → {result!r}{_RESET}")
|
||||
|
||||
|
||||
@@ -289,38 +289,20 @@ class _FakeSessionContext:
|
||||
self.tools.append((source_id, tools))
|
||||
|
||||
|
||||
def _extract_execute_code_result(function_result: Content) -> Content:
|
||||
def _extract_text_output(function_result: Content) -> str:
|
||||
assert function_result.type == "function_result"
|
||||
assert function_result.exception is None, (
|
||||
f"execute_code raised {function_result.exception!r} with items={function_result.items!r}"
|
||||
)
|
||||
|
||||
code_result = next(
|
||||
(item for item in function_result.items or [] if item.type == "code_interpreter_tool_result"),
|
||||
text_output = next(
|
||||
(item for item in function_result.items or [] if item.type == "text" and item.text is not None),
|
||||
None,
|
||||
)
|
||||
if code_result is not None:
|
||||
return code_result
|
||||
|
||||
text_outputs = [item for item in function_result.items or [] if item.type == "text"]
|
||||
if text_outputs:
|
||||
return Content.from_code_interpreter_tool_result(outputs=text_outputs)
|
||||
|
||||
if text_output is not None and text_output.text is not None:
|
||||
return text_output.text
|
||||
if function_result.result:
|
||||
return Content.from_code_interpreter_tool_result(outputs=[Content.from_text(function_result.result)])
|
||||
|
||||
raise AssertionError(f"execute_code returned no usable outputs: {function_result.items!r}")
|
||||
|
||||
|
||||
def _extract_text_output(result_content: Content) -> str:
|
||||
code_result = _extract_execute_code_result(result_content)
|
||||
text_output = next(
|
||||
(item for item in code_result.outputs or [] if item.type == "text" and item.text is not None), None
|
||||
)
|
||||
assert text_output is not None and text_output.text is not None, (
|
||||
f"Expected text output from execute_code, got {code_result.outputs!r}"
|
||||
)
|
||||
return text_output.text
|
||||
return function_result.result
|
||||
raise AssertionError(f"Expected text output from execute_code, got {function_result.items!r}")
|
||||
|
||||
|
||||
class _FakeCodeActChatClient(FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
@@ -432,7 +414,7 @@ async def test_execute_code_tool_populates_input_dir_with_workspace_and_file_mou
|
||||
)
|
||||
result = await execute_code.invoke(arguments={"code": "None"})
|
||||
|
||||
assert result[0].type == "code_interpreter_tool_result"
|
||||
assert result[0].type == "text"
|
||||
assert _FakeSandbox.instances[0].input_dir is not None
|
||||
|
||||
input_root = Path(_FakeSandbox.instances[0].input_dir)
|
||||
@@ -493,11 +475,9 @@ async def test_execute_code_tool_executes_with_structured_content(monkeypatch: p
|
||||
|
||||
result = await execute_code.invoke(arguments={"code": "create-output"})
|
||||
|
||||
assert result[0].type == "code_interpreter_tool_result"
|
||||
assert result[0].outputs is not None
|
||||
assert result[0].outputs[0].type == "text"
|
||||
assert result[0].outputs[0].text == "done\n"
|
||||
assert any(item.type == "data" for item in result[0].outputs)
|
||||
assert result[0].type == "text"
|
||||
assert result[0].text == "done\n"
|
||||
assert any(item.type == "data" for item in result)
|
||||
assert _FakeSandbox.instances[0].allowed_domains == [("api.example.com", ["GET"])]
|
||||
assert "compute" in _FakeSandbox.instances[0].registered_tools
|
||||
|
||||
@@ -512,11 +492,8 @@ async def test_execute_code_tool_collects_output_files_without_backend_listing(
|
||||
)
|
||||
result = await execute_code.invoke(arguments={"code": "create-output"})
|
||||
|
||||
assert result[0].type == "code_interpreter_tool_result"
|
||||
assert result[0].outputs is not None
|
||||
assert any(
|
||||
item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result[0].outputs
|
||||
)
|
||||
assert result[0].type == "text"
|
||||
assert any(item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result)
|
||||
|
||||
|
||||
async def test_execute_code_tool_waits_for_unlisted_output_files_to_appear(
|
||||
@@ -535,11 +512,7 @@ async def test_execute_code_tool_waits_for_unlisted_output_files_to_appear(
|
||||
for writer_thread in _FakeSandboxWithDelayedUnlistedOutput.writer_threads:
|
||||
writer_thread.join()
|
||||
|
||||
assert result[0].type == "code_interpreter_tool_result"
|
||||
assert result[0].outputs is not None
|
||||
assert any(
|
||||
item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result[0].outputs
|
||||
)
|
||||
assert any(item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result)
|
||||
|
||||
|
||||
async def test_execute_code_tool_failure_returns_error_content(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -549,10 +522,8 @@ async def test_execute_code_tool_failure_returns_error_content(monkeypatch: pyte
|
||||
execute_code = HyperlightExecuteCodeTool()
|
||||
result = await execute_code.invoke(arguments={"code": "fail"})
|
||||
|
||||
assert result[0].type == "code_interpreter_tool_result"
|
||||
assert result[0].outputs is not None
|
||||
assert result[0].outputs[0].type == "error"
|
||||
assert result[0].outputs[0].error_details == "sandbox boom"
|
||||
assert result[0].type == "error"
|
||||
assert result[0].error_details == "sandbox boom"
|
||||
|
||||
|
||||
async def test_execute_code_tool_retries_allowed_domains_with_urls_when_backend_rejects_host_targets(
|
||||
@@ -596,7 +567,7 @@ async def test_execute_code_tool_retries_allowed_domains_with_urls_when_backend_
|
||||
execute_code = HyperlightExecuteCodeTool(allowed_domains=[("127.0.0.1:8080", "get")])
|
||||
result = await execute_code.invoke(arguments={"code": "None"})
|
||||
|
||||
assert result[0].type == "code_interpreter_tool_result"
|
||||
assert result[0].type == "text"
|
||||
assert len(_FakeStrictNetworkSandbox.instances) == 2
|
||||
assert _FakeStrictNetworkSandbox.instances[0].allowed_domains == [("127.0.0.1:8080", ["GET"])]
|
||||
assert _FakeStrictNetworkSandbox.instances[1].allowed_domains == [
|
||||
@@ -731,8 +702,7 @@ async def test_provider_run_tool_writes_files_with_real_sandbox(tmp_path: Path)
|
||||
}
|
||||
)
|
||||
|
||||
assert result[0].type == "code_interpreter_tool_result"
|
||||
outputs = result[0].outputs or []
|
||||
outputs = result
|
||||
error_outputs = [
|
||||
f"{item.message}: {item.error_details}"
|
||||
for item in outputs
|
||||
@@ -795,8 +765,7 @@ async def test_provider_run_tool_pings_bing_with_real_sandbox() -> None:
|
||||
}
|
||||
)
|
||||
|
||||
assert result[0].type == "code_interpreter_tool_result"
|
||||
outputs = result[0].outputs or []
|
||||
outputs = result
|
||||
error_outputs = [
|
||||
f"{item.message}: {item.error_details}"
|
||||
for item in outputs
|
||||
@@ -823,9 +792,7 @@ async def test_sandbox_runs_simple_code(restored_sandbox) -> None:
|
||||
|
||||
@skip_if_hyperlight_integration_tests_disabled
|
||||
async def test_sandbox_stdout_and_stderr_captured(restored_sandbox) -> None:
|
||||
result = restored_sandbox.run(
|
||||
'import sys\nprint("out")\nprint("err", file=sys.stderr)'
|
||||
)
|
||||
result = restored_sandbox.run('import sys\nprint("out")\nprint("err", file=sys.stderr)')
|
||||
assert result.success
|
||||
assert "out" in result.stdout
|
||||
assert "err" in result.stderr
|
||||
@@ -910,24 +877,17 @@ async def test_output_dir_cleared_between_invocations() -> None:
|
||||
|
||||
# First invocation: write a file
|
||||
result1 = await run_tool.invoke(
|
||||
arguments={
|
||||
"code": (
|
||||
'with open("/output/stale.txt", "w") as f:\n'
|
||||
' f.write("first")\n'
|
||||
'print("wrote")\n'
|
||||
)
|
||||
}
|
||||
arguments={"code": ('with open("/output/stale.txt", "w") as f:\n f.write("first")\nprint("wrote")\n')}
|
||||
)
|
||||
assert result1[0].type == "code_interpreter_tool_result"
|
||||
outputs1 = result1[0].outputs or []
|
||||
assert result1[0].type == "text" or result1[0].type == "data"
|
||||
outputs1 = result1
|
||||
assert any(
|
||||
item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "")
|
||||
for item in outputs1
|
||||
item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "") for item in outputs1
|
||||
), "First invocation should produce stale.txt"
|
||||
|
||||
# Second invocation: no file writes
|
||||
result2 = await run_tool.invoke(arguments={"code": 'print("clean")\n'})
|
||||
outputs2 = result2[0].outputs or []
|
||||
outputs2 = result2
|
||||
stale_files = [
|
||||
item
|
||||
for item in outputs2
|
||||
@@ -971,11 +931,9 @@ async def test_run_code_does_not_block_event_loop() -> None:
|
||||
concurrent_ran = True
|
||||
release.set()
|
||||
|
||||
code_task = asyncio.create_task(
|
||||
run_tool.invoke(arguments={"code": 'print("done")\n'})
|
||||
)
|
||||
code_task = asyncio.create_task(run_tool.invoke(arguments={"code": 'print("done")\n'}))
|
||||
await _concurrent_task()
|
||||
result = await code_task
|
||||
|
||||
assert concurrent_ran, "Event loop was blocked during sandbox execution"
|
||||
assert result[0].type == "code_interpreter_tool_result"
|
||||
assert result[0].type == "text"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"mem0ai>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260409"
|
||||
version = "1.0.0b260421"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"ollama>=0.5.3,<0.5.4",
|
||||
]
|
||||
|
||||
|
||||
@@ -549,6 +549,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
chunk,
|
||||
options=validated_options,
|
||||
function_call_ids=function_call_ids,
|
||||
seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids,
|
||||
)
|
||||
else:
|
||||
async for chunk in await client.responses.create(stream=True, **run_options):
|
||||
@@ -556,6 +557,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
chunk,
|
||||
options=validated_options,
|
||||
function_call_ids=function_call_ids,
|
||||
seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids,
|
||||
)
|
||||
except Exception as ex:
|
||||
self._handle_request_error(ex)
|
||||
@@ -1587,6 +1589,54 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
"""Join shell commands into a single executable command string."""
|
||||
return "\n".join(command for command in commands if command).strip()
|
||||
|
||||
@staticmethod
|
||||
def _serialize_provider_payload(value: Any) -> Any:
|
||||
"""Convert OpenAI SDK objects into JSON-serializable Python values."""
|
||||
if isinstance(value, BaseModel):
|
||||
return value.model_dump(mode="json", exclude_none=True)
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): RawOpenAIChatClient._serialize_provider_payload(item) for key, item in value.items()} # type: ignore[reportUnknownVariableType]
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return [RawOpenAIChatClient._serialize_provider_payload(item) for item in value] # type: ignore[reportUnknownVariableType]
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _get_search_tool_name(item_type: str) -> str:
|
||||
"""Map OpenAI search output item types to unified content tool names."""
|
||||
return "web_search" if item_type == "web_search_call" else "file_search"
|
||||
|
||||
def _parse_search_tool_call_content(self, item: Any) -> Content:
|
||||
"""Create unified search tool call content from an OpenAI search output item."""
|
||||
item_type = getattr(item, "type", "")
|
||||
call_id = getattr(item, "id", None) or getattr(item, "call_id", None) or ""
|
||||
if item_type == "web_search_call":
|
||||
arguments = self._serialize_provider_payload(getattr(item, "action", None))
|
||||
else:
|
||||
arguments = {"queries": list(getattr(item, "queries", []) or [])}
|
||||
return Content.from_search_tool_call(
|
||||
call_id=call_id,
|
||||
tool_name=self._get_search_tool_name(item_type),
|
||||
arguments=arguments,
|
||||
status=getattr(item, "status", None),
|
||||
raw_representation=item,
|
||||
)
|
||||
|
||||
def _parse_search_tool_result_content(self, item: Any) -> Content:
|
||||
"""Create unified search tool result content from an OpenAI search output item."""
|
||||
item_type = getattr(item, "type", "")
|
||||
call_id = getattr(item, "id", None) or getattr(item, "call_id", None) or ""
|
||||
if item_type == "web_search_call":
|
||||
result = {"action": self._serialize_provider_payload(getattr(item, "action", None))}
|
||||
else:
|
||||
result = {"results": self._serialize_provider_payload(getattr(item, "results", None))}
|
||||
return Content.from_search_tool_result(
|
||||
call_id=call_id,
|
||||
tool_name=self._get_search_tool_name(item_type),
|
||||
result=result,
|
||||
status=getattr(item, "status", None),
|
||||
raw_representation=item,
|
||||
)
|
||||
|
||||
# region Parse methods
|
||||
def _parse_response_from_openai(
|
||||
self,
|
||||
@@ -1788,6 +1838,9 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
raw_representation=item,
|
||||
)
|
||||
)
|
||||
case "web_search_call" | "file_search_call":
|
||||
contents.append(self._parse_search_tool_call_content(item))
|
||||
contents.append(self._parse_search_tool_result_content(item))
|
||||
case "mcp_approval_request": # ResponseOutputMcpApprovalRequest
|
||||
contents.append(
|
||||
Content.from_function_approval_request(
|
||||
@@ -2377,8 +2430,19 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
additional_properties=additional_properties_empty or None,
|
||||
)
|
||||
)
|
||||
case "web_search_call" | "file_search_call":
|
||||
contents.append(self._parse_search_tool_call_content(event_item))
|
||||
case _:
|
||||
logger.debug("Unparsed event of type: %s: %s", event.type, event)
|
||||
case (
|
||||
"response.web_search_call.in_progress"
|
||||
| "response.web_search_call.searching"
|
||||
| "response.web_search_call.completed"
|
||||
| "response.file_search_call.in_progress"
|
||||
| "response.file_search_call.searching"
|
||||
| "response.file_search_call.completed"
|
||||
):
|
||||
pass
|
||||
case "response.function_call_arguments.delta":
|
||||
call_id, name = function_call_ids.get(event.output_index, (None, None))
|
||||
if call_id and name:
|
||||
@@ -2514,6 +2578,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
raw_representation=done_item,
|
||||
)
|
||||
)
|
||||
elif getattr(done_item, "type", None) in ("web_search_call", "file_search_call"):
|
||||
contents.append(self._parse_search_tool_result_content(done_item))
|
||||
case _:
|
||||
logger.debug("Unparsed event of type: %s: %s", event.type, event)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.1,<2",
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
]
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import (
|
||||
@@ -71,6 +71,35 @@ class OutputStruct(BaseModel):
|
||||
weather: str | None = None
|
||||
|
||||
|
||||
class _FakeAsyncEventStream:
|
||||
def __init__(self, events: list[object]) -> None:
|
||||
self._events = events
|
||||
self._iterator = iter(())
|
||||
|
||||
def __aiter__(self) -> "_FakeAsyncEventStream":
|
||||
self._iterator = iter(self._events)
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> object:
|
||||
try:
|
||||
return next(self._iterator)
|
||||
except StopIteration as exc:
|
||||
raise StopAsyncIteration from exc
|
||||
|
||||
|
||||
class _FakeAsyncEventStreamContext(_FakeAsyncEventStream):
|
||||
async def __aenter__(self) -> "_FakeAsyncEventStreamContext":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
traceback: object | None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
|
||||
async def create_vector_store(
|
||||
client: OpenAIChatClient,
|
||||
) -> tuple[str, Content]:
|
||||
@@ -1250,6 +1279,91 @@ def test_response_content_creation_with_function_call() -> None:
|
||||
assert function_call.arguments == '{"location": "Seattle"}'
|
||||
|
||||
|
||||
def test_parse_response_from_openai_with_web_search_call() -> None:
|
||||
"""Test _parse_response_from_openai with web search output."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.output_parsed = None
|
||||
mock_response.metadata = {}
|
||||
mock_response.usage = None
|
||||
mock_response.id = "resp-web"
|
||||
mock_response.model = "test-model"
|
||||
mock_response.created_at = 1000000000
|
||||
|
||||
mock_search_item = MagicMock()
|
||||
mock_search_item.type = "web_search_call"
|
||||
mock_search_item.id = "ws_123"
|
||||
mock_search_item.status = "completed"
|
||||
mock_search_item.action = {
|
||||
"type": "search",
|
||||
"query": "current weather in Seattle",
|
||||
"queries": ["current weather in Seattle"],
|
||||
"sources": [{"title": "Weather", "url": "https://weather.example"}],
|
||||
}
|
||||
|
||||
mock_response.output = [mock_search_item]
|
||||
|
||||
response = client._parse_response_from_openai(mock_response, options={}) # type: ignore
|
||||
|
||||
assert len(response.messages[0].contents) == 2
|
||||
call_content, result_content = response.messages[0].contents
|
||||
assert call_content.type == "search_tool_call"
|
||||
assert call_content.call_id == "ws_123"
|
||||
assert call_content.tool_name == "web_search"
|
||||
assert call_content.status == "completed"
|
||||
assert call_content.arguments == mock_search_item.action
|
||||
assert result_content.type == "search_tool_result"
|
||||
assert result_content.call_id == "ws_123"
|
||||
assert result_content.tool_name == "web_search"
|
||||
assert result_content.status == "completed"
|
||||
assert result_content.result == {"action": mock_search_item.action}
|
||||
|
||||
|
||||
def test_parse_response_from_openai_with_file_search_call() -> None:
|
||||
"""Test _parse_response_from_openai with file search output."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.output_parsed = None
|
||||
mock_response.metadata = {}
|
||||
mock_response.usage = None
|
||||
mock_response.id = "resp-file"
|
||||
mock_response.model = "test-model"
|
||||
mock_response.created_at = 1000000000
|
||||
|
||||
mock_search_item = MagicMock()
|
||||
mock_search_item.type = "file_search_call"
|
||||
mock_search_item.id = "fs_123"
|
||||
mock_search_item.status = "completed"
|
||||
mock_search_item.queries = ["weather history"]
|
||||
mock_search_item.results = [
|
||||
{
|
||||
"file_id": "file_1",
|
||||
"filename": "weather.txt",
|
||||
"score": 0.9,
|
||||
"text": "Seattle was cloudy.",
|
||||
}
|
||||
]
|
||||
|
||||
mock_response.output = [mock_search_item]
|
||||
|
||||
response = client._parse_response_from_openai(mock_response, options={}) # type: ignore
|
||||
|
||||
assert len(response.messages[0].contents) == 2
|
||||
call_content, result_content = response.messages[0].contents
|
||||
assert call_content.type == "search_tool_call"
|
||||
assert call_content.call_id == "fs_123"
|
||||
assert call_content.tool_name == "file_search"
|
||||
assert call_content.status == "completed"
|
||||
assert call_content.arguments == {"queries": ["weather history"]}
|
||||
assert result_content.type == "search_tool_result"
|
||||
assert result_content.call_id == "fs_123"
|
||||
assert result_content.tool_name == "file_search"
|
||||
assert result_content.status == "completed"
|
||||
assert result_content.result == {"results": mock_search_item.results}
|
||||
|
||||
|
||||
def test_prepare_content_for_opentool_approval_response() -> None:
|
||||
"""Test _prepare_content_for_openai with function approval response content."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
@@ -1394,6 +1508,86 @@ def test_parse_response_from_openai_with_mcp_server_tool_result() -> None:
|
||||
assert result_content.output is not None
|
||||
|
||||
|
||||
def test_parse_chunk_from_openai_with_web_search_call_added() -> None:
|
||||
"""Test that response.output_item.added for web_search_call emits search tool call content."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_item.added"
|
||||
mock_event.output_index = 0
|
||||
|
||||
mock_item = MagicMock()
|
||||
mock_item.type = "web_search_call"
|
||||
mock_item.id = "ws_call_123"
|
||||
mock_item.status = "in_progress"
|
||||
mock_item.action = {"type": "search", "query": "weather in Seattle"}
|
||||
mock_event.item = mock_item
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, options=chat_options, function_call_ids=function_call_ids)
|
||||
|
||||
assert len(update.contents) == 1
|
||||
content = update.contents[0]
|
||||
assert content.type == "search_tool_call"
|
||||
assert content.call_id == "ws_call_123"
|
||||
assert content.tool_name == "web_search"
|
||||
assert content.status == "in_progress"
|
||||
assert content.arguments == {"type": "search", "query": "weather in Seattle"}
|
||||
|
||||
|
||||
def test_parse_chunk_from_openai_with_file_search_call_done() -> None:
|
||||
"""Test that response.output_item.done for file_search_call emits search tool result content."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_item.done"
|
||||
|
||||
mock_item = MagicMock()
|
||||
mock_item.type = "file_search_call"
|
||||
mock_item.id = "fs_call_123"
|
||||
mock_item.status = "completed"
|
||||
mock_item.results = [{"file_id": "file_1", "text": "Seattle was cloudy."}]
|
||||
mock_event.item = mock_item
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, options=chat_options, function_call_ids=function_call_ids)
|
||||
|
||||
assert len(update.contents) == 1
|
||||
content = update.contents[0]
|
||||
assert content.type == "search_tool_result"
|
||||
assert content.call_id == "fs_call_123"
|
||||
assert content.tool_name == "file_search"
|
||||
assert content.status == "completed"
|
||||
assert content.result == {"results": [{"file_id": "file_1", "text": "Seattle was cloudy."}]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event_type",
|
||||
[
|
||||
"response.web_search_call.in_progress",
|
||||
"response.web_search_call.searching",
|
||||
"response.web_search_call.completed",
|
||||
"response.file_search_call.in_progress",
|
||||
"response.file_search_call.searching",
|
||||
"response.file_search_call.completed",
|
||||
],
|
||||
)
|
||||
def test_parse_chunk_from_openai_ignores_search_progress_events(event_type: str) -> None:
|
||||
"""Search progress events should be explicitly ignored instead of logged as unparsed."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = event_type
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, options=chat_options, function_call_ids=function_call_ids)
|
||||
|
||||
assert update.contents == []
|
||||
|
||||
|
||||
def test_parse_chunk_from_openai_with_mcp_call_added_defers_result() -> None:
|
||||
"""Test that response.output_item.added for mcp_call emits only the call, not the result.
|
||||
|
||||
@@ -2716,6 +2910,48 @@ async def test_get_response_streaming_with_response_format() -> None:
|
||||
await run_streaming()
|
||||
|
||||
|
||||
async def test_inner_get_response_streaming_with_response_format_tracks_reasoning_delta_ids() -> None:
|
||||
"""The responses.stream path should suppress reasoning done events after deltas."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
messages = [Message(role="user", contents=["Test streaming with format"])]
|
||||
item_id = "reasoning_stream"
|
||||
events = [
|
||||
ResponseReasoningTextDeltaEvent(
|
||||
type="response.reasoning_text.delta",
|
||||
content_index=0,
|
||||
item_id=item_id,
|
||||
output_index=0,
|
||||
sequence_number=1,
|
||||
delta="Hello ",
|
||||
),
|
||||
ResponseReasoningTextDoneEvent(
|
||||
type="response.reasoning_text.done",
|
||||
content_index=0,
|
||||
item_id=item_id,
|
||||
output_index=0,
|
||||
sequence_number=2,
|
||||
text="Hello ",
|
||||
),
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"_prepare_request",
|
||||
new=AsyncMock(return_value=(client.client, {"text_format": OutputStruct}, {})),
|
||||
),
|
||||
patch.object(client.client.responses, "stream", return_value=_FakeAsyncEventStreamContext(events)),
|
||||
patch.object(client, "_get_metadata_from_response", return_value={}),
|
||||
):
|
||||
stream = client._inner_get_response(messages=messages, options={}, stream=True)
|
||||
updates = [update async for update in stream]
|
||||
|
||||
reasoning_chunks = [
|
||||
content.text for update in updates for content in update.contents if content.type == "text_reasoning"
|
||||
]
|
||||
assert reasoning_chunks == ["Hello "]
|
||||
|
||||
|
||||
def test_prepare_content_for_openai_image_content() -> None:
|
||||
"""Test _prepare_content_for_openai with image content variations."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
@@ -3153,6 +3389,44 @@ def test_streaming_reasoning_deltas_then_done_no_duplication() -> None:
|
||||
assert "".join(c.text for c in all_contents) == "Hello world"
|
||||
|
||||
|
||||
async def test_inner_get_response_streaming_create_tracks_reasoning_delta_ids() -> None:
|
||||
"""The responses.create(stream=True) path should suppress reasoning done events after deltas."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
messages = [Message(role="user", contents=["Test streaming"])]
|
||||
item_id = "reasoning_create"
|
||||
events = [
|
||||
ResponseReasoningTextDeltaEvent(
|
||||
type="response.reasoning_text.delta",
|
||||
content_index=0,
|
||||
item_id=item_id,
|
||||
output_index=0,
|
||||
sequence_number=1,
|
||||
delta="Hello ",
|
||||
),
|
||||
ResponseReasoningTextDoneEvent(
|
||||
type="response.reasoning_text.done",
|
||||
content_index=0,
|
||||
item_id=item_id,
|
||||
output_index=0,
|
||||
sequence_number=2,
|
||||
text="Hello ",
|
||||
),
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(client, "_prepare_request", new=AsyncMock(return_value=(client.client, {}, {}))),
|
||||
patch.object(client.client.responses, "create", new=AsyncMock(return_value=_FakeAsyncEventStream(events))),
|
||||
patch.object(client, "_get_metadata_from_response", return_value={}),
|
||||
):
|
||||
stream = client._inner_get_response(messages=messages, options={}, stream=True)
|
||||
updates = [update async for update in stream]
|
||||
|
||||
reasoning_chunks = [
|
||||
content.text for update in updates for content in update.contents if content.type == "text_reasoning"
|
||||
]
|
||||
assert reasoning_chunks == ["Hello "]
|
||||
|
||||
|
||||
def test_streaming_reasoning_events_preserve_metadata() -> None:
|
||||
"""Test that reasoning events preserve metadata like regular text events."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
@@ -3890,26 +4164,22 @@ async def test_integration_tool_rich_content_image() -> None:
|
||||
client = OpenAIChatClient()
|
||||
client.function_invocation_configuration["max_iterations"] = 2
|
||||
|
||||
for streaming in [False, True]:
|
||||
messages = [
|
||||
Message(
|
||||
role="user",
|
||||
contents=["Call the get_test_image tool and describe what you see."],
|
||||
)
|
||||
]
|
||||
options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"}
|
||||
messages = [
|
||||
Message(
|
||||
role="user",
|
||||
contents=["Call the get_test_image tool and describe what you see."],
|
||||
)
|
||||
]
|
||||
options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"}
|
||||
|
||||
if streaming:
|
||||
response = await client.get_response(messages=messages, stream=True, options=options).get_final_response()
|
||||
else:
|
||||
response = await client.get_response(messages=messages, options=options)
|
||||
response = await client.get_response(messages=messages, stream=True, options=options).get_final_response()
|
||||
|
||||
assert response is not None
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
# sample_image.jpg contains a photo of a house; the model should mention it.
|
||||
assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}"
|
||||
assert response is not None
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
# sample_image.jpg contains a photo of a house; the model should mention it.
|
||||
assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}"
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
|
||||
@@ -486,6 +486,7 @@ async def test_integration_client_agent_existing_session() -> None:
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
@pytest.mark.skip(reason="Azure OpenAI is flaky when handling image content as function result. Needs investigation.")
|
||||
async def test_azure_openai_chat_client_tool_rich_content_image() -> None:
|
||||
image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg"
|
||||
image_bytes = image_path.read_bytes()
|
||||
@@ -499,21 +500,12 @@ async def test_azure_openai_chat_client_tool_rich_content_image() -> None:
|
||||
client = OpenAIChatClient(credential=credential)
|
||||
client.function_invocation_configuration["max_iterations"] = 2
|
||||
|
||||
for streaming in [False, True]:
|
||||
messages = [Message(role="user", contents=["Call the get_test_image tool and describe what you see."])]
|
||||
options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"}
|
||||
response = await client.get_response(
|
||||
messages=[Message(role="user", contents=["Call the get_test_image tool and describe what you see."])],
|
||||
stream=True,
|
||||
options={"tools": [get_test_image], "tool_choice": "auto"},
|
||||
).get_final_response()
|
||||
|
||||
if streaming:
|
||||
response = await client.get_response(
|
||||
messages=messages,
|
||||
stream=True,
|
||||
options=options,
|
||||
).get_final_response()
|
||||
else:
|
||||
response = await client.get_response(messages=messages, options=options)
|
||||
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert response.text is not None
|
||||
assert "house" in response.text.lower(), (
|
||||
f"Model did not describe the house image. Response: {response.text}"
|
||||
)
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert response.text is not None
|
||||
assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user