Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ namespace Microsoft.Extensions.AI;
[JsonDerivedType(typeof(CodeInterpreterToolResultContent), typeDiscriminator: "codeInterpreterToolResult")]
[JsonDerivedType(typeof(WebSearchToolCallContent), typeDiscriminator: "webSearchToolCall")]
[JsonDerivedType(typeof(WebSearchToolResultContent), typeDiscriminator: "webSearchToolResult")]

// These should be added in once they're no longer [Experimental]. If they're included while still
// experimental, any JsonSerializerContext that includes AIContent will incur errors about using
// experimental types in its source generated files. When [Experimental] is removed from these types,
// these lines should be uncommented and the corresponding lines in AIJsonUtilities.CreateDefaultOptions
// as well as the [JsonSerializable] attributes for them on the JsonContext should be removed.
// [JsonDerivedType(typeof(ToolAdditionContent), typeDiscriminator: "toolAddition")]
// [JsonDerivedType(typeof(ToolRemovalContent), typeDiscriminator: "toolRemoval")]
public class AIContent
{
/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.AI;

/// <summary>
/// Represents a tool that becomes available to the model at this point in the conversation.
/// </summary>
/// <remarks>
/// <para>
/// Adding a tool to <see cref="ChatOptions.Tools"/> changes the tool definitions at the start of every request,
/// which invalidates any prompt cache built on them. A <see cref="ToolAdditionContent"/> in the chat history instead
/// tells the model about the tool where the content appears, leaving everything before it unchanged.
/// </para>
/// <para>
/// The content only describes the tool to the model. To have the tool invoked by a component such as
/// <c>FunctionInvokingChatClient</c>, also add the invocable function to <see cref="ChatOptions.Tools"/>. When a tool's
/// first change in the history is a <see cref="ToolAdditionContent"/>, a chat client that supports positional tool changes
/// leaves the tool out of the tool definitions at the start of the request. A chat client without that support instead
/// applies the changes in the history to the tool definitions it sends.
/// </para>
/// <para>
/// Keep the content in the history unchanged for as long as the conversation continues. Its definition, rather than
/// the one in <see cref="ChatOptions.Tools"/>, is what is sent at its position, so the earlier part of the conversation
/// stays the same from request to request.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIToolChanges, UrlFormat = DiagnosticIds.UrlFormat)]
[DebuggerDisplay("Tool = {Tool.Name}")]
public sealed class ToolAdditionContent : AIContent
{
/// <summary>
/// Initializes a new instance of the <see cref="ToolAdditionContent"/> class.
/// </summary>
/// <param name="tool">The declaration of the tool that becomes available.</param>
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
[JsonConstructor]
public ToolAdditionContent(AIFunctionDeclaration tool)
{
Tool = Throw.IfNull(tool);
}

/// <summary>
/// Gets the declaration of the tool that becomes available.
/// </summary>
/// <remarks>
/// When the content is serialized, only the tool's name, description and JSON schemas are written, so a deserialized
/// content carries a declaration that can't be invoked.
/// </remarks>
[JsonConverter(typeof(DeclarationConverter))]
public AIFunctionDeclaration Tool { get; }

/// <summary>Serializes an <see cref="AIFunctionDeclaration"/> as its name, description and JSON schemas.</summary>
internal sealed class DeclarationConverter : JsonConverter<AIFunctionDeclaration>
{
public override AIFunctionDeclaration Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException("Expected a JSON object for the tool declaration.");
}

using var document = JsonDocument.ParseValue(ref reader);
JsonElement root = document.RootElement;

string? name = root.TryGetProperty("name", out JsonElement nameElement) ? nameElement.GetString() : null;
if (string.IsNullOrEmpty(name))
{
throw new JsonException("The tool declaration has no name.");
}

string? description = root.TryGetProperty("description", out JsonElement descriptionElement) ? descriptionElement.GetString() : null;
JsonElement jsonSchema = root.TryGetProperty("jsonSchema", out JsonElement schemaElement) ? schemaElement.Clone() : AIJsonUtilities.DefaultJsonSchema;
JsonElement? returnJsonSchema = root.TryGetProperty("returnJsonSchema", out JsonElement returnElement) ? returnElement.Clone() : null;

return AIFunctionFactory.CreateDeclaration(name!, description, jsonSchema, returnJsonSchema);
}

public override void Write(Utf8JsonWriter writer, AIFunctionDeclaration value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteString("name", value.Name);
if (!string.IsNullOrEmpty(value.Description))
{
writer.WriteString("description", value.Description);
}

writer.WritePropertyName("jsonSchema");
value.JsonSchema.WriteTo(writer);
if (value.ReturnJsonSchema is { } returnJsonSchema)
{
writer.WritePropertyName("returnJsonSchema");
returnJsonSchema.WriteTo(writer);
}

writer.WriteEndObject();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.AI;

/// <summary>
/// Represents a tool that stops being available to the model at this point in the conversation.
/// </summary>
/// <remarks>
/// <para>
/// Removing a tool from <see cref="ChatOptions.Tools"/> changes the tool definitions at the start of every request,
/// which invalidates any prompt cache built on them. A <see cref="ToolRemovalContent"/> in the chat history instead
/// tells the model that the tool is gone where the content appears, leaving everything before it unchanged.
/// </para>
/// <para>
/// The content only informs the model. A component such as <c>FunctionInvokingChatClient</c> can still invoke a function
/// that remains in <see cref="ChatOptions.Tools"/>, so to stop the tool from being invoked, remove the function from
/// <see cref="ChatOptions.Tools"/> as well.
/// </para>
/// <para>
/// If the function was declared in <see cref="ChatOptions.Tools"/> from the start of the conversation, removing it from
/// there changes the tool definitions at the start of the request, which invalidates any prompt cache built on them.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIToolChanges, UrlFormat = DiagnosticIds.UrlFormat)]
[DebuggerDisplay("ToolName = {ToolName}")]
public sealed class ToolRemovalContent : AIContent
{
/// <summary>
/// Initializes a new instance of the <see cref="ToolRemovalContent"/> class.
/// </summary>
/// <param name="toolName">The name of the tool that stops being available.</param>
/// <exception cref="ArgumentNullException"><paramref name="toolName"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="toolName"/> is empty or composed entirely of whitespace.</exception>
[JsonConstructor]
public ToolRemovalContent(string toolName)
{
ToolName = Throw.IfNullOrWhitespace(toolName);
}

/// <summary>
/// Gets the name of the tool that stops being available.
/// </summary>
public string ToolName { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4680,6 +4680,22 @@
}
]
},
{
"Type": "sealed class Microsoft.Extensions.AI.ToolAdditionContent : Microsoft.Extensions.AI.AIContent",
"Stage": "Experimental",
"Methods": [
{
"Member": "Microsoft.Extensions.AI.ToolAdditionContent.ToolAdditionContent(Microsoft.Extensions.AI.AIFunctionDeclaration tool);",
"Stage": "Experimental"
}
],
"Properties": [
{
"Member": "Microsoft.Extensions.AI.AIFunctionDeclaration Microsoft.Extensions.AI.ToolAdditionContent.Tool { get; }",
"Stage": "Experimental"
}
]
},
{
"Type": "sealed class Microsoft.Extensions.AI.ToolApprovalRequestContent : Microsoft.Extensions.AI.InputRequestContent",
"Stage": "Stable",
Expand Down Expand Up @@ -4744,6 +4760,22 @@
}
]
},
{
"Type": "sealed class Microsoft.Extensions.AI.ToolRemovalContent : Microsoft.Extensions.AI.AIContent",
"Stage": "Experimental",
"Methods": [
{
"Member": "Microsoft.Extensions.AI.ToolRemovalContent.ToolRemovalContent(string toolName);",
"Stage": "Experimental"
}
],
"Properties": [
{
"Member": "string Microsoft.Extensions.AI.ToolRemovalContent.ToolName { get; }",
"Stage": "Experimental"
}
]
},
{
"Type": "class Microsoft.Extensions.AI.ToolResultContent : Microsoft.Extensions.AI.AIContent",
"Stage": "Stable",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ private static JsonSerializerOptions CreateDefaultOptions()
options.Converters.Add(new JsonStringEnumConverter());
}

// Temporary workaround: these types are [Experimental] and can't be added as [JsonDerivedType] on AIContent yet,
// or else consuming assemblies that used source generation with AIContent would implicitly reference them.
// Once they're no longer [Experimental] and added as [JsonDerivedType] on AIContent, these lines should be removed.
AddAIContentTypeChain(options, typeof(ToolAdditionContent), typeDiscriminatorId: "toolAddition", checkBuiltIn: false);
AddAIContentTypeChain(options, typeof(ToolRemovalContent), typeDiscriminatorId: "toolRemoval", checkBuiltIn: false);

options.MakeReadOnly();
return options;
}
Expand Down Expand Up @@ -109,6 +115,10 @@ private static JsonSerializerOptions CreateDefaultOptions()
[JsonSerializable(typeof(AIContent))]
[JsonSerializable(typeof(IEnumerable<AIContent>))]

// Temporary workaround: [Experimental] AIContent types registered in CreateDefaultOptions.
[JsonSerializable(typeof(ToolAdditionContent))]
[JsonSerializable(typeof(ToolRemovalContent))]

// IEmbeddingGenerator
[JsonSerializable(typeof(EmbeddingGenerationOptions))]
[JsonSerializable(typeof(EmbeddingGeneratorMetadata))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ public async Task<ChatResponse> GetResponseAsync(

OpenAIClientExtensions.AddOpenAIApiType(OpenAIClientExtensions.OpenAIApiTypeChatCompletions);

// Chat Completions has no form for tool changes at a position in the conversation.
ToolChanges.ApplyToTools(ref messages, ref options);

var openAIChatMessages = ToOpenAIChatMessages(messages, options);
var openAIOptions = ToOpenAIOptions(options);

Expand All @@ -115,6 +118,9 @@ public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(

OpenAIClientExtensions.AddOpenAIApiType(OpenAIClientExtensions.OpenAIApiTypeChatCompletions);

// Chat Completions has no form for tool changes at a position in the conversation.
ToolChanges.ApplyToTools(ref messages, ref options);

var openAIChatMessages = ToOpenAIChatMessages(messages, options);
var openAIOptions = ToOpenAIOptions(options);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ public async Task<ChatResponse> GetResponseAsync(
OpenAIClientExtensions.AddOpenAIApiType(OpenAIClientExtensions.OpenAIApiTypeResponses);

// Convert the inputs into what ResponsesClient expects.
var openAIOptions = AsCreateResponseOptions(options, out string? openAIConversationId);
// Tools that the history introduces with a ToolAdditionContent are sent at that position as an additional_tools item.
var openAIOptions = AsCreateResponseOptions(ToolChanges.WithoutIntroducedTools(ref messages, options), out string? openAIConversationId);

// Provided continuation token signals that an existing background response should be fetched.
if (GetContinuationToken(messages, options) is { } token)
Expand Down Expand Up @@ -329,7 +330,8 @@ public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(

OpenAIClientExtensions.AddOpenAIApiType(OpenAIClientExtensions.OpenAIApiTypeResponses);

var openAIOptions = AsCreateResponseOptions(options, out string? openAIConversationId);
// Tools that the history introduces with a ToolAdditionContent are sent at that position as an additional_tools item.
var openAIOptions = AsCreateResponseOptions(ToolChanges.WithoutIntroducedTools(ref messages, options), out string? openAIConversationId);
openAIOptions.StreamingEnabled = true;

// Provided continuation token signals that an existing background response should be fetched.
Expand Down Expand Up @@ -882,6 +884,50 @@ internal static FunctionTool ToResponseTool(AIFunctionDeclaration aiFunction, Ch
};
}

/// <summary>
/// Builds an <c>{"type":"additional_tools"}</c> input item from the <see cref="ToolAdditionContent"/>s in a message, or returns
/// <see langword="null"/> when it has none. The OpenAI .NET SDK doesn't expose this item type, so the JSON is constructed manually.
/// </summary>
/// <remarks>
/// The Responses API has no item for removing a tool, so <see cref="ToolRemovalContent"/> isn't sent.
/// </remarks>
internal static ResponseItem? ToAdditionalToolsItem(ChatMessage message, ChatOptions? options)
{
List<FunctionTool>? tools = null;
foreach (AIContent content in message.Contents)
{
if (content is ToolAdditionContent addition)
{
(tools ??= []).Add(ToResponseTool(addition.Tool, options));
}
}

if (tools is null)
{
return null;
}

using var stream = new System.IO.MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
writer.WriteStartObject();
writer.WriteString("type"u8, "additional_tools"u8);
writer.WriteString("role"u8, "developer"u8);
writer.WriteStartArray("tools"u8);
foreach (FunctionTool tool in tools)
{
var toolData = ModelReaderWriter.Write(tool, ModelReaderWriterOptions.Json, OpenAIContext.Default);
using var doc = JsonDocument.Parse(toolData);
doc.RootElement.WriteTo(writer);
}

writer.WriteEndArray();
writer.WriteEndObject();
}

return ModelReaderWriter.Read<ResponseItem>(BinaryData.FromBytes(stream.ToArray()), ModelReaderWriterOptions.Json, OpenAIContext.Default)!;
}

/// <summary>
/// Builds a <c>{"type":"namespace"}</c> <see cref="ResponseTool"/> from a name and set of tools.
/// The OpenAI .NET SDK doesn't expose a NamespaceTool type, so we construct the JSON manually.
Expand Down Expand Up @@ -1291,6 +1337,11 @@ internal static IEnumerable<ResponseItem> ToOpenAIResponseItems(IEnumerable<Chat

foreach (ChatMessage input in inputs)
{
if (ToAdditionalToolsItem(input, options) is { } additionalTools)
{
yield return additionalTools;
}

if (input.Role == ChatRole.System ||
input.Role == OpenAIClientExtensions.ChatRoleDeveloper)
{
Expand Down
Loading