Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ Negative:
- Encryption at rest and quota enforcement remain platform concerns.
- Non-Foundry hosting layers can adopt an equivalent scheme independently.

## Update (2026-09-01): contract promoted to Abstractions

[ADR-0039](0039-shared-agent-session-store.md) promotes this `AgentSessionStore` contract to
`Microsoft.Agents.AI.Abstractions` and makes it the common contract for Foundry Hosting and conventional
Hosting. The required user partition and lookup behavior defined here remain unchanged.

## Update (2026-07-01): local runs no longer fail closed; sample dev provider removed

Superseding the ADR-0026/0030 behavior where a `null` result from `HostedSessionIsolationKeyProvider`
Expand Down
3 changes: 3 additions & 0 deletions docs/decisions/0032-dotnet-hosting-protocol-helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ informed: []

Realizes the helper-first direction of [ADR-0027](0027-hosting-channels.md) for .NET.

> **Update (2026-09-01):** [ADR-0039](0039-shared-agent-session-store.md) supersedes the
> `AgentSessionStore` portion of this decision. The protocol helper and workflow decisions remain accepted.

## Context and Problem Statement

[ADR-0027](0027-hosting-channels.md) refocused the (Python) hosting design away from a channel
Expand Down
73 changes: 73 additions & 0 deletions docs/decisions/0039-shared-agent-session-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
status: proposed
contact: rogerbarreto
date: 2026-09-01
deciders: rogerbarreto
consulted: []
informed: []
---

# Shared AgentSessionStore abstraction

## Context and Problem Statement

.NET has two public `AgentSessionStore` abstract classes. `Microsoft.Agents.AI.Hosting` defines a store
whose lookup creates a session when no value exists. `Microsoft.Agents.AI.Foundry.Hosting` defines a store
whose lookup returns `null`, accepts an explicit user partition, and provides a separate convenience method
that creates a session when needed. The types cannot be used interchangeably, so storage integrations depend
on a specific hosting protocol package instead of the core agent abstractions.

## Decision Drivers

- One storage contract must work across all hosting packages.
- Storage implementations must depend only on `Microsoft.Agents.AI.Abstractions`.
- A lookup must distinguish a missing value from a stored value without creating state as a side effect.
- Every caller must explicitly decide whether the session is partitioned by user.
- Existing Foundry storage behavior and per-user isolation must remain unchanged.

## Considered Options

1. Promote the Foundry Hosting contract to `Microsoft.Agents.AI.Abstractions`.
2. Promote the conventional Hosting contract and adapt Foundry Hosting to it.
3. Add a third contract and keep adapters for both existing contracts.

## Decision Outcome

Chosen option: **Promote the Foundry Hosting contract to `Microsoft.Agents.AI.Abstractions`**.

`AgentSessionStore` moves to the `Microsoft.Agents.AI` namespace and keeps the Foundry Hosting behavior:

- The abstraction and every public implementation start as experimental under diagnostic `MAAI001`.
- `GetSessionAsync` returns `AgentSession?` and returns `null` when no session is stored.
- `GetOrCreateSessionAsync` performs the explicit lookup or creation operation.
- `SaveSessionAsync` and both lookup methods require a `string? userId` argument with no default value.
A non-null value must not be empty or contain only whitespace.
- `DeleteSessionAsync` and service inspection are not part of the shared contract.

The duplicate types in `Microsoft.Agents.AI.Hosting` and `Microsoft.Agents.AI.Foundry.Hosting` are removed.
Both packages reference the shared type directly.

The conventional Hosting implementations adopt the same behavior. `IsolationKeyScopedAgentSessionStore`
passes the key from `AgentIsolationKeyProvider` as the `userId` argument while leaving `conversationId`
unchanged. The in-memory and Azure Blob stores return `null` for a missing session and partition saved
sessions by user. `AIHostAgent` uses `GetOrCreateSessionAsync` when it needs a ready session.

## Consequences

Positive:

- Storage implementations can be shared by Foundry Hosting, conventional Hosting, and future protocols.
- Missing session handling is explicit and consistent.
- User isolation is represented by its own argument instead of being encoded into a conversation identifier.
- `Microsoft.Agents.AI.Abstractions` owns the contract alongside `AIAgent` and `AgentSession`.

Negative:

- This is a source-breaking change for implementations of the preview Hosting contract.
- Callers must pass `userId: null` explicitly when no user partition exists.
- Consumers that need deletion must use a storage-specific API until a separate shared deletion capability is defined.

## More Information

- [ADR-0031](0031-hosted-per-user-session-storage-isolation.md) defines the explicit user partition used by the promoted contract.
- [ADR-0032](0032-dotnet-hosting-protocol-helpers.md) records the previous conventional Hosting contract.
75 changes: 51 additions & 24 deletions docs/specs/003-dotnet-hosting-protocol-helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,23 +87,37 @@ does (by default no request setting is mapped onto the run; unsupported settings
converters (an internal `ToResponse` overload with an optional originating request is added so the
facade can render without one). The streaming renderer's existing workflow-event support is preserved.

### `Microsoft.Agents.AI.Hosting` (execution state, protocol-neutral)
### `Microsoft.Agents.AI.Abstractions` (agent session persistence)

```csharp
namespace Microsoft.Agents.AI.Hosting;
namespace Microsoft.Agents.AI;

public abstract class AgentSessionStore
{
// ... existing members ...

// New: the one missing store operation. Virtual (not abstract) with a default that throws
// NotSupportedException, so existing external stores (e.g. the Foundry hosting stores) keep
// compiling; the in-box Hosting stores override it. In-box overrides treat deleting a missing
// session as a no-op.
public virtual ValueTask DeleteSessionAsync(
AIAgent agent, string conversationId, CancellationToken cancellationToken = default);
public abstract ValueTask<AgentSession?> GetSessionAsync(
AIAgent agent,
string conversationId,
string? userId,
CancellationToken cancellationToken = default);

public virtual ValueTask<AgentSession> GetOrCreateSessionAsync(
AIAgent agent,
string conversationId,
string? userId,
CancellationToken cancellationToken = default);

public abstract ValueTask SaveSessionAsync(
AIAgent agent,
string conversationId,
AgentSession session,
string? userId,
CancellationToken cancellationToken = default);
}
```

### `Microsoft.Agents.AI.Hosting` (workflow execution state)

```csharp
// Thin holder: pairs a workflow target with checkpointing + a per-session head cursor.
public sealed class HostedWorkflowState
{
Expand All @@ -121,15 +135,14 @@ public sealed class HostedWorkflowState
}
```

For agents, the application uses `AgentSessionStore` directly: `GetSessionAsync(agent, id)` creates a
session on miss and returns an independent instance per call (so concurrent calls can fork the same
stored state — for example branching from a `previous_response_id` or managing several `conversation`
ids side by side — without one branch observing another's in-flight mutations). The store performs no
cross-call locking; an application that needs concurrent runs against the same id to be serialized owns
that coordination. `SaveSessionAsync(agent, id, session)` persists post-run, including under a newly
minted `resp_*` id when the protocol mints a new continuation id. `DeleteSessionAsync` uses the new
store method. No agent-side holder is needed: create-on-miss already lives in the store, so a
pass-through wrapper would only bind the `agent` argument.
For agents, the application uses `AgentSessionStore` directly. `GetSessionAsync(agent, id, userId)`
returns `null` on a miss, while `GetOrCreateSessionAsync(agent, id, userId)` returns a ready session.
Each successful lookup returns an independent instance, so concurrent calls can fork the same stored
state without observing another branch's changes. The store performs no cross-call locking. An
application that needs concurrent runs against the same id to be serialized owns that coordination.
`SaveSessionAsync(agent, id, session, userId)` persists the post-run state, including under a newly
minted `resp_*` id when the protocol creates a continuation id. No agent-side holder is needed because
the convenience method already performs lookup or creation.

`HostedWorkflowState` defaults to `CheckpointManager.CreateInMemory()` and an in-memory
`sessionId -> CheckpointInfo` cursor. Because the checkpoint store is already `sessionId`-keyed but
Expand Down Expand Up @@ -171,8 +184,8 @@ parsing a structured payload into a typed record), without coupling the holder t
- Authenticate the caller before using any `GetSessionId(...)` result.
- Authorize and bind the candidate id to the authenticated principal/tenant before using it as an
`AgentSessionStore` key or a workflow checkpoint session id.
- For multi-user hosts, wrap the store with `IsolationKeyScopedAgentSessionStore` (for example via
`UseClaimsBasedAgentIsolation(...)`), so the session namespace is scoped per principal.
- For multi-user hosts, pass a trusted `userId`, or wrap the store with
`IsolationKeyScopedAgentSessionStore` so `AgentIsolationKeyProvider` supplies it.
- Persist session/checkpoint state only after the run or stream has completed.

## E2E Code Samples
Expand All @@ -193,7 +206,11 @@ app.MapPost("/responses", async (HttpContext http, CancellationToken ct) =>
string sessionId = Authorize(http.User, candidate) ?? OpenAIResponses.CreateResponseId();

var run = OpenAIResponses.ToAgentRunRequest(body);
var session = await sessionStore.GetSessionAsync(agent, sessionId, ct);
var session = await sessionStore.GetOrCreateSessionAsync(
agent,
sessionId,
userId: null,
cancellationToken: ct);

string responseId = OpenAIResponses.CreateResponseId();

Expand All @@ -206,12 +223,22 @@ app.MapPost("/responses", async (HttpContext http, CancellationToken ct) =>
await http.Response.WriteAsync(frame, ct);
await http.Response.Body.FlushAsync(ct);
}
await sessionStore.SaveSessionAsync(agent, responseId, session, ct);
await sessionStore.SaveSessionAsync(
agent,
responseId,
session,
userId: null,
cancellationToken: ct);
return Results.Empty;
}

var result = await agent.RunAsync(run.Messages, session, run.Options, ct);
await sessionStore.SaveSessionAsync(agent, responseId, session, ct);
await sessionStore.SaveSessionAsync(
agent,
responseId,
session,
userId: null,
cancellationToken: ct);
return Results.Json(OpenAIResponses.WriteResponse(result, responseId, sessionId));
});
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ static string LookupWeather([Description("The city to look up weather for.")] st
name: "WeatherAgent",
tools: [AIFunctionFactory.Create(LookupWeather, name: "lookup_weather")]);

// The application owns session storage directly. The in-memory store's GetSessionAsync creates a session
// on first use and returns an independent instance per call; no shared holder is needed. A real app that
// The application owns session storage directly. GetOrCreateSessionAsync loads a saved session or creates
// one on first use and returns an independent instance per call. A real app that
// runs concurrent turns against the same session id owns any coordination it needs.
AgentSessionStore sessionStore = new InMemoryAgentSessionStore();

Expand All @@ -66,7 +66,11 @@ static string LookupWeather([Description("The city to look up weather for.")] st
string? candidateSessionStoreId = OpenAIResponses.GetSessionStoreId(run);
string sessionStoreId = Authorize(http, candidateSessionStoreId) ?? OpenAIResponses.CreateResponseId();

AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId, cancellationToken).ConfigureAwait(false);
AgentSession session = await sessionStore.GetOrCreateSessionAsync(
agent,
sessionStoreId,
userId: null,
cancellationToken: cancellationToken).ConfigureAwait(false);
string responseId = OpenAIResponses.CreateResponseId();

// Choose where to persist the post-run session, which depends on how the caller continued the thread:
Expand All @@ -92,15 +96,15 @@ static string LookupWeather([Description("The city to look up weather for.")] st
}

// Persist the post-run session under the selected continuation id (see saveId above).
await sessionStore.SaveSessionAsync(agent, saveId, session, cancellationToken).ConfigureAwait(false);
await sessionStore.SaveSessionAsync(agent, saveId, session, userId: null, cancellationToken: cancellationToken).ConfigureAwait(false);

// The SSE body was already written straight to http.Response above, so return an empty result:
// this returns from the handler (the non-streaming code below does not run) without writing a body.
return Results.Empty;
}

AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, cancellationToken).ConfigureAwait(false);
await sessionStore.SaveSessionAsync(agent, saveId, session, cancellationToken).ConfigureAwait(false);
await sessionStore.SaveSessionAsync(agent, saveId, session, userId: null, cancellationToken: cancellationToken).ConfigureAwait(false);
return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId));
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ Exposes an `AIAgent` over the OpenAI Responses protocol on a `POST /responses` r
- `OpenAIResponses.WriteResponse(...)` / `WriteResponseStreamAsync(...)` render the agent output back to the
Responses wire shape (non-streaming JSON and SSE).

Session continuity uses an in-memory `AgentSessionStore` directly. `GetSessionAsync(agent, id)` creates a
session on first use and returns an independent instance per call; the store does no internal locking, so a
route that runs concurrent turns against the same id owns any coordination it needs.
Session continuity uses an in-memory `AgentSessionStore` directly. `GetOrCreateSessionAsync` loads a stored
session or creates one on first use and returns an independent instance per call. The store does no internal
locking, so a route that runs concurrent turns against the same id owns any coordination it needs.

The route persists each turn under a continuation id chosen by how the caller continued the thread:

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

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

namespace Microsoft.Agents.AI;

/// <summary>
/// Defines the contract for storing and retrieving agent conversation sessions.
/// </summary>
/// <remarks>
/// Implementations enable persistent storage of conversation sessions, allowing conversations to be
/// resumed across HTTP requests, application restarts, or different service instances in hosted scenarios.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class AgentSessionStore
{
/// <summary>
/// Saves an agent session to persistent storage.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation.</param>
/// <param name="session">The session to save.</param>
/// <param name="userId">
/// The per-user partition key that scopes this session to its owner. Pass <see langword="null"/> only
/// when there is no user context, such as in a single-user application or local development.
/// Non-null values must not be empty or contain only whitespace. The parameter is required so every
/// caller consciously decides the session scope.
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous save operation.</returns>
public abstract ValueTask SaveSessionAsync(
AIAgent agent,
string conversationId,
AgentSession session,
string? userId,
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
CancellationToken cancellationToken = default);

/// <summary>
/// Retrieves an agent session from persistent storage, or <see langword="null"/> when no session is stored
/// for the given identifiers.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation to retrieve.</param>
/// <param name="userId">
/// The per-user partition key that scopes this session to its owner. It must match the value used when the
/// session was saved. Pass <see langword="null"/> only when there is no user context. Non-null values must
/// not be empty or contain only whitespace.
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A task whose result contains the restored session, or <see langword="null"/> when nothing is stored for
/// the given identifiers. This method never creates a session.
/// </returns>
Comment thread
rogerbarreto marked this conversation as resolved.
public abstract ValueTask<AgentSession?> GetSessionAsync(
AIAgent agent,
string conversationId,
string? userId,
CancellationToken cancellationToken = default);

/// <summary>
/// Retrieves the stored session for the given identifiers, or creates a new one when none is stored.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation to retrieve.</param>
/// <param name="userId">The per-user partition key; see <see cref="GetSessionAsync"/> for its meaning.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task whose result is always a usable session.</returns>
public virtual async ValueTask<AgentSession> GetOrCreateSessionAsync(
AIAgent agent,
string conversationId,
string? userId,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(agent);

return await this.GetSessionAsync(agent, conversationId, userId, cancellationToken).ConfigureAwait(false)
?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
#nullable enable
[MAAI001]Microsoft.Agents.AI.AgentSessionStore
[MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void
[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.AgentSession?>
[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, Microsoft.Agents.AI.AgentSession! session, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.AgentSession!>
Loading
Loading