Merge branch 'main' into dev/dotnet_workflow/fix_handoff_agent_session_handling

This commit is contained in:
Jacob Alber
2026-04-17 14:53:07 -04:00
committed by GitHub
Unverified
13 changed files with 532 additions and 5 deletions
+2
View File
@@ -152,6 +152,7 @@
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Agent_Step21_WebSearch.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Evaluation/">
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
@@ -173,6 +174,7 @@
<Project Path="samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/Agent_OpenAI_Step03_CreateFromChatClient.csproj" />
<Project Path="samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient.csproj" />
<Project Path="samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Agent_OpenAI_Step05_Conversation.csproj" />
<Project Path="samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Agent_OpenAI_Step06_CodeInterpreterFileDownload.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentWithRAG/">
<File Path="samples/02-agents/AgentWithRAG/README.md" />
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,89 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to download files generated by Code Interpreter using the Containers API.
// Code Interpreter generates files inside containers (cfile_ / cntr_ IDs) which cannot be
// downloaded via the standard Files API. Use ContainerClient instead.
#pragma warning disable OPENAI001
using System.ClientModel;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Containers;
using OpenAI.Responses;
string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set.");
string model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-4o-mini";
var openAIClient = new OpenAIClient(new ApiKeyCredential(apiKey));
// Create an agent with Code Interpreter tool enabled
AIAgent agent = openAIClient
.GetResponsesClient()
.AsAIAgent(
model: model,
instructions: "You are a helpful assistant that can generate files using code.",
name: "CodeInterpreterAgent",
tools: [new HostedCodeInterpreterTool()]);
// Ask the agent to generate a file
AgentResponse response = await agent.RunAsync(
"Create a CSV file with the multiplication times tables from 1 to 12. Include headers.");
// Display the text response
foreach (TextContent textContent in response.Messages.SelectMany(x => x.Contents).OfType<TextContent>())
{
Console.WriteLine(textContent.Text);
}
// Extract container file citations from response annotations and download
ContainerClient containerClient = openAIClient.GetContainerClient();
HashSet<string> downloadedFiles = [];
bool foundContainerFiles = false;
foreach (AIContent content in response.Messages.SelectMany(x => x.Contents))
{
if (content.Annotations is null)
{
continue;
}
foreach (AIAnnotation annotation in content.Annotations)
{
// Container files from Code Interpreter have ContainerFileCitationMessageAnnotation as raw representation
if (annotation is CitationAnnotation citation
&& citation.RawRepresentation is ContainerFileCitationMessageAnnotation containerCitation)
{
foundContainerFiles = true;
// Deduplicate by container+file ID in case the same file is cited multiple times
string key = $"{containerCitation.ContainerId}/{containerCitation.FileId}";
if (!downloadedFiles.Add(key))
{
continue;
}
Console.WriteLine($"\nDownloading container file: {containerCitation.Filename}");
Console.WriteLine($" Container ID: {containerCitation.ContainerId}");
Console.WriteLine($" File ID: {containerCitation.FileId}");
BinaryData fileData = await containerClient.DownloadContainerFileAsync(
containerCitation.ContainerId,
containerCitation.FileId);
// Sanitize filename to prevent path traversal
string safeFilename = Path.GetFileName(containerCitation.Filename);
string outputPath = Path.Combine(Directory.GetCurrentDirectory(), safeFilename);
await File.WriteAllBytesAsync(outputPath, fileData.ToArray());
Console.WriteLine($" Saved to: {outputPath}");
}
}
}
if (!foundContainerFiles)
{
Console.WriteLine("\nNo container file citations found in the response.");
Console.WriteLine("The model may not have generated a downloadable file for this prompt.");
}
@@ -0,0 +1,51 @@
# Code Interpreter File Download (OpenAI)
This sample demonstrates how to download files generated by Code Interpreter when using the OpenAI Responses API.
## What this sample demonstrates
- Creating an agent with Code Interpreter tool using `ResponsesClient.AsAIAgent()`
- Generating files through Code Interpreter (e.g., CSV, Excel, images)
- Extracting container file citations from agent response annotations
- Downloading container files using the `ContainerClient` API
## Container files vs regular files
When Code Interpreter generates a file, the file is stored inside a **container** with a `cntr_` prefixed ID. The file itself gets a `cfile_` prefixed ID.
These container files **cannot** be downloaded using the standard Files API (`GetOpenAIFileClient`), which returns 404 for `cfile_` IDs. Instead, you must use the **Containers API** (`GetContainerClient`) to download them:
```csharp
// ❌ This does NOT work for container files
var filesClient = openAIClient.GetOpenAIFileClient();
await filesClient.DownloadFileAsync("cfile_..."); // Returns 404
// ✅ Use ContainerClient instead
var containerClient = openAIClient.GetContainerClient();
await containerClient.DownloadContainerFileAsync("cntr_...", "cfile_...");
```
The container ID and file ID are available from the `ContainerFileCitationMessageAnnotation` annotation in the response, accessible via `CitationAnnotation.RawRepresentation`.
## Prerequisites
- .NET 10 SDK or later
- OpenAI API key with access to a model that supports Code Interpreter
Set the following environment variables:
```powershell
$env:OPENAI_API_KEY="sk-..."
$env:OPENAI_CHAT_MODEL_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
```
## Run the sample
```powershell
dotnet run
```
## See also
- [Code Interpreter File Download with Foundry](../../../02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/) — same scenario using Microsoft Foundry
- [Code Interpreter](../../../02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/) — Code Interpreter without file download
@@ -14,4 +14,5 @@ Agent Framework provides additional support to allow OpenAI developers to use th
|[Using Reasoning Capabilities](./Agent_OpenAI_Step02_Reasoning/)|This sample demonstrates how to create an AI agent with reasoning capabilities using OpenAI's reasoning models and response types.|
|[Creating an Agent from a ChatClient](./Agent_OpenAI_Step03_CreateFromChatClient/)|This sample demonstrates how to create an AI agent directly from an OpenAI.Chat.ChatClient instance using OpenAIChatClientAgent.|
|[Creating an Agent from an OpenAIResponseClient](./Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/)|This sample demonstrates how to create an AI agent directly from an OpenAI.Responses.OpenAIResponseClient instance using OpenAIResponseClientAgent.|
|[Managing Conversation State](./Agent_OpenAI_Step05_Conversation/)|This sample demonstrates how to maintain conversation state across multiple turns using the AgentSession for context continuity.|
|[Managing Conversation State](./Agent_OpenAI_Step05_Conversation/)|This sample demonstrates how to maintain conversation state across multiple turns using the AgentSession for context continuity.|
|[Code Interpreter File Download](./Agent_OpenAI_Step06_CodeInterpreterFileDownload/)|This sample demonstrates how to download files generated by Code Interpreter using the Containers API (`cfile_`/`cntr_` IDs).|
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,91 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to download files generated by Code Interpreter using Microsoft Foundry.
// Code Interpreter generates files inside containers (cfile_ / cntr_ IDs) which cannot be
// downloaded via the standard Files API. Use ContainerClient from the project's OpenAI client instead.
#pragma warning disable OPENAI001
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Create an agent with Code Interpreter tool enabled
AIAgent agent = aiProjectClient.AsAIAgent(
deploymentName,
instructions: "You are a helpful assistant that can generate files using code.",
name: "CodeInterpreterAgent",
tools: [new HostedCodeInterpreterTool()]);
// Ask the agent to generate a file
AgentResponse response = await agent.RunAsync(
"Create a CSV file with the multiplication times tables from 1 to 12. Include headers.");
// Display the text response
foreach (TextContent textContent in response.Messages.SelectMany(x => x.Contents).OfType<TextContent>())
{
Console.WriteLine(textContent.Text);
}
// Extract container file citations from response annotations and download.
// AIProjectClient.GetProjectOpenAIClient() returns a ProjectOpenAIClient (inherits from OpenAI.OpenAIClient)
// which supports GetContainerClient(), unlike AzureOpenAIClient which does not.
var containerClient = aiProjectClient.GetProjectOpenAIClient().GetContainerClient();
HashSet<string> downloadedFiles = [];
bool foundContainerFiles = false;
foreach (AIContent content in response.Messages.SelectMany(x => x.Contents))
{
if (content.Annotations is null)
{
continue;
}
foreach (AIAnnotation annotation in content.Annotations)
{
// Container files from Code Interpreter have ContainerFileCitationMessageAnnotation as raw representation
if (annotation is CitationAnnotation citation
&& citation.RawRepresentation is ContainerFileCitationMessageAnnotation containerCitation)
{
foundContainerFiles = true;
// Deduplicate by container+file ID in case the same file is cited multiple times
string key = $"{containerCitation.ContainerId}/{containerCitation.FileId}";
if (!downloadedFiles.Add(key))
{
continue;
}
Console.WriteLine($"\nDownloading container file: {containerCitation.Filename}");
Console.WriteLine($" Container ID: {containerCitation.ContainerId}");
Console.WriteLine($" File ID: {containerCitation.FileId}");
BinaryData fileData = await containerClient.DownloadContainerFileAsync(
containerCitation.ContainerId,
containerCitation.FileId);
// Sanitize filename to prevent path traversal
string safeFilename = Path.GetFileName(containerCitation.Filename);
string outputPath = Path.Combine(Directory.GetCurrentDirectory(), safeFilename);
await File.WriteAllBytesAsync(outputPath, fileData.ToArray());
Console.WriteLine($" Saved to: {outputPath}");
}
}
}
if (!foundContainerFiles)
{
Console.WriteLine("\nNo container file citations found in the response.");
Console.WriteLine("The model may not have generated a downloadable file for this prompt.");
}
@@ -0,0 +1,56 @@
# Code Interpreter File Download (Microsoft Foundry)
This sample demonstrates how to download files generated by Code Interpreter when using Microsoft Foundry.
## What this sample demonstrates
- Creating an agent with Code Interpreter tool using `AIProjectClient.AsAIAgent()`
- Generating files through Code Interpreter (e.g., CSV, Excel, images)
- Extracting container file citations from agent response annotations
- Downloading container files using the `ContainerClient` via `AIProjectClient.GetProjectOpenAIClient()`
## Container files vs regular files
When Code Interpreter generates a file, the file is stored inside a **container** with a `cntr_` prefixed ID. The file itself gets a `cfile_` prefixed ID.
These container files **cannot** be downloaded using the standard Files API (`GetOpenAIFileClient`), which returns 404 for `cfile_` IDs. Instead, you must use the **Containers API** to download them.
### Getting the ContainerClient with Foundry
`AzureOpenAIClient.GetContainerClient()` is not supported and throws `InvalidOperationException`. Instead, use the project's OpenAI client which inherits directly from `OpenAI.OpenAIClient`:
```csharp
// ❌ AzureOpenAIClient does not support ContainerClient
var azureClient = new AzureOpenAIClient(endpoint, credential);
azureClient.GetContainerClient(); // Throws InvalidOperationException
// ✅ Use AIProjectClient's project OpenAI client
var containerClient = aiProjectClient.GetProjectOpenAIClient().GetContainerClient();
await containerClient.DownloadContainerFileAsync("cntr_...", "cfile_...");
```
The container ID and file ID are available from the `ContainerFileCitationMessageAnnotation` annotation in the response, accessible via `CitationAnnotation.RawRepresentation`.
## Prerequisites
- .NET 10 SDK or later
- Microsoft Foundry service endpoint and deployment configured
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
```
## Run the sample
```powershell
dotnet run
```
## See also
- [Code Interpreter File Download with OpenAI](../../../02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/) — same scenario using Public OpenAI
- [Code Interpreter](../Agent_Step14_CodeInterpreter/) — Code Interpreter without file download
@@ -72,6 +72,7 @@ Some samples require extra tool-specific environment variables. See each sample
| [Web search](./Agent_Step21_WebSearch/) | Web search tool |
| [Memory search](./Agent_Step22_MemorySearch/) | Memory search tool |
| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport |
| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter |
## Running the samples
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
namespace Microsoft.Agents.AI.Workflows.Declarative.Kit;
/// <summary>
@@ -25,6 +27,11 @@ public sealed record class ActionExecutorResult
internal static ActionExecutorResult ThrowIfNot(object? message)
{
if (message is PortableValue portableValue && portableValue.IsType(out ActionExecutorResult? unwrapped))
{
return unwrapped;
}
if (message is not ActionExecutorResult executorMessage)
{
throw new DeclarativeActionException($"Unexpected message type: {message?.GetType().Name ?? "(null)"} (Expected: {nameof(ActionExecutorResult)})");
@@ -27,9 +27,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
public static string Resume(string id) => $"{id}_{nameof(Resume)}";
}
public static bool RequiresInput(object? message) => message is ExternalInputRequest;
public static bool RequiresInput(object? message) =>
message is ExternalInputRequest || (message is PortableValue pv && pv.IsType(out ExternalInputRequest? _));
public static bool RequiresNothing(object? message) => message is ActionExecutorResult;
public static bool RequiresNothing(object? message) =>
message is ActionExecutorResult || (message is PortableValue pv && pv.IsType(out ActionExecutorResult? _));
private AzureAgentUsage AgentUsage => Throw.IfNull(this.Model.Agent, $"{nameof(this.Model)}.{nameof(this.Model.Agent)}");
private AzureAgentInput? AgentInput => this.Model.Input;
@@ -46,12 +46,14 @@ internal sealed class InvokeMcpToolExecutor(
/// <summary>
/// Determines if the message indicates external input is required.
/// </summary>
public static bool RequiresInput(object? message) => message is ExternalInputRequest;
public static bool RequiresInput(object? message) =>
message is ExternalInputRequest || (message is PortableValue pv && pv.IsType(out ExternalInputRequest? _));
/// <summary>
/// Determines if the message indicates no external input is required.
/// </summary>
public static bool RequiresNothing(object? message) => message is ActionExecutorResult;
public static bool RequiresNothing(object? message) =>
message is ActionExecutorResult || (message is PortableValue pv && pv.IsType(out ActionExecutorResult? _));
/// <inheritdoc/>
protected override bool EmitResultEvent => false;
@@ -0,0 +1,191 @@
// Copyright (c) Microsoft. All rights reserved.
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Kit;
/// <summary>
/// Tests that edge predicates correctly handle PortableValue-wrapped messages,
/// which occur after checkpoint restore (JSON round-trip).
/// </summary>
public sealed class PortableValuePredicateTests
{
#region ActionExecutorResult.ThrowIfNot
[Fact]
public void ActionExecutorResult_ThrowIfNot_WithDirectActionExecutorResult_ReturnsResult()
{
// Arrange
ActionExecutorResult result = new("test-executor");
// Act
ActionExecutorResult actual = ActionExecutorResult.ThrowIfNot(result);
// Assert
actual.Should().BeSameAs(result);
}
[Fact]
public void ActionExecutorResult_ThrowIfNot_WithPortableValueWrappedActionExecutorResult_Unwraps()
{
// Arrange
ActionExecutorResult result = new("test-executor");
PortableValue wrapped = new(result);
// Act
ActionExecutorResult actual = ActionExecutorResult.ThrowIfNot(wrapped);
// Assert
actual.ExecutorId.Should().Be("test-executor");
}
[Fact]
public void ActionExecutorResult_ThrowIfNot_WithNonActionExecutorResult_Throws()
{
// Arrange
object message = "not an ActionExecutorResult";
// Act & Assert
Assert.Throws<DeclarativeActionException>(() => ActionExecutorResult.ThrowIfNot(message));
}
[Fact]
public void ActionExecutorResult_ThrowIfNot_WithNull_Throws()
{
// Act & Assert
Assert.Throws<DeclarativeActionException>(() => ActionExecutorResult.ThrowIfNot(null));
}
[Fact]
public void ActionExecutorResult_ThrowIfNot_WithPortableValueWrappedNonResult_Throws()
{
// Arrange
PortableValue wrapped = new("not an ActionExecutorResult");
// Act & Assert
Assert.Throws<DeclarativeActionException>(() => ActionExecutorResult.ThrowIfNot(wrapped));
}
#endregion
#region InvokeAzureAgentExecutor Predicates
[Fact]
public void InvokeAzureAgentExecutor_RequiresInput_WithDirectExternalInputRequest_ReturnsTrue()
{
// Arrange
ExternalInputRequest request = new("test prompt");
// Act & Assert
InvokeAzureAgentExecutor.RequiresInput(request).Should().BeTrue();
}
[Fact]
public void InvokeAzureAgentExecutor_RequiresInput_WithPortableValueWrappedRequest_ReturnsTrue()
{
// Arrange
ExternalInputRequest request = new("test prompt");
PortableValue wrapped = new(request);
// Act & Assert
InvokeAzureAgentExecutor.RequiresInput(wrapped).Should().BeTrue();
}
[Fact]
public void InvokeAzureAgentExecutor_RequiresInput_WithActionExecutorResult_ReturnsFalse()
{
// Arrange
ActionExecutorResult result = new("test");
// Act & Assert
InvokeAzureAgentExecutor.RequiresInput(result).Should().BeFalse();
}
[Fact]
public void InvokeAzureAgentExecutor_RequiresNothing_WithDirectActionExecutorResult_ReturnsTrue()
{
// Arrange
ActionExecutorResult result = new("test");
// Act & Assert
InvokeAzureAgentExecutor.RequiresNothing(result).Should().BeTrue();
}
[Fact]
public void InvokeAzureAgentExecutor_RequiresNothing_WithPortableValueWrappedResult_ReturnsTrue()
{
// Arrange
ActionExecutorResult result = new("test");
PortableValue wrapped = new(result);
// Act & Assert
InvokeAzureAgentExecutor.RequiresNothing(wrapped).Should().BeTrue();
}
[Fact]
public void InvokeAzureAgentExecutor_RequiresNothing_WithExternalInputRequest_ReturnsFalse()
{
// Arrange
ExternalInputRequest request = new("test prompt");
// Act & Assert
InvokeAzureAgentExecutor.RequiresNothing(request).Should().BeFalse();
}
#endregion
#region InvokeMcpToolExecutor Predicates
[Fact]
public void InvokeMcpToolExecutor_RequiresInput_WithPortableValueWrappedRequest_ReturnsTrue()
{
// Arrange
ExternalInputRequest request = new("test prompt");
PortableValue wrapped = new(request);
// Act & Assert
InvokeMcpToolExecutor.RequiresInput(wrapped).Should().BeTrue();
}
[Fact]
public void InvokeMcpToolExecutor_RequiresNothing_WithPortableValueWrappedResult_ReturnsTrue()
{
// Arrange
ActionExecutorResult result = new("test");
PortableValue wrapped = new(result);
// Act & Assert
InvokeMcpToolExecutor.RequiresNothing(wrapped).Should().BeTrue();
}
#endregion
#region QuestionExecutor.IsComplete
[Fact]
public void QuestionExecutor_IsComplete_WithPortableValueWrappedResult_NullResult_ReturnsTrue()
{
// Arrange - result with null Result property means "complete"
ActionExecutorResult result = new("test", result: null);
PortableValue wrapped = new(result);
// Act & Assert
QuestionExecutor.IsComplete(wrapped).Should().BeTrue();
}
[Fact]
public void QuestionExecutor_IsComplete_WithPortableValueWrappedResult_NonNullResult_ReturnsFalse()
{
// Arrange - result with non-null Result property means "not complete"
ActionExecutorResult result = new("test", result: true);
PortableValue wrapped = new(result);
// Act & Assert
QuestionExecutor.IsComplete(wrapped).Should().BeFalse();
}
#endregion
}