Skip to content

Commit dd37330

Browse files
Address PersistentChatClient not handling streaming content errors (#54197)
* Address toolerrors swallowing behavior * Assets update * Update export API * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 73e16b6 commit dd37330

File tree

6 files changed

+134
-6
lines changed

6 files changed

+134
-6
lines changed

sdk/ai/Azure.AI.Agents.Persistent/api/Azure.AI.Agents.Persistent.net8.0.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1597,7 +1597,7 @@ public PersistentAgentsClient(string endpoint, Azure.Core.TokenCredential creden
15971597
public static partial class PersistentAgentsClientExtensions
15981598
{
15991599
public static Microsoft.Extensions.AI.AITool AsAITool(this Azure.AI.Agents.Persistent.ToolDefinition tool) { throw null; }
1600-
public static Microsoft.Extensions.AI.IChatClient AsIChatClient(this Azure.AI.Agents.Persistent.PersistentAgentsClient client, string agentId, string? defaultThreadId = null) { throw null; }
1600+
public static Microsoft.Extensions.AI.IChatClient AsIChatClient(this Azure.AI.Agents.Persistent.PersistentAgentsClient client, string agentId, string? defaultThreadId = null, bool throwOnContentErrors = true) { throw null; }
16011601
}
16021602
public static partial class PersistentAgentsExtensions
16031603
{

sdk/ai/Azure.AI.Agents.Persistent/api/Azure.AI.Agents.Persistent.netstandard2.0.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1597,7 +1597,7 @@ public PersistentAgentsClient(string endpoint, Azure.Core.TokenCredential creden
15971597
public static partial class PersistentAgentsClientExtensions
15981598
{
15991599
public static Microsoft.Extensions.AI.AITool AsAITool(this Azure.AI.Agents.Persistent.ToolDefinition tool) { throw null; }
1600-
public static Microsoft.Extensions.AI.IChatClient AsIChatClient(this Azure.AI.Agents.Persistent.PersistentAgentsClient client, string agentId, string? defaultThreadId = null) { throw null; }
1600+
public static Microsoft.Extensions.AI.IChatClient AsIChatClient(this Azure.AI.Agents.Persistent.PersistentAgentsClient client, string agentId, string? defaultThreadId = null, bool throwOnContentErrors = true) { throw null; }
16011601
}
16021602
public static partial class PersistentAgentsExtensions
16031603
{

sdk/ai/Azure.AI.Agents.Persistent/assets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"AssetsRepo": "Azure/azure-sdk-assets",
33
"AssetsRepoPrefixPath": "net",
44
"TagPrefix": "net/ai/Azure.AI.Agents.Persistent",
5-
"Tag": "net/ai/Azure.AI.Agents.Persistent_84020b2662"
5+
"Tag": "net/ai/Azure.AI.Agents.Persistent_89f0bef6e6"
66
}

sdk/ai/Azure.AI.Agents.Persistent/src/Custom/PersistentAgentsChatClient.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,13 @@ internal partial class PersistentAgentsChatClient : IChatClient
4040
/// <summary>Lazily-retrieved agent instance. Used for its properties.</summary>
4141
private PersistentAgent? _agent;
4242

43+
/// <summary>
44+
/// Indicates whether to throw exceptions when content errors are encountered.
45+
/// </summary>
46+
private readonly bool _throwOnContentErrors;
47+
4348
/// <summary>Initializes a new instance of the <see cref="PersistentAgentsChatClient"/> class for the specified <see cref="PersistentAgentsClient"/>.</summary>
44-
public PersistentAgentsChatClient(PersistentAgentsClient client, string agentId, string? defaultThreadId = null)
49+
public PersistentAgentsChatClient(PersistentAgentsClient client, string agentId, string? defaultThreadId = null, bool throwOnContentErrors = true)
4550
{
4651
Argument.AssertNotNull(client, nameof(client));
4752
Argument.AssertNotNullOrWhiteSpace(agentId, nameof(agentId));
@@ -51,6 +56,7 @@ public PersistentAgentsChatClient(PersistentAgentsClient client, string agentId,
5156
_defaultThreadId = defaultThreadId;
5257

5358
_metadata = new(ProviderName);
59+
_throwOnContentErrors = throwOnContentErrors;
5460
}
5561

5662
protected PersistentAgentsChatClient() { }
@@ -191,6 +197,14 @@ threadRun is not null &&
191197

192198
switch (ru)
193199
{
200+
case RunUpdate rup when rup.Value.Status == RunStatus.Failed && rup.Value.LastError is { } error:
201+
if (_throwOnContentErrors)
202+
{
203+
throw new InvalidOperationException(error.Message) { Data = { ["ErrorCode"] = error.Code } };
204+
}
205+
ruUpdate.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code, RawRepresentation = error });
206+
break;
207+
194208
case RequiredActionUpdate rau when rau.ToolCallId is string toolCallId && rau.FunctionName is string functionName:
195209
ruUpdate.Contents.Add(new FunctionCallContent(
196210
JsonSerializer.Serialize([ru.Value.Id, toolCallId], AgentsChatClientJsonContext.Default.StringArray),

sdk/ai/Azure.AI.Agents.Persistent/src/Custom/PersistentAgentsClientExtensions.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ public static class PersistentAgentsClientExtensions
2222
/// <see cref="IChatClient.GetResponseAsync"/> or <see cref="IChatClient.GetStreamingResponseAsync"/> via the <see cref="ChatOptions.ConversationId"/>
2323
/// property. If no thread ID is provided via either mechanism, a new thread will be created for the request.
2424
/// </param>
25+
/// <param name="throwOnContentErrors">Throws an exception if content errors are returned from the service. Default is <c>true</c>. This is useful to detect errors when tools are misconfigured that otherwise would be unnoticed because those come as a streaming data update.</param>
2526
/// <returns>An <see cref="IChatClient"/> instance configured to interact with the specified agent and thread.</returns>
26-
public static IChatClient AsIChatClient(this PersistentAgentsClient client, string agentId, string? defaultThreadId = null) =>
27-
new PersistentAgentsChatClient(client, agentId, defaultThreadId);
27+
public static IChatClient AsIChatClient(this PersistentAgentsClient client, string agentId, string? defaultThreadId = null, bool throwOnContentErrors = true) =>
28+
new PersistentAgentsChatClient(client, agentId, defaultThreadId, throwOnContentErrors);
2829

2930
/// <summary>Creates an <see cref="AITool"/> to represent a <see cref="ToolDefinition"/>.</summary>
3031
/// <param name="tool">The tool to wrap as an <see cref="AITool"/>.</param>

sdk/ai/Azure.AI.Agents.Persistent/tests/PersistentAgentsChatClientTests.cs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
using System.Runtime.CompilerServices;
99
using System.Text.Json;
1010
using System.Threading.Tasks;
11+
using Azure.Core;
12+
using Azure.Core.Pipeline;
1113
using Azure.Core.TestFramework;
1214
using Azure.Identity;
1315
using Microsoft.Extensions.AI;
@@ -26,6 +28,11 @@ public class PersistentAgentsChatClientTests : RecordedTestBase<AIAgentsTestEnvi
2628
private string _agentId;
2729
private string _threadId;
2830

31+
private const string FakeAgentEndpoint = "https://fake-host";
32+
private const string FakeAgentId = "agent-id";
33+
private const string FakeRunId = "run-id";
34+
private const string FakeThreadId = "thread-id";
35+
2936
public PersistentAgentsChatClientTests(bool isAsync) : base(isAsync)
3037
{
3138
TestDiagnostics = false;
@@ -350,7 +357,37 @@ public void TestGetService()
350357
Assert.Throws<ArgumentNullException>(() => chatClient.GetService(null));
351358
}
352359

360+
[RecordedTest]
361+
public async Task TestContentErrorHandling()
362+
{
363+
var mockTransport = new MockTransport((request) =>
364+
{
365+
return GetResponse(request, emptyRunList: false);
366+
});
367+
368+
PersistentAgentsClient client = GetClient(mockTransport);
369+
370+
IChatClient throwingChatClient = client.AsIChatClient(FakeAgentId, FakeThreadId, throwOnContentErrors: true);
371+
IChatClient nonThrowingChatClient = client.AsIChatClient(FakeAgentId, FakeThreadId, throwOnContentErrors: false);
372+
373+
var exception = Assert.ThrowsAsync<InvalidOperationException>(() => throwingChatClient.GetResponseAsync(new ChatMessage(ChatRole.User, "Get Mike's favourite word")));
374+
Assert.IsTrue(exception.Message.Contains("wrong-connection-id"));
375+
376+
var response = await nonThrowingChatClient.GetResponseAsync(new ChatMessage(ChatRole.User, "Get Mike's favourite word"));
377+
var errorContent = response.Messages.SelectMany(m => m.Contents).OfType<ErrorContent>().Single();
378+
Assert.IsTrue(errorContent.Message.Contains("wrong-connection-id"));
379+
}
380+
353381
#region Helpers
382+
383+
private PersistentAgentsClient GetClient(HttpPipelineTransport transport)
384+
{
385+
return new(FakeAgentEndpoint, new MockCredential(), options: new PersistentAgentsAdministrationClientOptions()
386+
{
387+
Transport = transport
388+
});
389+
}
390+
354391
private class CompositeDisposable : IDisposable
355392
{
356393
private readonly List<IDisposable> _disposables = [];
@@ -471,5 +508,81 @@ private static string GetFile([CallerFilePath] string pth = "", string fileName
471508
var dirName = Path.GetDirectoryName(pth) ?? "";
472509
return Path.Combine(new string[] { dirName, "TestData", fileName });
473510
}
511+
512+
private static MockResponse GetResponse(MockRequest request, bool? emptyRunList = true)
513+
{
514+
// Sent by client.Administration.GetAgentAsync(...) method
515+
if (request.Method == RequestMethod.Get && request.Uri.Path == $"/assistants/{FakeAgentId}")
516+
{
517+
return CreateOKMockResponse($$"""
518+
{
519+
"id": "{{FakeAgentId}}"
520+
}
521+
""");
522+
}
523+
// Sent by client.Runs.GetRunsAsync(...) method
524+
else if (request.Method == RequestMethod.Get && request.Uri.Path == $"/threads/{FakeThreadId}/runs")
525+
{
526+
return CreateOKMockResponse($$"""
527+
{
528+
"data": {{(emptyRunList is true
529+
? "[]"
530+
: $$"""[{"id": "{{FakeRunId}}"}]""")}}
531+
}
532+
""");
533+
}
534+
// Sent by client.Runs.CreateRunStreamingAsync(...) method
535+
else if (request.Method == RequestMethod.Post && request.Uri.Path == $"/threads/{FakeThreadId}/runs")
536+
{
537+
// Content failure response
538+
return CreateOKMockResponse(
539+
$$$"""
540+
event: thread.run.failed
541+
data: { "id":"{{{FakeRunId}}}","object":"thread.run","created_at":1764170243,"assistant_id":"asst_uYPWW0weSBNqXK3VjgRMkuim","thread_id":"thread_dmz0AZPJtnO9MnAfrzP1AtJ6","status":"failed","started_at":1764170244,"expires_at":null,"cancelled_at":null,"failed_at":1764170244,"completed_at":null,"required_action":null,"last_error":{ "code":"tool_user_error","message":"Error: invalid_tool_input; The specified connection ID 'wrong-connection-id' was not found in the project or account connections. Please verify that the connection id in tool input is correct and exists in the project or account."},"model":"gpt-4o","instructions":"Use the bing grounding tool to answer questions.Use the bing grounding tool to answer questions.","tools":[{ "type":"bing_grounding","bing_grounding":{ "search_configurations":[{ "connection_id":"wrong-connection-id","market":"en-US","set_lang":"en","count":5}]} }],"tool_resources":{ "code_interpreter":{ "file_ids":[]} },"metadata":{ },"temperature":1.0,"top_p":1.0,"max_completion_tokens":null,"max_prompt_tokens":null,"truncation_strategy":{ "type":"auto","last_messages":null},"incomplete_details":null,"usage":{ "prompt_tokens":0,"completion_tokens":0,"total_tokens":0,"prompt_token_details":{ "cached_tokens":0} },"response_format":"auto","tool_choice":"auto","parallel_tool_calls":true}
542+
543+
event: done
544+
data: [DONE]
545+
"""
546+
);
547+
}
548+
// Sent by client.Threads.CreateThreadAsync(...) method
549+
else if (request.Method == RequestMethod.Post && request.Uri.Path == $"/threads")
550+
{
551+
return CreateOKMockResponse($$"""
552+
{
553+
"id": "{{FakeThreadId}}"
554+
}
555+
""");
556+
}
557+
// Sent by client.Runs.CancelRunAsync(...) method
558+
else if (request.Method == RequestMethod.Post && request.Uri.Path == $"/threads/{FakeThreadId}/runs/{FakeRunId}/cancel")
559+
{
560+
return CreateOKMockResponse($$"""
561+
{
562+
"id": "{{FakeThreadId}}"
563+
}
564+
""");
565+
}
566+
// Sent by client.Runs.SubmitToolOutputsToStreamAsync(...) method
567+
else if (request.Method == RequestMethod.Post && request.Uri.Path == $"/threads//runs/{FakeRunId}/submit_tool_outputs")
568+
{
569+
return CreateOKMockResponse($$"""
570+
{
571+
"data":[{
572+
"id": "{{FakeRunId}}"
573+
}]
574+
}
575+
""");
576+
}
577+
578+
throw new InvalidOperationException("Unexpected request");
579+
}
580+
581+
private static MockResponse CreateOKMockResponse(string content)
582+
{
583+
var response = new MockResponse(200);
584+
response.SetContent(content);
585+
return response;
586+
}
474587
}
475588
}

0 commit comments

Comments
 (0)