Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,57 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.Models;

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

/// <summary>
/// A delegating <see cref="IAgentConversationIndex"/> that scopes the index by the caller's isolation key,
/// so that listing conversations for an agent returns only the caller's own conversations.
/// </summary>
/// <remarks>
/// The agent identifier forms the index key and is therefore scoped; the conversation identifiers held in
/// the index remain bare, so they can be passed straight back into <see cref="IConversationStorage"/>.
/// </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 = innerIndex ?? throw new ArgumentNullException(nameof(innerIndex));
this._resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
Comment thread
SergeyMenshykh marked this conversation as resolved.
Outdated
}

/// <inheritdoc />
public async Task AddConversationAsync(string agentId, string conversationId, CancellationToken cancellationToken = default)
{
string scopedAgentId = await this._resolver.ScopeIdAsync(agentId, cancellationToken).ConfigureAwait(false);
Comment thread
SergeyMenshykh marked this conversation as resolved.
Outdated

await this._innerIndex.AddConversationAsync(scopedAgentId, conversationId, cancellationToken).ConfigureAwait(false);
}

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

await this._innerIndex.RemoveConversationAsync(scopedAgentId, conversationId, cancellationToken).ConfigureAwait(false);
}

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

return await this._innerIndex.GetConversationIdsAsync(scopedAgentId, cancellationToken).ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
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;

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 = innerStorage ?? throw new ArgumentNullException(nameof(innerStorage));
this._resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
Comment thread
SergeyMenshykh marked this conversation as resolved.
Outdated
}

/// <inheritdoc />
public async Task<Conversation> CreateConversationAsync(Conversation conversation, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(conversation);
Comment thread
SergeyMenshykh marked this conversation as resolved.
Outdated

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)
{
ArgumentNullException.ThrowIfNull(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