Skip to content

Commit f71faa8

Browse files
authored
Python: DevUI: Use metadata.entity_id instead of model field (#1984)
* DevUI: Use metadata.entity_id for agent/workflow name instead of model field * OpenAI Responses: add explicit request validation * Review feedback
1 parent 778a9fe commit f71faa8

23 files changed

+785
-461
lines changed

dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ public AIAgentResponseExecutor(AIAgent agent)
2424
this._agent = agent;
2525
}
2626

27+
public ValueTask<ResponseError?> ValidateRequestAsync(
28+
CreateResponse request,
29+
CancellationToken cancellationToken = default) => ValueTask.FromResult<ResponseError?>(null);
30+
2731
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
2832
AgentInvocationContext context,
2933
CreateResponse request,

dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,15 @@ public static Response ToResponse(
5656
MaxOutputTokens = request.MaxOutputTokens,
5757
MaxToolCalls = request.MaxToolCalls,
5858
Metadata = request.Metadata is IReadOnlyDictionary<string, string> metadata ? new Dictionary<string, string>(metadata) : [],
59-
Model = request.Agent?.Name ?? request.Model,
59+
Model = request.Model,
6060
Output = output,
6161
ParallelToolCalls = request.ParallelToolCalls ?? true,
6262
PreviousResponseId = request.PreviousResponseId,
6363
Prompt = request.Prompt,
6464
PromptCacheKey = request.PromptCacheKey,
6565
Reasoning = request.Reasoning,
6666
SafetyIdentifier = request.SafetyIdentifier,
67-
ServiceTier = request.ServiceTier ?? "default",
67+
ServiceTier = request.ServiceTier,
6868
Status = ResponseStatus.Completed,
6969
Store = request.Store ?? true,
7070
Temperature = request.Temperature ?? 1.0,

dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseUpdateExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ Response CreateResponse(ResponseStatus status = ResponseStatus.Completed, IEnume
165165
MaxOutputTokens = request.MaxOutputTokens,
166166
MaxToolCalls = request.MaxToolCalls,
167167
Metadata = request.Metadata != null ? new Dictionary<string, string>(request.Metadata) : [],
168-
Model = request.Agent?.Name ?? request.Model,
168+
Model = request.Model,
169169
Output = outputs?.ToList() ?? [],
170170
ParallelToolCalls = request.ParallelToolCalls ?? true,
171171
PreviousResponseId = request.PreviousResponseId,

dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs

Lines changed: 44 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
1414

1515
/// <summary>
16-
/// Response executor that routes requests to hosted AIAgent services based on the model or agent.name parameter.
16+
/// Response executor that routes requests to hosted AIAgent services based on agent.name or metadata["entity_id"].
1717
/// This executor resolves agents from keyed services registered via AddAIAgent().
18+
/// The model field is reserved for actual model names and is never used for entity/agent identification.
1819
/// </summary>
1920
internal sealed class HostedAgentResponseExecutor : IResponseExecutor
2021
{
@@ -37,16 +38,46 @@ public HostedAgentResponseExecutor(
3738
this._logger = logger;
3839
}
3940

41+
/// <inheritdoc/>
42+
public ValueTask<ResponseError?> ValidateRequestAsync(
43+
CreateResponse request,
44+
CancellationToken cancellationToken = default)
45+
{
46+
// Extract agent name from agent.name or model parameter
47+
string? agentName = GetAgentName(request);
48+
49+
if (string.IsNullOrEmpty(agentName))
50+
{
51+
return ValueTask.FromResult<ResponseError?>(new ResponseError
52+
{
53+
Code = "missing_required_parameter",
54+
Message = "No 'agent.name' or 'metadata[\"entity_id\"]' specified in the request."
55+
});
56+
}
57+
58+
// Validate that the agent can be resolved
59+
AIAgent? agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
60+
if (agent is null)
61+
{
62+
this._logger.LogWarning("Failed to resolve agent with name '{AgentName}'", agentName);
63+
return ValueTask.FromResult<ResponseError?>(new ResponseError
64+
{
65+
Code = "agent_not_found",
66+
Message = $"Agent '{agentName}' not found. Ensure the agent is registered with AddAIAgent()."
67+
});
68+
}
69+
70+
return ValueTask.FromResult<ResponseError?>(null);
71+
}
72+
4073
/// <inheritdoc/>
4174
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
4275
AgentInvocationContext context,
4376
CreateResponse request,
4477
[EnumeratorCancellation] CancellationToken cancellationToken = default)
4578
{
46-
// Validate and resolve agent synchronously to ensure validation errors are thrown immediately
47-
AIAgent agent = this.ResolveAgent(request);
48-
49-
// Create options with properties from the request
79+
string agentName = GetAgentName(request)!;
80+
AIAgent agent = this._serviceProvider.GetRequiredKeyedService<AIAgent>(agentName);
5081
var chatOptions = new ChatOptions
5182
{
5283
ConversationId = request.Conversation?.Id,
@@ -57,16 +88,13 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
5788
ModelId = request.Model,
5889
};
5990
var options = new ChatClientAgentRunOptions(chatOptions);
60-
61-
// Convert input to chat messages
6291
var messages = new List<ChatMessage>();
6392

6493
foreach (var inputMessage in request.Input.GetInputMessages())
6594
{
6695
messages.Add(inputMessage.ToChatMessage());
6796
}
6897

69-
// Use the extension method to convert streaming updates to streaming response events
7098
await foreach (var streamingEvent in agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken)
7199
.ToStreamingResponseAsync(request, context, cancellationToken).ConfigureAwait(false))
72100
{
@@ -75,41 +103,20 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
75103
}
76104

77105
/// <summary>
78-
/// Resolves an agent from the service provider based on the request.
106+
/// Extracts the agent name for a request from the agent.name property, falling back to metadata["entity_id"].
79107
/// </summary>
80108
/// <param name="request">The create response request.</param>
81-
/// <returns>The resolved AIAgent instance.</returns>
82-
/// <exception cref="InvalidOperationException">Thrown when the agent cannot be resolved.</exception>
83-
private AIAgent ResolveAgent(CreateResponse request)
109+
/// <returns>The agent name.</returns>
110+
private static string? GetAgentName(CreateResponse request)
84111
{
85-
// Extract agent name from agent.name or model parameter
86-
var agentName = request.Agent?.Name ?? request.Model;
87-
if (string.IsNullOrEmpty(agentName))
88-
{
89-
throw new InvalidOperationException("No 'agent.name' or 'model' specified in the request.");
90-
}
112+
string? agentName = request.Agent?.Name;
91113

92-
// Resolve the keyed agent service
93-
try
114+
// Fall back to metadata["entity_id"] if agent.name is not present
115+
if (string.IsNullOrEmpty(agentName) && request.Metadata?.TryGetValue("entity_id", out string? entityId) == true)
94116
{
95-
return this._serviceProvider.GetRequiredKeyedService<AIAgent>(agentName);
117+
agentName = entityId;
96118
}
97-
catch (InvalidOperationException ex)
98-
{
99-
this._logger.LogError(ex, "Failed to resolve agent with name '{AgentName}'", agentName);
100-
throw new InvalidOperationException($"Agent '{agentName}' not found. Ensure the agent is registered with AddAIAgent().", ex);
101-
}
102-
}
103119

104-
/// <summary>
105-
/// Validates that the agent can be resolved without actually resolving it.
106-
/// This allows early validation before starting async execution.
107-
/// </summary>
108-
/// <param name="request">The create response request.</param>
109-
/// <exception cref="InvalidOperationException">Thrown when the agent cannot be resolved.</exception>
110-
public void ValidateAgent(CreateResponse request)
111-
{
112-
// Use the same logic as ResolveAgent but don't return the agent
113-
_ = this.ResolveAgent(request);
120+
return agentName;
114121
}
115122
}

dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponseExecutor.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
using System.Collections.Generic;
44
using System.Threading;
5+
using System.Threading.Tasks;
56
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
67

78
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
@@ -12,6 +13,16 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
1213
/// </summary>
1314
internal interface IResponseExecutor
1415
{
16+
/// <summary>
17+
/// Validates a create response request before execution.
18+
/// </summary>
19+
/// <param name="request">The create response request to validate.</param>
20+
/// <param name="cancellationToken">Cancellation token.</param>
21+
/// <returns>A <see cref="ResponseError"/> if validation fails, null if validation succeeds.</returns>
22+
ValueTask<ResponseError?> ValidateRequestAsync(
23+
CreateResponse request,
24+
CancellationToken cancellationToken = default);
25+
1526
/// <summary>
1627
/// Executes a response generation request and returns streaming events.
1728
/// </summary>

dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ internal interface IResponsesService
1818
/// Default limit for list operations.
1919
/// </summary>
2020
const int DefaultListLimit = 20;
21+
22+
/// <summary>
23+
/// Validates a create response request before execution.
24+
/// </summary>
25+
/// <param name="request">The create response request to validate.</param>
26+
/// <param name="cancellationToken">Cancellation token.</param>
27+
/// <returns>A ResponseError if validation fails, null if validation succeeds.</returns>
28+
ValueTask<ResponseError?> ValidateRequestAsync(
29+
CreateResponse request,
30+
CancellationToken cancellationToken = default);
31+
2132
/// <summary>
2233
/// Creates a model response for the given input.
2334
/// </summary>

dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -147,18 +147,27 @@ public InMemoryResponsesService(IResponseExecutor executor, InMemoryStorageOptio
147147
this._conversationStorage = conversationStorage;
148148
}
149149

150-
public async Task<Response> CreateResponseAsync(
150+
public async ValueTask<ResponseError?> ValidateRequestAsync(
151151
CreateResponse request,
152152
CancellationToken cancellationToken = default)
153153
{
154-
ValidateRequest(request);
155-
156-
// Validate agent resolution early for HostedAgentResponseExecutor
157-
if (this._executor is HostedAgentResponseExecutor hostedExecutor)
154+
if (request.Conversation is not null && !string.IsNullOrEmpty(request.Conversation.Id) &&
155+
!string.IsNullOrEmpty(request.PreviousResponseId))
158156
{
159-
hostedExecutor.ValidateAgent(request);
157+
return new ResponseError
158+
{
159+
Code = "invalid_request",
160+
Message = "Mutually exclusive parameters: 'conversation' and 'previous_response_id'. Ensure you are only providing one of: 'previous_response_id' or 'conversation'."
161+
};
160162
}
161163

164+
return await this._executor.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false);
165+
}
166+
167+
public async Task<Response> CreateResponseAsync(
168+
CreateResponse request,
169+
CancellationToken cancellationToken = default)
170+
{
162171
if (request.Stream == true)
163172
{
164173
throw new InvalidOperationException("Cannot create a streaming response using CreateResponseAsync. Use CreateResponseStreamingAsync instead.");
@@ -189,8 +198,6 @@ public async IAsyncEnumerable<StreamingResponseEvent> CreateResponseStreamingAsy
189198
CreateResponse request,
190199
[EnumeratorCancellation] CancellationToken cancellationToken = default)
191200
{
192-
ValidateRequest(request);
193-
194201
if (request.Stream == false)
195202
{
196203
throw new InvalidOperationException("Cannot create a non-streaming response using CreateResponseStreamingAsync. Use CreateResponseAsync instead.");
@@ -342,15 +349,6 @@ public Task<ListResponse<ItemResource>> ListResponseInputItemsAsync(
342349
});
343350
}
344351

345-
private static void ValidateRequest(CreateResponse request)
346-
{
347-
if (request.Conversation is not null && !string.IsNullOrEmpty(request.Conversation.Id) &&
348-
!string.IsNullOrEmpty(request.PreviousResponseId))
349-
{
350-
throw new InvalidOperationException("Mutually exclusive parameters: 'conversation' and 'previous_response_id'. Ensure you are only providing one of: 'previous_response_id' or 'conversation'.");
351-
}
352-
}
353-
354352
private ResponseState InitializeResponse(string responseId, CreateResponse request)
355353
{
356354
var metadata = request.Metadata ?? [];
@@ -371,7 +369,7 @@ private ResponseState InitializeResponse(string responseId, CreateResponse reque
371369
MaxOutputTokens = request.MaxOutputTokens,
372370
MaxToolCalls = request.MaxToolCalls,
373371
Metadata = metadata,
374-
Model = request.Model ?? "default",
372+
Model = request.Model,
375373
Output = [],
376374
ParallelToolCalls = request.ParallelToolCalls ?? true,
377375
PreviousResponseId = request.PreviousResponseId,

dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs

Lines changed: 30 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,21 @@ public async Task<IResult> CreateResponseAsync(
3434
[FromQuery] bool? stream,
3535
CancellationToken cancellationToken)
3636
{
37+
// Validate the request first
38+
ResponseError? validationError = await this._responsesService.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false);
39+
if (validationError is not null)
40+
{
41+
return Results.BadRequest(new ErrorResponse
42+
{
43+
Error = new ErrorDetails
44+
{
45+
Message = validationError.Message,
46+
Type = "invalid_request_error",
47+
Code = validationError.Code
48+
}
49+
});
50+
}
51+
3752
try
3853
{
3954
// Handle streaming vs non-streaming
@@ -55,45 +70,24 @@ public async Task<IResult> CreateResponseAsync(
5570
request,
5671
cancellationToken: cancellationToken).ConfigureAwait(false);
5772

58-
return Results.Ok(response);
59-
}
60-
catch (InvalidOperationException ex) when (ex.Message.Contains("Mutually exclusive"))
61-
{
62-
// Return OpenAI-style error for mutual exclusivity violations
63-
return Results.BadRequest(new ErrorResponse
73+
return response.Status switch
6474
{
65-
Error = new ErrorDetails
66-
{
67-
Message = ex.Message,
68-
Type = "invalid_request_error",
69-
Code = "mutually_exclusive_parameters"
70-
}
71-
});
72-
}
73-
catch (InvalidOperationException ex) when (ex.Message.Contains("not found") || ex.Message.Contains("does not exist"))
74-
{
75-
// Return OpenAI-style error for not found errors
76-
return Results.NotFound(new ErrorResponse
77-
{
78-
Error = new ErrorDetails
79-
{
80-
Message = ex.Message,
81-
Type = "invalid_request_error"
82-
}
83-
});
75+
ResponseStatus.Failed when response.Error is { } error => Results.Problem(
76+
detail: error.Message,
77+
statusCode: StatusCodes.Status500InternalServerError,
78+
title: error.Code ?? "Internal Server Error"),
79+
ResponseStatus.Failed => Results.Problem(),
80+
ResponseStatus.Queued => Results.Accepted(value: response),
81+
_ => Results.Ok(response)
82+
};
8483
}
85-
catch (InvalidOperationException ex) when (ex.Message.Contains("No 'agent.name' or 'model' specified"))
84+
catch (Exception ex)
8685
{
87-
// Return OpenAI-style error for missing required parameters
88-
return Results.BadRequest(new ErrorResponse
89-
{
90-
Error = new ErrorDetails
91-
{
92-
Message = ex.Message,
93-
Type = "invalid_request_error",
94-
Code = "missing_required_parameter"
95-
}
96-
});
86+
// Return InternalServerError for unexpected exceptions
87+
return Results.Problem(
88+
detail: ex.Message,
89+
statusCode: StatusCodes.Status500InternalServerError,
90+
title: "Internal Server Error");
9791
}
9892
}
9993

dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIHttpApiIntegrationTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public async Task CreateConversationAndResponse_NonStreaming_NonBackground_Updat
4545
// Act - Create response (non-streaming, non-background)
4646
var createResponseRequest = new
4747
{
48-
model = AgentName,
48+
metadata = new { entity_id = AgentName },
4949
conversation = conversationId,
5050
input = UserMessage,
5151
stream = false
@@ -122,7 +122,7 @@ public async Task CreateConversationAndResponse_Streaming_NonBackground_UpdatesC
122122
// Act - Create response (streaming, non-background)
123123
var createResponseRequest = new
124124
{
125-
model = AgentName,
125+
metadata = new { entity_id = AgentName },
126126
conversation = conversationId,
127127
input = UserMessage,
128128
stream = true
@@ -196,7 +196,7 @@ public async Task CreateConversationAndResponse_NonStreaming_Background_UpdatesC
196196
// Act - Create response (non-streaming, background)
197197
var createResponseRequest = new
198198
{
199-
model = AgentName,
199+
metadata = new { entity_id = AgentName },
200200
conversation = conversationId,
201201
input = UserMessage,
202202
stream = false,

0 commit comments

Comments
 (0)