fixes Conversation routing for non-first backends

This commit is contained in:
tommasodotnet
2026-03-05 09:57:26 +01:00
Unverified
parent fedbc3b20a
commit d31e5b8404
4 changed files with 117 additions and 10 deletions
@@ -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<string, (string ResourceName, string ContentType)>? _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<string, string> _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);
}
}
/// <summary>
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
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,
@@ -4,7 +4,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -14,6 +14,9 @@
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net472'">
<PackageReference Include="Aspire.Hosting.Azure.AIFoundry" />
<PackageReference Include="OpenAI" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" IsAspireProjectResource="false" />
@@ -1,13 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>b2c3d4e5-f6a7-8901-bcde-f12345678901</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net472'">
<PackageReference Include="Aspire.Azure.AI.Inference" />
<PackageReference Include="Azure.Identity" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
@@ -1,13 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>a1b2c3d4-e5f6-7890-abcd-ef1234567890</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net472'">
<PackageReference Include="Aspire.Azure.AI.Inference" />
<PackageReference Include="Azure.Identity" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />