Skip to content
Draft
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 @@ -69,6 +69,38 @@ AZURE_TOKEN_CREDENTIALS=dev
> credentials (Azure CLI, Visual Studio, `azd`) and skips the managed-identity probe. This variable
> is only for local runs; the deployed agent in Foundry uses the platform-injected managed identity.

## Agent lifetime

`builder.AddFoundryResponses(agent)` registers one agent object that serves every request for the
life of the process. When the agent needs services that should not live that long, for example a
database context, register a factory with a DI lifetime instead:

```csharp
builder.AddFoundryResponses(
agentName,
sp => new MyAgent(sp.GetRequiredService<MyDbContext>()),
ServiceLifetime.Scoped);
```

Each response run (streaming, background, or resumed after a restart) gets its own DI scope, which is
disposed when that run's event stream ends. A `Scoped` agent is therefore created once per response and
disposed afterwards, even when a background response keeps running after the HTTP request has returned.
Conversation state is not kept on the agent object; each turn loads the saved session, and sessions are
stored under `agentName`, so switching from the instance overload keeps existing conversations.

The session store has its own registration and lifetime, independent of the agent. Without one, every
agent uses the default store, a single shared instance. A store passed to `AddFoundryResponses(agent, store)`
is used only by that agent. Each store keeps its own sessions, so moving an agent to a different store
means the sessions it saved in the old store are no longer found:

```csharp
// For every agent that has no store of its own. A store object you pass in is never disposed by DI.
builder.AddFoundryAgentSessionStore(sp => new MySessionStore(sp.GetRequiredService<MyDbContext>()), ServiceLifetime.Scoped);

// Or for one agent only.
builder.AddFoundryAgentSessionStore(mySessionStore, agentName);
```

## Run and test locally

Local runs use two terminals: one hosts the agent, the other is a code-first client that talks to it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ internal sealed partial class AgentFrameworkResponseHandler : ResponseHandler
private readonly FoundryResponsesServerOptions _options;
private readonly FoundryToolboxService? _toolboxService;

/// <summary>
/// The factory agent that answers requests without a matching agent name when no agent is registered
/// without a key. See <see cref="FoundryDefaultAgentName"/>.
/// </summary>
private readonly string? _defaultAgentName;

/// <summary>
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
/// that resolves agents from keyed DI services.
Expand Down Expand Up @@ -82,6 +88,7 @@ public AgentFrameworkResponseHandler(
this._logger = logger;
this._options = foundryResponsesOptions;
this._toolboxService = toolboxService;
this._defaultAgentName = serviceProvider.GetService<FoundryDefaultAgentName>()?.Name;
}

/// <inheritdoc/>
Expand All @@ -90,13 +97,11 @@ public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
ResponseContext context,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// 1. Resolve agent
var agent = this.ResolveAgent(request);
var sessionStore = this.ResolveSessionStore(request);

// 2. Resolve the per-request hosted user identity, so the session can be
// 1. Resolve the per-request hosted user identity, so the session can be
// loaded from a per-user partition. Fresh sessions are tagged once; resumed sessions are
// validated against the live request to detect cross-user session leaks and in-process tampering.
// This runs before any agent or store is created, so a rejected request never invokes a
// caller-supplied agent or session store factory.
string? resolvedUserId = context.PlatformContext?.UserIdKey;
if (string.IsNullOrWhiteSpace(resolvedUserId))
{
Expand All @@ -114,6 +119,16 @@ public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
"Ensure the x-agent-user-id header is present.");
}

// 2. Resolve agent and session store from a DI scope owned by this response run. The scope
// covers the whole enumeration, not the HTTP request: background runs continue after the
// HTTP response is sent, and recovery runs have no HTTP request at all. Scoped and transient
// registrations are therefore created per run and disposed when the event stream ends.
AsyncServiceScope serviceScope = this._serviceProvider.CreateAsyncScope();
await using var configuredServiceScope = serviceScope.ConfigureAwait(false);
IServiceProvider services = serviceScope.ServiceProvider;
var (agent, sessionStoreKey) = this.ResolveAgent(request, services);
var sessionStore = this.ResolveSessionStore(sessionStoreKey, services);

// When resolvedUserId is null here the container is NOT hosted by Foundry (local
// development: docker run / dotnet run outside the platform, so no x-agent-user-id header).
// Per-user isolation simply does not apply in that case: no user partition is added to the
Expand Down Expand Up @@ -878,45 +893,71 @@ private bool ShouldPersistForResilience(CreateResponse request)
/// <summary>
/// Resolves an <see cref="AIAgent"/> from the request.
/// Tries <c>agent.name</c> first, then falls back to <c>metadata["entity_id"]</c>.
/// If neither is present, attempts to resolve a default (non-keyed) <see cref="AIAgent"/>.
/// If neither matches a keyed agent, attempts to resolve a default (non-keyed) <see cref="AIAgent"/>, and
/// then the agent registered through the factory overload of <c>AddFoundryResponses</c> as the default.
/// </summary>
private AIAgent ResolveAgent(CreateResponse request)
/// <param name="request">The incoming request.</param>
/// <param name="services">The service provider of the DI scope owned by the current response run.</param>
/// <returns>The prepared agent and the name to look its session store up with.</returns>
private (AIAgent Agent, string? SessionStoreKey) ResolveAgent(CreateResponse request, IServiceProvider services)
{
var agentName = GetAgentName(request);
var defaultAgent = services.GetService<AIAgent>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolving the non-keyed default before checking the requested key means every named request constructs that unrelated agent too. If its transient/scoped factory throws or performs expensive side effects, a healthy keyed agent can no longer serve its own requests. Resolve the keyed target first and avoid instantiating the non-keyed default unless it is actually selected, while preserving storage identity through registration metadata.


if (!string.IsNullOrEmpty(agentName))
{
var agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
var agent = services.GetKeyedService<AIAgent>(agentName);
if (agent is not null)
{
string storageIdentity = FoundryHostingAgent.ResolveSessionStorageIdentity(
agent,
agentName,
this._serviceProvider.GetService<AIAgent>());
return this.PrepareResolvedAgent(agent, storageIdentity);
// The factory default has no non-keyed registration to compare against, so it is
// recognized by name. It keeps the location AddFoundryResponses(agent) uses for the
// default agent, unless a non-keyed agent took the default role.
string storageIdentity = defaultAgent is null
&& string.Equals(agentName, this._defaultAgentName, StringComparison.Ordinal)
? DefaultAgentStorageIdentity(agentName)
: FoundryHostingAgent.ResolveSessionStorageIdentity(agent, agentName, defaultAgent);
return (this.PrepareResolvedAgent(agent, storageIdentity), agentName);
}

LogKeyedAgentNotFound(this._logger, agentName);
}

// Try non-keyed default
var defaultAgent = this._serviceProvider.GetService<AIAgent>();
if (defaultAgent is not null)
{
string storageIdentity = FoundryHostingAgent.ResolveSessionStorageIdentity(
defaultAgent,
registrationKey: null,
defaultAgent);
return this.PrepareResolvedAgent(defaultAgent, storageIdentity);

// The default agent uses the store registered under its own name, the same store a request
// naming that agent uses.
string? sessionStoreKey = string.IsNullOrWhiteSpace(defaultAgent.Name) ? null : defaultAgent.Name;
return (this.PrepareResolvedAgent(defaultAgent, storageIdentity), sessionStoreKey);
}

// The factory overload registers its agent only under its name, so that DI owns each created
// object once. Requests without a matching agent name reach it through the recorded default name.
if (this._defaultAgentName is { } defaultAgentName
&& services.GetKeyedService<AIAgent>(defaultAgentName) is { } factoryDefaultAgent)
{
return (
this.PrepareResolvedAgent(factoryDefaultAgent, DefaultAgentStorageIdentity(defaultAgentName)),
defaultAgentName);
}

var errorMessage = string.IsNullOrEmpty(agentName)
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered."
: $"Agent '{agentName}' not found. Ensure it is registered via builder.AddFoundryResponses(agent) or services.AddKeyedSingleton<AIAgent>(\"{agentName}\", ...).";
: $"Agent '{agentName}' not found. Ensure it is registered via builder.AddFoundryResponses(agent), builder.AddFoundryResponses(\"{agentName}\", agentFactory, lifetime), or a keyed AIAgent service registration such as services.AddKeyedSingleton<AIAgent>(\"{agentName}\", ...).";

throw new InvalidOperationException(errorMessage);
}

/// <summary>
/// The storage identity <c>AddFoundryResponses(agent)</c> gives the default agent, used for the factory
/// default whatever its lifetime.
/// </summary>
private static string DefaultAgentStorageIdentity(string agentName) => $"name:{agentName}";

private AIAgent PrepareResolvedAgent(AIAgent agent, string sessionStorageIdentity)
{
ServiceCollectionExtensions.TryApplyUserAgent(agent);
Expand All @@ -930,17 +971,16 @@ private AIAgent PrepareResolvedAgent(AIAgent agent, string sessionStorageIdentit
}

/// <summary>
/// Resolves an <see cref="AIAgent"/> from the request.
/// Tries <c>agent.name</c> first, then falls back to <c>metadata["entity_id"]</c>.
/// If neither is present, attempts to resolve a default (non-keyed) <see cref="AIAgent"/>.
/// Resolves the <see cref="AgentSessionStore"/> registered under <paramref name="agentName"/>, or the
/// store registered for all agents (non-keyed) when there is none.
/// </summary>
private AgentSessionStore ResolveSessionStore(CreateResponse request)
/// <param name="agentName">The key returned by <see cref="ResolveAgent"/>.</param>
/// <param name="services">The service provider of the DI scope owned by the current response run.</param>
private AgentSessionStore ResolveSessionStore(string? agentName, IServiceProvider services)
{
var agentName = GetAgentName(request);

if (!string.IsNullOrEmpty(agentName))
{
var sessionStore = this._serviceProvider.GetKeyedService<AgentSessionStore>(agentName);
var sessionStore = services.GetKeyedService<AgentSessionStore>(agentName);
if (sessionStore is not null)
{
return sessionStore;
Expand All @@ -949,16 +989,16 @@ private AgentSessionStore ResolveSessionStore(CreateResponse request)
LogKeyedSessionStoreNotFound(this._logger, agentName);
}

// Try non-keyed default
var defaultSessionStore = this._serviceProvider.GetService<AgentSessionStore>();
// An agent without a store of its own uses the store registered for all agents.
var defaultSessionStore = services.GetService<AgentSessionStore>();
if (defaultSessionStore is not null)
{
return defaultSessionStore;
}

var errorMessage = string.IsNullOrEmpty(agentName)
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AgentSessionStore is registered."
: $"AgentSessionStore for agent '{agentName}' not found. Ensure it is registered via builder.AddFoundryResponses(agent, agentSessionStore) or services.AddKeyedSingleton<AgentSessionStore>(\"{agentName}\", ...).";
: $"AgentSessionStore for agent '{agentName}' not found. Ensure it is registered via services.AddFoundryAgentSessionStore(agentSessionStore, \"{agentName}\"), services.AddFoundryAgentSessionStore(agentSessionStore) for all agents, or builder.AddFoundryResponses(agent, agentSessionStore).";

throw new InvalidOperationException(errorMessage);
}
Expand Down Expand Up @@ -1034,6 +1074,6 @@ private static partial void LogConsentLinkRejected(

[LoggerMessage(LogLevel.Warning, "Agent '{AgentName}' not found in keyed services. Attempting default resolution.", EventId = 0)]
private static partial void LogKeyedAgentNotFound(ILogger logger, string agentName);
[LoggerMessage(LogLevel.Warning, "SessionStore for agent '{AgentName}' not found in keyed services. Attempting default resolution.", EventId = 0)]
[LoggerMessage(LogLevel.Debug, "No session store registered for agent '{AgentName}'. Using the store registered for all agents.", EventId = 0)]
private static partial void LogKeyedSessionStoreNotFound(ILogger logger, string agentName);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.

using Microsoft.Shared.Diagnostics;

namespace Microsoft.Agents.AI.Foundry.Hosting;

/// <summary>
/// The name of the agent registered through the factory overload of <c>AddFoundryResponses</c> that answers
/// requests without a matching agent name.
/// </summary>
/// <remarks>
/// <para>
/// The factory overload registers its agent only under the agent name. It does not add a non-keyed
/// registration that forwards to the keyed one, because Microsoft DI disposes the object a factory returns
/// once per registration that created or returned it, so a forwarding registration would dispose the same
/// object twice. This value is registered instead, only when no default agent existed at registration time,
/// so the response handler can route requests without an agent name to that agent.
/// </para>
/// <para>
/// The response handler ignores it when an <see cref="AIAgent"/> is registered without a key, because that
/// agent receives requests without an agent name. Sessions of the agent named here are stored in the location
/// <c>AddFoundryResponses(agent)</c> uses for the default agent, <c>name:{Name}</c>, whatever its lifetime.
/// </para>
/// </remarks>
internal sealed class FoundryDefaultAgentName
{
public FoundryDefaultAgentName(string name)
{
this.Name = Throw.IfNullOrWhitespace(name);
}

/// <summary>Gets the key the default factory agent is registered under.</summary>
public string Name { get; }
}
Loading
Loading