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 @@ -101,6 +101,13 @@ 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. It supersedes the required `userId` parameter with `AgentSessionStoreKey.Partitions`. Foundry
Hosting adds the resolved user identity as a named partition before loading or saving a session.

## 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
88 changes: 88 additions & 0 deletions docs/decisions/0039-shared-agent-session-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
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.
- The contract must support any number of isolation dimensions without privileging user identity.
- 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.
4. Represent session identity as an immutable key with arbitrary named partitions.

## 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 receive an `AgentSessionStoreKey`.
- `AgentSessionStoreKey.SessionId` identifies the logical session.
- `AgentSessionStoreKey.Partitions` holds zero or more named isolation dimensions. Every partition is
part of identity and implementations cannot ignore unknown partitions.
- Partition order does not affect identity. Physical encoding remains the responsibility of each store.
- `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.

`DelegatingAgentSessionStore` lives in the `Microsoft.Agents.AI` package beside `ChatClientAgent`, providing
the common decorator base without requiring a hosting-protocol package.

The conventional Hosting implementations adopt the same behavior. `IsolationKeyScopedAgentSessionStore`
adds the value from `AgentIsolationKeyProvider` under the `isolation` partition while preserving existing
partitions. Protocol-specific hosting can add named partitions such as `user`, `tenant`, or `chat` before
loading the session. `AIHostAgent` uses `GetOrCreateSessionAsync` when it needs a ready session.

Azure Blob Storage, filesystem storage, and Foundry State Store each encode the session id and every
partition into their own collision-safe physical key. Version 1 Azure
Blob keys are not read because the package is still preview and the previous format cannot distinguish
all partition combinations safely.

Provider-specific metadata does not belong in `AgentSessionStoreKey`. For example, Foundry item tags can
be exposed by an overload or options type on `FoundryAgentSessionStore` without adding tags to Abstractions.

## Consequences

Positive:

- Storage implementations can be shared by Foundry Hosting, conventional Hosting, and future protocols.
- Missing session handling is explicit and consistent.
- Isolation dimensions are explicit, composable, and independent from any hosting protocol.
- `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 construct an `AgentSessionStoreKey`; unpartitioned sessions use only `SessionId`.
- 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) records the earlier Foundry-specific user partition.
- [ADR-0032](0032-dotnet-hosting-protocol-helpers.md) records the previous conventional Hosting contract.
72 changes: 48 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,34 @@ 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,
AgentSessionStoreKey key,
CancellationToken cancellationToken = default);

public virtual ValueTask<AgentSession> GetOrCreateSessionAsync(
AIAgent agent,
AgentSessionStoreKey key,
CancellationToken cancellationToken = default);

public abstract ValueTask SaveSessionAsync(
AIAgent agent,
AgentSessionStoreKey key,
AgentSession session,
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 +132,19 @@ 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, key)`
returns `null` on a miss, while `GetOrCreateSessionAsync(agent, key)` 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, key, session)` 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.

`AgentSessionStoreKey` contains a session id plus arbitrary named partitions. Every partition contributes
to identity, independent of dictionary order. Stores must not ignore unknown partitions. Physical key
encoding belongs to each store implementation. Provider-specific
metadata such as Foundry tags is not part of the key or the Abstractions contract.

`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 +186,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.
- Multi-user hosts must add a trusted identity partition, or wrap the store with
`IsolationKeyScopedAgentSessionStore` so `AgentIsolationKeyProvider` supplies one.
- Persist session/checkpoint state only after the run or stream has completed.

## E2E Code Samples
Expand All @@ -193,7 +208,8 @@ 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 key = new AgentSessionStoreKey(sessionId);
var session = await sessionStore.GetOrCreateSessionAsync(agent, key, ct);

string responseId = OpenAIResponses.CreateResponseId();

Expand All @@ -206,12 +222,20 @@ 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,
new AgentSessionStoreKey(responseId),
session,
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,
new AgentSessionStoreKey(responseId),
session,
ct);
return Results.Json(OpenAIResponses.WriteResponse(result, responseId, sessionId));
});
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,4 @@ The client defaults to `http://localhost:5000`; override with `RESPONSES_SERVER_
`OpenAIResponses.GetSessionStoreId(...)` returns an untrusted candidate key. The server's `Authorize(...)` is a
placeholder; a real application must authenticate the caller and authorize/bind the id to the authenticated
principal before using it as a session key. For multi-user hosts, scope the store with
`IsolationKeyScopedAgentSessionStore`.
`IsolationKeyScopedAgentSessionStore`, or add trusted partition values to `AgentSessionStoreKey`.
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 @@ -65,8 +65,12 @@ static string LookupWeather([Description("The city to look up weather for.")] st
// this key to the principal before using it. This sample simply falls back to a fresh id.
string? candidateSessionStoreId = OpenAIResponses.GetSessionStoreId(run);
string sessionStoreId = Authorize(http, candidateSessionStoreId) ?? OpenAIResponses.CreateResponseId();
var sessionKey = new AgentSessionStoreKey(sessionStoreId);

AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId, cancellationToken).ConfigureAwait(false);
AgentSession session = await sessionStore.GetOrCreateSessionAsync(
agent,
sessionKey,
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, new AgentSessionStoreKey(saveId), session, 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, new AgentSessionStoreKey(saveId), session, 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
Loading
Loading