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,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
80 changes: 80 additions & 0 deletions docs/decisions/0039-shared-agent-session-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
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.

Azure Blob Storage hashes a tagged, length-prefixed encoding of `userId` and `conversationId` under a
version 2 path. This prevents a scoped session from sharing a blob with an unscoped conversation whose
identifier contains the old delimiter. Reading version 1 keys is available only through
`EnableLegacyKeyFallback`, which defaults to `false`. It is intended for a controlled migration after all
application instances write version 2 keys and only when scoped and unscoped identifiers cannot coexist.

## 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.
- Existing Azure Blob sessions require an explicit, controlled version 1 fallback during migration.

## 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.
78 changes: 54 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,17 @@ 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.

Storage implementations must encode `userId` and the conversation identifier without collisions. A
scoped tuple and an unscoped conversation identifier must never resolve to the same storage key.

`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 +187,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 +209,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 +226,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
Loading
Loading