Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 @@ -10,6 +10,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Microsoft.AspNetCore.Builder;

Expand Down Expand Up @@ -71,7 +72,10 @@ public static IEndpointConventionBuilder MapOpenAIResponses(
responsesPath ??= $"/{agent.Name}/v1/responses";

// Create an executor for this agent
var executor = new AIAgentResponseExecutor(agent, mapOptions);
var executor = new AIAgentResponseExecutor(
agent,
mapOptions,
endpoints.ServiceProvider.GetService<ILogger<AIAgentResponseExecutor>>());
var storageOptions = endpoints.ServiceProvider.GetService<InMemoryStorageOptions>() ?? new InMemoryStorageOptions();
var conversationStorage = endpoints.ServiceProvider.GetService<IConversationStorage>();
var responsesService = new InMemoryResponsesService(executor, storageOptions, conversationStorage);
Expand Down
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
@@ -0,0 +1,62 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;

namespace Microsoft.Agents.AI.Hosting.OpenAI;

/// <summary>
/// Defines how a client-provided function declaration is handled when its name conflicts with a
/// function configured by the hosted agent developer.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class OpenAIClientFunctionToolNameConflictBehavior
{
private protected OpenAIClientFunctionToolNameConflictBehavior()
{
}

/// <summary>
/// Creates a behavior that rejects the request.
/// </summary>
/// <returns>The conflict behavior.</returns>
public static OpenAIClientFunctionToolNameConflictBehavior Reject() => new RejectBehavior();

/// <summary>
/// Creates a behavior that keeps the hosted agent function, ignores the client declaration,
/// and writes a server warning.
/// </summary>
/// <returns>The conflict behavior.</returns>
public static OpenAIClientFunctionToolNameConflictBehavior Ignore() => new IgnoreBehavior();

/// <summary>
/// Creates a behavior that uses the client declaration instead of the hosted agent function for
/// that request.
/// </summary>
/// <returns>The conflict behavior.</returns>
public static OpenAIClientFunctionToolNameConflictBehavior AllowOverride() => new AllowOverrideBehavior();

internal abstract BehaviorKind Kind { get; }

private sealed class RejectBehavior : OpenAIClientFunctionToolNameConflictBehavior
{
internal override BehaviorKind Kind => BehaviorKind.Reject;
}

private sealed class IgnoreBehavior : OpenAIClientFunctionToolNameConflictBehavior
{
internal override BehaviorKind Kind => BehaviorKind.Ignore;
}

private sealed class AllowOverrideBehavior : OpenAIClientFunctionToolNameConflictBehavior
{
internal override BehaviorKind Kind => BehaviorKind.AllowOverride;
}

internal enum BehaviorKind
{
Reject,
Ignore,
AllowOverride,
}
}
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; }
}
47 changes: 46 additions & 1 deletion dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Microsoft.Agents.AI.Hosting.OpenAI.Responses;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;

namespace Microsoft.Agents.AI.Hosting.OpenAI;

Expand Down Expand Up @@ -43,6 +44,39 @@ public static class OpenAIResponses
/// <exception cref="ArgumentException">The body could not be parsed as an OpenAI Responses request.</exception>
/// <exception cref="NotSupportedException">A request setting is not supported by the configured mapping.</exception>
public static OpenAIResponsesRunRequest ToAgentRunRequest(JsonElement body, OpenAIResponsesMapOptions? mapOptions = null)
=> ToAgentRunRequestCore(body, mapOptions ?? new OpenAIResponsesMapOptions(), agent: null, logger: null);

/// <summary>
/// Converts an OpenAI Responses request body into Agent Framework run values using the target
/// agent to resolve client function tool name conflicts.
/// </summary>
/// <param name="body">The OpenAI Responses-shaped request body.</param>
/// <param name="agent">The target agent whose configured functions participate in conflict resolution.</param>
/// <param name="mapOptions">Options controlling how request settings are mapped onto the run.</param>
/// <param name="logger">Optional logger used for client function conflict warnings.</param>
/// <returns>The parsed messages and mapped run options.</returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="agent"/> or <paramref name="mapOptions"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ArgumentException">The body could not be parsed as an OpenAI Responses request.</exception>
/// <exception cref="NotSupportedException">A request setting is not supported by the configured mapping.</exception>
public static OpenAIResponsesRunRequest ToAgentRunRequest(
JsonElement body,
AIAgent agent,
OpenAIResponsesMapOptions mapOptions,
ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(mapOptions);

return ToAgentRunRequestCore(body, mapOptions, agent, logger);
}

private static OpenAIResponsesRunRequest ToAgentRunRequestCore(
JsonElement body,
OpenAIResponsesMapOptions mapOptions,
AIAgent? agent,
ILogger? logger)
{
CreateResponse request;
try
Expand All @@ -60,7 +94,18 @@ public static OpenAIResponsesRunRequest ToAgentRunRequest(JsonElement body, Open
throw new ArgumentException("The request body is missing the required 'input' field.", nameof(body));
}

AgentRunOptions? options = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory(request.ToRequestInfo());
#pragma warning disable MAAI001
if (agent is null && mapOptions.DangerouslyAllowClientFunctionTools is not null)
{
throw new NotSupportedException(
$"{nameof(OpenAIResponsesMapOptions.DangerouslyAllowClientFunctionTools)} requires the " +
$"{nameof(ToAgentRunRequest)} overload that accepts an {nameof(AIAgent)}.");
}
#pragma warning restore MAAI001

AgentRunOptions? options = agent is null
? mapOptions.RunOptionsFactory(request.ToRequestInfo())
: request.ToRunOptions(mapOptions, agent, logger, logConflicts: true);

var messages = new List<ChatMessage>();
foreach (InputMessage inputMessage in request.Input.GetInputMessages())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Agents.AI.Hosting.OpenAI;
Expand Down Expand Up @@ -39,6 +41,43 @@ public sealed class OpenAIResponsesMapOptions
}
} = RejectRequestSettings;

/// <summary>
/// Gets or sets the explicit opt-in that allows function declarations supplied by the client to
/// be forwarded to the hosted agent's inference client.
/// </summary>
/// <remarks>
/// <para>
/// This setting is dangerous because client-provided function names, descriptions, and schemas
/// can change which tools the model chooses. The declarations cannot execute code in the hosted
/// server, but matching function calls are returned to the client for execution.
/// </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="null"/>, which leaves client-provided tools subject to
/// <see cref="RejectRequestSettings"/>. Enabling this setting requires an explicit
/// <see cref="OpenAIClientFunctionToolNameConflictBehavior"/>. The request's
/// <c>tool_choice</c> is not enabled by this setting and remains controlled by
/// <see cref="RunOptionsFactory"/>.
/// </para>
/// <para>
/// This setting applies only to <c>MapOpenAIResponses</c> endpoints because the target agent is
/// required to enforce name conflicts. Accepted function declarations are handled by the endpoint
/// and removed from <see cref="OpenAIResponseRequestInfo.Tools"/> before
/// <see cref="RunOptionsFactory"/> is called. Other tool types remain in that collection.
/// </para>
/// <para>
/// Client functions are forwarded with parallel tool calls disabled. This prevents a response from
/// combining a client function call, which must be returned to the client, with a hosted function
/// call that must execute inside the hosted agent.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public OpenAIClientFunctionToolNameConflictBehavior? DangerouslyAllowClientFunctionTools { get; set; }
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated

/// <summary>
/// The default <see cref="RunOptionsFactory"/> implementation. Throws a <see cref="NotSupportedException"/>
/// when the request specifies any setting that would otherwise be mapped onto the agent, and otherwise
Expand Down Expand Up @@ -83,7 +122,7 @@ public sealed class OpenAIResponsesMapOptions
LocalAdd("tools");
}

if (request.ToolChoice is not null)
if (request.HasToolChoice || request.ToolChoice is not null)
{
LocalAdd("tool_choice");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;

namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;

Expand All @@ -17,13 +18,18 @@ 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;
private readonly ILogger<AIAgentResponseExecutor>? _logger;

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

public ValueTask<ResponseError?> ValidateRequestAsync(
Expand All @@ -37,7 +43,7 @@ public AIAgentResponseExecutor(AIAgent agent, OpenAIResponsesMapOptions? mapOpti
{
// Invoke the factory during validation so that unsupported request settings are surfaced
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
// as a clean request error rather than an unhandled exception during execution.
_ = this._runOptionsFactory(request.ToRequestInfo());
_ = request.ToRunOptions(this._mapOptions, this._agent, this._logger);
return null;
}
catch (NotSupportedException ex)
Expand All @@ -58,7 +64,11 @@ 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 = request.ToRunOptions(
this._mapOptions,
this._agent,
this._logger,
logConflicts: true);

// 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());
_ = request.ToRunOptions(this._mapOptions, agent, this._logger);
}
catch (NotSupportedException ex)
{
Expand All @@ -109,7 +109,11 @@ 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 = request.ToRunOptions(
this._mapOptions,
agent,
this._logger,
logConflicts: true);
var messages = new List<ChatMessage>();

if (conversationHistory is not null)
Expand Down
Loading
Loading