Skip to content

Commit fd2aa95

Browse files
authored
Update PersistentAgentsChatClient to M.E.AI.Abstractions 9.10.0 (Azure#52852)
* Update PersistentAgentsChatClient to M.E.AI.Abstractions 9.9.1 - Expand AIFunction support to also include any AIFunctionDeclaration (AIFunction's base type) - Add an extension AsAITool method that makes it easy to add any persistent ToolDefinition to ChatOptions.Tools. - Add HostedMcpServerTool support. * Update M.E.AI.Abstractions to 9.10.0
1 parent c22dd3b commit fd2aa95

File tree

5 files changed

+170
-35
lines changed

5 files changed

+170
-35
lines changed

eng/Packages.Data.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@
214214
</ItemGroup>
215215

216216
<ItemGroup Condition="$(MSBuildProjectName.Contains('Azure.AI.Agents.Persistent'))">
217-
<PackageReference Update="Microsoft.Extensions.AI.Abstractions" Version="9.8.0"/>
217+
<PackageReference Update="Microsoft.Extensions.AI.Abstractions" Version="9.10.1"/>
218218
</ItemGroup>
219219

220220
<ItemGroup Condition="$(MSBuildProjectName.Contains('Azure.Projects'))">

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,6 +1596,7 @@ public PersistentAgentsClient(string endpoint, Azure.Core.TokenCredential creden
15961596
}
15971597
public static partial class PersistentAgentsClientExtensions
15981598
{
1599+
public static Microsoft.Extensions.AI.AITool AsAITool(this Azure.AI.Agents.Persistent.ToolDefinition tool) { throw null; }
15991600
public static Microsoft.Extensions.AI.IChatClient AsIChatClient(this Azure.AI.Agents.Persistent.PersistentAgentsClient client, string agentId, string? defaultThreadId = null) { throw null; }
16001601
}
16011602
public static partial class PersistentAgentsExtensions

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,6 +1596,7 @@ public PersistentAgentsClient(string endpoint, Azure.Core.TokenCredential creden
15961596
}
15971597
public static partial class PersistentAgentsClientExtensions
15981598
{
1599+
public static Microsoft.Extensions.AI.AITool AsAITool(this Azure.AI.Agents.Persistent.ToolDefinition tool) { throw null; }
15991600
public static Microsoft.Extensions.AI.IChatClient AsIChatClient(this Azure.AI.Agents.Persistent.PersistentAgentsClient client, string agentId, string? defaultThreadId = null) { throw null; }
16001601
}
16011602
public static partial class PersistentAgentsExtensions

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

Lines changed: 141 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
using System.Threading.Tasks;
1616
using Microsoft.Extensions.AI;
1717

18+
#pragma warning disable MEAI001 // MCP-related types are currently marked as [Experimental]
19+
1820
namespace Azure.AI.Agents.Persistent
1921
{
2022
/// <summary>Represents an <see cref="IChatClient"/> for an Azure.AI.Agents.Persistent <see cref="PersistentAgentsClient"/>.</summary>
@@ -74,7 +76,7 @@ public virtual async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAs
7476
Argument.AssertNotNull(messages, nameof(messages));
7577

7678
// Extract necessary state from messages and options.
77-
(ThreadAndRunOptions runOptions, List<FunctionResultContent>? toolResults) =
79+
(ThreadAndRunOptions runOptions, List<FunctionResultContent>? toolResults, List<McpServerToolApprovalResponseContent>? approvalResults) =
7880
await CreateRunOptionsAsync(messages, options, cancellationToken).ConfigureAwait(false);
7981

8082
// Get the thread ID.
@@ -100,15 +102,15 @@ public virtual async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAs
100102

101103
// Submit the request.
102104
IAsyncEnumerable<StreamingUpdate> updates;
103-
if (toolResults is not null &&
105+
if ((toolResults is not null || approvalResults is not null) &&
104106
threadRun is not null &&
105-
ConvertFunctionResultsToToolOutput(toolResults, out List<ToolOutput>? toolOutputs) is { } toolRunId &&
107+
ConvertFunctionResultsToToolOutput(toolResults, approvalResults, out List<ToolOutput> toolOutputs, out List<ToolApproval> toolApprovals) is { } toolRunId &&
106108
toolRunId == threadRun.Id)
107109
{
108110
// There's an active run and we have tool results to submit, so submit the results and continue streaming.
109111
// This is going to ignore any additional messages in the run options, as we are only submitting tool outputs,
110112
// but there doesn't appear to be a way to submit additional messages, and having such additional messages is rare.
111-
updates = _client!.Runs.SubmitToolOutputsToStreamAsync(threadRun, toolOutputs, cancellationToken);
113+
updates = _client!.Runs.SubmitToolOutputsToStreamAsync(threadRun, toolOutputs, toolApprovals, cancellationToken);
112114
}
113115
else
114116
{
@@ -191,13 +193,23 @@ threadRun is not null &&
191193
}));
192194
}
193195

194-
if (ru is RequiredActionUpdate rau && rau.ToolCallId is string toolCallId && rau.FunctionName is string functionName)
196+
switch (ru)
195197
{
196-
ruUpdate.Contents.Add(
197-
new FunctionCallContent(
198+
case RequiredActionUpdate rau when rau.ToolCallId is string toolCallId && rau.FunctionName is string functionName:
199+
ruUpdate.Contents.Add(new FunctionCallContent(
198200
JsonSerializer.Serialize([ru.Value.Id, toolCallId], AgentsChatClientJsonContext.Default.StringArray),
199201
functionName,
200202
JsonSerializer.Deserialize(rau.FunctionArguments, AgentsChatClientJsonContext.Default.IDictionaryStringObject)!));
203+
break;
204+
205+
case SubmitToolApprovalUpdate stau:
206+
ruUpdate.Contents.Add(new McpServerToolApprovalRequestContent(
207+
JsonSerializer.Serialize([stau.Value.Id, stau.ToolCallId], AgentsChatClientJsonContext.Default.StringArray),
208+
new McpServerToolCallContent(stau.ToolCallId, stau.Name, stau.ServerLabel)
209+
{
210+
Arguments = JsonSerializer.Deserialize(stau.Arguments, AgentsChatClientJsonContext.Default.IReadOnlyDictionaryStringObject)!,
211+
}));
212+
break;
201213
}
202214

203215
yield return ruUpdate;
@@ -274,7 +286,7 @@ public void Dispose() { }
274286
/// Creates the <see cref="ThreadAndRunOptions"/> to use for the request and extracts any function result contents
275287
/// that need to be submitted as tool results.
276288
/// </summary>
277-
private async ValueTask<(ThreadAndRunOptions RunOptions, List<FunctionResultContent>? ToolResults)> CreateRunOptionsAsync(
289+
private async ValueTask<(ThreadAndRunOptions RunOptions, List<FunctionResultContent>? ToolResults, List<McpServerToolApprovalResponseContent>? ApprovalResults)> CreateRunOptionsAsync(
278290
IEnumerable<ChatMessage> messages, ChatOptions? options, CancellationToken cancellationToken)
279291
{
280292
// Create the options instance to populate, either a fresh or using one the caller provides.
@@ -324,7 +336,11 @@ public void Dispose() { }
324336
{
325337
switch (tool)
326338
{
327-
case AIFunction aiFunction:
339+
case ToolDefinitionAITool rawTool:
340+
toolDefinitions.Add(rawTool.Tool);
341+
break;
342+
343+
case AIFunctionDeclaration aiFunction:
328344
toolDefinitions.Add(new FunctionToolDefinition(
329345
aiFunction.Name,
330346
aiFunction.Description,
@@ -372,6 +388,44 @@ public void Dispose() { }
372388
case HostedWebSearchTool webSearch when webSearch.AdditionalProperties?.TryGetValue("connectionId", out object? connectionId) is true:
373389
toolDefinitions.Add(new BingGroundingToolDefinition(new BingGroundingSearchToolParameters([new BingGroundingSearchConfiguration(connectionId!.ToString())])));
374390
break;
391+
392+
case HostedMcpServerTool mcpTool:
393+
MCPToolDefinition mcp = new(mcpTool.ServerName, mcpTool.ServerAddress);
394+
395+
if (mcpTool.AllowedTools is { Count: > 0 })
396+
{
397+
foreach (string toolName in mcpTool.AllowedTools)
398+
{
399+
mcp.AllowedTools.Add(toolName);
400+
}
401+
}
402+
403+
MCPToolResource mcpResource = !string.IsNullOrEmpty(mcpTool.AuthorizationToken) ?
404+
new(mcpTool.ServerName, new Dictionary<string, string>() { ["Authorization"] = $"Bearer {mcpTool.AuthorizationToken}" }) :
405+
new(mcpTool.ServerName);
406+
407+
switch (mcpTool.ApprovalMode)
408+
{
409+
case HostedMcpServerToolAlwaysRequireApprovalMode:
410+
mcpResource.RequireApproval = new MCPApproval("always");
411+
break;
412+
413+
case HostedMcpServerToolNeverRequireApprovalMode:
414+
mcpResource.RequireApproval = new MCPApproval("never");
415+
break;
416+
417+
case HostedMcpServerToolRequireSpecificApprovalMode requireSpecific:
418+
mcpResource.RequireApproval = new MCPApproval(new MCPApprovalPerTool()
419+
{
420+
Always = requireSpecific.AlwaysRequireApprovalToolNames is { Count: > 0 } alwaysRequireNames ? new(alwaysRequireNames) : null,
421+
Never = requireSpecific.NeverRequireApprovalToolNames is { Count: > 0 } neverRequireNames ? new(neverRequireNames) : null,
422+
});
423+
break;
424+
}
425+
426+
(toolResources ??= new()).Mcp.Add(mcpResource);
427+
toolDefinitions.Add(mcp);
428+
break;
375429
}
376430
}
377431

@@ -453,6 +507,7 @@ public void Dispose() { }
453507
// and everything else as user messages.
454508
StringBuilder? instructions = null;
455509
List<FunctionResultContent>? functionResults = null;
510+
List<McpServerToolApprovalResponseContent>? approvalResults = null;
456511

457512
runOptions.ThreadOptions ??= new();
458513

@@ -504,6 +559,10 @@ public void Dispose() { }
504559
(functionResults ??= []).Add(result);
505560
break;
506561

562+
case McpServerToolApprovalResponseContent mcpApproval:
563+
(approvalResults ??= []).Add(mcpApproval);
564+
break;
565+
507566
default:
508567
if (content.RawRepresentation is MessageInputContentBlock rawContent)
509568
{
@@ -534,56 +593,104 @@ public void Dispose() { }
534593
runOptions.OverrideInstructions = instructions.ToString();
535594
}
536595

537-
return (runOptions, functionResults);
596+
return (runOptions, functionResults, approvalResults);
538597
}
539598

540599
/// <summary>Convert <see cref="FunctionResultContent"/> instances to <see cref="ToolOutput"/> instances.</summary>
541-
/// <param name="toolResults">The tool results to process.</param>
600+
/// <param name="functionResults">The function results to process.</param>
601+
/// <param name="approvalResults">The MCP tool approval results to process.</param>
542602
/// <param name="toolOutputs">The generated list of tool outputs, if any could be created.</param>
603+
/// <param name="toolApprovals">The generated list of tool approvals, if any could be created.</param>
543604
/// <returns>The run ID associated with the corresponding function call requests.</returns>
544-
private static string? ConvertFunctionResultsToToolOutput(List<FunctionResultContent>? toolResults, out List<ToolOutput>? toolOutputs)
605+
private static string? ConvertFunctionResultsToToolOutput(
606+
List<FunctionResultContent>? functionResults,
607+
List<McpServerToolApprovalResponseContent>? approvalResults,
608+
out List<ToolOutput> toolOutputs,
609+
out List<ToolApproval> toolApprovals)
545610
{
546611
string? runId = null;
547-
toolOutputs = null;
548-
if (toolResults?.Count > 0)
612+
toolOutputs = [];
613+
toolApprovals = [];
614+
615+
if (functionResults?.Count > 0)
549616
{
550-
foreach (FunctionResultContent frc in toolResults)
617+
foreach (FunctionResultContent frc in functionResults)
551618
{
552-
// When creating the FunctionCallContext, we created it with a CallId == [runId, callId].
553-
// We need to extract the run ID and ensure that the ToolOutput we send back to Azure
554-
// is only the call ID.
555-
string[]? runAndCallIDs;
556-
try
557-
{
558-
runAndCallIDs = JsonSerializer.Deserialize(frc.CallId, AgentsChatClientJsonContext.Default.StringArray);
559-
}
560-
catch
619+
if (TryParseRunAndCallIds(frc.CallId, out string? parsedRunId, out string? callId) &&
620+
(runId is null || runId == parsedRunId))
561621
{
562-
continue;
622+
runId = parsedRunId;
623+
toolOutputs.Add(new(callId, frc.Result?.ToString() ?? string.Empty));
563624
}
625+
}
626+
}
564627

565-
if (runAndCallIDs is null ||
566-
runAndCallIDs.Length != 2 ||
567-
string.IsNullOrWhiteSpace(runAndCallIDs[0]) || // run ID
568-
string.IsNullOrWhiteSpace(runAndCallIDs[1]) || // call ID
569-
(runId is not null && runId != runAndCallIDs[0]))
628+
if (approvalResults?.Count > 0)
629+
{
630+
foreach (McpServerToolApprovalResponseContent trc in approvalResults)
631+
{
632+
if (TryParseRunAndCallIds(trc.Id, out string? parsedRunId, out string? callId) &&
633+
(runId is null || runId == parsedRunId))
570634
{
571-
continue;
635+
runId = parsedRunId;
636+
toolApprovals.Add(new(callId, trc.Approved));
572637
}
573-
574-
runId = runAndCallIDs[0];
575-
(toolOutputs ??= []).Add(new(runAndCallIDs[1], frc.Result?.ToString() ?? string.Empty));
576638
}
577639
}
578640

579641
return runId;
642+
643+
static bool TryParseRunAndCallIds(string id, out string? runId, out string? callId)
644+
{
645+
// When creating the AIContent instances, we created it with a CallId == [runId, callId].
646+
// We need to extract the run ID and ensure that the ToolOutput we send back to Azure
647+
// is only the call ID.
648+
runId = null;
649+
callId = null;
650+
651+
string[]? runAndCallIDs;
652+
try
653+
{
654+
runAndCallIDs = JsonSerializer.Deserialize(id, AgentsChatClientJsonContext.Default.StringArray);
655+
}
656+
catch
657+
{
658+
return false;
659+
}
660+
661+
if (runAndCallIDs is null ||
662+
runAndCallIDs.Length != 2 ||
663+
string.IsNullOrWhiteSpace(runAndCallIDs[0]) || // run ID
664+
string.IsNullOrWhiteSpace(runAndCallIDs[1])) // call ID
665+
{
666+
return false;
667+
}
668+
669+
runId = runAndCallIDs[0];
670+
callId = runAndCallIDs[1];
671+
return true;
672+
}
673+
}
674+
675+
/// <summary>
676+
/// <see cref="AITool"/> type that allows for any <see cref="ToolDefinition"/> to be
677+
/// passed into the <see cref="IChatClient"/> via <see cref="ChatOptions.Tools"/>.
678+
/// </summary>
679+
internal sealed class ToolDefinitionAITool(ToolDefinition tool) : AITool
680+
{
681+
public override string Name => tool.GetType().Name;
682+
public ToolDefinition Tool => tool;
683+
public override object? GetService(Type serviceType, object? serviceKey) =>
684+
serviceKey is null && serviceType?.IsInstanceOfType(Tool) is true ? Tool :
685+
base.GetService(serviceType!, serviceKey);
580686
}
581687

582688
[JsonSerializable(typeof(JsonElement))]
583689
[JsonSerializable(typeof(JsonNode))]
584690
[JsonSerializable(typeof(JsonObject))]
585691
[JsonSerializable(typeof(string[]))]
586692
[JsonSerializable(typeof(IDictionary<string, object>))]
693+
[JsonSerializable(typeof(IReadOnlyDictionary<string, object>))]
587694
private sealed partial class AgentsChatClientJsonContext : JsonSerializerContext;
588695
}
589696
}

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,31 @@ public static class PersistentAgentsClientExtensions
2525
/// <returns>An <see cref="IChatClient"/> instance configured to interact with the specified agent and thread.</returns>
2626
public static IChatClient AsIChatClient(this PersistentAgentsClient client, string agentId, string? defaultThreadId = null) =>
2727
new PersistentAgentsChatClient(client, agentId, defaultThreadId);
28+
29+
/// <summary>Creates an <see cref="AITool"/> to represent a <see cref="ToolDefinition"/>.</summary>
30+
/// <param name="tool">The tool to wrap as an <see cref="AITool"/>.</param>
31+
/// <returns>The <paramref name="tool"/> wrapped as an <see cref="AITool"/>.</returns>
32+
/// <remarks>
33+
/// <para>
34+
/// The returned tool is only suitable for use with the <see cref="IChatClient"/> returned by
35+
/// <see cref="AsIChatClient"/> (or <see cref="IChatClient"/>s that delegate
36+
/// to such an instance). It is likely to be ignored by any other <see cref="IChatClient"/> implementation.
37+
/// </para>
38+
/// <para>
39+
/// When a tool has a corresponding <see cref="AITool"/>-derived type already defined in Microsoft.Extensions.AI,
40+
/// such as <see cref="AIFunction"/>, <see cref="HostedWebSearchTool"/>, or <see cref="HostedFileSearchTool"/>,
41+
/// those types should be preferred instead of this method, as they are more portable,
42+
/// capable of being respected by any <see cref="IChatClient"/> implementation. This method does not attempt to
43+
/// map the supplied <see cref="ToolDefinition"/> to any of those types, it simply wraps it as-is:
44+
/// the <see cref="IChatClient"/> returned by <see cref="AsIChatClient"/> will be able to unwrap the
45+
/// <see cref="ToolDefinition"/> when it processes the list of tools.
46+
/// </para>
47+
/// </remarks>
48+
public static AITool AsAITool(this ToolDefinition tool)
49+
{
50+
Argument.AssertNotNull(tool, nameof(tool));
51+
52+
return new PersistentAgentsChatClient.ToolDefinitionAITool(tool);
53+
}
2854
}
2955
}

0 commit comments

Comments
 (0)