diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs index 3e8d58b7c7..aadb4e58f1 100644 --- a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs +++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -37,6 +38,10 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable // Frontend resources loaded from the Microsoft.Agents.AI.DevUI assembly (null if unavailable) private readonly Dictionary? _frontendResources; + // Maps conversation IDs to backend URLs for routing GET requests that lack agent_id context. + // Populated when the aggregator routes conversation requests to a positively-resolved backend. + private readonly ConcurrentDictionary _conversationBackendMap = new(StringComparer.OrdinalIgnoreCase); + public DevUIAggregatorHostedService( DevUIResource resource, ILogger logger) @@ -469,6 +474,16 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable ? RewriteAgentIdInQueryString(context.Request.QueryString, actualAgentId) : context.Request.QueryString.ToString(); + // Try conversation→backend map for previously-seen conversations + if (backendUrl is null) + { + var conversationId = ExtractConversationId(path); + if (conversationId is not null && this._conversationBackendMap.TryGetValue(conversationId, out var mappedUrl)) + { + backendUrl = mappedUrl; + } + } + if (backendUrl is null && context.Request.ContentLength > 0) { var bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false); @@ -502,8 +517,8 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable ? RewriteAgentIdInQueryString(context.Request.QueryString, actualId) : context.Request.QueryString.ToString(); - await ProxyRequestAsync( - context, backendUrl, targetPath + bodyQueryString, rewritten).ConfigureAwait(false); + await this.ProxyAndRecordConversationAsync( + context, backendUrl, path, targetPath + bodyQueryString, rewritten).ConfigureAwait(false); return; } } @@ -522,7 +537,8 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable return; } - // No body and no query param — route to first backend + // Route to resolved backend (from query or conversation map), or fall back to first backend + var backendKnown = backendUrl is not null; backendUrl ??= this.ResolveBackends().Values.FirstOrDefault(); if (backendUrl is null) { @@ -531,8 +547,16 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable } var convPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}"; - await ProxyRequestAsync( - context, backendUrl, convPath + queryString, bodyBytes: null).ConfigureAwait(false); + if (backendKnown) + { + await this.ProxyAndRecordConversationAsync( + context, backendUrl, path, convPath + queryString, bodyBytes: null).ConfigureAwait(false); + } + else + { + await ProxyRequestAsync( + context, backendUrl, convPath + queryString, bodyBytes: null).ConfigureAwait(false); + } } /// @@ -551,6 +575,86 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable return QueryString.Create(query).ToString(); } + private static string? ExtractConversationId(string? path) + { + if (string.IsNullOrEmpty(path)) + { + return null; + } + + var slashIndex = path.IndexOf('/'); + return slashIndex > 0 ? path[..slashIndex] : path; + } + + /// + /// Records the conversation→backend mapping and proxies the request. + /// For creation POSTs (no conversation ID in path), intercepts the response to capture the new ID. + /// + private async Task ProxyAndRecordConversationAsync( + HttpContext context, + string backendUrl, + string? conversationPath, + string targetUrl, + byte[]? bodyBytes) + { + var conversationId = ExtractConversationId(conversationPath); + if (conversationId is not null) + { + // We already know the conversation ID — record and proxy normally + this._conversationBackendMap[conversationId] = backendUrl; + await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false); + return; + } + + // Creation POST: intercept response to capture the new conversation ID + if (!context.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase)) + { + await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false); + return; + } + + var originalBody = context.Response.Body; + using var buffer = new MemoryStream(); + context.Response.Body = buffer; + + try + { + await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false); + + if (context.Response.StatusCode is >= 200 and < 300) + { + buffer.Position = 0; + try + { + using var doc = await JsonDocument.ParseAsync( + buffer, cancellationToken: context.RequestAborted).ConfigureAwait(false); + if (doc.RootElement.TryGetProperty("id", out var idProp) && + idProp.ValueKind == JsonValueKind.String) + { + var createdId = idProp.GetString(); + if (createdId is not null) + { + this._conversationBackendMap[createdId] = backendUrl; + this._logger.LogDebug( + "Recorded conversation '{ConversationId}' → backend '{BackendUrl}'", + createdId, backendUrl); + } + } + } + catch + { + // Best-effort: response may not be parseable JSON + } + } + } + finally + { + context.Response.Body = originalBody; + buffer.Position = 0; + await buffer.CopyToAsync(originalBody, context.RequestAborted).ConfigureAwait(false); + } + } + private static async Task ProxyRequestAsync( HttpContext context, string backendUrl, diff --git a/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj index b060a2860e..b8f2f1757d 100644 --- a/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj +++ b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj @@ -4,7 +4,7 @@ Exe - net10.0 + net10.0 enable enable @@ -14,6 +14,9 @@ + + + diff --git a/dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj b/dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj index cbbbccac4a..01a3cf51d9 100644 --- a/dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj +++ b/dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj @@ -1,13 +1,13 @@ - net10.0 + net10.0 enable enable b2c3d4e5-f6a7-8901-bcde-f12345678901 - + diff --git a/dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj b/dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj index a30f969a80..d1a26923b4 100644 --- a/dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj +++ b/dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj @@ -1,13 +1,13 @@ - net10.0 + net10.0 enable enable a1b2c3d4-e5f6-7890-abcd-ef1234567890 - +