Skip to content

Commit 97db852

Browse files
Fix Critical and High AI chat review findings
C1 - Two-phase streaming tool call pattern: buffer all function calls during the stream, execute sequentially after stream completes, then make ONE continuation with all outputs. Previously, N parallel tool calls created N forked conversation branches. C2 - Context length errors in streaming path: added RethrowContextLengthErrors helper wrapper that puts try/catch only around MoveNextAsync, keeping yield return outside. C# CS1626 prohibits yield inside try/catch, so the wrapper is the only valid pattern for exception remapping in async iterators. H1 - Restore stream-write body in second catch in StreamMessage: the SSE error event was lost in a prior edit. H2 - Clone all relevant ResponseCreationOptions fields in CloneOptionsWithPreviousResponseId: EndUserId, MaxOutputTokenCount, TextOptions, TruncationMode, ParallelToolCallsEnabled, StoredOutputEnabled, ToolChoice, Temperature, TopP, ServiceTier, Metadata. H3 - IsContextLengthError now parses structured JSON error code via TryExtractErrorCode: handles status 413, code token_limit_exceeded, and context_length_exceeded. Falls back to message substring match. Bonus: wire EndUserId into CreateResponseOptionsAsync (SDK supports it). Add LogMcpToolArgumentParseError logger message.
1 parent 332d38f commit 97db852

1 file changed

Lines changed: 151 additions & 76 deletions

File tree

EssentialCSharp.Chat.Shared/Services/AIChatService.cs

Lines changed: 151 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,11 @@ private static string SanitizeForXmlContext(string? input) =>
172172
input?.Replace("<", "\u2039").Replace(">", "\u203A") ?? string.Empty;
173173

174174
/// <summary>
175-
/// Processes streaming updates from the OpenAI Responses API, handling both regular responses and function calls
175+
/// Processes streaming updates from the OpenAI Responses API.
176+
/// Buffers all function call items before executing them — this is critical for correctness:
177+
/// if the model emits multiple parallel tool calls, firing a separate continuation per call
178+
/// creates forked conversation branches. Collecting all calls and submitting all outputs
179+
/// in a single continuation matches the non-streaming behavior.
176180
/// </summary>
177181
private async IAsyncEnumerable<(string text, string? responseId)> ProcessStreamingUpdatesAsync(
178182
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
@@ -187,10 +191,14 @@ private static string SanitizeForXmlContext(string? input) =>
187191
// Track this leg's response ID so tool-call continuations chain from it,
188192
// ensuring the model's context includes the user's message + reasoning.
189193
string? currentLegResponseId = null;
194+
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
195+
List<FunctionCallResponseItem>? pendingFunctionCalls = null;
190196

191-
await foreach (var update in streamingUpdates.WithCancellation(cancellationToken))
197+
// Wrap the raw stream to convert context-length API errors to our domain exception.
198+
// C# does not allow yield inside a try/catch, so error remapping is done in a
199+
// separate helper that puts try/catch only around MoveNextAsync.
200+
await foreach (var update in RethrowContextLengthErrors(streamingUpdates, responseOptions.PreviousResponseId, cancellationToken))
192201
{
193-
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
194202
if (update is StreamingResponseCreatedUpdate created)
195203
{
196204
// Emit the response ID early so the controller can record ownership
@@ -200,104 +208,124 @@ private static string SanitizeForXmlContext(string? input) =>
200208
}
201209
else if (update is StreamingResponseOutputItemDoneUpdate itemDone)
202210
{
203-
// Check if this is a function call that needs to be executed
204211
if (itemDone.Item is FunctionCallResponseItem functionCallItem && mcpClient != null)
205212
{
206213
if (toolCallDepth >= 10)
207214
throw new InvalidOperationException("Maximum tool call depth exceeded.");
208215

209-
// Chain the continuation from THIS leg's response ID so the model sees:
210-
// context(outer) → userMessage + AI reasoning + funcCall (stored in currentLegResponseId)
211-
// → [toolOutput only] → next response
212-
// Using the outer previousResponseId here would omit the user's message from context.
213-
var continuationOptions = CloneOptionsWithPreviousResponseId(responseOptions, currentLegResponseId);
214-
215-
await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, continuationOptions, mcpClient, toolCallDepth + 1, endUserId, cancellationToken))
216-
{
217-
yield return functionResult;
218-
}
216+
// Buffer all function calls — do NOT fire continuations inline.
217+
// Sending one continuation per call would create N forked conversation
218+
// branches, each missing the other N-1 tool results.
219+
pendingFunctionCalls ??= [];
220+
pendingFunctionCalls.Add(functionCallItem);
219221
}
220222
}
221223
else if (update is StreamingResponseOutputTextDeltaUpdate deltaUpdate)
222224
{
223225
yield return (deltaUpdate.Delta.ToString(), null);
224226
}
225-
else if (update is StreamingResponseCompletedUpdate)
227+
// StreamingResponseCompletedUpdate: ResponseId already emitted above — no-op.
228+
}
229+
230+
// After the stream completes, execute all buffered tool calls and send ALL outputs
231+
// in a single continuation request. This mirrors the non-streaming loop in
232+
// GetChatCompletionCore and avoids conversation branching.
233+
if (pendingFunctionCalls is { Count: > 0 } && mcpClient != null)
234+
{
235+
var continuationOptions = CloneOptionsWithPreviousResponseId(responseOptions, currentLegResponseId);
236+
var outputItems = new List<ResponseItem>(pendingFunctionCalls.Count);
237+
238+
foreach (var functionCallItem in pendingFunctionCalls)
226239
{
227-
// ResponseId was already emitted from StreamingResponseCreatedUpdate at the start
228-
// of this response leg — no need to emit again from the completion event.
240+
outputItems.Add(await ExecuteSingleToolCallAsync(functionCallItem, toolCallDepth, endUserId, mcpClient, cancellationToken));
229241
}
242+
243+
var continuationStream = _ResponseClient.CreateResponseStreamingAsync(outputItems, continuationOptions, cancellationToken);
230244
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
245+
246+
await foreach (var result in ProcessStreamingUpdatesAsync(continuationStream, continuationOptions, mcpClient, toolCallDepth + 1, endUserId, cancellationToken))
247+
{
248+
yield return result;
249+
}
231250
}
232251
}
233252

234253
/// <summary>
235-
/// Executes a function call and streams the response
254+
/// Wraps a streaming response enumerable to remap <see cref="ClientResultException"/>
255+
/// context-length errors to <see cref="ConversationContextLimitExceededException"/>.
256+
/// <para>
257+
/// C# prohibits <c>yield return</c> inside a <c>try</c> block with a <c>catch</c> clause
258+
/// (CS1626). By putting the <c>try/catch</c> only around <c>MoveNextAsync</c> and the
259+
/// <c>yield return</c> outside, we satisfy the compiler while still remapping exceptions.
260+
/// </para>
236261
/// </summary>
237-
private async IAsyncEnumerable<(string text, string? responseId)> ExecuteFunctionCallAsync(
238262
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
239-
FunctionCallResponseItem functionCallItem,
240-
ResponseCreationOptions responseOptions,
263+
private static async IAsyncEnumerable<StreamingResponseUpdate> RethrowContextLengthErrors(
264+
IAsyncEnumerable<StreamingResponseUpdate> source,
265+
string? previousResponseId,
266+
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
267+
{
268+
await using var enumerator = source.GetAsyncEnumerator(cancellationToken);
269+
while (true)
270+
{
271+
bool hasNext;
272+
try { hasNext = await enumerator.MoveNextAsync(); }
273+
catch (ClientResultException ex) when (IsContextLengthError(ex))
274+
{ throw new ConversationContextLimitExceededException(previousResponseId, ex); }
275+
276+
if (!hasNext) break;
277+
yield return enumerator.Current; // yield return is outside try/catch — valid
278+
}
279+
}
241280
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
242-
McpClient mcpClient,
281+
282+
/// <summary>
283+
/// Executes a single MCP tool call and returns the output item to include in the
284+
/// continuation request. Handles allowlist validation and argument-parsing errors
285+
/// so a single bad tool never aborts the entire continuation.
286+
/// </summary>
287+
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
288+
private async Task<ResponseItem> ExecuteSingleToolCallAsync(
289+
FunctionCallResponseItem functionCallItem,
243290
int toolCallDepth,
244-
string? endUserId = null,
245-
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
291+
string? endUserId,
292+
McpClient mcpClient,
293+
CancellationToken cancellationToken)
246294
{
247295
// Defense-in-depth: validate tool name against static allowlist before executing.
248296
if (!IsMcpToolAllowed(functionCallItem.FunctionName))
249297
{
250298
LogMcpToolCallRejected(_Logger, functionCallItem.FunctionName, endUserId);
251-
// Feed a benign error back to the model so it can recover gracefully.
252-
// The functionCallItem is in the stored response at PreviousResponseId — only send the output.
253-
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
254-
var errorItems = new List<ResponseItem>
255-
{
256-
new FunctionCallOutputResponseItem(
257-
functionCallItem.CallId,
258-
$"Tool '{functionCallItem.FunctionName}' is not available.")
259-
};
260-
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
261-
var recoveryStream = _ResponseClient.CreateResponseStreamingAsync(
262-
errorItems, responseOptions, cancellationToken);
263-
await foreach (var result in ProcessStreamingUpdatesAsync(
264-
recoveryStream, responseOptions, mcpClient, toolCallDepth, endUserId, cancellationToken))
265-
{
266-
yield return result;
267-
}
268-
yield break;
299+
return new FunctionCallOutputResponseItem(
300+
functionCallItem.CallId,
301+
$"Tool '{functionCallItem.FunctionName}' is not available.");
269302
}
270303

271304
LogMcpToolCallInvokedStream(_Logger, functionCallItem.FunctionName, toolCallDepth, endUserId);
272-
// A dictionary of arguments to pass to the tool. Each key represents a parameter name, and its associated value represents the argument value.
273-
var arguments = ParseToolArguments(functionCallItem.FunctionArguments);
274305

275-
// Execute the function call using the MCP client
306+
Dictionary<string, object?> arguments;
307+
try
308+
{
309+
arguments = ParseToolArguments(functionCallItem.FunctionArguments);
310+
}
311+
catch (Exception ex)
312+
{
313+
LogMcpToolArgumentParseError(_Logger, functionCallItem.FunctionName, ex, endUserId);
314+
return new FunctionCallOutputResponseItem(
315+
functionCallItem.CallId,
316+
$"Error parsing arguments for '{functionCallItem.FunctionName}': invalid JSON.");
317+
}
318+
276319
var toolResult = await mcpClient.CallToolAsync(
277320
functionCallItem.FunctionName,
278321
arguments: arguments,
279322
cancellationToken: cancellationToken);
280323

281-
// The functionCallItem is already stored in the server's context (referenced by
282-
// responseOptions.PreviousResponseId). Only the tool output is new content.
283-
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
284-
var inputItems = new List<ResponseItem>
285-
{
286-
new FunctionCallOutputResponseItem(functionCallItem.CallId, McpToolResultFormatter.GetModelInput(toolResult))
287-
};
288-
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
289-
290-
// Stream the function call response using the same processing logic
291-
var functionResponseStream = _ResponseClient.CreateResponseStreamingAsync(
292-
inputItems,
293-
responseOptions,
294-
cancellationToken);
295-
296-
await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, toolCallDepth, endUserId, cancellationToken))
297-
{
298-
yield return result;
299-
}
324+
return new FunctionCallOutputResponseItem(
325+
functionCallItem.CallId,
326+
McpToolResultFormatter.GetModelInput(toolResult));
300327
}
328+
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
301329

302330
/// <summary>
303331
/// Creates response options with optional features
@@ -330,12 +358,11 @@ private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
330358
options.PreviousResponseId = previousResponseId;
331359
}
332360

333-
// endUserId is reserved for forwarding to Azure OpenAI for end-user attribution
334-
// (Microsoft Defender prompt-shield correlation). OpenAI .NET SDK v2.7.0 does not
335-
// expose ResponseCreationOptions.User; this parameter is intentionally discarded
336-
// until SDK support is available.
361+
// Wire up end-user ID for Azure OpenAI abuse monitoring and Microsoft Defender
362+
// prompt-shield correlation. The SDK now exposes EndUserId directly.
337363
// See: https://learn.microsoft.com/en-us/azure/defender-for-cloud/gain-end-user-context-ai
338-
_ = endUserId;
364+
if (!string.IsNullOrEmpty(endUserId))
365+
options.EndUserId = endUserId;
339366

340367
// Add tools if provided
341368
if (tools != null)
@@ -480,9 +507,10 @@ private bool IsMcpToolAllowed(string toolName)
480507
}
481508

482509
/// <summary>
483-
/// Returns a shallow clone of <paramref name="source"/> with
510+
/// Returns a clone of <paramref name="source"/> with
484511
/// <see cref="ResponseCreationOptions.PreviousResponseId"/> replaced.
485-
/// Used when chaining tool-call continuations from a specific response leg.
512+
/// All behavior-affecting properties are copied so that tool-call continuation legs
513+
/// produce identical generation behavior to the initial leg.
486514
/// </summary>
487515
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
488516
private static ResponseCreationOptions CloneOptionsWithPreviousResponseId(
@@ -493,10 +521,23 @@ private static ResponseCreationOptions CloneOptionsWithPreviousResponseId(
493521
{
494522
Instructions = source.Instructions,
495523
PreviousResponseId = previousResponseId,
524+
EndUserId = source.EndUserId,
496525
ReasoningOptions = source.ReasoningOptions,
526+
MaxOutputTokenCount = source.MaxOutputTokenCount,
527+
TextOptions = source.TextOptions,
528+
TruncationMode = source.TruncationMode,
529+
ParallelToolCallsEnabled = source.ParallelToolCallsEnabled,
530+
StoredOutputEnabled = source.StoredOutputEnabled,
531+
ToolChoice = source.ToolChoice,
532+
Temperature = source.Temperature,
533+
TopP = source.TopP,
534+
ServiceTier = source.ServiceTier,
497535
};
498536
foreach (var tool in source.Tools)
499537
clone.Tools.Add(tool);
538+
if (source.Metadata is { Count: > 0 })
539+
foreach (var kvp in source.Metadata)
540+
clone.Metadata[kvp.Key] = kvp.Value;
500541
return clone;
501542
}
502543
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
@@ -538,6 +579,9 @@ private static ResponseCreationOptions CloneOptionsWithPreviousResponseId(
538579
[LoggerMessage(Level = LogLevel.Warning, Message = "AI tool call rejected — not on allowlist: tool={ToolName} user={EndUserId}")]
539580
private static partial void LogMcpToolCallRejected(ILogger logger, string toolName, string? endUserId);
540581

582+
[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to parse tool arguments for '{ToolName}': user={EndUserId}")]
583+
private static partial void LogMcpToolArgumentParseError(ILogger logger, string toolName, Exception exception, string? endUserId);
584+
541585
[LoggerMessage(Level = LogLevel.Warning, Message = "MCP tool skipped during option setup — not on allowlist: tool={ToolName}")]
542586
private static partial void LogMcpToolSkippedNotAllowed(ILogger logger, string toolName);
543587

@@ -546,10 +590,41 @@ private static ResponseCreationOptions CloneOptionsWithPreviousResponseId(
546590

547591
/// <summary>
548592
/// Returns <c>true</c> when the API error indicates the conversation context window was exceeded.
593+
/// Prefers structured JSON error code from the response body; falls back to message text matching.
594+
/// Also handles HTTP 413 (payload too large via API gateway) and <c>token_limit_exceeded</c>.
595+
/// </summary>
596+
private static bool IsContextLengthError(ClientResultException ex)
597+
{
598+
if (ex.Status is not (400 or 413)) return false;
599+
600+
// Prefer structured error code from the response body
601+
var errorCode = TryExtractErrorCode(ex);
602+
if (errorCode is not null)
603+
return errorCode is "context_length_exceeded" or "token_limit_exceeded";
604+
605+
// Fallback: substring match on exception message (Azure OpenAI format may vary)
606+
return ex.Message.Contains("context_length_exceeded", StringComparison.OrdinalIgnoreCase) ||
607+
ex.Message.Contains("maximum context length", StringComparison.OrdinalIgnoreCase) ||
608+
ex.Message.Contains("reduce the length of the messages", StringComparison.OrdinalIgnoreCase) ||
609+
ex.Message.Contains("token_limit_exceeded", StringComparison.OrdinalIgnoreCase);
610+
}
611+
612+
/// <summary>
613+
/// Attempts to extract the <c>error.code</c> field from the raw JSON response body.
614+
/// Returns <c>null</c> on any parse failure — this is best-effort.
549615
/// </summary>
550-
private static bool IsContextLengthError(ClientResultException ex) =>
551-
ex.Status == 400 &&
552-
(ex.Message.Contains("context_length_exceeded", StringComparison.OrdinalIgnoreCase) ||
553-
ex.Message.Contains("maximum context length", StringComparison.OrdinalIgnoreCase) ||
554-
ex.Message.Contains("reduce the length of the messages", StringComparison.OrdinalIgnoreCase));
616+
private static string? TryExtractErrorCode(ClientResultException ex)
617+
{
618+
try
619+
{
620+
var content = ex.GetRawResponse()?.Content;
621+
if (content is null) return null;
622+
using var doc = System.Text.Json.JsonDocument.Parse(content.ToMemory());
623+
if (doc.RootElement.TryGetProperty("error", out var error) &&
624+
error.TryGetProperty("code", out var code))
625+
return code.GetString();
626+
}
627+
catch { /* best-effort — don't let error parsing crash the error handler */ }
628+
return null;
629+
}
555630
}

0 commit comments

Comments
 (0)