diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 227175d03da..6f5fa9d46a6 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -242,6 +242,7 @@ + diff --git a/dotnet/samples/03-workflows/Declarative/FileInput/FileInput.csproj b/dotnet/samples/03-workflows/Declarative/FileInput/FileInput.csproj new file mode 100644 index 00000000000..530b9d1bda3 --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/FileInput/FileInput.csproj @@ -0,0 +1,43 @@ + + + + Exe + net10.0 + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + + + Always + + + Always + + + + diff --git a/dotnet/samples/03-workflows/Declarative/FileInput/FileInput.yaml b/dotnet/samples/03-workflows/Declarative/FileInput/FileInput.yaml new file mode 100644 index 00000000000..ca7d1eff621 --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/FileInput/FileInput.yaml @@ -0,0 +1,30 @@ +# +# This workflow demonstrates accepting file-based input as workflow input. +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_demo + actions: + + # Show that the workflow can inspect the text portion of the message. + # The uploaded file is already attached to the conversation and is available + # to agent-backed actions that use System.ConversationId. + - kind: SendActivity + id: announce_file_input + activity: |- + Received file-based workflow input. + + Prompt: + {System.LastMessage.Text} + + # Invoke an agent in the original conversation. The workflow root already added + # the file-bearing user message to this conversation before the first action ran. + - kind: InvokeAzureAgent + id: summarize_file + conversationId: =System.ConversationId + agent: + name: FileInputAgent + output: + autoSend: true diff --git a/dotnet/samples/03-workflows/Declarative/FileInput/ProductBrief.txt b/dotnet/samples/03-workflows/Declarative/FileInput/ProductBrief.txt new file mode 100644 index 00000000000..74777e0bb03 --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/FileInput/ProductBrief.txt @@ -0,0 +1,16 @@ +Product: Contoso Trail Bottle + +The Contoso Trail Bottle is a lightweight stainless-steel water bottle designed +for hikers, commuters, and students. It keeps drinks cold for 24 hours, fits in +standard backpack side pockets, and uses a leak-resistant twist lid. + +Audience: +- Weekend hikers +- Urban commuters +- Students who want a durable reusable bottle + +Key differentiators: +- Recycled stainless-steel body +- Dishwasher-safe lid +- Replaceable silicone gasket +- Optional clip loop for backpacks diff --git a/dotnet/samples/03-workflows/Declarative/FileInput/Program.cs b/dotnet/samples/03-workflows/Declarative/FileInput/Program.cs new file mode 100644 index 00000000000..06d09b2385e --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/FileInput/Program.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Identity; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using OpenAI.Files; +using Shared.Foundry; +using Shared.Workflows; + +namespace Demo.Workflows.Declarative.FileInput; + +/// +/// Demonstrate how to provide file-based input to a declarative workflow. +/// +/// +/// See the README.md file in this folder and the parent folder (../README.md) for +/// detailed information about the configuration required to run this sample. +/// +internal sealed class Program +{ + public static async Task Main() + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + + // Ensure sample agents exist in Foundry. + await CreateAgentAsync(foundryEndpoint, configuration); + + string filePath = Path.Combine(AppContext.BaseDirectory, "ProductBrief.txt"); + await using UploadedFile uploadedFile = await UploadInputFileAsync(foundryEndpoint, filePath); + + // Create the workflow factory. This class demonstrates how to initialize a + // declarative workflow from a YAML file. Once the workflow is created, it + // can be executed just like any regular workflow. + WorkflowFactory workflowFactory = new("FileInput.yaml", foundryEndpoint); + + // Execute the workflow with a ChatMessage that contains both text and an uploaded + // file reference. Agent-backed actions can use the same workflow conversation to + // access the file. + WorkflowRunner runner = new(); + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, CreateInputMessage(uploadedFile.FileId)); + } + + private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration) + { + // 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(foundryEndpoint, new DefaultAzureCredential()); + + await aiProjectClient.CreateAgentAsync( + agentName: "FileInputAgent", + agentDefinition: DefineFileInputAgent(configuration), + agentDescription: "Summarizes files provided as declarative workflow input."); + } + + private static DeclarativeAgentDefinition DefineFileInputAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModel)) + { + Instructions = + """ + You summarize files that are provided as user input to a workflow. + + When a file is attached, inspect the file content and provide: + - A short summary + - Important facts or entities + - One suggested follow-up question + + If no file content is available, explain that you did not receive a file. + """ + }; + + private static async Task UploadInputFileAsync(Uri foundryEndpoint, string filePath) + { + // 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(foundryEndpoint, new DefaultAzureCredential()); + OpenAIFileClient fileClient = aiProjectClient.GetProjectOpenAIClient().GetOpenAIFileClient(); + + using FileStream fileStream = File.OpenRead(filePath); + OpenAIFile openAIFile = await fileClient.UploadFileAsync( + fileStream, + Path.GetFileName(filePath), + FileUploadPurpose.Assistants).ConfigureAwait(false); + + Console.ForegroundColor = ConsoleColor.Cyan; + try + { + Console.WriteLine($"FILE: {openAIFile.Id}"); + } + finally + { + Console.ResetColor(); + } + + return new UploadedFile(fileClient, openAIFile.Id); + } + + private static ChatMessage CreateInputMessage(string fileId) + { + return new ChatMessage( + ChatRole.User, + [ + new TextContent("Summarize the attached file for a launch announcement. File name: ProductBrief.txt"), + new HostedFileContent(fileId), + ]); + } + + private sealed record UploadedFile(OpenAIFileClient FileClient, string FileId) : IAsyncDisposable + { + public async ValueTask DisposeAsync() + { + try + { + await this.FileClient.DeleteFileAsync(this.FileId).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Unable to delete uploaded file {this.FileId}: {ex.Message}"); + } + } + } +} diff --git a/dotnet/samples/03-workflows/Declarative/FileInput/README.md b/dotnet/samples/03-workflows/Declarative/FileInput/README.md new file mode 100644 index 00000000000..85a6eb490b5 --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/FileInput/README.md @@ -0,0 +1,15 @@ +# Declarative workflow file input + +This sample demonstrates how to provide file-based input to a declarative workflow. It uploads the bundled `ProductBrief.txt` file to the Foundry project, converts the uploaded file reference into a `ChatMessage` with both `TextContent` and `HostedFileContent`, then starts a YAML-defined workflow with that message. + +The workflow displays `System.LastMessage.Text`, then invokes a Foundry-backed agent in the same workflow conversation so the uploaded file is available to the agent. + +## Run the sample + +Configure the common declarative workflow settings described in the parent [README](../README.md), then run: + +```pwsh +dotnet run +``` + +The sample always uploads `ProductBrief.txt` from this project. The program creates a `ChatMessage` whose content includes the prompt and uploaded file reference, then starts the workflow with that message. The YAML invokes the agent with `conversationId: =System.ConversationId` so the agent sees the same conversation item that contains the file. diff --git a/dotnet/samples/03-workflows/Declarative/README.md b/dotnet/samples/03-workflows/Declarative/README.md index 1fe87a6a782..745d4fe122b 100644 --- a/dotnet/samples/03-workflows/Declarative/README.md +++ b/dotnet/samples/03-workflows/Declarative/README.md @@ -97,3 +97,15 @@ To run the sampes from the command line: dotnet run c:/myworkflows/Marketing.yaml ``` > The sample will allow for interactive input in the absence of an input argument. + +### File-based input + +The `FileInput` sample demonstrates starting a declarative workflow with a `ChatMessage` +that contains an uploaded file reference, not just text: + +```pwsh +cd dotnet/samples/03-workflows/Declarative/FileInput +dotnet run +``` + +See [FileInput](./FileInput/) for details. diff --git a/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs index b5573ec80e1..c0c3f882104 100644 --- a/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs +++ b/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs @@ -54,6 +54,17 @@ public WorkflowRunner(params IEnumerable functions) } public async Task ExecuteAsync(Func workflowProvider, string input) + { + await this.ExecuteCoreAsync(workflowProvider, input).ConfigureAwait(false); + } + + public async Task ExecuteAsync(Func workflowProvider, ChatMessage input) + { + await this.ExecuteCoreAsync(workflowProvider, input).ConfigureAwait(false); + } + + private async Task ExecuteCoreAsync(Func workflowProvider, TInput input) + where TInput : notnull { // Reset EOF flag so a reused WorkflowRunner instance handles stdin correctly on each run. this._stdinEof = false;