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
4 changes: 3 additions & 1 deletion dotnet/samples/04-hosting/af-hosting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ Agent Framework gives you two options:
1. **`MapOpenAIResponses` (batteries included).** A single call maps a ready-made `/responses` endpoint that
handles the protocol, routing, and session storage for you. Pick this when you want a working endpoint
quickly and the built-in behavior fits. See [AgentWebChat](../../05-end-to-end/AgentWebChat) for a sample
that uses it.
that uses it. If your host serves more than one user, also register an isolation provider (for example
`builder.Services.UseClaimsBasedAgentIsolation(...)`) so responses and conversations are partitioned by the
calling principal — `response_id` and `conversation_id` are resume identifiers, not authorization tokens.

2. **Call the conversion helpers from your own route (these samples).** You write the ASP.NET Core route and
call the `OpenAIResponses` helper methods to translate between the Responses HTTP payloads and the agent.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();

// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data.
// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions, tasks, conversations,
// and responses by authenticated caller. Without this, contextId/taskId/conversation_id/response_id alone are
// the lookup keys — any caller who knows them can access another caller's data.
// Example using claims-based identity:
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });

Expand Down Expand Up @@ -176,8 +177,9 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te
pirateAgentBuilder.AddA2AServer();
knightsKnavesAgentBuilder.AddA2AServer();

// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data.
// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions, tasks, conversations,
// and responses by authenticated caller. Without this, contextId/taskId/conversation_id/response_id alone are
// the lookup keys — any caller who knows them can access another caller's data.
// Example using claims-based identity:
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.Models;
using Microsoft.Shared.Diagnostics;

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

/// <summary>
/// A delegating <see cref="IAgentConversationIndex"/> that scopes indexed conversation identifiers by the caller's isolation key,
/// so that listing conversations for an agent returns only the caller's own conversations.
/// </summary>
/// <remarks>
/// The index is keyed by the bare agent identifier, so the underlying cache holds one entry per agent
/// rather than one per caller-agent pair. Conversation identifiers held in that entry are scoped,
/// filtered for the current caller, and returned bare.
/// </remarks>
internal sealed class IsolationKeyScopedAgentConversationIndex : IAgentConversationIndex
{
private readonly IAgentConversationIndex _innerIndex;
private readonly IsolationKeyResolver _resolver;

/// <summary>
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentConversationIndex"/> class.
/// </summary>
/// <param name="innerIndex">The underlying index to delegate to.</param>
/// <param name="resolver">The resolver used to scope agent identifiers.</param>
public IsolationKeyScopedAgentConversationIndex(IAgentConversationIndex innerIndex, IsolationKeyResolver resolver)
{
this._innerIndex = Throw.IfNull(innerIndex);
this._resolver = Throw.IfNull(resolver);
}

/// <inheritdoc />
public async Task AddConversationAsync(string agentId, string conversationId, CancellationToken cancellationToken = default)
{
string scopedConversationId = await this._resolver.ScopeIdAsync(conversationId, cancellationToken).ConfigureAwait(false);

await this._innerIndex.AddConversationAsync(agentId, scopedConversationId, cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc />
public async Task RemoveConversationAsync(string agentId, string conversationId, CancellationToken cancellationToken = default)
{
string scopedConversationId = await this._resolver.ScopeIdAsync(conversationId, cancellationToken).ConfigureAwait(false);

await this._innerIndex.RemoveConversationAsync(agentId, scopedConversationId, cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc />
public async Task<ListResponse<string>> GetConversationIdsAsync(string agentId, CancellationToken cancellationToken = default)
{
string? key = await this._resolver.GetKeyAsync(cancellationToken).ConfigureAwait(false);
ListResponse<string> response = await this._innerIndex.GetConversationIdsAsync(agentId, cancellationToken).ConfigureAwait(false);

if (key is null)
{
return response;
}

var conversationIds = new List<string>(response.Data.Count);
foreach (string scopedConversationId in response.Data)
{
if (IsolationKeyResolver.IsInScope(scopedConversationId, key))
{
conversationIds.Add(IsolationKeyResolver.UnscopeId(scopedConversationId, key));
}
}

return new ListResponse<string>
{
Data = conversationIds,
FirstId = conversationIds.Count > 0 ? conversationIds[0] : null,
LastId = conversationIds.Count > 0 ? conversationIds[^1] : null,
HasMore = response.HasMore,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.Conversations.Models;
using Microsoft.Agents.AI.Hosting.OpenAI.Models;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Shared.Diagnostics;

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

/// <summary>
/// A delegating <see cref="IConversationStorage"/> that scopes conversation keys by the caller's isolation
/// key, so that a conversation can only be resolved by the caller that created it.
/// </summary>
/// <remarks>
/// Only the storage key is scoped. The <see cref="Conversation.Id"/> observed by callers is always the bare
/// identifier, so the wire format of the OpenAI Conversations API is unchanged.
/// </remarks>
internal sealed class IsolationKeyScopedConversationStorage : IConversationStorage
{
private readonly IConversationStorage _innerStorage;
private readonly IsolationKeyResolver _resolver;

/// <summary>
/// Initializes a new instance of the <see cref="IsolationKeyScopedConversationStorage"/> class.
/// </summary>
/// <param name="innerStorage">The underlying storage to delegate to.</param>
/// <param name="resolver">The resolver used to scope conversation identifiers.</param>
public IsolationKeyScopedConversationStorage(IConversationStorage innerStorage, IsolationKeyResolver resolver)
{
this._innerStorage = Throw.IfNull(innerStorage);
this._resolver = Throw.IfNull(resolver);
}

/// <inheritdoc />
public async Task<Conversation> CreateConversationAsync(Conversation conversation, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(conversation);

var key = await this._resolver.GetKeyAsync(cancellationToken).ConfigureAwait(false);

var created = await this._innerStorage.CreateConversationAsync(ScopeConversation(conversation, key), cancellationToken).ConfigureAwait(false);

return UnscopeConversation(created, key);
}

/// <inheritdoc />
public async Task<Conversation?> GetConversationAsync(string conversationId, CancellationToken cancellationToken = default)
{
var key = await this._resolver.GetKeyAsync(cancellationToken).ConfigureAwait(false);

var conversation = await this._innerStorage.GetConversationAsync(IsolationKeyResolver.ScopeId(conversationId, key), cancellationToken).ConfigureAwait(false);

return conversation is null ? null : UnscopeConversation(conversation, key);
}

/// <inheritdoc />
public async Task<Conversation?> UpdateConversationAsync(Conversation conversation, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(conversation);

var key = await this._resolver.GetKeyAsync(cancellationToken).ConfigureAwait(false);

var updated = await this._innerStorage.UpdateConversationAsync(ScopeConversation(conversation, key), cancellationToken).ConfigureAwait(false);

return updated is null ? null : UnscopeConversation(updated, key);
}

/// <inheritdoc />
public async Task<bool> DeleteConversationAsync(string conversationId, CancellationToken cancellationToken = default)
{
var scopedId = await this._resolver.ScopeIdAsync(conversationId, cancellationToken).ConfigureAwait(false);

return await this._innerStorage.DeleteConversationAsync(scopedId, cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc />
public async Task AddItemsAsync(string conversationId, IEnumerable<ItemResource> items, CancellationToken cancellationToken = default)
{
var scopedId = await this._resolver.ScopeIdAsync(conversationId, cancellationToken).ConfigureAwait(false);

await this._innerStorage.AddItemsAsync(scopedId, items, cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc />
public async Task<ItemResource?> GetItemAsync(string conversationId, string itemId, CancellationToken cancellationToken = default)
{
var scopedId = await this._resolver.ScopeIdAsync(conversationId, cancellationToken).ConfigureAwait(false);

return await this._innerStorage.GetItemAsync(scopedId, itemId, cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc />
public async Task<ListResponse<ItemResource>> ListItemsAsync(string conversationId, int? limit = null, SortOrder? order = null, string? after = null, CancellationToken cancellationToken = default)
{
var scopedId = await this._resolver.ScopeIdAsync(conversationId, cancellationToken).ConfigureAwait(false);

return await this._innerStorage.ListItemsAsync(scopedId, limit, order, after, cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc />
public async Task<bool> DeleteItemAsync(string conversationId, string itemId, CancellationToken cancellationToken = default)
{
var scopedId = await this._resolver.ScopeIdAsync(conversationId, cancellationToken).ConfigureAwait(false);

return await this._innerStorage.DeleteItemAsync(scopedId, itemId, cancellationToken).ConfigureAwait(false);
}

private static Conversation ScopeConversation(Conversation conversation, string? key)
=> key is null ? conversation : conversation with { Id = IsolationKeyResolver.ScopeId(conversation.Id, key) };

private static Conversation UnscopeConversation(Conversation conversation, string? key)
=> key is null ? conversation : conversation with { Id = IsolationKeyResolver.UnscopeId(conversation.Id, key) };
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.OpenAI;
using Microsoft.Agents.AI.Hosting.OpenAI.Conversations;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
Expand All @@ -17,13 +19,47 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
/// Maps OpenAI Conversations API endpoints to the specified <see cref="IEndpointRouteBuilder"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI Conversations endpoints to.</param>
/// <remarks>
/// <para>
/// <strong>Trust model.</strong> A conversation identifier arrives from the wire and is a resume identifier,
/// not an authorization token. Hosts that serve more than one user must register an
/// <see cref="AgentIsolationKeyProvider"/> - typically by calling <c>UseClaimsBasedAgentIsolation(...)</c> -
/// so that conversations are partitioned by the calling principal. Without it, any caller who knows a
/// conversation identifier can read, modify, or delete that conversation, and
/// <c>GET /v1/conversations?agent_id=</c> lists every conversation for the agent rather than the caller's own.
/// </para>
/// <para>
/// Isolation does not replace authentication. Hosts should also require an authenticated caller, for example
/// by calling <c>RequireAuthorization()</c> on the returned builder.
/// </para>
/// </remarks>
public static IEndpointConventionBuilder MapOpenAIConversations(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);

// Resolve the optional caller isolation provider.
var isolationKeyProvider = endpoints.ServiceProvider.GetService<AgentIsolationKeyProvider>();

// Require a key whenever isolation is configured.
var isolationKeyResolver = new IsolationKeyResolver(isolationKeyProvider, strict: isolationKeyProvider is not null);

// Resolve the underlying conversation services.
var storage = endpoints.ServiceProvider.GetService<IConversationStorage>()
?? throw new InvalidOperationException("IConversationStorage is not registered. Call AddOpenAIConversations() in your service configuration.");
var conversationIndex = endpoints.ServiceProvider.GetService<IAgentConversationIndex>();

// Wrap conversation storage so each operation is scoped by the caller's isolation key.
if (storage is not IsolationKeyScopedConversationStorage)
{
storage = new IsolationKeyScopedConversationStorage(storage, isolationKeyResolver);
}

// Wrap agent conversation lookup so each operation is scoped by the caller's isolation key.
if (conversationIndex is not null and not IsolationKeyScopedAgentConversationIndex)
{
conversationIndex = new IsolationKeyScopedAgentConversationIndex(conversationIndex, isolationKeyResolver);
}

var handlers = new ConversationsHttpHandler(storage, conversationIndex);

var group = endpoints.MapGroup("/v1/conversations")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,19 @@ public static IEndpointConventionBuilder MapOpenAIResponses(

// Create an executor for this agent
var executor = new AIAgentResponseExecutor(agent, mapOptions);

// Resolve the response storage settings and optional conversation storage.
var storageOptions = endpoints.ServiceProvider.GetService<InMemoryStorageOptions>() ?? new InMemoryStorageOptions();
var conversationStorage = endpoints.ServiceProvider.GetService<IConversationStorage>();
var responsesService = new InMemoryResponsesService(executor, storageOptions, conversationStorage);

// Resolve the optional caller isolation provider.
var isolationKeyProvider = endpoints.ServiceProvider.GetService<AgentIsolationKeyProvider>();

// Require a key whenever isolation is configured.
var isolationKeyResolver = new IsolationKeyResolver(isolationKeyProvider, strict: isolationKeyProvider is not null);

// Create the response service so response and conversation operations are scoped by the caller's isolation key.
var responsesService = new InMemoryResponsesService(executor, storageOptions, conversationStorage, isolationKeyResolver);

var handlers = new ResponsesHttpHandler(responsesService);

Expand Down
Loading
Loading