From 0b1ed03cd00c997cd668bb19a68776a5616a0fd2 Mon Sep 17 00:00:00 2001 From: alliscode Date: Tue, 31 Mar 2026 14:57:00 -0700 Subject: [PATCH 01/31] Add Azure AI Foundry Responses hosting adapter Implement Microsoft.Agents.AI.Hosting.AzureAIResponses to host agent-framework AIAgents and workflows within Azure Foundry as hosted agents via the Azure.AI.AgentServer.Responses SDK. - AgentFrameworkResponseHandler: bridges ResponseHandler to AIAgent execution - InputConverter: converts Responses API inputs/history to MEAI ChatMessage - OutputConverter: converts agent response updates to SSE event stream - ServiceCollectionExtensions: DI registration helpers - 336 unit tests across net8.0/net9.0/net10.0 (112 per TFM) - ResponseStreamValidator: SSE protocol validation tool for samples - FoundryResponsesHosting sample app Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 4 + dotnet/agent-framework-dotnet.slnx | 5 + dotnet/nuget.config | 4 + .../FoundryResponsesHosting.csproj | 27 + .../FoundryResponsesHosting/Pages.cs | 470 +++++++ .../FoundryResponsesHosting/Program.cs | 183 +++ .../Properties/launchSettings.json | 12 + .../ResponseStreamValidator.cs | 601 +++++++++ .../AgentFrameworkResponseHandler.cs | 186 +++ .../InputConverter.cs | 296 +++++ ....Agents.AI.Hosting.AzureAIResponses.csproj | 40 + .../OutputConverter.cs | 346 ++++++ .../ServiceCollectionExtensions.cs | 78 ++ .../AgentFrameworkResponseHandlerTests.cs | 815 +++++++++++++ .../InputConverterTests.cs | 671 ++++++++++ ....Hosting.AzureAIResponses.UnitTests.csproj | 17 + .../OutputConverterTests.cs | 1080 +++++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 72 ++ .../WorkflowIntegrationTests.cs | 509 ++++++++ 19 files changed, 5416 insertions(+) create mode 100644 dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj create mode 100644 dotnet/samples/04-hosting/FoundryResponsesHosting/Pages.cs create mode 100644 dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryResponsesHosting/Properties/launchSettings.json create mode 100644 dotnet/samples/04-hosting/FoundryResponsesHosting/ResponseStreamValidator.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/AgentFrameworkResponseHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/InputConverter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 1d3c2608b9..20dbf32bb6 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -19,9 +19,13 @@ + + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 24b596509e..3d0465763f 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -261,6 +261,9 @@ + + + @@ -491,6 +494,7 @@ + @@ -537,6 +541,7 @@ + diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 76d943ce16..202c1fc671 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -3,8 +3,12 @@ + + + + diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj b/dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj new file mode 100644 index 0000000000..6725ef8d3b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj @@ -0,0 +1,27 @@ + + + + Exe + net10.0 + enable + enable + false + $(NoWarn);NU1903;NU1605 + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/Pages.cs b/dotnet/samples/04-hosting/FoundryResponsesHosting/Pages.cs new file mode 100644 index 0000000000..bff2c62e99 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/Pages.cs @@ -0,0 +1,470 @@ +// Copyright (c) Microsoft. All rights reserved. + +/// +/// Static HTML pages served by the sample application. +/// +internal static class Pages +{ + // ═══════════════════════════════════════════════════════════════════════ + // Homepage + // ═══════════════════════════════════════════════════════════════════════ + + internal const string Home = """ + + + + + Foundry Responses Hosting — Demos + + + +
+

🚀 Foundry Responses Hosting

+

+ Agent-framework agents hosted via the Azure AI Responses Server SDK.
+ Each demo registers a different agent and serves it through POST /responses. +

+ +
+ All demos share the same /responses endpoint. + The model field in the request selects which agent handles it. +
+
+ + +"""; + + // ═══════════════════════════════════════════════════════════════════════ + // Tool Demo + // ═══════════════════════════════════════════════════════════════════════ + + internal const string ToolDemo = """ + + + + + Tool Demo — Foundry Responses Hosting + + + +
+ ← Back to demos +

🔧 Tool Demo

+

Agent with local tools (time, weather) + Microsoft Learn MCP (docs search)

+
+ + + + +
+
+
+ + +
+
+
+ + + + +"""; + + // ═══════════════════════════════════════════════════════════════════════ + // Workflow Demo + // ═══════════════════════════════════════════════════════════════════════ + + internal const string WorkflowDemo = """ + + + + + Workflow Demo — Foundry Responses Hosting + + + +
+ ← Back to demos +

🔀 Workflow Demo — Agent Handoffs

+

A triage agent routes your question to a specialist (Code Expert or Creative Writer)

+
+
👤 User → 🔀 Triage → 💻 Code Expert / ✍️ Creative Writer
+
+
+ + + + +
+
+
+ + +
+
+
+ + + + +"""; + + // ═══════════════════════════════════════════════════════════════════════ + // SSE Validator Script (shared by all demo pages) + // ═══════════════════════════════════════════════════════════════════════ + + internal const string ValidationScript = """ +// SseValidator - inline SSE stream validation for Foundry Responses demos +// Captures events during streaming and validates against the API behaviour contract. +(function() { + const style = document.createElement('style'); + style.textContent = ` + .sse-val { margin: .4rem 0 .6rem; padding: .3rem .5rem; font-size: .75rem; color: #aaa; border-top: 1px dashed #e8e8e8; } + .val-ok { color: #7ab88a; } + .val-err { color: #d47272; font-weight: 500; } + .val-issues { margin: .2rem 0; } + .val-issue { color: #c06060; font-size: .72rem; padding: .1rem 0; } + .val-issue b { color: #b04040; } + .val-at { color: #ccc; font-size: .68rem; } + .val-log summary { cursor: pointer; color: #bbb; font-size: .72rem; } + .val-log-items { max-height: 120px; overflow-y: auto; font-size: .7rem; background: #fafafa; + padding: .3rem; border-radius: 3px; margin-top: .15rem; + font-family: 'Cascadia Code', 'Fira Code', monospace; } + .val-i { color: #ccc; display: inline-block; width: 1.8rem; text-align: right; margin-right: .3rem; } + .val-t { color: #8ab4d0; } + `; + document.head.appendChild(style); +})(); + +class SseValidator { + constructor() { this.events = []; } + reset() { this.events = []; } + capture(eventType, data) { this.events.push({ eventType, data }); } + + async validate() { + const resp = await fetch('/api/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ events: this.events }) + }); + return await resp.json(); + } + + renderElement(result) { + const el = document.createElement('div'); + el.className = 'sse-val'; + const n = result.eventCount; + const ok = result.isValid; + const vs = result.violations || []; + const esc = s => String(s).replace(/&/g,'&').replace(//g,'>'); + + let h = ok + ? `${n} events — all rules passed ✅` + : `${n} events — ${vs.length} violation(s)`; + + if (vs.length) { + h += '
'; + vs.forEach(v => { + h += `
[${esc(v.ruleId)}] ${esc(v.message)} #${v.eventIndex}
`; + }); + h += '
'; + } + + h += `
Event log (${this.events.length})
`; + this.events.forEach((e, i) => { + h += `
${i} ${esc(e.eventType)}
`; + }); + h += '
'; + + el.innerHTML = h; + return el; + } +} +"""; +} diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs b/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs new file mode 100644 index 0000000000..32f39f641c --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates hosting agent-framework agents as Foundry Hosted Agents +// using the Azure AI Responses Server SDK. +// +// Demos: +// / - Homepage listing all demos +// /tool-demo - Agent with local tools + remote MCP tools +// /workflow-demo - Triage workflow routing to specialist agents +// +// Prerequisites: +// - Azure OpenAI resource with a deployed model +// +// Environment variables: +// - AZURE_OPENAI_ENDPOINT - your Azure OpenAI endpoint +// - AZURE_OPENAI_DEPLOYMENT - the model deployment name (default: "gpt-4o") + +using System.ComponentModel; +using Azure.AI.OpenAI; +using Azure.AI.AgentServer.Responses; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Hosting.AzureAIResponses; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; + +var builder = WebApplication.CreateBuilder(args); + +// --------------------------------------------------------------------------- +// 1. Register the Azure AI Responses Server SDK +// --------------------------------------------------------------------------- +builder.Services.AddResponsesServer(); + +// --------------------------------------------------------------------------- +// 2. Create the shared Azure OpenAI chat client +// --------------------------------------------------------------------------- +var endpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.")); +var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o"; + +var azureClient = new AzureOpenAIClient(endpoint, new DefaultAzureCredential()); +IChatClient chatClient = azureClient.GetChatClient(deployment).AsIChatClient(); + +// --------------------------------------------------------------------------- +// 3. DEMO 1: Tool Agent — local tools + Microsoft Learn MCP +// --------------------------------------------------------------------------- +Console.WriteLine("Connecting to Microsoft Learn MCP server..."); +McpClient mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new() +{ + Endpoint = new Uri("https://learn.microsoft.com/api/mcp"), + Name = "Microsoft Learn MCP", +})); +var mcpTools = await mcpClient.ListToolsAsync(); +Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}"); + +builder.AddAIAgent( + name: "tool-agent", + instructions: """ + You are a helpful assistant hosted as a Foundry Hosted Agent. + You have access to several tools - use them proactively: + - GetCurrentTime: Returns the current date/time in any timezone. + - GetWeather: Returns weather conditions for any location. + - Microsoft Learn MCP tools: Search and fetch Microsoft documentation. + When a user asks a technical question about Microsoft products, use the + documentation search tools to give accurate, up-to-date answers. + """, + chatClient: chatClient) + .WithAITool(AIFunctionFactory.Create(GetCurrentTime)) + .WithAITool(AIFunctionFactory.Create(GetWeather)) + .WithAITools(mcpTools.Cast().ToArray()); + +// --------------------------------------------------------------------------- +// 4. DEMO 2: Triage Workflow — routes to specialist agents +// --------------------------------------------------------------------------- +ChatClientAgent triageAgent = new( + chatClient, + instructions: """ + You are a triage agent that determines which specialist to hand off to. + Based on the user's question, ALWAYS hand off to one of the available agents. + Do NOT answer the question yourself - just route it. + """, + name: "triage_agent", + description: "Routes messages to the appropriate specialist agent"); + +ChatClientAgent codeExpert = new( + chatClient, + instructions: """ + You are a coding and technology expert. You help with programming questions, + explain technical concepts, debug code, and suggest best practices. + Provide clear, well-structured answers with code examples when appropriate. + """, + name: "code_expert", + description: "Specialist agent for programming and technology questions"); + +ChatClientAgent creativeWriter = new( + chatClient, + instructions: """ + You are a creative writing specialist. You help write stories, poems, + marketing copy, emails, and other creative content. You have a flair + for engaging language and vivid descriptions. + """, + name: "creative_writer", + description: "Specialist agent for creative writing and content tasks"); + +Workflow triageWorkflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(triageAgent) + .WithHandoffs(triageAgent, [codeExpert, creativeWriter]) + .WithHandoffs([codeExpert, creativeWriter], triageAgent) + .Build(); + +builder.AddAIAgent("triage-workflow", (_, key) => + triageWorkflow.AsAIAgent(name: key)); + +// --------------------------------------------------------------------------- +// 5. Wire up the agent-framework handler as the IResponseHandler +// --------------------------------------------------------------------------- +builder.Services.AddAgentFrameworkHandler(); + +var app = builder.Build(); + +// Dispose the MCP client on shutdown +app.Lifetime.ApplicationStopping.Register(() => + mcpClient.DisposeAsync().AsTask().GetAwaiter().GetResult()); + +// --------------------------------------------------------------------------- +// 6. Routes +// --------------------------------------------------------------------------- +app.MapGet("/ready", () => Results.Ok("ready")); +app.MapResponsesServer(); + +app.MapGet("/", () => Results.Content(Pages.Home, "text/html")); +app.MapGet("/tool-demo", () => Results.Content(Pages.ToolDemo, "text/html")); +app.MapGet("/workflow-demo", () => Results.Content(Pages.WorkflowDemo, "text/html")); +app.MapGet("/js/sse-validator.js", () => Results.Content(Pages.ValidationScript, "application/javascript")); + +// Validation endpoint: accepts captured SSE lines and validates them +app.MapPost("/api/validate", (FoundryResponsesHosting.CapturedSseStream captured) => +{ + var validator = new FoundryResponsesHosting.ResponseStreamValidator(); + foreach (var evt in captured.Events) + { + validator.ProcessEvent(evt.EventType, evt.Data); + } + + validator.Complete(); + return Results.Json(validator.GetResult()); +}); + +app.Run(); + +// --------------------------------------------------------------------------- +// Local tool definitions +// --------------------------------------------------------------------------- + +[Description("Gets the current date and time in the specified timezone.")] +static string GetCurrentTime( + [Description("IANA timezone (e.g. 'America/New_York', 'Europe/London', 'UTC'). Defaults to UTC.")] + string timezone = "UTC") +{ + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(timezone); + return TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz).ToString("F"); + } + catch + { + return DateTime.UtcNow.ToString("F") + " (UTC - unknown timezone: " + timezone + ")"; + } +} + +[Description("Gets the current weather for a location. Returns temperature, conditions, and humidity.")] +static string GetWeather( + [Description("The city or location (e.g. 'Seattle', 'London, UK').")] + string location) +{ + // Simulated weather - deterministic per location for demo consistency + var rng = new Random(location.ToUpperInvariant().GetHashCode()); + var temp = rng.Next(-5, 35); + string[] conditions = ["sunny", "partly cloudy", "overcast", "rainy", "snowy", "windy", "foggy"]; + var condition = conditions[rng.Next(conditions.Length)]; + return $"Weather in {location}: {temp}C, {condition}. Humidity: {rng.Next(30, 90)}%. Wind: {rng.Next(5, 30)} km/h."; +} diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryResponsesHosting/Properties/launchSettings.json new file mode 100644 index 0000000000..b56d7a9ff4 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "FoundryResponsesHosting": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:54747;http://localhost:54748" + } + } +} \ No newline at end of file diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/ResponseStreamValidator.cs b/dotnet/samples/04-hosting/FoundryResponsesHosting/ResponseStreamValidator.cs new file mode 100644 index 0000000000..72da677f45 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/ResponseStreamValidator.cs @@ -0,0 +1,601 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FoundryResponsesHosting; + +/// Captured SSE event for validation. +[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Instantiated by JSON deserialization")] +internal sealed record CapturedSseEvent( + [property: JsonPropertyName("eventType")] string EventType, + [property: JsonPropertyName("data")] string Data); + +/// Captured SSE stream sent from the client for server-side validation. +[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Instantiated by JSON deserialization")] +internal sealed record CapturedSseStream( + [property: JsonPropertyName("events")] List Events); + +/// +/// Validates an SSE event stream from the Azure AI Responses Server SDK against +/// the API behaviour contract. Feed events sequentially via +/// and call when the stream ends. +/// +internal sealed class ResponseStreamValidator +{ + private readonly List _violations = []; + private int _eventCount; + private int _expectedSequenceNumber; + private StreamState _state = StreamState.Initial; + private string? _responseId; + private readonly HashSet _addedItemIndices = []; + private readonly HashSet _doneItemIndices = []; + private readonly HashSet _addedContentParts = []; // "outputIdx:partIdx" + private readonly HashSet _doneContentParts = []; + private readonly Dictionary _textAccumulators = []; // "outputIdx:contentIdx" → accumulated text + private bool _hasTerminal; + + /// All violations found so far. + internal IReadOnlyList Violations => _violations; + + /// + /// Processes a single SSE event line pair (event type + JSON data). + /// + /// The SSE event type (e.g. "response.created"). + /// The raw JSON data payload. + internal void ProcessEvent(string eventType, string jsonData) + { + JsonElement data; + try + { + data = JsonDocument.Parse(jsonData).RootElement; + } + catch (JsonException ex) + { + Fail("PARSE-01", $"Invalid JSON in event data: {ex.Message}"); + return; + } + + _eventCount++; + + // ── Sequence number validation ────────────────────────────────── + if (data.TryGetProperty("sequence_number", out var seqProp) && seqProp.ValueKind == JsonValueKind.Number) + { + int seq = seqProp.GetInt32(); + if (seq != _expectedSequenceNumber) + { + Fail("SEQ-01", $"Expected sequence_number {_expectedSequenceNumber}, got {seq}"); + } + + _expectedSequenceNumber = seq + 1; + } + else if (_state != StreamState.Initial || eventType != "error") + { + // Pre-creation error events may not have sequence_number + Fail("SEQ-02", $"Missing sequence_number on event '{eventType}'"); + } + + // ── Post-terminal guard ───────────────────────────────────────── + if (_hasTerminal) + { + Fail("TERM-01", $"Event '{eventType}' received after terminal event"); + return; + } + + // ── Dispatch by event type ────────────────────────────────────── + switch (eventType) + { + case "response.created": + ValidateResponseCreated(data); + break; + + case "response.queued": + ValidateStateTransition(eventType, StreamState.Created, StreamState.Queued); + ValidateResponseEnvelope(data, eventType); + break; + + case "response.in_progress": + if (_state is StreamState.Created or StreamState.Queued) + { + _state = StreamState.InProgress; + } + else + { + Fail("ORDER-02", $"'response.in_progress' received in state {_state} (expected Created or Queued)"); + } + + ValidateResponseEnvelope(data, eventType); + break; + + case "response.output_item.added": + case "output_item.added": + ValidateInProgress(eventType); + ValidateOutputItemAdded(data); + break; + + case "response.output_item.done": + case "output_item.done": + ValidateInProgress(eventType); + ValidateOutputItemDone(data); + break; + + case "response.content_part.added": + case "content_part.added": + ValidateInProgress(eventType); + ValidateContentPartAdded(data); + break; + + case "response.content_part.done": + case "content_part.done": + ValidateInProgress(eventType); + ValidateContentPartDone(data); + break; + + case "response.output_text.delta": + case "output_text.delta": + ValidateInProgress(eventType); + ValidateTextDelta(data); + break; + + case "response.output_text.done": + case "output_text.done": + ValidateInProgress(eventType); + ValidateTextDone(data); + break; + + case "response.function_call_arguments.delta": + case "function_call_arguments.delta": + ValidateInProgress(eventType); + break; + + case "response.function_call_arguments.done": + case "function_call_arguments.done": + ValidateInProgress(eventType); + break; + + case "response.completed": + ValidateTerminal(data, "completed"); + break; + + case "response.failed": + ValidateTerminal(data, "failed"); + break; + + case "response.incomplete": + ValidateTerminal(data, "incomplete"); + break; + + case "error": + // Pre-creation error — standalone, no response.created precedes it + if (_state != StreamState.Initial) + { + Fail("ERR-01", "'error' event received after response.created — should use response.failed instead"); + } + + _hasTerminal = true; + break; + + default: + // Unknown events are not violations — the spec may evolve + break; + } + } + + /// + /// Call after the stream ends. Checks that a terminal event was received. + /// + internal void Complete() + { + if (!_hasTerminal && _state != StreamState.Initial) + { + Fail("TERM-02", "Stream ended without a terminal event (response.completed, response.failed, or response.incomplete)"); + } + + if (_state == StreamState.Initial && _eventCount == 0) + { + Fail("EMPTY-01", "No events received in the stream"); + } + + // Check for output items that were added but never completed + foreach (int idx in _addedItemIndices) + { + if (!_doneItemIndices.Contains(idx)) + { + Fail("ITEM-03", $"Output item at index {idx} was added but never received output_item.done"); + } + } + + // Check for content parts that were added but never completed + foreach (string key in _addedContentParts) + { + if (!_doneContentParts.Contains(key)) + { + Fail("CONTENT-03", $"Content part '{key}' was added but never received content_part.done"); + } + } + } + + /// + /// Returns a summary of all validation results. + /// + internal ValidationResult GetResult() + { + return new ValidationResult( + EventCount: _eventCount, + IsValid: _violations.Count == 0, + Violations: [.. _violations]); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Event-specific validators + // ═══════════════════════════════════════════════════════════════════════ + + private void ValidateResponseCreated(JsonElement data) + { + if (_state != StreamState.Initial) + { + Fail("ORDER-01", $"'response.created' received in state {_state} (expected Initial — must be first event)"); + return; + } + + _state = StreamState.Created; + + // Must have a response envelope + if (!data.TryGetProperty("response", out var resp)) + { + Fail("FIELD-01", "'response.created' missing 'response' object"); + return; + } + + // Required response fields + ValidateRequiredResponseFields(resp, "response.created"); + + // Capture response ID for cross-event checks + if (resp.TryGetProperty("id", out var idProp)) + { + _responseId = idProp.GetString(); + } + + // Status must be non-terminal + if (resp.TryGetProperty("status", out var statusProp)) + { + string? status = statusProp.GetString(); + if (status is "completed" or "failed" or "incomplete" or "cancelled") + { + Fail("STATUS-01", $"'response.created' has terminal status '{status}' — must be 'queued' or 'in_progress'"); + } + } + } + + private void ValidateTerminal(JsonElement data, string expectedKind) + { + if (_state is StreamState.Initial or StreamState.Created) + { + Fail("ORDER-03", $"Terminal event 'response.{expectedKind}' received before 'response.in_progress'"); + } + + _hasTerminal = true; + _state = StreamState.Terminal; + + if (!data.TryGetProperty("response", out var resp)) + { + Fail("FIELD-01", $"'response.{expectedKind}' missing 'response' object"); + return; + } + + ValidateRequiredResponseFields(resp, $"response.{expectedKind}"); + + if (resp.TryGetProperty("status", out var statusProp)) + { + string? status = statusProp.GetString(); + + // completed_at validation (B6) + bool hasCompletedAt = resp.TryGetProperty("completed_at", out var catProp) + && catProp.ValueKind != JsonValueKind.Null; + + if (status == "completed" && !hasCompletedAt) + { + Fail("FIELD-02", "'completed_at' must be non-null when status is 'completed'"); + } + + if (status != "completed" && hasCompletedAt) + { + Fail("FIELD-03", $"'completed_at' must be null when status is '{status}'"); + } + + // error field validation + bool hasError = resp.TryGetProperty("error", out var errProp) + && errProp.ValueKind != JsonValueKind.Null; + + if (status == "failed" && !hasError) + { + Fail("FIELD-04", "'error' must be non-null when status is 'failed'"); + } + + if (status is "completed" or "incomplete" && hasError) + { + Fail("FIELD-05", $"'error' must be null when status is '{status}'"); + } + + // error structure validation + if (hasError) + { + ValidateErrorObject(errProp, $"response.{expectedKind}"); + } + + // cancelled output must be empty (B11) + if (status == "cancelled" && resp.TryGetProperty("output", out var outputProp) + && outputProp.ValueKind == JsonValueKind.Array && outputProp.GetArrayLength() > 0) + { + Fail("CANCEL-01", "Cancelled response must have empty output array (B11)"); + } + + // response ID consistency + if (_responseId is not null && resp.TryGetProperty("id", out var idProp) + && idProp.GetString() != _responseId) + { + Fail("ID-01", $"Response ID changed: was '{_responseId}', now '{idProp.GetString()}'"); + } + } + + // Usage validation (optional, but if present must be structured correctly) + if (resp.TryGetProperty("usage", out var usageProp) && usageProp.ValueKind == JsonValueKind.Object) + { + ValidateUsage(usageProp, $"response.{expectedKind}"); + } + } + + private void ValidateOutputItemAdded(JsonElement data) + { + if (data.TryGetProperty("output_index", out var idxProp) && idxProp.ValueKind == JsonValueKind.Number) + { + int index = idxProp.GetInt32(); + if (!_addedItemIndices.Add(index)) + { + Fail("ITEM-01", $"Duplicate output_item.added for output_index {index}"); + } + } + else + { + Fail("FIELD-06", "output_item.added missing 'output_index' field"); + } + + if (!data.TryGetProperty("item", out _)) + { + Fail("FIELD-07", "output_item.added missing 'item' object"); + } + } + + private void ValidateOutputItemDone(JsonElement data) + { + if (data.TryGetProperty("output_index", out var idxProp) && idxProp.ValueKind == JsonValueKind.Number) + { + int index = idxProp.GetInt32(); + if (!_addedItemIndices.Contains(index)) + { + Fail("ITEM-02", $"output_item.done for output_index {index} without preceding output_item.added"); + } + + _doneItemIndices.Add(index); + } + else + { + Fail("FIELD-06", "output_item.done missing 'output_index' field"); + } + } + + private void ValidateContentPartAdded(JsonElement data) + { + string key = GetContentPartKey(data); + if (!_addedContentParts.Add(key)) + { + Fail("CONTENT-01", $"Duplicate content_part.added for {key}"); + } + } + + private void ValidateContentPartDone(JsonElement data) + { + string key = GetContentPartKey(data); + if (!_addedContentParts.Contains(key)) + { + Fail("CONTENT-02", $"content_part.done for {key} without preceding content_part.added"); + } + + _doneContentParts.Add(key); + } + + private void ValidateTextDelta(JsonElement data) + { + string key = GetTextKey(data); + string delta = data.TryGetProperty("delta", out var deltaProp) + ? deltaProp.GetString() ?? string.Empty + : string.Empty; + + if (!_textAccumulators.TryGetValue(key, out string? existing)) + { + _textAccumulators[key] = delta; + } + else + { + _textAccumulators[key] = existing + delta; + } + } + + private void ValidateTextDone(JsonElement data) + { + string key = GetTextKey(data); + string? finalText = data.TryGetProperty("text", out var textProp) + ? textProp.GetString() + : null; + + if (finalText is null) + { + Fail("TEXT-01", $"output_text.done for {key} missing 'text' field"); + return; + } + + if (_textAccumulators.TryGetValue(key, out string? accumulated) && accumulated != finalText) + { + Fail("TEXT-02", $"output_text.done text for {key} does not match accumulated deltas (accumulated {accumulated.Length} chars, done has {finalText.Length} chars)"); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Shared field validators + // ═══════════════════════════════════════════════════════════════════════ + + private void ValidateRequiredResponseFields(JsonElement resp, string context) + { + if (!HasNonNullString(resp, "id")) + { + Fail("FIELD-01", $"{context}: response missing 'id'"); + } + + if (resp.TryGetProperty("object", out var objProp)) + { + if (objProp.GetString() != "response") + { + Fail("FIELD-08", $"{context}: response.object must be 'response', got '{objProp.GetString()}'"); + } + } + else + { + Fail("FIELD-08", $"{context}: response missing 'object' field"); + } + + if (!resp.TryGetProperty("created_at", out var catProp) || catProp.ValueKind == JsonValueKind.Null) + { + Fail("FIELD-09", $"{context}: response missing 'created_at'"); + } + + if (!resp.TryGetProperty("status", out _)) + { + Fail("FIELD-10", $"{context}: response missing 'status'"); + } + + if (!resp.TryGetProperty("output", out var outputProp) || outputProp.ValueKind != JsonValueKind.Array) + { + Fail("FIELD-11", $"{context}: response missing 'output' array"); + } + } + + private void ValidateErrorObject(JsonElement error, string context) + { + if (!HasNonNullString(error, "code")) + { + Fail("ERR-02", $"{context}: error object missing 'code' field"); + } + + if (!HasNonNullString(error, "message")) + { + Fail("ERR-03", $"{context}: error object missing 'message' field"); + } + } + + private void ValidateUsage(JsonElement usage, string context) + { + if (!usage.TryGetProperty("input_tokens", out _)) + { + Fail("USAGE-01", $"{context}: usage missing 'input_tokens'"); + } + + if (!usage.TryGetProperty("output_tokens", out _)) + { + Fail("USAGE-02", $"{context}: usage missing 'output_tokens'"); + } + + if (!usage.TryGetProperty("total_tokens", out _)) + { + Fail("USAGE-03", $"{context}: usage missing 'total_tokens'"); + } + } + + private void ValidateResponseEnvelope(JsonElement data, string eventType) + { + if (!data.TryGetProperty("response", out var resp)) + { + Fail("FIELD-01", $"'{eventType}' missing 'response' object"); + return; + } + + ValidateRequiredResponseFields(resp, eventType); + + // Response ID consistency + if (_responseId is not null && resp.TryGetProperty("id", out var idProp) + && idProp.GetString() != _responseId) + { + Fail("ID-01", $"Response ID changed: was '{_responseId}', now '{idProp.GetString()}'"); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Helpers + // ═══════════════════════════════════════════════════════════════════════ + + private void ValidateInProgress(string eventType) + { + if (_state != StreamState.InProgress) + { + Fail("ORDER-04", $"'{eventType}' received in state {_state} (expected InProgress)"); + } + } + + private void ValidateStateTransition(string eventType, StreamState expected, StreamState next) + { + if (_state != expected) + { + Fail("ORDER-05", $"'{eventType}' received in state {_state} (expected {expected})"); + } + else + { + _state = next; + } + } + + private void Fail(string ruleId, string message) + { + _violations.Add(new ValidationViolation(ruleId, message, _eventCount)); + } + + private static bool HasNonNullString(JsonElement obj, string property) + { + return obj.TryGetProperty(property, out var prop) + && prop.ValueKind == JsonValueKind.String + && !string.IsNullOrEmpty(prop.GetString()); + } + + private static string GetContentPartKey(JsonElement data) + { + int outputIdx = data.TryGetProperty("output_index", out var oi) ? oi.GetInt32() : -1; + int partIdx = data.TryGetProperty("content_index", out var pi) ? pi.GetInt32() : -1; + return $"{outputIdx}:{partIdx}"; + } + + private static string GetTextKey(JsonElement data) + { + int outputIdx = data.TryGetProperty("output_index", out var oi) ? oi.GetInt32() : -1; + int contentIdx = data.TryGetProperty("content_index", out var ci) ? ci.GetInt32() : -1; + return $"{outputIdx}:{contentIdx}"; + } + + private enum StreamState + { + Initial, + Created, + Queued, + InProgress, + Terminal, + } +} + +/// A single validation violation. +/// The rule identifier (e.g. SEQ-01, FIELD-02). +/// Human-readable description of the violation. +/// 1-based index of the event that triggered this violation. +internal sealed record ValidationViolation(string RuleId, string Message, int EventIndex); + +/// Overall validation result. +/// Total number of events processed. +/// True if no violations were found. +/// List of all violations. +internal sealed record ValidationResult(int EventCount, bool IsValid, IReadOnlyList Violations); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/AgentFrameworkResponseHandler.cs new file mode 100644 index 0000000000..5f07cd4530 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/AgentFrameworkResponseHandler.cs @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses; + +/// +/// A implementation that bridges the Azure AI Responses Server SDK +/// with agent-framework instances, enabling agent-framework agents and workflows +/// to be hosted as Azure Foundry Hosted Agents. +/// +public class AgentFrameworkResponseHandler : ResponseHandler +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class + /// that resolves agents from keyed DI services. + /// + /// The service provider for resolving agents. + /// The logger instance. + public AgentFrameworkResponseHandler( + IServiceProvider serviceProvider, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(logger); + + this._serviceProvider = serviceProvider; + this._logger = logger; + } + + /// + public override async IAsyncEnumerable CreateAsync( + CreateResponse request, + ResponseContext context, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + // 1. Resolve agent + var agent = this.ResolveAgent(request); + + // 2. Create the SDK event stream builder + var stream = new ResponseEventStream(context, request); + + // 3. Emit lifecycle events + yield return stream.EmitCreated(); + yield return stream.EmitInProgress(); + + // 4. Convert input: history + current input → ChatMessage[] + var messages = new List(); + + // Load conversation history if available + var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); + if (history.Count > 0) + { + messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history)); + } + + // Load and convert current input items + var inputItems = await context.GetInputItemsAsync(cancellationToken).ConfigureAwait(false); + if (inputItems.Count > 0) + { + messages.AddRange(InputConverter.ConvertOutputItemsToMessages(inputItems)); + } + else + { + // Fall back to raw request input + messages.AddRange(InputConverter.ConvertInputToMessages(request)); + } + + // 5. Build chat options + var chatOptions = InputConverter.ConvertToChatOptions(request); + chatOptions.Instructions = request.Instructions; + var options = new ChatClientAgentRunOptions(chatOptions); + + // 6. Run the agent and convert output + // NOTE: C# forbids 'yield return' inside a try block that has a catch clause, + // and inside catch blocks. We use a flag to defer the yield to outside the try/catch. + bool emittedTerminal = false; + var enumerator = OutputConverter.ConvertUpdatesToEventsAsync( + agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken), + stream, + cancellationToken).GetAsyncEnumerator(cancellationToken); + try + { + while (true) + { + bool shutdownDetected = false; + ResponseStreamEvent? evt = null; + try + { + if (!await enumerator.MoveNextAsync().ConfigureAwait(false)) + { + break; + } + + evt = enumerator.Current; + } + catch (OperationCanceledException) when (context.IsShutdownRequested && !emittedTerminal) + { + shutdownDetected = true; + } + + if (shutdownDetected) + { + // Server is shutting down — emit incomplete so clients can resume + this._logger.LogInformation("Shutdown detected, emitting incomplete response."); + yield return stream.EmitIncomplete(); + yield break; + } + + // yield is in the outer try (finally-only) — allowed by C# + yield return evt!; + + if (evt is ResponseCompletedEvent or ResponseFailedEvent or ResponseIncompleteEvent) + { + emittedTerminal = true; + } + } + } + finally + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + } + + /// + /// Resolves an from the request. + /// Tries agent.name first, then falls back to metadata["entity_id"]. + /// If neither is present, attempts to resolve a default (non-keyed) . + /// + private AIAgent ResolveAgent(CreateResponse request) + { + var agentName = GetAgentName(request); + + if (!string.IsNullOrEmpty(agentName)) + { + var agent = this._serviceProvider.GetKeyedService(agentName); + if (agent is not null) + { + return agent; + } + + this._logger.LogWarning("Agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName); + } + + // Try non-keyed default + var defaultAgent = this._serviceProvider.GetService(); + if (defaultAgent is not null) + { + return defaultAgent; + } + + var errorMessage = string.IsNullOrEmpty(agentName) + ? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered." + : $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AIAgent."; + + throw new InvalidOperationException(errorMessage); + } + + private static string? GetAgentName(CreateResponse request) + { + // Try agent.name from AgentReference + var agentName = request.AgentReference?.Name; + + // Fall back to "model" field (OpenAI clients send the agent name as the model) + if (string.IsNullOrEmpty(agentName)) + { + agentName = request.Model; + } + + // Fall back to metadata["entity_id"] + if (string.IsNullOrEmpty(agentName) && request.Metadata?.AdditionalProperties is not null) + { + request.Metadata.AdditionalProperties.TryGetValue("entity_id", out agentName); + } + + return agentName; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/InputConverter.cs new file mode 100644 index 0000000000..a35c8cd5b8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/InputConverter.cs @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses; + +/// +/// Converts Responses Server SDK input types to agent-framework types. +/// +internal static class InputConverter +{ + /// + /// Converts the SDK request input items into a list of . + /// + /// The create response request from the SDK. + /// A list of chat messages representing the request input. + public static List ConvertInputToMessages(CreateResponse request) + { + var messages = new List(); + + foreach (var item in request.GetInputExpanded()) + { + var message = ConvertInputItemToMessage(item); + if (message is not null) + { + messages.Add(message); + } + } + + return messages; + } + + /// + /// Converts resolved SDK history/input items into instances. + /// + /// The resolved output items from the SDK context. + /// A list of chat messages. + public static List ConvertOutputItemsToMessages(IReadOnlyList items) + { + var messages = new List(); + + foreach (var item in items) + { + var message = ConvertOutputItemToMessage(item); + if (message is not null) + { + messages.Add(message); + } + } + + return messages; + } + + /// + /// Creates from the SDK request properties. + /// + /// The create response request. + /// A configured instance. + public static ChatOptions ConvertToChatOptions(CreateResponse request) + { + return new ChatOptions + { + Temperature = (float?)request.Temperature, + TopP = (float?)request.TopP, + MaxOutputTokens = (int?)request.MaxOutputTokens, + ModelId = request.Model, + }; + } + + private static ChatMessage? ConvertInputItemToMessage(Item item) + { + return item switch + { + ItemMessage msg => ConvertItemMessage(msg), + FunctionCallOutputItemParam funcOutput => ConvertFunctionCallOutput(funcOutput), + ItemFunctionToolCall funcCall => ConvertItemFunctionToolCall(funcCall), + ItemReferenceParam => null, + _ => null + }; + } + + private static ChatMessage ConvertItemMessage(ItemMessage msg) + { + var role = ConvertMessageRole(msg.Role); + var contents = new List(); + + foreach (var content in msg.GetContentExpanded()) + { + switch (content) + { + case MessageContentInputTextContent textContent: + contents.Add(new MeaiTextContent(textContent.Text)); + break; + case MessageContentInputImageContent imageContent: + if (imageContent.ImageUrl is not null) + { + var url = imageContent.ImageUrl.ToString(); + if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + contents.Add(new DataContent(url, "image/*")); + } + else + { + contents.Add(new UriContent(imageContent.ImageUrl, "image/*")); + } + } + else if (!string.IsNullOrEmpty(imageContent.FileId)) + { + contents.Add(new HostedFileContent(imageContent.FileId)); + } + + break; + case MessageContentInputFileContent fileContent: + if (fileContent.FileUrl is not null) + { + contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream")); + } + else if (!string.IsNullOrEmpty(fileContent.FileData)) + { + contents.Add(new DataContent(fileContent.FileData, "application/octet-stream")); + } + else if (!string.IsNullOrEmpty(fileContent.FileId)) + { + contents.Add(new HostedFileContent(fileContent.FileId)); + } + else if (!string.IsNullOrEmpty(fileContent.Filename)) + { + contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]")); + } + + break; + } + } + + if (contents.Count == 0) + { + contents.Add(new MeaiTextContent(string.Empty)); + } + + return new ChatMessage(role, contents); + } + + private static ChatMessage ConvertFunctionCallOutput(FunctionCallOutputItemParam funcOutput) + { + var output = funcOutput.Output?.ToString() ?? string.Empty; + return new ChatMessage( + ChatRole.Tool, + [new FunctionResultContent(funcOutput.CallId, output)]); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK input.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK input.")] + private static ChatMessage ConvertItemFunctionToolCall(ItemFunctionToolCall funcCall) + { + IDictionary? arguments = null; + if (funcCall.Arguments is not null) + { + try + { + arguments = JsonSerializer.Deserialize>(funcCall.Arguments); + } + catch (JsonException) + { + arguments = new Dictionary { ["_raw"] = funcCall.Arguments }; + } + } + + return new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]); + } + + private static ChatMessage? ConvertOutputItemToMessage(OutputItem item) + { + return item switch + { + OutputItemMessage msg => ConvertOutputItemMessageToChat(msg), + OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall), + FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput), + OutputItemReasoningItem => null, + _ => null + }; + } + + private static ChatMessage ConvertOutputItemMessageToChat(OutputItemMessage msg) + { + var role = ConvertMessageRole(msg.Role); + var contents = new List(); + + foreach (var content in msg.Content) + { + switch (content) + { + case MessageContentInputTextContent textContent: + contents.Add(new MeaiTextContent(textContent.Text)); + break; + case MessageContentOutputTextContent textContent: + contents.Add(new MeaiTextContent(textContent.Text)); + break; + case MessageContentRefusalContent refusal: + contents.Add(new MeaiTextContent($"[Refusal: {refusal.Refusal}]")); + break; + case MessageContentInputImageContent imageContent: + if (imageContent.ImageUrl is not null) + { + var url = imageContent.ImageUrl.ToString(); + if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + contents.Add(new DataContent(url, "image/*")); + } + else + { + contents.Add(new UriContent(imageContent.ImageUrl, "image/*")); + } + } + else if (!string.IsNullOrEmpty(imageContent.FileId)) + { + contents.Add(new HostedFileContent(imageContent.FileId)); + } + + break; + case MessageContentInputFileContent fileContent: + if (fileContent.FileUrl is not null) + { + contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream")); + } + else if (!string.IsNullOrEmpty(fileContent.FileData)) + { + contents.Add(new DataContent(fileContent.FileData, "application/octet-stream")); + } + else if (!string.IsNullOrEmpty(fileContent.FileId)) + { + contents.Add(new HostedFileContent(fileContent.FileId)); + } + else if (!string.IsNullOrEmpty(fileContent.Filename)) + { + contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]")); + } + + break; + } + } + + if (contents.Count == 0) + { + contents.Add(new MeaiTextContent(string.Empty)); + } + + return new ChatMessage(role, contents); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK output history.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK output history.")] + private static ChatMessage ConvertOutputItemFunctionCall(OutputItemFunctionToolCall funcCall) + { + IDictionary? arguments = null; + if (funcCall.Arguments is not null) + { + try + { + arguments = JsonSerializer.Deserialize>(funcCall.Arguments); + } + catch (JsonException) + { + arguments = new Dictionary { ["_raw"] = funcCall.Arguments }; + } + } + + return new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]); + } + + private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput) + { + return new ChatMessage( + ChatRole.Tool, + [new FunctionResultContent(funcOutput.CallId, funcOutput.Output)]); + } + + private static ChatRole ConvertMessageRole(MessageRole role) + { + return role switch + { + MessageRole.User => ChatRole.User, + MessageRole.Assistant => ChatRole.Assistant, + MessageRole.System => ChatRole.System, + MessageRole.Developer => new ChatRole("developer"), + _ => ChatRole.User + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj new file mode 100644 index 0000000000..b881e287cc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj @@ -0,0 +1,40 @@ + + + + $(TargetFrameworksCore) + enable + Microsoft.Agents.AI.Hosting.AzureAIResponses + alpha + $(NoWarn);MEAI001;NU1903 + false + + + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs new file mode 100644 index 0000000000..c620cf6324 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses; + +/// +/// Converts agent-framework streams into +/// Responses Server SDK sequences using the +/// builder pattern. +/// +internal static class OutputConverter +{ + /// + /// Converts a stream of into a stream of + /// using the SDK builder pattern. + /// + /// The agent response updates to convert. + /// The SDK event stream builder. + /// Cancellation token. + /// An async enumerable of SDK response stream events (excluding lifecycle events). + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call arguments dictionary.")] + public static async IAsyncEnumerable ConvertUpdatesToEventsAsync( + IAsyncEnumerable updates, + ResponseEventStream stream, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ResponseUsage? accumulatedUsage = null; + OutputItemMessageBuilder? currentMessageBuilder = null; + TextContentBuilder? currentTextBuilder = null; + StringBuilder? accumulatedText = null; + string? previousMessageId = null; + bool hasTerminalEvent = false; + var executorItemIds = new Dictionary(); + + await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Handle workflow events from RawRepresentation + if (update.RawRepresentation is WorkflowEvent workflowEvent) + { + // Close any open message builder before emitting workflow items + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + previousMessageId = null; + + foreach (var evt in EmitWorkflowEvent(stream, workflowEvent, executorItemIds)) + { + yield return evt; + } + + continue; + } + + foreach (var content in update.Contents) + { + switch (content) + { + case MeaiTextContent textContent: + { + if (!IsSameMessage(update.MessageId, previousMessageId) && currentMessageBuilder is not null) + { + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + } + + previousMessageId = update.MessageId; + + if (currentMessageBuilder is null) + { + currentMessageBuilder = stream.AddOutputItemMessage(); + yield return currentMessageBuilder.EmitAdded(); + + currentTextBuilder = currentMessageBuilder.AddTextContent(); + yield return currentTextBuilder.EmitAdded(); + + accumulatedText = new StringBuilder(); + } + + if (textContent.Text is { Length: > 0 }) + { + accumulatedText!.Append(textContent.Text); + yield return currentTextBuilder!.EmitDelta(textContent.Text); + } + + break; + } + + case FunctionCallContent funcCall: + { + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + previousMessageId = null; + + var callId = funcCall.CallId ?? Guid.NewGuid().ToString("N"); + var funcBuilder = stream.AddOutputItemFunctionCall(funcCall.Name, callId); + yield return funcBuilder.EmitAdded(); + + var arguments = funcCall.Arguments is not null + ? JsonSerializer.Serialize(funcCall.Arguments) + : "{}"; + + yield return funcBuilder.EmitArgumentsDelta(arguments); + yield return funcBuilder.EmitArgumentsDone(arguments); + yield return funcBuilder.EmitDone(); + break; + } + + case TextReasoningContent reasoningContent: + { + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + previousMessageId = null; + + var reasoningBuilder = stream.AddOutputItemReasoningItem(); + yield return reasoningBuilder.EmitAdded(); + + var summaryPart = reasoningBuilder.AddSummaryPart(); + yield return summaryPart.EmitAdded(); + + var text = reasoningContent.Text ?? string.Empty; + yield return summaryPart.EmitTextDelta(text); + yield return summaryPart.EmitTextDone(text); + yield return summaryPart.EmitDone(); + reasoningBuilder.EmitSummaryPartDone(summaryPart); + + yield return reasoningBuilder.EmitDone(); + break; + } + + case UsageContent usageContent when usageContent.Details is not null: + { + accumulatedUsage = ConvertUsage(usageContent.Details, accumulatedUsage); + break; + } + + case ErrorContent errorContent: + { + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + previousMessageId = null; + hasTerminalEvent = true; + + yield return stream.EmitFailed( + ResponseErrorCode.ServerError, + errorContent.Message ?? "An error occurred during agent execution.", + accumulatedUsage); + yield break; + } + + case DataContent: + case UriContent: + // Image/audio/file content from agents is not currently supported + // as streaming output items in the Responses Server SDK builder pattern. + // These would need to be serialized as base64 or URL references. + break; + + case FunctionResultContent: + // Function results are internal to the agent's tool-calling loop + // and are not emitted as output items in the response stream. + break; + + default: + break; + } + } + } + + // Close any remaining open message + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + if (!hasTerminalEvent) + { + yield return stream.EmitCompleted(accumulatedUsage); + } + } + + private static IEnumerable CloseCurrentMessage( + OutputItemMessageBuilder? messageBuilder, + TextContentBuilder? textBuilder, + StringBuilder? accumulatedText) + { + if (messageBuilder is null) + { + yield break; + } + + if (textBuilder is not null) + { + var finalText = accumulatedText?.ToString() ?? string.Empty; + yield return textBuilder.EmitDone(finalText); + yield return messageBuilder.EmitContentDone(textBuilder); + } + + yield return messageBuilder.EmitDone(); + } + + private static bool IsSameMessage(string? currentId, string? previousId) => + currentId is not { Length: > 0 } || previousId is not { Length: > 0 } || currentId == previousId; + + private static ResponseUsage ConvertUsage(UsageDetails details, ResponseUsage? existing) + { + var inputTokens = (long)(details.InputTokenCount ?? 0); + var outputTokens = (long)(details.OutputTokenCount ?? 0); + var totalTokens = (long)(details.TotalTokenCount ?? 0); + + if (existing is not null) + { + inputTokens += existing.InputTokens; + outputTokens += existing.OutputTokens; + totalTokens += existing.TotalTokens; + } + + return AzureAIAgentServerResponsesModelFactory.ResponseUsage( + inputTokens: inputTokens, + outputTokens: outputTokens, + totalTokens: totalTokens); + } + + private static IEnumerable EmitWorkflowEvent( + ResponseEventStream stream, + WorkflowEvent workflowEvent, + Dictionary executorItemIds) + { + switch (workflowEvent) + { + case ExecutorInvokedEvent invokedEvent: + { + var itemId = GenerateItemId("wfa"); + executorItemIds[invokedEvent.ExecutorId] = itemId; + + var item = new WorkflowActionOutputItem( + kind: "InvokeExecutor", + actionId: invokedEvent.ExecutorId, + status: WorkflowActionOutputItemStatus.InProgress, + id: itemId); + + var builder = stream.AddOutputItem(itemId); + yield return builder.EmitAdded(item); + yield return builder.EmitDone(item); + break; + } + + case ExecutorCompletedEvent completedEvent: + { + var itemId = GenerateItemId("wfa"); + + var item = new WorkflowActionOutputItem( + kind: "InvokeExecutor", + actionId: completedEvent.ExecutorId, + status: WorkflowActionOutputItemStatus.Completed, + id: itemId); + + var builder = stream.AddOutputItem(itemId); + yield return builder.EmitAdded(item); + yield return builder.EmitDone(item); + executorItemIds.Remove(completedEvent.ExecutorId); + break; + } + + case ExecutorFailedEvent failedEvent: + { + var itemId = GenerateItemId("wfa"); + + var item = new WorkflowActionOutputItem( + kind: "InvokeExecutor", + actionId: failedEvent.ExecutorId, + status: WorkflowActionOutputItemStatus.Failed, + id: itemId); + + var builder = stream.AddOutputItem(itemId); + yield return builder.EmitAdded(item); + yield return builder.EmitDone(item); + executorItemIds.Remove(failedEvent.ExecutorId); + break; + } + + // Informational/lifecycle events — no SDK output needed. + // Note: AgentResponseUpdateEvent and WorkflowErrorEvent are unwrapped by + // WorkflowSession.InvokeStageAsync() into regular AgentResponseUpdate objects + // with populated Contents (TextContent, ErrorContent, etc.), so they flow + // through the normal content processing path above — not through this method. + case SuperStepStartedEvent: + case SuperStepCompletedEvent: + case WorkflowStartedEvent: + case WorkflowWarningEvent: + case RequestInfoEvent: + break; + } + } + + /// + /// Generates a valid item ID matching the SDK's {prefix}_{50chars} format. + /// + private static string GenerateItemId(string prefix) + { + // SDK format: {prefix}_{50 char body} + var bytes = RandomNumberGenerator.GetBytes(25); + var body = Convert.ToHexString(bytes); // 50 hex chars, uppercase + return $"{prefix}_{body}"; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..d596ba3457 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.AgentServer.Responses; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses; + +/// +/// Extension methods for to register the agent-framework +/// response handler with the Azure AI Responses Server SDK. +/// +public static class AgentFrameworkResponsesServiceCollectionExtensions +{ + /// + /// Registers as the + /// for the Azure AI Responses Server SDK. Agents are resolved from keyed DI services + /// using the agent.name or metadata["entity_id"] from incoming requests. + /// + /// + /// + /// Call this method after AddResponsesServer() and after registering your + /// instances (e.g., via AddAIAgent()). + /// + /// + /// Example: + /// + /// builder.Services.AddResponsesServer(); + /// builder.AddAIAgent("my-agent", ...); + /// builder.Services.AddAgentFrameworkHandler(); + /// + /// var app = builder.Build(); + /// app.MapResponsesServer(); + /// + /// + /// + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddAgentFrameworkHandler(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddSingleton(); + return services; + } + + /// + /// Registers a specific as the handler for all incoming requests, + /// regardless of the agent.name in the request. + /// + /// + /// + /// Use this overload when hosting a single agent. The provided agent instance is + /// registered both as a keyed service and as the default . + /// + /// + /// Example: + /// + /// builder.Services.AddResponsesServer(); + /// builder.Services.AddAgentFrameworkHandler(myAgent); + /// + /// var app = builder.Build(); + /// app.MapResponsesServer(); + /// + /// + /// + /// The service collection. + /// The agent instance to register. + /// The service collection for chaining. + public static IServiceCollection AddAgentFrameworkHandler(this IServiceCollection services, AIAgent agent) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(agent); + + services.TryAddSingleton(agent); + services.TryAddSingleton(); + return services; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs new file mode 100644 index 0000000000..ee32771a67 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -0,0 +1,815 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; + +public class AgentFrameworkResponseHandlerTests +{ + private static string ValidResponseId => "resp_" + new string('0', 46); + + [Fact] + public async Task CreateAsync_WithDefaultAgent_ProducesStreamEvents() + { + // Arrange + var agent = CreateTestAgent("Hello from the agent!"); + var services = new ServiceCollection(); + services.AddSingleton(agent); + services.AddSingleton>(NullLogger.Instance); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}"); + Assert.IsType(events[0]); + Assert.IsType(events[1]); + } + + [Fact] + public async Task CreateAsync_WithKeyedAgent_ResolvesCorrectAgent() + { + // Arrange + var agent = CreateTestAgent("Keyed agent response"); + var services = new ServiceCollection(); + services.AddKeyedSingleton("my-agent", agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("my-agent")); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert - should have produced events from the keyed agent + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_NoAgentRegistered_ThrowsInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + } + }); + } + + [Fact] + public void Constructor_NullServiceProvider_ThrowsArgumentNullException() + { + Assert.Throws( + () => new AgentFrameworkResponseHandler(null!, NullLogger.Instance)); + } + + [Fact] + public void Constructor_NullLogger_ThrowsArgumentNullException() + { + var sp = new ServiceCollection().BuildServiceProvider(); + Assert.Throws( + () => new AgentFrameworkResponseHandler(sp, null!)); + } + + [Fact] + public async Task CreateAsync_ResolvesAgentByModelField() + { + // Arrange + var agent = CreateTestAgent("model agent"); + var services = new ServiceCollection(); + services.AddKeyedSingleton("my-agent", agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-agent"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_ResolvesAgentByEntityIdMetadata() + { + // Arrange + var agent = CreateTestAgent("entity agent"); + var services = new ServiceCollection(); + services.AddKeyedSingleton("entity-agent", agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: ""); + var metadata = new Metadata(); + metadata.AdditionalProperties["entity_id"] = "entity-agent"; + request.Metadata = metadata; + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_NamedAgentNotFound_FallsBackToDefault() + { + // Arrange + var agent = CreateTestAgent("default agent"); + var services = new ServiceCollection(); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("nonexistent-agent")); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_NoAgentFound_ErrorMessageIncludesAgentName() + { + // Arrange + var services = new ServiceCollection(); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("missing-agent")); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act & Assert + var ex = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + } + }); + + Assert.Contains("missing-agent", ex.Message); + } + + [Fact] + public async Task CreateAsync_NoAgentNoName_ErrorMessageIsGeneric() + { + // Arrange + var services = new ServiceCollection(); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: ""); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act & Assert + var ex = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + } + }); + + Assert.Contains("No agent name specified", ex.Message); + } + + [Fact] + public async Task CreateAsync_AgentResolvedBeforeEmitCreated_ExceptionHasNoEvents() + { + // Arrange + var services = new ServiceCollection(); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + bool threw = false; + try + { + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + } + catch (InvalidOperationException) + { + threw = true; + } + + // Assert + Assert.True(threw); + Assert.Empty(events); + } + + [Fact] + public async Task CreateAsync_WithHistory_PrependsHistoryToMessages() + { + // Arrange + var agent = new CapturingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var historyItem = new OutputItemMessage( + id: "hist_1", + role: MessageRole.Assistant, + content: [new MessageContentOutputTextContent( + "Previous response", + Array.Empty(), + Array.Empty())], + status: MessageStatus.Completed); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(new OutputItem[] { historyItem }); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.NotNull(agent.CapturedMessages); + var messages = agent.CapturedMessages.ToList(); + Assert.True(messages.Count >= 2); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + } + + [Fact] + public async Task CreateAsync_WithInputItems_UsesResolvedInputItems() + { + // Arrange + var agent = new CapturingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Raw input" } } } + }); + + var inputItem = new OutputItemMessage( + id: "input_1", + role: MessageRole.Assistant, + content: [new MessageContentOutputTextContent( + "Resolved input", + Array.Empty(), + Array.Empty())], + status: MessageStatus.Completed); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(new OutputItem[] { inputItem }); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.NotNull(agent.CapturedMessages); + var messages = agent.CapturedMessages.ToList(); + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + } + + [Fact] + public async Task CreateAsync_NoInputItems_FallsBackToRawRequestInput() + { + // Arrange + var agent = new CapturingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Raw input" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.NotNull(agent.CapturedMessages); + var messages = agent.CapturedMessages.ToList(); + Assert.Single(messages); + Assert.Equal(ChatRole.User, messages[0].Role); + } + + [Fact] + public async Task CreateAsync_PassesInstructionsToAgent() + { + // Arrange + var agent = new CapturingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + instructions: "You are a helpful assistant."); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.NotNull(agent.CapturedOptions); + var chatClientOptions = Assert.IsType(agent.CapturedOptions); + Assert.Equal("You are a helpful assistant.", chatClientOptions.ChatOptions?.Instructions); + } + + [Fact] + public async Task CreateAsync_AgentThrows_ExceptionPropagates() + { + // Arrange + var agent = new ThrowingAgent(new InvalidOperationException("Agent crashed")); + var services = new ServiceCollection(); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + } + }); + } + + [Fact] + public async Task CreateAsync_MultipleKeyedAgents_ResolvesCorrectOne() + { + // Arrange + var agent1 = CreateTestAgent("Agent 1 response"); + var agent2 = CreateTestAgent("Agent 2 response"); + var services = new ServiceCollection(); + services.AddKeyedSingleton("agent-1", agent1); + services.AddKeyedSingleton("agent-2", agent2); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("agent-2")); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_CancellationDuringExecution_PropagatesOperationCanceledException() + { + // Arrange + var agent = new CancellationCheckingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, cts.Token)) + { + } + }); + } + + private static TestAgent CreateTestAgent(string responseText) + { + return new TestAgent(responseText); + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync(params AgentResponseUpdate[] items) + { + foreach (var item in items) + { + yield return item; + } + + await Task.CompletedTask; + } + + private sealed class TestAgent(string responseText) : AIAgent + { + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + ToAsyncEnumerableAsync(new AgentResponseUpdate + { + MessageId = "resp_msg_1", + Contents = [new MeaiTextContent(responseText)] + }); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } + + private sealed class ThrowingAgent(Exception exception) : AIAgent + { + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw exception; + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } + + private sealed class CapturingAgent : AIAgent + { + public IEnumerable? CapturedMessages { get; private set; } + public AgentRunOptions? CapturedOptions { get; private set; } + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) + { + CapturedMessages = messages.ToList(); + CapturedOptions = options; + return ToAsyncEnumerableAsync(new AgentResponseUpdate + { + MessageId = "resp_msg_1", + Contents = [new MeaiTextContent("captured")] + }); + } + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } + + private sealed class CancellationCheckingAgent : AIAgent + { + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new AgentResponseUpdate { Contents = [new MeaiTextContent("test")] }; + await Task.CompletedTask; + } + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs new file mode 100644 index 0000000000..34555798a7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs @@ -0,0 +1,671 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; + +public class InputConverterTests +{ + [Fact] + public void ConvertInputToMessages_EmptyRequest_ReturnsEmptyList() + { + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(Array.Empty()); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Empty(messages); + } + + [Fact] + public void ConvertInputToMessages_UserTextMessage_ReturnsUserMessage() + { + var input = new[] + { + new + { + type = "message", + id = "msg_001", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "Hello, agent!" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(ChatRole.User, messages[0].Role); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text == "Hello, agent!"); + } + + [Fact] + public void ConvertInputToMessages_FunctionCallOutput_ReturnsToolMessage() + { + var input = new[] + { + new + { + type = "function_call_output", + id = "fc_out_001", + call_id = "call_123", + output = "42" + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(ChatRole.Tool, messages[0].Role); + var funcResult = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(funcResult); + Assert.Equal("call_123", funcResult.CallId); + } + + [Fact] + public void ConvertInputToMessages_FunctionToolCall_ReturnsAssistantMessage() + { + var input = new[] + { + new + { + type = "function_call", + id = "fc_001", + call_id = "call_456", + name = "get_weather", + arguments = "{\"location\": \"Seattle\"}" + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + var funcCall = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(funcCall); + Assert.Equal("call_456", funcCall.CallId); + Assert.Equal("get_weather", funcCall.Name); + } + + [Fact] + public void ConvertInputToMessages_MultipleItems_ReturnsAllMessages() + { + var input = new object[] + { + new + { + type = "message", + id = "msg_001", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "What's the weather?" } } + }, + new + { + type = "function_call", + id = "fc_001", + call_id = "call_789", + name = "get_weather", + arguments = "{}" + }, + new + { + type = "function_call_output", + id = "fc_out_001", + call_id = "call_789", + output = "Sunny, 72°F" + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Equal(3, messages.Count); + Assert.Equal(ChatRole.User, messages[0].Role); + Assert.Equal(ChatRole.Assistant, messages[1].Role); + Assert.Equal(ChatRole.Tool, messages[2].Role); + } + + [Fact] + public void ConvertToChatOptions_SetsTemperatureAndTopP() + { + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + temperature: 0.7, + topP: 0.9, + maxOutputTokens: 1000, + model: "gpt-4o"); + + var options = InputConverter.ConvertToChatOptions(request); + + Assert.Equal(0.7f, options.Temperature); + Assert.Equal(0.9f, options.TopP); + Assert.Equal(1000, options.MaxOutputTokens); + Assert.Equal("gpt-4o", options.ModelId); + } + + [Fact] + public void ConvertToChatOptions_NullValues_SetsNulls() + { + var request = new CreateResponse(); + + var options = InputConverter.ConvertToChatOptions(request); + + Assert.Null(options.Temperature); + Assert.Null(options.TopP); + Assert.Null(options.MaxOutputTokens); + } + + [Fact] + public void ConvertOutputItemsToMessages_OutputMessage_ReturnsAssistantMessage() + { + var textContent = new MessageContentOutputTextContent( + "Hello from assistant", + Array.Empty(), + Array.Empty()); + var outputMsg = new OutputItemMessage( + id: "out_001", + role: MessageRole.Assistant, + content: [textContent], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text == "Hello from assistant"); + } + + [Fact] + public void ConvertOutputItemsToMessages_FunctionToolCall_ReturnsAssistantMessage() + { + var funcCall = new OutputItemFunctionToolCall( + callId: "call_abc", + name: "search", + arguments: "{\"query\": \"test\"}"); + + var messages = InputConverter.ConvertOutputItemsToMessages([funcCall]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + var content = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(content); + Assert.Equal("call_abc", content.CallId); + Assert.Equal("search", content.Name); + } + + [Fact] + public void ConvertOutputItemsToMessages_FunctionToolCallOutputResource_ReturnsToolMessage() + { + var funcOutput = new FunctionToolCallOutputResource( + callId: "call_def", + output: BinaryData.FromString("result data")); + + var messages = InputConverter.ConvertOutputItemsToMessages([funcOutput]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Tool, messages[0].Role); + var result = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(result); + Assert.Equal("call_def", result.CallId); + } + + [Fact] + public void ConvertOutputItemsToMessages_ReasoningItem_ReturnsNull() + { + var reasoning = AzureAIAgentServerResponsesModelFactory.OutputItemReasoningItem( + id: "reason_001"); + + var messages = InputConverter.ConvertOutputItemsToMessages([reasoning]); + + Assert.Empty(messages); + } + + // ── Image Content Tests (B-03 through B-06) ── + + [Fact] + public void ConvertInputToMessages_ImageContentWithHttpUrl_ReturnsUriContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_image", image_url = "https://example.com/img.png" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is UriContent); + } + + [Fact] + public void ConvertInputToMessages_ImageContentWithDataUri_ReturnsDataContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_image", image_url = "data:image/png;base64,iVBORw0KGgo=" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is DataContent); + } + + [Fact] + public void ConvertInputToMessages_ImageContentWithFileId_ReturnsHostedFileContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_image", file_id = "file_abc123" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is HostedFileContent); + } + + [Fact] + public void ConvertInputToMessages_ImageContentNoUrlOrFileId_ProducesNoContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_image" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Single(messages[0].Contents); + } + + // ── File Content Tests (B-07 through B-11) ── + + [Fact] + public void ConvertInputToMessages_FileContentWithUrl_ReturnsUriContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file", file_url = "https://example.com/doc.pdf" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is UriContent); + } + + [Fact] + public void ConvertInputToMessages_FileContentWithInlineData_ReturnsDataContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file", file_data = "data:application/pdf;base64,iVBORw0KGgo=" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is DataContent); + } + + [Fact] + public void ConvertInputToMessages_FileContentWithFileId_ReturnsHostedFileContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file", file_id = "file_xyz789" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is HostedFileContent); + } + + [Fact] + public void ConvertInputToMessages_FileContentWithFilenameOnly_ReturnsFallbackText() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file", filename = "report.pdf" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("report.pdf")); + } + + [Fact] + public void ConvertInputToMessages_FileContentWithNothing_ProducesNoContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Single(messages[0].Contents); + } + + // ── Mixed Content / Edge Cases (B-15 through B-18) ── + + [Fact] + public void ConvertInputToMessages_MixedContentInSingleMessage_ReturnsAllContentTypes() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new object[] + { + new { type = "input_text", text = "Look at this:" }, + new { type = "input_image", image_url = "https://example.com/img.png" } + } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(2, messages[0].Contents.Count); + } + + [Fact] + public void ConvertInputToMessages_EmptyMessageContent_ReturnsFallbackTextContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = Array.Empty() + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + var textContent = Assert.IsType(Assert.Single(messages[0].Contents)); + Assert.Equal(string.Empty, textContent.Text); + } + + [Fact] + public void ConvertOutputItemsToMessages_OutputMessageRefusal_ReturnsRefusalText() + { + var refusal = new MessageContentRefusalContent("I cannot help with that"); + var outputMsg = new OutputItemMessage( + id: "out_1", + role: MessageRole.Assistant, + content: [refusal], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("[Refusal:")); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("I cannot help with that")); + } + + [Fact] + public void ConvertInputToMessages_ItemReferenceParam_IsSkipped() + { + var input = new object[] + { + new { type = "item_reference", id = "ref_001" }, + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + } + + // ── Role Mapping Tests (C-01 through C-05) ── + + [Fact] + public void ConvertInputToMessages_UserRole_ReturnsChatRoleUser() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "Hi" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(ChatRole.User, messages[0].Role); + } + + [Fact] + public void ConvertOutputItemsToMessages_AssistantRole_ReturnsChatRoleAssistant() + { + // OutputItemMessage always maps to assistant role + var textContent = new MessageContentOutputTextContent( + "Hi", Array.Empty(), Array.Empty()); + var outputMsg = new OutputItemMessage( + id: "msg_1", + role: MessageRole.Assistant, + content: [textContent], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + } + + // ── History Conversion Edge Cases (D-02 through D-12) ── + + [Fact] + public void ConvertOutputItemsToMessages_OutputMessageWithRefusal_ReturnsRefusalText() + { + var refusal = new MessageContentRefusalContent("Not allowed"); + var outputMsg = new OutputItemMessage( + id: "out_1", + role: MessageRole.Assistant, + content: [refusal], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("[Refusal:")); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("Not allowed")); + } + + [Fact] + public void ConvertOutputItemsToMessages_OutputMessageWithEmptyContent_ReturnsFallbackText() + { + var outputMsg = new OutputItemMessage( + id: "out_1", + role: MessageRole.Assistant, + content: [], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + var textContent = Assert.IsType(Assert.Single(messages[0].Contents)); + Assert.Equal(string.Empty, textContent.Text); + } + + [Fact] + public void ConvertOutputItemsToMessages_FunctionToolCallWithMalformedArgs_UsesRawFallback() + { + var funcCall = new OutputItemFunctionToolCall( + callId: "call_1", + name: "test", + arguments: "not-json{{{"); + + var messages = InputConverter.ConvertOutputItemsToMessages([funcCall]); + + Assert.Single(messages); + var content = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(content); + Assert.NotNull(content.Arguments); + Assert.True(content.Arguments.ContainsKey("_raw")); + } + + [Fact] + public void ConvertOutputItemsToMessages_UnknownOutputItemType_IsSkipped() + { + var messages = InputConverter.ConvertOutputItemsToMessages([]); + + Assert.Empty(messages); + } + + [Fact] + public void ConvertToChatOptions_ModelId_SetFromRequest() + { + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-model"); + + var options = InputConverter.ConvertToChatOptions(request); + + Assert.Equal("my-model", options.ModelId); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests.csproj new file mode 100644 index 0000000000..09c3ba24c1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests.csproj @@ -0,0 +1,17 @@ + + + + $(TargetFrameworksCore) + false + $(NoWarn);NU1903;NU1605 + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs new file mode 100644 index 0000000000..dde11298c7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs @@ -0,0 +1,1080 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using Microsoft.Agents.AI.Workflows; +using Moq; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; + +public class OutputConverterTests +{ + private static (ResponseEventStream stream, Mock mockContext) CreateTestStream() + { + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model"); + var stream = new ResponseEventStream(mockContext.Object, request); + return (stream, mockContext); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_EmptyStream_EmitsCompleted() + { + var (stream, _) = CreateTestStream(); + var updates = ToAsync(Array.Empty()); + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream)) + { + events.Add(evt); + } + + Assert.Single(events); + Assert.IsType(events[0]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_SingleTextUpdate_EmitsMessageAndCompleted() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("Hello, world!")] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + // Expected: MessageAdded, TextAdded, TextDelta, TextDone, ContentDone, MessageDone, Completed + Assert.True(events.Count >= 5, $"Expected at least 5 events, got {events.Count}"); + Assert.IsType(events[0]); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultipleTextUpdates_EmitsStreamingDeltas() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Hello, ")] }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("world!")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Should have two text delta events among the others + Assert.True(events.Count >= 6, $"Expected at least 6 events, got {events.Count}"); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCall_EmitsFunctionCallEvents() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_1", "get_weather", + new Dictionary { ["city"] = "Seattle" })] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + // Should have: FuncAdded, ArgsDelta, ArgsDone, FuncDone, Completed + Assert.IsType(events[0]); + Assert.IsType(events[^1]); + Assert.True(events.Count >= 4, $"Expected at least 4 events for function call, got {events.Count}"); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorContent_EmitsFailed() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new ErrorContent("Something went wrong")] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.IsType(events[^1]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorContent_DoesNotEmitCompleted() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new ErrorContent("Failure")] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.DoesNotContain(events, e => e is ResponseCompletedEvent); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_UsageContent_IncludesUsageInCompleted() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("Hi")] + }, + new AgentResponseUpdate + { + Contents = [new UsageContent(new UsageDetails + { + InputTokenCount = 10, + OutputTokenCount = 5, + TotalTokenCount = 15 + })] + } + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + var completedEvent = events.OfType().SingleOrDefault(); + Assert.NotNull(completedEvent); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_ReasoningContent_EmitsReasoningEvents() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new TextReasoningContent("Let me think about this...")] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + // Should have: ReasoningAdded, SummaryPartAdded, TextDelta, TextDone, SummaryDone, ReasoningDone, Completed + Assert.True(events.Count >= 5, $"Expected at least 5 events for reasoning, got {events.Count}"); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_CancellationRequested_Throws() + { + var (stream, _) = CreateTestStream(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var updates = ToAsync(new[] { new AgentResponseUpdate { Contents = [new MeaiTextContent("test")] } }); + + await Assert.ThrowsAnyAsync(async () => + { + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream, cts.Token)) + { + // Should throw before yielding + } + }); + } + + // F-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_EmptyTextContent_NoTextDeltaEmitted() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("")] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.DoesNotContain(events, e => e is ResponseTextDeltaEvent); + Assert.Contains(events, e => e is ResponseCompletedEvent); + } + + // F-04 + [Fact] + public async Task ConvertUpdatesToEventsAsync_NullTextContent_NoTextDeltaEmitted() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent((string)null!)] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.DoesNotContain(events, e => e is ResponseTextDeltaEvent); + Assert.Contains(events, e => e is ResponseCompletedEvent); + } + + // F-07 + [Fact] + public async Task ConvertUpdatesToEventsAsync_DifferentMessageIds_CreatesMultipleMessages() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("First")] }, + new AgentResponseUpdate { MessageId = "msg_2", Contents = [new MeaiTextContent("Second")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // F-08 + [Fact] + public async Task ConvertUpdatesToEventsAsync_NullMessageIds_TreatedAsSameMessage() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = null, Contents = [new MeaiTextContent("First")] }, + new AgentResponseUpdate { MessageId = null, Contents = [new MeaiTextContent("Second")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Single(events.OfType()); + } + + // G-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCallClosesOpenMessage() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("thinking...")] }, + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "search", new Dictionary { ["q"] = "test" })] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // G-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCallWithNullArguments_EmitsEmptyJson() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_1", "do_something", (IDictionary?)null)] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.IsType(events[^1]); + } + + // G-04 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCallWithEmptyCallId_GeneratesCallId() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new FunctionCallContent("", "do_something", new Dictionary { ["x"] = 1 })] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseOutputItemAddedEvent); + } + + // G-05 + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultipleFunctionCalls_EmitsSeparateBuilders() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "func_a", new Dictionary { ["a"] = 1 })] }, + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_2", "func_b", new Dictionary { ["b"] = 2 })] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // H-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ReasoningWithNullText_EmitsEmptyString() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new TextReasoningContent(null)] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.True(events.Count >= 5, $"Expected at least 5 events, got {events.Count}"); + Assert.IsType(events[^1]); + } + + // H-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ReasoningClosesOpenMessage() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial")] }, + new AgentResponseUpdate { Contents = [new TextReasoningContent("thinking")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // I-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorContentWithNullMessage_UsesDefaultMessage() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new ErrorContent(null!)] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseFailedEvent); + } + + // I-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorContentClosesOpenMessage() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial text")] }, + new AgentResponseUpdate { Contents = [new ErrorContent("Something broke")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.True(events.OfType().Any()); + Assert.IsType(events[^1]); + } + + // I-06 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorAfterPartialText_ClosesMessageThenFails() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial text")] }, + new AgentResponseUpdate { Contents = [new ErrorContent("Unexpected error")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.True(events.OfType().Any()); + Assert.IsType(events[^1]); + Assert.DoesNotContain(events, e => e is ResponseCompletedEvent); + } + + // J-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultipleUsageUpdates_AccumulatesTokens() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Hi")] }, + new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 5, TotalTokenCount = 15 })] }, + new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 20, OutputTokenCount = 10, TotalTokenCount = 30 })] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseCompletedEvent); + } + + // J-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_UsageWithZeroTokens_StillCompletes() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new UsageContent(new UsageDetails { InputTokenCount = 0, OutputTokenCount = 0, TotalTokenCount = 0 })] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseCompletedEvent); + } + + // K-01 + [Fact] + public async Task ConvertUpdatesToEventsAsync_DataContent_IsSkippedWithNoEvents() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new DataContent("data:image/png;base64,aWNv", "image/png")] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Single(events); + Assert.IsType(events[0]); + } + + // K-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_UriContent_IsSkippedWithNoEvents() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new UriContent("https://example.com/file.txt", "text/plain")] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Single(events); + Assert.IsType(events[0]); + } + + // K-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionResultContent_IsSkippedWithNoEvents() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "result data")] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Single(events); + Assert.IsType(events[0]); + } + + // L-01 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ExecutorInvokedEvent_EmitsWorkflowActionItem() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("executor_1", "invoked") }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseOutputItemAddedEvent); + Assert.Contains(events, e => e is ResponseOutputItemDoneEvent); + Assert.IsType(events[^1]); + } + + // L-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ExecutorCompletedEvent_EmitsCompletedWorkflowAction() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("executor_1", null) }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseOutputItemAddedEvent); + Assert.Contains(events, e => e is ResponseOutputItemDoneEvent); + Assert.IsType(events[^1]); + } + + // L-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ExecutorFailedEvent_EmitsFailedWorkflowAction() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { RawRepresentation = new ExecutorFailedEvent("executor_1", new InvalidOperationException("test error")) }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseOutputItemAddedEvent); + Assert.Contains(events, e => e is ResponseOutputItemDoneEvent); + Assert.IsType(events[^1]); + } + + // L-04 + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowEventClosesOpenMessage() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("exec_1", "invoked") }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // L-06 + [Fact] + public async Task ConvertUpdatesToEventsAsync_InterleavedWorkflowAndTextEvents() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("exec_1", "invoked") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Agent says hello")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("exec_1", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(3, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // M-01 + [Fact] + public async Task ConvertUpdatesToEventsAsync_TextThenFunctionCallThenText_ProducesCorrectSequence() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Let me check...")] }, + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "search", new Dictionary { ["q"] = "weather" })] }, + new AgentResponseUpdate { MessageId = "msg_2", Contents = [new MeaiTextContent("Here are the results")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(3, events.OfType().Count()); + } + + // M-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ReasoningThenText_ProducesCorrectSequence() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { Contents = [new TextReasoningContent("Thinking about the answer...")] }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("The answer is 42")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // M-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_TextThenError_EmitsMessageThenFailed() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Starting...")] }, + new AgentResponseUpdate { Contents = [new ErrorContent("Unexpected error")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.IsType(events[^1]); + Assert.DoesNotContain(events, e => e is ResponseCompletedEvent); + Assert.Single(events.OfType()); + } + + // M-04 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCallThenTextThenFunctionCall_ProducesThreeItems() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "func_a", new Dictionary { ["a"] = 1 })] }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Processing...")] }, + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_2", "func_b", new Dictionary { ["b"] = 2 })] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(3, events.OfType().Count()); + } + + // ===== Workflow content flow tests (W series) ===== + // These simulate the exact update patterns that WorkflowSession.InvokeStageAsync() produces + // when wrapping a Workflow as an AIAgent via AsAIAgent(). + + // W-01: Multi-executor text output — different MessageIds cause separate messages + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultiExecutorTextOutput_CreatesSeparateMessages() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + // Executor 1 invoked (RawRepresentation) + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + // Executor 1 produces text (unwrapped AgentResponseUpdateEvent) + new AgentResponseUpdate { MessageId = "msg_agent1", Contents = [new MeaiTextContent("Hello from agent 1")] }, + // Executor 1 completed + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + // Executor 2 invoked + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") }, + // Executor 2 produces text (different MessageId) + new AgentResponseUpdate { MessageId = "msg_agent2", Contents = [new MeaiTextContent("Hello from agent 2")] }, + // Executor 2 completed + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 2 workflow action items (invoked) + 1 text message + 2 workflow action items (completed) + 1 text message = 6 output items + Assert.Equal(6, events.OfType().Count()); + // 2 text deltas (one per agent) + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // W-02: Workflow error via ErrorContent (as produced by WorkflowSession for WorkflowErrorEvent) + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowErrorAsContent_EmitsFailed() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Starting work...")] }, + // WorkflowErrorEvent is converted to ErrorContent by WorkflowSession + new AgentResponseUpdate { Contents = [new ErrorContent("Workflow execution failed")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Should close the open message, then emit failed + Assert.True(events.OfType().Any()); + Assert.IsType(events[^1]); + Assert.DoesNotContain(events, e => e is ResponseCompletedEvent); + } + + // W-03: Function call from workflow executor (e.g. handoff agent calling transfer_to_agent) + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowFunctionCall_EmitsFunctionCallEvents() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("triage_agent", "start") }, + // Agent produces function call (handoff) + new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_handoff", "transfer_to_code_expert", + new Dictionary { ["reason"] = "User asked about code" })] + }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("triage_agent", null) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("code_expert", "start") }, + new AgentResponseUpdate { MessageId = "msg_expert", Contents = [new MeaiTextContent("Here's how async/await works...")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("code_expert", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Should have: 4 workflow actions + 1 function call + 1 text message = 6 output items + Assert.Equal(6, events.OfType().Count()); + Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent); + Assert.Contains(events, e => e is ResponseTextDeltaEvent); + Assert.IsType(events[^1]); + } + + // W-04: Informational events (superstep, workflow started) are silently skipped + [Fact] + public async Task ConvertUpdatesToEventsAsync_InformationalWorkflowEvents_AreSkipped() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("start") }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Result")] }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Only one output item (the text message), no workflow action items for informational events + Assert.Single(events.OfType()); + Assert.Contains(events, e => e is ResponseTextDeltaEvent); + Assert.IsType(events[^1]); + } + + // W-05: Warning events are silently skipped + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowWarningEvent_IsSkipped() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new WorkflowWarningEvent("Agent took too long") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Done")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Single(events.OfType()); + Assert.IsType(events[^1]); + } + + // W-06: Streaming text from multiple workflow turns (same executor, different message IDs) + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultiTurnSameExecutor_CreatesSeparateMessages() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_turn1", Contents = [new MeaiTextContent("First response")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + // Same executor invoked again (second superstep) + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_turn2", Contents = [new MeaiTextContent("Second response")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 4 workflow action items + 2 text messages = 6 output items + Assert.Equal(6, events.OfType().Count()); + Assert.Equal(2, events.OfType().Count()); + } + + // W-07: Executor failure mid-stream with partial text + [Fact] + public async Task ConvertUpdatesToEventsAsync_ExecutorFailureAfterPartialText_ClosesMessageAndEmitsFailure() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Starting to process...")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorFailedEvent("agent_1", new InvalidOperationException("Agent crashed")) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Text message should be closed before the failed workflow action item + Assert.True(events.OfType().Any()); + // Workflow action items: invoked + failed = 2, plus text message = 3 + Assert.Equal(3, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // W-08: Full handoff pattern — triage → function call → target agent text + [Fact] + public async Task ConvertUpdatesToEventsAsync_FullHandoffPattern_ProducesCorrectEventSequence() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + // Workflow lifecycle + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("triage", "start") }, + // Triage agent decides to hand off + new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_1", "transfer_to_expert", + new Dictionary { ["reason"] = "technical question" })] + }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("triage", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + // Next superstep + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("expert", "start") }, + // Expert agent responds with text + new AgentResponseUpdate { MessageId = "msg_expert_1", Contents = [new MeaiTextContent("Let me explain...")] }, + new AgentResponseUpdate { MessageId = "msg_expert_1", Contents = [new MeaiTextContent(" Here's how it works.")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("expert", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Workflow actions: invoked triage, completed triage, invoked expert, completed expert = 4 + // Content items: 1 function call, 1 text message = 2 + // Total output items: 6 + Assert.Equal(6, events.OfType().Count()); + Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent); + // Two text deltas for the two streaming chunks + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // W-09: SubworkflowErrorEvent treated as informational (error content comes separately) + [Fact] + public async Task ConvertUpdatesToEventsAsync_SubworkflowErrorEvent_IsSkipped() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new SubworkflowErrorEvent("sub_1", new InvalidOperationException("sub failed")) }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Recovered")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // SubworkflowErrorEvent extends WorkflowErrorEvent which falls through to default skip + Assert.Single(events.OfType()); + Assert.IsType(events[^1]); + } + + // W-10: Mixed content types from workflow — reasoning + text + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowReasoningThenText_ProducesCorrectSequence() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("thinking_agent", "start") }, + // Agent produces reasoning content + new AgentResponseUpdate { Contents = [new TextReasoningContent("Analyzing the problem...")] }, + // Then text response + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("The answer is 42")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("thinking_agent", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Workflow actions: 2 (invoked + completed), reasoning: 1, text message: 1 = 4 output items + Assert.Equal(4, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // W-11: Usage content accumulated across workflow executors + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowUsageAcrossExecutors_AccumulatesCorrectly() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Response 1")] }, + new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 100, OutputTokenCount = 50, TotalTokenCount = 150 })] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") }, + new AgentResponseUpdate { MessageId = "msg_2", Contents = [new MeaiTextContent("Response 2")] }, + new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 200, OutputTokenCount = 100, TotalTokenCount = 300 })] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Usage should be accumulated in the completed event + Assert.IsType(events[^1]); + } + + // W-12: Empty workflow — only lifecycle events, no content + [Fact] + public async Task ConvertUpdatesToEventsAsync_EmptyWorkflowOnlyLifecycle_EmitsOnlyCompleted() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("start") }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Only the terminal completed event + Assert.Single(events); + Assert.IsType(events[0]); + } + + private static async IAsyncEnumerable ToAsync(IEnumerable source) + { + foreach (var item in source) + { + yield return item; + } + + await Task.CompletedTask; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..9cf45d70af --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Azure.AI.AgentServer.Responses; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void AddAgentFrameworkHandler_RegistersResponseHandler() + { + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddAgentFrameworkHandler(); + + var descriptor = services.FirstOrDefault( + d => d.ServiceType == typeof(ResponseHandler)); + Assert.NotNull(descriptor); + Assert.Equal(typeof(AgentFrameworkResponseHandler), descriptor.ImplementationType); + } + + [Fact] + public void AddAgentFrameworkHandler_CalledTwice_RegistersOnce() + { + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddAgentFrameworkHandler(); + services.AddAgentFrameworkHandler(); + + var count = services.Count(d => d.ServiceType == typeof(ResponseHandler)); + Assert.Equal(1, count); + } + + [Fact] + public void AddAgentFrameworkHandler_NullServices_ThrowsArgumentNullException() + { + Assert.Throws( + () => AgentFrameworkResponsesServiceCollectionExtensions.AddAgentFrameworkHandler(null!)); + } + + [Fact] + public void AddAgentFrameworkHandler_WithAgent_RegistersAgentAndHandler() + { + var services = new ServiceCollection(); + services.AddLogging(); + var mockAgent = new Mock(); + + services.AddAgentFrameworkHandler(mockAgent.Object); + + var handlerDescriptor = services.FirstOrDefault( + d => d.ServiceType == typeof(ResponseHandler)); + Assert.NotNull(handlerDescriptor); + + var agentDescriptor = services.FirstOrDefault( + d => d.ServiceType == typeof(AIAgent)); + Assert.NotNull(agentDescriptor); + } + + [Fact] + public void AddAgentFrameworkHandler_WithNullAgent_ThrowsArgumentNullException() + { + var services = new ServiceCollection(); + Assert.Throws( + () => services.AddAgentFrameworkHandler((AIAgent)null!)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs new file mode 100644 index 0000000000..e5f9bb0405 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs @@ -0,0 +1,509 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; + +/// +/// Integration tests that verify workflow execution through the +/// pipeline. +/// These use real workflow builders and the InProcessExecution environment +/// to produce authentic streaming event patterns. +/// +public class WorkflowIntegrationTests +{ + private static string ValidResponseId => "resp_" + new string('0', 46); + + // ===== Sequential Workflow Tests ===== + + [Fact] + public async Task SequentialWorkflow_SingleAgent_ProducesTextOutput() + { + // Arrange: single-agent sequential workflow + var echoAgent = new StreamingTextAgent("echo", "Hello from the workflow!"); + var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential", echoAgent); + var workflowAgent = workflow.AsAIAgent( + id: "workflow-agent", + name: "Test Workflow", + executionEnvironment: InProcessExecution.OffThread, + includeExceptionDetails: true); + + var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello"); + + // Act + var events = await CollectEventsAsync(handler, request, context); + + // Assert: should have lifecycle events + at least one text output + terminal + Assert.IsType(events[0]); + Assert.IsType(events[1]); + Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}"); + + var lastEvent = events[^1]; + Assert.True( + lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent, + $"Expected terminal event, got {lastEvent.GetType().Name}"); + } + + [Fact] + public async Task SequentialWorkflow_TwoAgents_ProducesOutputFromBoth() + { + // Arrange: two agents in sequence + var agent1 = new StreamingTextAgent("agent1", "First agent says hello"); + var agent2 = new StreamingTextAgent("agent2", "Second agent says goodbye"); + var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential-2", agent1, agent2); + var workflowAgent = workflow.AsAIAgent( + id: "seq-workflow", + name: "Sequential Workflow", + executionEnvironment: InProcessExecution.OffThread, + includeExceptionDetails: true); + + var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Process this"); + + // Act + var events = await CollectEventsAsync(handler, request, context); + + // Assert: should have workflow action events for executor lifecycle + var lastEvent = events[^1]; + Assert.True( + lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent, + $"Expected terminal event, got {lastEvent.GetType().Name}"); + + // Should have output item events (either text messages or workflow actions) + Assert.True(events.OfType().Any(), + "Expected at least one output item from the workflow"); + } + + // ===== Workflow Error Propagation ===== + + [Fact] + public async Task Workflow_AgentThrowsException_ProducesErrorOutput() + { + // Arrange: workflow with an agent that throws + var throwingAgent = new ThrowingStreamingAgent("thrower", new InvalidOperationException("Agent crashed")); + var workflow = AgentWorkflowBuilder.BuildSequential("test-error", throwingAgent); + var workflowAgent = workflow.AsAIAgent( + id: "error-workflow", + name: "Error Workflow", + executionEnvironment: InProcessExecution.OffThread, + includeExceptionDetails: true); + + var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Trigger error"); + + // Act + var events = await CollectEventsAsync(handler, request, context); + + // Assert: should have lifecycle events + error/failure indicator + Assert.IsType(events[0]); + Assert.IsType(events[1]); + + var lastEvent = events[^1]; + // Workflow errors surface as either Failed or Completed (depending on error handling) + Assert.True( + lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent, + $"Expected terminal event, got {lastEvent.GetType().Name}"); + } + + // ===== Workflow Action Lifecycle Events ===== + + [Fact] + public async Task Workflow_ExecutorEvents_ProduceWorkflowActionItems() + { + // Arrange + var agent = new StreamingTextAgent("test-agent", "Result"); + var workflow = AgentWorkflowBuilder.BuildSequential("test-actions", agent); + var workflowAgent = workflow.AsAIAgent( + id: "actions-workflow", + name: "Actions Workflow", + executionEnvironment: InProcessExecution.OffThread); + + var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello"); + + // Act + var events = await CollectEventsAsync(handler, request, context); + + // Assert: workflow should produce OutputItemAdded events for executor lifecycle + var addedEvents = events.OfType().ToList(); + Assert.True(addedEvents.Count >= 1, + $"Expected at least 1 output item added event, got {addedEvents.Count}"); + } + + // ===== Keyed Workflow Registration ===== + + [Fact] + public async Task WorkflowAgent_RegisteredWithKey_ResolvesCorrectly() + { + // Arrange: workflow agent registered with a keyed service name + var agent = new StreamingTextAgent("inner", "Keyed workflow response"); + var workflow = AgentWorkflowBuilder.BuildSequential("keyed-wf", agent); + var workflowAgent = workflow.AsAIAgent( + id: "keyed-workflow", + name: "Keyed Workflow", + executionEnvironment: InProcessExecution.OffThread); + + var services = new ServiceCollection(); + services.AddKeyedSingleton("my-workflow", workflowAgent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("my-workflow")); + request.Input = CreateUserInput("Test keyed workflow"); + var mockContext = CreateMockContext(); + + // Act + var events = await CollectEventsAsync(handler, request, mockContext.Object); + + // Assert + Assert.IsType(events[0]); + Assert.True(events.Count >= 3, $"Expected at least 3 events, got {events.Count}"); + } + + // ===== OutputConverter Direct Workflow Pattern Tests ===== + // These test the OutputConverter directly with update patterns that mirror real workflows. + + [Fact] + public async Task OutputConverter_SequentialWorkflowPattern_ProducesCorrectEvents() + { + // Simulate what WorkflowSession produces for a 2-agent sequential workflow + var (stream, _) = CreateTestStream(); + var updates = new[] + { + // Superstep 1: Agent 1 + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_a1", Contents = [new MeaiTextContent("Agent 1 output")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + // Superstep 2: Agent 2 + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") }, + new AgentResponseUpdate { MessageId = "msg_a2", Contents = [new MeaiTextContent("Agent 2 output")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 4 workflow action items + 2 text messages = 6 output items + Assert.Equal(6, events.OfType().Count()); + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task OutputConverter_GroupChatPattern_ProducesCorrectEvents() + { + // Simulate round-robin group chat: agent1 → agent2 → agent1 → terminate + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") }, + new AgentResponseUpdate { MessageId = "msg_gc_1", Contents = [new MeaiTextContent("Agent 1 turn 1")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_2", "turn") }, + new AgentResponseUpdate { MessageId = "msg_gc_2", Contents = [new MeaiTextContent("Agent 2 turn 1")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_2", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(3) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") }, + new AgentResponseUpdate { MessageId = "msg_gc_3", Contents = [new MeaiTextContent("Agent 1 turn 2")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(3) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 6 workflow actions + 3 text messages = 9 output items + Assert.Equal(9, events.OfType().Count()); + Assert.Equal(3, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task OutputConverter_CodeExecutorPattern_ProducesCorrectEvents() + { + // Simulate a code-based FunctionExecutor: invoked → completed, no text content + // (code executors don't produce AgentResponseUpdateEvent, just executor lifecycle) + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("uppercase_fn", "hello") }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("uppercase_fn", "HELLO") }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + // Second executor uses the output + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("format_agent", "start") }, + new AgentResponseUpdate { MessageId = "msg_fmt", Contents = [new MeaiTextContent("Formatted: HELLO")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("format_agent", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 4 workflow actions + 1 text message = 5 output items + Assert.Equal(5, events.OfType().Count()); + Assert.Single(events.OfType()); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task OutputConverter_SubworkflowPattern_ProducesCorrectEvents() + { + // Simulate a parent workflow that invokes a sub-workflow executor + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("parent") }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + // Sub-workflow executor invoked + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("sub_workflow_host", "start") }, + // Inner agent within sub-workflow produces text (unwrapped by WorkflowSession) + new AgentResponseUpdate { MessageId = "msg_sub_1", Contents = [new MeaiTextContent("Sub-workflow agent output")] }, + // Sub-workflow executor completed + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("sub_workflow_host", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 2 workflow actions + 1 text message = 3 output items + Assert.Equal(3, events.OfType().Count()); + Assert.Single(events.OfType()); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task OutputConverter_WorkflowWithMultipleContentTypes_HandlesAllCorrectly() + { + // Simulate a workflow producing reasoning, text, function calls, and usage + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("planner", "start") }, + // Reasoning + new AgentResponseUpdate { Contents = [new TextReasoningContent("Let me think about this...")] }, + // Function call (tool use) + new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_search", "web_search", + new Dictionary { ["query"] = "latest news" })] + }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("planner", null) }, + // Next executor uses tool result + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("writer", "start") }, + new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("Based on my research, ")] }, + new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("here are the findings.")] }, + new AgentResponseUpdate + { + Contents = [new UsageContent(new UsageDetails { InputTokenCount = 500, OutputTokenCount = 200, TotalTokenCount = 700 })] + }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("writer", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Workflow actions: 4 (2 invoked + 2 completed) + // Content: 1 reasoning + 1 function call + 1 text message = 3 + // Total: 7 output items + Assert.Equal(7, events.OfType().Count()); + Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent); + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // ===== Helpers ===== + + private static (AgentFrameworkResponseHandler handler, CreateResponse request, ResponseContext context) + CreateHandlerWithAgent(AIAgent agent, string userMessage) + { + var services = new ServiceCollection(); + services.AddSingleton(agent); + services.AddSingleton>(NullLogger.Instance); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = CreateUserInput(userMessage); + var mockContext = CreateMockContext(); + + return (handler, request, mockContext.Object); + } + + private static BinaryData CreateUserInput(string text) + { + return BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_in_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text } } + } + }); + } + + private static Mock CreateMockContext() + { + var mock = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mock.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mock.Setup(x => x.GetInputItemsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + return mock; + } + + private static (ResponseEventStream stream, Mock mockContext) CreateTestStream() + { + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model"); + var stream = new ResponseEventStream(mockContext.Object, request); + return (stream, mockContext); + } + + private static async Task> CollectEventsAsync( + AgentFrameworkResponseHandler handler, + CreateResponse request, + ResponseContext context) + { + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, context, CancellationToken.None)) + { + events.Add(evt); + } + + return events; + } + + private static async IAsyncEnumerable ToAsync(IEnumerable source) + { + foreach (var item in source) + { + yield return item; + } + + await Task.CompletedTask; + } + + // ===== Test Agent Types ===== + + /// + /// A test agent that streams a single text update. + /// + private sealed class StreamingTextAgent(string id, string responseText) : AIAgent + { + public new string Id => id; + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return new AgentResponseUpdate + { + MessageId = $"msg_{id}", + Contents = [new MeaiTextContent(responseText)] + }; + + await Task.CompletedTask; + } + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } + + /// + /// A test agent that always throws an exception during streaming. + /// + private sealed class ThrowingStreamingAgent(string id, Exception exception) : AIAgent + { + public new string Id => id; + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw exception; + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } +} From 69597d7d77c549dde20a78c5a873cac2e4cdcb42 Mon Sep 17 00:00:00 2001 From: alliscode Date: Wed, 1 Apr 2026 08:29:42 -0700 Subject: [PATCH 02/31] Bump System.ClientModel to 1.10.0 for Azure.Core 1.52.0 compat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OutputConverter.cs | 8 ++++---- .../ServiceCollectionExtensions.cs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs index c620cf6324..f6b9aed006 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; @@ -244,9 +244,9 @@ internal static class OutputConverter private static ResponseUsage ConvertUsage(UsageDetails details, ResponseUsage? existing) { - var inputTokens = (long)(details.InputTokenCount ?? 0); - var outputTokens = (long)(details.OutputTokenCount ?? 0); - var totalTokens = (long)(details.TotalTokenCount ?? 0); + var inputTokens = details.InputTokenCount ?? 0; + var outputTokens = details.OutputTokenCount ?? 0; + var totalTokens = details.TotalTokenCount ?? 0; if (existing is not null) { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs index d596ba3457..c06353a8ec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Azure.AI.AgentServer.Responses; using Microsoft.Extensions.DependencyInjection; From 52cc4a9f0e9c1666420ebcc3baf7486fb83b366c Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 2 Apr 2026 08:51:05 -0700 Subject: [PATCH 03/31] Clean up tests and sample formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FoundryResponsesHosting/Pages.cs | 2 +- .../FoundryResponsesHosting/Program.cs | 2 +- .../ResponseStreamValidator.cs | 230 +++++++++--------- .../AgentFrameworkResponseHandlerTests.cs | 24 +- .../InputConverterTests.cs | 4 +- .../OutputConverterTests.cs | 8 +- .../ServiceCollectionExtensionsTests.cs | 4 +- .../WorkflowIntegrationTests.cs | 8 +- 8 files changed, 138 insertions(+), 144 deletions(-) diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/Pages.cs b/dotnet/samples/04-hosting/FoundryResponsesHosting/Pages.cs index bff2c62e99..916b0fdf17 100644 --- a/dotnet/samples/04-hosting/FoundryResponsesHosting/Pages.cs +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/Pages.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. /// /// Static HTML pages served by the sample application. diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs b/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs index 32f39f641c..88af464ac7 100644 --- a/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs @@ -16,8 +16,8 @@ // - AZURE_OPENAI_DEPLOYMENT - the model deployment name (default: "gpt-4o") using System.ComponentModel; -using Azure.AI.OpenAI; using Azure.AI.AgentServer.Responses; +using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/ResponseStreamValidator.cs b/dotnet/samples/04-hosting/FoundryResponsesHosting/ResponseStreamValidator.cs index 72da677f45..5dc1b4c791 100644 --- a/dotnet/samples/04-hosting/FoundryResponsesHosting/ResponseStreamValidator.cs +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/ResponseStreamValidator.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Text.Json; using System.Text.Json.Serialization; @@ -36,7 +36,7 @@ internal sealed class ResponseStreamValidator private bool _hasTerminal; /// All violations found so far. - internal IReadOnlyList Violations => _violations; + internal IReadOnlyList Violations => this._violations; /// /// Processes a single SSE event line pair (event type + JSON data). @@ -52,33 +52,33 @@ internal sealed class ResponseStreamValidator } catch (JsonException ex) { - Fail("PARSE-01", $"Invalid JSON in event data: {ex.Message}"); + this.Fail("PARSE-01", $"Invalid JSON in event data: {ex.Message}"); return; } - _eventCount++; + this._eventCount++; // ── Sequence number validation ────────────────────────────────── if (data.TryGetProperty("sequence_number", out var seqProp) && seqProp.ValueKind == JsonValueKind.Number) { int seq = seqProp.GetInt32(); - if (seq != _expectedSequenceNumber) + if (seq != this._expectedSequenceNumber) { - Fail("SEQ-01", $"Expected sequence_number {_expectedSequenceNumber}, got {seq}"); + this.Fail("SEQ-01", $"Expected sequence_number {this._expectedSequenceNumber}, got {seq}"); } - _expectedSequenceNumber = seq + 1; + this._expectedSequenceNumber = seq + 1; } - else if (_state != StreamState.Initial || eventType != "error") + else if (this._state != StreamState.Initial || eventType != "error") { // Pre-creation error events may not have sequence_number - Fail("SEQ-02", $"Missing sequence_number on event '{eventType}'"); + this.Fail("SEQ-02", $"Missing sequence_number on event '{eventType}'"); } // ── Post-terminal guard ───────────────────────────────────────── - if (_hasTerminal) + if (this._hasTerminal) { - Fail("TERM-01", $"Event '{eventType}' received after terminal event"); + this.Fail("TERM-01", $"Event '{eventType}' received after terminal event"); return; } @@ -86,93 +86,93 @@ internal sealed class ResponseStreamValidator switch (eventType) { case "response.created": - ValidateResponseCreated(data); + this.ValidateResponseCreated(data); break; case "response.queued": - ValidateStateTransition(eventType, StreamState.Created, StreamState.Queued); - ValidateResponseEnvelope(data, eventType); + this.ValidateStateTransition(eventType, StreamState.Created, StreamState.Queued); + this.ValidateResponseEnvelope(data, eventType); break; case "response.in_progress": - if (_state is StreamState.Created or StreamState.Queued) + if (this._state is StreamState.Created or StreamState.Queued) { - _state = StreamState.InProgress; + this._state = StreamState.InProgress; } else { - Fail("ORDER-02", $"'response.in_progress' received in state {_state} (expected Created or Queued)"); + this.Fail("ORDER-02", $"'response.in_progress' received in state {this._state} (expected Created or Queued)"); } - ValidateResponseEnvelope(data, eventType); + this.ValidateResponseEnvelope(data, eventType); break; case "response.output_item.added": case "output_item.added": - ValidateInProgress(eventType); - ValidateOutputItemAdded(data); + this.ValidateInProgress(eventType); + this.ValidateOutputItemAdded(data); break; case "response.output_item.done": case "output_item.done": - ValidateInProgress(eventType); - ValidateOutputItemDone(data); + this.ValidateInProgress(eventType); + this.ValidateOutputItemDone(data); break; case "response.content_part.added": case "content_part.added": - ValidateInProgress(eventType); - ValidateContentPartAdded(data); + this.ValidateInProgress(eventType); + this.ValidateContentPartAdded(data); break; case "response.content_part.done": case "content_part.done": - ValidateInProgress(eventType); - ValidateContentPartDone(data); + this.ValidateInProgress(eventType); + this.ValidateContentPartDone(data); break; case "response.output_text.delta": case "output_text.delta": - ValidateInProgress(eventType); - ValidateTextDelta(data); + this.ValidateInProgress(eventType); + this.ValidateTextDelta(data); break; case "response.output_text.done": case "output_text.done": - ValidateInProgress(eventType); - ValidateTextDone(data); + this.ValidateInProgress(eventType); + this.ValidateTextDone(data); break; case "response.function_call_arguments.delta": case "function_call_arguments.delta": - ValidateInProgress(eventType); + this.ValidateInProgress(eventType); break; case "response.function_call_arguments.done": case "function_call_arguments.done": - ValidateInProgress(eventType); + this.ValidateInProgress(eventType); break; case "response.completed": - ValidateTerminal(data, "completed"); + this.ValidateTerminal(data, "completed"); break; case "response.failed": - ValidateTerminal(data, "failed"); + this.ValidateTerminal(data, "failed"); break; case "response.incomplete": - ValidateTerminal(data, "incomplete"); + this.ValidateTerminal(data, "incomplete"); break; case "error": // Pre-creation error — standalone, no response.created precedes it - if (_state != StreamState.Initial) + if (this._state != StreamState.Initial) { - Fail("ERR-01", "'error' event received after response.created — should use response.failed instead"); + this.Fail("ERR-01", "'error' event received after response.created — should use response.failed instead"); } - _hasTerminal = true; + this._hasTerminal = true; break; default: @@ -186,31 +186,31 @@ internal sealed class ResponseStreamValidator /// internal void Complete() { - if (!_hasTerminal && _state != StreamState.Initial) + if (!this._hasTerminal && this._state != StreamState.Initial) { - Fail("TERM-02", "Stream ended without a terminal event (response.completed, response.failed, or response.incomplete)"); + this.Fail("TERM-02", "Stream ended without a terminal event (response.completed, response.failed, or response.incomplete)"); } - if (_state == StreamState.Initial && _eventCount == 0) + if (this._state == StreamState.Initial && this._eventCount == 0) { - Fail("EMPTY-01", "No events received in the stream"); + this.Fail("EMPTY-01", "No events received in the stream"); } // Check for output items that were added but never completed - foreach (int idx in _addedItemIndices) + foreach (int idx in this._addedItemIndices) { - if (!_doneItemIndices.Contains(idx)) + if (!this._doneItemIndices.Contains(idx)) { - Fail("ITEM-03", $"Output item at index {idx} was added but never received output_item.done"); + this.Fail("ITEM-03", $"Output item at index {idx} was added but never received output_item.done"); } } // Check for content parts that were added but never completed - foreach (string key in _addedContentParts) + foreach (string key in this._addedContentParts) { - if (!_doneContentParts.Contains(key)) + if (!this._doneContentParts.Contains(key)) { - Fail("CONTENT-03", $"Content part '{key}' was added but never received content_part.done"); + this.Fail("CONTENT-03", $"Content part '{key}' was added but never received content_part.done"); } } } @@ -221,9 +221,9 @@ internal sealed class ResponseStreamValidator internal ValidationResult GetResult() { return new ValidationResult( - EventCount: _eventCount, - IsValid: _violations.Count == 0, - Violations: [.. _violations]); + EventCount: this._eventCount, + IsValid: this._violations.Count == 0, + Violations: [.. this._violations]); } // ═══════════════════════════════════════════════════════════════════════ @@ -232,28 +232,28 @@ internal sealed class ResponseStreamValidator private void ValidateResponseCreated(JsonElement data) { - if (_state != StreamState.Initial) + if (this._state != StreamState.Initial) { - Fail("ORDER-01", $"'response.created' received in state {_state} (expected Initial — must be first event)"); + this.Fail("ORDER-01", $"'response.created' received in state {this._state} (expected Initial — must be first event)"); return; } - _state = StreamState.Created; + this._state = StreamState.Created; // Must have a response envelope if (!data.TryGetProperty("response", out var resp)) { - Fail("FIELD-01", "'response.created' missing 'response' object"); + this.Fail("FIELD-01", "'response.created' missing 'response' object"); return; } // Required response fields - ValidateRequiredResponseFields(resp, "response.created"); + this.ValidateRequiredResponseFields(resp, "response.created"); // Capture response ID for cross-event checks if (resp.TryGetProperty("id", out var idProp)) { - _responseId = idProp.GetString(); + this._responseId = idProp.GetString(); } // Status must be non-terminal @@ -262,28 +262,28 @@ internal sealed class ResponseStreamValidator string? status = statusProp.GetString(); if (status is "completed" or "failed" or "incomplete" or "cancelled") { - Fail("STATUS-01", $"'response.created' has terminal status '{status}' — must be 'queued' or 'in_progress'"); + this.Fail("STATUS-01", $"'response.created' has terminal status '{status}' — must be 'queued' or 'in_progress'"); } } } private void ValidateTerminal(JsonElement data, string expectedKind) { - if (_state is StreamState.Initial or StreamState.Created) + if (this._state is StreamState.Initial or StreamState.Created) { - Fail("ORDER-03", $"Terminal event 'response.{expectedKind}' received before 'response.in_progress'"); + this.Fail("ORDER-03", $"Terminal event 'response.{expectedKind}' received before 'response.in_progress'"); } - _hasTerminal = true; - _state = StreamState.Terminal; + this._hasTerminal = true; + this._state = StreamState.Terminal; if (!data.TryGetProperty("response", out var resp)) { - Fail("FIELD-01", $"'response.{expectedKind}' missing 'response' object"); + this.Fail("FIELD-01", $"'response.{expectedKind}' missing 'response' object"); return; } - ValidateRequiredResponseFields(resp, $"response.{expectedKind}"); + this.ValidateRequiredResponseFields(resp, $"response.{expectedKind}"); if (resp.TryGetProperty("status", out var statusProp)) { @@ -295,12 +295,12 @@ internal sealed class ResponseStreamValidator if (status == "completed" && !hasCompletedAt) { - Fail("FIELD-02", "'completed_at' must be non-null when status is 'completed'"); + this.Fail("FIELD-02", "'completed_at' must be non-null when status is 'completed'"); } if (status != "completed" && hasCompletedAt) { - Fail("FIELD-03", $"'completed_at' must be null when status is '{status}'"); + this.Fail("FIELD-03", $"'completed_at' must be null when status is '{status}'"); } // error field validation @@ -309,39 +309,39 @@ internal sealed class ResponseStreamValidator if (status == "failed" && !hasError) { - Fail("FIELD-04", "'error' must be non-null when status is 'failed'"); + this.Fail("FIELD-04", "'error' must be non-null when status is 'failed'"); } if (status is "completed" or "incomplete" && hasError) { - Fail("FIELD-05", $"'error' must be null when status is '{status}'"); + this.Fail("FIELD-05", $"'error' must be null when status is '{status}'"); } // error structure validation if (hasError) { - ValidateErrorObject(errProp, $"response.{expectedKind}"); + this.ValidateErrorObject(errProp, $"response.{expectedKind}"); } // cancelled output must be empty (B11) if (status == "cancelled" && resp.TryGetProperty("output", out var outputProp) && outputProp.ValueKind == JsonValueKind.Array && outputProp.GetArrayLength() > 0) { - Fail("CANCEL-01", "Cancelled response must have empty output array (B11)"); + this.Fail("CANCEL-01", "Cancelled response must have empty output array (B11)"); } // response ID consistency - if (_responseId is not null && resp.TryGetProperty("id", out var idProp) - && idProp.GetString() != _responseId) + if (this._responseId is not null && resp.TryGetProperty("id", out var idProp) + && idProp.GetString() != this._responseId) { - Fail("ID-01", $"Response ID changed: was '{_responseId}', now '{idProp.GetString()}'"); + this.Fail("ID-01", $"Response ID changed: was '{this._responseId}', now '{idProp.GetString()}'"); } } // Usage validation (optional, but if present must be structured correctly) if (resp.TryGetProperty("usage", out var usageProp) && usageProp.ValueKind == JsonValueKind.Object) { - ValidateUsage(usageProp, $"response.{expectedKind}"); + this.ValidateUsage(usageProp, $"response.{expectedKind}"); } } @@ -350,19 +350,19 @@ internal sealed class ResponseStreamValidator if (data.TryGetProperty("output_index", out var idxProp) && idxProp.ValueKind == JsonValueKind.Number) { int index = idxProp.GetInt32(); - if (!_addedItemIndices.Add(index)) + if (!this._addedItemIndices.Add(index)) { - Fail("ITEM-01", $"Duplicate output_item.added for output_index {index}"); + this.Fail("ITEM-01", $"Duplicate output_item.added for output_index {index}"); } } else { - Fail("FIELD-06", "output_item.added missing 'output_index' field"); + this.Fail("FIELD-06", "output_item.added missing 'output_index' field"); } if (!data.TryGetProperty("item", out _)) { - Fail("FIELD-07", "output_item.added missing 'item' object"); + this.Fail("FIELD-07", "output_item.added missing 'item' object"); } } @@ -371,37 +371,37 @@ internal sealed class ResponseStreamValidator if (data.TryGetProperty("output_index", out var idxProp) && idxProp.ValueKind == JsonValueKind.Number) { int index = idxProp.GetInt32(); - if (!_addedItemIndices.Contains(index)) + if (!this._addedItemIndices.Contains(index)) { - Fail("ITEM-02", $"output_item.done for output_index {index} without preceding output_item.added"); + this.Fail("ITEM-02", $"output_item.done for output_index {index} without preceding output_item.added"); } - _doneItemIndices.Add(index); + this._doneItemIndices.Add(index); } else { - Fail("FIELD-06", "output_item.done missing 'output_index' field"); + this.Fail("FIELD-06", "output_item.done missing 'output_index' field"); } } private void ValidateContentPartAdded(JsonElement data) { string key = GetContentPartKey(data); - if (!_addedContentParts.Add(key)) + if (!this._addedContentParts.Add(key)) { - Fail("CONTENT-01", $"Duplicate content_part.added for {key}"); + this.Fail("CONTENT-01", $"Duplicate content_part.added for {key}"); } } private void ValidateContentPartDone(JsonElement data) { string key = GetContentPartKey(data); - if (!_addedContentParts.Contains(key)) + if (!this._addedContentParts.Contains(key)) { - Fail("CONTENT-02", $"content_part.done for {key} without preceding content_part.added"); + this.Fail("CONTENT-02", $"content_part.done for {key} without preceding content_part.added"); } - _doneContentParts.Add(key); + this._doneContentParts.Add(key); } private void ValidateTextDelta(JsonElement data) @@ -411,13 +411,13 @@ internal sealed class ResponseStreamValidator ? deltaProp.GetString() ?? string.Empty : string.Empty; - if (!_textAccumulators.TryGetValue(key, out string? existing)) + if (!this._textAccumulators.TryGetValue(key, out string? existing)) { - _textAccumulators[key] = delta; + this._textAccumulators[key] = delta; } else { - _textAccumulators[key] = existing + delta; + this._textAccumulators[key] = existing + delta; } } @@ -430,13 +430,13 @@ internal sealed class ResponseStreamValidator if (finalText is null) { - Fail("TEXT-01", $"output_text.done for {key} missing 'text' field"); + this.Fail("TEXT-01", $"output_text.done for {key} missing 'text' field"); return; } - if (_textAccumulators.TryGetValue(key, out string? accumulated) && accumulated != finalText) + if (this._textAccumulators.TryGetValue(key, out string? accumulated) && accumulated != finalText) { - Fail("TEXT-02", $"output_text.done text for {key} does not match accumulated deltas (accumulated {accumulated.Length} chars, done has {finalText.Length} chars)"); + this.Fail("TEXT-02", $"output_text.done text for {key} does not match accumulated deltas (accumulated {accumulated.Length} chars, done has {finalText.Length} chars)"); } } @@ -448,34 +448,34 @@ internal sealed class ResponseStreamValidator { if (!HasNonNullString(resp, "id")) { - Fail("FIELD-01", $"{context}: response missing 'id'"); + this.Fail("FIELD-01", $"{context}: response missing 'id'"); } if (resp.TryGetProperty("object", out var objProp)) { if (objProp.GetString() != "response") { - Fail("FIELD-08", $"{context}: response.object must be 'response', got '{objProp.GetString()}'"); + this.Fail("FIELD-08", $"{context}: response.object must be 'response', got '{objProp.GetString()}'"); } } else { - Fail("FIELD-08", $"{context}: response missing 'object' field"); + this.Fail("FIELD-08", $"{context}: response missing 'object' field"); } if (!resp.TryGetProperty("created_at", out var catProp) || catProp.ValueKind == JsonValueKind.Null) { - Fail("FIELD-09", $"{context}: response missing 'created_at'"); + this.Fail("FIELD-09", $"{context}: response missing 'created_at'"); } if (!resp.TryGetProperty("status", out _)) { - Fail("FIELD-10", $"{context}: response missing 'status'"); + this.Fail("FIELD-10", $"{context}: response missing 'status'"); } if (!resp.TryGetProperty("output", out var outputProp) || outputProp.ValueKind != JsonValueKind.Array) { - Fail("FIELD-11", $"{context}: response missing 'output' array"); + this.Fail("FIELD-11", $"{context}: response missing 'output' array"); } } @@ -483,12 +483,12 @@ internal sealed class ResponseStreamValidator { if (!HasNonNullString(error, "code")) { - Fail("ERR-02", $"{context}: error object missing 'code' field"); + this.Fail("ERR-02", $"{context}: error object missing 'code' field"); } if (!HasNonNullString(error, "message")) { - Fail("ERR-03", $"{context}: error object missing 'message' field"); + this.Fail("ERR-03", $"{context}: error object missing 'message' field"); } } @@ -496,17 +496,17 @@ internal sealed class ResponseStreamValidator { if (!usage.TryGetProperty("input_tokens", out _)) { - Fail("USAGE-01", $"{context}: usage missing 'input_tokens'"); + this.Fail("USAGE-01", $"{context}: usage missing 'input_tokens'"); } if (!usage.TryGetProperty("output_tokens", out _)) { - Fail("USAGE-02", $"{context}: usage missing 'output_tokens'"); + this.Fail("USAGE-02", $"{context}: usage missing 'output_tokens'"); } if (!usage.TryGetProperty("total_tokens", out _)) { - Fail("USAGE-03", $"{context}: usage missing 'total_tokens'"); + this.Fail("USAGE-03", $"{context}: usage missing 'total_tokens'"); } } @@ -514,17 +514,17 @@ internal sealed class ResponseStreamValidator { if (!data.TryGetProperty("response", out var resp)) { - Fail("FIELD-01", $"'{eventType}' missing 'response' object"); + this.Fail("FIELD-01", $"'{eventType}' missing 'response' object"); return; } - ValidateRequiredResponseFields(resp, eventType); + this.ValidateRequiredResponseFields(resp, eventType); // Response ID consistency - if (_responseId is not null && resp.TryGetProperty("id", out var idProp) - && idProp.GetString() != _responseId) + if (this._responseId is not null && resp.TryGetProperty("id", out var idProp) + && idProp.GetString() != this._responseId) { - Fail("ID-01", $"Response ID changed: was '{_responseId}', now '{idProp.GetString()}'"); + this.Fail("ID-01", $"Response ID changed: was '{this._responseId}', now '{idProp.GetString()}'"); } } @@ -534,27 +534,27 @@ internal sealed class ResponseStreamValidator private void ValidateInProgress(string eventType) { - if (_state != StreamState.InProgress) + if (this._state != StreamState.InProgress) { - Fail("ORDER-04", $"'{eventType}' received in state {_state} (expected InProgress)"); + this.Fail("ORDER-04", $"'{eventType}' received in state {this._state} (expected InProgress)"); } } private void ValidateStateTransition(string eventType, StreamState expected, StreamState next) { - if (_state != expected) + if (this._state != expected) { - Fail("ORDER-05", $"'{eventType}' received in state {_state} (expected {expected})"); + this.Fail("ORDER-05", $"'{eventType}' received in state {this._state} (expected {expected})"); } else { - _state = next; + this._state = next; } } private void Fail(string ruleId, string message) { - _violations.Add(new ValidationViolation(ruleId, message, _eventCount)); + this._violations.Add(new ValidationViolation(ruleId, message, this._eventCount)); } private static bool HasNonNullString(JsonElement obj, string property) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs index ee32771a67..10d1271d10 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -20,8 +20,6 @@ namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; public class AgentFrameworkResponseHandlerTests { - private static string ValidResponseId => "resp_" + new string('0', 46); - [Fact] public async Task CreateAsync_WithDefaultAgent_ProducesStreamEvents() { @@ -688,13 +686,13 @@ public class AgentFrameworkResponseHandlerTests protected override ValueTask SerializeSessionCoreAsync( AgentSession session, - System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override ValueTask DeserializeSessionCoreAsync( JsonElement serializedState, - System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) => throw new NotImplementedException(); } @@ -721,13 +719,13 @@ public class AgentFrameworkResponseHandlerTests protected override ValueTask SerializeSessionCoreAsync( AgentSession session, - System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override ValueTask DeserializeSessionCoreAsync( JsonElement serializedState, - System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) => throw new NotImplementedException(); } @@ -743,8 +741,8 @@ public class AgentFrameworkResponseHandlerTests AgentRunOptions? options, CancellationToken cancellationToken = default) { - CapturedMessages = messages.ToList(); - CapturedOptions = options; + this.CapturedMessages = messages.ToList(); + this.CapturedOptions = options; return ToAsyncEnumerableAsync(new AgentResponseUpdate { MessageId = "resp_msg_1", @@ -765,13 +763,13 @@ public class AgentFrameworkResponseHandlerTests protected override ValueTask SerializeSessionCoreAsync( AgentSession session, - System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override ValueTask DeserializeSessionCoreAsync( JsonElement serializedState, - System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) => throw new NotImplementedException(); } @@ -802,13 +800,13 @@ public class AgentFrameworkResponseHandlerTests protected override ValueTask SerializeSessionCoreAsync( AgentSession session, - System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override ValueTask DeserializeSessionCoreAsync( JsonElement serializedState, - System.Text.Json.JsonSerializerOptions? jsonSerializerOptions, + JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) => throw new NotImplementedException(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs index 34555798a7..85748214f1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs @@ -1,9 +1,7 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; using System.Linq; -using System.Text.Json; using Azure.AI.AgentServer.Responses; using Azure.AI.AgentServer.Responses.Models; using Microsoft.Extensions.AI; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs index dde11298c7..f139e3bf71 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -7,8 +7,8 @@ using System.Threading; using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; using Azure.AI.AgentServer.Responses.Models; -using Microsoft.Extensions.AI; using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; using Moq; using MeaiTextContent = Microsoft.Extensions.AI.TextContent; @@ -233,7 +233,7 @@ public class OutputConverterTests public async Task ConvertUpdatesToEventsAsync_NullTextContent_NoTextDeltaEmitted() { var (stream, _) = CreateTestStream(); - var update = new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent((string)null!)] }; + var update = new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent(null!)] }; var events = new List(); await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) @@ -314,7 +314,7 @@ public class OutputConverterTests var (stream, _) = CreateTestStream(); var update = new AgentResponseUpdate { - Contents = [new FunctionCallContent("call_1", "do_something", (IDictionary?)null)] + Contents = [new FunctionCallContent("call_1", "do_something", null)] }; var events = new List(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs index 9cf45d70af..2be9dc0bc8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Linq; @@ -67,6 +67,6 @@ public class ServiceCollectionExtensionsTests { var services = new ServiceCollection(); Assert.Throws( - () => services.AddAgentFrameworkHandler((AIAgent)null!)); + () => services.AddAgentFrameworkHandler(null!)); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs index e5f9bb0405..e8f035ba85 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -27,8 +27,6 @@ namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; /// public class WorkflowIntegrationTests { - private static string ValidResponseId => "resp_" + new string('0', 46); - // ===== Sequential Workflow Tests ===== [Fact] @@ -156,7 +154,7 @@ public class WorkflowIntegrationTests executionEnvironment: InProcessExecution.OffThread); var services = new ServiceCollection(); - services.AddKeyedSingleton("my-workflow", workflowAgent); + services.AddKeyedSingleton("my-workflow", workflowAgent); var sp = services.BuildServiceProvider(); var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); @@ -357,7 +355,7 @@ public class WorkflowIntegrationTests CreateHandlerWithAgent(AIAgent agent, string userMessage) { var services = new ServiceCollection(); - services.AddSingleton(agent); + services.AddSingleton(agent); services.AddSingleton>(NullLogger.Instance); var sp = services.BuildServiceProvider(); From 59ad42912ff3992947adab61453d29e187830bf2 Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 2 Apr 2026 08:54:14 -0700 Subject: [PATCH 04/31] Update Azure.AI.AgentServer packages to 1.0.0-alpha.20260401.5 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 20dbf32bb6..d5af9da0b6 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -19,9 +19,9 @@ - - - + + + From bb7082d3c6cad737ffc390ce4995eb94d472f451 Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 2 Apr 2026 10:59:50 -0700 Subject: [PATCH 05/31] Add hosted package version suffix (0.9.0-hosted) to distinguish from mainline Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/nuget/nuget-package.props | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index c64f43949f..3ce985cdd3 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -7,6 +7,8 @@ $(VersionPrefix)-$(VersionSuffix).260402.1 $(VersionPrefix)-preview.260402.1 $(VersionPrefix) + + 0.9.0-hosted.260402.1 1.0.0 Debug;Release;Publish From a1c19b90cd0bbc6f3cd9d51dcf59a77e3b9837d3 Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 2 Apr 2026 13:31:58 -0700 Subject: [PATCH 06/31] Move Foundry Responses hosting into Microsoft.Agents.AI.Foundry package Move source and test files from the standalone Hosting.AzureAIResponses project into the Foundry package under a Hosting/ subfolder. This consolidates the Foundry-specific hosting adapter into the main Foundry package. - Source: Microsoft.Agents.AI.Foundry.Hosting namespace - Tests: merged into Foundry.UnitTests/Hosting/ - Conditionally compiled for .NETCoreApp TFMs only (net8.0+) - Deleted standalone Hosting.AzureAIResponses project and test project - Updated sample and solution references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/agent-framework-dotnet.slnx | 2 - .../FoundryResponsesHosting.csproj | 2 +- .../FoundryResponsesHosting/Program.cs | 4 +- .../Hosting}/AgentFrameworkResponseHandler.cs | 8 +++- .../Hosting}/InputConverter.cs | 7 +++- .../Hosting}/OutputConverter.cs | 8 +++- .../Hosting}/ServiceCollectionExtensions.cs | 5 ++- .../Microsoft.Agents.AI.Foundry.csproj | 22 +++++++++- ....Agents.AI.Hosting.AzureAIResponses.csproj | 40 ------------------- .../AgentFrameworkResponseHandlerTests.cs | 5 ++- .../Hosting}/InputConverterTests.cs | 5 ++- .../Hosting}/OutputConverterTests.cs | 5 ++- .../ServiceCollectionExtensionsTests.cs | 5 ++- .../Hosting}/WorkflowIntegrationTests.cs | 5 ++- ...crosoft.Agents.AI.Foundry.UnitTests.csproj | 14 +++++++ ....Hosting.AzureAIResponses.UnitTests.csproj | 17 -------- 16 files changed, 73 insertions(+), 81 deletions(-) rename dotnet/src/{Microsoft.Agents.AI.Hosting.AzureAIResponses => Microsoft.Agents.AI.Foundry/Hosting}/AgentFrameworkResponseHandler.cs (97%) rename dotnet/src/{Microsoft.Agents.AI.Hosting.AzureAIResponses => Microsoft.Agents.AI.Foundry/Hosting}/InputConverter.cs (98%) rename dotnet/src/{Microsoft.Agents.AI.Hosting.AzureAIResponses => Microsoft.Agents.AI.Foundry/Hosting}/OutputConverter.cs (98%) rename dotnet/src/{Microsoft.Agents.AI.Hosting.AzureAIResponses => Microsoft.Agents.AI.Foundry/Hosting}/ServiceCollectionExtensions.cs (96%) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj rename dotnet/tests/{Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests/Hosting}/AgentFrameworkResponseHandlerTests.cs (99%) rename dotnet/tests/{Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests/Hosting}/InputConverterTests.cs (99%) rename dotnet/tests/{Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests/Hosting}/OutputConverterTests.cs (99%) rename dotnet/tests/{Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests/Hosting}/ServiceCollectionExtensionsTests.cs (93%) rename dotnet/tests/{Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests/Hosting}/WorkflowIntegrationTests.cs (99%) delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests.csproj diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 3d0465763f..ab74c2650a 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -494,7 +494,6 @@ - @@ -541,7 +540,6 @@ - diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj b/dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj index 6725ef8d3b..3dfab4ad48 100644 --- a/dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj @@ -18,8 +18,8 @@ + - diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs b/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs index 88af464ac7..10a82d1b87 100644 --- a/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. // This sample demonstrates hosting agent-framework agents as Foundry Hosted Agents // using the Azure AI Responses Server SDK. @@ -21,7 +21,7 @@ using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; -using Microsoft.Agents.AI.Hosting.AzureAIResponses; +using Microsoft.Agents.AI.Foundry.Hosting; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using ModelContextProtocol.Client; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/AgentFrameworkResponseHandler.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs index 5f07cd4530..acb8e9c556 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs @@ -1,13 +1,17 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; using Azure.AI.AgentServer.Responses.Models; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Microsoft.Agents.AI.Hosting.AzureAIResponses; +namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// A implementation that bridges the Azure AI Responses Server SDK diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs similarity index 98% rename from dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/InputConverter.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs index a35c8cd5b8..49bd216a90 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/InputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs @@ -1,12 +1,15 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Text.Json; using Azure.AI.AgentServer.Responses.Models; using Microsoft.Extensions.AI; using MeaiTextContent = Microsoft.Extensions.AI.TextContent; -namespace Microsoft.Agents.AI.Hosting.AzureAIResponses; +namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// Converts Responses Server SDK input types to agent-framework types. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/OutputConverter.cs similarity index 98% rename from dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/OutputConverter.cs index f6b9aed006..1fd6672c83 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/OutputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/OutputConverter.cs @@ -1,17 +1,21 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; using Azure.AI.AgentServer.Responses.Models; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using MeaiTextContent = Microsoft.Extensions.AI.TextContent; -namespace Microsoft.Agents.AI.Hosting.AzureAIResponses; +namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// Converts agent-framework streams into diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs similarity index 96% rename from dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs index c06353a8ec..bae8821745 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs @@ -1,10 +1,11 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. +using System; using Azure.AI.AgentServer.Responses; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -namespace Microsoft.Agents.AI.Hosting.AzureAIResponses; +namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// Extension methods for to register the agent-framework diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj index 670d140043..e3c1773941 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj @@ -3,7 +3,8 @@ true true - $(NoWarn);OPENAI001 + $(NoWarn);OPENAI001;MEAI001;NU1903 + false @@ -20,18 +21,32 @@ true + + + + + + + + + + + + + + Microsoft Agent Framework for Foundry Agents @@ -43,4 +58,9 @@ + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj deleted file mode 100644 index b881e287cc..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureAIResponses/Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj +++ /dev/null @@ -1,40 +0,0 @@ - - - - $(TargetFrameworksCore) - enable - Microsoft.Agents.AI.Hosting.AzureAIResponses - alpha - $(NoWarn);MEAI001;NU1903 - false - - - - - - true - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs index 10d1271d10..dc38c42739 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Foundry.Hosting; using Azure.AI.AgentServer.Responses; using Azure.AI.AgentServer.Responses.Models; using Microsoft.Extensions.AI; @@ -16,7 +17,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Moq; using MeaiTextContent = Microsoft.Extensions.AI.TextContent; -namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; public class AgentFrameworkResponseHandlerTests { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs index 85748214f1..7f36fd10d7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/InputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs @@ -1,13 +1,14 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Linq; +using Microsoft.Agents.AI.Foundry.Hosting; using Azure.AI.AgentServer.Responses; using Azure.AI.AgentServer.Responses.Models; using Microsoft.Extensions.AI; using MeaiTextContent = Microsoft.Extensions.AI.TextContent; -namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; public class InputConverterTests { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/OutputConverterTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/OutputConverterTests.cs index f139e3bf71..330312d2b4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/OutputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/OutputConverterTests.cs @@ -1,10 +1,11 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Foundry.Hosting; using Azure.AI.AgentServer.Responses; using Azure.AI.AgentServer.Responses.Models; using Microsoft.Agents.AI.Workflows; @@ -12,7 +13,7 @@ using Microsoft.Extensions.AI; using Moq; using MeaiTextContent = Microsoft.Extensions.AI.TextContent; -namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; public class OutputConverterTests { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs similarity index 93% rename from dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs index 2be9dc0bc8..ee61cef71a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/ServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs @@ -1,12 +1,13 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Linq; +using Microsoft.Agents.AI.Foundry.Hosting; using Azure.AI.AgentServer.Responses; using Microsoft.Extensions.DependencyInjection; using Moq; -namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; public class ServiceCollectionExtensionsTests { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs index e8f035ba85..66924bdcba 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/WorkflowIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Agents.AI.Foundry.Hosting; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -17,7 +18,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Moq; using MeaiTextContent = Microsoft.Extensions.AI.TextContent; -namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; /// /// Integration tests that verify workflow execution through the diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index 7b85de0384..6473b799bf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -1,10 +1,24 @@ + + false + $(NoWarn);NU1903;NU1605 + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests.csproj deleted file mode 100644 index 09c3ba24c1..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - $(TargetFrameworksCore) - false - $(NoWarn);NU1903;NU1605 - - - - - - - - - - - From af9807b4a4540867646df3f4512fab545b50c068 Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 2 Apr 2026 15:08:34 -0700 Subject: [PATCH 07/31] Bump package version to 0.9.0-hosted.260402.2 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/nuget/nuget-package.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 3ce985cdd3..b89b823219 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -8,7 +8,7 @@ $(VersionPrefix)-preview.260402.1 $(VersionPrefix) - 0.9.0-hosted.260402.1 + 0.9.0-hosted.260402.2 1.0.0 Debug;Release;Publish From 5ba8521ce265f60b17ebc131e31b93377928ee40 Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 2 Apr 2026 15:14:43 -0700 Subject: [PATCH 08/31] Bump OpenTelemetry packages to fix NU1109 downgrade errors - OpenTelemetry/Api/Exporter.Console/Exporter.InMemory: 1.13.1 -> 1.15.0 - OpenTelemetry.Exporter.OpenTelemetryProtocol: already 1.15.0 - OpenTelemetry.Extensions.Hosting: already 1.14.0 - OpenTelemetry.Instrumentation.AspNetCore/Http: already 1.14.0 - OpenTelemetry.Instrumentation.Runtime: 1.13.0 -> 1.14.0 - Azure.Monitor.OpenTelemetry.Exporter: 1.4.0 -> 1.5.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index d5af9da0b6..2a7fc3ee9b 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -27,7 +27,7 @@ - + @@ -52,15 +52,15 @@ - - - - - - - - - + + + + + + + + + From 3b03c6dd5579ebf55a2a8afefc5ba381efd60e7d Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 2 Apr 2026 16:39:13 -0700 Subject: [PATCH 09/31] Fix CA1873: guard LogWarning with IsEnabled check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Hosting/AgentFrameworkResponseHandler.cs | 8 +++++--- .../Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs | 3 +-- .../Hosting/OutputConverter.cs | 2 +- .../Hosting/ServiceCollectionExtensions.cs | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs index acb8e9c556..3423a0b4a3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs @@ -1,10 +1,9 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading; -using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; using Azure.AI.AgentServer.Responses.Models; using Microsoft.Extensions.AI; @@ -151,7 +150,10 @@ public class AgentFrameworkResponseHandler : ResponseHandler return agent; } - this._logger.LogWarning("Agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName); + if (this._logger.IsEnabled(LogLevel.Warning)) + { + this._logger.LogWarning("Agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName); + } } // Try non-keyed default diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs index 49bd216a90..e6d607c793 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs @@ -1,9 +1,8 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Text.Json; using Azure.AI.AgentServer.Responses.Models; using Microsoft.Extensions.AI; diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/OutputConverter.cs index 1fd6672c83..79aaf768d9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/OutputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/OutputConverter.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs index bae8821745..d8e8a83f29 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using Azure.AI.AgentServer.Responses; From 21dc5311bea7f8238db6890449e73d8dbdd68e8f Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 2 Apr 2026 19:44:44 -0700 Subject: [PATCH 10/31] Fix model override bug and add client REPL sample - InputConverter: stop propagating request.Model to ChatOptions.ModelId Hosted agents use their own model; client-provided model values like 'hosted-agent' were being passed through and causing server errors. - Add FoundryResponsesRepl sample: interactive CLI client that connects to a Foundry Responses endpoint using ResponsesClient.AsAIAgent() - Bump package version to 0.9.0-hosted.260403.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/agent-framework-dotnet.slnx | 6 +- dotnet/nuget/nuget-package.props | 2 +- .../FoundryResponsesRepl.csproj | 21 ++++ .../FoundryResponsesRepl/Program.cs | 98 +++++++++++++++++++ .../Properties/launchSettings.json | 12 +++ .../Hosting/InputConverter.cs | 5 +- .../Hosting/InputConverterTests.cs | 7 +- 7 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 dotnet/samples/04-hosting/FoundryResponsesRepl/FoundryResponsesRepl.csproj create mode 100644 dotnet/samples/04-hosting/FoundryResponsesRepl/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryResponsesRepl/Properties/launchSettings.json diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index ab74c2650a..5d746ccd97 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -1,4 +1,4 @@ - + @@ -260,7 +260,9 @@ - + + + diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index b89b823219..6d1c657fa8 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -8,7 +8,7 @@ $(VersionPrefix)-preview.260402.1 $(VersionPrefix) - 0.9.0-hosted.260402.2 + 0.9.0-hosted.260403.1 1.0.0 Debug;Release;Publish diff --git a/dotnet/samples/04-hosting/FoundryResponsesRepl/FoundryResponsesRepl.csproj b/dotnet/samples/04-hosting/FoundryResponsesRepl/FoundryResponsesRepl.csproj new file mode 100644 index 0000000000..15cdf820eb --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryResponsesRepl/FoundryResponsesRepl.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + false + $(NoWarn);NU1903;NU1605 + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryResponsesRepl/Program.cs b/dotnet/samples/04-hosting/FoundryResponsesRepl/Program.cs new file mode 100644 index 0000000000..be0e6ccf7b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryResponsesRepl/Program.cs @@ -0,0 +1,98 @@ +// Foundry Responses Client REPL +// +// Connects to a Foundry Responses agent running on a given endpoint +// and provides an interactive multi-turn chat REPL. +// +// Usage: +// dotnet run (connects to http://localhost:8088) +// dotnet run -- --endpoint http://localhost:9090 +// dotnet run -- --endpoint https://my-foundry-project.services.ai.azure.com +// +// The endpoint should be running a Foundry Responses server (POST /responses). + +using System.ClientModel; +using Microsoft.Agents.AI; +using OpenAI; +using OpenAI.Responses; + +// ── Parse args ──────────────────────────────────────────────────────────────── + +string endpointUrl = "http://localhost:8088"; +for (int i = 0; i < args.Length - 1; i++) +{ + if (args[i] is "--endpoint" or "-e") + { + endpointUrl = args[i + 1]; + } +} + +// ── Create an agent-framework agent backed by the remote Responses endpoint ── + +// The OpenAI SDK's ResponsesClient can target any OpenAI-compatible endpoint. +// We use a dummy API key since our local server doesn't require auth. +var credential = new ApiKeyCredential( + Environment.GetEnvironmentVariable("RESPONSES_API_KEY") ?? "no-key-needed"); + +var openAiClient = new OpenAIClient( + credential, + new OpenAIClientOptions { Endpoint = new Uri(endpointUrl) }); + +ResponsesClient responsesClient = openAiClient.GetResponsesClient(); + +// Wrap as an agent-framework AIAgent via the OpenAI extensions. +// We pass an empty model since hosted agents use their own model configuration. +AIAgent agent = responsesClient.AsAIAgent( + model: "", + name: "remote-agent"); + +// Create a session so multi-turn context is preserved via previous_response_id +AgentSession session = await agent.CreateSessionAsync(); + +// ── REPL ────────────────────────────────────────────────────────────────────── + +Console.ForegroundColor = ConsoleColor.Cyan; +Console.WriteLine("╔══════════════════════════════════════════════════════════╗"); +Console.WriteLine("║ Foundry Responses Client REPL ║"); +Console.WriteLine($"║ Connected to: {endpointUrl,-41}║"); +Console.WriteLine("║ Type a message or 'quit' to exit ║"); +Console.WriteLine("╚══════════════════════════════════════════════════════════╝"); +Console.ResetColor(); +Console.WriteLine(); + +while (true) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("You> "); + Console.ResetColor(); + + string? input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) continue; + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase) || + input.Equals("exit", StringComparison.OrdinalIgnoreCase)) + break; + + try + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("Agent> "); + Console.ResetColor(); + + // Stream the response token-by-token + await foreach (var update in agent.RunStreamingAsync(input, session)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error: {ex.Message}"); + Console.ResetColor(); + } + + Console.WriteLine(); +} + +Console.WriteLine("Goodbye!"); diff --git a/dotnet/samples/04-hosting/FoundryResponsesRepl/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryResponsesRepl/Properties/launchSettings.json new file mode 100644 index 0000000000..4a939fc693 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryResponsesRepl/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "FoundryResponsesRepl": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:61980;http://localhost:61981" + } + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs index e6d607c793..10554d4e5c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs @@ -69,7 +69,10 @@ internal static class InputConverter Temperature = (float?)request.Temperature, TopP = (float?)request.TopP, MaxOutputTokens = (int?)request.MaxOutputTokens, - ModelId = request.Model, + // Note: We intentionally do NOT set ModelId from request.Model here. + // The hosted agent already has its own model configured, and passing + // the client-provided model would override it (causing failures when + // clients send placeholder values like "hosted-agent"). }; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs index 7f36fd10d7..48c1a22ee8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs @@ -157,7 +157,7 @@ public class InputConverterTests Assert.Equal(0.7f, options.Temperature); Assert.Equal(0.9f, options.TopP); Assert.Equal(1000, options.MaxOutputTokens); - Assert.Equal("gpt-4o", options.ModelId); + Assert.Null(options.ModelId); } [Fact] @@ -659,12 +659,13 @@ public class InputConverterTests } [Fact] - public void ConvertToChatOptions_ModelId_SetFromRequest() + public void ConvertToChatOptions_ModelId_NotSetFromRequest() { var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-model"); var options = InputConverter.ConvertToChatOptions(request); - Assert.Equal("my-model", options.ModelId); + // Model from the request is intentionally NOT propagated — the hosted agent uses its own model. + Assert.Null(options.ModelId); } } From eb673790338d3c977e1744e88280e8289cbb6bf7 Mon Sep 17 00:00:00 2001 From: alliscode Date: Fri, 3 Apr 2026 11:17:21 -0700 Subject: [PATCH 11/31] Catch agent errors and emit response.failed with real error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, unhandled exceptions from agent execution would bubble up to the SDK orchestrator, which emits a generic 'An internal server error occurred.' message — hiding the actual cause (e.g., 401 auth failures, model not found, etc.). Now AgentFrameworkResponseHandler catches non-cancellation exceptions and emits a proper response.failed event containing the real error message, making it visible to clients and in logs. OperationCanceledException still propagates for proper cancellation handling by the SDK. Also bumps package version to 0.9.0-hosted.260403.2. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/nuget/nuget-package.props | 2 +- .../FoundryResponsesHosting.csproj | 2 +- .../Hosting/AgentFrameworkResponseHandler.cs | 21 +++++++++++++++++++ .../AgentFrameworkResponseHandlerTests.cs | 19 ++++++++++------- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 6d1c657fa8..2cbd1356bc 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -8,7 +8,7 @@ $(VersionPrefix)-preview.260402.1 $(VersionPrefix) - 0.9.0-hosted.260403.1 + 0.9.0-hosted.260403.2 1.0.0 Debug;Release;Publish diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj b/dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj index 3dfab4ad48..269754203d 100644 --- a/dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj @@ -6,7 +6,7 @@ enable enable false - $(NoWarn);NU1903;NU1605 + $(NoWarn);NU1903;NU1605;MAAIW001 diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs index 3423a0b4a3..eaacbc67a3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs @@ -95,6 +95,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler while (true) { bool shutdownDetected = false; + ResponseStreamEvent? failedEvent = null; ResponseStreamEvent? evt = null; try { @@ -109,6 +110,26 @@ public class AgentFrameworkResponseHandler : ResponseHandler { shutdownDetected = true; } + catch (Exception ex) when (ex is not OperationCanceledException && !emittedTerminal) + { + // Catch agent execution errors and emit a proper failed event + // with the real error message instead of letting the SDK emit + // a generic "An internal server error occurred." + if (this._logger.IsEnabled(LogLevel.Error)) + { + this._logger.LogError(ex, "Agent execution failed for response {ResponseId}.", context.ResponseId); + } + + failedEvent = stream.EmitFailed( + ResponseErrorCode.ServerError, + ex.Message); + } + + if (failedEvent is not null) + { + yield return failedEvent; + yield break; + } if (shutdownDetected) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs index dc38c42739..b1bdd31916 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs @@ -538,7 +538,7 @@ public class AgentFrameworkResponseHandlerTests } [Fact] - public async Task CreateAsync_AgentThrows_ExceptionPropagates() + public async Task CreateAsync_AgentThrows_EmitsFailedEventWithErrorMessage() { // Arrange var agent = new ThrowingAgent(new InvalidOperationException("Agent crashed")); @@ -561,13 +561,18 @@ public class AgentFrameworkResponseHandlerTests mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - // Act & Assert - await Assert.ThrowsAsync(async () => + // Act — collect all events + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) { - await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) - { - } - }); + events.Add(evt); + } + + // Assert — should contain created, in_progress, and failed (with real error message) + Assert.Contains(events, e => e is ResponseCreatedEvent); + Assert.Contains(events, e => e is ResponseInProgressEvent); + var failedEvent = Assert.Single(events.OfType()); + Assert.Contains("Agent crashed", failedEvent.Response.Error.Message); } [Fact] From 23db9569ce9e4d4faceaec85fece61f4cfcae3ef Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 3 Apr 2026 16:35:44 -0700 Subject: [PATCH 12/31] Renaming and merging hosting extensions. (#5091) * Rename AddAgentFrameworkHandler to AddFoundryResponses and add MapFoundryResponses - Rename extension methods: AddAgentFrameworkHandler -> AddFoundryResponses, MapAgentFrameworkHandler -> MapFoundryResponses - AddFoundryResponses now calls AddResponsesServer() internally - Add MapFoundryResponses() extension on IEndpointRouteBuilder - Update sample and tests to use new API names - Remove redundant AddResponsesServer() and /ready endpoint from sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fixing numbering in sample. --------- Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FoundryResponsesHosting/Program.cs | 25 ++++----- .../Hosting/ServiceCollectionExtensions.cs | 51 ++++++++++++------- .../ServiceCollectionExtensionsTests.cs | 22 ++++---- 3 files changed, 53 insertions(+), 45 deletions(-) diff --git a/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs b/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs index 10a82d1b87..59fa723949 100644 --- a/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs +++ b/dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. // This sample demonstrates hosting agent-framework agents as Foundry Hosted Agents // using the Azure AI Responses Server SDK. @@ -16,7 +16,6 @@ // - AZURE_OPENAI_DEPLOYMENT - the model deployment name (default: "gpt-4o") using System.ComponentModel; -using Azure.AI.AgentServer.Responses; using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; @@ -29,22 +28,16 @@ using ModelContextProtocol.Client; var builder = WebApplication.CreateBuilder(args); // --------------------------------------------------------------------------- -// 1. Register the Azure AI Responses Server SDK +// 1. Create the shared Azure OpenAI chat client // --------------------------------------------------------------------------- -builder.Services.AddResponsesServer(); - -// --------------------------------------------------------------------------- -// 2. Create the shared Azure OpenAI chat client -// --------------------------------------------------------------------------- -var endpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.")); +var endpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.")); var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o"; var azureClient = new AzureOpenAIClient(endpoint, new DefaultAzureCredential()); IChatClient chatClient = azureClient.GetChatClient(deployment).AsIChatClient(); // --------------------------------------------------------------------------- -// 3. DEMO 1: Tool Agent — local tools + Microsoft Learn MCP +// 2. DEMO 1: Tool Agent — local tools + Microsoft Learn MCP // --------------------------------------------------------------------------- Console.WriteLine("Connecting to Microsoft Learn MCP server..."); McpClient mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new() @@ -72,7 +65,7 @@ builder.AddAIAgent( .WithAITools(mcpTools.Cast().ToArray()); // --------------------------------------------------------------------------- -// 4. DEMO 2: Triage Workflow — routes to specialist agents +// 3. DEMO 2: Triage Workflow — routes to specialist agents // --------------------------------------------------------------------------- ChatClientAgent triageAgent = new( chatClient, @@ -113,9 +106,9 @@ builder.AddAIAgent("triage-workflow", (_, key) => triageWorkflow.AsAIAgent(name: key)); // --------------------------------------------------------------------------- -// 5. Wire up the agent-framework handler as the IResponseHandler +// 4. Wire up the agent-framework handler and Responses Server SDK // --------------------------------------------------------------------------- -builder.Services.AddAgentFrameworkHandler(); +builder.Services.AddFoundryResponses(); var app = builder.Build(); @@ -124,10 +117,10 @@ app.Lifetime.ApplicationStopping.Register(() => mcpClient.DisposeAsync().AsTask().GetAwaiter().GetResult()); // --------------------------------------------------------------------------- -// 6. Routes +// 5. Routes // --------------------------------------------------------------------------- app.MapGet("/ready", () => Results.Ok("ready")); -app.MapResponsesServer(); +app.MapFoundryResponses(); app.MapGet("/", () => Results.Content(Pages.Home, "text/html")); app.MapGet("/tool-demo", () => Results.Content(Pages.ToolDemo, "text/html")); diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs index d8e8a83f29..e3ff61ad0c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs @@ -2,78 +2,93 @@ using System; using Azure.AI.AgentServer.Responses; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; namespace Microsoft.Agents.AI.Foundry.Hosting; /// -/// Extension methods for to register the agent-framework -/// response handler with the Azure AI Responses Server SDK. +/// Extension methods for registering agent-framework agents as Foundry Hosted Agents +/// using the Azure AI Responses Server SDK. /// -public static class AgentFrameworkResponsesServiceCollectionExtensions +public static class FoundryHostingExtensions { /// - /// Registers as the - /// for the Azure AI Responses Server SDK. Agents are resolved from keyed DI services + /// Registers the Azure AI Responses Server SDK and + /// as the . Agents are resolved from keyed DI services /// using the agent.name or metadata["entity_id"] from incoming requests. /// /// /// - /// Call this method after AddResponsesServer() and after registering your - /// instances (e.g., via AddAIAgent()). + /// This method calls AddResponsesServer() internally, so you do not need to + /// call it separately. Register your instances before calling this. /// /// /// Example: /// - /// builder.Services.AddResponsesServer(); /// builder.AddAIAgent("my-agent", ...); - /// builder.Services.AddAgentFrameworkHandler(); + /// builder.Services.AddFoundryResponses(); /// /// var app = builder.Build(); - /// app.MapResponsesServer(); + /// app.MapFoundryResponses(); /// /// /// /// The service collection. /// The service collection for chaining. - public static IServiceCollection AddAgentFrameworkHandler(this IServiceCollection services) + public static IServiceCollection AddFoundryResponses(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); + services.AddResponsesServer(); services.TryAddSingleton(); return services; } /// - /// Registers a specific as the handler for all incoming requests, - /// regardless of the agent.name in the request. + /// Registers the Azure AI Responses Server SDK and a specific + /// as the handler for all incoming requests, regardless of the agent.name in the request. /// /// /// /// Use this overload when hosting a single agent. The provided agent instance is - /// registered both as a keyed service and as the default . + /// registered as both a keyed service and the default . + /// This method calls AddResponsesServer() internally. /// /// /// Example: /// - /// builder.Services.AddResponsesServer(); - /// builder.Services.AddAgentFrameworkHandler(myAgent); + /// builder.Services.AddFoundryResponses(myAgent); /// /// var app = builder.Build(); - /// app.MapResponsesServer(); + /// app.MapFoundryResponses(); /// /// /// /// The service collection. /// The agent instance to register. /// The service collection for chaining. - public static IServiceCollection AddAgentFrameworkHandler(this IServiceCollection services, AIAgent agent) + public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(agent); + services.AddResponsesServer(); services.TryAddSingleton(agent); services.TryAddSingleton(); return services; } + + /// + /// Maps the Responses API routes for the agent-framework handler to the endpoint routing pipeline. + /// + /// The endpoint route builder. + /// Optional route prefix (e.g., "/openai/v1"). Default: empty (routes at /responses). + /// The endpoint route builder for chaining. + public static IEndpointRouteBuilder MapFoundryResponses(this IEndpointRouteBuilder endpoints, string prefix = "") + { + ArgumentNullException.ThrowIfNull(endpoints); + endpoints.MapResponsesServer(prefix); + return endpoints; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs index ee61cef71a..d3fffaed5a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs @@ -12,12 +12,12 @@ namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; public class ServiceCollectionExtensionsTests { [Fact] - public void AddAgentFrameworkHandler_RegistersResponseHandler() + public void AddFoundryResponses_RegistersResponseHandler() { var services = new ServiceCollection(); services.AddLogging(); - services.AddAgentFrameworkHandler(); + services.AddFoundryResponses(); var descriptor = services.FirstOrDefault( d => d.ServiceType == typeof(ResponseHandler)); @@ -26,33 +26,33 @@ public class ServiceCollectionExtensionsTests } [Fact] - public void AddAgentFrameworkHandler_CalledTwice_RegistersOnce() + public void AddFoundryResponses_CalledTwice_RegistersOnce() { var services = new ServiceCollection(); services.AddLogging(); - services.AddAgentFrameworkHandler(); - services.AddAgentFrameworkHandler(); + services.AddFoundryResponses(); + services.AddFoundryResponses(); var count = services.Count(d => d.ServiceType == typeof(ResponseHandler)); Assert.Equal(1, count); } [Fact] - public void AddAgentFrameworkHandler_NullServices_ThrowsArgumentNullException() + public void AddFoundryResponses_NullServices_ThrowsArgumentNullException() { Assert.Throws( - () => AgentFrameworkResponsesServiceCollectionExtensions.AddAgentFrameworkHandler(null!)); + () => FoundryHostingExtensions.AddFoundryResponses(null!)); } [Fact] - public void AddAgentFrameworkHandler_WithAgent_RegistersAgentAndHandler() + public void AddFoundryResponses_WithAgent_RegistersAgentAndHandler() { var services = new ServiceCollection(); services.AddLogging(); var mockAgent = new Mock(); - services.AddAgentFrameworkHandler(mockAgent.Object); + services.AddFoundryResponses(mockAgent.Object); var handlerDescriptor = services.FirstOrDefault( d => d.ServiceType == typeof(ResponseHandler)); @@ -64,10 +64,10 @@ public class ServiceCollectionExtensionsTests } [Fact] - public void AddAgentFrameworkHandler_WithNullAgent_ThrowsArgumentNullException() + public void AddFoundryResponses_WithNullAgent_ThrowsArgumentNullException() { var services = new ServiceCollection(); Assert.Throws( - () => services.AddAgentFrameworkHandler(null!)); + () => services.AddFoundryResponses(null!)); } } From 53dd99018599fb0e93e1ccc65cf4d9790d1307b3 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:30:43 +0100 Subject: [PATCH 13/31] Address breaking changes in 260408 --- dotnet/Directory.Packages.props | 6 +- .../Hosting/AgentFrameworkResponseHandler.cs | 4 +- .../Hosting/InputConverter.cs | 21 +++++ .../AgentFrameworkResponseHandlerTests.cs | 77 +++++++++---------- .../Hosting/WorkflowIntegrationTests.cs | 4 +- 5 files changed, 64 insertions(+), 48 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 2a7fc3ee9b..11628f4e38 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -19,9 +19,9 @@ - - - + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs index eaacbc67a3..40ad435382 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs @@ -66,10 +66,10 @@ public class AgentFrameworkResponseHandler : ResponseHandler } // Load and convert current input items - var inputItems = await context.GetInputItemsAsync(cancellationToken).ConfigureAwait(false); + var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); if (inputItems.Count > 0) { - messages.AddRange(InputConverter.ConvertOutputItemsToMessages(inputItems)); + messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems)); } else { diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs index 10554d4e5c..1d8be8f590 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InputConverter.cs @@ -36,6 +36,27 @@ internal static class InputConverter return messages; } + /// + /// Converts resolved SDK input items into instances. + /// + /// The resolved input items from the SDK context. + /// A list of chat messages. + public static List ConvertItemsToMessages(IReadOnlyList items) + { + var messages = new List(); + + foreach (var item in items) + { + var message = ConvertInputItemToMessage(item); + if (message is not null) + { + messages.Add(message); + } + } + + return messages; + } + /// /// Converts resolved SDK history/input items into instances. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs index b1bdd31916..eac5c60263 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -43,8 +43,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); // Act var events = new List(); @@ -82,8 +82,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); // Act var events = new List(); @@ -116,8 +116,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); // Act & Assert await Assert.ThrowsAsync(async () => @@ -164,8 +164,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); // Act var events = new List(); @@ -203,8 +203,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); // Act var events = new List(); @@ -241,8 +241,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); // Act var events = new List(); @@ -277,8 +277,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); // Act & Assert var ex = await Assert.ThrowsAsync(async () => @@ -310,8 +310,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); // Act & Assert var ex = await Assert.ThrowsAsync(async () => @@ -343,8 +343,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); // Act var events = new List(); @@ -396,8 +396,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(new OutputItem[] { historyItem }); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); // Act var events = new List(); @@ -431,20 +431,15 @@ public class AgentFrameworkResponseHandlerTests content = new[] { new { type = "input_text", text = "Raw input" } } } }); - var inputItem = new OutputItemMessage( - id: "input_1", - role: MessageRole.Assistant, - content: [new MessageContentOutputTextContent( - "Resolved input", - Array.Empty(), - Array.Empty())], - status: MessageStatus.Completed); + var inputItem = new ItemMessage( + MessageRole.Assistant, + [new MessageContentInputTextContent("Resolved input")]); var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(new OutputItem[] { inputItem }); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Item[] { inputItem }); // Act var events = new List(); @@ -481,8 +476,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); // Act var events = new List(); @@ -521,8 +516,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); // Act var events = new List(); @@ -558,8 +553,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); // Act — collect all events var events = new List(); @@ -600,8 +595,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); // Act var events = new List(); @@ -636,8 +631,8 @@ public class AgentFrameworkResponseHandlerTests var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); using var cts = new CancellationTokenSource(); cts.Cancel(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs index 66924bdcba..be78f8046d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs @@ -383,8 +383,8 @@ public class WorkflowIntegrationTests var mock = new Mock("resp_" + new string('0', 46)) { CallBase = true }; mock.Setup(x => x.GetHistoryAsync(It.IsAny())) .ReturnsAsync(Array.Empty()); - mock.Setup(x => x.GetInputItemsAsync(It.IsAny())) - .ReturnsAsync(Array.Empty()); + mock.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); return mock; } From 176b8f9b3419b08ffd163b190633701541d5c6a0 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:46:10 +0100 Subject: [PATCH 14/31] Bump hosted internal package version --- dotnet/nuget/nuget-package.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 2cbd1356bc..27e2bbea9e 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -8,7 +8,7 @@ $(VersionPrefix)-preview.260402.1 $(VersionPrefix) - 0.9.0-hosted.260403.2 + 0.9.0-hosted.260409.1 1.0.0 Debug;Release;Publish From fed3c06817e89b6dc348541dc3dff9a982b6e86f Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:40:54 +0100 Subject: [PATCH 15/31] Add UserAgent middleware tests for Foundry hosting --- .../Hosting/ServiceCollectionExtensions.cs | 54 +++++++ .../Hosting/UserAgentMiddlewareTests.cs | 134 ++++++++++++++++++ ...crosoft.Agents.AI.Foundry.UnitTests.csproj | 1 + 3 files changed, 189 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs index e3ff61ad0c..7f01e5904c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs @@ -1,7 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Reflection; +using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -89,6 +93,56 @@ public static class FoundryHostingExtensions { ArgumentNullException.ThrowIfNull(endpoints); endpoints.MapResponsesServer(prefix); + + if (endpoints is IApplicationBuilder app) + { + // Ensure the middleware is added to the pipeline + app.UseMiddleware(); + } + return endpoints; } + + private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next) + { + private static readonly string s_userAgentValue = CreateUserAgentValue(); + + public async Task InvokeAsync(HttpContext context) + { + var headers = context.Request.Headers; + var userAgent = headers.UserAgent.ToString(); + + if (string.IsNullOrEmpty(userAgent)) + { + headers.UserAgent = s_userAgentValue; + } + else if (!userAgent.Contains(s_userAgentValue, StringComparison.OrdinalIgnoreCase)) + { + headers.UserAgent = $"{userAgent} {s_userAgentValue}"; + } + + await next(context).ConfigureAwait(false); + } + + private static string CreateUserAgentValue() + { + const string Name = "agent-framework-dotnet"; + + if (typeof(AgentFrameworkUserAgentMiddleware).Assembly.GetCustomAttribute()?.InformationalVersion is string version) + { + int pos = version.IndexOf('+'); + if (pos >= 0) + { + version = version.Substring(0, pos); + } + + if (version.Length > 0) + { + return $"{Name}/{version}"; + } + } + + return Name; + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs new file mode 100644 index 0000000000..64f7458852 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +/// +/// Tests for the AgentFrameworkUserAgentMiddleware registered by +/// . +/// +public sealed partial class UserAgentMiddlewareTests : IAsyncDisposable +{ + private const string VersionedUserAgentPattern = @"agent-framework-dotnet/\d+\.\d+\.\d+"; + + private WebApplication? _app; + private HttpClient? _httpClient; + + public async ValueTask DisposeAsync() + { + this._httpClient?.Dispose(); + if (this._app != null) + { + await this._app.DisposeAsync(); + } + } + + [Fact] + public async Task MapFoundryResponses_NoUserAgentHeader_SetsAgentFrameworkUserAgentAsync() + { + // Arrange + await this.CreateTestServerAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + + // Act + var response = await this._httpClient!.SendAsync(request); + var userAgent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Matches(VersionedUserAgentPattern, userAgent); + } + + [Fact] + public async Task MapFoundryResponses_WithExistingUserAgent_AppendsAgentFrameworkUserAgentAsync() + { + // Arrange + await this.CreateTestServerAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + request.Headers.TryAddWithoutValidation("User-Agent", "MyApp/1.0"); + + // Act + var response = await this._httpClient!.SendAsync(request); + var userAgent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.StartsWith("MyApp/1.0", userAgent); + Assert.Matches(VersionedUserAgentPattern, userAgent); + } + + [Fact] + public async Task MapFoundryResponses_AlreadyContainsUserAgent_DoesNotDuplicateAsync() + { + // Arrange + await this.CreateTestServerAsync(); + + // First request to capture the actual middleware-generated value + using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + var firstResponse = await this._httpClient!.SendAsync(firstRequest); + var middlewareValue = await firstResponse.Content.ReadAsStringAsync(); + + // Act: send a second request that already contains the middleware value + using var secondRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + secondRequest.Headers.TryAddWithoutValidation("User-Agent", $"MyApp/2.0 {middlewareValue}"); + var secondResponse = await this._httpClient!.SendAsync(secondRequest); + var userAgent = await secondResponse.Content.ReadAsStringAsync(); + + // Assert: should remain unchanged (no duplication) + Assert.Equal($"MyApp/2.0 {middlewareValue}", userAgent); + Assert.Single(VersionedUserAgentRegex().Matches(userAgent)); + } + + [Fact] + public async Task MapFoundryResponses_UserAgentValue_ContainsVersionAsync() + { + // Arrange + await this.CreateTestServerAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + + // Act + var response = await this._httpClient!.SendAsync(request); + var userAgent = await response.Content.ReadAsStringAsync(); + + // Assert: should match "agent-framework-dotnet/x.y.z" pattern + Assert.Matches(VersionedUserAgentPattern, userAgent); + } + + private async Task CreateTestServerAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var mockAgent = new Mock(); + builder.Services.AddFoundryResponses(mockAgent.Object); + + this._app = builder.Build(); + this._app.MapFoundryResponses(); + + // Test endpoint that echoes the User-Agent header after middleware processing + this._app.MapGet("/test-ua", (HttpContext ctx) => + Results.Text(ctx.Request.Headers.UserAgent.ToString())); + + await this._app.StartAsync(); + + var testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + this._httpClient = testServer.CreateClient(); + } + + [GeneratedRegex(VersionedUserAgentPattern)] + private static partial Regex VersionedUserAgentRegex(); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index 6473b799bf..f006096208 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -12,6 +12,7 @@ + From 7407c0a35ed2e4cb69fdcf9bbe0db5dfb07d7bdc Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:57:16 +0100 Subject: [PATCH 16/31] Hosting Samples update --- dotnet/Directory.Packages.props | 1 + dotnet/agent-framework-dotnet.slnx | 38 +++++++---- .../AgentThreadAndHITL.csproj | 0 .../AgentThreadAndHITL/Dockerfile | 0 .../AgentThreadAndHITL/Program.cs | 0 .../AgentThreadAndHITL/README.md | 0 .../AgentThreadAndHITL/agent.yaml | 0 .../AgentThreadAndHITL/run-requests.http | 0 .../AgentWithHostedMCP.csproj | 0 .../AgentWithHostedMCP/Dockerfile | 0 .../AgentWithHostedMCP/Program.cs | 0 .../AgentWithHostedMCP/README.md | 0 .../AgentWithHostedMCP/agent.yaml | 0 .../AgentWithHostedMCP/run-requests.http | 0 .../AgentWithLocalTools/.dockerignore | 0 .../AgentWithLocalTools.csproj | 0 .../AgentWithLocalTools/Dockerfile | 0 .../AgentWithLocalTools/Program.cs | 0 .../AgentWithLocalTools/README.md | 0 .../AgentWithLocalTools/agent.yaml | 0 .../AgentWithLocalTools/run-requests.http | 0 .../AgentWithTextSearchRag.csproj | 0 .../AgentWithTextSearchRag/Dockerfile | 0 .../AgentWithTextSearchRag/Program.cs | 0 .../AgentWithTextSearchRag/README.md | 0 .../AgentWithTextSearchRag/agent.yaml | 0 .../AgentWithTextSearchRag/run-requests.http | 0 .../AgentsInWorkflows.csproj | 0 .../AgentsInWorkflows/Dockerfile | 0 .../AgentsInWorkflows/Program.cs | 0 .../AgentsInWorkflows/README.md | 0 .../AgentsInWorkflows/agent.yaml | 0 .../AgentsInWorkflows/run-requests.http | 0 .../FoundryMultiAgent/Dockerfile | 0 .../FoundryMultiAgent.csproj | 0 .../FoundryMultiAgent/Program.cs | 0 .../FoundryMultiAgent/README.md | 0 .../FoundryMultiAgent/agent.yaml | 0 .../appsettings.Development.json | 0 .../FoundryMultiAgent/run-requests.http | 0 .../FoundrySingleAgent/Dockerfile | 0 .../FoundrySingleAgent.csproj | 0 .../FoundrySingleAgent/Program.cs | 0 .../FoundrySingleAgent/README.md | 0 .../FoundrySingleAgent/agent.yaml | 0 .../FoundrySingleAgent/run-requests.http | 0 .../HostedAgentsV1}/README.md | 0 .../HostedAgentsV2/consumption/Program.cs | 68 +++++++++++++++++++ .../consumption/SimpleAgent.csproj | 22 ++++++ .../HostedAgentsV2/foundry-hosting/Dockerfile | 17 +++++ .../HostedAgentsV2/foundry-hosting/Program.cs | 30 ++++++++ .../Properties/launchSettings.json | 12 ++++ .../HostedAgentsV2/foundry-hosting/agent.yaml | 20 ++++++ .../simple-agent-foundry.csproj | 25 +++++++ .../instance-hosting/Dockerfile | 17 +++++ .../HostedChatClientAgent.csproj | 25 +++++++ .../instance-hosting/Program.cs | 33 +++++++++ .../Properties/launchSettings.json | 12 ++++ .../instance-hosting/agent.yaml | 20 ++++++ .../AzureAIProjectChatClientExtensions.cs | 8 +-- 60 files changed, 329 insertions(+), 19 deletions(-) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentThreadAndHITL/AgentThreadAndHITL.csproj (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentThreadAndHITL/Dockerfile (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentThreadAndHITL/Program.cs (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentThreadAndHITL/README.md (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentThreadAndHITL/agent.yaml (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentThreadAndHITL/run-requests.http (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithHostedMCP/AgentWithHostedMCP.csproj (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithHostedMCP/Dockerfile (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithHostedMCP/Program.cs (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithHostedMCP/README.md (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithHostedMCP/agent.yaml (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithHostedMCP/run-requests.http (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithLocalTools/.dockerignore (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithLocalTools/AgentWithLocalTools.csproj (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithLocalTools/Dockerfile (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithLocalTools/Program.cs (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithLocalTools/README.md (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithLocalTools/agent.yaml (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithLocalTools/run-requests.http (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithTextSearchRag/Dockerfile (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithTextSearchRag/Program.cs (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithTextSearchRag/README.md (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithTextSearchRag/agent.yaml (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentWithTextSearchRag/run-requests.http (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentsInWorkflows/AgentsInWorkflows.csproj (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentsInWorkflows/Dockerfile (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentsInWorkflows/Program.cs (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentsInWorkflows/README.md (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentsInWorkflows/agent.yaml (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/AgentsInWorkflows/run-requests.http (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/FoundryMultiAgent/Dockerfile (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/FoundryMultiAgent/FoundryMultiAgent.csproj (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/FoundryMultiAgent/Program.cs (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/FoundryMultiAgent/README.md (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/FoundryMultiAgent/agent.yaml (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/FoundryMultiAgent/appsettings.Development.json (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/FoundryMultiAgent/run-requests.http (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/FoundrySingleAgent/Dockerfile (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/FoundrySingleAgent/FoundrySingleAgent.csproj (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/FoundrySingleAgent/Program.cs (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/FoundrySingleAgent/README.md (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/FoundrySingleAgent/agent.yaml (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/FoundrySingleAgent/run-requests.http (100%) rename dotnet/samples/{05-end-to-end/HostedAgents => 04-hosting/FoundryHostedAgents/HostedAgentsV1}/README.md (100%) create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/SimpleAgent.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Properties/launchSettings.json create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/agent.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/simple-agent-foundry.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/HostedChatClientAgent.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Properties/launchSettings.json create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/agent.yaml diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 11628f4e38..36e68c958a 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -27,6 +27,7 @@ + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 5d746ccd97..f52c04eb7a 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -263,6 +263,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -315,15 +335,6 @@ - - - - - - - - - @@ -485,13 +496,12 @@ - - + @@ -513,11 +523,10 @@ - + - @@ -533,12 +542,11 @@ - - + diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentThreadAndHITL/AgentThreadAndHITL.csproj similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentThreadAndHITL/AgentThreadAndHITL.csproj diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentThreadAndHITL/Dockerfile similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Dockerfile rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentThreadAndHITL/Dockerfile diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentThreadAndHITL/Program.cs similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Program.cs rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentThreadAndHITL/Program.cs diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentThreadAndHITL/README.md similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/README.md rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentThreadAndHITL/README.md diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentThreadAndHITL/agent.yaml similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/agent.yaml rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentThreadAndHITL/agent.yaml diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/run-requests.http b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentThreadAndHITL/run-requests.http similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/run-requests.http rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentThreadAndHITL/run-requests.http diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithHostedMCP/AgentWithHostedMCP.csproj similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithHostedMCP/AgentWithHostedMCP.csproj diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithHostedMCP/Dockerfile similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Dockerfile rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithHostedMCP/Dockerfile diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithHostedMCP/Program.cs similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithHostedMCP/Program.cs diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithHostedMCP/README.md similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/README.md rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithHostedMCP/README.md diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithHostedMCP/agent.yaml similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/agent.yaml rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithHostedMCP/agent.yaml diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/run-requests.http b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithHostedMCP/run-requests.http similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/run-requests.http rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithHostedMCP/run-requests.http diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/.dockerignore b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithLocalTools/.dockerignore similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/.dockerignore rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithLocalTools/.dockerignore diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithLocalTools/AgentWithLocalTools.csproj similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithLocalTools/AgentWithLocalTools.csproj diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithLocalTools/Dockerfile similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Dockerfile rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithLocalTools/Dockerfile diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithLocalTools/Program.cs similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithLocalTools/Program.cs diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithLocalTools/README.md similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithLocalTools/README.md diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithLocalTools/agent.yaml similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/agent.yaml rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithLocalTools/agent.yaml diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/run-requests.http b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithLocalTools/run-requests.http similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/run-requests.http rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithLocalTools/run-requests.http diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithTextSearchRag/Dockerfile similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Dockerfile rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithTextSearchRag/Dockerfile diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithTextSearchRag/Program.cs similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Program.cs rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithTextSearchRag/Program.cs diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithTextSearchRag/README.md similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/README.md rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithTextSearchRag/README.md diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithTextSearchRag/agent.yaml similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/agent.yaml rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithTextSearchRag/agent.yaml diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/run-requests.http b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithTextSearchRag/run-requests.http similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/run-requests.http rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentWithTextSearchRag/run-requests.http diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentsInWorkflows/AgentsInWorkflows.csproj similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentsInWorkflows/AgentsInWorkflows.csproj diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentsInWorkflows/Dockerfile similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Dockerfile rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentsInWorkflows/Dockerfile diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentsInWorkflows/Program.cs similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Program.cs rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentsInWorkflows/Program.cs diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentsInWorkflows/README.md similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/README.md rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentsInWorkflows/README.md diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentsInWorkflows/agent.yaml similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/agent.yaml rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentsInWorkflows/agent.yaml diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/run-requests.http b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentsInWorkflows/run-requests.http similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/run-requests.http rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/AgentsInWorkflows/run-requests.http diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundryMultiAgent/Dockerfile similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Dockerfile rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundryMultiAgent/Dockerfile diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundryMultiAgent/FoundryMultiAgent.csproj similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundryMultiAgent/FoundryMultiAgent.csproj diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundryMultiAgent/Program.cs similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundryMultiAgent/Program.cs diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundryMultiAgent/README.md similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundryMultiAgent/README.md diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundryMultiAgent/agent.yaml similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundryMultiAgent/agent.yaml diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/appsettings.Development.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundryMultiAgent/appsettings.Development.json similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/appsettings.Development.json rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundryMultiAgent/appsettings.Development.json diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/run-requests.http b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundryMultiAgent/run-requests.http similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/run-requests.http rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundryMultiAgent/run-requests.http diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundrySingleAgent/Dockerfile similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Dockerfile rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundrySingleAgent/Dockerfile diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundrySingleAgent/FoundrySingleAgent.csproj similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundrySingleAgent/FoundrySingleAgent.csproj diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundrySingleAgent/Program.cs similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundrySingleAgent/Program.cs diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundrySingleAgent/README.md similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundrySingleAgent/README.md diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundrySingleAgent/agent.yaml similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/agent.yaml rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundrySingleAgent/agent.yaml diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/run-requests.http b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundrySingleAgent/run-requests.http similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/run-requests.http rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/FoundrySingleAgent/run-requests.http diff --git a/dotnet/samples/05-end-to-end/HostedAgents/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/README.md similarity index 100% rename from dotnet/samples/05-end-to-end/HostedAgents/README.md rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV1/README.md diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/Program.cs new file mode 100644 index 0000000000..f1c3686279 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/Program.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +string agentEndpoint = Environment.GetEnvironmentVariable("AGENT_ENDPOINT") ?? "http://localhost:8088"; + +// ── Create an agent-framework agent backed by the remote agent endpoint ────── +// The Foundry Agent SDK's AIProjectClient can target any OpenAI-compatible endpoint. + +var aiProjectClient = new AIProjectClient(new Uri(agentEndpoint), new AzureCliCredential()); +var agent = aiProjectClient.AsAIAgent(); + +AgentSession session = await agent.CreateSessionAsync(); + +// ── REPL ────────────────────────────────────────────────────────────────────── + +Console.ForegroundColor = ConsoleColor.Cyan; +Console.WriteLine("╔══════════════════════════════════════════════════════════╗"); +Console.WriteLine("║ Simple Agent Client ║"); +Console.WriteLine($"║ Connected to: {agentEndpoint,-41}║"); +Console.WriteLine("║ Type a message or 'quit' to exit ║"); +Console.WriteLine("╚══════════════════════════════════════════════════════════╝"); +Console.ResetColor(); +Console.WriteLine(); + +while (true) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("You> "); + Console.ResetColor(); + + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) { continue; } + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase) || + input.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { break; } + + try + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("Agent> "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(input, session)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error: {ex.Message}"); + Console.ResetColor(); + } + + Console.WriteLine(); +} + +Console.WriteLine("Goodbye!"); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/SimpleAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/SimpleAgent.csproj new file mode 100644 index 0000000000..d2651ef7a7 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/SimpleAgent.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + false + SimpleAgentClient + simple-agent-client + $(NoWarn);NU1903;NU1605;OPENAI001 + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Dockerfile new file mode 100644 index 0000000000..2898f31ed0 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "simple-agent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Program.cs new file mode 100644 index 0000000000..ad0f2b2ca2 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Program.cs @@ -0,0 +1,30 @@ +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); + +var aiProjectClient = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()); + +// Retrieve the Foundry-managed agent by name (latest version). +ProjectsAgentRecord agentRecord = await aiProjectClient + .AgentAdministrationClient.GetAgentAsync(agentName); + +AIAgent agent = aiProjectClient.AsAIAgent(agentRecord); + +// Host the agent as a Foundry Hosted Agent using the Responses API. +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); +app.Run(); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Properties/launchSettings.json new file mode 100644 index 0000000000..fc4cf2a105 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "simple-agent-foundry": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:59703;http://localhost:59708" + } + } +} \ No newline at end of file diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/agent.yaml new file mode 100644 index 0000000000..a0fe89f966 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/agent.yaml @@ -0,0 +1,20 @@ +name: simple-agent +description: > + A simple general-purpose AI assistant hosted as a Foundry Hosted Agent, + backed by a Foundry-managed agent definition. +metadata: + tags: + - AI Agent Hosting + - Simple Agent + - Foundry Agent +template: + name: simple-agent + kind: hosted + protocols: + - protocol: responses + version: v1 + environment_variables: + - name: AZURE_AI_PROJECT_ENDPOINT + value: ${AZURE_AI_PROJECT_ENDPOINT} + - name: AGENT_NAME + value: ${AGENT_NAME} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/simple-agent-foundry.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/simple-agent-foundry.csproj new file mode 100644 index 0000000000..a9adf5bb21 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/simple-agent-foundry.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + SimpleAgent + simple-agent + $(NoWarn);NU1903;NU1605 + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Dockerfile new file mode 100644 index 0000000000..2898f31ed0 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "simple-agent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/HostedChatClientAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/HostedChatClientAgent.csproj new file mode 100644 index 0000000000..a9adf5bb21 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/HostedChatClientAgent.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + SimpleAgent + simple-agent + $(NoWarn);NU1903;NU1605 + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Program.cs new file mode 100644 index 0000000000..8571f0a1f0 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Program.cs @@ -0,0 +1,33 @@ +using Azure.AI.Projects; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); +var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; + +// Create the agent via the AI project client using the Responses API. +AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()) + .AsAIAgent( + model: deployment, + instructions: """ + You are a helpful AI assistant hosted as a Foundry Hosted Agent. + You can help with a wide range of tasks including answering questions, + providing explanations, brainstorming ideas, and offering guidance. + Be concise, clear, and helpful in your responses. + """, + name: "simple-agent", + description: "A simple general-purpose AI assistant"); + +// Host the agent as a Foundry Hosted Agent using the Responses API. +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); +app.Run(); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Properties/launchSettings.json new file mode 100644 index 0000000000..5f47fe2db6 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Hosted-ChatClientAgent": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:59054;http://localhost:59055" + } + } +} \ No newline at end of file diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/agent.yaml new file mode 100644 index 0000000000..dfab24e712 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/agent.yaml @@ -0,0 +1,20 @@ +name: simple-agent +description: > + A simple general-purpose AI assistant hosted as a Foundry Hosted Agent. +metadata: + tags: + - AI Agent Hosting + - Simple Agent +template: + name: simple-agent + kind: hosted + protocols: + - protocol: responses + version: v1 + environment_variables: + - name: AZURE_AI_PROJECT_ENDPOINT + value: ${AZURE_AI_PROJECT_ENDPOINT} + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: AGENT_NAME + value: ${AGENT_NAME} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs index 4383cfb6d4..4b895e4bc0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs @@ -13,6 +13,7 @@ using Microsoft.Agents.AI; using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; using OpenAI.Responses; @@ -181,7 +182,7 @@ public static partial class AzureAIProjectChatClientExtensions /// Creates a non-versioned backed by the project's Responses API using the specified options. /// /// The to use for Responses API calls. Cannot be . - /// Configuration options that control the agent's behavior. is required. + /// Optional configuration options that control the agent's behavior. /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for creating loggers used by the agent. /// An optional to use for resolving services required by the instances being invoked. @@ -190,15 +191,14 @@ public static partial class AzureAIProjectChatClientExtensions /// Thrown when does not specify . public static ChatClientAgent AsAIAgent( this AIProjectClient aiProjectClient, - ChatClientAgentOptions options, + ChatClientAgentOptions? options = null, Func? clientFactory = null, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null) { Throw.IfNull(aiProjectClient); - Throw.IfNull(options); - return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services); + return CreateResponsesChatClientAgent(aiProjectClient, options ?? new(), clientFactory, loggerFactory, services); } #region Private From 4ab43734c65d355efdb3deb203fcacb9141ffc55 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:35:53 +0100 Subject: [PATCH 17/31] Hosting Samples update --- .../HostedAgentsV2/instance-hosting/Program.cs | 2 ++ .../instance-hosting/Properties/launchSettings.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Program.cs index 8571f0a1f0..07c7103c1a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Program.cs @@ -1,3 +1,5 @@ +// Copyright (c) Microsoft. All rights reserved. + using Azure.AI.Projects; using Azure.Identity; using DotNetEnv; diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Properties/launchSettings.json index 5f47fe2db6..b7f2c658ac 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Properties/launchSettings.json +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Properties/launchSettings.json @@ -6,7 +6,7 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, - "applicationUrl": "https://localhost:59054;http://localhost:59055" + "applicationUrl": "http://localhost:8088" } } } \ No newline at end of file From 026f71ac32f3790b421d9c8ca3f32421512c1d66 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:36:00 +0100 Subject: [PATCH 18/31] Hosting Samples update --- dotnet/agent-framework-dotnet.slnx | 8 ++++---- .../HostedAgentsV2/consumption/Program.cs | 18 +++++++++--------- ...oundry.csproj => HostedFoundryAgent.csproj} | 0 .../HostedAgentsV2/foundry-hosting/Program.cs | 6 ++++-- .../Properties/launchSettings.json | 2 +- 5 files changed, 18 insertions(+), 16 deletions(-) rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/{simple-agent-foundry.csproj => HostedFoundryAgent.csproj} (100%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index f52c04eb7a..16778e2278 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -274,14 +274,14 @@ - - - - + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/Program.cs index f1c3686279..f0a1e74069 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/Program.cs @@ -8,7 +8,7 @@ using Microsoft.Agents.AI; // Load .env file if present (for local development) Env.TraversePath().Load(); -string agentEndpoint = Environment.GetEnvironmentVariable("AGENT_ENDPOINT") ?? "http://localhost:8088"; +string agentEndpoint = Environment.GetEnvironmentVariable("AGENT_ENDPOINT") ?? "http://localhost:59055"; // ── Create an agent-framework agent backed by the remote agent endpoint ────── // The Foundry Agent SDK's AIProjectClient can target any OpenAI-compatible endpoint. @@ -21,11 +21,13 @@ AgentSession session = await agent.CreateSessionAsync(); // ── REPL ────────────────────────────────────────────────────────────────────── Console.ForegroundColor = ConsoleColor.Cyan; -Console.WriteLine("╔══════════════════════════════════════════════════════════╗"); -Console.WriteLine("║ Simple Agent Client ║"); -Console.WriteLine($"║ Connected to: {agentEndpoint,-41}║"); -Console.WriteLine("║ Type a message or 'quit' to exit ║"); -Console.WriteLine("╚══════════════════════════════════════════════════════════╝"); +Console.WriteLine($""" + ══════════════════════════════════════════════════════════ + Simple Agent Sample + Connected to: {agentEndpoint} + Type a message or 'quit' to exit + ══════════════════════════════════════════════════════════ + """); Console.ResetColor(); Console.WriteLine(); @@ -38,9 +40,7 @@ while (true) string? input = Console.ReadLine(); if (string.IsNullOrWhiteSpace(input)) { continue; } - if (input.Equals("quit", StringComparison.OrdinalIgnoreCase) || - input.Equals("exit", StringComparison.OrdinalIgnoreCase)) - { break; } + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } try { diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/simple-agent-foundry.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/HostedFoundryAgent.csproj similarity index 100% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/simple-agent-foundry.csproj rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/HostedFoundryAgent.csproj diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Program.cs index ad0f2b2ca2..0fe1ad9ac3 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Program.cs @@ -1,8 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using DotNetEnv; -using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Agents.AI.Foundry.Hosting; // Load .env file if present (for local development) @@ -19,7 +21,7 @@ var aiProjectClient = new AIProjectClient(projectEndpoint, new DefaultAzureCrede ProjectsAgentRecord agentRecord = await aiProjectClient .AgentAdministrationClient.GetAgentAsync(agentName); -AIAgent agent = aiProjectClient.AsAIAgent(agentRecord); +FoundryAgent agent = aiProjectClient.AsAIAgent(agentRecord); // Host the agent as a Foundry Hosted Agent using the Responses API. var builder = WebApplication.CreateBuilder(args); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Properties/launchSettings.json index fc4cf2a105..11588cf909 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Properties/launchSettings.json +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Properties/launchSettings.json @@ -6,7 +6,7 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, - "applicationUrl": "https://localhost:59703;http://localhost:59708" + "applicationUrl": "http://localhost:8089" } } } \ No newline at end of file From eb7f8617c1f7bf6b36b004fa209abc11f70b64ab Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:05:44 +0100 Subject: [PATCH 19/31] Hosting Samples update --- dotnet/agent-framework-dotnet.slnx | 6 +++--- .../Dockerfile | 2 +- .../HostedChatClientAgent.csproj | 10 +++------- .../Program.cs | 0 .../Properties/launchSettings.json | 0 .../agent.yaml | 0 .../Dockerfile | 2 +- .../HostedFoundryAgent.csproj | 10 +++------- .../Program.cs | 0 .../Properties/launchSettings.json | 2 +- .../agent.yaml | 0 .../{consumption => UsingHostedAgent}/Program.cs | 2 +- .../SimpleAgent.csproj | 0 13 files changed, 13 insertions(+), 21 deletions(-) rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/{instance-hosting => Hosted-ChatClientAgent}/Dockerfile (88%) rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/{instance-hosting => Hosted-ChatClientAgent}/HostedChatClientAgent.csproj (59%) rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/{instance-hosting => Hosted-ChatClientAgent}/Program.cs (100%) rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/{instance-hosting => Hosted-ChatClientAgent}/Properties/launchSettings.json (100%) rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/{instance-hosting => Hosted-ChatClientAgent}/agent.yaml (100%) rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/{foundry-hosting => Hosted-FoundryAgent}/Dockerfile (88%) rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/{foundry-hosting => Hosted-FoundryAgent}/HostedFoundryAgent.csproj (59%) rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/{foundry-hosting => Hosted-FoundryAgent}/Program.cs (100%) rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/{foundry-hosting => Hosted-FoundryAgent}/Properties/launchSettings.json (81%) rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/{foundry-hosting => Hosted-FoundryAgent}/agent.yaml (100%) rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/{consumption => UsingHostedAgent}/Program.cs (98%) rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/{consumption => UsingHostedAgent}/SimpleAgent.csproj (100%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 16778e2278..969a5c16d8 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -275,13 +275,13 @@ - + - + - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Dockerfile similarity index 88% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Dockerfile rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Dockerfile index 2898f31ed0..6f1be8ee8e 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Dockerfile +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Dockerfile @@ -14,4 +14,4 @@ WORKDIR /app COPY --from=build /app/publish . EXPOSE 8088 ENV ASPNETCORE_URLS=http://+:8088 -ENTRYPOINT ["dotnet", "simple-agent.dll"] +ENTRYPOINT ["dotnet", "HostedChatClientAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/HostedChatClientAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/HostedChatClientAgent.csproj similarity index 59% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/HostedChatClientAgent.csproj rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/HostedChatClientAgent.csproj index a9adf5bb21..096681d505 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/HostedChatClientAgent.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/HostedChatClientAgent.csproj @@ -5,16 +5,12 @@ enable enable false - SimpleAgent - simple-agent - $(NoWarn);NU1903;NU1605 + HostedChatClientAgent + HostedChatClientAgent + $(NoWarn); - - - - diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs similarity index 100% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Program.cs rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Properties/launchSettings.json similarity index 100% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/Properties/launchSettings.json rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Properties/launchSettings.json diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/agent.yaml similarity index 100% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/instance-hosting/agent.yaml rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/agent.yaml diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Dockerfile similarity index 88% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Dockerfile rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Dockerfile index 2898f31ed0..eda1f7e1e9 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Dockerfile +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Dockerfile @@ -14,4 +14,4 @@ WORKDIR /app COPY --from=build /app/publish . EXPOSE 8088 ENV ASPNETCORE_URLS=http://+:8088 -ENTRYPOINT ["dotnet", "simple-agent.dll"] +ENTRYPOINT ["dotnet", "HostedFoundryAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/HostedFoundryAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/HostedFoundryAgent.csproj similarity index 59% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/HostedFoundryAgent.csproj rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/HostedFoundryAgent.csproj index a9adf5bb21..2fc0ec43e4 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/HostedFoundryAgent.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/HostedFoundryAgent.csproj @@ -5,16 +5,12 @@ enable enable false - SimpleAgent - simple-agent - $(NoWarn);NU1903;NU1605 + HostedFoundryAgent + HostedFoundryAgent + $(NoWarn); - - - - diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs similarity index 100% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Program.cs rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Properties/launchSettings.json similarity index 81% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Properties/launchSettings.json rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Properties/launchSettings.json index 11588cf909..a7047d02a1 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/Properties/launchSettings.json +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Properties/launchSettings.json @@ -6,7 +6,7 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, - "applicationUrl": "http://localhost:8089" + "applicationUrl": "http://localhost:8088" } } } \ No newline at end of file diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/agent.yaml similarity index 100% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/foundry-hosting/agent.yaml rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/agent.yaml diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs similarity index 98% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/Program.cs rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs index f0a1e74069..774cd3781a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs @@ -8,7 +8,7 @@ using Microsoft.Agents.AI; // Load .env file if present (for local development) Env.TraversePath().Load(); -string agentEndpoint = Environment.GetEnvironmentVariable("AGENT_ENDPOINT") ?? "http://localhost:59055"; +string agentEndpoint = Environment.GetEnvironmentVariable("AGENT_ENDPOINT") ?? "http://localhost:8088"; // ── Create an agent-framework agent backed by the remote agent endpoint ────── // The Foundry Agent SDK's AIProjectClient can target any OpenAI-compatible endpoint. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/SimpleAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/SimpleAgent.csproj similarity index 100% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/consumption/SimpleAgent.csproj rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/SimpleAgent.csproj From a82c1ede09e84dc3f4c80d342cbc58b8d0e030ed Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:03:55 +0100 Subject: [PATCH 20/31] ChatClientAgent working --- .../Hosted-ChatClientAgent/Program.cs | 7 +++ .../Hosted-FoundryAgent/Program.cs | 7 +++ .../UsingHostedAgent/Program.cs | 45 ++++++++++++++++++- .../UsingHostedAgent/SimpleAgent.csproj | 2 + .../AzureAIProjectChatClientExtensions.cs | 4 +- 5 files changed, 60 insertions(+), 5 deletions(-) diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs index 07c7103c1a..88a2b1d610 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs @@ -32,4 +32,11 @@ builder.Services.AddFoundryResponses(agent); var app = builder.Build(); app.MapFoundryResponses(); + +// In Development, also map the OpenAI-compatible route that AIProjectClient uses. +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + app.Run(); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs index 0fe1ad9ac3..c946d60058 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs @@ -29,4 +29,11 @@ builder.Services.AddFoundryResponses(agent); var app = builder.Build(); app.MapFoundryResponses(); + +// In Development, also map the OpenAI-compatible route that AIProjectClient uses. +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + app.Run(); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs index 774cd3781a..27e0a404d7 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.ClientModel.Primitives; using Azure.AI.Projects; using Azure.Identity; using DotNetEnv; @@ -11,9 +12,21 @@ Env.TraversePath().Load(); string agentEndpoint = Environment.GetEnvironmentVariable("AGENT_ENDPOINT") ?? "http://localhost:8088"; // ── Create an agent-framework agent backed by the remote agent endpoint ────── -// The Foundry Agent SDK's AIProjectClient can target any OpenAI-compatible endpoint. -var aiProjectClient = new AIProjectClient(new Uri(agentEndpoint), new AzureCliCredential()); +var endpointUri = new Uri(agentEndpoint); +var options = new AIProjectClientOptions(); + +// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy +// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right +// before the request hits the wire. +Uri clientEndpoint = endpointUri; +if (endpointUri.Scheme == "http") +{ + clientEndpoint = new UriBuilder(endpointUri) { Scheme = "https" }.Uri; + options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); +} + +var aiProjectClient = new AIProjectClient(clientEndpoint, new AzureCliCredential(), options); var agent = aiProjectClient.AsAIAgent(); AgentSession session = await agent.CreateSessionAsync(); @@ -66,3 +79,31 @@ while (true) } Console.WriteLine("Goodbye!"); + +/// +/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient +/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check. +/// +internal sealed class HttpSchemeRewritePolicy : PipelinePolicy +{ + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + private static void RewriteScheme(PipelineMessage message) + { + var uri = message.Request.Uri!; + if (uri.Scheme == Uri.UriSchemeHttps) + { + message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri; + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/SimpleAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/SimpleAgent.csproj index d2651ef7a7..814b5dba2d 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/SimpleAgent.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/SimpleAgent.csproj @@ -12,6 +12,8 @@ + + diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs index 4b895e4bc0..f429221c15 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs @@ -13,7 +13,6 @@ using Microsoft.Agents.AI; using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; using OpenAI.Responses; @@ -230,8 +229,7 @@ public static partial class AzureAIProjectChatClientExtensions { Throw.IfNull(aiProjectClient); Throw.IfNull(agentOptions); - Throw.IfNull(agentOptions.ChatOptions); - Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId); + agentOptions.ChatOptions ??= new(); IChatClient chatClient = aiProjectClient .GetProjectOpenAIClient() From fcc96823a48521f8026a5d9735f172c080c05097 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:45:52 +0100 Subject: [PATCH 21/31] Adding SessionStorage and SessionManagement, improving samples to align Consumption vs Hosting --- .../Hosted-ChatClientAgent/Program.cs | 6 +- .../Properties/launchSettings.json | 1 - .../Properties/launchSettings.json | 3 +- .../UsingHostedAgent/Program.cs | 25 ++++---- .../AzureAIProjectChatClientExtensions.cs | 3 +- .../Hosting/AgentFrameworkResponseHandler.cs | 59 ++++++++++++++++++- .../Hosting/AgentSessionStore.cs | 46 +++++++++++++++ .../Hosting/InMemoryAgentSessionStore.cs | 53 +++++++++++++++++ .../Hosting/ServiceCollectionExtensions.cs | 18 +++++- 9 files changed, 195 insertions(+), 19 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentSessionStore.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InMemoryAgentSessionStore.cs diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs index 88a2b1d610..448f0f16af 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs @@ -11,6 +11,10 @@ Env.TraversePath().Load(); var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); + +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); + var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; // Create the agent via the AI project client using the Responses API. @@ -23,7 +27,7 @@ AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential( providing explanations, brainstorming ideas, and offering guidance. Be concise, clear, and helpful in your responses. """, - name: "simple-agent", + name: agentName, description: "A simple general-purpose AI assistant"); // Host the agent as a Foundry Hosted Agent using the Responses API. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Properties/launchSettings.json index b7f2c658ac..cc21f3dd2e 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Properties/launchSettings.json +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Properties/launchSettings.json @@ -2,7 +2,6 @@ "profiles": { "Hosted-ChatClientAgent": { "commandName": "Project", - "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Properties/launchSettings.json index a7047d02a1..b4c4e005d3 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Properties/launchSettings.json +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Properties/launchSettings.json @@ -1,8 +1,7 @@ { "profiles": { - "simple-agent-foundry": { + "HostedFoundryAgent": { "commandName": "Project", - "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs index 27e0a404d7..af5b4be275 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs @@ -1,33 +1,38 @@ // Copyright (c) Microsoft. All rights reserved. using System.ClientModel.Primitives; +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Azure.Identity; using DotNetEnv; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; // Load .env file if present (for local development) Env.TraversePath().Load(); -string agentEndpoint = Environment.GetEnvironmentVariable("AGENT_ENDPOINT") ?? "http://localhost:8088"; +Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") + ?? "http://localhost:8088"); + +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); // ── Create an agent-framework agent backed by the remote agent endpoint ────── -var endpointUri = new Uri(agentEndpoint); var options = new AIProjectClientOptions(); -// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy -// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right -// before the request hits the wire. -Uri clientEndpoint = endpointUri; -if (endpointUri.Scheme == "http") +if (agentEndpoint.Scheme == "http") { - clientEndpoint = new UriBuilder(endpointUri) { Scheme = "https" }.Uri; + // For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy + // BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right + // before the request hits the wire. + + agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); } -var aiProjectClient = new AIProjectClient(clientEndpoint, new AzureCliCredential(), options); -var agent = aiProjectClient.AsAIAgent(); +var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options); +FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName)); AgentSession session = await agent.CreateSessionAsync(); diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs index f429221c15..1f0f0a6e5f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs @@ -229,7 +229,8 @@ public static partial class AzureAIProjectChatClientExtensions { Throw.IfNull(aiProjectClient); Throw.IfNull(agentOptions); - agentOptions.ChatOptions ??= new(); + Throw.IfNull(agentOptions.ChatOptions); + Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId); IChatClient chatClient = aiProjectClient .GetProjectOpenAIClient() diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs index 40ad435382..87bfd1fc75 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentFrameworkResponseHandler.cs @@ -47,8 +47,20 @@ public class AgentFrameworkResponseHandler : ResponseHandler { // 1. Resolve agent var agent = this.ResolveAgent(request); + var sessionStore = this.ResolveSessionStore(request); - // 2. Create the SDK event stream builder + // 2. Load or create a new session from the interaction + var sessionConversationId = request.GetConversationId() ?? Guid.NewGuid().ToString(); + + var chatClientAgent = agent.GetService(); + + AgentSession? session = !string.IsNullOrEmpty(sessionConversationId) + ? await sessionStore.GetSessionAsync(agent, sessionConversationId, cancellationToken).ConfigureAwait(false) + : chatClientAgent is not null + ? await chatClientAgent.CreateSessionAsync(sessionConversationId, cancellationToken).ConfigureAwait(false) + : await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + + // 3. Create the SDK event stream builder var stream = new ResponseEventStream(context, request); // 3. Emit lifecycle events @@ -87,7 +99,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler // and inside catch blocks. We use a flag to defer the yield to outside the try/catch. bool emittedTerminal = false; var enumerator = OutputConverter.ConvertUpdatesToEventsAsync( - agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken), + agent.RunStreamingAsync(messages, session, options: options, cancellationToken: cancellationToken), stream, cancellationToken).GetAsyncEnumerator(cancellationToken); try @@ -151,6 +163,12 @@ public class AgentFrameworkResponseHandler : ResponseHandler finally { await enumerator.DisposeAsync().ConfigureAwait(false); + + // Persist session after streaming completes (successful or not) + if (session is not null && !string.IsNullOrEmpty(sessionConversationId)) + { + await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, CancellationToken.None).ConfigureAwait(false); + } } } @@ -191,6 +209,43 @@ public class AgentFrameworkResponseHandler : ResponseHandler throw new InvalidOperationException(errorMessage); } + /// + /// Resolves an from the request. + /// Tries agent.name first, then falls back to metadata["entity_id"]. + /// If neither is present, attempts to resolve a default (non-keyed) . + /// + private AgentSessionStore ResolveSessionStore(CreateResponse request) + { + var agentName = GetAgentName(request); + + if (!string.IsNullOrEmpty(agentName)) + { + var sessionStore = this._serviceProvider.GetKeyedService(agentName); + if (sessionStore is not null) + { + return sessionStore; + } + + if (this._logger.IsEnabled(LogLevel.Warning)) + { + this._logger.LogWarning("SessionStore for agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName); + } + } + + // Try non-keyed default + var defaultSessionStore = this._serviceProvider.GetService(); + if (defaultSessionStore is not null) + { + return defaultSessionStore; + } + + var errorMessage = string.IsNullOrEmpty(agentName) + ? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AgentSessionStore is registered." + : $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AgentSessionStore."; + + throw new InvalidOperationException(errorMessage); + } + private static string? GetAgentName(CreateResponse request) { // Try agent.name from AgentReference diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentSessionStore.cs new file mode 100644 index 0000000000..6aef3269b4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentSessionStore.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Defines the contract for storing and retrieving agent conversation sessions. +/// +/// +/// Implementations of this interface enable persistent storage of conversation sessions, +/// allowing conversations to be resumed across HTTP requests, application restarts, +/// or different service instances in hosted scenarios. +/// +public abstract class AgentSessionStore +{ + /// + /// Saves a serialized agent session to persistent storage. + /// + /// The agent that owns this session. + /// The unique identifier for the conversation/session. + /// The session to save. + /// The to monitor for cancellation requests. + /// A task that represents the asynchronous save operation. + public abstract ValueTask SaveSessionAsync( + AIAgent agent, + string conversationId, + AgentSession session, + CancellationToken cancellationToken = default); + + /// + /// Retrieves a serialized agent session from persistent storage. + /// + /// The agent that owns this session. + /// The unique identifier for the conversation/session to retrieve. + /// The to monitor for cancellation requests. + /// + /// A task that represents the asynchronous retrieval operation. + /// The task result contains the session, or a new session if not found. + /// + public abstract ValueTask GetSessionAsync( + AIAgent agent, + string conversationId, + CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InMemoryAgentSessionStore.cs new file mode 100644 index 0000000000..4ae94ed4fe --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/InMemoryAgentSessionStore.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Provides an in-memory implementation of for development and testing scenarios. +/// +/// +/// +/// This implementation stores sessions in memory using a concurrent dictionary and is suitable for: +/// +/// Single-instance development scenarios +/// Testing and prototyping +/// Scenarios where session persistence across restarts is not required +/// +/// +/// +/// Warning: All stored sessions will be lost when the application restarts. +/// For production use with multiple instances or persistence across restarts, use a durable storage implementation +/// such as Redis, SQL Server, or Azure Cosmos DB. +/// +/// +public sealed class InMemoryAgentSessionStore : AgentSessionStore +{ + private readonly ConcurrentDictionary _sessions = new(); + + /// + public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + { + var key = GetKey(conversationId, agent.Id); + this._sessions[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// + public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + { + var key = GetKey(conversationId, agent.Id); + JsonElement? sessionContent = this._sessions.TryGetValue(key, out var existingSession) ? existingSession : null; + + return sessionContent switch + { + null => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false), + _ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false), + }; + } + + private static string GetKey(string conversationId, string agentId) => $"{agentId}:{conversationId}"; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs index 7f01e5904c..bf3b3421f6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs @@ -45,6 +45,7 @@ public static class FoundryHostingExtensions { ArgumentNullException.ThrowIfNull(services); services.AddResponsesServer(); + services.TryAddSingleton(); services.TryAddSingleton(); return services; } @@ -71,14 +72,27 @@ public static class FoundryHostingExtensions /// /// The service collection. /// The agent instance to register. + /// The agent session store to use for managing agent sessions server-side. If null, an in-memory session store will be used. /// The service collection for chaining. - public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent) + public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(agent); services.AddResponsesServer(); - services.TryAddSingleton(agent); + agentSessionStore ??= new InMemoryAgentSessionStore(); + + if (!string.IsNullOrWhiteSpace(agent.Name)) + { + services.TryAddKeyedSingleton(agent.Name, agent); + services.TryAddKeyedSingleton(agent.Name, agentSessionStore); + } + else + { + services.TryAddSingleton(agent); + services.TryAddSingleton(agentSessionStore); + } + services.TryAddSingleton(); return services; } From 4895ad74e13bf22fdb1ed4c36090bb81787ff0a2 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:21:17 +0100 Subject: [PATCH 22/31] Using updates --- .../HostedAgentsV2/UsingHostedAgent/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs index af5b4be275..5d9c003dfa 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs @@ -86,6 +86,7 @@ while (true) Console.WriteLine("Goodbye!"); /// +/// For Local Development Only /// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient /// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check. /// From 9e842e18214c4d5dfa03bfdfbac2e12316f9bb8f Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:11:26 +0100 Subject: [PATCH 23/31] Update chat client agent for contributor and devs --- .gitignore | 4 + dotnet/.gitignore | 9 +- dotnet/agent-framework-dotnet.slnx | 4 +- .../Hosted-ChatClientAgent/.env.local | 4 + .../Dockerfile.contributor | 19 +++ .../HostedChatClientAgent.csproj | 9 ++ .../Hosted-ChatClientAgent/Program.cs | 49 +++++++- .../Hosted-ChatClientAgent/README.md | 109 ++++++++++++++++++ .../agent.manifest.yaml | 28 +++++ .../Hosted-ChatClientAgent/agent.yaml | 29 ++--- .../Hosted-FoundryAgent/agent.manifest.yaml | 28 +++++ .../Hosted-FoundryAgent/agent.yaml | 29 ++--- .../SimpleAgent}/Program.cs | 0 .../SimpleAgent}/SimpleAgent.csproj | 2 +- .../Hosting/AgentSessionStore.cs | 2 +- .../Hosting/ServiceCollectionExtensions.cs | 10 +- 16 files changed, 284 insertions(+), 51 deletions(-) create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/.env.local create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/agent.manifest.yaml rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/{UsingHostedAgent => Using-Samples/SimpleAgent}/Program.cs (100%) rename dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/{UsingHostedAgent => Using-Samples/SimpleAgent}/SimpleAgent.csproj (85%) diff --git a/.gitignore b/.gitignore index 089abb5395..28d753c87b 100644 --- a/.gitignore +++ b/.gitignore @@ -134,6 +134,10 @@ celerybeat.pid .venv env/ venv/ + +# Foundry agent CLI (contains secrets, auto-generated) +.foundry-agent.json +.foundry-agent-build.log ENV/ env.bak/ venv.bak/ diff --git a/dotnet/.gitignore b/dotnet/.gitignore index ce1409abe9..572680831e 100644 --- a/dotnet/.gitignore +++ b/dotnet/.gitignore @@ -402,4 +402,11 @@ FodyWeavers.xsd *.msp # JetBrains Rider -*.sln.iml \ No newline at end of file +*.sln.iml + +# Foundry agent CLI config (contains secrets, auto-generated) +.foundry-agent.json +.foundry-agent-build.log + +# Pre-published output for Docker builds +out/ \ No newline at end of file diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 969a5c16d8..5ffb5692fd 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -280,8 +280,8 @@ - - + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/.env.local b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/.env.local new file mode 100644 index 0000000000..6d7831229d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/.env.local @@ -0,0 +1,4 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Dockerfile.contributor new file mode 100644 index 0000000000..200f674bdd --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-chat-client-agent . +# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-chat-client-agent --env-file .env hosted-chat-client-agent +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedChatClientAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/HostedChatClientAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/HostedChatClientAgent.csproj index 096681d505..b1fe8d3e3c 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/HostedChatClientAgent.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/HostedChatClientAgent.csproj @@ -14,8 +14,17 @@ + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs index 448f0f16af..b4d76bbc52 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Azure.AI.Projects; +using Azure.Core; using Azure.Identity; using DotNetEnv; using Microsoft.Agents.AI; @@ -17,8 +18,14 @@ var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + // Create the agent via the AI project client using the Responses API. -AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()) +AIAgent agent = new AIProjectClient(projectEndpoint, credential) .AsAIAgent( model: deployment, instructions: """ @@ -44,3 +51,43 @@ if (app.Environment.IsDevelopment()) } app.Run(); + +/// +/// A for local Docker debugging only. +/// +/// When debugging and testing a hosted agent in a local Docker container, Azure CLI +/// and other interactive credentials are not available. This credential reads a +/// pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable. +/// +/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed. +/// In production, the Foundry platform injects a managed identity automatically. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return GetAccessToken(); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(GetAccessToken()); + } + + private static AccessToken GetAccessToken() + { + var token = Environment.GetEnvironmentVariable(EnvironmentVariable); + if (string.IsNullOrEmpty(token)) + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/README.md new file mode 100644 index 0000000000..0c5ce36cfe --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/README.md @@ -0,0 +1,109 @@ +# Hosted-ChatClientAgent + +A simple general-purpose AI assistant hosted as a Foundry Hosted Agent using the Agent Framework instance hosting pattern. The agent is created inline via `AIProjectClient.AsAIAgent(model, instructions)` and served using the Responses protocol. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your project endpoint: + +```bash +cp .env.local .env +``` + +Edit `.env` and set your Azure AI Foundry project endpoint: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +``` + +> **Note:** `.env` is gitignored. The `.env.local` template is checked in as a reference. + +## Running directly (contributors) + +This project uses `ProjectReference` to build against the local Agent Framework source. + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent +dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "Hello!" +``` + +Or with curl (specifying the agent name explicitly): + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Hello!", "model": "hosted-chat-client-agent"}' +``` + +## Running with Docker + +Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies outside this folder. Use `Dockerfile.contributor` which takes a pre-published output. + +### 1. Publish for the container runtime (Linux Alpine) + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-chat-client-agent . +``` + +### 3. Run the container + +Generate a bearer token on your host and pass it to the container: + +```bash +# Generate token (expires in ~1 hour) +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +# Run with token +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=hosted-chat-client-agent \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-chat-client-agent +``` + +> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration. + +### 4. Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "Hello!" +``` + +Or with curl (specifying the agent name explicitly): + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Hello!", "model": "hosted-chat-client-agent"}' +``` + +## NuGet package users + +If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedChatClientAgent.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/agent.manifest.yaml new file mode 100644 index 0000000000..58a07d8bb3 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/agent.manifest.yaml @@ -0,0 +1,28 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-chat-client-agent +displayName: "Hosted Chat Client Agent" + +description: > + A simple general-purpose AI assistant hosted as a Foundry Hosted Agent + using the Agent Framework instance hosting pattern. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming + - Agent Framework + +template: + name: hosted-chat-client-agent + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/agent.yaml index dfab24e712..0a97abc35a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/agent.yaml @@ -1,20 +1,9 @@ -name: simple-agent -description: > - A simple general-purpose AI assistant hosted as a Foundry Hosted Agent. -metadata: - tags: - - AI Agent Hosting - - Simple Agent -template: - name: simple-agent - kind: hosted - protocols: - - protocol: responses - version: v1 - environment_variables: - - name: AZURE_AI_PROJECT_ENDPOINT - value: ${AZURE_AI_PROJECT_ENDPOINT} - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} - - name: AGENT_NAME - value: ${AGENT_NAME} +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-chat-client-agent +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/agent.manifest.yaml new file mode 100644 index 0000000000..9b33646c8a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/agent.manifest.yaml @@ -0,0 +1,28 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-foundry-agent +displayName: "Hosted Foundry Agent" + +description: > + A simple general-purpose AI assistant hosted as a Foundry Hosted Agent, + backed by a Foundry-managed agent definition. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming + - Agent Framework + +template: + name: hosted-foundry-agent + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/agent.yaml index a0fe89f966..74223e72fe 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/agent.yaml @@ -1,20 +1,9 @@ -name: simple-agent -description: > - A simple general-purpose AI assistant hosted as a Foundry Hosted Agent, - backed by a Foundry-managed agent definition. -metadata: - tags: - - AI Agent Hosting - - Simple Agent - - Foundry Agent -template: - name: simple-agent - kind: hosted - protocols: - - protocol: responses - version: v1 - environment_variables: - - name: AZURE_AI_PROJECT_ENDPOINT - value: ${AZURE_AI_PROJECT_ENDPOINT} - - name: AGENT_NAME - value: ${AGENT_NAME} +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-foundry-agent +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleAgent/Program.cs similarity index 100% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/Program.cs rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleAgent/Program.cs diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/SimpleAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleAgent/SimpleAgent.csproj similarity index 85% rename from dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/SimpleAgent.csproj rename to dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleAgent/SimpleAgent.csproj index 814b5dba2d..05e150880e 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/UsingHostedAgent/SimpleAgent.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleAgent/SimpleAgent.csproj @@ -18,7 +18,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentSessionStore.cs index 6aef3269b4..c61584e9e0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/AgentSessionStore.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Threading; using System.Threading.Tasks; diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs index bf3b3421f6..fe3b07c023 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/ServiceCollectionExtensions.cs @@ -87,11 +87,11 @@ public static class FoundryHostingExtensions services.TryAddKeyedSingleton(agent.Name, agent); services.TryAddKeyedSingleton(agent.Name, agentSessionStore); } - else - { - services.TryAddSingleton(agent); - services.TryAddSingleton(agentSessionStore); - } + + // Also register as the default (non-keyed) agent so requests + // without an agent name can resolve it (e.g., local dev tooling). + services.TryAddSingleton(agent); + services.TryAddSingleton(agentSessionStore); services.TryAddSingleton(); return services; From 340ec82623ffbe047e063c768aab933f19d17586 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:29:36 +0100 Subject: [PATCH 24/31] Foundry Agent Hosting --- .../Hosted-ChatClientAgent/.env.local | 1 + .../Hosted-ChatClientAgent/Program.cs | 2 +- .../Hosted-FoundryAgent/.env.local | 4 + .../Dockerfile.contributor | 19 +++ .../HostedFoundryAgent.csproj | 9 ++ .../Hosted-FoundryAgent/Program.cs | 49 ++++++- .../Hosted-FoundryAgent/README.md | 121 ++++++++++++++++++ 7 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/.env.local create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/README.md diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/.env.local b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/.env.local index 6d7831229d..cbf693b3a9 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/.env.local +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/.env.local @@ -2,3 +2,4 @@ AZURE_AI_PROJECT_ENDPOINT= ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +AZURE_BEARER_TOKEN= diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs index b4d76bbc52..21cf34852a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs @@ -19,7 +19,7 @@ var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; // Use a chained credential: try a temporary dev token first (for local Docker debugging), -// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production). +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity running in foundry). TokenCredential credential = new ChainedTokenCredential( new DevTemporaryTokenCredential(), new DefaultAzureCredential()); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/.env.local b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/.env.local new file mode 100644 index 0000000000..1fefe43ebd --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/.env.local @@ -0,0 +1,4 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_BEARER_TOKEN= diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Dockerfile.contributor new file mode 100644 index 0000000000..2b6a2dbbc4 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-foundry-agent . +# docker run --rm -p 8088:8088 -e AGENT_NAME= -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-foundry-agent +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedFoundryAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/HostedFoundryAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/HostedFoundryAgent.csproj index 2fc0ec43e4..e49a15769f 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/HostedFoundryAgent.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/HostedFoundryAgent.csproj @@ -14,8 +14,17 @@ + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs index c946d60058..a8f2f249c8 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs @@ -2,6 +2,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; +using Azure.Core; using Azure.Identity; using DotNetEnv; using Microsoft.Agents.AI.Foundry; @@ -15,7 +16,13 @@ var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJE var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? throw new InvalidOperationException("AGENT_NAME is not set."); -var aiProjectClient = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()); +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity running in foundry). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +var aiProjectClient = new AIProjectClient(projectEndpoint, credential); // Retrieve the Foundry-managed agent by name (latest version). ProjectsAgentRecord agentRecord = await aiProjectClient @@ -37,3 +44,43 @@ if (app.Environment.IsDevelopment()) } app.Run(); + +/// +/// A for local Docker debugging only. +/// +/// When debugging and testing a hosted agent in a local Docker container, Azure CLI +/// and other interactive credentials are not available. This credential reads a +/// pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable. +/// +/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed. +/// In production, the Foundry platform injects a managed identity automatically. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return GetAccessToken(); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(GetAccessToken()); + } + + private static AccessToken GetAccessToken() + { + var token = Environment.GetEnvironmentVariable(EnvironmentVariable); + if (string.IsNullOrEmpty(token)) + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/README.md new file mode 100644 index 0000000000..b95e7ff808 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/README.md @@ -0,0 +1,121 @@ +# Hosted-FoundryAgent + +A hosted agent that delegates to a **Foundry-managed agent definition**. Instead of defining the model, instructions, and tools inline in code, this sample retrieves an existing agent registered in the Foundry platform via `AIProjectClient.AsAIAgent(agentRecord)` and hosts it using the Responses protocol. + +This is the **Foundry hosting** pattern — the agent's behavior is configured in the platform (via Foundry UI, CLI, or API), and this server simply wraps and serves it. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a **registered agent** (created via Foundry UI, CLI, or API) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your project endpoint: + +```bash +cp .env.local .env +``` + +Edit `.env` and set your Azure AI Foundry project endpoint: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +``` + +> **Note:** `.env` is gitignored. The `.env.local` template is checked in as a reference. + +You also need to set `AGENT_NAME` — the name of the Foundry-managed agent to host. This is injected automatically by the Foundry platform when deployed. For local development, pass it as an environment variable. + +## Running directly (contributors) + +This project uses `ProjectReference` to build against the local Agent Framework source. + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent +AGENT_NAME= dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "Hello!" +``` + +Or with curl (specifying the agent name explicitly): + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Hello!", "model": ""}' +``` + +## Running with Docker + +Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies outside this folder. Use `Dockerfile.contributor` which takes a pre-published output. + +### 1. Publish for the container runtime (Linux Alpine) + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-foundry-agent . +``` + +### 3. Run the container + +Generate a bearer token on your host and pass it to the container: + +```bash +# Generate token (expires in ~1 hour) +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +# Run with token +docker run --rm -p 8088:8088 \ + -e AGENT_NAME= \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-foundry-agent +``` + +> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration. + +### 4. Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "Hello!" +``` + +Or with curl (specifying the agent name explicitly): + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Hello!", "model": ""}' +``` + +## NuGet package users + +If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedFoundryAgent.csproj` for the `PackageReference` alternative. + +## How it differs from Hosted-ChatClientAgent + +| | Hosted-ChatClientAgent | Hosted-FoundryAgent | +|---|---|---| +| **Agent definition** | Inline in code (`AsAIAgent(model, instructions)`) | Managed in Foundry platform (`AsAIAgent(agentRecord)`) | +| **Model/instructions** | Set in `Program.cs` | Set in Foundry UI/CLI/API | +| **Tools** | Defined in code | Configured in the platform | +| **Use case** | Full control over agent behavior | Platform-managed agent with centralized config | From ad625602a3b0afa65d7c0ce0fb3fb022426be05d Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:20:18 +0100 Subject: [PATCH 25/31] Address text rag sample working --- dotnet/agent-framework-dotnet.slnx | 3 + .../Hosted-ChatClientAgent/Program.cs | 13 +- .../Hosted-FoundryAgent/Program.cs | 13 +- .../HostedAgentsV2/Hosted-TextRag/.env.local | 5 + .../HostedAgentsV2/Hosted-TextRag/Dockerfile | 17 +++ .../Hosted-TextRag/Dockerfile.contributor | 19 +++ .../Hosted-TextRag/HostedTextRag.csproj | 32 +++++ .../HostedAgentsV2/Hosted-TextRag/Program.cs | 130 ++++++++++++++++++ .../Properties/launchSettings.json | 11 ++ .../HostedAgentsV2/Hosted-TextRag/README.md | 116 ++++++++++++++++ .../Hosted-TextRag/agent.manifest.yaml | 30 ++++ .../HostedAgentsV2/Hosted-TextRag/agent.yaml | 9 ++ .../AgentThreadAndHITL.csproj | 24 ++++ .../AgentThreadAndHITL/Program.cs | 115 ++++++++++++++++ .../AgentWithLocalTools.csproj | 24 ++++ .../AgentWithLocalTools/Program.cs | 115 ++++++++++++++++ .../AgentWithTextSearchRag.csproj | 24 ++++ .../AgentWithTextSearchRag/Program.cs | 115 ++++++++++++++++ .../AgentsInWorkflows.csproj | 24 ++++ .../AgentsInWorkflows/Program.cs | 115 ++++++++++++++++ 20 files changed, 946 insertions(+), 8 deletions(-) create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/.env.local create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/HostedTextRag.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Properties/launchSettings.json create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/agent.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/AgentThreadAndHITL.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/AgentWithLocalTools.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/AgentsInWorkflows.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/Program.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 5ffb5692fd..8de8933bea 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -280,6 +280,9 @@ + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs index 21cf34852a..e7dcf415f7 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs @@ -69,6 +69,12 @@ app.Run(); internal sealed class DevTemporaryTokenCredential : TokenCredential { private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + _token = Environment.GetEnvironmentVariable(EnvironmentVariable); + } public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) { @@ -80,14 +86,13 @@ internal sealed class DevTemporaryTokenCredential : TokenCredential return new ValueTask(GetAccessToken()); } - private static AccessToken GetAccessToken() + private AccessToken GetAccessToken() { - var token = Environment.GetEnvironmentVariable(EnvironmentVariable); - if (string.IsNullOrEmpty(token)) + if (string.IsNullOrEmpty(_token)) { throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); } - return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1)); + return new AccessToken(_token, DateTimeOffset.UtcNow.AddHours(1)); } } diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs index a8f2f249c8..7f509084c0 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs @@ -62,6 +62,12 @@ app.Run(); internal sealed class DevTemporaryTokenCredential : TokenCredential { private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + _token = Environment.GetEnvironmentVariable(EnvironmentVariable); + } public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) { @@ -73,14 +79,13 @@ internal sealed class DevTemporaryTokenCredential : TokenCredential return new ValueTask(GetAccessToken()); } - private static AccessToken GetAccessToken() + private AccessToken GetAccessToken() { - var token = Environment.GetEnvironmentVariable(EnvironmentVariable); - if (string.IsNullOrEmpty(token)) + if (string.IsNullOrEmpty(_token)) { throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); } - return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1)); + return new AccessToken(_token, DateTimeOffset.UtcNow.AddHours(1)); } } diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/.env.local b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/.env.local new file mode 100644 index 0000000000..cbf693b3a9 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/.env.local @@ -0,0 +1,5 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +AZURE_BEARER_TOKEN= diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Dockerfile new file mode 100644 index 0000000000..062d0f4f7e --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedTextRag.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Dockerfile.contributor new file mode 100644 index 0000000000..9a90c74335 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-text-rag . +# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-text-rag -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-text-rag +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedTextRag.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/HostedTextRag.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/HostedTextRag.csproj new file mode 100644 index 0000000000..9a22108c7b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/HostedTextRag.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + HostedTextRag + HostedTextRag + $(NoWarn); + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Program.cs new file mode 100644 index 0000000000..45d027f740 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Program.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG) +// capabilities to a hosted agent. The provider runs a search against an external knowledge base +// before each model invocation and injects the results into the model context. + +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +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"; + +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +TextSearchProviderOptions textSearchOptions = new() +{ + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, + RecentMessageMemoryLimit = 6, +}; + +AIAgent agent = new AIProjectClient(new Uri(endpoint), credential) + .AsAIAgent(new ChatClientAgentOptions + { + Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-text-rag", + ChatOptions = new ChatOptions + { + ModelId = deploymentName, + Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", + }, + AIContextProviders = [new TextSearchProvider(MockSearchAsync, textSearchOptions)] + }); + +// Host the agent as a Foundry Hosted Agent using the Responses API. +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); + +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + +app.Run(); + +// ── Mock search function ───────────────────────────────────────────────────── +// In production, replace this with a real search provider (e.g., Azure AI Search). + +static Task> MockSearchAsync(string query, CancellationToken cancellationToken) +{ + List results = []; + + if (query.Contains("return", StringComparison.OrdinalIgnoreCase) || query.Contains("refund", StringComparison.OrdinalIgnoreCase)) + { + results.Add(new() + { + SourceName = "Contoso Outdoors Return Policy", + SourceLink = "https://contoso.com/policies/returns", + Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection." + }); + } + + if (query.Contains("shipping", StringComparison.OrdinalIgnoreCase)) + { + results.Add(new() + { + SourceName = "Contoso Outdoors Shipping Guide", + SourceLink = "https://contoso.com/help/shipping", + Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout." + }); + } + + if (query.Contains("tent", StringComparison.OrdinalIgnoreCase) || query.Contains("fabric", StringComparison.OrdinalIgnoreCase)) + { + results.Add(new() + { + SourceName = "TrailRunner Tent Care Instructions", + SourceLink = "https://contoso.com/manuals/trailrunner-tent", + Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating." + }); + } + + return Task.FromResult>(results); +} + +/// +/// A for local Docker debugging only. +/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable. +/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => GetAccessToken(); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(GetAccessToken()); + + private static AccessToken GetAccessToken() + { + var token = Environment.GetEnvironmentVariable(EnvironmentVariable); + if (string.IsNullOrEmpty(token)) + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Properties/launchSettings.json new file mode 100644 index 0000000000..932d4e67fc --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "HostedTextRag": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:8088" + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/README.md new file mode 100644 index 0000000000..75c9dba797 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/README.md @@ -0,0 +1,116 @@ +# Hosted-TextRag + +A hosted agent with **Retrieval Augmented Generation (RAG)** capabilities using `TextSearchProvider`. The agent grounds its answers in product documentation by running a search before each model invocation, then citing the source in its response. + +This sample demonstrates how to add knowledge grounding to a hosted agent without requiring an external search index — using a mock search function that can be replaced with Azure AI Search or any other provider. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your project endpoint: + +```bash +cp .env.local .env +``` + +Edit `.env` and set your Azure AI Foundry project endpoint: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +AZURE_BEARER_TOKEN= +``` + +> **Note:** `.env` is gitignored. The `.env.local` template is checked in as a reference. + +## Running directly (contributors) + +This project uses `ProjectReference` to build against the local Agent Framework source. + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag +AGENT_NAME=hosted-text-rag dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "What is your return policy?" +azd ai agent invoke --local "How long does shipping take?" +azd ai agent invoke --local "How do I clean my tent?" +``` + +Or with curl: + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "What is your return policy?", "model": "hosted-text-rag"}' +``` + +## Running with Docker + +Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output. + +### 1. Publish for the container runtime (Linux Alpine) + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-text-rag . +``` + +### 3. Run the container + +Generate a bearer token on your host and pass it to the container: + +```bash +# Generate token (expires in ~1 hour) +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +# Run with token +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=hosted-text-rag \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-text-rag +``` + +### 4. Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "What is your return policy?" +``` + +## How RAG works in this sample + +The `TextSearchProvider` runs a mock search **before each model invocation**: + +| User query contains | Search result injected | +|---|---| +| "return" or "refund" | Contoso Outdoors Return Policy | +| "shipping" | Contoso Outdoors Shipping Guide | +| "tent" or "fabric" | TrailRunner Tent Care Instructions | + +The model receives the search results as additional context and cites the source in its response. In production, replace `MockSearchAsync` with a call to Azure AI Search or your preferred search provider. + +## NuGet package users + +If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedTextRag.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/agent.manifest.yaml new file mode 100644 index 0000000000..1459925136 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/agent.manifest.yaml @@ -0,0 +1,30 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-text-rag +displayName: "Hosted Text RAG Agent" + +description: > + A support specialist agent for Contoso Outdoors with RAG capabilities. + Uses TextSearchProvider to ground answers in product documentation + before each model invocation. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - RAG + - Text Search + - Agent Framework + +template: + name: hosted-text-rag + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/agent.yaml new file mode 100644 index 0000000000..c8d6928e2e --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-TextRag/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-text-rag +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/AgentThreadAndHITL.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/AgentThreadAndHITL.csproj new file mode 100644 index 0000000000..69905ed43b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/AgentThreadAndHITL.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + false + AgentThreadAndHITLClient + agent-thread-hitl-client + $(NoWarn);NU1903;NU1605;OPENAI001 + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/Program.cs new file mode 100644 index 0000000000..14ddb29fe8 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/Program.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") + ?? "http://localhost:8088"); + +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); + +// ── Create an agent-framework agent backed by the remote agent endpoint ────── + +var options = new AIProjectClientOptions(); + +if (agentEndpoint.Scheme == "http") +{ + // For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy + // BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right + // before the request hits the wire. + + agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; + options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); +} + +var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options); +FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName)); + +AgentSession session = await agent.CreateSessionAsync(); + +// ── REPL ────────────────────────────────────────────────────────────────────── + +Console.ForegroundColor = ConsoleColor.Cyan; +Console.WriteLine($""" + ══════════════════════════════════════════════════════════ + HITL Agent Client + Connected to: {agentEndpoint} + Type a message or 'quit' to exit + ══════════════════════════════════════════════════════════ + """); +Console.ResetColor(); +Console.WriteLine(); + +while (true) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("You> "); + Console.ResetColor(); + + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) { continue; } + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } + + try + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("Agent> "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(input, session)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error: {ex.Message}"); + Console.ResetColor(); + } + + Console.WriteLine(); +} + +Console.WriteLine("Goodbye!"); + +/// +/// For Local Development Only +/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient +/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check. +/// +internal sealed class HttpSchemeRewritePolicy : PipelinePolicy +{ + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + private static void RewriteScheme(PipelineMessage message) + { + var uri = message.Request.Uri!; + if (uri.Scheme == Uri.UriSchemeHttps) + { + message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri; + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/AgentWithLocalTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/AgentWithLocalTools.csproj new file mode 100644 index 0000000000..0742994449 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/AgentWithLocalTools.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + false + AgentWithLocalToolsClient + agent-with-local-tools-client + $(NoWarn);NU1903;NU1605;OPENAI001 + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/Program.cs new file mode 100644 index 0000000000..0caa06c36b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/Program.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") + ?? "http://localhost:8088"); + +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); + +// ── Create an agent-framework agent backed by the remote agent endpoint ────── + +var options = new AIProjectClientOptions(); + +if (agentEndpoint.Scheme == "http") +{ + // For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy + // BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right + // before the request hits the wire. + + agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; + options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); +} + +var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options); +FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName)); + +AgentSession session = await agent.CreateSessionAsync(); + +// ── REPL ────────────────────────────────────────────────────────────────────── + +Console.ForegroundColor = ConsoleColor.Cyan; +Console.WriteLine($""" + ══════════════════════════════════════════════════════════ + Hotel Agent Client + Connected to: {agentEndpoint} + Type a message or 'quit' to exit + ══════════════════════════════════════════════════════════ + """); +Console.ResetColor(); +Console.WriteLine(); + +while (true) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("You> "); + Console.ResetColor(); + + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) { continue; } + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } + + try + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("Agent> "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(input, session)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error: {ex.Message}"); + Console.ResetColor(); + } + + Console.WriteLine(); +} + +Console.WriteLine("Goodbye!"); + +/// +/// For Local Development Only +/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient +/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check. +/// +internal sealed class HttpSchemeRewritePolicy : PipelinePolicy +{ + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + private static void RewriteScheme(PipelineMessage message) + { + var uri = message.Request.Uri!; + if (uri.Scheme == Uri.UriSchemeHttps) + { + message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri; + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj new file mode 100644 index 0000000000..028977d0aa --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + false + AgentWithTextSearchRagClient + agent-with-rag-client + $(NoWarn);NU1903;NU1605;OPENAI001 + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/Program.cs new file mode 100644 index 0000000000..efc6c1d982 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/Program.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") + ?? "http://localhost:8088"); + +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); + +// ── Create an agent-framework agent backed by the remote agent endpoint ────── + +var options = new AIProjectClientOptions(); + +if (agentEndpoint.Scheme == "http") +{ + // For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy + // BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right + // before the request hits the wire. + + agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; + options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); +} + +var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options); +FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName)); + +AgentSession session = await agent.CreateSessionAsync(); + +// ── REPL ────────────────────────────────────────────────────────────────────── + +Console.ForegroundColor = ConsoleColor.Cyan; +Console.WriteLine($""" + ══════════════════════════════════════════════════════════ + RAG Agent Client + Connected to: {agentEndpoint} + Type a message or 'quit' to exit + ══════════════════════════════════════════════════════════ + """); +Console.ResetColor(); +Console.WriteLine(); + +while (true) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("You> "); + Console.ResetColor(); + + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) { continue; } + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } + + try + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("Agent> "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(input, session)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error: {ex.Message}"); + Console.ResetColor(); + } + + Console.WriteLine(); +} + +Console.WriteLine("Goodbye!"); + +/// +/// For Local Development Only +/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient +/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check. +/// +internal sealed class HttpSchemeRewritePolicy : PipelinePolicy +{ + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + private static void RewriteScheme(PipelineMessage message) + { + var uri = message.Request.Uri!; + if (uri.Scheme == Uri.UriSchemeHttps) + { + message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri; + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/AgentsInWorkflows.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/AgentsInWorkflows.csproj new file mode 100644 index 0000000000..27e2da3908 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/AgentsInWorkflows.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + false + AgentsInWorkflowsClient + agents-in-workflows-client + $(NoWarn);NU1903;NU1605;OPENAI001 + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/Program.cs new file mode 100644 index 0000000000..33e4765fb9 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/Program.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") + ?? "http://localhost:8088"); + +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); + +// ── Create an agent-framework agent backed by the remote agent endpoint ────── + +var options = new AIProjectClientOptions(); + +if (agentEndpoint.Scheme == "http") +{ + // For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy + // BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right + // before the request hits the wire. + + agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; + options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); +} + +var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options); +FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName)); + +AgentSession session = await agent.CreateSessionAsync(); + +// ── REPL ────────────────────────────────────────────────────────────────────── + +Console.ForegroundColor = ConsoleColor.Cyan; +Console.WriteLine($""" + ══════════════════════════════════════════════════════════ + Translation Workflow Agent Client + Connected to: {agentEndpoint} + Type a message or 'quit' to exit + ══════════════════════════════════════════════════════════ + """); +Console.ResetColor(); +Console.WriteLine(); + +while (true) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("You> "); + Console.ResetColor(); + + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) { continue; } + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } + + try + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("Agent> "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(input, session)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error: {ex.Message}"); + Console.ResetColor(); + } + + Console.WriteLine(); +} + +Console.WriteLine("Goodbye!"); + +/// +/// For Local Development Only +/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient +/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check. +/// +internal sealed class HttpSchemeRewritePolicy : PipelinePolicy +{ + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + private static void RewriteScheme(PipelineMessage message) + { + var uri = message.Request.Uri!; + if (uri.Scheme == Uri.UriSchemeHttps) + { + message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri; + } + } +} From d02cee5353c1b02f0bc118e5b99654c968647017 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:30:13 +0100 Subject: [PATCH 26/31] Version bump --- dotnet/nuget/nuget-package.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 27e2bbea9e..2d62fe0b46 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -8,7 +8,7 @@ $(VersionPrefix)-preview.260402.1 $(VersionPrefix) - 0.9.0-hosted.260409.1 + 0.9.0-hosted.260413.1 1.0.0 Debug;Release;Publish From 4b9d848541ec462a0a8cb4b14ff4eeb696e79aed Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:59:18 +0100 Subject: [PATCH 27/31] Adding LocalTools + Workflow samples --- dotnet/agent-framework-dotnet.slnx | 6 + .../Hosted-LocalTools/.env.local | 4 + .../Hosted-LocalTools/Dockerfile | 17 ++ .../Hosted-LocalTools/Dockerfile.contributor | 19 ++ .../Hosted-LocalTools/HostedLocalTools.csproj | 30 ++++ .../Hosted-LocalTools/Program.cs | 164 ++++++++++++++++++ .../Properties/launchSettings.json | 11 ++ .../Hosted-LocalTools/README.md | 113 ++++++++++++ .../Hosted-LocalTools/agent.manifest.yaml | 29 ++++ .../Hosted-LocalTools/agent.yaml | 9 + .../Hosted-Workflows/.env.local | 4 + .../Hosted-Workflows/Dockerfile | 17 ++ .../Hosted-Workflows/Dockerfile.contributor | 18 ++ .../Hosted-Workflows/HostedWorkflows.csproj | 34 ++++ .../Hosted-Workflows/Program.cs | 97 +++++++++++ .../Properties/launchSettings.json | 11 ++ .../HostedAgentsV2/Hosted-Workflows/README.md | 109 ++++++++++++ .../Hosted-Workflows/agent.manifest.yaml | 29 ++++ .../Hosted-Workflows/agent.yaml | 9 + 19 files changed, 730 insertions(+) create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/.env.local create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/HostedLocalTools.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Properties/launchSettings.json create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/agent.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/.env.local create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/HostedWorkflows.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Properties/launchSettings.json create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/agent.yaml diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 8de8933bea..c065430b54 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -283,6 +283,12 @@ + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/.env.local b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/.env.local new file mode 100644 index 0000000000..6d7831229d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/.env.local @@ -0,0 +1,4 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Dockerfile new file mode 100644 index 0000000000..1b72fcd93f --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedLocalTools.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Dockerfile.contributor new file mode 100644 index 0000000000..65f920824a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-local-tools . +# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-local-tools -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-local-tools +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedLocalTools.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/HostedLocalTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/HostedLocalTools.csproj new file mode 100644 index 0000000000..b0d39d8cee --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/HostedLocalTools.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + HostedLocalTools + HostedLocalTools + $(NoWarn); + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Program.cs new file mode 100644 index 0000000000..f1b2f7e3bd --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Program.cs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Seattle Hotel Agent - A hosted agent with local C# function tools. +// Demonstrates how to define and wire local tools that the LLM can invoke, +// a key advantage of code-based hosted agents over prompt agents. + +using System.ComponentModel; +using System.Globalization; +using System.Text; +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +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"; + +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +// ── Hotel data ─────────────────────────────────────────────────────────────── + +Hotel[] seattleHotels = +[ + new("Contoso Suites", 189, 4.5, "Downtown"), + new("Fabrikam Residences", 159, 4.2, "Pike Place Market"), + new("Alpine Ski House", 249, 4.7, "Seattle Center"), + new("Margie's Travel Lodge", 219, 4.4, "Waterfront"), + new("Northwind Inn", 139, 4.0, "Capitol Hill"), + new("Relecloud Hotel", 99, 3.8, "University District"), +]; + +// ── Tool: GetAvailableHotels ───────────────────────────────────────────────── + +[Description("Get available hotels in Seattle for the specified dates.")] +string GetAvailableHotels( + [Description("Check-in date in YYYY-MM-DD format")] string checkInDate, + [Description("Check-out date in YYYY-MM-DD format")] string checkOutDate, + [Description("Maximum price per night in USD (optional, defaults to 500)")] int maxPrice = 500) +{ + if (!DateTime.TryParseExact(checkInDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkIn)) + { + return "Error parsing check-in date. Please use YYYY-MM-DD format."; + } + + if (!DateTime.TryParseExact(checkOutDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkOut)) + { + return "Error parsing check-out date. Please use YYYY-MM-DD format."; + } + + if (checkOut <= checkIn) + { + return "Error: Check-out date must be after check-in date."; + } + + int nights = (checkOut - checkIn).Days; + List availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList(); + + if (availableHotels.Count == 0) + { + return $"No hotels found in Seattle within your budget of ${maxPrice}/night."; + } + + StringBuilder result = new(); + result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):"); + result.AppendLine(); + + foreach (Hotel hotel in availableHotels) + { + int totalCost = hotel.PricePerNight * nights; + result.AppendLine($"**{hotel.Name}**"); + result.AppendLine($" Location: {hotel.Location}"); + result.AppendLine($" Rating: {hotel.Rating}/5"); + result.AppendLine($" ${hotel.PricePerNight}/night (Total: ${totalCost})"); + result.AppendLine(); + } + + return result.ToString(); +} + +// ── Create and host the agent ──────────────────────────────────────────────── + +AIAgent agent = new AIProjectClient(new Uri(endpoint), credential) + .AsAIAgent( + model: deploymentName, + instructions: """ + You are a helpful travel assistant specializing in finding hotels in Seattle, Washington. + + When a user asks about hotels in Seattle: + 1. Ask for their check-in and check-out dates if not provided + 2. Ask about their budget preferences if not mentioned + 3. Use the GetAvailableHotels tool to find available options + 4. Present the results in a friendly, informative way + 5. Offer to help with additional questions about the hotels or Seattle + + Be conversational and helpful. If users ask about things outside of Seattle hotels, + politely let them know you specialize in Seattle hotel recommendations. + """, + name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-local-tools", + description: "Seattle hotel search agent with local function tools", + tools: [AIFunctionFactory.Create(GetAvailableHotels)]); + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); + +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + +app.Run(); + +// ── Types ──────────────────────────────────────────────────────────────────── + +internal sealed record Hotel(string Name, int PricePerNight, double Rating, string Location); + +/// +/// A for local Docker debugging only. +/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable +/// once at startup. This should NOT be used in production. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + _token = Environment.GetEnvironmentVariable(EnvironmentVariable); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => GetAccessToken(); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(GetAccessToken()); + + private AccessToken GetAccessToken() + { + if (string.IsNullOrEmpty(_token)) + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(_token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Properties/launchSettings.json new file mode 100644 index 0000000000..ae1bb80b7d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "HostedLocalTools": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:8088" + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/README.md new file mode 100644 index 0000000000..3c41803b95 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/README.md @@ -0,0 +1,113 @@ +# Hosted-LocalTools + +A hosted agent with **local C# function tools** for hotel search. Demonstrates how to define and wire local tools that the LLM can invoke — a key advantage of code-based hosted agents over prompt agents. + +The agent specializes in finding hotels in Seattle, with a `GetAvailableHotels` tool that searches a mock hotel database by dates and budget. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your project endpoint: + +```bash +cp .env.local .env +``` + +Edit `.env` and set your Azure AI Foundry project endpoint: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +``` + +> **Note:** `.env` is gitignored. The `.env.local` template is checked in as a reference. + +## Running directly (contributors) + +This project uses `ProjectReference` to build against the local Agent Framework source. + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools +AGENT_NAME=hosted-local-tools dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "Find me a hotel in Seattle for Dec 20-25 under $200/night" +``` + +Or with curl: + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Find me a hotel in Seattle for Dec 20-25 under $200/night", "model": "hosted-local-tools"}' +``` + +## Running with Docker + +Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output. + +### 1. Publish for the container runtime (Linux Alpine) + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-local-tools . +``` + +### 3. Run the container + +Generate a bearer token on your host and pass it to the container: + +```bash +# Generate token (expires in ~1 hour) +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +# Run with token +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=hosted-local-tools \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-local-tools +``` + +### 4. Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "What hotels are available in Seattle for next weekend?" +``` + +## How local tools work + +The agent has a single tool `GetAvailableHotels` defined as a C# method with `[Description]` attributes. The LLM decides when to call it based on the user's request: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `checkInDate` | string | Check-in date (YYYY-MM-DD) | +| `checkOutDate` | string | Check-out date (YYYY-MM-DD) | +| `maxPrice` | int | Max price per night in USD (default: 500) | + +The tool searches a mock database of 6 Seattle hotels and returns formatted results with name, location, rating, and pricing. + +## NuGet package users + +If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedLocalTools.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/agent.manifest.yaml new file mode 100644 index 0000000000..a056b51649 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/agent.manifest.yaml @@ -0,0 +1,29 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-local-tools +displayName: "Seattle Hotel Agent with Local Tools" + +description: > + A travel assistant agent that helps users find hotels in Seattle. + Demonstrates local C# tool execution — a key advantage of code-based + hosted agents over prompt agents. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Local Tools + - Agent Framework + +template: + name: hosted-local-tools + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/agent.yaml new file mode 100644 index 0000000000..18ecc4a9f7 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-local-tools +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/.env.local b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/.env.local new file mode 100644 index 0000000000..6d7831229d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/.env.local @@ -0,0 +1,4 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Dockerfile new file mode 100644 index 0000000000..e770ec172b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedWorkflows.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Dockerfile.contributor new file mode 100644 index 0000000000..b8dae44c2b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Dockerfile.contributor @@ -0,0 +1,18 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local source, which means a standard +# multi-stage Docker build cannot resolve dependencies outside this folder. +# Pre-publish the app targeting the container runtime and copy the output: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-workflows . +# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-workflows -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-workflows +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedWorkflows.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/HostedWorkflows.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/HostedWorkflows.csproj new file mode 100644 index 0000000000..2f210a18d8 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/HostedWorkflows.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + false + HostedWorkflows + HostedWorkflows + $(NoWarn); + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Program.cs new file mode 100644 index 0000000000..6288e3cf5f --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Program.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Translation Chain Workflow Agent — demonstrates how to compose multiple AI agents +// into a sequential workflow pipeline. Three translation agents are connected: +// English → French → Spanish → English, showing how agents can be orchestrated +// as workflow executors in a hosted agent. + +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +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"; + +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +// Create a chat client from the Foundry project +IChatClient chatClient = new AIProjectClient(new Uri(endpoint), credential) + .GetProjectOpenAIClient() + .GetChatClient(deploymentName) + .AsIChatClient(); + +// Create translation agents +AIAgent frenchAgent = chatClient.AsAIAgent("You are a translation assistant that translates the provided text to French."); +AIAgent spanishAgent = chatClient.AsAIAgent("You are a translation assistant that translates the provided text to Spanish."); +AIAgent englishAgent = chatClient.AsAIAgent("You are a translation assistant that translates the provided text to English."); + +// Build the sequential workflow: French → Spanish → English +AIAgent agent = new WorkflowBuilder(frenchAgent) + .AddEdge(frenchAgent, spanishAgent) + .AddEdge(spanishAgent, englishAgent) + .Build() + .AsAIAgent( + name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-workflows"); + +// Host the workflow agent as a Foundry Hosted Agent using the Responses API. +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); + +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + +app.Run(); + +/// +/// A for local Docker debugging only. +/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable +/// once at startup. This should NOT be used in production. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + _token = Environment.GetEnvironmentVariable(EnvironmentVariable); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => GetAccessToken(); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(GetAccessToken()); + + private AccessToken GetAccessToken() + { + if (string.IsNullOrEmpty(_token)) + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(_token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Properties/launchSettings.json new file mode 100644 index 0000000000..0e2908985a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "HostedWorkflows": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:8088" + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/README.md new file mode 100644 index 0000000000..0bb000aaa1 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/README.md @@ -0,0 +1,109 @@ +# Hosted-Workflows + +A hosted agent that demonstrates **multi-agent workflow orchestration**. Three translation agents are composed into a sequential pipeline: English → French → Spanish → English, showing how agents can be chained as workflow executors using `WorkflowBuilder`. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your project endpoint: + +```bash +cp .env.local .env +``` + +Edit `.env` and set your Azure AI Foundry project endpoint: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +``` + +> **Note:** `.env` is gitignored. The `.env.local` template is checked in as a reference. + +## Running directly (contributors) + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows +AGENT_NAME=hosted-workflows dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "The quick brown fox jumps over the lazy dog" +``` + +Or with curl: + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "The quick brown fox jumps over the lazy dog", "model": "hosted-workflows"}' +``` + +The text will be translated through the chain: English → French → Spanish → English. + +## Running with Docker + +### 1. Publish for the container runtime + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-workflows . +``` + +### 3. Run the container + +```bash +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=hosted-workflows \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-workflows +``` + +### 4. Test it + +```bash +azd ai agent invoke --local "Hello, how are you today?" +``` + +## How the workflow works + +``` +Input text + │ + ▼ +┌─────────────┐ ┌──────────────┐ ┌──────────────┐ +│ French Agent │ → │ Spanish Agent │ → │ English Agent │ +│ (translate) │ │ (translate) │ │ (translate) │ +└─────────────┘ └──────────────┘ └──────────────┘ + │ + ▼ + Final output + (back in English) +``` + +Each agent in the chain receives the output of the previous agent. The final result demonstrates how meaning is preserved (or subtly shifted) through multiple translation hops. + +## NuGet package users + +Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflows.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/agent.manifest.yaml new file mode 100644 index 0000000000..e902b6232f --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/agent.manifest.yaml @@ -0,0 +1,29 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-workflows +displayName: "Translation Chain Workflow Agent" + +description: > + A workflow agent that performs sequential translation through multiple languages. + Translates text from English to French, then to Spanish, and finally back to English, + demonstrating how AI agents can be composed as workflow executors. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Workflows + - Agent Framework + +template: + name: hosted-workflows + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/agent.yaml new file mode 100644 index 0000000000..ab138939b4 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-workflows +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi From 1584879edd63814cfe95d836ac37727cd1e72893 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:50:11 +0100 Subject: [PATCH 28/31] Removing extra using samples --- .../AgentThreadAndHITL.csproj | 24 ---- .../AgentThreadAndHITL/Program.cs | 115 ------------------ .../AgentWithLocalTools.csproj | 24 ---- .../AgentWithLocalTools/Program.cs | 115 ------------------ .../AgentWithTextSearchRag.csproj | 24 ---- .../AgentWithTextSearchRag/Program.cs | 115 ------------------ .../AgentsInWorkflows.csproj | 24 ---- .../AgentsInWorkflows/Program.cs | 115 ------------------ 8 files changed, 556 deletions(-) delete mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/AgentThreadAndHITL.csproj delete mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/Program.cs delete mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/AgentWithLocalTools.csproj delete mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/Program.cs delete mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj delete mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/Program.cs delete mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/AgentsInWorkflows.csproj delete mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/Program.cs diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/AgentThreadAndHITL.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/AgentThreadAndHITL.csproj deleted file mode 100644 index 69905ed43b..0000000000 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/AgentThreadAndHITL.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - Exe - net10.0 - enable - enable - false - AgentThreadAndHITLClient - agent-thread-hitl-client - $(NoWarn);NU1903;NU1605;OPENAI001 - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/Program.cs deleted file mode 100644 index 14ddb29fe8..0000000000 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentThreadAndHITL/Program.cs +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ClientModel.Primitives; -using Azure.AI.Extensions.OpenAI; -using Azure.AI.Projects; -using Azure.Identity; -using DotNetEnv; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Foundry; - -// Load .env file if present (for local development) -Env.TraversePath().Load(); - -Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") - ?? "http://localhost:8088"); - -var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") - ?? throw new InvalidOperationException("AGENT_NAME is not set."); - -// ── Create an agent-framework agent backed by the remote agent endpoint ────── - -var options = new AIProjectClientOptions(); - -if (agentEndpoint.Scheme == "http") -{ - // For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy - // BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right - // before the request hits the wire. - - agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; - options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); -} - -var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options); -FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName)); - -AgentSession session = await agent.CreateSessionAsync(); - -// ── REPL ────────────────────────────────────────────────────────────────────── - -Console.ForegroundColor = ConsoleColor.Cyan; -Console.WriteLine($""" - ══════════════════════════════════════════════════════════ - HITL Agent Client - Connected to: {agentEndpoint} - Type a message or 'quit' to exit - ══════════════════════════════════════════════════════════ - """); -Console.ResetColor(); -Console.WriteLine(); - -while (true) -{ - Console.ForegroundColor = ConsoleColor.Green; - Console.Write("You> "); - Console.ResetColor(); - - string? input = Console.ReadLine(); - - if (string.IsNullOrWhiteSpace(input)) { continue; } - if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } - - try - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.Write("Agent> "); - Console.ResetColor(); - - await foreach (var update in agent.RunStreamingAsync(input, session)) - { - Console.Write(update); - } - - Console.WriteLine(); - } - catch (Exception ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error: {ex.Message}"); - Console.ResetColor(); - } - - Console.WriteLine(); -} - -Console.WriteLine("Goodbye!"); - -/// -/// For Local Development Only -/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient -/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check. -/// -internal sealed class HttpSchemeRewritePolicy : PipelinePolicy -{ - public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - RewriteScheme(message); - ProcessNext(message, pipeline, currentIndex); - } - - public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - RewriteScheme(message); - await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); - } - - private static void RewriteScheme(PipelineMessage message) - { - var uri = message.Request.Uri!; - if (uri.Scheme == Uri.UriSchemeHttps) - { - message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri; - } - } -} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/AgentWithLocalTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/AgentWithLocalTools.csproj deleted file mode 100644 index 0742994449..0000000000 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/AgentWithLocalTools.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - Exe - net10.0 - enable - enable - false - AgentWithLocalToolsClient - agent-with-local-tools-client - $(NoWarn);NU1903;NU1605;OPENAI001 - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/Program.cs deleted file mode 100644 index 0caa06c36b..0000000000 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithLocalTools/Program.cs +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ClientModel.Primitives; -using Azure.AI.Extensions.OpenAI; -using Azure.AI.Projects; -using Azure.Identity; -using DotNetEnv; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Foundry; - -// Load .env file if present (for local development) -Env.TraversePath().Load(); - -Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") - ?? "http://localhost:8088"); - -var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") - ?? throw new InvalidOperationException("AGENT_NAME is not set."); - -// ── Create an agent-framework agent backed by the remote agent endpoint ────── - -var options = new AIProjectClientOptions(); - -if (agentEndpoint.Scheme == "http") -{ - // For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy - // BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right - // before the request hits the wire. - - agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; - options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); -} - -var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options); -FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName)); - -AgentSession session = await agent.CreateSessionAsync(); - -// ── REPL ────────────────────────────────────────────────────────────────────── - -Console.ForegroundColor = ConsoleColor.Cyan; -Console.WriteLine($""" - ══════════════════════════════════════════════════════════ - Hotel Agent Client - Connected to: {agentEndpoint} - Type a message or 'quit' to exit - ══════════════════════════════════════════════════════════ - """); -Console.ResetColor(); -Console.WriteLine(); - -while (true) -{ - Console.ForegroundColor = ConsoleColor.Green; - Console.Write("You> "); - Console.ResetColor(); - - string? input = Console.ReadLine(); - - if (string.IsNullOrWhiteSpace(input)) { continue; } - if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } - - try - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.Write("Agent> "); - Console.ResetColor(); - - await foreach (var update in agent.RunStreamingAsync(input, session)) - { - Console.Write(update); - } - - Console.WriteLine(); - } - catch (Exception ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error: {ex.Message}"); - Console.ResetColor(); - } - - Console.WriteLine(); -} - -Console.WriteLine("Goodbye!"); - -/// -/// For Local Development Only -/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient -/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check. -/// -internal sealed class HttpSchemeRewritePolicy : PipelinePolicy -{ - public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - RewriteScheme(message); - ProcessNext(message, pipeline, currentIndex); - } - - public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - RewriteScheme(message); - await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); - } - - private static void RewriteScheme(PipelineMessage message) - { - var uri = message.Request.Uri!; - if (uri.Scheme == Uri.UriSchemeHttps) - { - message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri; - } - } -} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj deleted file mode 100644 index 028977d0aa..0000000000 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - Exe - net10.0 - enable - enable - false - AgentWithTextSearchRagClient - agent-with-rag-client - $(NoWarn);NU1903;NU1605;OPENAI001 - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/Program.cs deleted file mode 100644 index efc6c1d982..0000000000 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentWithTextSearchRag/Program.cs +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ClientModel.Primitives; -using Azure.AI.Extensions.OpenAI; -using Azure.AI.Projects; -using Azure.Identity; -using DotNetEnv; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Foundry; - -// Load .env file if present (for local development) -Env.TraversePath().Load(); - -Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") - ?? "http://localhost:8088"); - -var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") - ?? throw new InvalidOperationException("AGENT_NAME is not set."); - -// ── Create an agent-framework agent backed by the remote agent endpoint ────── - -var options = new AIProjectClientOptions(); - -if (agentEndpoint.Scheme == "http") -{ - // For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy - // BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right - // before the request hits the wire. - - agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; - options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); -} - -var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options); -FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName)); - -AgentSession session = await agent.CreateSessionAsync(); - -// ── REPL ────────────────────────────────────────────────────────────────────── - -Console.ForegroundColor = ConsoleColor.Cyan; -Console.WriteLine($""" - ══════════════════════════════════════════════════════════ - RAG Agent Client - Connected to: {agentEndpoint} - Type a message or 'quit' to exit - ══════════════════════════════════════════════════════════ - """); -Console.ResetColor(); -Console.WriteLine(); - -while (true) -{ - Console.ForegroundColor = ConsoleColor.Green; - Console.Write("You> "); - Console.ResetColor(); - - string? input = Console.ReadLine(); - - if (string.IsNullOrWhiteSpace(input)) { continue; } - if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } - - try - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.Write("Agent> "); - Console.ResetColor(); - - await foreach (var update in agent.RunStreamingAsync(input, session)) - { - Console.Write(update); - } - - Console.WriteLine(); - } - catch (Exception ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error: {ex.Message}"); - Console.ResetColor(); - } - - Console.WriteLine(); -} - -Console.WriteLine("Goodbye!"); - -/// -/// For Local Development Only -/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient -/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check. -/// -internal sealed class HttpSchemeRewritePolicy : PipelinePolicy -{ - public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - RewriteScheme(message); - ProcessNext(message, pipeline, currentIndex); - } - - public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - RewriteScheme(message); - await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); - } - - private static void RewriteScheme(PipelineMessage message) - { - var uri = message.Request.Uri!; - if (uri.Scheme == Uri.UriSchemeHttps) - { - message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri; - } - } -} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/AgentsInWorkflows.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/AgentsInWorkflows.csproj deleted file mode 100644 index 27e2da3908..0000000000 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/AgentsInWorkflows.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - Exe - net10.0 - enable - enable - false - AgentsInWorkflowsClient - agents-in-workflows-client - $(NoWarn);NU1903;NU1605;OPENAI001 - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/Program.cs deleted file mode 100644 index 33e4765fb9..0000000000 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/AgentsInWorkflows/Program.cs +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ClientModel.Primitives; -using Azure.AI.Extensions.OpenAI; -using Azure.AI.Projects; -using Azure.Identity; -using DotNetEnv; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Foundry; - -// Load .env file if present (for local development) -Env.TraversePath().Load(); - -Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") - ?? "http://localhost:8088"); - -var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") - ?? throw new InvalidOperationException("AGENT_NAME is not set."); - -// ── Create an agent-framework agent backed by the remote agent endpoint ────── - -var options = new AIProjectClientOptions(); - -if (agentEndpoint.Scheme == "http") -{ - // For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy - // BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right - // before the request hits the wire. - - agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; - options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); -} - -var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options); -FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName)); - -AgentSession session = await agent.CreateSessionAsync(); - -// ── REPL ────────────────────────────────────────────────────────────────────── - -Console.ForegroundColor = ConsoleColor.Cyan; -Console.WriteLine($""" - ══════════════════════════════════════════════════════════ - Translation Workflow Agent Client - Connected to: {agentEndpoint} - Type a message or 'quit' to exit - ══════════════════════════════════════════════════════════ - """); -Console.ResetColor(); -Console.WriteLine(); - -while (true) -{ - Console.ForegroundColor = ConsoleColor.Green; - Console.Write("You> "); - Console.ResetColor(); - - string? input = Console.ReadLine(); - - if (string.IsNullOrWhiteSpace(input)) { continue; } - if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } - - try - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.Write("Agent> "); - Console.ResetColor(); - - await foreach (var update in agent.RunStreamingAsync(input, session)) - { - Console.Write(update); - } - - Console.WriteLine(); - } - catch (Exception ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error: {ex.Message}"); - Console.ResetColor(); - } - - Console.WriteLine(); -} - -Console.WriteLine("Goodbye!"); - -/// -/// For Local Development Only -/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient -/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check. -/// -internal sealed class HttpSchemeRewritePolicy : PipelinePolicy -{ - public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - RewriteScheme(message); - ProcessNext(message, pipeline, currentIndex); - } - - public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - RewriteScheme(message); - await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); - } - - private static void RewriteScheme(PipelineMessage message) - { - var uri = message.Request.Uri!; - if (uri.Scheme == Uri.UriSchemeHttps) - { - message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri; - } - } -} From 5b16684930f6ac484b8b1e7741a1ae2f91247b48 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:11:05 +0100 Subject: [PATCH 29/31] Add Hosted-McpTools sample with dual MCP pattern Demonstrates two MCP integration layers in a single hosted agent: - Client-side MCP: McpClient connects to Microsoft Learn, agent handles tool invocations locally (docs_search, code_sample_search, docs_fetch) - Server-side MCP: HostedMcpServerTool delegates tool discovery and invocation to the LLM provider (Responses API), no local connection Includes DevTemporaryTokenCredential for Docker local debugging, Dockerfile.contributor for ProjectReference builds, and the openai/v1 route mapping for AIProjectClient compatibility in Development mode. --- dotnet/agent-framework-dotnet.slnx | 3 + .../HostedAgentsV2/Hosted-McpTools/.env.local | 4 + .../HostedAgentsV2/Hosted-McpTools/Dockerfile | 17 +++ .../Hosted-McpTools/Dockerfile.contributor | 18 +++ .../Hosted-McpTools/HostedMcpTools.csproj | 31 +++++ .../HostedAgentsV2/Hosted-McpTools/Program.cs | 130 ++++++++++++++++++ .../Properties/launchSettings.json | 11 ++ .../HostedAgentsV2/Hosted-McpTools/README.md | 86 ++++++++++++ .../Hosted-McpTools/agent.manifest.yaml | 30 ++++ .../HostedAgentsV2/Hosted-McpTools/agent.yaml | 9 ++ 10 files changed, 339 insertions(+) create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/.env.local create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/HostedMcpTools.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Properties/launchSettings.json create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/agent.yaml diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c065430b54..12554aa8d7 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -289,6 +289,9 @@ + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/.env.local b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/.env.local new file mode 100644 index 0000000000..6d7831229d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/.env.local @@ -0,0 +1,4 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Dockerfile new file mode 100644 index 0000000000..fe7fceb685 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedMcpTools.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Dockerfile.contributor new file mode 100644 index 0000000000..51c8c347d8 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Dockerfile.contributor @@ -0,0 +1,18 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local source, which means a standard +# multi-stage Docker build cannot resolve dependencies outside this folder. +# Pre-publish the app targeting the container runtime and copy the output: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-mcp-tools . +# docker run --rm -p 8088:8088 -e AGENT_NAME=mcp-tools -e GITHUB_PAT=$GITHUB_PAT -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-mcp-tools +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedMcpTools.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/HostedMcpTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/HostedMcpTools.csproj new file mode 100644 index 0000000000..9ce19dd540 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/HostedMcpTools.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + HostedMcpTools + HostedMcpTools + $(NoWarn); + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Program.cs new file mode 100644 index 0000000000..a969b75477 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Program.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates a hosted agent with two layers of MCP (Model Context Protocol) tools: +// +// 1. CLIENT-SIDE MCP: The agent connects to the Microsoft Learn MCP server directly via +// McpClient, discovers tools, and handles tool invocations locally within the agent process. +// +// 2. SERVER-SIDE MCP: The agent declares a HostedMcpServerTool for the same MCP server which +// delegates tool discovery and invocation to the LLM provider (Azure OpenAI Responses API). +// The provider calls the MCP server on behalf of the agent — no local connection needed. +// +// Both patterns use the Microsoft Learn MCP server to illustrate the architectural difference: +// client-side tools are resolved and invoked by the agent, while server-side tools are resolved +// and invoked by the LLM provider. + +#pragma warning disable MEAI001 // HostedMcpServerTool is experimental + +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); +var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; + +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +// ── Client-side MCP: Microsoft Learn (local resolution) ────────────────────── +// Connect directly to the MCP server. The agent discovers and invokes tools locally. +Console.WriteLine("Connecting to Microsoft Learn MCP server (client-side)..."); + +await using var learnMcp = await McpClient.CreateAsync(new HttpClientTransport(new() +{ + Endpoint = new Uri("https://learn.microsoft.com/api/mcp"), + Name = "Microsoft Learn (client)", +})); + +var clientTools = await learnMcp.ListToolsAsync(); +Console.WriteLine($"Client-side MCP tools: {string.Join(", ", clientTools.Select(t => t.Name))}"); + +// ── Server-side MCP: Microsoft Learn (provider resolution) ─────────────────── +// Declare a HostedMcpServerTool — the LLM provider (Responses API) handles tool +// invocations directly. No local MCP connection needed for this pattern. +AITool serverTool = new HostedMcpServerTool( + serverName: "microsoft_learn_hosted", + serverAddress: "https://learn.microsoft.com/api/mcp") +{ + AllowedTools = ["microsoft_docs_search"], + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire +}; +Console.WriteLine("Server-side MCP tool: microsoft_docs_search (via HostedMcpServerTool)"); + +// ── Combine both tool types into a single agent ────────────────────────────── +// The agent has access to tools from both MCP patterns simultaneously. +List allTools = [.. clientTools.Cast(), serverTool]; + +AIAgent agent = new AIProjectClient(projectEndpoint, credential) + .AsAIAgent( + model: deployment, + instructions: """ + You are a helpful developer assistant with access to Microsoft Learn documentation. + Use the available tools to search and retrieve documentation. + Be concise and provide direct answers with relevant links. + """, + name: "mcp-tools", + description: "Developer assistant with dual-layer MCP tools (client-side and server-side)", + tools: allTools); + +// Host the agent as a Foundry Hosted Agent using the Responses API. +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); + +// In Development, also map the OpenAI-compatible route that AIProjectClient uses. +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + +app.Run(); + +/// +/// A for local Docker debugging only. +/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable +/// once at startup. This should NOT be used in production. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + _token = Environment.GetEnvironmentVariable(EnvironmentVariable); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => GetAccessToken(); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(GetAccessToken()); + + private AccessToken GetAccessToken() + { + if (string.IsNullOrEmpty(_token)) + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(_token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Properties/launchSettings.json new file mode 100644 index 0000000000..3042eb4d44 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "HostedMcpTools": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:8088" + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/README.md new file mode 100644 index 0000000000..0990dfc6bd --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/README.md @@ -0,0 +1,86 @@ +# Hosted-McpTools + +A hosted agent demonstrating **two layers of MCP (Model Context Protocol) tool integration**: + +1. **Client-side MCP (GitHub)** — The agent connects directly to the GitHub MCP server via `McpClient`, discovers tools, and handles tool invocations locally within the agent process. + +2. **Server-side MCP (Microsoft Learn)** — The agent declares a `HostedMcpServerTool` which delegates tool discovery and invocation to the LLM provider (Azure OpenAI Responses API). The provider calls the MCP server on behalf of the agent with no local connection needed. + +## How the two MCP patterns differ + +| | Client-side MCP | Server-side MCP | +|---|---|---| +| **Connection** | Agent connects to MCP server directly | LLM provider connects to MCP server | +| **Tool invocation** | Handled by the agent process | Handled by the Responses API | +| **Auth** | Agent manages credentials (e.g., GitHub PAT) | Provider manages credentials | +| **Use case** | Custom/private MCP servers, fine-grained control | Public MCP servers, simpler setup | +| **Example** | GitHub (`McpClient` + `HttpClientTransport`) | Microsoft Learn (`HostedMcpServerTool`) | + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) +- A **GitHub Personal Access Token** (create at https://github.com/settings/tokens) + +## Configuration + +Copy the template and fill in your values: + +```bash +cp .env.local .env +``` + +Edit `.env`: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +GITHUB_PAT=ghp_your_token_here +``` + +## Running directly (contributors) + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools +dotnet run +``` + +### Test it + +Using the Azure Developer CLI: + +```bash +# Uses GitHub MCP (client-side) +azd ai agent invoke --local "Search for the agent-framework repository on GitHub" + +# Uses Microsoft Learn MCP (server-side) +azd ai agent invoke --local "How do I create an Azure storage account using az cli?" +``` + +## Running with Docker + +### 1. Publish for the container runtime + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build and run + +```bash +docker build -f Dockerfile.contributor -t hosted-mcp-tools . + +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=mcp-tools \ + -e GITHUB_PAT=$GITHUB_PAT \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-mcp-tools +``` + +## NuGet package users + +Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedMcpTools.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/agent.manifest.yaml new file mode 100644 index 0000000000..d5952940b0 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/agent.manifest.yaml @@ -0,0 +1,30 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: mcp-tools +displayName: "MCP Tools Agent" + +description: > + A developer assistant demonstrating dual-layer MCP integration: + client-side GitHub MCP tools handled by the agent and server-side + Microsoft Learn MCP tools delegated to the LLM provider. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Agent Framework + - MCP + - Model Context Protocol + +template: + name: mcp-tools + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/agent.yaml new file mode 100644 index 0000000000..34beb3e2c9 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: mcp-tools +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi From 548dfb10b69ae3938e0c1944f09051dfa3b134a2 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 15 Apr 2026 16:34:28 -0700 Subject: [PATCH 30/31] =?UTF-8?q?.NET:=20Bump=20Azure.AI.AgentServer=20pac?= =?UTF-8?q?kages=20to=201.0.0-beta.1/beta.21=20and=20fix=20br=E2=80=A6=20(?= =?UTF-8?q?#5287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump Azure.AI.AgentServer packages to 1.0.0-beta.1/beta.21 and fix breaking API changes - Azure.AI.AgentServer.Core: 1.0.0-beta.11 -> 1.0.0-beta.21 - Azure.AI.AgentServer.Invocations: 1.0.0-alpha.20260408.4 -> 1.0.0-beta.1 - Azure.AI.AgentServer.Responses: 1.0.0-alpha.20260408.4 -> 1.0.0-beta.1 - Azure.Identity: 1.20.0 -> 1.21.0 (transitive requirement) - Azure.Core: 1.52.0 -> 1.53.0 (transitive requirement) - Remove azure-sdk-for-net dev feed (packages now on nuget.org) - Fix OutputConverter for new builder API (auto-tracked children, split EmitTextDone/EmitDone) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fixing small issues. --------- Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 10 +++++----- dotnet/nuget.config | 4 ---- .../samples/04-hosting/FoundryResponsesRepl/Program.cs | 10 +++++++++- .../Hosting/OutputConverter.cs | 5 ++--- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 36e68c958a..5beaaab40d 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -19,14 +19,14 @@ - - - + + + - - + + diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 202c1fc671..76d943ce16 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -3,12 +3,8 @@ - - - - diff --git a/dotnet/samples/04-hosting/FoundryResponsesRepl/Program.cs b/dotnet/samples/04-hosting/FoundryResponsesRepl/Program.cs index be0e6ccf7b..8b877dc519 100644 --- a/dotnet/samples/04-hosting/FoundryResponsesRepl/Program.cs +++ b/dotnet/samples/04-hosting/FoundryResponsesRepl/Program.cs @@ -1,3 +1,5 @@ +// Copyright (c) Microsoft. All rights reserved. + // Foundry Responses Client REPL // // Connects to a Foundry Responses agent running on a given endpoint @@ -66,10 +68,16 @@ while (true) Console.ResetColor(); string? input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input)) continue; + if (string.IsNullOrWhiteSpace(input)) + { + continue; + } + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { break; + } try { diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/OutputConverter.cs index 79aaf768d9..58ba989ebf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/OutputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Hosting/OutputConverter.cs @@ -161,7 +161,6 @@ internal static class OutputConverter yield return summaryPart.EmitTextDelta(text); yield return summaryPart.EmitTextDone(text); yield return summaryPart.EmitDone(); - reasoningBuilder.EmitSummaryPartDone(summaryPart); yield return reasoningBuilder.EmitDone(); break; @@ -236,8 +235,8 @@ internal static class OutputConverter if (textBuilder is not null) { var finalText = accumulatedText?.ToString() ?? string.Empty; - yield return textBuilder.EmitDone(finalText); - yield return messageBuilder.EmitContentDone(textBuilder); + yield return textBuilder.EmitTextDone(finalText); + yield return textBuilder.EmitDone(); } yield return messageBuilder.EmitDone(); From 53fec2a989d669adfc954cba195ac12ee6dbac50 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:43:13 +0100 Subject: [PATCH 31/31] Add Invocations protocol samples (hosted echo agent + client) (#5278) Add Hosted-Invocations-EchoAgent: a minimal echo agent hosted via the Invocations protocol (POST /invocations) using AddInvocationsServer and MapInvocationsServer, bridged to an Agent Framework AIAgent through a custom InvocationHandler. Add SimpleInvocationsAgent: a console REPL client that wraps HttpClient calls to the /invocations endpoint in a custom InvocationsAIAgent, demonstrating programmatic consumption of the Invocations protocol. Both samples default to port 8088 for consistency with other hosted agent samples. --- .../Hosted-Invocations-EchoAgent/Dockerfile | 17 +++ .../Dockerfile.contributor | 19 +++ .../EchoAIAgent.cs | 85 ++++++++++++ .../EchoInvocationHandler.cs | 33 +++++ .../Hosted-Invocations-EchoAgent.csproj | 29 ++++ .../Hosted-Invocations-EchoAgent/Program.cs | 24 ++++ .../Properties/launchSettings.json | 11 ++ .../Hosted-Invocations-EchoAgent/README.md | 66 +++++++++ .../agent.manifest.yaml | 27 ++++ .../Hosted-Invocations-EchoAgent/agent.yaml | 9 ++ .../InvocationsAIAgent.cs | 129 ++++++++++++++++++ .../SimpleInvocationsAgent/Program.cs | 61 +++++++++ .../SimpleInvocationsAgent.csproj | 22 +++ 13 files changed, 532 insertions(+) create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/EchoAIAgent.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/EchoInvocationHandler.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Properties/launchSettings.json create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/agent.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/InvocationsAIAgent.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/SimpleInvocationsAgent.csproj diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Dockerfile new file mode 100644 index 0000000000..24585dec12 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedInvocationsEchoAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Dockerfile.contributor new file mode 100644 index 0000000000..91a403c26c --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Abstractions source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-invocations-echo-agent . +# docker run --rm -p 8088:8088 hosted-invocations-echo-agent +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedInvocationsEchoAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/EchoAIAgent.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/EchoAIAgent.cs new file mode 100644 index 0000000000..ccbfe72781 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/EchoAIAgent.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI; + +/// +/// A minimal that echoes the user's input text back as the response. +/// No LLM or external service is required. +/// +public sealed class EchoAIAgent : AIAgent +{ + /// + public override string Name => "echo-agent"; + + /// + public override string Description => "An agent that echoes back the input message."; + + /// + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var inputText = GetInputText(messages); + var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, $"Echo: {inputText}")); + return Task.FromResult(response); + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var inputText = GetInputText(messages); + yield return new AgentResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new TextContent($"Echo: {inputText}")], + }; + + await Task.CompletedTask; + } + + /// + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new EchoAgentSession()); + + /// + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(JsonSerializer.SerializeToElement(new { }, jsonSerializerOptions)); + + /// + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(new EchoAgentSession()); + + private static string GetInputText(IEnumerable messages) + { + foreach (var message in messages) + { + if (message.Role == ChatRole.User) + { + return message.Text ?? string.Empty; + } + } + + return string.Empty; + } + + /// + /// Minimal session for the echo agent. No state is persisted. + /// + private sealed class EchoAgentSession : AgentSession; +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/EchoInvocationHandler.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/EchoInvocationHandler.cs new file mode 100644 index 0000000000..9834e7577a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/EchoInvocationHandler.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.AgentServer.Invocations; +using Microsoft.Agents.AI; +using Microsoft.AspNetCore.Http; + +namespace HostedInvocationsEchoAgent; + +/// +/// An that reads the request body as plain text, +/// passes it to the , and writes the response back. +/// +public sealed class EchoInvocationHandler(EchoAIAgent agent) : InvocationHandler +{ + /// + public override async Task HandleAsync( + HttpRequest request, + HttpResponse response, + InvocationContext context, + CancellationToken cancellationToken) + { + // Read the raw text from the request body. + using var reader = new StreamReader(request.Body); + var input = await reader.ReadToEndAsync(cancellationToken); + + // Run the echo agent with the input text. + var agentResponse = await agent.RunAsync(input, cancellationToken: cancellationToken); + + // Write the agent response text back to the HTTP response. + response.ContentType = "text/plain"; + await response.WriteAsync(agentResponse.Text, cancellationToken); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj new file mode 100644 index 0000000000..b84faccf9e --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + HostedInvocationsEchoAgent + HostedInvocationsEchoAgent + $(NoWarn); + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Program.cs new file mode 100644 index 0000000000..1650253dfc --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Program.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.AgentServer.Invocations; +using HostedInvocationsEchoAgent; +using Microsoft.Agents.AI; + +var builder = WebApplication.CreateBuilder(args); + +// Register the echo agent as a singleton (no LLM needed). +builder.Services.AddSingleton(); + +// Register the Invocations SDK services and wire the handler. +builder.Services.AddInvocationsServer(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +// Map the Invocations protocol endpoints: +// POST /invocations — invoke the agent +// GET /invocations/{id} — get result (not used by this sample) +// POST /invocations/{id}/cancel — cancel (not used by this sample) +app.MapInvocationsServer(); + +app.Run(); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Properties/launchSettings.json new file mode 100644 index 0000000000..7722815b12 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Hosted-Invocations-EchoAgent": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:8088" + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/README.md new file mode 100644 index 0000000000..7133c43cf1 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/README.md @@ -0,0 +1,66 @@ +# Hosted-Invocations-EchoAgent + +A minimal echo agent hosted as a Foundry Hosted Agent using the **Invocations protocol**. The agent reads the request body as plain text, passes it through a custom `EchoAIAgent`, and writes the echoed text back in the response. No LLM or Azure credentials are required. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) + +## Running directly (contributors) + +This project uses `ProjectReference` to build against the local Agent Framework source. + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent +dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +```bash +curl -X POST http://localhost:8088/invocations \ + -H "Content-Type: text/plain" \ + -d "Hello, world!" +``` + +Expected response: + +``` +Echo: Hello, world! +``` + +## Running with Docker + +Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies outside this folder. Use `Dockerfile.contributor` which takes a pre-published output. + +### 1. Publish for the container runtime (Linux Alpine) + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-invocations-echo-agent . +``` + +### 3. Run the container + +```bash +docker run --rm -p 8088:8088 hosted-invocations-echo-agent +``` + +### 4. Test it + +```bash +curl -X POST http://localhost:8088/invocations \ + -H "Content-Type: text/plain" \ + -d "Hello from Docker!" +``` + +## NuGet package users + +If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `Hosted-Invocations-EchoAgent.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/agent.manifest.yaml new file mode 100644 index 0000000000..09e4b0f885 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/agent.manifest.yaml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-invocations-echo-agent +displayName: "Hosted Invocations Echo Agent" + +description: > + A minimal echo agent hosted as a Foundry Hosted Agent using the Invocations + protocol. Reads the request body as plain text, echoes it back in the response. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Invocations Protocol + - Agent Framework + +template: + name: hosted-invocations-echo-agent + kind: hosted + protocols: + - protocol: invocations + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/agent.yaml new file mode 100644 index 0000000000..001a19f0ac --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-invocations-echo-agent +protocols: + - protocol: invocations + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/InvocationsAIAgent.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/InvocationsAIAgent.cs new file mode 100644 index 0000000000..54f8022d1b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/InvocationsAIAgent.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI; + +/// +/// An that invokes a remote agent hosted with the Invocations protocol +/// by sending plain-text HTTP POST requests to the /invocations endpoint. +/// +public sealed class InvocationsAIAgent : AIAgent +{ + private readonly HttpClient _httpClient; + private readonly Uri _invocationsUri; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The base URI of the hosted agent (e.g., http://localhost:8089). + /// The /invocations path is appended automatically. + /// + /// Optional to use. If , a new instance is created. + /// Optional name for the agent. + /// Optional description for the agent. + public InvocationsAIAgent( + Uri agentEndpoint, + HttpClient? httpClient = null, + string? name = null, + string? description = null) + { + ArgumentNullException.ThrowIfNull(agentEndpoint); + + this._httpClient = httpClient ?? new HttpClient(); + + // Ensure the base URI ends with a slash so that combining works correctly. + var baseUri = agentEndpoint.AbsoluteUri.EndsWith('/') + ? agentEndpoint + : new Uri(agentEndpoint.AbsoluteUri + "/"); + this._invocationsUri = new Uri(baseUri, "invocations"); + + this.Name = name ?? "invocations-agent"; + this.Description = description ?? "An agent that calls a remote Invocations protocol endpoint."; + } + + /// + public override string? Name { get; } + + /// + public override string? Description { get; } + + /// + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var inputText = GetLastUserText(messages); + var responseText = await SendInvocationAsync(inputText, cancellationToken).ConfigureAwait(false); + return new AgentResponse(new ChatMessage(ChatRole.Assistant, responseText)); + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // The Invocations protocol returns a complete response (no SSE streaming), + // so we yield a single update with the full text. + var inputText = GetLastUserText(messages); + var responseText = await SendInvocationAsync(inputText, cancellationToken).ConfigureAwait(false); + + yield return new AgentResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new TextContent(responseText)], + }; + } + + /// + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new InvocationsAgentSession()); + + /// + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(JsonSerializer.SerializeToElement(new { }, jsonSerializerOptions)); + + /// + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(new InvocationsAgentSession()); + + private async Task SendInvocationAsync(string input, CancellationToken cancellationToken) + { + using var content = new StringContent(input, System.Text.Encoding.UTF8, "text/plain"); + using var response = await this._httpClient.PostAsync(this._invocationsUri, content, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + + private static string GetLastUserText(IEnumerable messages) + { + string? lastUserText = null; + foreach (var message in messages) + { + if (message.Role == ChatRole.User) + { + lastUserText = message.Text; + } + } + + return lastUserText ?? string.Empty; + } + + /// + /// Minimal session for the invocations agent. No state is persisted. + /// + private sealed class InvocationsAgentSession : AgentSession; +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/Program.cs new file mode 100644 index 0000000000..915e73737d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/Program.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using DotNetEnv; +using Microsoft.Agents.AI; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") + ?? "http://localhost:8088"); + +// Create an agent that calls the remote Invocations endpoint. +InvocationsAIAgent agent = new(agentEndpoint); + +// REPL +Console.ForegroundColor = ConsoleColor.Cyan; +Console.WriteLine($""" + ══════════════════════════════════════════════════════════ + Simple Invocations Agent Sample + Connected to: {agentEndpoint} + Type a message or 'quit' to exit + ══════════════════════════════════════════════════════════ + """); +Console.ResetColor(); +Console.WriteLine(); + +while (true) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("You> "); + Console.ResetColor(); + + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) { continue; } + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } + + try + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("Agent> "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(input)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error: {ex.Message}"); + Console.ResetColor(); + } + + Console.WriteLine(); +} + +Console.WriteLine("Goodbye!"); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/SimpleInvocationsAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/SimpleInvocationsAgent.csproj new file mode 100644 index 0000000000..d509bd2c70 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/SimpleInvocationsAgent.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + false + SimpleInvocationsAgentClient + simple-invocations-agent-client + $(NoWarn);NU1903;NU1605 + + + + + + + + + + +