Skip to content
Merged
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 @@ -12,6 +12,7 @@
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />

<PropertyGroup>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,6 @@ public sealed class OpenAIResponseRequestInfo
/// <see langword="null"/>.
/// </remarks>
public ChatToolMode? ToolChoice { get; set; }

internal bool HasToolChoice { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Agents.AI.Hosting.OpenAI;
Expand All @@ -18,26 +23,80 @@ public sealed class OpenAIResponsesMapOptions
/// </summary>
/// <remarks>
/// <para>
/// By default this is set to <see cref="RejectRequestSettings"/>, which throws when the request
/// By default this uses <see cref="RejectRequestSettings"/>, which throws when the request
/// carries any setting that would otherwise be mapped onto the agent (for example
/// <c>temperature</c>, <c>instructions</c>, <c>tools</c> or <c>tool_choice</c>). This prevents a
/// caller from silently overriding the configuration of a self-contained agent.
/// Enabling <see cref="DangerouslyAllowClientFunctionTools"/> selects a default mapping that
/// forwards function declarations while continuing to reject other unsupported settings.
/// </para>
/// <para>
/// Hosting developers that want to honor specific request settings can supply their own callback
/// that maps the desired fields onto an <see cref="AgentRunOptions"/> (or a subclass such as
/// <see cref="ChatClientAgentRunOptions"/>), and may choose to throw, map, or ignore any field.
/// A custom callback receives all request settings, including the complete
/// <see cref="OpenAIResponseRequestInfo.Tools"/> collection, and replaces the default mapping.
/// Its result is used unchanged, regardless of <see cref="DangerouslyAllowClientFunctionTools"/>.
/// Returning <see langword="null"/> runs the agent with its own configuration only.
/// </para>
/// </remarks>
public Func<OpenAIResponseRequestInfo, AgentRunOptions?> RunOptionsFactory
{
get;
get
{
#pragma warning disable MAAI001
return field ?? (this.DangerouslyAllowClientFunctionTools
? MapClientFunctionTools
: RejectRequestSettings);
#pragma warning restore MAAI001
}
set
{
field = Throw.IfNull(value);
}
} = RejectRequestSettings;
}

/// <summary>
/// Gets or sets whether the default mapping forwards client-provided function declarations
/// in the agent's run options.
/// </summary>
/// <remarks>
/// <para>
/// This setting is dangerous because client-provided function names, descriptions, and schemas
/// can change which tools the model chooses. The declarations do not contain executable code.
/// The downstream chat client and provider determine how function calls are handled.
/// </para>
/// <para>
/// A client function may cause the model to choose it instead of a function configured by the
/// hosted agent developer, even when their names do not conflict. Function arguments and any data
/// included in those arguments are then returned to the client.
/// </para>
/// <para>
/// The default is <see langword="false"/>, which leaves client-provided tools subject to
/// <see cref="RunOptionsFactory"/> and its default <see cref="RejectRequestSettings"/> behavior. The request's
/// <c>tool_choice</c> is not enabled by this setting and remains controlled by
/// <see cref="RunOptionsFactory"/>.
/// </para>
/// <para>
/// With the default mapping, accepted function declarations are converted to
/// <c>ChatClientAgentRunOptions.ChatOptions.Tools</c>. Other tool types and unsupported request
/// settings are rejected. This setting has no effect when a custom <see cref="RunOptionsFactory"/>
/// is supplied; that callback owns the entire mapping.
/// </para>
/// <para>
/// The default mapping produces <see cref="ChatClientAgentRunOptions"/>. The hosting layer
/// does not require a particular agent implementation. Agents that do not consume these options
/// may ignore the mapped functions; enabling this setting does not add function support to them.
/// </para>
/// <para>
/// Function names are not checked for conflicts or deduplicated. The downstream chat client and
/// provider determine whether duplicate names are accepted and which function is selected.
/// The hosting layer does not guarantee that a hosted function takes precedence over a client
/// declaration. The agent's parallel tool calling configuration is not changed.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool DangerouslyAllowClientFunctionTools { get; set; }

/// <summary>
/// The default <see cref="RunOptionsFactory"/> implementation. Throws a <see cref="NotSupportedException"/>
Expand All @@ -55,6 +114,30 @@ public sealed class OpenAIResponsesMapOptions
{
ArgumentNullException.ThrowIfNull(request);

ThrowIfUnsupportedRequestSettings(request, request.Tools);
return null;
}

private static AgentRunOptions? MapClientFunctionTools(OpenAIResponseRequestInfo request)
{
ArgumentNullException.ThrowIfNull(request);

if (request.Tools is not { Count: > 0 } tools)
{
return RejectRequestSettings(request);
}

(List<AITool>? clientTools, List<JsonElement>? remainingTools) = tools.ConvertClientFunctionTools();
ThrowIfUnsupportedRequestSettings(request, remainingTools);
return clientTools is { Count: > 0 }
? new ChatClientAgentRunOptions(new ChatOptions { Tools = clientTools })
: null;
}

private static void ThrowIfUnsupportedRequestSettings(
OpenAIResponseRequestInfo request,
IReadOnlyList<JsonElement>? tools)
{
List<string>? unsupported = null;
void LocalAdd(string name) => (unsupported ??= []).Add(name);

Expand All @@ -78,12 +161,12 @@ public sealed class OpenAIResponsesMapOptions
LocalAdd("instructions");
}

if (request.Tools is { Count: > 0 })
if (tools is { Count: > 0 })
{
LocalAdd("tools");
}

if (request.ToolChoice is not null)
if (request.HasToolChoice || request.ToolChoice is not null)
{
LocalAdd("tool_choice");
}
Expand All @@ -94,7 +177,5 @@ public sealed class OpenAIResponsesMapOptions
$"The following request setting(s) are not supported by this agent endpoint: {string.Join(", ", unsupported)}. " +
"Configure an OpenAIResponsesMapOptions.RunOptionsFactory to map these settings onto the agent if they should be honored.");
}

return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
internal sealed class AIAgentResponseExecutor : IResponseExecutor
{
private readonly AIAgent _agent;
private readonly Func<OpenAIResponseRequestInfo, AgentRunOptions?> _runOptionsFactory;
private readonly OpenAIResponsesMapOptions _mapOptions;

public AIAgentResponseExecutor(AIAgent agent, OpenAIResponsesMapOptions? mapOptions = null)
public AIAgentResponseExecutor(
AIAgent agent,
OpenAIResponsesMapOptions? mapOptions = null)
{
ArgumentNullException.ThrowIfNull(agent);
this._agent = agent;
this._runOptionsFactory = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory;
this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions();
}

public ValueTask<ResponseError?> ValidateRequestAsync(
Expand All @@ -35,9 +37,9 @@ public AIAgentResponseExecutor(AIAgent agent, OpenAIResponsesMapOptions? mapOpti
{
try
{
// Invoke the factory during validation so that unsupported request settings are surfaced
// Map options during validation so that unsupported request settings are surfaced
// as a clean request error rather than an unhandled exception during execution.
_ = this._runOptionsFactory(request.ToRequestInfo());
_ = this._mapOptions.RunOptionsFactory(request.ToRequestInfo());
return null;
}
catch (NotSupportedException ex)
Expand All @@ -58,7 +60,7 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
{
// The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which (if any)
// request settings are mapped onto the agent run. By default no request setting is mapped.
AgentRunOptions? options = this._runOptionsFactory(request.ToRequestInfo());
AgentRunOptions? options = this._mapOptions.RunOptionsFactory(request.ToRequestInfo());

// Convert input to chat messages, prepending conversation history if available
var messages = new List<ChatMessage>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<HostedAgentResponseExecutor> _logger;
private readonly Func<OpenAIResponseRequestInfo, AgentRunOptions?> _runOptionsFactory;
private readonly OpenAIResponsesMapOptions _mapOptions;

/// <summary>
/// Initializes a new instance of the <see cref="HostedAgentResponseExecutor"/> class.
Expand All @@ -39,7 +39,7 @@ public HostedAgentResponseExecutor(

this._serviceProvider = serviceProvider;
this._logger = logger;
this._runOptionsFactory = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory;
this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions();
}

/// <inheritdoc/>
Expand Down Expand Up @@ -83,7 +83,7 @@ Ensure the agent is registered with '{agentName}' name in the dependency injecti
// exception during execution.
try
{
_ = this._runOptionsFactory(request.ToRequestInfo());
_ = this._mapOptions.RunOptionsFactory(request.ToRequestInfo());
}
catch (NotSupportedException ex)
{
Expand All @@ -109,7 +109,7 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(

// The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which (if any)
// request settings are mapped onto the agent run. By default no request setting is mapped.
AgentRunOptions? options = this._runOptionsFactory(request.ToRequestInfo());
AgentRunOptions? options = this._mapOptions.RunOptionsFactory(request.ToRequestInfo());
var messages = new List<ChatMessage>();

if (conversationHistory is not null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;

internal static class OpenAIResponseRequestInfoBuilder
{
private static readonly JsonElement s_emptyJson = JsonElement.Parse("{}");

public static OpenAIResponseRequestInfo ToRequestInfo(this CreateResponse request) => new()
{
Temperature = request.Temperature,
Expand All @@ -18,8 +20,94 @@ internal static class OpenAIResponseRequestInfoBuilder
Model = request.Model,
Tools = request.Tools is { Count: > 0 } tools ? new List<JsonElement>(tools) : null,
ToolChoice = request.ToolChoice?.ToChatToolMode(),
HasToolChoice = request.ToolChoice is not null,
};

internal static (List<AITool>? ClientTools, List<JsonElement>? RemainingTools)
ConvertClientFunctionTools(this IReadOnlyList<JsonElement> tools)
{
List<AITool>? clientTools = null;
List<JsonElement>? remainingTools = null;

foreach (JsonElement tool in tools)
{
if (tool.ToFunctionTool() is { } functionTool)
{
(clientTools ??= []).Add(functionTool);
}
else
{
(remainingTools ??= []).Add(tool);
}
}

return (clientTools, remainingTools);
}

private static ClientAIFunctionDeclaration? ToFunctionTool(this JsonElement tool)
{
if (tool.ValueKind != JsonValueKind.Object ||
!tool.TryGetProperty("type", out JsonElement type) ||
type.ValueKind != JsonValueKind.String ||
type.GetString() != "function" ||
!tool.TryGetProperty("name", out JsonElement name) ||
name.ValueKind != JsonValueKind.String ||
name.GetString() is not { Length: > 0 } functionName)
{
return null;
}

JsonElement parameters = tool.TryGetProperty("parameters", out JsonElement requestParameters) &&
requestParameters.ValueKind == JsonValueKind.Object
? requestParameters
: s_emptyJson;

string? description = tool.TryGetProperty("description", out JsonElement requestDescription) &&
requestDescription.ValueKind == JsonValueKind.String
? requestDescription.GetString()
: null;

AIFunctionDeclaration function = AIFunctionFactory.CreateDeclaration(functionName, description, parameters);
bool? strict = tool.TryGetProperty("strict", out JsonElement requestStrict) &&
requestStrict.ValueKind is JsonValueKind.True or JsonValueKind.False
? requestStrict.GetBoolean()
: null;

return new ClientAIFunctionDeclaration(function, strict);
}

internal sealed class ClientAIFunctionDeclaration : AIFunctionDeclaration
Comment thread
rogerbarreto marked this conversation as resolved.
{
private readonly AIFunctionDeclaration _innerFunction;

public ClientAIFunctionDeclaration(AIFunctionDeclaration innerFunction, bool? strict)
{
this._innerFunction = innerFunction;
var additionalProperties = new Dictionary<string, object?>();
foreach (KeyValuePair<string, object?> property in innerFunction.AdditionalProperties)
{
additionalProperties.Add(property.Key, property.Value);
}

if (strict is not null)
{
additionalProperties["strict"] = strict;
}

this.AdditionalProperties = additionalProperties;
}

public override string Name => this._innerFunction.Name;

public override string Description => this._innerFunction.Description;

public override JsonElement JsonSchema => this._innerFunction.JsonSchema;

public override JsonElement? ReturnJsonSchema => this._innerFunction.ReturnJsonSchema;

public override IReadOnlyDictionary<string, object?> AdditionalProperties { get; }
}

/// <summary>
/// Maps an OpenAI Responses <c>tool_choice</c> value onto its <see cref="ChatToolMode"/> equivalent.
/// </summary>
Expand Down
Loading
Loading