Skip to content

Commit 332d38f

Browse files
Fix AI chat Responses API tool-chaining and context limit handling
- Fix streaming tool continuations: track currentLegResponseId from StreamingResponseCreatedUpdate and clone options with that ID before calling ExecuteFunctionCallAsync, so the continuation correctly references the response that produced the function call (not the outer conversation root). - Fix non-streaming loop token waste: after each tool-call response, advance PreviousResponseId to the new responseId and reset responseItems to [] so subsequent iterations only send new tool outputs — not the growing full history. This prevents quadratic token re-processing. - Remove redundant functionCallItem from continuation inputs: when using previous_response_id chaining, the function call is already stored server-side in the referenced response; only the FunctionCallOutputResponseItem is new content. - Add ConversationContextLimitExceededException domain exception and IsContextLengthError helper to detect context window overflow from ClientResultException (HTTP 400 with context_length_exceeded text). - Handle context limit in ChatController: both /api/chat/message and /api/chat/stream return 400 with errorCode 'context_limit_exceeded'. - Add CloneOptionsWithPreviousResponseId helper to avoid mutating shared options across concurrent streaming legs. - Add ParseToolArguments helper to deduplicate JSON argument parsing in both streaming and non-streaming paths. - Fix OPENAI001 pragma: extend the disable region to cover the full loop body including the ClientResult<OpenAIResponse> declaration.
1 parent 0d2cb75 commit 332d38f

3 files changed

Lines changed: 165 additions & 75 deletions

File tree

EssentialCSharp.Chat.Shared/Services/AIChatService.cs

Lines changed: 100 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using ModelContextProtocol.Client;
55
using ModelContextProtocol.Protocol;
66
using OpenAI.Responses;
7+
using System.ClientModel;
78
using System.Collections.Frozen;
89

910
namespace EssentialCSharp.Chat.Common.Services;
@@ -183,14 +184,19 @@ private static string SanitizeForXmlContext(string? input) =>
183184
string? endUserId = null,
184185
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
185186
{
187+
// Track this leg's response ID so tool-call continuations chain from it,
188+
// ensuring the model's context includes the user's message + reasoning.
189+
string? currentLegResponseId = null;
190+
186191
await foreach (var update in streamingUpdates.WithCancellation(cancellationToken))
187192
{
188193
#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.
189194
if (update is StreamingResponseCreatedUpdate created)
190195
{
191196
// Emit the response ID early so the controller can record ownership
192197
// before the stream completes — handles client disconnects mid-stream.
193-
yield return (string.Empty, responseId: created.Response.Id);
198+
currentLegResponseId = created.Response.Id;
199+
yield return (string.Empty, responseId: currentLegResponseId);
194200
}
195201
else if (update is StreamingResponseOutputItemDoneUpdate itemDone)
196202
{
@@ -200,10 +206,13 @@ private static string SanitizeForXmlContext(string? input) =>
200206
if (toolCallDepth >= 10)
201207
throw new InvalidOperationException("Maximum tool call depth exceeded.");
202208

203-
// Execute the function call and stream its response.
204-
// Each nested ProcessStreamingUpdatesAsync emits its own StreamingResponseCreatedUpdate
205-
// (which already yields its responseId early), so no extra tracking is needed here.
206-
await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, toolCallDepth + 1, endUserId, cancellationToken))
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))
207216
{
208217
yield return functionResult;
209218
}
@@ -239,12 +248,11 @@ private static string SanitizeForXmlContext(string? input) =>
239248
if (!IsMcpToolAllowed(functionCallItem.FunctionName))
240249
{
241250
LogMcpToolCallRejected(_Logger, functionCallItem.FunctionName, endUserId);
242-
// Feed a benign error back to the model so it can recover gracefully,
243-
// mirroring what GetChatCompletionCore does on the non-streaming path.
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.
244253
#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.
245254
var errorItems = new List<ResponseItem>
246255
{
247-
functionCallItem,
248256
new FunctionCallOutputResponseItem(
249257
functionCallItem.CallId,
250258
$"Tool '{functionCallItem.FunctionName}' is not available.")
@@ -262,45 +270,19 @@ private static string SanitizeForXmlContext(string? input) =>
262270

263271
LogMcpToolCallInvokedStream(_Logger, functionCallItem.FunctionName, toolCallDepth, endUserId);
264272
// A dictionary of arguments to pass to the tool. Each key represents a parameter name, and its associated value represents the argument value.
265-
Dictionary<string, object?> arguments = [];
266-
// example JsonResponse:
267-
// "{\"question\":\"Azure OpenAI Responses API (Preview)\"}"
268-
var jsonResponse = functionCallItem.FunctionArguments.ToString();
269-
var jsonArguments = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(jsonResponse) ?? new Dictionary<string, object?>();
270-
271-
// Convert JsonElement values to their actual types
272-
foreach (var kvp in jsonArguments)
273-
{
274-
if (kvp.Value is System.Text.Json.JsonElement jsonElement)
275-
{
276-
arguments[kvp.Key] = jsonElement.ValueKind switch
277-
{
278-
System.Text.Json.JsonValueKind.String => jsonElement.GetString(),
279-
System.Text.Json.JsonValueKind.Number => jsonElement.GetDecimal(),
280-
System.Text.Json.JsonValueKind.True => true,
281-
System.Text.Json.JsonValueKind.False => false,
282-
System.Text.Json.JsonValueKind.Null => null,
283-
_ => jsonElement.ToString()
284-
};
285-
}
286-
else
287-
{
288-
arguments[kvp.Key] = kvp.Value;
289-
}
290-
}
273+
var arguments = ParseToolArguments(functionCallItem.FunctionArguments);
291274

292275
// Execute the function call using the MCP client
293276
var toolResult = await mcpClient.CallToolAsync(
294277
functionCallItem.FunctionName,
295278
arguments: arguments,
296279
cancellationToken: cancellationToken);
297280

298-
// Create input items with both the function call and the result
299-
// This matches the Python pattern: append both tool_call and result
281+
// The functionCallItem is already stored in the server's context (referenced by
282+
// responseOptions.PreviousResponseId). Only the tool output is new content.
300283
#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.
301284
var inputItems = new List<ResponseItem>
302285
{
303-
functionCallItem, // The original function call
304286
new FunctionCallOutputResponseItem(functionCallItem.CallId, McpToolResultFormatter.GetModelInput(toolResult))
305287
};
306288
#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.
@@ -412,15 +394,22 @@ private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
412394
// Create the response using the Responses API
413395
#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.
414396
List<ResponseItem> responseItems = [ResponseItem.CreateUserMessageItem(prompt)];
415-
#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.
416397

417398
const int MaxToolCallIterations = 10;
418399
for (int iteration = 0; iteration < MaxToolCallIterations; iteration++)
419400
{
420-
var response = await _ResponseClient.CreateResponseAsync(
421-
responseItems,
422-
options: responseOptions,
423-
cancellationToken: cancellationToken);
401+
ClientResult<OpenAIResponse> response;
402+
try
403+
{
404+
response = await _ResponseClient.CreateResponseAsync(
405+
responseItems,
406+
options: responseOptions,
407+
cancellationToken: cancellationToken);
408+
}
409+
catch (ClientResultException ex) when (IsContextLengthError(ex))
410+
{
411+
throw new ConversationContextLimitExceededException(responseOptions.PreviousResponseId, ex);
412+
}
424413

425414
string responseId = response.Value.Id;
426415

@@ -429,47 +418,36 @@ private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
429418

430419
if (functionCalls.Count > 0 && mcpClient != null)
431420
{
421+
// Advance the chain: the server now has everything up to responseId stored
422+
// (user message + all prior function calls/results + this response's funcCalls).
423+
// The next request only needs to supply the tool outputs — not the growing history.
424+
responseOptions.PreviousResponseId = responseId;
425+
responseItems = [];
426+
432427
foreach (var functionCallItem in functionCalls)
433428
{
434429
// Defense-in-depth: validate tool name against static allowlist before executing.
435430
// This catches cases where the model hallucinates a tool name not on the list.
436431
if (!IsMcpToolAllowed(functionCallItem.FunctionName))
437432
{
438433
LogMcpToolCallRejected(_Logger, functionCallItem.FunctionName, endUserId);
439-
// Return a benign error to the model so it can respond gracefully
440-
responseItems.Add(functionCallItem);
434+
// The functionCallItem is in the stored response; send only the error output.
441435
responseItems.Add(new FunctionCallOutputResponseItem(
442436
functionCallItem.CallId,
443437
$"Tool '{functionCallItem.FunctionName}' is not available."));
444438
continue;
445439
}
446440

447441
LogMcpToolCallInvoked(_Logger, functionCallItem.FunctionName, iteration, endUserId);
448-
var jsonResponse = functionCallItem.FunctionArguments.ToString();
449-
var jsonArguments = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(jsonResponse) ?? new Dictionary<string, object?>();
450-
451-
Dictionary<string, object?> arguments = [];
452-
foreach (var kvp in jsonArguments)
453-
{
454-
arguments[kvp.Key] = kvp.Value is System.Text.Json.JsonElement jsonElement
455-
? jsonElement.ValueKind switch
456-
{
457-
System.Text.Json.JsonValueKind.String => jsonElement.GetString(),
458-
System.Text.Json.JsonValueKind.Number => jsonElement.GetDecimal(),
459-
System.Text.Json.JsonValueKind.True => true,
460-
System.Text.Json.JsonValueKind.False => false,
461-
System.Text.Json.JsonValueKind.Null => null,
462-
_ => (object?)jsonElement.ToString()
463-
}
464-
: kvp.Value;
465-
}
442+
var arguments = ParseToolArguments(functionCallItem.FunctionArguments);
466443

467444
var toolResult = await mcpClient.CallToolAsync(
468445
functionCallItem.FunctionName,
469446
arguments: arguments,
470447
cancellationToken: cancellationToken);
471448

472-
responseItems.Add(functionCallItem);
449+
// The functionCallItem is stored server-side in the response at PreviousResponseId.
450+
// Only the tool output is new content that needs to be included.
473451
responseItems.Add(new FunctionCallOutputResponseItem(
474452
functionCallItem.CallId,
475453
McpToolResultFormatter.GetModelInput(toolResult)));
@@ -501,6 +479,56 @@ private bool IsMcpToolAllowed(string toolName)
501479
return _AllowedMcpTools.Contains(toolName);
502480
}
503481

482+
/// <summary>
483+
/// Returns a shallow clone of <paramref name="source"/> with
484+
/// <see cref="ResponseCreationOptions.PreviousResponseId"/> replaced.
485+
/// Used when chaining tool-call continuations from a specific response leg.
486+
/// </summary>
487+
#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.
488+
private static ResponseCreationOptions CloneOptionsWithPreviousResponseId(
489+
ResponseCreationOptions source,
490+
string? previousResponseId)
491+
{
492+
var clone = new ResponseCreationOptions
493+
{
494+
Instructions = source.Instructions,
495+
PreviousResponseId = previousResponseId,
496+
ReasoningOptions = source.ReasoningOptions,
497+
};
498+
foreach (var tool in source.Tools)
499+
clone.Tools.Add(tool);
500+
return clone;
501+
}
502+
#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.
503+
504+
/// <summary>
505+
/// Parses function call arguments from a <see cref="BinaryData"/> JSON payload into a
506+
/// strongly-typed dictionary, converting <see cref="System.Text.Json.JsonElement"/> values
507+
/// to their native CLR equivalents.
508+
/// </summary>
509+
private static Dictionary<string, object?> ParseToolArguments(BinaryData functionArguments)
510+
{
511+
var jsonArguments = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(
512+
functionArguments.ToString()) ?? [];
513+
514+
var arguments = new Dictionary<string, object?>(jsonArguments.Count);
515+
foreach (var kvp in jsonArguments)
516+
{
517+
arguments[kvp.Key] = kvp.Value is System.Text.Json.JsonElement jsonElement
518+
? jsonElement.ValueKind switch
519+
{
520+
System.Text.Json.JsonValueKind.String => jsonElement.GetString(),
521+
System.Text.Json.JsonValueKind.Number => jsonElement.GetDecimal(),
522+
System.Text.Json.JsonValueKind.True => true,
523+
System.Text.Json.JsonValueKind.False => false,
524+
System.Text.Json.JsonValueKind.Null => null,
525+
_ => (object?)jsonElement.ToString()
526+
}
527+
: kvp.Value;
528+
}
529+
return arguments;
530+
}
531+
504532
[LoggerMessage(Level = LogLevel.Information, Message = "AI tool call invoked: tool={ToolName} iteration={Iteration} user={EndUserId}")]
505533
private static partial void LogMcpToolCallInvoked(ILogger logger, string toolName, int iteration, string? endUserId);
506534

@@ -515,4 +543,13 @@ private bool IsMcpToolAllowed(string toolName)
515543

516544
[LoggerMessage(Level = LogLevel.Information, Message = "AI contextual search performed for prompt enrichment")]
517545
private static partial void LogContextualSearchPerformed(ILogger logger);
546+
547+
/// <summary>
548+
/// Returns <c>true</c> when the API error indicates the conversation context window was exceeded.
549+
/// </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));
518555
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
namespace EssentialCSharp.Chat.Common.Services;
2+
3+
/// <summary>
4+
/// Thrown when a conversation's accumulated context exceeds the model's context window limit.
5+
/// </summary>
6+
/// <remarks>
7+
/// This occurs when using <c>previous_response_id</c> chaining over many turns — the server
8+
/// reconstructs the full history on each request, and that history eventually exceeds the model's
9+
/// maximum input tokens. Callers should prompt the user to start a new conversation rather than
10+
/// retrying with the same <c>previousResponseId</c>.
11+
/// </remarks>
12+
public sealed class ConversationContextLimitExceededException : Exception
13+
{
14+
/// <summary>
15+
/// The <c>previous_response_id</c> that caused the overflow, if known.
16+
/// </summary>
17+
public string? PreviousResponseId { get; }
18+
19+
public ConversationContextLimitExceededException(string? previousResponseId)
20+
: base("This conversation has exceeded the model's context window limit.")
21+
{
22+
PreviousResponseId = previousResponseId;
23+
}
24+
25+
public ConversationContextLimitExceededException(string? previousResponseId, Exception innerException)
26+
: base("This conversation has exceeded the model's context window limit.", innerException)
27+
{
28+
PreviousResponseId = previousResponseId;
29+
}
30+
}

EssentialCSharp.Web/Controllers/ChatController.cs

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,28 @@ public async Task<IActionResult> SendMessage([FromBody] ChatMessageRequest reque
4444
if (!_ResponseIdValidationService.ValidateResponseId(userId, previousResponseId))
4545
return BadRequest(new { error = "Invalid conversation context." });
4646

47-
var (response, responseId) = await _AiChatService.GetChatCompletion(
48-
prompt: request.Message,
49-
previousResponseId: previousResponseId,
50-
enableContextualSearch: request.EnableContextualSearch,
51-
endUserId: userId,
52-
cancellationToken: cancellationToken);
47+
try
48+
{
49+
var (response, responseId) = await _AiChatService.GetChatCompletion(
50+
prompt: request.Message,
51+
previousResponseId: previousResponseId,
52+
enableContextualSearch: request.EnableContextualSearch,
53+
endUserId: userId,
54+
cancellationToken: cancellationToken);
5355

54-
_ResponseIdValidationService.RecordResponseId(userId, responseId);
56+
_ResponseIdValidationService.RecordResponseId(userId, responseId);
5557

56-
return Ok(new ChatMessageResponse
58+
return Ok(new ChatMessageResponse
59+
{
60+
Response = response,
61+
ResponseId = responseId,
62+
Timestamp = DateTime.UtcNow
63+
});
64+
}
65+
catch (ConversationContextLimitExceededException)
5766
{
58-
Response = response,
59-
ResponseId = responseId,
60-
Timestamp = DateTime.UtcNow
61-
});
67+
return BadRequest(new { error = "This conversation has grown too long. Please start a new one.", errorCode = "context_limit_exceeded" });
68+
}
6269
}
6370

6471
[HttpPost("stream")]
@@ -130,6 +137,22 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
130137
{
131138
LogChatStreamCancelled(_Logger, User.Identity?.Name);
132139
}
140+
catch (ConversationContextLimitExceededException) when (!Response.HasStarted)
141+
{
142+
Response.StatusCode = 400;
143+
Response.ContentType = "application/json";
144+
await Response.WriteAsJsonAsync(new { error = "This conversation has grown too long. Please start a new one.", errorCode = "context_limit_exceeded" }, CancellationToken.None);
145+
}
146+
catch (ConversationContextLimitExceededException)
147+
{
148+
LogChatStreamErrorMidStream(_Logger, new InvalidOperationException("Context limit exceeded mid-stream"), User.Identity?.Name);
149+
try
150+
{
151+
await Response.WriteAsync("data: {\"type\":\"error\",\"message\":\"This conversation has grown too long. Please start a new one.\",\"errorCode\":\"context_limit_exceeded\"}\n\n", CancellationToken.None);
152+
await Response.Body.FlushAsync(CancellationToken.None);
153+
}
154+
catch { /* client already disconnected */ }
155+
}
133156
catch (Exception ex) when (!Response.HasStarted)
134157
{
135158
LogChatStreamErrorBeforeResponseStarted(_Logger, ex, User.Identity?.Name);

0 commit comments

Comments
 (0)