diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 6f5fa9d46a..35df1a77f6 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -307,7 +307,9 @@ - + + + diff --git a/dotnet/samples/04-hosting/ContainerWorkflow/ContainerWorkflow.csproj b/dotnet/samples/04-hosting/ContainerWorkflow/ContainerWorkflow.csproj new file mode 100644 index 0000000000..a7d4c64490 --- /dev/null +++ b/dotnet/samples/04-hosting/ContainerWorkflow/ContainerWorkflow.csproj @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + + + + + diff --git a/dotnet/samples/04-hosting/ContainerWorkflow/Dockerfile b/dotnet/samples/04-hosting/ContainerWorkflow/Dockerfile new file mode 100644 index 0000000000..7ad8c6a55a --- /dev/null +++ b/dotnet/samples/04-hosting/ContainerWorkflow/Dockerfile @@ -0,0 +1,13 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet publish dotnet/samples/04-hosting/ContainerWorkflow/ContainerWorkflow.csproj \ + -c Release -f net10.0 -o /app/publish --tl:off + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish . +USER $APP_UID +ENV ASPNETCORE_HTTP_PORTS=8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "ContainerWorkflow.dll"] diff --git a/dotnet/samples/04-hosting/ContainerWorkflow/Dockerfile.dockerignore b/dotnet/samples/04-hosting/ContainerWorkflow/Dockerfile.dockerignore new file mode 100644 index 0000000000..18c7b2c67e --- /dev/null +++ b/dotnet/samples/04-hosting/ContainerWorkflow/Dockerfile.dockerignore @@ -0,0 +1,15 @@ +** +!CODE_OF_CONDUCT.md +!.editorconfig +!dotnet/ +!dotnet/** +**/bin/ +**/obj/ +**/.env +**/.env.* +**/local-feed/ +**/appsettings.Development.json +**/secrets.json +**/*.pfx +**/*.pem +**/*.key diff --git a/dotnet/samples/04-hosting/ContainerWorkflow/Program.cs b/dotnet/samples/04-hosting/ContainerWorkflow/Program.cs new file mode 100644 index 0000000000..207cb7118f --- /dev/null +++ b/dotnet/samples/04-hosting/ContainerWorkflow/Program.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Host a deterministic expense-routing workflow in ASP.NET Core, locally or in a container. +using Microsoft.Agents.AI.Workflows; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +WebApplication app = builder.Build(); + +app.MapGet("/health", () => Results.Ok()); +app.MapPost("/expenses", async (ExpenseRequest expense, CancellationToken cancellationToken) => +{ + if (string.IsNullOrWhiteSpace(expense.Id) || expense.Id.Length > 128 || expense.Amount <= 0) + { + return Results.BadRequest(new { error = "Supply an id of 1-128 characters and an amount greater than zero." }); + } + + // Each request owns its executors and workflow state, so concurrent requests remain isolated. + NormalizeExpenseExecutor normalize = new(); + RouteExpenseExecutor route = new(); + Workflow workflow = new WorkflowBuilder(normalize).AddEdge(normalize, route).WithOutputFrom(route).Build(); + + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, expense, cancellationToken: cancellationToken); + ExpenseDecision? decision = null; + await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken)) + { + if (evt is WorkflowErrorEvent or ExecutorFailedEvent) + { + return Results.Problem("The expense workflow failed."); + } + + if (evt is WorkflowOutputEvent { Data: ExpenseDecision output }) + { + decision = output; + } + } + + // Disposing the run also stops execution when request cancellation ends stream consumption. + return decision is null ? Results.Problem("The workflow produced no decision.") : Results.Ok(decision); +}); + +await app.RunAsync(); + +internal readonly record struct ExpenseRequest(string? Id, decimal Amount); + +internal sealed record ExpenseDecision(string Id, decimal Amount, string Route); + +internal sealed class NormalizeExpenseExecutor() : Executor("NormalizeExpense") +{ + /// + public override ValueTask HandleAsync(ExpenseRequest message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(message with { Id = message.Id!.Trim() }); + } +} + +internal sealed class RouteExpenseExecutor() : Executor("RouteExpense") +{ + /// + public override ValueTask HandleAsync(ExpenseRequest message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + // ponytail: this sample routes expenses; it does not approve payments or call a business system. + return ValueTask.FromResult(new ExpenseDecision(message.Id!, message.Amount, + message.Amount > 100 ? "manager_review" : "standard_review")); + } +} diff --git a/dotnet/samples/04-hosting/ContainerWorkflow/README.md b/dotnet/samples/04-hosting/ContainerWorkflow/README.md new file mode 100644 index 0000000000..921590b9eb --- /dev/null +++ b/dotnet/samples/04-hosting/ContainerWorkflow/README.md @@ -0,0 +1,76 @@ +# Host a workflow in a container + +This sample hosts a pro-code workflow in ASP.NET Core and packages it as a non-root +Linux container. It routes an expense through two executors: normalize the expense +ID, then select standard review (amount <= 100) or manager review (amount > 100). +The rules are deterministic so you can verify the hosting setup without a model, +cloud account, credentials, or external business system. + +Each HTTP request builds its own workflow and executors. Cancelling a request ends +event consumption; disposing its `StreamingRun` stops the underlying execution. +An executor failure produces an HTTP 500 response rather than a successful result. + +## Run locally + +Install the .NET SDK specified in `dotnet/global.json`. From the repository root: + +```sh +dotnet run --project dotnet/samples/04-hosting/ContainerWorkflow -f net10.0 -- --urls http://localhost:8080 +``` + +In another terminal: + +```sh +curl http://localhost:8080/health +curl -H "Content-Type: application/json" -d '{"id":"EXP-001","amount":150}' http://localhost:8080/expenses +``` + +Expected response: + +```json +{"id":"EXP-001","amount":150,"route":"manager_review"} +``` + +For PowerShell, use: + +```powershell +Invoke-RestMethod http://localhost:8080/expenses -Method Post -ContentType application/json -Body '{"id":"EXP-001","amount":150}' +``` + +Amounts of 100 or less select `standard_review`. A missing/blank ID, an ID longer +than 128 characters, or a non-positive amount returns HTTP 400. Malformed JSON is +also rejected by ASP.NET Core. + +## Build and run the container + +With Docker running in Linux-container mode, run these commands from the repository +root. The build uses project references to compile this checkout of the framework. +The Dockerfile-specific ignore file limits the build context to .NET sources and +excludes local build output and common credential files. + +```sh +docker build -f dotnet/samples/04-hosting/ContainerWorkflow/Dockerfile -t agent-framework-workflow . +docker run --rm --name agent-framework-workflow -p 127.0.0.1:8080:8080 agent-framework-workflow +``` + +Use the same requests above to verify the container. Stop it with Ctrl+C, or from +another terminal: + +```sh +docker stop agent-framework-workflow +``` + +## Hosting boundaries + +This is an in-process, request/response deployment example. It does not persist +workflow state across process restarts, execute payments, or provide a human +approval UI. The expense ID is returned for correlation; it is not an idempotency +key. Repeating a request executes a new workflow. + +The endpoint is unauthenticated for local testing. Add application authentication +and authorization before exposing it to other users. No inbound credentials or +request bodies are deliberately logged by the sample. + +For cloud-managed hosting, see [Foundry hosted agents](../FoundryHostedAgents). +For durable agents and external scheduling, see the +[Durable Agent Framework extension](https://github.com/microsoft/agent-framework-durable-extension). diff --git a/dotnet/samples/04-hosting/README.md b/dotnet/samples/04-hosting/README.md new file mode 100644 index 0000000000..31bba5b91e --- /dev/null +++ b/dotnet/samples/04-hosting/README.md @@ -0,0 +1,7 @@ +# Hosting samples + +| Sample | Demonstrates | +| --- | --- | +| [Container workflow](./ContainerWorkflow) | Host a pro-code workflow with ASP.NET Core and run it in a Docker container | +| [Foundry hosted agents](./FoundryHostedAgents) | Deploy and invoke agents with Microsoft Foundry | +| [A2A](./A2A) | Host agents using the Agent-to-Agent protocol |